From 925a7aae92dcf0346ee7f3bf8a5194301e4e1de0 Mon Sep 17 00:00:00 2001 From: prateek Date: Sat, 14 Feb 2026 15:13:57 +0530 Subject: [PATCH] feat: implement runtime and workspace plugins (tmux, process, worktree, clone) (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implement runtime and workspace plugins (tmux, process, worktree, clone) Implement all 4 runtime/workspace plugins for the agent orchestrator, replacing the stub files with full implementations of the Runtime and Workspace interfaces from @agent-orchestrator/core. - runtime-tmux: tmux session lifecycle with busy detection, wait-for-idle message delivery, capture-pane output, and load-buffer for long messages - runtime-process: child process management with rolling output buffer, graceful SIGTERM/SIGKILL shutdown, and stdin message delivery - workspace-worktree: git worktree create/destroy/list with symlink support for shared resources and postCreate hook execution - workspace-clone: git clone --reference for fast isolated clones with postCreate hooks Closes INT-1328 Co-Authored-By: Claude Opus 4.6 * fix: address all PR review comments on runtime/workspace plugins Fixes for all 13 review issues: 1. Move processes Map inside create() for per-instance isolation (#1) 2. Wrap stdin.write in promise with error/backpressure handling (#2) 3. Use child.once("exit") instead of on("exit") to prevent leaks (#3) 4. Use child.once("exit") in destroy() timeout to prevent leaks (#4) 5. Add assertValidSessionId() with /^[a-zA-Z0-9_-]+$/ regex (#5) 6. Single capture-pane call in isBusy() to avoid TOCTOU (#6) 7. Throw error when sendMessage sends to busy session after timeout (#7) 8. Use crypto.randomUUID() for temp file names to avoid collisions (#8) 9. Document that postCreate commands run with full shell privileges (#9) 10. Inspect worktree add error — only retry on "already exists" (#10) 11. Delete orphaned branch after worktree remove in destroy() (#11) 12. Log warning for corrupted clones in list() instead of silent skip (#12) 13. Return "not running" attach info for exited processes (#13) Co-Authored-By: Claude Opus 4.6 * fix: harden runtime/workspace plugins (spawn errors, stdin races, git injection) - runtime-process: catch spawn errors with setImmediate pattern, prevent double resolve/reject in sendMessage with done-flag, add late error handler to prevent unhandled crashes, eliminate non-null assertions - runtime-tmux: guard config.environment with ?? {} fallback - workspace-worktree: add -- separator to git checkout/branch commands to prevent option injection, guard branch deletion to feature branches only (must contain /), ensure parent dirs exist before symlink creation - workspace-clone: add -- separator to git checkout commands, suppress no-console lint for expected diagnostic warning Co-Authored-By: Claude Opus 4.6 * fix: address codex review — spawn race, path traversal, temp file perms - runtime-process: replace setImmediate with spawn/error event listeners to fix race where create() returns handle for failed process; document intentional shell:true usage - runtime-tmux: restrict temp file permissions to 0o600 - workspace-worktree: validate projectId/sessionId as safe path segments, reject absolute/traversal symlink paths, verify resolved targets stay within workspace - workspace-clone: validate projectId/sessionId as safe path segments in both create() and list() Co-Authored-By: Claude Opus 4.6 * fix: address all review comments and fix CI - workspace-clone/worktree: remove incorrect -- from git checkout (switches to path mode, breaking branch operations) - workspace-worktree: fix prefix collision in list() by appending / to startsWith check (foo no longer matches foobar) - runtime-tmux: use named tmux buffers (-b flag) to prevent concurrent sendMessage calls from overwriting each other's paste content - runtime-process: check signalCode in addition to exitCode for accurate liveness detection of signal-killed processes - runtime-process: reject duplicate session IDs to prevent orphaned child processes from overwritten map entries - runtime-tmux: anchor $ in busy detection to line start (^\$\s) to avoid false idle detection from dollar signs in output Co-Authored-By: Claude Opus 4.6 * fix: forward plugin config through registry so workspace dirs take effect The plugin registry was calling plugin.create() with no arguments, silently ignoring configured worktreeDir/cloneDir settings. Added extractPluginConfig() to map orchestrator config to per-plugin config, and updated register()/loadBuiltins() to pass config through. Co-Authored-By: Claude Opus 4.6 * fix: clean up orphaned worktrees/clones on checkout failure, remove risky branch -D - Worktree: remove git branch -D from destroy() — the "/" heuristic could delete pre-existing local branches unrelated to the workspace - Worktree: wrap fallback checkout in try/catch, clean up orphaned worktree if checkout fails - Clone: wrap fallback checkout in try/catch, rmSync the orphaned clone directory if both checkout attempts fail Co-Authored-By: Claude Opus 4.6 * fix: clean up partial clone directory when git clone fails Wrap the git clone call in try/catch so a failed clone removes any partial clonePath left on disk, preventing orphaned directories that block retries for the same sessionId. Co-Authored-By: Claude Opus 4.6 * refactor: remove busy detection from tmux runtime Busy/idle detection is an agent-layer concern, not a runtime concern. Each agent (Claude Code, Codex, Aider) has its own native mechanism for checking activity status. The tmux runtime should only manage sessions and send messages immediately without polling. Removed: isBusy() heuristics, sleep-based polling loop, sentWhileBusy error. sendMessage() now sends immediately. Co-Authored-By: Claude Opus 4.6 * fix: clean up tmux session when launch command send-keys fails If send-keys fails after new-session succeeds, the orphaned tmux session was left running and unmanaged. Now wraps send-keys in try/catch that kills the session before rethrowing. Co-Authored-By: Claude Opus 4.6 * fix: prevent clone error handler from deleting pre-existing workspace Add early existence check before git clone so create() fails immediately if clonePath already exists, rather than cloning into it, failing, and then deleting the pre-existing workspace in the catch block. Co-Authored-By: Claude Opus 4.6 * fix: clean up leaked tmux buffer when paste-buffer fails The -d flag on paste-buffer only deletes the named buffer on success. If paste fails, the buffer persists in the tmux server. Added delete-buffer in the finally block to ensure cleanup regardless of paste outcome. Co-Authored-By: Claude Opus 4.6 * fix: prevent orphan processes on duplicate sessionId, auto-remove exited sessions - Move processes.has() check before spawn() so duplicate sessionIds are rejected before any child process is created - Auto-remove exited sessions from the process map in the exit handler so the sessionId can be reused without manual destroy() Co-Authored-By: Claude Opus 4.6 * fix: register process in map before exit handler to prevent stale entries A fast-exiting child could fire the exit event before processes.set() ran, causing delete() to no-op and set() to insert a permanently stale entry. Moving set() before the exit handler registration ensures delete() always finds the entry. Co-Authored-By: Claude Opus 4.6 * fix: atomic session reservation and immediate exit handler registration - Reserve map slot synchronously (no await gap between has() and set()) so concurrent create() calls cannot both pass the duplicate check - Register exit handler immediately after spawn(), before any await, so fast-exiting processes cannot miss cleanup Co-Authored-By: Claude Opus 4.6 * fix: kill process group on destroy, use literal mode for tmux send-keys - runtime-process: use process.kill(-pid) to kill the entire process group, not just the shell. Added detached:true so child gets its own process group. Falls back to child.kill() if group kill fails. - runtime-tmux: add -l flag to send-keys for short messages so text like "Enter" or "Space" is sent literally, not as key names. Co-Authored-By: Claude Opus 4.6 * test: add comprehensive unit and integration tests for all 4 plugins Add 136 unit tests and 35 integration tests covering runtime-tmux, runtime-process, workspace-worktree, and workspace-clone plugins. Also adds 13 unit tests for the core plugin-registry. Unit tests (136 total): - runtime-tmux (25): create/destroy, send-keys modes, getOutput, isAlive, getMetrics - runtime-process (40): spawn lifecycle, SIGKILL escalation, output buffering, isolation - workspace-worktree (40): create/destroy, tilde expansion, branch fallback, symlinks - workspace-clone (31): create/destroy, --reference clone, pre-existence check, cleanup Integration tests (35 total): - runtime-tmux (8): full lifecycle with real tmux sessions - runtime-process (11): full lifecycle with real child processes - workspace-worktree (8): real git worktree operations - workspace-clone (8): real git clone operations Plugin-registry tests (13): - register/get/list, config forwarding, slot isolation, loadBuiltins, loadFromConfig Co-Authored-By: Claude Opus 4.6 * fix: guard against null process in runtime-process methods ProcessEntry.process is null between reservation and spawn completion. Add explicit null checks in destroy(), sendMessage(), isAlive(), and getAttachInfo() to prevent TypeError if called during that window. Addresses bugbot review comment about transient null process crash. Co-Authored-By: Claude Opus 4.6 * fix: buffer partial lines in runtime-process output capture Stream chunks split at arbitrary boundaries, so splitting on "\n" and pushing each piece as a complete line corrupts output when a line spans two chunks. Buffer the trailing partial line and only push complete (newline-terminated) lines. Flush remaining partial on process exit. Addresses bugbot review comment about incorrect output chunk splitting. Co-Authored-By: Claude Opus 4.6 * fix: correct vitest peer dep resolution in pnpm-lock.yaml The lockfile referenced vitest@3.2.4 without jiti/jsdom/lightningcss peer deps, but that resolution didn't exist in the packages section. Update to match the full resolution available on main. Co-Authored-By: Claude Opus 4.6 * fix: use per-stream partial buffers in runtime-process output capture stdout and stderr shared one partial-line buffer, so interleaved chunks from different streams could be concatenated into corrupted lines. Each stream now gets its own closure with an independent partial buffer. Addresses bugbot review comment about mixed stream chunk corruption. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../src/__tests__/plugin-registry.test.ts | 187 +++++ packages/core/src/plugin-registry.ts | 33 +- packages/core/src/types.ts | 8 +- packages/integration-tests/package.json | 6 +- .../src/runtime-process.integration.test.ts | 87 ++ .../src/runtime-tmux.integration.test.ts | 81 ++ .../src/workspace-clone.integration.test.ts | 125 +++ .../workspace-worktree.integration.test.ts | 130 +++ packages/plugins/runtime-process/package.json | 5 +- .../src/__tests__/index.test.ts | 712 ++++++++++++++++ packages/plugins/runtime-process/src/index.ts | 279 ++++++- packages/plugins/runtime-tmux/package.json | 5 +- .../runtime-tmux/src/__tests__/index.test.ts | 548 +++++++++++++ packages/plugins/runtime-tmux/src/index.ts | 162 +++- packages/plugins/workspace-clone/package.json | 5 +- .../src/__tests__/index.test.ts | 677 +++++++++++++++ packages/plugins/workspace-clone/src/index.ts | 182 ++++- .../plugins/workspace-worktree/package.json | 5 +- .../src/__tests__/index.test.ts | 771 ++++++++++++++++++ .../plugins/workspace-worktree/src/index.ts | 247 +++++- pnpm-lock.yaml | 24 + 21 files changed, 4259 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/__tests__/plugin-registry.test.ts create mode 100644 packages/integration-tests/src/runtime-process.integration.test.ts create mode 100644 packages/integration-tests/src/runtime-tmux.integration.test.ts create mode 100644 packages/integration-tests/src/workspace-clone.integration.test.ts create mode 100644 packages/integration-tests/src/workspace-worktree.integration.test.ts create mode 100644 packages/plugins/runtime-process/src/__tests__/index.test.ts create mode 100644 packages/plugins/runtime-tmux/src/__tests__/index.test.ts create mode 100644 packages/plugins/workspace-clone/src/__tests__/index.test.ts create mode 100644 packages/plugins/workspace-worktree/src/__tests__/index.test.ts 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: