feat: implement runtime and workspace plugins (tmux, process, worktree, clone) (#2)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-02-14 15:13:57 +05:30 committed by GitHub
parent e450fed462
commit 925a7aae92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 4259 additions and 20 deletions

View File

@ -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<string, unknown>) => ({
name,
_config: config,
})),
};
}
function makeOrchestratorConfig(
overrides?: Partial<OrchestratorConfig>,
): 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<string, unknown> }>(
"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();
});
});

View File

@ -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<string, unknown> | 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<string, unknown>): 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<void> {
async loadBuiltins(orchestratorConfig?: OrchestratorConfig): Promise<void> {
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<void> {
// First, load all built-ins
await this.loadBuiltins();
async loadFromConfig(config: OrchestratorConfig): Promise<void> {
// 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)

View File

@ -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<string, unknown>): void;
/** Get a plugin by slot and name */
get<T>(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<void>;
/** Load built-in plugins, optionally with orchestrator config for plugin settings */
loadBuiltins(config?: OrchestratorConfig): Promise<void>;
/** Load plugins from config (npm packages, local paths) */
loadFromConfig(config: OrchestratorConfig): Promise<void>;

View File

@ -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",

View File

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

View File

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

View File

@ -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<string> {
const { stdout } = await execFileAsync("git", args, { cwd });
return stdout.trimEnd();
}
describe("workspace-clone (integration)", () => {
let repoDir: string;
let cloneBaseDir: string;
let workspace: ReturnType<typeof clonePlugin.create>;
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();
});
});

View File

@ -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<string> {
const { stdout } = await execFileAsync("git", args, { cwd });
return stdout.trimEnd();
}
describe("workspace-worktree (integration)", () => {
let repoDir: string;
let worktreeBaseDir: string;
let workspace: ReturnType<typeof worktreePlugin.create>;
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();
});
});

View File

@ -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"
}
}

View File

@ -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<string, unknown> = {}) {
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<string, string> };
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);
});
});

View File

@ -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<string, ProcessEntry>();
return {
name: "process",
async create(config: RuntimeCreateConfig): Promise<RuntimeHandle> {
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<void>((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<void> {
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<void>((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<void> {
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<void>((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<string> {
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<boolean> {
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<RuntimeMetrics> {
const entry = processes.get(handle.id);
const createdAt = entry?.createdAt ?? Date.now();
return {
uptimeMs: Date.now() - createdAt,
};
},
async getAttachInfo(handle: RuntimeHandle): Promise<AttachInfo> {
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<Runtime>;

View File

@ -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"
}
}

View File

@ -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<typeof vi.fn>;
/** 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",
});
});
});

View File

@ -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<string> {
const { stdout } = await execFileAsync("tmux", args);
return stdout.trimEnd();
}
export function create(): Runtime {
return {
name: "tmux",
async create(config: RuntimeCreateConfig): Promise<RuntimeHandle> {
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<void> {
try {
await tmux("kill-session", "-t", handle.id);
} catch {
// Session may already be dead — that's fine
}
},
async sendMessage(handle: RuntimeHandle, message: string): Promise<void> {
// 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<string> {
try {
return await tmux("capture-pane", "-t", handle.id, "-p", "-S", `-${lines}`);
} catch {
return "";
}
},
async isAlive(handle: RuntimeHandle): Promise<boolean> {
try {
await tmux("has-session", "-t", handle.id);
return true;
} catch {
return false;
}
},
async getMetrics(handle: RuntimeHandle): Promise<RuntimeMetrics> {
const createdAt = (handle.data.createdAt as number) ?? Date.now();
return {
uptimeMs: Date.now() - createdAt,
};
},
async getAttachInfo(handle: RuntimeHandle): Promise<AttachInfo> {
return {
type: "tmux",
target: handle.id,
command: `tmux attach -t ${handle.id}`,
};
},
};
}
export default { manifest, create } satisfies PluginModule<Runtime>;

View File

@ -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"
}
}

View File

@ -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<typeof vi.fn>;
/** 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>): 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>)
.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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(true);
(fs.readdirSync as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(true);
(fs.readdirSync as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(true);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
(fs.readdirSync as ReturnType<typeof vi.fn>).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();
});
});

View File

@ -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<string> {
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<string, unknown>): Workspace {
const cloneBaseDir = config?.cloneDir
? expandPath(config.cloneDir as string)
: join(homedir(), ".ao-clones");
return {
name: "clone",
async create(cfg: WorkspaceCreateConfig): Promise<WorkspaceInfo> {
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<void> {
if (existsSync(workspacePath)) {
rmSync(workspacePath, { recursive: true, force: true });
}
},
async list(projectId: string): Promise<WorkspaceInfo[]> {
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<void> {
// 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<Workspace>;

View File

@ -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"
}
}

View File

@ -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<typeof vi.fn>;
const mockExistsSync = existsSync as ReturnType<typeof vi.fn>;
const mockLstatSync = lstatSync as ReturnType<typeof vi.fn>;
const mockSymlinkSync = symlinkSync as ReturnType<typeof vi.fn>;
const mockRmSync = rmSync as ReturnType<typeof vi.fn>;
const mockMkdirSync = mkdirSync as ReturnType<typeof vi.fn>;
const mockReaddirSync = readdirSync as ReturnType<typeof vi.fn>;
// ---------------------------------------------------------------------------
// 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>): ProjectConfig {
return {
name: "test-project",
repo: "test/repo",
path: "/repo/path",
defaultBranch: "main",
sessionPrefix: "test",
...overrides,
};
}
function makeCreateConfig(overrides?: Partial<WorkspaceCreateConfig>): 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 <branch> <path> <baseRef>
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",
);
});
});

View File

@ -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<string> {
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<string, unknown>): Workspace {
const worktreeBaseDir = config?.worktreeDir
? expandPath(config.worktreeDir as string)
: join(homedir(), ".worktrees");
return {
name: "worktree",
async create(cfg: WorkspaceCreateConfig): Promise<WorkspaceInfo> {
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<void> {
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<WorkspaceInfo[]> {
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<void> {
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<Workspace>;

View File

@ -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: