diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts new file mode 100644 index 000000000..f328b01e6 --- /dev/null +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createPluginRegistry } from "../plugin-registry.js"; +import type { PluginModule, PluginManifest, OrchestratorConfig } from "../types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makePlugin( + slot: PluginManifest["slot"], + name: string, +): PluginModule { + return { + manifest: { + name, + slot, + description: `Test ${slot} plugin: ${name}`, + version: "0.0.1", + }, + create: vi.fn((config?: Record) => ({ + name, + _config: config, + })), + }; +} + +function makeOrchestratorConfig( + overrides?: Partial, +): OrchestratorConfig { + return { + projects: {}, + ...overrides, + } as OrchestratorConfig; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("createPluginRegistry", () => { + it("returns a registry object", () => { + const registry = createPluginRegistry(); + expect(registry).toHaveProperty("register"); + expect(registry).toHaveProperty("get"); + expect(registry).toHaveProperty("list"); + expect(registry).toHaveProperty("loadBuiltins"); + expect(registry).toHaveProperty("loadFromConfig"); + }); +}); + +describe("register + get", () => { + it("registers and retrieves a plugin", () => { + const registry = createPluginRegistry(); + const plugin = makePlugin("runtime", "tmux"); + + registry.register(plugin); + + const instance = registry.get<{ name: string }>("runtime", "tmux"); + expect(instance).not.toBeNull(); + expect(instance!.name).toBe("tmux"); + }); + + it("returns null for unregistered plugin", () => { + const registry = createPluginRegistry(); + expect(registry.get("runtime", "nonexistent")).toBeNull(); + }); + + it("passes config to plugin create()", () => { + const registry = createPluginRegistry(); + const plugin = makePlugin("workspace", "worktree"); + + registry.register(plugin, { worktreeDir: "/custom/path" }); + + expect(plugin.create).toHaveBeenCalledWith({ worktreeDir: "/custom/path" }); + const instance = registry.get<{ _config: Record }>( + "workspace", + "worktree", + ); + expect(instance!._config).toEqual({ worktreeDir: "/custom/path" }); + }); + + it("overwrites previously registered plugin with same slot:name", () => { + const registry = createPluginRegistry(); + const plugin1 = makePlugin("runtime", "tmux"); + const plugin2 = makePlugin("runtime", "tmux"); + + registry.register(plugin1); + registry.register(plugin2); + + // Should call create on both + expect(plugin1.create).toHaveBeenCalledTimes(1); + expect(plugin2.create).toHaveBeenCalledTimes(1); + + // get() returns the latest + const instance = registry.get<{ name: string }>("runtime", "tmux"); + expect(instance).not.toBeNull(); + }); + + it("registers plugins in different slots independently", () => { + const registry = createPluginRegistry(); + const runtimePlugin = makePlugin("runtime", "tmux"); + const workspacePlugin = makePlugin("workspace", "worktree"); + + registry.register(runtimePlugin); + registry.register(workspacePlugin); + + expect(registry.get("runtime", "tmux")).not.toBeNull(); + expect(registry.get("workspace", "worktree")).not.toBeNull(); + expect(registry.get("runtime", "worktree")).toBeNull(); + expect(registry.get("workspace", "tmux")).toBeNull(); + }); +}); + +describe("list", () => { + it("lists plugins in a given slot", () => { + const registry = createPluginRegistry(); + registry.register(makePlugin("runtime", "tmux")); + registry.register(makePlugin("runtime", "process")); + registry.register(makePlugin("workspace", "worktree")); + + const runtimes = registry.list("runtime"); + expect(runtimes).toHaveLength(2); + expect(runtimes.map((m) => m.name)).toContain("tmux"); + expect(runtimes.map((m) => m.name)).toContain("process"); + }); + + it("returns empty array for slot with no plugins", () => { + const registry = createPluginRegistry(); + expect(registry.list("notifier")).toEqual([]); + }); + + it("does not return plugins from other slots", () => { + const registry = createPluginRegistry(); + registry.register(makePlugin("runtime", "tmux")); + + expect(registry.list("workspace")).toEqual([]); + }); +}); + +describe("loadBuiltins", () => { + it("silently skips unavailable packages", async () => { + const registry = createPluginRegistry(); + // loadBuiltins tries to import all built-in packages. + // In the test environment, most are not resolvable — should not throw. + await expect(registry.loadBuiltins()).resolves.toBeUndefined(); + }); +}); + +describe("extractPluginConfig (via register with config)", () => { + // extractPluginConfig is tested indirectly: we verify that register() + // correctly passes config through, and that loadBuiltins() would call + // extractPluginConfig for known slot:name pairs. The actual config + // forwarding logic is validated in workspace plugin unit tests. + + it("register passes config to plugin create()", () => { + const registry = createPluginRegistry(); + const plugin = makePlugin("workspace", "worktree"); + + registry.register(plugin, { worktreeDir: "/custom/path" }); + + expect(plugin.create).toHaveBeenCalledWith({ worktreeDir: "/custom/path" }); + }); + + it("register passes undefined config when none provided", () => { + const registry = createPluginRegistry(); + const plugin = makePlugin("workspace", "clone"); + + registry.register(plugin); + + expect(plugin.create).toHaveBeenCalledWith(undefined); + }); +}); + +describe("loadFromConfig", () => { + it("does not throw when no plugins are importable", async () => { + const registry = createPluginRegistry(); + const config = makeOrchestratorConfig({ worktreeDir: "/test" }); + + // loadFromConfig calls loadBuiltins internally, which may fail to + // import packages in the test env — should still succeed gracefully + await expect(registry.loadFromConfig(config)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index e6b7ef4d1..1352c8e5b 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -48,14 +48,30 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> = { slot: "terminal", name: "web", pkg: "@agent-orchestrator/plugin-terminal-web" }, ]; +/** Extract plugin-specific config from orchestrator config */ +function extractPluginConfig( + slot: PluginSlot, + name: string, + config: OrchestratorConfig, +): Record | undefined { + // Map well-known orchestrator config fields to plugin config + if (slot === "workspace" && name === "worktree" && config.worktreeDir) { + return { worktreeDir: config.worktreeDir }; + } + if (slot === "workspace" && name === "clone" && config.worktreeDir) { + return { cloneDir: config.worktreeDir }; + } + return undefined; +} + export function createPluginRegistry(): PluginRegistry { const plugins: PluginMap = new Map(); return { - register(plugin: PluginModule): void { + register(plugin: PluginModule, config?: Record): void { const { manifest } = plugin; const key = makeKey(manifest.slot, manifest.name); - const instance = plugin.create(); + const instance = plugin.create(config); plugins.set(key, { manifest, instance }); }, @@ -74,12 +90,15 @@ export function createPluginRegistry(): PluginRegistry { return result; }, - async loadBuiltins(): Promise { + async loadBuiltins(orchestratorConfig?: OrchestratorConfig): Promise { for (const builtin of BUILTIN_PLUGINS) { try { const mod = (await import(builtin.pkg)) as PluginModule; if (mod.manifest && typeof mod.create === "function") { - this.register(mod); + const pluginConfig = orchestratorConfig + ? extractPluginConfig(builtin.slot, builtin.name, orchestratorConfig) + : undefined; + this.register(mod, pluginConfig); } } catch { // Plugin not installed — that's fine, only load what's available @@ -87,9 +106,9 @@ export function createPluginRegistry(): PluginRegistry { } }, - async loadFromConfig(_config: OrchestratorConfig): Promise { - // First, load all built-ins - await this.loadBuiltins(); + async loadFromConfig(config: OrchestratorConfig): Promise { + // Load built-ins with orchestrator config so plugins receive their settings + await this.loadBuiltins(config); // Then, load any additional plugins specified in project configs // (future: support npm package names and local file paths) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6e779735e..352a03c95 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -807,8 +807,8 @@ export interface LifecycleManager { /** Plugin registry — discovery + loading */ export interface PluginRegistry { - /** Register a plugin */ - register(plugin: PluginModule): void; + /** Register a plugin, optionally with config to pass to create() */ + register(plugin: PluginModule, config?: Record): void; /** Get a plugin by slot and name */ get(slot: PluginSlot, name: string): T | null; @@ -816,8 +816,8 @@ export interface PluginRegistry { /** List plugins for a slot */ list(slot: PluginSlot): PluginManifest[]; - /** Load built-in plugins */ - loadBuiltins(): Promise; + /** Load built-in plugins, optionally with orchestrator config for plugin settings */ + loadBuiltins(config?: OrchestratorConfig): Promise; /** Load plugins from config (npm packages, local paths) */ loadFromConfig(config: OrchestratorConfig): Promise; diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 66aaf933b..4646879c9 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -14,7 +14,11 @@ "@agent-orchestrator/plugin-agent-claude-code": "workspace:*", "@agent-orchestrator/plugin-agent-codex": "workspace:*", "@agent-orchestrator/plugin-agent-aider": "workspace:*", - "@agent-orchestrator/plugin-agent-opencode": "workspace:*" + "@agent-orchestrator/plugin-agent-opencode": "workspace:*", + "@agent-orchestrator/plugin-runtime-tmux": "workspace:*", + "@agent-orchestrator/plugin-runtime-process": "workspace:*", + "@agent-orchestrator/plugin-workspace-worktree": "workspace:*", + "@agent-orchestrator/plugin-workspace-clone": "workspace:*" }, "devDependencies": { "@types/node": "^25.2.3", diff --git a/packages/integration-tests/src/runtime-process.integration.test.ts b/packages/integration-tests/src/runtime-process.integration.test.ts new file mode 100644 index 000000000..001a52c7a --- /dev/null +++ b/packages/integration-tests/src/runtime-process.integration.test.ts @@ -0,0 +1,87 @@ +import { afterAll, describe, expect, it } from "vitest"; +import processPlugin from "@agent-orchestrator/plugin-runtime-process"; +import type { RuntimeHandle } from "@agent-orchestrator/core"; +import { sleep } from "./helpers/polling.js"; + +describe("runtime-process (integration)", () => { + const runtime = processPlugin.create(); + const sessionId = `proc-inttest-${Date.now()}`; + let handle: RuntimeHandle; + + afterAll(async () => { + try { + await runtime.destroy(handle); + } catch { /* best-effort cleanup */ } + }, 30_000); + + it("creates a child process", async () => { + handle = await runtime.create({ + sessionId, + workspacePath: "/tmp", + launchCommand: "cat", // cat echoes stdin to stdout + environment: { AO_TEST: "1" }, + }); + + expect(handle.id).toBe(sessionId); + expect(handle.runtimeName).toBe("process"); + expect(handle.data.pid).toBeTypeOf("number"); + }); + + it("isAlive returns true for running process", async () => { + expect(await runtime.isAlive(handle)).toBe(true); + }); + + it("sendMessage writes to stdin and output is captured", async () => { + await runtime.sendMessage(handle, "hello from test"); + await sleep(200); // give time for stdout to be captured + const output = await runtime.getOutput(handle); + expect(output).toContain("hello from test"); + }); + + it("getMetrics returns uptime", async () => { + const metrics = await runtime.getMetrics!(handle); + expect(metrics.uptimeMs).toBeGreaterThan(0); + }); + + it("getAttachInfo returns PID", async () => { + const info = await runtime.getAttachInfo!(handle); + expect(info.type).toBe("process"); + expect(info.target).toMatch(/^\d+$/); + }); + + it("rejects duplicate session IDs", async () => { + await expect( + runtime.create({ + sessionId, + workspacePath: "/tmp", + launchCommand: "cat", + environment: {}, + }), + ).rejects.toThrow("already exists"); + }); + + it("sendMessage throws for unknown session", async () => { + await expect(runtime.sendMessage({ id: "nonexistent", runtimeName: "process", data: {} }, "hi")).rejects.toThrow( + "No process found", + ); + }); + + it("destroy kills the process", async () => { + await runtime.destroy(handle); + await sleep(200); // give time for exit handler + expect(await runtime.isAlive(handle)).toBe(false); + }); + + it("getOutput returns empty for destroyed session", async () => { + const output = await runtime.getOutput(handle); + expect(output).toBe(""); + }); + + it("isAlive returns false for unknown session", async () => { + expect(await runtime.isAlive({ id: "nonexistent", runtimeName: "process", data: {} })).toBe(false); + }); + + it("destroy is idempotent", async () => { + await runtime.destroy(handle); // should not throw + }); +}); diff --git a/packages/integration-tests/src/runtime-tmux.integration.test.ts b/packages/integration-tests/src/runtime-tmux.integration.test.ts new file mode 100644 index 000000000..43fddac5e --- /dev/null +++ b/packages/integration-tests/src/runtime-tmux.integration.test.ts @@ -0,0 +1,81 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import tmuxPlugin from "@agent-orchestrator/plugin-runtime-tmux"; +import type { RuntimeHandle } from "@agent-orchestrator/core"; +import { isTmuxAvailable, killSessionsByPrefix } from "./helpers/tmux.js"; +import { sleep } from "./helpers/polling.js"; + +const tmuxOk = await isTmuxAvailable(); +const SESSION_PREFIX = "ao-inttest-tmux-"; + +describe.skipIf(!tmuxOk)("runtime-tmux (integration)", () => { + const runtime = tmuxPlugin.create(); + const sessionId = `${SESSION_PREFIX}${Date.now()}`; + let handle: RuntimeHandle; + + beforeAll(async () => { + await killSessionsByPrefix(SESSION_PREFIX); + }, 30_000); + + afterAll(async () => { + try { + await runtime.destroy(handle); + } catch { /* best-effort cleanup */ } + await killSessionsByPrefix(SESSION_PREFIX); + }, 30_000); + + it("creates a tmux session", async () => { + handle = await runtime.create({ + sessionId, + workspacePath: "/tmp", + launchCommand: "cat", // cat will wait for stdin + environment: { AO_TEST: "1" }, + }); + + expect(handle.id).toBe(sessionId); + expect(handle.runtimeName).toBe("tmux"); + }); + + it("isAlive returns true for running session", async () => { + expect(await runtime.isAlive(handle)).toBe(true); + }); + + it("sendMessage sends text and getOutput captures it", async () => { + await runtime.sendMessage(handle, "hello world"); + await sleep(500); // give tmux time to process + const output = await runtime.getOutput(handle); + expect(output).toContain("hello world"); + }); + + it("sendMessage handles long text via buffer", async () => { + const longText = "x".repeat(250); + await runtime.sendMessage(handle, longText); + await sleep(500); + const output = await runtime.getOutput(handle); + // tmux wraps long text at column width, so strip ANSI escapes and newlines + // eslint-disable-next-line no-control-regex + const stripped = output.replace(/\x1b\[[0-9;]*m/g, "").replace(/\n/g, ""); + expect(stripped).toContain(longText); + }); + + it("getMetrics returns uptime", async () => { + const metrics = await runtime.getMetrics!(handle); + expect(metrics.uptimeMs).toBeGreaterThan(0); + }); + + it("getAttachInfo returns tmux command", async () => { + const info = await runtime.getAttachInfo!(handle); + expect(info.type).toBe("tmux"); + expect(info.target).toBe(sessionId); + expect(info.command).toContain("tmux attach"); + }); + + it("destroy kills the session", async () => { + await runtime.destroy(handle); + expect(await runtime.isAlive(handle)).toBe(false); + }); + + it("destroy is idempotent", async () => { + // Should not throw even though session is already dead + await runtime.destroy(handle); + }); +}); diff --git a/packages/integration-tests/src/workspace-clone.integration.test.ts b/packages/integration-tests/src/workspace-clone.integration.test.ts new file mode 100644 index 000000000..8471d9743 --- /dev/null +++ b/packages/integration-tests/src/workspace-clone.integration.test.ts @@ -0,0 +1,125 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm, realpath } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import clonePlugin from "@agent-orchestrator/plugin-workspace-clone"; +import type { ProjectConfig, WorkspaceInfo } from "@agent-orchestrator/core"; + +const execFileAsync = promisify(execFile); + +async function git(cwd: string, ...args: string[]): Promise { + const { stdout } = await execFileAsync("git", args, { cwd }); + return stdout.trimEnd(); +} + +describe("workspace-clone (integration)", () => { + let repoDir: string; + let cloneBaseDir: string; + let workspace: ReturnType; + let project: ProjectConfig; + let createdInfo: WorkspaceInfo; + + beforeAll(async () => { + // Create a temp repo with initial commit + const rawRepo = await mkdtemp(join(tmpdir(), "ao-inttest-clone-repo-")); + repoDir = await realpath(rawRepo); + + await git(repoDir, "init", "-b", "main"); + await git(repoDir, "config", "user.email", "test@test.com"); + await git(repoDir, "config", "user.name", "Test"); + await execFileAsync("sh", ["-c", "echo hello > README.md"], { cwd: repoDir }); + await git(repoDir, "add", "."); + await git(repoDir, "commit", "-m", "initial commit"); + + // Clone plugin needs a remote URL — use the local path as origin + await git(repoDir, "remote", "add", "origin", repoDir); + + // Create clone base dir + const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-clone-base-")); + cloneBaseDir = await realpath(rawBase); + + workspace = clonePlugin.create({ cloneDir: cloneBaseDir }); + + project = { + name: "inttest", + repo: "test/inttest", + path: repoDir, + defaultBranch: "main", + sessionPrefix: "test", + }; + }, 30_000); + + afterAll(async () => { + if (repoDir) await rm(repoDir, { recursive: true, force: true }).catch(() => {}); + if (cloneBaseDir) await rm(cloneBaseDir, { recursive: true, force: true }).catch(() => {}); + }, 30_000); + + it("creates a clone workspace", async () => { + createdInfo = await workspace.create({ + projectId: "inttest", + sessionId: "session-1", + project, + branch: "feat/test-branch", + }); + + expect(createdInfo.path).toContain("session-1"); + expect(createdInfo.branch).toBe("feat/test-branch"); + expect(createdInfo.sessionId).toBe("session-1"); + expect(createdInfo.projectId).toBe("inttest"); + expect(existsSync(createdInfo.path)).toBe(true); + }); + + it("clone is on the correct branch", async () => { + const branch = await git(createdInfo.path, "branch", "--show-current"); + expect(branch).toBe("feat/test-branch"); + }); + + it("clone has the files from main", async () => { + expect(existsSync(join(createdInfo.path, "README.md"))).toBe(true); + }); + + it("rejects duplicate workspace", async () => { + await expect( + workspace.create({ + projectId: "inttest", + sessionId: "session-1", + project, + branch: "feat/other", + }), + ).rejects.toThrow("already exists"); + }); + + it("lists the clone", async () => { + const list = await workspace.list("inttest"); + expect(list.length).toBeGreaterThanOrEqual(1); + const found = list.find((w) => w.sessionId === "session-1"); + expect(found).toBeDefined(); + expect(found!.branch).toBe("feat/test-branch"); + }); + + it("rejects invalid projectId", async () => { + await expect( + workspace.create({ + projectId: "bad/id", + sessionId: "ok", + project, + branch: "feat/x", + }), + ).rejects.toThrow("Invalid projectId"); + }); + + it("destroys the clone", async () => { + const pathToDestroy = createdInfo.path; + await workspace.destroy(pathToDestroy); + expect(existsSync(pathToDestroy)).toBe(false); + }); + + it("list returns empty after destroy", async () => { + const list = await workspace.list("inttest"); + const found = list.find((w) => w.sessionId === "session-1"); + expect(found).toBeUndefined(); + }); +}); diff --git a/packages/integration-tests/src/workspace-worktree.integration.test.ts b/packages/integration-tests/src/workspace-worktree.integration.test.ts new file mode 100644 index 000000000..08da1e8a6 --- /dev/null +++ b/packages/integration-tests/src/workspace-worktree.integration.test.ts @@ -0,0 +1,130 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm, realpath } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import worktreePlugin from "@agent-orchestrator/plugin-workspace-worktree"; +import type { ProjectConfig, WorkspaceInfo } from "@agent-orchestrator/core"; + +const execFileAsync = promisify(execFile); + +async function git(cwd: string, ...args: string[]): Promise { + const { stdout } = await execFileAsync("git", args, { cwd }); + return stdout.trimEnd(); +} + +describe("workspace-worktree (integration)", () => { + let repoDir: string; + let worktreeBaseDir: string; + let workspace: ReturnType; + let project: ProjectConfig; + let createdInfo: WorkspaceInfo; + + beforeAll(async () => { + // Create a temp repo with initial commit + const rawRepo = await mkdtemp(join(tmpdir(), "ao-inttest-wt-repo-")); + repoDir = await realpath(rawRepo); + + await git(repoDir, "init", "-b", "main"); + await git(repoDir, "config", "user.email", "test@test.com"); + await git(repoDir, "config", "user.name", "Test"); + await execFileAsync("sh", ["-c", "echo hello > README.md"], { cwd: repoDir }); + await git(repoDir, "add", "."); + await git(repoDir, "commit", "-m", "initial commit"); + + // Add "origin" pointing at itself so the plugin's fetch succeeds + await git(repoDir, "remote", "add", "origin", repoDir); + await git(repoDir, "fetch", "origin"); + + // Create worktree base dir + const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-wt-base-")); + worktreeBaseDir = await realpath(rawBase); + + workspace = worktreePlugin.create({ worktreeDir: worktreeBaseDir }); + + project = { + name: "inttest", + repo: "test/inttest", + path: repoDir, + defaultBranch: "main", + sessionPrefix: "test", + }; + }, 30_000); + + afterAll(async () => { + // Clean up worktrees first (must be done before removing repo) + try { + await git(repoDir, "worktree", "prune"); + } catch { /* best-effort cleanup */ } + if (repoDir) await rm(repoDir, { recursive: true, force: true }).catch(() => {}); + if (worktreeBaseDir) await rm(worktreeBaseDir, { recursive: true, force: true }).catch(() => {}); + }, 30_000); + + it("creates a worktree workspace", async () => { + createdInfo = await workspace.create({ + projectId: "inttest", + sessionId: "session-1", + project, + branch: "feat/test-branch", + }); + + expect(createdInfo.path).toContain("session-1"); + expect(createdInfo.branch).toBe("feat/test-branch"); + expect(createdInfo.sessionId).toBe("session-1"); + expect(createdInfo.projectId).toBe("inttest"); + expect(existsSync(createdInfo.path)).toBe(true); + }); + + it("worktree is on the correct branch", async () => { + const branch = await git(createdInfo.path, "branch", "--show-current"); + expect(branch).toBe("feat/test-branch"); + }); + + it("worktree has the files from main", async () => { + expect(existsSync(join(createdInfo.path, "README.md"))).toBe(true); + }); + + it("lists the worktree", async () => { + const list = await workspace.list("inttest"); + expect(list.length).toBeGreaterThanOrEqual(1); + const found = list.find((w) => w.sessionId === "session-1"); + expect(found).toBeDefined(); + expect(found!.branch).toBe("feat/test-branch"); + }); + + it("rejects invalid projectId", async () => { + await expect( + workspace.create({ + projectId: "../escape", + sessionId: "ok", + project, + branch: "feat/x", + }), + ).rejects.toThrow("Invalid projectId"); + }); + + it("rejects invalid sessionId", async () => { + await expect( + workspace.create({ + projectId: "inttest", + sessionId: "bad/id", + project, + branch: "feat/x", + }), + ).rejects.toThrow("Invalid sessionId"); + }); + + it("destroys the worktree", async () => { + const pathToDestroy = createdInfo.path; + await workspace.destroy(pathToDestroy); + expect(existsSync(pathToDestroy)).toBe(false); + }); + + it("list returns empty after destroy", async () => { + const list = await workspace.list("inttest"); + const found = list.find((w) => w.sessionId === "session-1"); + expect(found).toBeUndefined(); + }); +}); diff --git a/packages/plugins/runtime-process/package.json b/packages/plugins/runtime-process/package.json index 26eb9f683..b32b8bf37 100644 --- a/packages/plugins/runtime-process/package.json +++ b/packages/plugins/runtime-process/package.json @@ -14,6 +14,8 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "clean": "rm -rf dist" }, "dependencies": { @@ -21,6 +23,7 @@ }, "devDependencies": { "@types/node": "^25.2.3", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.0.0" } } diff --git a/packages/plugins/runtime-process/src/__tests__/index.test.ts b/packages/plugins/runtime-process/src/__tests__/index.test.ts new file mode 100644 index 000000000..161459bb0 --- /dev/null +++ b/packages/plugins/runtime-process/src/__tests__/index.test.ts @@ -0,0 +1,712 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "node:events"; +import type { RuntimeHandle } from "@agent-orchestrator/core"; + +// --------------------------------------------------------------------------- +// Hoisted mock — must be set up before import +// --------------------------------------------------------------------------- +const { mockSpawn } = vi.hoisted(() => ({ + mockSpawn: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + spawn: mockSpawn, +})); + +import { create, manifest, default as defaultExport } from "../index.js"; + +// --------------------------------------------------------------------------- +// Mock ChildProcess +// --------------------------------------------------------------------------- +class MockChildProcess extends EventEmitter { + pid = 12345; + exitCode: number | null = null; + signalCode: string | null = null; + stdin = { + writable: true, + write: vi.fn((_data: string, cb: (err?: Error | null) => void) => { + cb(null); + }), + on: vi.fn(), + removeListener: vi.fn(), + }; + stdout = new EventEmitter(); + stderr = new EventEmitter(); + kill = vi.fn(); +} + +function createMockChild(autoSpawn = true): MockChildProcess { + const child = new MockChildProcess(); + if (autoSpawn) { + // Emit "spawn" on next tick so the await in create() resolves + process.nextTick(() => child.emit("spawn")); + } + return child; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function makeHandle(id = "test-session"): RuntimeHandle { + return { id, runtimeName: "process", data: { pid: 12345 } }; +} + +function defaultConfig(overrides: Record = {}) { + return { + sessionId: "test-session", + launchCommand: "echo hello", + workspacePath: "/tmp/workspace", + environment: { FOO: "bar" }, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +// ========================================================================= +// Manifest & exports +// ========================================================================= +describe("manifest & exports", () => { + it("has correct manifest fields", () => { + expect(manifest).toEqual({ + name: "process", + slot: "runtime", + description: "Runtime plugin: child processes", + version: "0.1.0", + }); + }); + + it("default export is a valid PluginModule", () => { + expect(defaultExport.manifest).toBe(manifest); + expect(typeof defaultExport.create).toBe("function"); + }); + + it("create() returns a runtime with name 'process'", () => { + const runtime = create(); + expect(runtime.name).toBe("process"); + }); +}); + +// ========================================================================= +// runtime.create() +// ========================================================================= +describe("create()", () => { + it("spawns process with shell:true, detached:true, correct cwd and env", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + expect(mockSpawn).toHaveBeenCalledWith( + "echo hello", + expect.objectContaining({ + cwd: "/tmp/workspace", + shell: true, + detached: true, + stdio: ["pipe", "pipe", "pipe"], + }), + ); + + // Check the env includes the config environment merged with process.env + const callArgs = mockSpawn.mock.calls[0][1] as { env: Record }; + expect(callArgs.env.FOO).toBe("bar"); + }); + + it("returns handle with correct id, runtimeName, and pid in data", async () => { + const child = createMockChild(); + child.pid = 99999; + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig({ sessionId: "my-session" })); + + expect(handle.id).toBe("my-session"); + expect(handle.runtimeName).toBe("process"); + expect(handle.data).toEqual( + expect.objectContaining({ pid: 99999 }), + ); + }); + + it("rejects invalid session IDs with special characters", async () => { + const runtime = create(); + await expect( + runtime.create(defaultConfig({ sessionId: "bad session!!" })), + ).rejects.toThrow(/Invalid session ID/); + }); + + it("rejects session ID with dots", async () => { + const runtime = create(); + await expect( + runtime.create(defaultConfig({ sessionId: "bad.session" })), + ).rejects.toThrow(/Invalid session ID/); + }); + + it("rejects session ID with spaces", async () => { + const runtime = create(); + await expect( + runtime.create(defaultConfig({ sessionId: "bad session" })), + ).rejects.toThrow(/Invalid session ID/); + }); + + it("accepts valid session IDs with alphanumeric, hyphens, underscores", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig({ sessionId: "my-session_01" })); + expect(handle.id).toBe("my-session_01"); + }); + + it("rejects duplicate session IDs", async () => { + const child1 = createMockChild(); + mockSpawn.mockReturnValue(child1); + + const runtime = create(); + await runtime.create(defaultConfig({ sessionId: "dup-session" })); + + // Second call with same ID should throw + const child2 = createMockChild(); + mockSpawn.mockReturnValue(child2); + await expect( + runtime.create(defaultConfig({ sessionId: "dup-session" })), + ).rejects.toThrow(/already exists/); + }); + + it("cleans up on spawn error", async () => { + const child = createMockChild(false); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const createPromise = runtime.create(defaultConfig({ sessionId: "fail-session" })); + + // Emit error on next tick + process.nextTick(() => child.emit("error", new Error("ENOENT"))); + + await expect(createPromise).rejects.toThrow(/Failed to spawn/); + + // After the error, the session ID should be cleaned up from internal map. + // We can verify by trying to create with the same ID again (should succeed). + const child2 = createMockChild(); + mockSpawn.mockReturnValue(child2); + const handle = await runtime.create(defaultConfig({ sessionId: "fail-session" })); + expect(handle.id).toBe("fail-session"); + }); + + it("cleans up when spawn() itself throws synchronously", async () => { + mockSpawn.mockImplementation(() => { + throw new Error("spawn EACCES"); + }); + + const runtime = create(); + await expect( + runtime.create(defaultConfig({ sessionId: "sync-fail" })), + ).rejects.toThrow(/Failed to spawn/); + + // Slot should be freed — re-create should work + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + const handle = await runtime.create(defaultConfig({ sessionId: "sync-fail" })); + expect(handle.id).toBe("sync-fail"); + }); +}); + +// ========================================================================= +// destroy() +// ========================================================================= +describe("destroy()", () => { + it("kills the process and resolves after exit", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig()); + + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + + // Simulate exit after SIGTERM + child.kill.mockImplementation(() => { + // ignored — process.kill(-pid) is used instead + }); + + // When destroy is called, it sends SIGTERM then waits for exit. + // We need to emit exit when the process receives the signal. + const destroyPromise = runtime.destroy(handle); + + // Give the event loop a tick for destroy to register its "exit" listener + await new Promise((r) => setTimeout(r, 10)); + child.exitCode = 0; + child.emit("exit", 0, null); + + await destroyPromise; + + // process.kill(-pid, "SIGTERM") should have been called + expect(killSpy).toHaveBeenCalledWith(-12345, "SIGTERM"); + + killSpy.mockRestore(); + }); + + it("does not throw for unknown handle (no-op)", async () => { + const runtime = create(); + await expect( + runtime.destroy(makeHandle("nonexistent")), + ).resolves.toBeUndefined(); + }); + + it("does not attempt kill if process already exited", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig()); + + // Simulate the process having exited already + child.exitCode = 0; + + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + + await runtime.destroy(handle); + + // Should NOT have called process.kill since process already exited + expect(killSpy).not.toHaveBeenCalled(); + expect(child.kill).not.toHaveBeenCalled(); + + killSpy.mockRestore(); + }); + + it("escalates to SIGKILL after 5 second timeout", async () => { + vi.useFakeTimers(); + + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + + // We need to emit "spawn" manually with fake timers + const createPromise = runtime.create(defaultConfig({ sessionId: "kill-timeout" })); + await vi.runAllTimersAsync(); + // "spawn" was scheduled via nextTick in createMockChild, which ran + const handle = await createPromise; + + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + + const destroyPromise = runtime.destroy(handle); + + // Advance past the 5-second timeout — process never exits + await vi.advanceTimersByTimeAsync(5100); + + await destroyPromise; + + // Should have called SIGTERM first, then SIGKILL + expect(killSpy).toHaveBeenCalledWith(-12345, "SIGTERM"); + expect(killSpy).toHaveBeenCalledWith(-12345, "SIGKILL"); + + killSpy.mockRestore(); + vi.useRealTimers(); + }); + + it("falls back to child.kill when process.kill(-pid) throws", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig()); + + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { + throw new Error("ESRCH"); + }); + + const destroyPromise = runtime.destroy(handle); + + await new Promise((r) => setTimeout(r, 10)); + child.exitCode = 0; + child.emit("exit", 0, null); + + await destroyPromise; + + // process.kill threw, so child.kill should have been called as fallback + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + + killSpy.mockRestore(); + }); +}); + +// ========================================================================= +// sendMessage() +// ========================================================================= +describe("sendMessage()", () => { + it("writes message with trailing newline to stdin", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig()); + + await runtime.sendMessage(handle, "hello world"); + + expect(child.stdin.write).toHaveBeenCalledWith( + "hello world\n", + expect.any(Function), + ); + }); + + it("throws for unknown session", async () => { + const runtime = create(); + await expect( + runtime.sendMessage(makeHandle("nonexistent"), "hello"), + ).rejects.toThrow(/No process found/); + }); + + it("throws when stdin is not writable", async () => { + const child = createMockChild(); + child.stdin.writable = false; + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig()); + + await expect( + runtime.sendMessage(handle, "hello"), + ).rejects.toThrow(/stdin not writable/); + }); + + it("rejects when stdin.write returns an error", async () => { + const child = createMockChild(); + child.stdin.write = vi.fn((_data: string, cb: (err?: Error | null) => void) => { + cb(new Error("write EPIPE")); + }); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig()); + + await expect( + runtime.sendMessage(handle, "hello"), + ).rejects.toThrow(/write EPIPE/); + }); +}); + +// ========================================================================= +// getOutput() +// ========================================================================= +describe("getOutput()", () => { + it("returns buffered output lines", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + // Simulate stdout data — lines are newline-terminated + child.stdout.emit("data", Buffer.from("line1\nline2\nline3\n")); + + const output = await runtime.getOutput(makeHandle(), 50); + expect(output).toBe("line1\nline2\nline3"); + }); + + it("buffers partial lines across chunks", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + // Partial line split across two chunks + child.stdout.emit("data", Buffer.from("hel")); + child.stdout.emit("data", Buffer.from("lo\nworld\n")); + + const output = await runtime.getOutput(makeHandle(), 50); + expect(output).toBe("hello\nworld"); + }); + + it("returns only the requested number of lines", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + child.stdout.emit("data", Buffer.from("a\nb\nc\nd\ne\n")); + + const output = await runtime.getOutput(makeHandle(), 2); + expect(output).toBe("d\ne"); + }); + + it("returns empty string for unknown session", async () => { + const runtime = create(); + const output = await runtime.getOutput(makeHandle("nonexistent"), 50); + expect(output).toBe(""); + }); + + it("captures stderr in the output buffer too", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + child.stderr.emit("data", Buffer.from("error output\n")); + + const output = await runtime.getOutput(makeHandle(), 50); + expect(output).toBe("error output"); + }); + + it("interleaves stdout and stderr", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + child.stdout.emit("data", Buffer.from("out1\n")); + child.stderr.emit("data", Buffer.from("err1\n")); + child.stdout.emit("data", Buffer.from("out2\n")); + + const output = await runtime.getOutput(makeHandle(), 50); + expect(output).toBe("out1\nerr1\nout2"); + }); + + it("does not mix partial lines across stdout and stderr", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + // stdout emits a partial line, then stderr emits a full line, + // then stdout completes its line — they should NOT be concatenated + child.stdout.emit("data", Buffer.from("hel")); + child.stderr.emit("data", Buffer.from("error\n")); + child.stdout.emit("data", Buffer.from("lo\n")); + + const output = await runtime.getOutput(makeHandle(), 50); + expect(output).toBe("error\nhello"); + }); +}); + +// ========================================================================= +// isAlive() +// ========================================================================= +describe("isAlive()", () => { + it("returns true when process is running (exitCode and signalCode null)", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + expect(await runtime.isAlive(makeHandle())).toBe(true); + }); + + it("returns false when process has exited", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + child.exitCode = 1; + + expect(await runtime.isAlive(makeHandle())).toBe(false); + }); + + it("returns false when process was signalled", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + child.signalCode = "SIGTERM"; + + expect(await runtime.isAlive(makeHandle())).toBe(false); + }); + + it("returns false for unknown session", async () => { + const runtime = create(); + expect(await runtime.isAlive(makeHandle("nonexistent"))).toBe(false); + }); +}); + +// ========================================================================= +// getMetrics() +// ========================================================================= +describe("getMetrics()", () => { + it("returns uptimeMs for a running session", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + + await runtime.create(defaultConfig()); + + // Small delay to ensure uptime > 0 + await new Promise((r) => setTimeout(r, 10)); + + const metrics = await runtime.getMetrics!(makeHandle()); + expect(metrics.uptimeMs).toBeGreaterThanOrEqual(0); + expect(metrics.uptimeMs).toBeLessThan(5000); + }); + + it("returns uptimeMs for unknown session (uses Date.now as fallback)", async () => { + const runtime = create(); + const metrics = await runtime.getMetrics!(makeHandle("nonexistent")); + // When entry is null, createdAt defaults to Date.now(), so uptimeMs is ~0 + expect(metrics.uptimeMs).toBeGreaterThanOrEqual(0); + expect(metrics.uptimeMs).toBeLessThan(100); + }); +}); + +// ========================================================================= +// getAttachInfo() +// ========================================================================= +describe("getAttachInfo()", () => { + it("returns PID as target when process is running", async () => { + const child = createMockChild(); + child.pid = 54321; + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + const info = await runtime.getAttachInfo!(makeHandle()); + expect(info.type).toBe("process"); + expect(info.target).toBe("54321"); + }); + + it("returns 'no longer running' command when process has exited", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + child.exitCode = 0; + + const info = await runtime.getAttachInfo!(makeHandle()); + expect(info.type).toBe("process"); + expect(info.target).toBe(""); + expect(info.command).toContain("no longer running"); + }); + + it("returns 'no longer running' for unknown session", async () => { + const runtime = create(); + const info = await runtime.getAttachInfo!(makeHandle("nonexistent")); + expect(info.type).toBe("process"); + expect(info.target).toBe(""); + expect(info.command).toContain("no longer running"); + }); + + it("returns 'no longer running' when process was killed by signal", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + child.signalCode = "SIGKILL"; + + const info = await runtime.getAttachInfo!(makeHandle()); + expect(info.type).toBe("process"); + expect(info.target).toBe(""); + expect(info.command).toContain("no longer running"); + }); +}); + +// ========================================================================= +// Exit handler cleans up internal map +// ========================================================================= +describe("exit handler", () => { + it("removes session from internal map when process exits", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + // Process is alive + expect(await runtime.isAlive(makeHandle())).toBe(true); + + // Simulate exit + child.exitCode = 0; + child.emit("exit", 0, null); + + // After exit, session should be gone from the map + expect(await runtime.isAlive(makeHandle())).toBe(false); + }); + + it("allows re-creating a session after exit cleanup", async () => { + const child1 = createMockChild(); + mockSpawn.mockReturnValue(child1); + + const runtime = create(); + await runtime.create(defaultConfig({ sessionId: "reuse-me" })); + + // Simulate exit + child1.exitCode = 0; + child1.emit("exit", 0, null); + + // Re-create with same ID should work + const child2 = createMockChild(); + mockSpawn.mockReturnValue(child2); + const handle = await runtime.create(defaultConfig({ sessionId: "reuse-me" })); + expect(handle.id).toBe("reuse-me"); + }); +}); + +// ========================================================================= +// Output buffer truncation +// ========================================================================= +describe("output buffer truncation", () => { + it("truncates output buffer to MAX_OUTPUT_LINES (1000)", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + // Generate 1200 newline-terminated lines + const lines = Array.from({ length: 1200 }, (_, i) => `line-${i}`).join("\n") + "\n"; + child.stdout.emit("data", Buffer.from(lines)); + + // Request all lines — should be capped at 1000 + const output = await runtime.getOutput(makeHandle(), 2000); + const outputLines = output.split("\n"); + expect(outputLines.length).toBeLessThanOrEqual(1000); + // Should contain the last line + expect(outputLines[outputLines.length - 1]).toBe("line-1199"); + // Should NOT contain the first lines (they were truncated) + expect(output).not.toContain("line-0\n"); + }); +}); + +// ========================================================================= +// Per-instance isolation +// ========================================================================= +describe("per-instance isolation", () => { + it("each create() call gets its own isolated processes map", async () => { + const child1 = createMockChild(); + child1.pid = 11111; + + const runtime1 = create(); + const runtime2 = create(); + + mockSpawn.mockReturnValue(child1); + await runtime1.create(defaultConfig({ sessionId: "session-a" })); + + const child2 = createMockChild(); + child2.pid = 99999; + mockSpawn.mockReturnValue(child2); + await runtime2.create(defaultConfig({ sessionId: "session-a" })); + + // Both runtimes can have the same session ID independently + expect(await runtime1.isAlive(makeHandle("session-a"))).toBe(true); + expect(await runtime2.isAlive(makeHandle("session-a"))).toBe(true); + }); +}); diff --git a/packages/plugins/runtime-process/src/index.ts b/packages/plugins/runtime-process/src/index.ts index 624bca7fe..939485fa9 100644 --- a/packages/plugins/runtime-process/src/index.ts +++ b/packages/plugins/runtime-process/src/index.ts @@ -1 +1,278 @@ -// runtime-process plugin — to be implemented +import { spawn, type ChildProcess } from "node:child_process"; +import type { + PluginModule, + Runtime, + RuntimeCreateConfig, + RuntimeHandle, + RuntimeMetrics, + AttachInfo, +} from "@agent-orchestrator/core"; + +export const manifest = { + name: "process", + slot: "runtime" as const, + description: "Runtime plugin: child processes", + version: "0.1.0", +}; + +/** Only allow safe characters in session IDs */ +const SAFE_SESSION_ID = /^[a-zA-Z0-9_-]+$/; + +function assertValidSessionId(id: string): void { + if (!SAFE_SESSION_ID.test(id)) { + throw new Error(`Invalid session ID "${id}": must match ${SAFE_SESSION_ID}`); + } +} + +interface ProcessEntry { + process: ChildProcess | null; + outputBuffer: string[]; + createdAt: number; +} + +const MAX_OUTPUT_LINES = 1000; + +export function create(): Runtime { + // Per-instance process map — each create() call gets its own isolated state + const processes = new Map(); + + return { + name: "process", + + async create(config: RuntimeCreateConfig): Promise { + assertValidSessionId(config.sessionId); + + const handleId = config.sessionId; + + // Prevent duplicate session IDs — check and reserve atomically (no await + // between check and set) so concurrent create() calls can't both pass. + if (processes.has(handleId)) { + throw new Error(`Session "${handleId}" already exists — destroy it before re-creating`); + } + + const entry: ProcessEntry = { + process: null, // set after spawn — methods guard against null + outputBuffer: [], + createdAt: Date.now(), + }; + processes.set(handleId, entry); + + // NOTE: shell:true is intentional — launchCommand comes from trusted YAML config + // and may contain pipes, redirects, or other shell syntax. + let child: ChildProcess; + try { + child = spawn(config.launchCommand, { + cwd: config.workspacePath, + env: { ...process.env, ...config.environment }, + stdio: ["pipe", "pipe", "pipe"], + shell: true, + detached: true, // Own process group so destroy() can kill child commands + }); + } catch (err: unknown) { + processes.delete(handleId); + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to spawn process for session ${handleId}: ${msg}`, { cause: err }); + } + + entry.process = child; + + // Attach exit handler immediately — before any await — so fast-exiting + // processes can't slip through the gap. + child.once("exit", () => { + entry.outputBuffer.push(`[process exited with code ${child.exitCode}]`); + processes.delete(handleId); + }); + + // Handle late errors (process crashes after spawn) + child.on("error", () => { + // Already captured via exit handler — prevent unhandled error crash + }); + + // Wait for spawn success or error + await new Promise((resolve, reject) => { + const onError = (err: Error) => { + child.removeListener("spawn", onSpawn); + processes.delete(handleId); + reject(new Error(`Failed to spawn process for session ${handleId}: ${err.message}`)); + }; + const onSpawn = () => { + child.removeListener("error", onError); + resolve(); + }; + child.once("error", onError); + child.once("spawn", onSpawn); + }); + + // Capture stdout and stderr into rolling buffer. + // Each stream gets its own partial-line buffer so interleaved chunks + // from different streams don't corrupt each other. + function makeAppendOutput(): (data: Buffer) => void { + let partial = ""; + return (data: Buffer) => { + const text = partial + data.toString("utf-8"); + const lines = text.split("\n"); + // Last element is either "" (if text ended with \n) or a partial line + partial = lines.pop()!; + for (const line of lines) { + entry.outputBuffer.push(line); + } + // Trim buffer to max size + if (entry.outputBuffer.length > MAX_OUTPUT_LINES) { + entry.outputBuffer.splice(0, entry.outputBuffer.length - MAX_OUTPUT_LINES); + } + }; + } + + const appendStdout = makeAppendOutput(); + const appendStderr = makeAppendOutput(); + child.stdout?.on("data", appendStdout); + child.stderr?.on("data", appendStderr); + + // Flush any trailing partial lines when the process exits + child.once("exit", () => { + // Trigger flush by sending a final newline through each handler + appendStdout(Buffer.from("\n")); + appendStderr(Buffer.from("\n")); + }); + + return { + id: handleId, + runtimeName: "process", + data: { + pid: child.pid, + createdAt: entry.createdAt, + }, + }; + }, + + async destroy(handle: RuntimeHandle): Promise { + const entry = processes.get(handle.id); + if (!entry) return; + + const child = entry.process; + if (!child) { + // Process hasn't spawned yet — just remove the reservation + processes.delete(handle.id); + return; + } + if (child.exitCode === null && child.signalCode === null) { + // Kill the entire process group (negative PID) so child commands + // spawned by the shell are also terminated, not just the shell itself. + const pid = child.pid; + if (pid) { + try { + process.kill(-pid, "SIGTERM"); + } catch { + // Process group may not exist — fall back to direct kill + child.kill("SIGTERM"); + } + } else { + child.kill("SIGTERM"); + } + + // Give it 5 seconds, then SIGKILL — use once() to avoid listener leaks + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + if (pid) { + try { + process.kill(-pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + } else { + child.kill("SIGKILL"); + } + } + resolve(); + }, 5000); + child.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + } + + processes.delete(handle.id); + }, + + async sendMessage(handle: RuntimeHandle, message: string): Promise { + const entry = processes.get(handle.id); + if (!entry) { + throw new Error(`No process found for session ${handle.id}`); + } + + const child = entry.process; + if (!child) { + throw new Error(`Process for session ${handle.id} is still spawning`); + } + const stdin = child.stdin; + if (!stdin || !stdin.writable) { + throw new Error(`stdin not writable for session ${handle.id}`); + } + + // Wrap write in a promise with done-flag to prevent double resolve/reject + await new Promise((resolve, reject) => { + let done = false; + const finish = (err?: Error | null) => { + if (done) return; + done = true; + cleanup(); + if (err) reject(err); + else resolve(); + }; + const onError = (err: Error) => finish(err); + const onDrain = () => { + // Drain means backpressure cleared — still wait for write callback + }; + const cleanup = () => { + stdin.removeListener("error", onError); + stdin.removeListener("drain", onDrain); + }; + stdin.on("error", onError); + stdin.on("drain", onDrain); + stdin.write(message + "\n", (err) => finish(err ?? null)); + }); + }, + + async getOutput(handle: RuntimeHandle, lines = 50): Promise { + const entry = processes.get(handle.id); + if (!entry) return ""; + + const buffer = entry.outputBuffer; + const start = Math.max(0, buffer.length - lines); + return buffer.slice(start).join("\n"); + }, + + async isAlive(handle: RuntimeHandle): Promise { + const entry = processes.get(handle.id); + if (!entry || !entry.process) return false; + return entry.process.exitCode === null && entry.process.signalCode === null; + }, + + async getMetrics(handle: RuntimeHandle): Promise { + const entry = processes.get(handle.id); + const createdAt = entry?.createdAt ?? Date.now(); + return { + uptimeMs: Date.now() - createdAt, + }; + }, + + async getAttachInfo(handle: RuntimeHandle): Promise { + const entry = processes.get(handle.id); + if (!entry || !entry.process || entry.process.exitCode !== null || entry.process.signalCode !== null) { + return { + type: "process", + target: "", + command: `# process for session ${handle.id} is no longer running`, + }; + } + return { + type: "process", + target: String(entry.process.pid), + }; + }, + }; +} + +export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/runtime-tmux/package.json b/packages/plugins/runtime-tmux/package.json index e22921bde..149ea2406 100644 --- a/packages/plugins/runtime-tmux/package.json +++ b/packages/plugins/runtime-tmux/package.json @@ -14,6 +14,8 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "clean": "rm -rf dist" }, "dependencies": { @@ -21,6 +23,7 @@ }, "devDependencies": { "@types/node": "^25.2.3", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.0.0" } } diff --git a/packages/plugins/runtime-tmux/src/__tests__/index.test.ts b/packages/plugins/runtime-tmux/src/__tests__/index.test.ts new file mode 100644 index 000000000..ba4b4e132 --- /dev/null +++ b/packages/plugins/runtime-tmux/src/__tests__/index.test.ts @@ -0,0 +1,548 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as childProcess from "node:child_process"; +import * as fs from "node:fs"; +import type { RuntimeHandle } from "@agent-orchestrator/core"; + +// Mock node:child_process with custom promisify support +vi.mock("node:child_process", () => { + const mockExecFile = vi.fn(); + // promisify(execFile) checks for a custom promisify symbol. Set it so + // await execFileAsync(...) returns { stdout, stderr } properly. + (mockExecFile as any)[Symbol.for("nodejs.util.promisify.custom")] = vi.fn(); + return { execFile: mockExecFile }; +}); + +// Mock node:crypto for deterministic UUIDs +vi.mock("node:crypto", () => ({ + randomUUID: () => "test-uuid-1234", +})); + +// Mock node:fs for writeFileSync / unlinkSync +vi.mock("node:fs", () => ({ + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +// Get reference to the promisify-custom mock — this is what the plugin actually calls +const mockExecFileCustom = (childProcess.execFile as any)[ + Symbol.for("nodejs.util.promisify.custom") +] as ReturnType; + +/** Queue a successful tmux command with the given stdout. */ +function mockTmuxSuccess(stdout = "") { + mockExecFileCustom.mockResolvedValueOnce({ stdout: stdout + "\n", stderr: "" }); +} + +/** Queue a failed tmux command. */ +function mockTmuxError(message: string) { + mockExecFileCustom.mockRejectedValueOnce(new Error(message)); +} + +/** Create a RuntimeHandle for testing. */ +function makeHandle(id: string, createdAt?: number): RuntimeHandle { + return { + id, + runtimeName: "tmux", + data: { + createdAt: createdAt ?? 1000, + workspacePath: "/tmp/workspace", + }, + }; +} + +// Import after mocks are set up +import tmuxPlugin, { manifest, create } from "../index.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("manifest", () => { + it("has name 'tmux' and slot 'runtime'", () => { + expect(manifest.name).toBe("tmux"); + expect(manifest.slot).toBe("runtime"); + expect(manifest.version).toBe("0.1.0"); + expect(manifest.description).toBe("Runtime plugin: tmux sessions"); + }); + + it("default export includes manifest and create", () => { + expect(tmuxPlugin.manifest).toBe(manifest); + expect(tmuxPlugin.create).toBe(create); + }); +}); + +describe("create()", () => { + it("returns a Runtime with name 'tmux'", () => { + const runtime = create(); + expect(runtime.name).toBe("tmux"); + }); +}); + +describe("runtime.create()", () => { + it("calls new-session with correct args", async () => { + const runtime = create(); + + // 1: new-session, 2: send-keys (launch command) + mockTmuxSuccess(); + mockTmuxSuccess(); + + const handle = await runtime.create({ + sessionId: "test-session", + workspacePath: "/tmp/workspace", + launchCommand: "echo hello", + environment: {}, + }); + + expect(handle.id).toBe("test-session"); + expect(handle.runtimeName).toBe("tmux"); + expect(handle.data.workspacePath).toBe("/tmp/workspace"); + + // First call: new-session + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "new-session", + "-d", + "-s", + "test-session", + "-c", + "/tmp/workspace", + ]); + }); + + it("includes -e KEY=VALUE flags for environment variables", async () => { + const runtime = create(); + + mockTmuxSuccess(); + mockTmuxSuccess(); + + await runtime.create({ + sessionId: "env-session", + workspacePath: "/tmp/ws", + launchCommand: "bash", + environment: { AO_SESSION: "env-session", FOO: "bar" }, + }); + + // First call: new-session with env args + const firstCallArgs = mockExecFileCustom.mock.calls[0]; + const args = firstCallArgs[1] as string[]; + expect(args).toContain("-e"); + expect(args).toContain("AO_SESSION=env-session"); + expect(args).toContain("FOO=bar"); + }); + + it("sends launch command via send-keys", async () => { + const runtime = create(); + + mockTmuxSuccess(); + mockTmuxSuccess(); + + await runtime.create({ + sessionId: "launch-test", + workspacePath: "/tmp/ws", + launchCommand: "claude --session abc", + environment: {}, + }); + + // Second call: send-keys with the launch command + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "send-keys", + "-t", + "launch-test", + "claude --session abc", + "Enter", + ]); + }); + + it("cleans up session if send-keys fails", async () => { + const runtime = create(); + + // 1: new-session succeeds + mockTmuxSuccess(); + // 2: send-keys fails + mockTmuxError("send-keys failed"); + // 3: kill-session (cleanup attempt) + mockTmuxSuccess(); + + await expect( + runtime.create({ + sessionId: "fail-session", + workspacePath: "/tmp/ws", + launchCommand: "bad-command", + environment: {}, + }), + ).rejects.toThrow('Failed to send launch command to session "fail-session"'); + + // Verify kill-session was called for cleanup + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "kill-session", + "-t", + "fail-session", + ]); + }); + + it("rejects invalid session IDs with special characters", async () => { + const runtime = create(); + + await expect( + runtime.create({ + sessionId: "bad session!", + workspacePath: "/tmp/ws", + launchCommand: "echo", + environment: {}, + }), + ).rejects.toThrow('Invalid session ID "bad session!"'); + }); + + it("rejects session IDs with dots", async () => { + const runtime = create(); + + await expect( + runtime.create({ + sessionId: "bad.session", + workspacePath: "/tmp/ws", + launchCommand: "echo", + environment: {}, + }), + ).rejects.toThrow("Invalid session ID"); + }); + + it("accepts valid session IDs with hyphens and underscores", async () => { + const runtime = create(); + + mockTmuxSuccess(); + mockTmuxSuccess(); + + const handle = await runtime.create({ + sessionId: "valid-session_123", + workspacePath: "/tmp/ws", + launchCommand: "echo", + environment: {}, + }); + + expect(handle.id).toBe("valid-session_123"); + }); + + it("handles no environment (undefined)", async () => { + const runtime = create(); + + mockTmuxSuccess(); + mockTmuxSuccess(); + + await runtime.create({ + sessionId: "no-env", + workspacePath: "/tmp/ws", + launchCommand: "echo hi", + } as any); + + // First call should not contain -e flags + const firstCallArgs = mockExecFileCustom.mock.calls[0][1] as string[]; + expect(firstCallArgs).toEqual([ + "new-session", + "-d", + "-s", + "no-env", + "-c", + "/tmp/ws", + ]); + }); +}); + +describe("runtime.destroy()", () => { + it("calls kill-session with the handle id", async () => { + const runtime = create(); + const handle = makeHandle("destroy-test"); + + mockTmuxSuccess(); + + await runtime.destroy(handle); + + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "kill-session", + "-t", + "destroy-test", + ]); + }); + + it("does not throw if session is already gone", async () => { + const runtime = create(); + const handle = makeHandle("already-dead"); + + mockTmuxError("session not found: already-dead"); + + // Should not throw + await expect(runtime.destroy(handle)).resolves.toBeUndefined(); + }); +}); + +describe("runtime.sendMessage()", () => { + it("sends short text with send-keys -l (literal) + Enter", async () => { + const runtime = create(); + const handle = makeHandle("msg-short"); + + // 1: send-keys C-u (clear), 2: send-keys -l text, 3: send-keys Enter + mockTmuxSuccess(); + mockTmuxSuccess(); + mockTmuxSuccess(); + + await runtime.sendMessage(handle, "hello world"); + + expect(mockExecFileCustom).toHaveBeenCalledTimes(3); + + // Call 0: Clear partial input + expect(mockExecFileCustom).toHaveBeenNthCalledWith(1, "tmux", [ + "send-keys", + "-t", + "msg-short", + "C-u", + ]); + + // Call 1: Literal text + expect(mockExecFileCustom).toHaveBeenNthCalledWith(2, "tmux", [ + "send-keys", + "-t", + "msg-short", + "-l", + "hello world", + ]); + + // Call 2: Enter + expect(mockExecFileCustom).toHaveBeenNthCalledWith(3, "tmux", [ + "send-keys", + "-t", + "msg-short", + "Enter", + ]); + }); + + it("uses load-buffer + paste-buffer for long text (> 200 chars)", async () => { + const runtime = create(); + const handle = makeHandle("msg-long"); + const longText = "x".repeat(250); + + // 1: C-u, 2: load-buffer, 3: paste-buffer, 4: unlinkSync (sync), 5: delete-buffer, 6: Enter + mockTmuxSuccess(); // C-u + mockTmuxSuccess(); // load-buffer + mockTmuxSuccess(); // paste-buffer + mockTmuxSuccess(); // delete-buffer (finally block) + mockTmuxSuccess(); // Enter + + await runtime.sendMessage(handle, longText); + + expect(mockExecFileCustom).toHaveBeenCalledTimes(5); + + // Call 0: clear + expect(mockExecFileCustom).toHaveBeenNthCalledWith(1, "tmux", [ + "send-keys", + "-t", + "msg-long", + "C-u", + ]); + + // Call 1: load-buffer with named buffer + expect(mockExecFileCustom).toHaveBeenNthCalledWith(2, "tmux", [ + "load-buffer", + "-b", + "ao-test-uuid-1234", + expect.stringContaining("ao-send-test-uuid-1234.txt"), + ]); + + // Call 2: paste-buffer + expect(mockExecFileCustom).toHaveBeenNthCalledWith(3, "tmux", [ + "paste-buffer", + "-b", + "ao-test-uuid-1234", + "-t", + "msg-long", + "-d", + ]); + + // Verify writeFileSync was called with the message + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining("ao-send-test-uuid-1234.txt"), + longText, + { encoding: "utf-8", mode: 0o600 }, + ); + + // Verify unlinkSync was called for cleanup + expect(fs.unlinkSync).toHaveBeenCalledWith( + expect.stringContaining("ao-send-test-uuid-1234.txt"), + ); + }); + + it("uses load-buffer for multiline text", async () => { + const runtime = create(); + const handle = makeHandle("msg-multi"); + + mockTmuxSuccess(); // C-u + mockTmuxSuccess(); // load-buffer + mockTmuxSuccess(); // paste-buffer + mockTmuxSuccess(); // delete-buffer (finally) + mockTmuxSuccess(); // Enter + + await runtime.sendMessage(handle, "line1\nline2\nline3"); + + // Should use buffer path, not send-keys -l + expect(mockExecFileCustom).toHaveBeenNthCalledWith(2, "tmux", [ + "load-buffer", + "-b", + "ao-test-uuid-1234", + expect.stringContaining("ao-send-test-uuid-1234.txt"), + ]); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining("ao-send-test-uuid-1234.txt"), + "line1\nline2\nline3", + { encoding: "utf-8", mode: 0o600 }, + ); + }); + + it("cleans up buffer and temp file on paste failure", async () => { + const runtime = create(); + const handle = makeHandle("msg-fail"); + const longText = "y".repeat(250); + + mockTmuxSuccess(); // C-u + mockTmuxSuccess(); // load-buffer succeeds + mockTmuxError("paste-buffer failed"); // paste-buffer fails + // finally block: + // unlinkSync is sync (mocked) + mockTmuxSuccess(); // delete-buffer in finally + // After finally, the error propagates — no Enter call + + await expect(runtime.sendMessage(handle, longText)).rejects.toThrow("paste-buffer failed"); + + // unlinkSync should still be called for temp file cleanup + expect(fs.unlinkSync).toHaveBeenCalledWith( + expect.stringContaining("ao-send-test-uuid-1234.txt"), + ); + + // delete-buffer should be called in finally block + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "delete-buffer", + "-b", + "ao-test-uuid-1234", + ]); + }); +}); + +describe("runtime.getOutput()", () => { + it("calls capture-pane with correct args and default lines", async () => { + const runtime = create(); + const handle = makeHandle("output-test"); + + mockTmuxSuccess("some output\nfrom tmux"); + + const output = await runtime.getOutput(handle); + + expect(output).toBe("some output\nfrom tmux"); + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "capture-pane", + "-t", + "output-test", + "-p", + "-S", + "-50", + ]); + }); + + it("passes custom line count", async () => { + const runtime = create(); + const handle = makeHandle("output-custom"); + + mockTmuxSuccess("output"); + + await runtime.getOutput(handle, 100); + + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "capture-pane", + "-t", + "output-custom", + "-p", + "-S", + "-100", + ]); + }); + + it("returns empty string on error", async () => { + const runtime = create(); + const handle = makeHandle("output-err"); + + mockTmuxError("session not found"); + + const output = await runtime.getOutput(handle); + + expect(output).toBe(""); + }); +}); + +describe("runtime.isAlive()", () => { + it("returns true when has-session succeeds", async () => { + const runtime = create(); + const handle = makeHandle("alive-test"); + + mockTmuxSuccess(); + + const alive = await runtime.isAlive(handle); + + expect(alive).toBe(true); + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "has-session", + "-t", + "alive-test", + ]); + }); + + it("returns false when has-session fails", async () => { + const runtime = create(); + const handle = makeHandle("dead-test"); + + mockTmuxError("session not found"); + + const alive = await runtime.isAlive(handle); + + expect(alive).toBe(false); + }); +}); + +describe("runtime.getMetrics()", () => { + it("returns uptimeMs based on createdAt", async () => { + const runtime = create(); + const now = Date.now(); + const handle = makeHandle("metrics-test", now - 5000); + + const metrics = await runtime.getMetrics!(handle); + + // uptimeMs should be approximately 5000ms (allow some wiggle room) + expect(metrics.uptimeMs).toBeGreaterThanOrEqual(5000); + expect(metrics.uptimeMs).toBeLessThan(6000); + }); + + it("handles missing createdAt by using Date.now()", async () => { + const runtime = create(); + const handle: RuntimeHandle = { + id: "metrics-no-created", + runtimeName: "tmux", + data: {}, + }; + + const metrics = await runtime.getMetrics!(handle); + + // uptimeMs should be very close to 0 since createdAt defaults to Date.now() + expect(metrics.uptimeMs).toBeGreaterThanOrEqual(0); + expect(metrics.uptimeMs).toBeLessThan(1000); + }); +}); + +describe("runtime.getAttachInfo()", () => { + it("returns tmux type and attach command", async () => { + const runtime = create(); + const handle = makeHandle("attach-test"); + + const info = await runtime.getAttachInfo!(handle); + + expect(info).toEqual({ + type: "tmux", + target: "attach-test", + command: "tmux attach -t attach-test", + }); + }); +}); diff --git a/packages/plugins/runtime-tmux/src/index.ts b/packages/plugins/runtime-tmux/src/index.ts index b34e376d1..b75b807ab 100644 --- a/packages/plugins/runtime-tmux/src/index.ts +++ b/packages/plugins/runtime-tmux/src/index.ts @@ -1 +1,161 @@ -// runtime-tmux plugin — to be implemented +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; +import { writeFileSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + PluginModule, + Runtime, + RuntimeCreateConfig, + RuntimeHandle, + RuntimeMetrics, + AttachInfo, +} from "@agent-orchestrator/core"; + +const execFileAsync = promisify(execFile); + +export const manifest = { + name: "tmux", + slot: "runtime" as const, + description: "Runtime plugin: tmux sessions", + version: "0.1.0", +}; + +/** Only allow safe characters in session IDs */ +const SAFE_SESSION_ID = /^[a-zA-Z0-9_-]+$/; + +function assertValidSessionId(id: string): void { + if (!SAFE_SESSION_ID.test(id)) { + throw new Error(`Invalid session ID "${id}": must match ${SAFE_SESSION_ID}`); + } +} + +/** Run a tmux command and return stdout */ +async function tmux(...args: string[]): Promise { + const { stdout } = await execFileAsync("tmux", args); + return stdout.trimEnd(); +} + +export function create(): Runtime { + return { + name: "tmux", + + async create(config: RuntimeCreateConfig): Promise { + assertValidSessionId(config.sessionId); + const sessionName = config.sessionId; + + // Build environment flags: -e KEY=VALUE for each env var + const envArgs: string[] = []; + for (const [key, value] of Object.entries(config.environment ?? {})) { + envArgs.push("-e", `${key}=${value}`); + } + + // Create tmux session in detached mode + await tmux("new-session", "-d", "-s", sessionName, "-c", config.workspacePath, ...envArgs); + + // Send the launch command — clean up the session if this fails + try { + await tmux("send-keys", "-t", sessionName, config.launchCommand, "Enter"); + } catch (err: unknown) { + try { + await tmux("kill-session", "-t", sessionName); + } catch { + // Best-effort cleanup + } + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to send launch command to session "${sessionName}": ${msg}`, { + cause: err, + }); + } + + return { + id: sessionName, + runtimeName: "tmux", + data: { + createdAt: Date.now(), + workspacePath: config.workspacePath, + }, + }; + }, + + async destroy(handle: RuntimeHandle): Promise { + try { + await tmux("kill-session", "-t", handle.id); + } catch { + // Session may already be dead — that's fine + } + }, + + async sendMessage(handle: RuntimeHandle, message: string): Promise { + // Clear any partial input + await tmux("send-keys", "-t", handle.id, "C-u"); + + // For long or multiline messages, use load-buffer + paste-buffer + // Use randomUUID to avoid temp file collisions on concurrent sends + if (message.includes("\n") || message.length > 200) { + const bufferName = `ao-${randomUUID()}`; + const tmpPath = join(tmpdir(), `ao-send-${randomUUID()}.txt`); + writeFileSync(tmpPath, message, { encoding: "utf-8", mode: 0o600 }); + try { + await tmux("load-buffer", "-b", bufferName, tmpPath); + await tmux("paste-buffer", "-b", bufferName, "-t", handle.id, "-d"); + } finally { + // Clean up temp file and tmux buffer (in case paste-buffer failed + // and the -d flag didn't delete it) + try { + unlinkSync(tmpPath); + } catch { + // ignore cleanup errors + } + try { + await tmux("delete-buffer", "-b", bufferName); + } catch { + // Buffer may already be deleted by -d flag — that's fine + } + } + } else { + // Use -l (literal) so text like "Enter" or "Space" isn't interpreted + // as tmux key names + await tmux("send-keys", "-t", handle.id, "-l", message); + } + + await tmux("send-keys", "-t", handle.id, "Enter"); + }, + + async getOutput(handle: RuntimeHandle, lines = 50): Promise { + try { + return await tmux("capture-pane", "-t", handle.id, "-p", "-S", `-${lines}`); + } catch { + return ""; + } + }, + + async isAlive(handle: RuntimeHandle): Promise { + try { + await tmux("has-session", "-t", handle.id); + return true; + } catch { + return false; + } + }, + + async getMetrics(handle: RuntimeHandle): Promise { + const createdAt = (handle.data.createdAt as number) ?? Date.now(); + return { + uptimeMs: Date.now() - createdAt, + }; + }, + + async getAttachInfo(handle: RuntimeHandle): Promise { + return { + type: "tmux", + target: handle.id, + command: `tmux attach -t ${handle.id}`, + }; + }, + }; +} + + +export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/workspace-clone/package.json b/packages/plugins/workspace-clone/package.json index 3553d6e6c..d88f7bc02 100644 --- a/packages/plugins/workspace-clone/package.json +++ b/packages/plugins/workspace-clone/package.json @@ -14,6 +14,8 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "clean": "rm -rf dist" }, "dependencies": { @@ -21,6 +23,7 @@ }, "devDependencies": { "@types/node": "^25.2.3", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.0.0" } } diff --git a/packages/plugins/workspace-clone/src/__tests__/index.test.ts b/packages/plugins/workspace-clone/src/__tests__/index.test.ts new file mode 100644 index 000000000..eb9a73187 --- /dev/null +++ b/packages/plugins/workspace-clone/src/__tests__/index.test.ts @@ -0,0 +1,677 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as childProcess from "node:child_process"; +import * as fs from "node:fs"; +import type { ProjectConfig } from "@agent-orchestrator/core"; + +// Mock node:child_process with custom promisify support +vi.mock("node:child_process", () => { + const mockExecFile = vi.fn(); + (mockExecFile as any)[Symbol.for("nodejs.util.promisify.custom")] = vi.fn(); + return { execFile: mockExecFile }; +}); + +// Mock node:fs +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + rmSync: vi.fn(), + mkdirSync: vi.fn(), + readdirSync: vi.fn(), +})); + +// Mock node:os +vi.mock("node:os", () => ({ + homedir: () => "/mock-home", +})); + +// Get reference to the promisify-custom mock — this is what the plugin actually calls +const mockExecFileAsync = (childProcess.execFile as any)[ + Symbol.for("nodejs.util.promisify.custom") +] as ReturnType; + +/** Queue a successful git command with the given stdout. */ +function mockGitSuccess(stdout: string) { + mockExecFileAsync.mockResolvedValueOnce({ stdout: stdout + "\n", stderr: "" }); +} + +/** Queue a failed git command. */ +function mockGitError(message: string) { + mockExecFileAsync.mockRejectedValueOnce(new Error(message)); +} + +/** Create a ProjectConfig for testing. */ +function makeProject(overrides?: Partial): ProjectConfig { + return { + name: "test-project", + repo: "test/repo", + path: "/repo/path", + defaultBranch: "main", + sessionPrefix: "test", + ...overrides, + }; +} + +// Import after mocks are set up +import clonePlugin, { manifest, create } from "../index.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// manifest +// --------------------------------------------------------------------------- +describe("manifest", () => { + it("has name 'clone' and slot 'workspace'", () => { + expect(manifest.name).toBe("clone"); + expect(manifest.slot).toBe("workspace"); + expect(manifest.version).toBe("0.1.0"); + expect(manifest.description).toBe("Workspace plugin: git clone isolation"); + }); + + it("default export includes manifest and create", () => { + expect(clonePlugin.manifest).toBe(manifest); + expect(clonePlugin.create).toBe(create); + }); +}); + +// --------------------------------------------------------------------------- +// create() factory +// --------------------------------------------------------------------------- +describe("create()", () => { + it("returns a Workspace with name 'clone'", () => { + const workspace = create(); + expect(workspace.name).toBe("clone"); + }); + + it("uses ~/.ao-clones as default base dir", async () => { + const workspace = create(); + + // Setup: remote URL lookup + mockGitSuccess("https://github.com/test/repo.git"); + // existsSync: workspace path does not exist yet + (fs.existsSync as ReturnType).mockReturnValue(false); + // git clone + mockGitSuccess(""); + // git checkout -b + mockGitSuccess(""); + + const info = await workspace.create({ + projectId: "myproject", + sessionId: "session-1", + branch: "feat/test", + project: makeProject(), + }); + + expect(info.path).toBe("/mock-home/.ao-clones/myproject/session-1"); + }); + + it("uses custom cloneDir when configured", async () => { + const workspace = create({ cloneDir: "~/custom-clones" }); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + mockGitSuccess(""); + + const info = await workspace.create({ + projectId: "myproject", + sessionId: "session-2", + branch: "feat/custom", + project: makeProject(), + }); + + expect(info.path).toBe("/mock-home/custom-clones/myproject/session-2"); + }); +}); + +// --------------------------------------------------------------------------- +// workspace.create() +// --------------------------------------------------------------------------- +describe("workspace.create()", () => { + it("gets remote URL via git remote get-url origin", async () => { + const workspace = create(); + + // 1: git remote get-url origin + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + // 2: git clone + mockGitSuccess(""); + // 3: git checkout -b + mockGitSuccess(""); + + await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/branch", + project: makeProject(), + }); + + // First call should be git remote get-url origin + expect(mockExecFileAsync).toHaveBeenNthCalledWith( + 1, + "git", + ["remote", "get-url", "origin"], + { cwd: "/repo/path" }, + ); + }); + + it("falls back to local path when remote URL lookup fails", async () => { + const workspace = create(); + + // 1: git remote get-url origin FAILS + mockGitError("fatal: not a git repository"); + (fs.existsSync as ReturnType).mockReturnValue(false); + // 2: git clone (uses local path as remoteUrl) + mockGitSuccess(""); + // 3: git checkout -b + mockGitSuccess(""); + + await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/branch", + project: makeProject(), + }); + + // Clone should use local path as the remote URL + const cloneCall = mockExecFileAsync.mock.calls[1]; + expect(cloneCall[0]).toBe("git"); + const cloneArgs = cloneCall[1] as string[]; + // The remote URL argument (after --reference repoPath --branch defaultBranch) is the 6th arg + expect(cloneArgs[5]).toBe("/repo/path"); + }); + + it("calls git clone with --reference flag", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + mockGitSuccess(""); + + await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/branch", + project: makeProject({ defaultBranch: "develop" }), + }); + + // The clone call (second call overall) + expect(mockExecFileAsync).toHaveBeenNthCalledWith(2, "git", [ + "clone", + "--reference", + "/repo/path", + "--branch", + "develop", + "https://github.com/test/repo.git", + "/mock-home/.ao-clones/proj/sess", + ]); + }); + + it("creates feature branch via checkout -b", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + // git checkout -b feat/new-branch + mockGitSuccess(""); + + await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/new-branch", + project: makeProject(), + }); + + // Third call: checkout -b + expect(mockExecFileAsync).toHaveBeenNthCalledWith( + 3, + "git", + ["checkout", "-b", "feat/new-branch"], + { cwd: "/mock-home/.ao-clones/proj/sess" }, + ); + }); + + it("falls back to plain checkout when branch already exists", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + // git checkout -b fails (branch exists) + mockGitError("fatal: A branch named 'feat/existing' already exists"); + // git checkout (plain) succeeds + mockGitSuccess(""); + + const info = await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/existing", + project: makeProject(), + }); + + // Fourth call: plain checkout + expect(mockExecFileAsync).toHaveBeenNthCalledWith( + 4, + "git", + ["checkout", "feat/existing"], + { cwd: "/mock-home/.ao-clones/proj/sess" }, + ); + + expect(info.branch).toBe("feat/existing"); + }); + + it("cleans up partial clone on clone failure", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + // existsSync: first call for "already exists" check => false + // second call inside catch for cleanup check => true + (fs.existsSync as ReturnType) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + // git clone fails + mockGitError("fatal: could not read from remote repository"); + + await expect( + workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/branch", + project: makeProject(), + }), + ).rejects.toThrow('Failed to clone repo for session "sess"'); + + // rmSync should be called to clean up + expect(fs.rmSync).toHaveBeenCalledWith("/mock-home/.ao-clones/proj/sess", { + recursive: true, + force: true, + }); + }); + + it("cleans up clone on checkout failure and throws", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + // git clone succeeds + mockGitSuccess(""); + // git checkout -b fails + mockGitError("error: pathspec did not match"); + // git checkout (fallback) also fails + mockGitError("error: pathspec 'bad-branch' did not match"); + + await expect( + workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "bad-branch", + project: makeProject(), + }), + ).rejects.toThrow('Failed to checkout branch "bad-branch" in clone'); + + // rmSync should be called to clean up the orphaned clone + expect(fs.rmSync).toHaveBeenCalledWith("/mock-home/.ao-clones/proj/sess", { + recursive: true, + force: true, + }); + }); + + it("throws if workspace path already exists", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + // existsSync returns true — path already exists + (fs.existsSync as ReturnType).mockReturnValue(true); + + await expect( + workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/branch", + project: makeProject(), + }), + ).rejects.toThrow( + 'Workspace path "/mock-home/.ao-clones/proj/sess" already exists for session "sess"', + ); + }); + + it("rejects invalid projectId with special characters", async () => { + const workspace = create(); + + await expect( + workspace.create({ + projectId: "bad/project", + sessionId: "sess", + branch: "feat/branch", + project: makeProject(), + }), + ).rejects.toThrow('Invalid projectId "bad/project"'); + }); + + it("rejects projectId with dots", async () => { + const workspace = create(); + + await expect( + workspace.create({ + projectId: "bad.project", + sessionId: "sess", + branch: "feat/branch", + project: makeProject(), + }), + ).rejects.toThrow('Invalid projectId "bad.project"'); + }); + + it("rejects invalid sessionId with special characters", async () => { + const workspace = create(); + + await expect( + workspace.create({ + projectId: "proj", + sessionId: "bad session!", + branch: "feat/branch", + project: makeProject(), + }), + ).rejects.toThrow('Invalid sessionId "bad session!"'); + }); + + it("rejects sessionId with path traversal", async () => { + const workspace = create(); + + await expect( + workspace.create({ + projectId: "proj", + sessionId: "../escape", + branch: "feat/branch", + project: makeProject(), + }), + ).rejects.toThrow('Invalid sessionId "../escape"'); + }); + + it("returns correct WorkspaceInfo", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + mockGitSuccess(""); + + const info = await workspace.create({ + projectId: "my-project", + sessionId: "session-42", + branch: "feat/awesome", + project: makeProject(), + }); + + expect(info).toEqual({ + path: "/mock-home/.ao-clones/my-project/session-42", + branch: "feat/awesome", + sessionId: "session-42", + projectId: "my-project", + }); + }); + + it("creates project clone directory with recursive option", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + mockGitSuccess(""); + + await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/branch", + project: makeProject(), + }); + + expect(fs.mkdirSync).toHaveBeenCalledWith("/mock-home/.ao-clones/proj", { recursive: true }); + }); + + it("expands ~ in project path", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + mockGitSuccess(""); + + await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/branch", + project: makeProject({ path: "~/my-repos/project" }), + }); + + // git remote get-url should use expanded path + expect(mockExecFileAsync).toHaveBeenNthCalledWith( + 1, + "git", + ["remote", "get-url", "origin"], + { cwd: "/mock-home/my-repos/project" }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// workspace.destroy() +// --------------------------------------------------------------------------- +describe("workspace.destroy()", () => { + it("removes directory with rmSync when it exists", async () => { + const workspace = create(); + (fs.existsSync as ReturnType).mockReturnValue(true); + + await workspace.destroy("/mock-home/.ao-clones/proj/sess"); + + expect(fs.rmSync).toHaveBeenCalledWith("/mock-home/.ao-clones/proj/sess", { + recursive: true, + force: true, + }); + }); + + it("does not throw when directory does not exist", async () => { + const workspace = create(); + (fs.existsSync as ReturnType).mockReturnValue(false); + + await expect( + workspace.destroy("/mock-home/.ao-clones/proj/nonexistent"), + ).resolves.toBeUndefined(); + + expect(fs.rmSync).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// workspace.list() +// --------------------------------------------------------------------------- +describe("workspace.list()", () => { + it("returns empty array when project directory does not exist", async () => { + const workspace = create(); + (fs.existsSync as ReturnType).mockReturnValue(false); + + const result = await workspace.list("myproject"); + + expect(result).toEqual([]); + }); + + it("returns workspace entries with branch info", async () => { + const workspace = create(); + (fs.existsSync as ReturnType).mockReturnValue(true); + + (fs.readdirSync as ReturnType).mockReturnValue([ + { name: "session-1", isDirectory: () => true }, + { name: "session-2", isDirectory: () => true }, + ]); + + // git branch --show-current for each directory + mockGitSuccess("feat/feature-a"); + mockGitSuccess("feat/feature-b"); + + const result = await workspace.list("myproject"); + + expect(result).toEqual([ + { + path: "/mock-home/.ao-clones/myproject/session-1", + branch: "feat/feature-a", + sessionId: "session-1", + projectId: "myproject", + }, + { + path: "/mock-home/.ao-clones/myproject/session-2", + branch: "feat/feature-b", + sessionId: "session-2", + projectId: "myproject", + }, + ]); + + // Verify git branch --show-current was called for each entry + expect(mockExecFileAsync).toHaveBeenNthCalledWith( + 1, + "git", + ["branch", "--show-current"], + { cwd: "/mock-home/.ao-clones/myproject/session-1" }, + ); + expect(mockExecFileAsync).toHaveBeenNthCalledWith( + 2, + "git", + ["branch", "--show-current"], + { cwd: "/mock-home/.ao-clones/myproject/session-2" }, + ); + }); + + it("skips non-directory entries", async () => { + const workspace = create(); + (fs.existsSync as ReturnType).mockReturnValue(true); + + (fs.readdirSync as ReturnType).mockReturnValue([ + { name: "session-1", isDirectory: () => true }, + { name: "some-file.txt", isDirectory: () => false }, + { name: ".DS_Store", isDirectory: () => false }, + ]); + + // Only one git call for the single directory + mockGitSuccess("main"); + + const result = await workspace.list("myproject"); + + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe("session-1"); + expect(mockExecFileAsync).toHaveBeenCalledTimes(1); + }); + + it("skips invalid git repos with console warning", async () => { + const workspace = create(); + (fs.existsSync as ReturnType).mockReturnValue(true); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + (fs.readdirSync as ReturnType).mockReturnValue([ + { name: "valid-session", isDirectory: () => true }, + { name: "corrupt-session", isDirectory: () => true }, + ]); + + // First directory succeeds + mockGitSuccess("feat/working"); + // Second directory fails (not a valid git repo) + mockGitError("fatal: not a git repository"); + + const result = await workspace.list("myproject"); + + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe("valid-session"); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[workspace-clone] Skipping "corrupt-session"'), + ); + + warnSpy.mockRestore(); + }); + + it("rejects invalid projectId with special characters", async () => { + const workspace = create(); + + await expect(workspace.list("bad/project")).rejects.toThrow( + 'Invalid projectId "bad/project"', + ); + }); + + it("rejects projectId with spaces", async () => { + const workspace = create(); + + await expect(workspace.list("bad project")).rejects.toThrow( + 'Invalid projectId "bad project"', + ); + }); +}); + +// --------------------------------------------------------------------------- +// workspace.postCreate() +// --------------------------------------------------------------------------- +describe("workspace.postCreate()", () => { + it("runs each postCreate command via sh -c", async () => { + const workspace = create(); + + const info = { + path: "/mock-home/.ao-clones/proj/sess", + branch: "feat/branch", + sessionId: "sess", + projectId: "proj", + }; + + const project = makeProject({ + postCreate: ["pnpm install", "pnpm build"], + }); + + // Two commands + mockGitSuccess(""); + mockGitSuccess(""); + + await workspace.postCreate!(info, project); + + expect(mockExecFileAsync).toHaveBeenCalledTimes(2); + + expect(mockExecFileAsync).toHaveBeenNthCalledWith(1, "sh", ["-c", "pnpm install"], { + cwd: "/mock-home/.ao-clones/proj/sess", + }); + + expect(mockExecFileAsync).toHaveBeenNthCalledWith(2, "sh", ["-c", "pnpm build"], { + cwd: "/mock-home/.ao-clones/proj/sess", + }); + }); + + it("does nothing when postCreate is undefined", async () => { + const workspace = create(); + + const info = { + path: "/mock-home/.ao-clones/proj/sess", + branch: "feat/branch", + sessionId: "sess", + projectId: "proj", + }; + + const project = makeProject(); // no postCreate + + await workspace.postCreate!(info, project); + + expect(mockExecFileAsync).not.toHaveBeenCalled(); + }); + + it("does nothing when postCreate is empty array", async () => { + const workspace = create(); + + const info = { + path: "/mock-home/.ao-clones/proj/sess", + branch: "feat/branch", + sessionId: "sess", + projectId: "proj", + }; + + const project = makeProject({ postCreate: [] }); + + await workspace.postCreate!(info, project); + + expect(mockExecFileAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/workspace-clone/src/index.ts b/packages/plugins/workspace-clone/src/index.ts index 415f5b63b..0c28fa356 100644 --- a/packages/plugins/workspace-clone/src/index.ts +++ b/packages/plugins/workspace-clone/src/index.ts @@ -1 +1,181 @@ -// workspace-clone plugin — to be implemented +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync, rmSync, mkdirSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import type { + PluginModule, + Workspace, + WorkspaceCreateConfig, + WorkspaceInfo, + ProjectConfig, +} from "@agent-orchestrator/core"; + +const execFileAsync = promisify(execFile); + +export const manifest = { + name: "clone", + slot: "workspace" as const, + description: "Workspace plugin: git clone isolation", + version: "0.1.0", +}; + +/** Run a git command in a given directory */ +async function git(cwd: string, ...args: string[]): Promise { + const { stdout } = await execFileAsync("git", args, { cwd }); + return stdout.trimEnd(); +} + +/** Only allow safe characters in path segments to prevent directory traversal */ +const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9_-]+$/; + +function assertSafePathSegment(value: string, label: string): void { + if (!SAFE_PATH_SEGMENT.test(value)) { + throw new Error(`Invalid ${label} "${value}": must match ${SAFE_PATH_SEGMENT}`); + } +} + +/** Expand ~ to home directory */ +function expandPath(p: string): string { + if (p.startsWith("~/")) { + return join(homedir(), p.slice(2)); + } + return p; +} + +export function create(config?: Record): Workspace { + const cloneBaseDir = config?.cloneDir + ? expandPath(config.cloneDir as string) + : join(homedir(), ".ao-clones"); + + return { + name: "clone", + + async create(cfg: WorkspaceCreateConfig): Promise { + assertSafePathSegment(cfg.projectId, "projectId"); + assertSafePathSegment(cfg.sessionId, "sessionId"); + + const repoPath = expandPath(cfg.project.path); + const projectCloneDir = join(cloneBaseDir, cfg.projectId); + const clonePath = join(projectCloneDir, cfg.sessionId); + + mkdirSync(projectCloneDir, { recursive: true }); + + // Get the remote URL from the source repo + let remoteUrl: string; + try { + remoteUrl = await git(repoPath, "remote", "get-url", "origin"); + } catch { + // Fallback: use the local path as source + remoteUrl = repoPath; + } + + // Fail early if destination already exists — avoid deleting a pre-existing + // workspace in the error handler below + if (existsSync(clonePath)) { + throw new Error( + `Workspace path "${clonePath}" already exists for session "${cfg.sessionId}" — destroy it before re-creating`, + ); + } + + // Clone using --reference for faster clone with shared objects + try { + await execFileAsync("git", [ + "clone", + "--reference", + repoPath, + "--branch", + cfg.project.defaultBranch, + remoteUrl, + clonePath, + ]); + } catch (cloneErr: unknown) { + // Clone failed — clean up any partial directory left on disk + if (existsSync(clonePath)) { + rmSync(clonePath, { recursive: true, force: true }); + } + const msg = cloneErr instanceof Error ? cloneErr.message : String(cloneErr); + throw new Error(`Failed to clone repo for session "${cfg.sessionId}": ${msg}`, { + cause: cloneErr, + }); + } + + // Create and checkout the feature branch + try { + await git(clonePath, "checkout", "-b", cfg.branch); + } catch { + // Branch may exist on remote — try plain checkout + try { + await git(clonePath, "checkout", cfg.branch); + } catch (checkoutErr: unknown) { + // Both checkout attempts failed — clean up the orphaned clone + rmSync(clonePath, { recursive: true, force: true }); + const msg = checkoutErr instanceof Error ? checkoutErr.message : String(checkoutErr); + throw new Error(`Failed to checkout branch "${cfg.branch}" in clone: ${msg}`, { + cause: checkoutErr, + }); + } + } + + return { + path: clonePath, + branch: cfg.branch, + sessionId: cfg.sessionId, + projectId: cfg.projectId, + }; + }, + + async destroy(workspacePath: string): Promise { + if (existsSync(workspacePath)) { + rmSync(workspacePath, { recursive: true, force: true }); + } + }, + + async list(projectId: string): Promise { + assertSafePathSegment(projectId, "projectId"); + const projectCloneDir = join(cloneBaseDir, projectId); + if (!existsSync(projectCloneDir)) return []; + + const entries = readdirSync(projectCloneDir, { withFileTypes: true }); + const infos: WorkspaceInfo[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const clonePath = join(projectCloneDir, entry.name); + let branch: string; + + try { + branch = await git(clonePath, "branch", "--show-current"); + } catch (err: unknown) { + // Warn about corrupted clones instead of silently skipping + const msg = err instanceof Error ? err.message : String(err); + // eslint-disable-next-line no-console -- expected diagnostic for corrupted clones + console.warn(`[workspace-clone] Skipping "${entry.name}": not a valid git repo (${msg})`); + continue; + } + + infos.push({ + path: clonePath, + branch, + sessionId: entry.name, + projectId, + }); + } + + return infos; + }, + + async postCreate(info: WorkspaceInfo, project: ProjectConfig): Promise { + // Run postCreate hooks + // NOTE: commands run with full shell privileges — they come from trusted YAML config + if (project.postCreate) { + for (const command of project.postCreate) { + await execFileAsync("sh", ["-c", command], { cwd: info.path }); + } + } + }, + }; +} + +export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/workspace-worktree/package.json b/packages/plugins/workspace-worktree/package.json index abf15e7b3..be7816a13 100644 --- a/packages/plugins/workspace-worktree/package.json +++ b/packages/plugins/workspace-worktree/package.json @@ -14,6 +14,8 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "clean": "rm -rf dist" }, "dependencies": { @@ -21,6 +23,7 @@ }, "devDependencies": { "@types/node": "^25.2.3", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.0.0" } } diff --git a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts new file mode 100644 index 000000000..7371b6e43 --- /dev/null +++ b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts @@ -0,0 +1,771 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { ProjectConfig, WorkspaceCreateConfig, WorkspaceInfo } from "@agent-orchestrator/core"; + +// --------------------------------------------------------------------------- +// Mocks — must be declared before any import that uses the mocked modules +// --------------------------------------------------------------------------- + +vi.mock("node:child_process", () => { + const mockExecFile = vi.fn(); + // Set custom promisify so `promisify(execFile)` returns { stdout, stderr } + (mockExecFile as any)[Symbol.for("nodejs.util.promisify.custom")] = vi.fn(); + return { execFile: mockExecFile }; +}); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + lstatSync: vi.fn(), + symlinkSync: vi.fn(), + rmSync: vi.fn(), + mkdirSync: vi.fn(), + readdirSync: vi.fn(), +})); + +vi.mock("node:os", () => ({ + homedir: () => "/mock-home", +})); + +// --------------------------------------------------------------------------- +// Imports (after mocks) +// --------------------------------------------------------------------------- + +import * as childProcess from "node:child_process"; +import { existsSync, lstatSync, symlinkSync, rmSync, mkdirSync, readdirSync } from "node:fs"; +import { create, manifest } from "../index.js"; + +// --------------------------------------------------------------------------- +// Typed mock references +// --------------------------------------------------------------------------- + +const mockExecFileAsync = (childProcess.execFile as any)[ + Symbol.for("nodejs.util.promisify.custom") +] as ReturnType; + +const mockExistsSync = existsSync as ReturnType; +const mockLstatSync = lstatSync as ReturnType; +const mockSymlinkSync = symlinkSync as ReturnType; +const mockRmSync = rmSync as ReturnType; +const mockMkdirSync = mkdirSync as ReturnType; +const mockReaddirSync = readdirSync as ReturnType; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mockGitSuccess(stdout: string) { + mockExecFileAsync.mockResolvedValueOnce({ stdout: stdout + "\n", stderr: "" }); +} + +function mockGitError(message: string) { + mockExecFileAsync.mockRejectedValueOnce(new Error(message)); +} + +function makeProject(overrides?: Partial): ProjectConfig { + return { + name: "test-project", + repo: "test/repo", + path: "/repo/path", + defaultBranch: "main", + sessionPrefix: "test", + ...overrides, + }; +} + +function makeCreateConfig(overrides?: Partial): WorkspaceCreateConfig { + return { + projectId: "myproject", + project: makeProject(), + sessionId: "session-1", + branch: "feat/TEST-1", + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Reset mocks before each test +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// =========================================================================== +// Tests +// =========================================================================== + +describe("manifest", () => { + it("has name 'worktree' and slot 'workspace'", () => { + expect(manifest.name).toBe("worktree"); + expect(manifest.slot).toBe("workspace"); + expect(manifest.version).toBe("0.1.0"); + expect(manifest.description).toBe("Workspace plugin: git worktrees"); + }); +}); + +describe("create() factory", () => { + it("uses ~/.worktrees as default base dir", async () => { + const ws = create(); + + // Mock: fetch, worktree add + mockGitSuccess(""); // fetch + mockGitSuccess(""); // worktree add + + const info = await ws.create(makeCreateConfig()); + + expect(info.path).toBe("/mock-home/.worktrees/myproject/session-1"); + }); + + it("uses custom worktreeDir from config", async () => { + const ws = create({ worktreeDir: "/custom/worktrees" }); + + mockGitSuccess(""); // fetch + mockGitSuccess(""); // worktree add + + const info = await ws.create(makeCreateConfig()); + + expect(info.path).toBe("/custom/worktrees/myproject/session-1"); + }); + + it("expands tilde in custom worktreeDir", async () => { + const ws = create({ worktreeDir: "~/custom-path" }); + + mockGitSuccess(""); // fetch + mockGitSuccess(""); // worktree add + + const info = await ws.create(makeCreateConfig()); + + expect(info.path).toBe("/mock-home/custom-path/myproject/session-1"); + }); +}); + +describe("workspace.create()", () => { + it("calls git fetch and git worktree add with correct args", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitSuccess(""); // worktree add + + await ws.create(makeCreateConfig()); + + // First call: git fetch origin --quiet + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + ["fetch", "origin", "--quiet"], + { cwd: "/repo/path" }, + ); + + // Second call: git worktree add -b + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + [ + "worktree", + "add", + "-b", + "feat/TEST-1", + "/mock-home/.worktrees/myproject/session-1", + "origin/main", + ], + { cwd: "/repo/path" }, + ); + }); + + it("creates the project worktree directory", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitSuccess(""); // worktree add + + await ws.create(makeCreateConfig()); + + expect(mockMkdirSync).toHaveBeenCalledWith( + "/mock-home/.worktrees/myproject", + { recursive: true }, + ); + }); + + it("continues when fetch fails (offline)", async () => { + const ws = create(); + + mockGitError("Could not resolve host"); // fetch fails + mockGitSuccess(""); // worktree add succeeds + + const info = await ws.create(makeCreateConfig()); + + expect(info.path).toBe("/mock-home/.worktrees/myproject/session-1"); + }); + + it("handles branch already exists by adding worktree then checking out", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitError("already exists"); // worktree add -b fails + mockGitSuccess(""); // worktree add (without -b) + mockGitSuccess(""); // checkout + + const info = await ws.create(makeCreateConfig()); + + // Third call: worktree add without -b + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + [ + "worktree", + "add", + "/mock-home/.worktrees/myproject/session-1", + "origin/main", + ], + { cwd: "/repo/path" }, + ); + + // Fourth call: checkout + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + ["checkout", "feat/TEST-1"], + { cwd: "/mock-home/.worktrees/myproject/session-1" }, + ); + + expect(info.branch).toBe("feat/TEST-1"); + }); + + it("cleans up worktree on checkout failure", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitError("already exists"); // worktree add -b fails + mockGitSuccess(""); // worktree add (without -b) + mockGitError("checkout failed: conflict"); // checkout fails + mockGitSuccess(""); // worktree remove (cleanup) + + await expect(ws.create(makeCreateConfig())).rejects.toThrow( + 'Failed to checkout branch "feat/TEST-1" in worktree: checkout failed: conflict', + ); + + // Verify cleanup was attempted + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + [ + "worktree", + "remove", + "--force", + "/mock-home/.worktrees/myproject/session-1", + ], + { cwd: "/repo/path" }, + ); + }); + + it("still throws on checkout failure even if cleanup fails", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitError("already exists"); // worktree add -b fails + mockGitSuccess(""); // worktree add (without -b) + mockGitError("checkout failed"); // checkout fails + mockGitError("worktree remove failed"); // cleanup also fails + + await expect(ws.create(makeCreateConfig())).rejects.toThrow( + 'Failed to checkout branch "feat/TEST-1" in worktree', + ); + }); + + it("throws for non-already-exists worktree add errors", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitError("fatal: invalid reference"); // worktree add fails with other error + + await expect(ws.create(makeCreateConfig())).rejects.toThrow( + 'Failed to create worktree for branch "feat/TEST-1": fatal: invalid reference', + ); + }); + + it("rejects invalid projectId", async () => { + const ws = create(); + + await expect( + ws.create(makeCreateConfig({ projectId: "bad/project" })), + ).rejects.toThrow('Invalid projectId "bad/project"'); + }); + + it("rejects projectId with dots", async () => { + const ws = create(); + + await expect( + ws.create(makeCreateConfig({ projectId: "my.project" })), + ).rejects.toThrow('Invalid projectId "my.project"'); + }); + + it("rejects invalid sessionId", async () => { + const ws = create(); + + await expect( + ws.create(makeCreateConfig({ sessionId: "../escape" })), + ).rejects.toThrow('Invalid sessionId "../escape"'); + }); + + it("rejects sessionId with spaces", async () => { + const ws = create(); + + await expect( + ws.create(makeCreateConfig({ sessionId: "bad session" })), + ).rejects.toThrow('Invalid sessionId "bad session"'); + }); + + it("returns correct WorkspaceInfo", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitSuccess(""); // worktree add + + const info = await ws.create(makeCreateConfig()); + + expect(info).toEqual({ + path: "/mock-home/.worktrees/myproject/session-1", + branch: "feat/TEST-1", + sessionId: "session-1", + projectId: "myproject", + }); + }); + + it("expands tilde in project path", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitSuccess(""); // worktree add + + await ws.create( + makeCreateConfig({ + project: makeProject({ path: "~/my-repo" }), + }), + ); + + // fetch should use expanded path + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + ["fetch", "origin", "--quiet"], + { cwd: "/mock-home/my-repo" }, + ); + }); +}); + +describe("workspace.destroy()", () => { + it("removes worktree via git commands", async () => { + const ws = create(); + + // rev-parse returns the .git dir + mockGitSuccess("/repo/path/.git"); + // worktree remove succeeds + mockGitSuccess(""); + + await ws.destroy("/mock-home/.worktrees/myproject/session-1"); + + // First call: rev-parse + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + ["rev-parse", "--path-format=absolute", "--git-common-dir"], + { cwd: "/mock-home/.worktrees/myproject/session-1" }, + ); + + // Second call: worktree remove + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + ["worktree", "remove", "--force", "/mock-home/.worktrees/myproject/session-1"], + { cwd: "/repo/path" }, + ); + }); + + it("falls back to rmSync when git commands fail", async () => { + const ws = create(); + + mockGitError("not a git repository"); // rev-parse fails + mockExistsSync.mockReturnValueOnce(true); + + await ws.destroy("/mock-home/.worktrees/myproject/session-1"); + + expect(mockRmSync).toHaveBeenCalledWith( + "/mock-home/.worktrees/myproject/session-1", + { recursive: true, force: true }, + ); + }); + + it("does nothing if git fails and directory does not exist", async () => { + const ws = create(); + + mockGitError("not a git repository"); + mockExistsSync.mockReturnValueOnce(false); + + await ws.destroy("/nonexistent/path"); + + expect(mockRmSync).not.toHaveBeenCalled(); + }); +}); + +describe("workspace.list()", () => { + it("returns empty array when project directory does not exist", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(false); + + const result = await ws.list("myproject"); + + expect(result).toEqual([]); + }); + + it("returns empty array when project directory has no subdirectories", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(true); + mockReaddirSync.mockReturnValueOnce([]); + + const result = await ws.list("myproject"); + + expect(result).toEqual([]); + }); + + it("parses worktree list porcelain output", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(true); + mockReaddirSync.mockReturnValueOnce([ + { name: "session-1", isDirectory: () => true }, + { name: "session-2", isDirectory: () => true }, + ]); + + const porcelainOutput = [ + "worktree /mock-home/.worktrees/myproject/session-1", + "HEAD abc1234", + "branch refs/heads/feat/TEST-1", + "", + "worktree /mock-home/.worktrees/myproject/session-2", + "HEAD def5678", + "branch refs/heads/feat/TEST-2", + "", + "worktree /repo/path", + "HEAD 0000000", + "branch refs/heads/main", + ].join("\n"); + + mockGitSuccess(porcelainOutput); + + const result = await ws.list("myproject"); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + path: "/mock-home/.worktrees/myproject/session-1", + branch: "feat/TEST-1", + sessionId: "session-1", + projectId: "myproject", + }); + expect(result[1]).toEqual({ + path: "/mock-home/.worktrees/myproject/session-2", + branch: "feat/TEST-2", + sessionId: "session-2", + projectId: "myproject", + }); + }); + + it("handles detached HEAD worktrees", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(true); + mockReaddirSync.mockReturnValueOnce([ + { name: "session-1", isDirectory: () => true }, + ]); + + const porcelainOutput = [ + "worktree /mock-home/.worktrees/myproject/session-1", + "HEAD abc1234", + "detached", + ].join("\n"); + + mockGitSuccess(porcelainOutput); + + const result = await ws.list("myproject"); + + expect(result).toHaveLength(1); + expect(result[0].branch).toBe("detached"); + }); + + it("excludes worktrees outside the project directory", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(true); + mockReaddirSync.mockReturnValueOnce([ + { name: "session-1", isDirectory: () => true }, + ]); + + const porcelainOutput = [ + "worktree /other/path/session-1", + "HEAD abc1234", + "branch refs/heads/feat/other", + "", + "worktree /mock-home/.worktrees/myproject/session-1", + "HEAD def5678", + "branch refs/heads/feat/TEST-1", + ].join("\n"); + + mockGitSuccess(porcelainOutput); + + const result = await ws.list("myproject"); + + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe("session-1"); + }); + + it("returns empty when all git worktree list calls fail", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(true); + mockReaddirSync.mockReturnValueOnce([ + { name: "session-1", isDirectory: () => true }, + ]); + + mockGitError("fatal: not a git repository"); + + const result = await ws.list("myproject"); + + expect(result).toEqual([]); + }); + + it("tries next directory when first worktree list fails", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(true); + mockReaddirSync.mockReturnValueOnce([ + { name: "session-1", isDirectory: () => true }, + { name: "session-2", isDirectory: () => true }, + ]); + + // First dir fails + mockGitError("fatal: not a git repository"); + // Second dir succeeds + const porcelainOutput = [ + "worktree /mock-home/.worktrees/myproject/session-2", + "HEAD abc1234", + "branch refs/heads/feat/TEST-2", + ].join("\n"); + mockGitSuccess(porcelainOutput); + + const result = await ws.list("myproject"); + + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe("session-2"); + }); + + it("rejects invalid projectId", async () => { + const ws = create(); + + await expect(ws.list("bad/id")).rejects.toThrow('Invalid projectId "bad/id"'); + }); + + it("filters out non-directory entries", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(true); + mockReaddirSync.mockReturnValueOnce([ + { name: "session-1", isDirectory: () => true }, + { name: ".DS_Store", isDirectory: () => false }, + { name: "readme.txt", isDirectory: () => false }, + ]); + + const porcelainOutput = [ + "worktree /mock-home/.worktrees/myproject/session-1", + "HEAD abc1234", + "branch refs/heads/feat/TEST-1", + ].join("\n"); + + mockGitSuccess(porcelainOutput); + + const result = await ws.list("myproject"); + + expect(result).toHaveLength(1); + }); +}); + +describe("workspace.postCreate()", () => { + const workspaceInfo: WorkspaceInfo = { + path: "/mock-home/.worktrees/myproject/session-1", + branch: "feat/TEST-1", + sessionId: "session-1", + projectId: "myproject", + }; + + it("creates symlinks for configured paths", async () => { + const ws = create(); + const project = makeProject({ symlinks: ["node_modules", ".env"] }); + + // First symlink: node_modules exists, target lstat throws (doesn't exist) + mockExistsSync.mockReturnValueOnce(true); // sourcePath exists + mockLstatSync.mockImplementationOnce(() => { + throw new Error("ENOENT"); + }); + + // Second symlink: .env exists, target lstat throws (doesn't exist) + mockExistsSync.mockReturnValueOnce(true); // sourcePath exists + mockLstatSync.mockImplementationOnce(() => { + throw new Error("ENOENT"); + }); + + await ws.postCreate!(workspaceInfo, project); + + expect(mockSymlinkSync).toHaveBeenCalledTimes(2); + expect(mockSymlinkSync).toHaveBeenCalledWith( + "/repo/path/node_modules", + "/mock-home/.worktrees/myproject/session-1/node_modules", + ); + expect(mockSymlinkSync).toHaveBeenCalledWith( + "/repo/path/.env", + "/mock-home/.worktrees/myproject/session-1/.env", + ); + }); + + it("removes existing target before symlinking", async () => { + const ws = create(); + const project = makeProject({ symlinks: ["node_modules"] }); + + mockExistsSync.mockReturnValueOnce(true); // sourcePath exists + mockLstatSync.mockReturnValueOnce({ + isSymbolicLink: () => true, + isFile: () => false, + isDirectory: () => false, + }); + + await ws.postCreate!(workspaceInfo, project); + + expect(mockRmSync).toHaveBeenCalledWith( + "/mock-home/.worktrees/myproject/session-1/node_modules", + { recursive: true, force: true }, + ); + expect(mockSymlinkSync).toHaveBeenCalledTimes(1); + }); + + it("skips symlinks when source does not exist", async () => { + const ws = create(); + const project = makeProject({ symlinks: ["nonexistent"] }); + + mockExistsSync.mockReturnValueOnce(false); // sourcePath does not exist + + await ws.postCreate!(workspaceInfo, project); + + expect(mockSymlinkSync).not.toHaveBeenCalled(); + }); + + it("rejects absolute symlink paths", async () => { + const ws = create(); + const project = makeProject({ symlinks: ["/absolute/path"] }); + + await expect(ws.postCreate!(workspaceInfo, project)).rejects.toThrow( + 'Invalid symlink path "/absolute/path": must be a relative path without ".." segments', + ); + }); + + it("rejects .. directory traversal in symlink paths", async () => { + const ws = create(); + const project = makeProject({ symlinks: ["../escape"] }); + + await expect(ws.postCreate!(workspaceInfo, project)).rejects.toThrow( + 'Invalid symlink path "../escape": must be a relative path without ".." segments', + ); + }); + + it("rejects .. embedded in symlink paths", async () => { + const ws = create(); + const project = makeProject({ symlinks: ["foo/../../../etc/passwd"] }); + + await expect(ws.postCreate!(workspaceInfo, project)).rejects.toThrow( + 'must be a relative path without ".." segments', + ); + }); + + it("creates parent directories for nested symlink targets", async () => { + const ws = create(); + const project = makeProject({ symlinks: ["config/settings"] }); + + mockExistsSync.mockReturnValueOnce(true); // sourcePath exists + mockLstatSync.mockImplementationOnce(() => { + throw new Error("ENOENT"); + }); + + await ws.postCreate!(workspaceInfo, project); + + expect(mockMkdirSync).toHaveBeenCalledWith( + "/mock-home/.worktrees/myproject/session-1/config", + { recursive: true }, + ); + }); + + it("runs postCreate commands", async () => { + const ws = create(); + const project = makeProject({ + postCreate: ["pnpm install", "pnpm build"], + }); + + // Two sh -c calls + mockExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" }); + mockExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" }); + + await ws.postCreate!(workspaceInfo, project); + + expect(mockExecFileAsync).toHaveBeenCalledWith( + "sh", + ["-c", "pnpm install"], + { cwd: "/mock-home/.worktrees/myproject/session-1" }, + ); + expect(mockExecFileAsync).toHaveBeenCalledWith( + "sh", + ["-c", "pnpm build"], + { cwd: "/mock-home/.worktrees/myproject/session-1" }, + ); + }); + + it("does nothing when no symlinks or postCreate configured", async () => { + const ws = create(); + const project = makeProject(); + + await ws.postCreate!(workspaceInfo, project); + + expect(mockSymlinkSync).not.toHaveBeenCalled(); + expect(mockExecFileAsync).not.toHaveBeenCalled(); + }); + + it("handles both symlinks and postCreate commands together", async () => { + const ws = create(); + const project = makeProject({ + symlinks: ["node_modules"], + postCreate: ["pnpm install"], + }); + + // Symlink: source exists, target doesn't + mockExistsSync.mockReturnValueOnce(true); + mockLstatSync.mockImplementationOnce(() => { + throw new Error("ENOENT"); + }); + + // postCreate command + mockExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" }); + + await ws.postCreate!(workspaceInfo, project); + + expect(mockSymlinkSync).toHaveBeenCalledTimes(1); + expect(mockExecFileAsync).toHaveBeenCalledWith( + "sh", + ["-c", "pnpm install"], + { cwd: "/mock-home/.worktrees/myproject/session-1" }, + ); + }); + + it("expands tilde in project path for symlink sources", async () => { + const ws = create(); + const project = makeProject({ path: "~/my-repo", symlinks: ["data"] }); + + mockExistsSync.mockReturnValueOnce(true); + mockLstatSync.mockImplementationOnce(() => { + throw new Error("ENOENT"); + }); + + await ws.postCreate!(workspaceInfo, project); + + expect(mockSymlinkSync).toHaveBeenCalledWith( + "/mock-home/my-repo/data", + "/mock-home/.worktrees/myproject/session-1/data", + ); + }); +}); diff --git a/packages/plugins/workspace-worktree/src/index.ts b/packages/plugins/workspace-worktree/src/index.ts index 5ec1870aa..25a917f9d 100644 --- a/packages/plugins/workspace-worktree/src/index.ts +++ b/packages/plugins/workspace-worktree/src/index.ts @@ -1 +1,246 @@ -// workspace-worktree plugin — to be implemented +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync, lstatSync, symlinkSync, rmSync, mkdirSync, readdirSync } from "node:fs"; +import { join, resolve, basename, dirname } from "node:path"; +import { homedir } from "node:os"; +import type { + PluginModule, + Workspace, + WorkspaceCreateConfig, + WorkspaceInfo, + ProjectConfig, +} from "@agent-orchestrator/core"; + +const execFileAsync = promisify(execFile); + +export const manifest = { + name: "worktree", + slot: "workspace" as const, + description: "Workspace plugin: git worktrees", + version: "0.1.0", +}; + +/** Run a git command in a given directory */ +async function git(cwd: string, ...args: string[]): Promise { + const { stdout } = await execFileAsync("git", args, { cwd }); + return stdout.trimEnd(); +} + +/** Only allow safe characters in path segments to prevent directory traversal */ +const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9_-]+$/; + +function assertSafePathSegment(value: string, label: string): void { + if (!SAFE_PATH_SEGMENT.test(value)) { + throw new Error(`Invalid ${label} "${value}": must match ${SAFE_PATH_SEGMENT}`); + } +} + +/** Expand ~ to home directory */ +function expandPath(p: string): string { + if (p.startsWith("~/")) { + return join(homedir(), p.slice(2)); + } + return p; +} + +export function create(config?: Record): Workspace { + const worktreeBaseDir = config?.worktreeDir + ? expandPath(config.worktreeDir as string) + : join(homedir(), ".worktrees"); + + return { + name: "worktree", + + async create(cfg: WorkspaceCreateConfig): Promise { + assertSafePathSegment(cfg.projectId, "projectId"); + assertSafePathSegment(cfg.sessionId, "sessionId"); + + const repoPath = expandPath(cfg.project.path); + const projectWorktreeDir = join(worktreeBaseDir, cfg.projectId); + const worktreePath = join(projectWorktreeDir, cfg.sessionId); + + mkdirSync(projectWorktreeDir, { recursive: true }); + + // Fetch latest from remote + try { + await git(repoPath, "fetch", "origin", "--quiet"); + } catch { + // Fetch may fail if offline — continue anyway + } + + const baseRef = `origin/${cfg.project.defaultBranch}`; + + // Create worktree with a new branch + try { + await git(repoPath, "worktree", "add", "-b", cfg.branch, worktreePath, baseRef); + } catch (err: unknown) { + // Only retry if the error is "branch already exists" + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes("already exists")) { + throw new Error(`Failed to create worktree for branch "${cfg.branch}": ${msg}`, { + cause: err, + }); + } + // Branch already exists — create worktree and check it out + await git(repoPath, "worktree", "add", worktreePath, baseRef); + try { + await git(worktreePath, "checkout", cfg.branch); + } catch (checkoutErr: unknown) { + // Checkout failed — remove the orphaned worktree before rethrowing + try { + await git(repoPath, "worktree", "remove", "--force", worktreePath); + } catch { + // Best-effort cleanup + } + const checkoutMsg = + checkoutErr instanceof Error ? checkoutErr.message : String(checkoutErr); + throw new Error( + `Failed to checkout branch "${cfg.branch}" in worktree: ${checkoutMsg}`, + { cause: checkoutErr }, + ); + } + } + + return { + path: worktreePath, + branch: cfg.branch, + sessionId: cfg.sessionId, + projectId: cfg.projectId, + }; + }, + + async destroy(workspacePath: string): Promise { + try { + const gitCommonDir = await git( + workspacePath, + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ); + // git-common-dir returns something like /path/to/repo/.git + const repoPath = resolve(gitCommonDir, ".."); + await git(repoPath, "worktree", "remove", "--force", workspacePath); + + // NOTE: We intentionally do NOT delete the branch here. The worktree + // removal is sufficient. Auto-deleting branches risks removing + // pre-existing local branches unrelated to this workspace (any branch + // containing "/" would have been deleted). Stale branches can be + // cleaned up separately via `git branch --merged` or similar. + } catch { + // If git commands fail, try to clean up the directory + if (existsSync(workspacePath)) { + rmSync(workspacePath, { recursive: true, force: true }); + } + } + }, + + async list(projectId: string): Promise { + assertSafePathSegment(projectId, "projectId"); + const projectWorktreeDir = join(worktreeBaseDir, projectId); + if (!existsSync(projectWorktreeDir)) return []; + + const entries = readdirSync(projectWorktreeDir, { withFileTypes: true }); + const dirs = entries + .filter((e) => e.isDirectory()) + .map((e) => join(projectWorktreeDir, e.name)); + + if (dirs.length === 0) return []; + + // Use first valid worktree to get the list + let worktreeListOutput = ""; + for (const dir of dirs) { + try { + worktreeListOutput = await git(dir, "worktree", "list", "--porcelain"); + break; + } catch { + continue; + } + } + + if (!worktreeListOutput) return []; + + // Parse porcelain output — only include worktrees within our project directory + const infos: WorkspaceInfo[] = []; + const blocks = worktreeListOutput.split("\n\n"); + + for (const block of blocks) { + const lines = block.trim().split("\n"); + let path = ""; + let branch = ""; + + for (const line of lines) { + if (line.startsWith("worktree ")) { + path = line.slice("worktree ".length); + } else if (line.startsWith("branch ")) { + // branch refs/heads/feat/INT-1234 → feat/INT-1234 + branch = line.slice("branch ".length).replace("refs/heads/", ""); + } + } + + if (path && (path === projectWorktreeDir || path.startsWith(projectWorktreeDir + "/"))) { + const sessionId = basename(path); + infos.push({ + path, + branch: branch || "detached", + sessionId, + projectId, + }); + } + } + + return infos; + }, + + async postCreate(info: WorkspaceInfo, project: ProjectConfig): Promise { + const repoPath = expandPath(project.path); + + // Symlink shared resources + if (project.symlinks) { + for (const symlinkPath of project.symlinks) { + // Guard against absolute paths and directory traversal + if (symlinkPath.startsWith("/") || symlinkPath.includes("..")) { + throw new Error( + `Invalid symlink path "${symlinkPath}": must be a relative path without ".." segments`, + ); + } + + const sourcePath = join(repoPath, symlinkPath); + const targetPath = resolve(info.path, symlinkPath); + + // Verify resolved target is still within the workspace + if (!targetPath.startsWith(info.path + "/") && targetPath !== info.path) { + throw new Error( + `Symlink target "${symlinkPath}" resolves outside workspace: ${targetPath}`, + ); + } + + if (!existsSync(sourcePath)) continue; + + // Remove existing target if it exists + try { + const stat = lstatSync(targetPath); + if (stat.isSymbolicLink() || stat.isFile() || stat.isDirectory()) { + rmSync(targetPath, { recursive: true, force: true }); + } + } catch { + // Target doesn't exist — that's fine + } + + // Ensure parent directory exists for nested symlink targets + mkdirSync(dirname(targetPath), { recursive: true }); + symlinkSync(sourcePath, targetPath); + } + } + + // Run postCreate hooks + // NOTE: commands run with full shell privileges — they come from trusted YAML config + if (project.postCreate) { + for (const command of project.postCreate) { + await execFileAsync("sh", ["-c", command], { cwd: info.path }); + } + } + }, + }; +} + +export default { manifest, create } satisfies PluginModule; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d134fa54..ac6f04cc7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,18 @@ importers: '@agent-orchestrator/plugin-agent-opencode': specifier: workspace:* version: link:../plugins/agent-opencode + '@agent-orchestrator/plugin-runtime-process': + specifier: workspace:* + version: link:../plugins/runtime-process + '@agent-orchestrator/plugin-runtime-tmux': + specifier: workspace:* + version: link:../plugins/runtime-tmux + '@agent-orchestrator/plugin-workspace-clone': + specifier: workspace:* + version: link:../plugins/workspace-clone + '@agent-orchestrator/plugin-workspace-worktree': + specifier: workspace:* + version: link:../plugins/workspace-worktree devDependencies: '@types/node': specifier: ^25.2.3 @@ -214,6 +226,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/runtime-tmux: dependencies: @@ -227,6 +242,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/scm-github: dependencies: @@ -305,6 +323,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/workspace-worktree: dependencies: @@ -318,6 +339,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages/web: dependencies: