From e4995bbb1cc36454fc0fa3142ad9c7030c397ff0 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 23 Apr 2026 19:15:22 -0700 Subject: [PATCH] Add SSH environment support (#4358) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The environments subsystem already models execution environments, but before this branch there was no end-to-end SSH-backed runtime path for agents to actually run work against a remote box > - That meant agents could be configured around environment concepts without a reliable way to execute adapter sessions remotely, sync workspace state, and preserve run context across supported adapters > - We also need environment selection to participate in normal Paperclip control-plane behavior: agent defaults, project/issue selection, route validation, and environment probing > - Because this capability is still experimental, the UI surface should be easy to hide and easy to remove later without undoing the underlying implementation > - This pull request adds SSH environment execution support across the runtime, adapters, routes, schema, and tests, then puts the visible environment-management UI behind an experimental flag > - The benefit is that we can validate real SSH-backed agent execution now while keeping the user-facing controls safely gated until the feature is ready to come out of experimentation ## What Changed - Added SSH-backed execution target support in the shared adapter runtime, including remote workspace preparation, skill/runtime asset sync, remote session handling, and workspace restore behavior after runs. - Added SSH execution coverage for supported local adapters, plus remote execution tests across Claude, Codex, Cursor, Gemini, OpenCode, and Pi. - Added environment selection and environment-management backend support needed for SSH execution, including route/service work, validation, probing, and agent default environment persistence. - Added CLI support for SSH environment lab verification and updated related docs/tests. - Added the `enableEnvironments` experimental flag and gated the environment UI behind it on company settings, agent configuration, and project configuration surfaces. ## Verification - `pnpm exec vitest run packages/adapters/claude-local/src/server/execute.remote.test.ts packages/adapters/cursor-local/src/server/execute.remote.test.ts packages/adapters/gemini-local/src/server/execute.remote.test.ts packages/adapters/opencode-local/src/server/execute.remote.test.ts packages/adapters/pi-local/src/server/execute.remote.test.ts` - `pnpm exec vitest run server/src/__tests__/environment-routes.test.ts` - `pnpm exec vitest run server/src/__tests__/instance-settings-routes.test.ts` - `pnpm exec vitest run ui/src/lib/new-agent-hire-payload.test.ts ui/src/lib/new-agent-runtime-config.test.ts` - `pnpm -r typecheck` - `pnpm build` - Manual verification on a branch-local dev server: - enabled the experimental flag - created an SSH environment - created a Linux Claude agent using that environment - confirmed a run executed on the Linux box and synced workspace changes back ## Risks - Medium: this touches runtime execution flow across multiple adapters, so regressions would likely show up in remote session setup, workspace sync, or environment selection precedence. - The UI flag reduces exposure, but the underlying runtime and route changes are still substantial and rely on migration correctness. - The change set is broad across adapters, control-plane services, migrations, and UI gating, so review should pay close attention to environment-selection precedence and remote workspace lifecycle behavior. ## Model Used - OpenAI Codex via Paperclip's local Codex adapter, GPT-5-class coding model with tool use and code execution in the local repo workspace. The local adapter does not surface a more specific public model version string in this branch workflow. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- cli/src/__tests__/env-lab.test.ts | 24 + cli/src/commands/env-lab.ts | 174 +++ cli/src/commands/worktree.ts | 1 + cli/src/index.ts | 2 + doc/CLI.md | 11 +- .../src/execution-target.test.ts | 161 +++ .../adapter-utils/src/execution-target.ts | 399 ++++++ .../src/remote-managed-runtime.ts | 118 ++ packages/adapter-utils/src/server-utils.ts | 112 +- .../adapter-utils/src/ssh-fixture.test.ts | 275 ++++ packages/adapter-utils/src/ssh.ts | 1233 +++++++++++++++++ packages/adapter-utils/src/types.ts | 12 + .../src/server/execute.remote.test.ts | 262 ++++ .../claude-local/src/server/execute.ts | 152 +- .../adapters/claude-local/vitest.config.ts | 7 + .../src/server/execute.remote.test.ts | 359 +++++ .../codex-local/src/server/execute.ts | 123 +- .../src/server/execute.remote.test.ts | 268 ++++ .../cursor-local/src/server/execute.ts | 173 ++- .../src/server/execute.remote.test.ts | 272 ++++ .../gemini-local/src/server/execute.ts | 162 ++- .../src/server/execute.remote.test.ts | 225 +++ .../opencode-local/src/server/execute.ts | 190 ++- .../opencode-local/src/server/parse.test.ts | 27 + .../opencode-local/src/server/parse.ts | 4 +- .../src/server/execute.remote.test.ts | 229 +++ .../adapters/pi-local/src/server/execute.ts | 267 +++- .../0067_agent_default_environment.sql | 3 + .../0068_environment_local_driver_unique.sql | 2 + packages/db/src/migrations/meta/_journal.json | 14 + packages/db/src/schema/agents.ts | 3 + packages/db/src/schema/environments.ts | 5 +- packages/shared/src/constants.ts | 3 +- packages/shared/src/environment-support.ts | 64 + packages/shared/src/index.ts | 15 + packages/shared/src/types/agent.ts | 1 + packages/shared/src/types/environment.ts | 21 +- packages/shared/src/types/index.ts | 8 +- packages/shared/src/types/instance.ts | 1 + .../shared/src/types/workspace-runtime.ts | 82 ++ packages/shared/src/validators/agent.ts | 1 + packages/shared/src/validators/environment.ts | 9 + .../src/validators/execution-workspace.ts | 1 + packages/shared/src/validators/index.ts | 2 + packages/shared/src/validators/instance.ts | 1 + packages/shared/src/validators/issue.ts | 1 + packages/shared/src/validators/project.ts | 1 + server/src/__tests__/activity-routes.test.ts | 19 +- .../agent-instructions-routes.test.ts | 5 + .../agent-permissions-routes.test.ts | 568 +++++++- .../src/__tests__/agent-skills-routes.test.ts | 5 + server/src/__tests__/cli-auth-routes.test.ts | 23 +- .../src/__tests__/environment-config.test.ts | 118 ++ .../__tests__/environment-live-ssh.test.ts | 183 +++ .../src/__tests__/environment-probe.test.ts | 118 ++ .../src/__tests__/environment-routes.test.ts | 1181 ++++++++++++++++ ...environment-selection-route-guards.test.ts | 492 +++++++ .../src/__tests__/environment-service.test.ts | 27 + .../execution-workspace-policy.test.ts | 74 + .../execution-workspaces-service.test.ts | 120 ++ .../heartbeat-process-recovery.test.ts | 5 +- .../instance-settings-routes.test.ts | 21 + .../invite-test-resolution-route.test.ts | 19 +- server/src/__tests__/paperclip-env.test.ts | 18 +- .../project-goal-telemetry-routes.test.ts | 6 + .../src/__tests__/project-routes-env.test.ts | 6 + .../workspace-runtime-routes-authz.test.ts | 5 + server/src/app.ts | 10 +- server/src/routes/agents.ts | 28 + server/src/routes/environment-selection.ts | 32 + server/src/routes/environments.ts | 423 ++++++ server/src/routes/issues.ts | 18 + server/src/routes/projects.ts | 27 +- server/src/services/agents.ts | 6 + server/src/services/environment-config.ts | 237 ++++ server/src/services/environment-probe.ts | 77 + server/src/services/environments.ts | 12 +- .../services/execution-workspace-policy.ts | 25 + server/src/services/execution-workspaces.ts | 35 + server/src/services/heartbeat.ts | 163 ++- server/src/services/instance-settings.ts | 2 + server/src/services/issues.ts | 31 + server/src/services/projects.ts | 30 + ui/src/api/environments.ts | 32 + ui/src/components/AgentConfigForm.tsx | 63 +- ui/src/components/ProjectProperties.tsx | 40 + .../lib/company-portability-sidebar.test.ts | 1 + ui/src/lib/new-agent-hire-payload.test.ts | 44 + ui/src/lib/new-agent-hire-payload.ts | 38 + ui/src/lib/queryKeys.ts | 3 + ui/src/pages/CompanyAccess.tsx | 1 + ui/src/pages/CompanySettings.tsx | 555 +++++++- ui/src/pages/InstanceExperimentalSettings.tsx | 22 +- ui/src/pages/NewAgent.tsx | 25 +- vitest.config.ts | 4 + 95 files changed, 10162 insertions(+), 315 deletions(-) create mode 100644 cli/src/__tests__/env-lab.test.ts create mode 100644 cli/src/commands/env-lab.ts create mode 100644 packages/adapter-utils/src/execution-target.test.ts create mode 100644 packages/adapter-utils/src/execution-target.ts create mode 100644 packages/adapter-utils/src/remote-managed-runtime.ts create mode 100644 packages/adapter-utils/src/ssh-fixture.test.ts create mode 100644 packages/adapter-utils/src/ssh.ts create mode 100644 packages/adapters/claude-local/src/server/execute.remote.test.ts create mode 100644 packages/adapters/claude-local/vitest.config.ts create mode 100644 packages/adapters/codex-local/src/server/execute.remote.test.ts create mode 100644 packages/adapters/cursor-local/src/server/execute.remote.test.ts create mode 100644 packages/adapters/gemini-local/src/server/execute.remote.test.ts create mode 100644 packages/adapters/opencode-local/src/server/execute.remote.test.ts create mode 100644 packages/adapters/pi-local/src/server/execute.remote.test.ts create mode 100644 packages/db/src/migrations/0067_agent_default_environment.sql create mode 100644 packages/db/src/migrations/0068_environment_local_driver_unique.sql create mode 100644 packages/shared/src/environment-support.ts create mode 100644 server/src/__tests__/environment-config.test.ts create mode 100644 server/src/__tests__/environment-live-ssh.test.ts create mode 100644 server/src/__tests__/environment-probe.test.ts create mode 100644 server/src/__tests__/environment-routes.test.ts create mode 100644 server/src/__tests__/environment-selection-route-guards.test.ts create mode 100644 server/src/routes/environment-selection.ts create mode 100644 server/src/routes/environments.ts create mode 100644 server/src/services/environment-config.ts create mode 100644 server/src/services/environment-probe.ts create mode 100644 ui/src/api/environments.ts create mode 100644 ui/src/lib/new-agent-hire-payload.test.ts create mode 100644 ui/src/lib/new-agent-hire-payload.ts diff --git a/cli/src/__tests__/env-lab.test.ts b/cli/src/__tests__/env-lab.test.ts new file mode 100644 index 0000000000..02d6d7daf1 --- /dev/null +++ b/cli/src/__tests__/env-lab.test.ts @@ -0,0 +1,24 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { collectEnvLabDoctorStatus, resolveEnvLabSshStatePath } from "../commands/env-lab.js"; + +describe("env-lab command", () => { + it("resolves the default SSH fixture state path under the instance root", () => { + const statePath = resolveEnvLabSshStatePath("fixture-test"); + + expect(statePath).toContain( + path.join("instances", "fixture-test", "env-lab", "ssh-fixture", "state.json"), + ); + }); + + it("reports doctor status for an instance without a running fixture", async () => { + const status = await collectEnvLabDoctorStatus({ instance: "fixture-test-missing" }); + + expect(status.statePath).toContain( + path.join("instances", "fixture-test-missing", "env-lab", "ssh-fixture", "state.json"), + ); + expect(typeof status.ssh.supported).toBe("boolean"); + expect(status.ssh.running).toBe(false); + expect(status.ssh.environment).toBeNull(); + }); +}); diff --git a/cli/src/commands/env-lab.ts b/cli/src/commands/env-lab.ts new file mode 100644 index 0000000000..55227c9f63 --- /dev/null +++ b/cli/src/commands/env-lab.ts @@ -0,0 +1,174 @@ +import path from "node:path"; +import type { Command } from "commander"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { + buildSshEnvLabFixtureConfig, + getSshEnvLabSupport, + readSshEnvLabFixtureStatus, + startSshEnvLabFixture, + stopSshEnvLabFixture, +} from "@paperclipai/adapter-utils/ssh"; +import { resolvePaperclipInstanceId, resolvePaperclipInstanceRoot } from "../config/home.js"; + +export function resolveEnvLabSshStatePath(instanceId?: string): string { + const resolvedInstanceId = resolvePaperclipInstanceId(instanceId); + return path.resolve( + resolvePaperclipInstanceRoot(resolvedInstanceId), + "env-lab", + "ssh-fixture", + "state.json", + ); +} + +function printJson(value: unknown) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function summarizeFixture(state: { + host: string; + port: number; + username: string; + workspaceDir: string; + sshdLogPath: string; +}) { + p.log.message(`Host: ${pc.cyan(state.host)}:${pc.cyan(String(state.port))}`); + p.log.message(`User: ${pc.cyan(state.username)}`); + p.log.message(`Workspace: ${pc.cyan(state.workspaceDir)}`); + p.log.message(`Log: ${pc.dim(state.sshdLogPath)}`); +} + +export async function collectEnvLabDoctorStatus(opts: { instance?: string }) { + const statePath = resolveEnvLabSshStatePath(opts.instance); + const [sshSupport, sshStatus] = await Promise.all([ + getSshEnvLabSupport(), + readSshEnvLabFixtureStatus(statePath), + ]); + const environment = sshStatus.state ? await buildSshEnvLabFixtureConfig(sshStatus.state) : null; + + return { + statePath, + ssh: { + supported: sshSupport.supported, + reason: sshSupport.reason, + running: sshStatus.running, + state: sshStatus.state, + environment, + }, + }; +} + +export async function envLabUpCommand(opts: { instance?: string; json?: boolean }) { + const statePath = resolveEnvLabSshStatePath(opts.instance); + const state = await startSshEnvLabFixture({ statePath }); + const environment = await buildSshEnvLabFixtureConfig(state); + + if (opts.json) { + printJson({ state, environment }); + return; + } + + p.log.success("SSH env-lab fixture is running."); + summarizeFixture(state); + p.log.message(`State: ${pc.dim(statePath)}`); +} + +export async function envLabStatusCommand(opts: { instance?: string; json?: boolean }) { + const statePath = resolveEnvLabSshStatePath(opts.instance); + const status = await readSshEnvLabFixtureStatus(statePath); + const environment = status.state ? await buildSshEnvLabFixtureConfig(status.state) : null; + + if (opts.json) { + printJson({ ...status, environment, statePath }); + return; + } + + if (!status.state || !status.running) { + p.log.info(`SSH env-lab fixture is not running (${pc.dim(statePath)}).`); + return; + } + + p.log.success("SSH env-lab fixture is running."); + summarizeFixture(status.state); + p.log.message(`State: ${pc.dim(statePath)}`); +} + +export async function envLabDownCommand(opts: { instance?: string; json?: boolean }) { + const statePath = resolveEnvLabSshStatePath(opts.instance); + const stopped = await stopSshEnvLabFixture(statePath); + + if (opts.json) { + printJson({ stopped, statePath }); + return; + } + + if (!stopped) { + p.log.info(`No SSH env-lab fixture was running (${pc.dim(statePath)}).`); + return; + } + + p.log.success("SSH env-lab fixture stopped."); + p.log.message(`State: ${pc.dim(statePath)}`); +} + +export async function envLabDoctorCommand(opts: { instance?: string; json?: boolean }) { + const status = await collectEnvLabDoctorStatus(opts); + + if (opts.json) { + printJson(status); + return; + } + + if (status.ssh.supported) { + p.log.success("SSH fixture prerequisites are installed."); + } else { + p.log.warn(`SSH fixture prerequisites are incomplete: ${status.ssh.reason ?? "unknown reason"}`); + } + + if (status.ssh.state && status.ssh.running) { + p.log.success("SSH env-lab fixture is running."); + summarizeFixture(status.ssh.state); + p.log.message(`Private key: ${pc.dim(status.ssh.state.clientPrivateKeyPath)}`); + p.log.message(`Known hosts: ${pc.dim(status.ssh.state.knownHostsPath)}`); + } else if (status.ssh.state) { + p.log.warn("SSH env-lab fixture state exists, but the process is not running."); + p.log.message(`State: ${pc.dim(status.statePath)}`); + } else { + p.log.info("SSH env-lab fixture is not running."); + p.log.message(`State: ${pc.dim(status.statePath)}`); + } + + p.log.message(`Cleanup: ${pc.dim("pnpm paperclipai env-lab down")}`); +} + +export function registerEnvLabCommands(program: Command) { + const envLab = program.command("env-lab").description("Deterministic local environment fixtures"); + + envLab + .command("up") + .description("Start the default SSH env-lab fixture") + .option("-i, --instance ", "Paperclip instance id (default: current/default)") + .option("--json", "Print machine-readable fixture details") + .action(envLabUpCommand); + + envLab + .command("status") + .description("Show the current SSH env-lab fixture state") + .option("-i, --instance ", "Paperclip instance id (default: current/default)") + .option("--json", "Print machine-readable fixture details") + .action(envLabStatusCommand); + + envLab + .command("down") + .description("Stop the default SSH env-lab fixture") + .option("-i, --instance ", "Paperclip instance id (default: current/default)") + .option("--json", "Print machine-readable stop details") + .action(envLabDownCommand); + + envLab + .command("doctor") + .description("Check SSH fixture prerequisites and current status") + .option("-i, --instance ", "Paperclip instance id (default: current/default)") + .option("--json", "Print machine-readable diagnostic details") + .action(envLabDoctorCommand); +} diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index bff59f50d9..faa8e490e3 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -1311,6 +1311,7 @@ async function seedWorktreeDatabase(input: { backupDir: path.resolve(input.targetPaths.backupDir, "seed"), retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: `${input.instanceId}-seed`, + backupEngine: "javascript", includeMigrationJournal: true, excludeTables: seedPlan.excludedTables, nullifyColumns: seedPlan.nullifyColumns, diff --git a/cli/src/index.ts b/cli/src/index.ts index b2208798a7..bbec356f34 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -8,6 +8,7 @@ import { heartbeatRun } from "./commands/heartbeat-run.js"; import { runCommand } from "./commands/run.js"; import { bootstrapCeoInvite } from "./commands/auth-bootstrap-ceo.js"; import { dbBackupCommand } from "./commands/db-backup.js"; +import { registerEnvLabCommands } from "./commands/env-lab.js"; import { registerContextCommands } from "./commands/client/context.js"; import { registerCompanyCommands } from "./commands/client/company.js"; import { registerIssueCommands } from "./commands/client/issue.js"; @@ -147,6 +148,7 @@ registerDashboardCommands(program); registerRoutineCommands(program); registerFeedbackCommands(program); registerWorktreeCommands(program); +registerEnvLabCommands(program); registerPluginCommands(program); const auth = program.command("auth").description("Authentication and bootstrap utilities"); diff --git a/doc/CLI.md b/doc/CLI.md index c124b44745..9f2aba8645 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -2,7 +2,7 @@ Paperclip CLI now supports both: -- instance setup/diagnostics (`onboard`, `doctor`, `configure`, `env`, `allowed-hostname`) +- instance setup/diagnostics (`onboard`, `doctor`, `configure`, `env`, `allowed-hostname`, `env-lab`) - control-plane client operations (issues, approvals, agents, activity, dashboard) ## Base Usage @@ -45,6 +45,15 @@ Allow an authenticated/private hostname (for example custom Tailscale DNS): pnpm paperclipai allowed-hostname dotta-macbook-pro ``` +Bring up the default local SSH fixture for environment testing: + +```sh +pnpm paperclipai env-lab up +pnpm paperclipai env-lab doctor +pnpm paperclipai env-lab status --json +pnpm paperclipai env-lab down +``` + All client commands support: - `--data-dir ` diff --git a/packages/adapter-utils/src/execution-target.test.ts b/packages/adapter-utils/src/execution-target.test.ts new file mode 100644 index 0000000000..b68c8c10b2 --- /dev/null +++ b/packages/adapter-utils/src/execution-target.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as ssh from "./ssh.js"; +import { + adapterExecutionTargetUsesManagedHome, + runAdapterExecutionTargetShellCommand, +} from "./execution-target.js"; + +describe("runAdapterExecutionTargetShellCommand", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("quotes remote shell commands with the shared SSH quoting helper", async () => { + const runSshCommandSpy = vi.spyOn(ssh, "runSshCommand").mockResolvedValue({ + stdout: "", + stderr: "", + }); + + await runAdapterExecutionTargetShellCommand( + "run-1", + { + kind: "remote", + transport: "ssh", + remoteCwd: "/srv/paperclip/workspace", + spec: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteCwd: "/srv/paperclip/workspace", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }, + `printf '%s\\n' "$HOME" && echo "it's ok"`, + { + cwd: "/tmp/local", + env: {}, + }, + ); + + expect(runSshCommandSpy).toHaveBeenCalledWith( + expect.objectContaining({ + host: "ssh.example.test", + username: "ssh-user", + }), + `sh -lc ${ssh.shellQuote(`printf '%s\\n' "$HOME" && echo "it's ok"`)}`, + expect.any(Object), + ); + }); + + it("returns a timedOut result when the SSH shell command times out", async () => { + vi.spyOn(ssh, "runSshCommand").mockRejectedValue(Object.assign(new Error("timed out"), { + code: "ETIMEDOUT", + stdout: "partial stdout", + stderr: "partial stderr", + signal: "SIGTERM", + })); + const onLog = vi.fn(async () => {}); + + const result = await runAdapterExecutionTargetShellCommand( + "run-2", + { + kind: "remote", + transport: "ssh", + remoteCwd: "/srv/paperclip/workspace", + spec: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteCwd: "/srv/paperclip/workspace", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }, + "sleep 10", + { + cwd: "/tmp/local", + env: {}, + onLog, + }, + ); + + expect(result).toMatchObject({ + exitCode: null, + signal: "SIGTERM", + timedOut: true, + stdout: "partial stdout", + stderr: "partial stderr", + }); + expect(onLog).toHaveBeenCalledWith("stdout", "partial stdout"); + expect(onLog).toHaveBeenCalledWith("stderr", "partial stderr"); + }); + + it("returns the SSH process exit code for non-zero remote command failures", async () => { + vi.spyOn(ssh, "runSshCommand").mockRejectedValue(Object.assign(new Error("non-zero exit"), { + code: 17, + stdout: "partial stdout", + stderr: "partial stderr", + signal: null, + })); + const onLog = vi.fn(async () => {}); + + const result = await runAdapterExecutionTargetShellCommand( + "run-3", + { + kind: "remote", + transport: "ssh", + remoteCwd: "/srv/paperclip/workspace", + spec: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteCwd: "/srv/paperclip/workspace", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }, + "false", + { + cwd: "/tmp/local", + env: {}, + onLog, + }, + ); + + expect(result).toMatchObject({ + exitCode: 17, + signal: null, + timedOut: false, + stdout: "partial stdout", + stderr: "partial stderr", + }); + expect(onLog).toHaveBeenCalledWith("stdout", "partial stdout"); + expect(onLog).toHaveBeenCalledWith("stderr", "partial stderr"); + }); + + it("keeps managed homes disabled for both local and SSH targets", () => { + expect(adapterExecutionTargetUsesManagedHome(null)).toBe(false); + expect(adapterExecutionTargetUsesManagedHome({ + kind: "remote", + transport: "ssh", + remoteCwd: "/srv/paperclip/workspace", + spec: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteCwd: "/srv/paperclip/workspace", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + })).toBe(false); + }); +}); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts new file mode 100644 index 0000000000..d55c9debdc --- /dev/null +++ b/packages/adapter-utils/src/execution-target.ts @@ -0,0 +1,399 @@ +import path from "node:path"; +import type { SshRemoteExecutionSpec } from "./ssh.js"; +import { + buildRemoteExecutionSessionIdentity, + prepareRemoteManagedRuntime, + remoteExecutionSessionMatches, + type RemoteManagedRuntimeAsset, +} from "./remote-managed-runtime.js"; +import { parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js"; +import { + ensureCommandResolvable, + resolveCommandForLogs, + runChildProcess, + type RunProcessResult, + type TerminalResultCleanupOptions, +} from "./server-utils.js"; + +export interface AdapterLocalExecutionTarget { + kind: "local"; + environmentId?: string | null; + leaseId?: string | null; +} + +export interface AdapterSshExecutionTarget { + kind: "remote"; + transport: "ssh"; + environmentId?: string | null; + leaseId?: string | null; + remoteCwd: string; + paperclipApiUrl?: string | null; + spec: SshRemoteExecutionSpec; +} + +export type AdapterExecutionTarget = + | AdapterLocalExecutionTarget + | AdapterSshExecutionTarget; + +export type AdapterRemoteExecutionSpec = SshRemoteExecutionSpec; + +export type AdapterManagedRuntimeAsset = RemoteManagedRuntimeAsset; + +export interface PreparedAdapterExecutionTargetRuntime { + target: AdapterExecutionTarget; + runtimeRootDir: string | null; + assetDirs: Record; + restoreWorkspace(): Promise; +} + +export interface AdapterExecutionTargetProcessOptions { + cwd: string; + env: Record; + stdin?: string; + timeoutSec: number; + graceSec: number; + onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; + onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise; + terminalResultCleanup?: TerminalResultCleanupOptions; +} + +export interface AdapterExecutionTargetShellOptions { + cwd: string; + env: Record; + timeoutSec?: number; + graceSec?: number; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; +} + +function parseObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function readStringMeta(parsed: Record, key: string): string | null { + return readString(parsed[key]); +} + +function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecutionTarget { + const parsed = parseObject(value); + if (parsed.kind === "local") return true; + if (parsed.kind !== "remote") return false; + if (parsed.transport === "ssh") return parseSshRemoteExecutionSpec(parseObject(parsed.spec)) !== null; + return false; +} + +export function adapterExecutionTargetToRemoteSpec( + target: AdapterExecutionTarget | null | undefined, +): AdapterRemoteExecutionSpec | null { + return target?.kind === "remote" && target.transport === "ssh" ? target.spec : null; +} + +export function adapterExecutionTargetIsRemote( + target: AdapterExecutionTarget | null | undefined, +): boolean { + return target?.kind === "remote"; +} + +export function adapterExecutionTargetUsesManagedHome( + target: AdapterExecutionTarget | null | undefined, +): boolean { + // SSH execution targets sync the runtime assets they need into the remote cwd today, + // so neither local nor remote targets provision a separate managed adapter home. + void target; + return false; +} + +export function adapterExecutionTargetRemoteCwd( + target: AdapterExecutionTarget | null | undefined, + localCwd: string, +): string { + return target?.kind === "remote" ? target.remoteCwd : localCwd; +} + +export function adapterExecutionTargetPaperclipApiUrl( + target: AdapterExecutionTarget | null | undefined, +): string | null { + if (target?.kind !== "remote") return null; + return target.paperclipApiUrl ?? target.spec.paperclipApiUrl ?? null; +} + +export function describeAdapterExecutionTarget( + target: AdapterExecutionTarget | null | undefined, +): string { + if (!target || target.kind === "local") return "local environment"; + return `SSH environment ${target.spec.username}@${target.spec.host}:${target.spec.port}`; +} + +export async function ensureAdapterExecutionTargetCommandResolvable( + command: string, + target: AdapterExecutionTarget | null | undefined, + cwd: string, + env: NodeJS.ProcessEnv, +) { + await ensureCommandResolvable(command, cwd, env, { + remoteExecution: adapterExecutionTargetToRemoteSpec(target), + }); +} + +export async function resolveAdapterExecutionTargetCommandForLogs( + command: string, + target: AdapterExecutionTarget | null | undefined, + cwd: string, + env: NodeJS.ProcessEnv, +): Promise { + return await resolveCommandForLogs(command, cwd, env, { + remoteExecution: adapterExecutionTargetToRemoteSpec(target), + }); +} + +export async function runAdapterExecutionTargetProcess( + runId: string, + target: AdapterExecutionTarget | null | undefined, + command: string, + args: string[], + options: AdapterExecutionTargetProcessOptions, +): Promise { + return await runChildProcess(runId, command, args, { + cwd: options.cwd, + env: options.env, + stdin: options.stdin, + timeoutSec: options.timeoutSec, + graceSec: options.graceSec, + onLog: options.onLog, + onSpawn: options.onSpawn, + terminalResultCleanup: options.terminalResultCleanup, + remoteExecution: adapterExecutionTargetToRemoteSpec(target), + }); +} + +export async function runAdapterExecutionTargetShellCommand( + runId: string, + target: AdapterExecutionTarget | null | undefined, + command: string, + options: AdapterExecutionTargetShellOptions, +): Promise { + const onLog = options.onLog ?? (async () => {}); + if (target?.kind === "remote") { + const startedAt = new Date().toISOString(); + try { + const result = await runSshCommand(target.spec, `sh -lc ${shellQuote(command)}`, { + timeoutMs: (options.timeoutSec ?? 15) * 1000, + }); + if (result.stdout) await onLog("stdout", result.stdout); + if (result.stderr) await onLog("stderr", result.stderr); + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: result.stdout, + stderr: result.stderr, + pid: null, + startedAt, + }; + } catch (error) { + const timedOutError = error as NodeJS.ErrnoException & { + stdout?: string; + stderr?: string; + signal?: string | null; + }; + const stdout = timedOutError.stdout ?? ""; + const stderr = timedOutError.stderr ?? ""; + if (typeof timedOutError.code === "number") { + if (stdout) await onLog("stdout", stdout); + if (stderr) await onLog("stderr", stderr); + return { + exitCode: timedOutError.code, + signal: timedOutError.signal ?? null, + timedOut: false, + stdout, + stderr, + pid: null, + startedAt, + }; + } + if (timedOutError.code !== "ETIMEDOUT") { + throw error; + } + if (stdout) await onLog("stdout", stdout); + if (stderr) await onLog("stderr", stderr); + return { + exitCode: null, + signal: timedOutError.signal ?? null, + timedOut: true, + stdout, + stderr, + pid: null, + startedAt, + }; + } + } + + return await runAdapterExecutionTargetProcess( + runId, + target, + "sh", + ["-lc", command], + { + cwd: options.cwd, + env: options.env, + timeoutSec: options.timeoutSec ?? 15, + graceSec: options.graceSec ?? 5, + onLog, + }, + ); +} + +export async function readAdapterExecutionTargetHomeDir( + runId: string, + target: AdapterExecutionTarget | null | undefined, + options: AdapterExecutionTargetShellOptions, +): Promise { + const result = await runAdapterExecutionTargetShellCommand( + runId, + target, + 'printf %s "$HOME"', + options, + ); + const homeDir = result.stdout.trim(); + return homeDir.length > 0 ? homeDir : null; +} + +export async function ensureAdapterExecutionTargetFile( + runId: string, + target: AdapterExecutionTarget | null | undefined, + filePath: string, + options: AdapterExecutionTargetShellOptions, +): Promise { + await runAdapterExecutionTargetShellCommand( + runId, + target, + `mkdir -p ${shellQuote(path.posix.dirname(filePath))} && : > ${shellQuote(filePath)}`, + options, + ); +} + +export function adapterExecutionTargetSessionIdentity( + target: AdapterExecutionTarget | null | undefined, +): Record | null { + if (!target || target.kind === "local") return null; + return buildRemoteExecutionSessionIdentity(target.spec); +} + +export function adapterExecutionTargetSessionMatches( + saved: unknown, + target: AdapterExecutionTarget | null | undefined, +): boolean { + if (!target || target.kind === "local") { + return Object.keys(parseObject(saved)).length === 0; + } + return remoteExecutionSessionMatches(saved, target.spec); +} + +export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTarget | null { + const parsed = parseObject(value); + const kind = readStringMeta(parsed, "kind"); + + if (kind === "local") { + return { + kind: "local", + environmentId: readStringMeta(parsed, "environmentId"), + leaseId: readStringMeta(parsed, "leaseId"), + }; + } + + if (kind === "remote" && readStringMeta(parsed, "transport") === "ssh") { + const spec = parseSshRemoteExecutionSpec(parseObject(parsed.spec)); + if (!spec) return null; + return { + kind: "remote", + transport: "ssh", + environmentId: readStringMeta(parsed, "environmentId"), + leaseId: readStringMeta(parsed, "leaseId"), + remoteCwd: spec.remoteCwd, + paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl") ?? spec.paperclipApiUrl ?? null, + spec, + }; + } + + return null; +} + +export function adapterExecutionTargetFromRemoteExecution( + remoteExecution: unknown, + metadata: Pick = {}, +): AdapterExecutionTarget | null { + const parsed = parseObject(remoteExecution); + const ssh = parseSshRemoteExecutionSpec(parsed); + if (ssh) { + return { + kind: "remote", + transport: "ssh", + environmentId: metadata.environmentId ?? null, + leaseId: metadata.leaseId ?? null, + remoteCwd: ssh.remoteCwd, + paperclipApiUrl: ssh.paperclipApiUrl ?? null, + spec: ssh, + }; + } + + return null; +} + +export function readAdapterExecutionTarget(input: { + executionTarget?: unknown; + legacyRemoteExecution?: unknown; +}): AdapterExecutionTarget | null { + if (isAdapterExecutionTargetInstance(input.executionTarget)) { + return input.executionTarget; + } + return ( + parseAdapterExecutionTarget(input.executionTarget) ?? + adapterExecutionTargetFromRemoteExecution(input.legacyRemoteExecution) + ); +} + +export async function prepareAdapterExecutionTargetRuntime(input: { + target: AdapterExecutionTarget | null | undefined; + adapterKey: string; + workspaceLocalDir: string; + workspaceExclude?: string[]; + preserveAbsentOnRestore?: string[]; + assets?: AdapterManagedRuntimeAsset[]; + installCommand?: string | null; +}): Promise { + const target = input.target ?? { kind: "local" as const }; + if (target.kind === "local") { + return { + target, + runtimeRootDir: null, + assetDirs: {}, + restoreWorkspace: async () => {}, + }; + } + + const prepared = await prepareRemoteManagedRuntime({ + spec: target.spec, + adapterKey: input.adapterKey, + workspaceLocalDir: input.workspaceLocalDir, + assets: input.assets, + }); + return { + target, + runtimeRootDir: prepared.runtimeRootDir, + assetDirs: prepared.assetDirs, + restoreWorkspace: prepared.restoreWorkspace, + }; +} + +export function runtimeAssetDir( + prepared: Pick, + key: string, + fallbackRemoteCwd: string, +): string { + return prepared.assetDirs[key] ?? path.posix.join(fallbackRemoteCwd, ".paperclip-runtime", key); +} diff --git a/packages/adapter-utils/src/remote-managed-runtime.ts b/packages/adapter-utils/src/remote-managed-runtime.ts new file mode 100644 index 0000000000..089ff46aba --- /dev/null +++ b/packages/adapter-utils/src/remote-managed-runtime.ts @@ -0,0 +1,118 @@ +import path from "node:path"; +import { + type SshRemoteExecutionSpec, + prepareWorkspaceForSshExecution, + restoreWorkspaceFromSshExecution, + syncDirectoryToSsh, +} from "./ssh.js"; + +export interface RemoteManagedRuntimeAsset { + key: string; + localDir: string; + followSymlinks?: boolean; + exclude?: string[]; +} + +export interface PreparedRemoteManagedRuntime { + spec: SshRemoteExecutionSpec; + workspaceLocalDir: string; + workspaceRemoteDir: string; + runtimeRootDir: string; + assetDirs: Record; + restoreWorkspace(): Promise; +} + +function asObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function asNumber(value: unknown): number { + return typeof value === "number" ? value : Number(value); +} + +export function buildRemoteExecutionSessionIdentity(spec: SshRemoteExecutionSpec | null) { + if (!spec) return null; + return { + transport: "ssh", + host: spec.host, + port: spec.port, + username: spec.username, + remoteCwd: spec.remoteCwd, + ...(spec.paperclipApiUrl ? { paperclipApiUrl: spec.paperclipApiUrl } : {}), + } as const; +} + +export function remoteExecutionSessionMatches(saved: unknown, current: SshRemoteExecutionSpec | null): boolean { + const currentIdentity = buildRemoteExecutionSessionIdentity(current); + if (!currentIdentity) return false; + + const parsedSaved = asObject(saved); + return ( + asString(parsedSaved.transport) === currentIdentity.transport && + asString(parsedSaved.host) === currentIdentity.host && + asNumber(parsedSaved.port) === currentIdentity.port && + asString(parsedSaved.username) === currentIdentity.username && + asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd && + asString(parsedSaved.paperclipApiUrl) === asString(currentIdentity.paperclipApiUrl) + ); +} + +export async function prepareRemoteManagedRuntime(input: { + spec: SshRemoteExecutionSpec; + adapterKey: string; + workspaceLocalDir: string; + workspaceRemoteDir?: string; + assets?: RemoteManagedRuntimeAsset[]; +}): Promise { + const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd; + const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey); + + await prepareWorkspaceForSshExecution({ + spec: input.spec, + localDir: input.workspaceLocalDir, + remoteDir: workspaceRemoteDir, + }); + + const assetDirs: Record = {}; + try { + for (const asset of input.assets ?? []) { + const remoteDir = path.posix.join(runtimeRootDir, asset.key); + assetDirs[asset.key] = remoteDir; + await syncDirectoryToSsh({ + spec: input.spec, + localDir: asset.localDir, + remoteDir, + followSymlinks: asset.followSymlinks, + exclude: asset.exclude, + }); + } + } catch (error) { + await restoreWorkspaceFromSshExecution({ + spec: input.spec, + localDir: input.workspaceLocalDir, + remoteDir: workspaceRemoteDir, + }); + throw error; + } + + return { + spec: input.spec, + workspaceLocalDir: input.workspaceLocalDir, + workspaceRemoteDir, + runtimeRootDir, + assetDirs, + restoreWorkspace: async () => { + await restoreWorkspaceFromSshExecution({ + spec: input.spec, + localDir: input.workspaceLocalDir, + remoteDir: workspaceRemoteDir, + }); + }, + }; +} diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 4ec8f3e0d8..9e40d4ae39 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { constants as fsConstants, promises as fs, type Dirent } from "node:fs"; import path from "node:path"; +import { buildSshSpawnTarget, type SshRemoteExecutionSpec } from "./ssh.js"; import type { AdapterSkillEntry, AdapterSkillSnapshot, @@ -30,8 +31,12 @@ interface RunningProcess { interface SpawnTarget { command: string; args: string[]; + cwd?: string; + cleanup?: () => Promise; } +type RemoteExecutionSpec = SshRemoteExecutionSpec; + type ChildProcessWithEvents = ChildProcess & { on(event: "error", listener: (err: Error) => void): ChildProcess; on( @@ -806,11 +811,26 @@ export function buildPaperclipEnv(agent: { id: string; companyId: string }): Rec process.env.PAPERCLIP_LISTEN_HOST ?? process.env.HOST ?? "localhost", ); const runtimePort = process.env.PAPERCLIP_LISTEN_PORT ?? process.env.PORT ?? "3100"; - const apiUrl = process.env.PAPERCLIP_API_URL ?? `http://${runtimeHost}:${runtimePort}`; + const apiUrl = + process.env.PAPERCLIP_RUNTIME_API_URL ?? + process.env.PAPERCLIP_API_URL ?? + `http://${runtimeHost}:${runtimePort}`; vars.PAPERCLIP_API_URL = apiUrl; return vars; } +export function sanitizeInheritedPaperclipEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...baseEnv }; + for (const key of Object.keys(env)) { + if (!key.startsWith("PAPERCLIP_")) continue; + if (key === "PAPERCLIP_RUNTIME_API_URL") continue; + if (key === "PAPERCLIP_LISTEN_HOST") continue; + if (key === "PAPERCLIP_LISTEN_PORT") continue; + delete env[key]; + } + return env; +} + export function defaultPathForPlatform() { if (process.platform === "win32") { return "C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\Wbem"; @@ -859,7 +879,18 @@ async function resolveCommandPath(command: string, cwd: string, env: NodeJS.Proc return null; } -export async function resolveCommandForLogs(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise { +export async function resolveCommandForLogs( + command: string, + cwd: string, + env: NodeJS.ProcessEnv, + options: { + remoteExecution?: RemoteExecutionSpec | null; + } = {}, +): Promise { + const remote = options.remoteExecution ?? null; + if (remote) { + return `ssh://${remote.username}@${remote.host}:${remote.port}/${remote.remoteCwd} :: ${command}`; + } return (await resolveCommandPath(command, cwd, env)) ?? command; } @@ -879,7 +910,33 @@ async function resolveSpawnTarget( args: string[], cwd: string, env: NodeJS.ProcessEnv, + options: { + remoteExecution?: RemoteExecutionSpec | null; + remoteEnv?: Record | null; + } = {}, ): Promise { + const remote = options.remoteExecution ?? null; + if (remote) { + const sshResolved = await resolveCommandPath("ssh", process.cwd(), env); + if (!sshResolved) { + throw new Error('Command not found in PATH: "ssh"'); + } + const spawnTarget = await buildSshSpawnTarget({ + spec: remote, + command, + args, + env: Object.fromEntries( + Object.entries(options.remoteEnv ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ), + }); + return { + command: sshResolved, + args: spawnTarget.args, + cwd: process.cwd(), + cleanup: spawnTarget.cleanup, + }; + } + const resolved = await resolveCommandPath(command, cwd, env); const executable = resolved ?? command; @@ -1306,7 +1363,19 @@ export async function removeMaintainerOnlySkillSymlinks( } } -export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) { +export async function ensureCommandResolvable( + command: string, + cwd: string, + env: NodeJS.ProcessEnv, + options: { + remoteExecution?: RemoteExecutionSpec | null; + } = {}, +) { + if (options.remoteExecution) { + const resolvedSsh = await resolveCommandPath("ssh", process.cwd(), env); + if (resolvedSsh) return; + throw new Error('Command not found in PATH: "ssh"'); + } const resolved = await resolveCommandPath(command, cwd, env); if (resolved) return; if (command.includes("/") || command.includes("\\")) { @@ -1330,12 +1399,15 @@ export async function runChildProcess( onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise; terminalResultCleanup?: TerminalResultCleanupOptions; stdin?: string; + remoteExecution?: RemoteExecutionSpec | null; }, ): Promise { const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg)); - return new Promise((resolve, reject) => { - const rawMerged: NodeJS.ProcessEnv = { ...process.env, ...opts.env }; + const rawMerged: NodeJS.ProcessEnv = { + ...sanitizeInheritedPaperclipEnv(process.env), + ...opts.env, + }; // Strip Claude Code nesting-guard env vars so spawned `claude` processes // don't refuse to start with "cannot be launched inside another session". @@ -1353,10 +1425,13 @@ export async function runChildProcess( } const mergedEnv = ensurePathInEnv(rawMerged); - void resolveSpawnTarget(command, args, opts.cwd, mergedEnv) + void resolveSpawnTarget(command, args, opts.cwd, mergedEnv, { + remoteExecution: opts.remoteExecution ?? null, + remoteEnv: opts.remoteExecution ? opts.env : null, + }) .then((target) => { const child = spawn(target.command, target.args, { - cwd: opts.cwd, + cwd: target.cwd ?? opts.cwd, env: mergedEnv, detached: process.platform !== "win32", shell: false, @@ -1484,6 +1559,7 @@ export async function runChildProcess( if (timeout) clearTimeout(timeout); clearTerminalCleanupTimers(); runningProcesses.delete(runId); + void target.cleanup?.(); const errno = (err as NodeJS.ErrnoException).code; const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? ""; const msg = @@ -1502,15 +1578,19 @@ export async function runChildProcess( clearTerminalCleanupTimers(); runningProcesses.delete(runId); void logChain.finally(() => { - resolve({ - exitCode: code, - signal, - timedOut, - stdout, - stderr, - pid: child.pid ?? null, - startedAt, - }); + void Promise.resolve() + .then(() => target.cleanup?.()) + .finally(() => { + resolve({ + exitCode: code, + signal, + timedOut, + stdout, + stderr, + pid: child.pid ?? null, + startedAt, + }); + }); }); }); }) diff --git a/packages/adapter-utils/src/ssh-fixture.test.ts b/packages/adapter-utils/src/ssh-fixture.test.ts new file mode 100644 index 0000000000..dd1ae28ae4 --- /dev/null +++ b/packages/adapter-utils/src/ssh-fixture.test.ts @@ -0,0 +1,275 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildSshSpawnTarget, + buildSshEnvLabFixtureConfig, + getSshEnvLabSupport, + prepareWorkspaceForSshExecution, + readSshEnvLabFixtureStatus, + restoreWorkspaceFromSshExecution, + runSshCommand, + syncDirectoryToSsh, + startSshEnvLabFixture, + stopSshEnvLabFixture, +} from "./ssh.js"; + +async function git(cwd: string, args: string[]): Promise { + return await new Promise((resolve, reject) => { + execFile("git", ["-C", cwd, ...args], (error, stdout, stderr) => { + if (error) { + reject(new Error((stderr || stdout || error.message).trim())); + return; + } + resolve(stdout.trim()); + }); + }); +} + +describe("ssh env-lab fixture", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("starts an isolated sshd fixture and executes commands through it", async () => { + const support = await getSshEnvLabSupport(); + if (!support.supported) { + console.warn( + `Skipping SSH env-lab fixture test: ${support.reason ?? "unsupported environment"}`, + ); + return; + } + + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-")); + cleanupDirs.push(rootDir); + const statePath = path.join(rootDir, "state.json"); + + const started = await startSshEnvLabFixture({ statePath }); + const config = await buildSshEnvLabFixtureConfig(started); + const quotedWorkspace = JSON.stringify(started.workspaceDir); + const result = await runSshCommand( + config, + `sh -lc 'cd ${quotedWorkspace} && pwd'`, + ); + + expect(result.stdout.trim()).toBe(started.workspaceDir); + const status = await readSshEnvLabFixtureStatus(statePath); + expect(status.running).toBe(true); + + await stopSshEnvLabFixture(statePath); + + const stopped = await readSshEnvLabFixtureStatus(statePath); + expect(stopped.running).toBe(false); + }); + + it("does not treat an unrelated reused pid as the running fixture", async () => { + const support = await getSshEnvLabSupport(); + if (!support.supported) { + console.warn( + `Skipping SSH env-lab fixture test: ${support.reason ?? "unsupported environment"}`, + ); + return; + } + + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-")); + cleanupDirs.push(rootDir); + const statePath = path.join(rootDir, "state.json"); + + const started = await startSshEnvLabFixture({ statePath }); + await stopSshEnvLabFixture(statePath); + await mkdir(path.dirname(statePath), { recursive: true }); + + await writeFile( + statePath, + JSON.stringify({ ...started, pid: process.pid }, null, 2), + { mode: 0o600 }, + ); + + const staleStatus = await readSshEnvLabFixtureStatus(statePath); + expect(staleStatus.running).toBe(false); + + const restarted = await startSshEnvLabFixture({ statePath }); + expect(restarted.pid).not.toBe(process.pid); + + await stopSshEnvLabFixture(statePath); + }); + + it("rejects invalid environment variable keys when constructing SSH spawn targets", async () => { + await expect( + buildSshSpawnTarget({ + spec: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteCwd: "/srv/paperclip/workspace", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + command: "env", + args: [], + env: { + "BAD KEY": "value", + }, + }), + ).rejects.toThrow("Invalid SSH environment variable key: BAD KEY"); + }); + + it("syncs a local directory into the remote fixture workspace", async () => { + const support = await getSshEnvLabSupport(); + if (!support.supported) { + console.warn( + `Skipping SSH env-lab fixture test: ${support.reason ?? "unsupported environment"}`, + ); + return; + } + + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-")); + cleanupDirs.push(rootDir); + const statePath = path.join(rootDir, "state.json"); + const localDir = path.join(rootDir, "local-overlay"); + + await mkdir(localDir, { recursive: true }); + await writeFile(path.join(localDir, "message.txt"), "hello from paperclip\n", "utf8"); + await writeFile(path.join(localDir, "._message.txt"), "should never sync\n", "utf8"); + + const started = await startSshEnvLabFixture({ statePath }); + const config = await buildSshEnvLabFixtureConfig(started); + const remoteDir = path.posix.join(started.workspaceDir, "overlay"); + + await syncDirectoryToSsh({ + spec: { + ...config, + remoteCwd: started.workspaceDir, + }, + localDir, + remoteDir, + }); + + const result = await runSshCommand( + config, + `sh -lc 'cat ${JSON.stringify(path.posix.join(remoteDir, "message.txt"))} && if [ -e ${JSON.stringify(path.posix.join(remoteDir, "._message.txt"))} ]; then echo appledouble-present; fi'`, + ); + + expect(result.stdout).toContain("hello from paperclip"); + expect(result.stdout).not.toContain("appledouble-present"); + }); + + it("can dereference local symlinks while syncing to the remote fixture", async () => { + const support = await getSshEnvLabSupport(); + if (!support.supported) { + console.warn( + `Skipping SSH symlink sync test: ${support.reason ?? "unsupported environment"}`, + ); + return; + } + + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-")); + cleanupDirs.push(rootDir); + const statePath = path.join(rootDir, "state.json"); + const sourceDir = path.join(rootDir, "source"); + const localDir = path.join(rootDir, "local-overlay"); + + await mkdir(sourceDir, { recursive: true }); + await mkdir(localDir, { recursive: true }); + await writeFile(path.join(sourceDir, "auth.json"), "{\"token\":\"secret\"}\n", "utf8"); + await symlink(path.join(sourceDir, "auth.json"), path.join(localDir, "auth.json")); + + const started = await startSshEnvLabFixture({ statePath }); + const config = await buildSshEnvLabFixtureConfig(started); + const remoteDir = path.posix.join(started.workspaceDir, "overlay-follow-links"); + + await syncDirectoryToSsh({ + spec: { + ...config, + remoteCwd: started.workspaceDir, + }, + localDir, + remoteDir, + followSymlinks: true, + }); + + const result = await runSshCommand( + config, + `sh -lc 'if [ -L ${JSON.stringify(path.posix.join(remoteDir, "auth.json"))} ]; then echo symlink; else echo regular; fi && cat ${JSON.stringify(path.posix.join(remoteDir, "auth.json"))}'`, + ); + + expect(result.stdout).toContain("regular"); + expect(result.stdout).toContain("{\"token\":\"secret\"}"); + }); + + it("round-trips a git workspace through the SSH fixture", async () => { + const support = await getSshEnvLabSupport(); + if (!support.supported) { + console.warn( + `Skipping SSH workspace round-trip test: ${support.reason ?? "unsupported environment"}`, + ); + return; + } + + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-")); + cleanupDirs.push(rootDir); + const statePath = path.join(rootDir, "state.json"); + const localRepo = path.join(rootDir, "local-workspace"); + + await mkdir(localRepo, { recursive: true }); + await git(localRepo, ["init", "-b", "main"]); + await git(localRepo, ["config", "user.name", "Paperclip Test"]); + await git(localRepo, ["config", "user.email", "test@paperclip.dev"]); + await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8"); + await writeFile(path.join(localRepo, "._tracked.txt"), "should stay local only\n", "utf8"); + await git(localRepo, ["add", "tracked.txt"]); + await git(localRepo, ["commit", "-m", "initial"]); + const originalHead = await git(localRepo, ["rev-parse", "HEAD"]); + await writeFile(path.join(localRepo, "tracked.txt"), "dirty local\n", "utf8"); + await writeFile(path.join(localRepo, "untracked.txt"), "from local\n", "utf8"); + + const started = await startSshEnvLabFixture({ statePath }); + const config = await buildSshEnvLabFixtureConfig(started); + const spec = { + ...config, + remoteCwd: started.workspaceDir, + } as const; + + await prepareWorkspaceForSshExecution({ + spec, + localDir: localRepo, + remoteDir: started.workspaceDir, + }); + + const remoteStatus = await runSshCommand( + config, + `sh -lc 'cd ${JSON.stringify(started.workspaceDir)} && git status --short'`, + ); + expect(remoteStatus.stdout).toContain("M tracked.txt"); + expect(remoteStatus.stdout).toContain("?? untracked.txt"); + expect(remoteStatus.stdout).not.toContain("._tracked.txt"); + + await runSshCommand( + config, + `sh -lc 'cd ${JSON.stringify(started.workspaceDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && git add tracked.txt untracked.txt && git commit -m "remote update" >/dev/null && printf "remote dirty\\n" > tracked.txt && printf "remote extra\\n" > remote-only.txt'`, + { timeoutMs: 30_000, maxBuffer: 256 * 1024 }, + ); + + await restoreWorkspaceFromSshExecution({ + spec, + localDir: localRepo, + remoteDir: started.workspaceDir, + }); + + const restoredHead = await git(localRepo, ["rev-parse", "HEAD"]); + expect(restoredHead).not.toBe(originalHead); + expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe("remote update"); + expect(await git(localRepo, ["status", "--short"])).toContain("M tracked.txt"); + expect(await git(localRepo, ["status", "--short"])).not.toContain("._tracked.txt"); + }); +}); diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts new file mode 100644 index 0000000000..461d36c15a --- /dev/null +++ b/packages/adapter-utils/src/ssh.ts @@ -0,0 +1,1233 @@ +import { execFile, spawn } from "node:child_process"; +import { constants as fsConstants, createReadStream, createWriteStream, promises as fs } from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +export interface SshConnectionConfig { + host: string; + port: number; + username: string; + remoteWorkspacePath: string; + privateKey: string | null; + knownHosts: string | null; + strictHostKeyChecking: boolean; +} + +export interface SshCommandResult { + stdout: string; + stderr: string; +} + +export interface SshRemoteExecutionSpec extends SshConnectionConfig { + remoteCwd: string; + paperclipApiUrl?: string | null; +} + +export interface SshEnvLabSupport { + supported: boolean; + reason: string | null; +} + +export interface SshEnvLabFixtureState { + kind: "ssh_openbsd"; + bindHost: string; + host: string; + port: number; + username: string; + rootDir: string; + workspaceDir: string; + statePath: string; + pid: number; + createdAt: string; + clientPrivateKeyPath: string; + clientPublicKeyPath: string; + hostPrivateKeyPath: string; + hostPublicKeyPath: string; + authorizedKeysPath: string; + knownHostsPath: string; + sshdConfigPath: string; + sshdLogPath: string; +} + +interface LocalGitWorkspaceSnapshot { + headCommit: string; + branchName: string | null; + deletedPaths: string[]; +} + +export function shellQuote(value: string) { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + +function isValidShellEnvKey(value: string) { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); +} + +export function parseSshRemoteExecutionSpec(value: unknown): SshRemoteExecutionSpec | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const parsed = value as Record; + const host = typeof parsed.host === "string" ? parsed.host.trim() : ""; + const username = typeof parsed.username === "string" ? parsed.username.trim() : ""; + const remoteCwd = typeof parsed.remoteCwd === "string" ? parsed.remoteCwd.trim() : ""; + const portValue = typeof parsed.port === "number" ? parsed.port : Number(parsed.port); + if (!host || !username || !remoteCwd || !Number.isInteger(portValue) || portValue < 1 || portValue > 65535) { + return null; + } + + return { + host, + port: portValue, + username, + remoteCwd, + paperclipApiUrl: + typeof parsed.paperclipApiUrl === "string" && parsed.paperclipApiUrl.trim().length > 0 + ? parsed.paperclipApiUrl.trim() + : null, + remoteWorkspacePath: + typeof parsed.remoteWorkspacePath === "string" && parsed.remoteWorkspacePath.trim().length > 0 + ? parsed.remoteWorkspacePath.trim() + : remoteCwd, + privateKey: typeof parsed.privateKey === "string" && parsed.privateKey.length > 0 ? parsed.privateKey : null, + knownHosts: typeof parsed.knownHosts === "string" && parsed.knownHosts.length > 0 ? parsed.knownHosts : null, + strictHostKeyChecking: + typeof parsed.strictHostKeyChecking === "boolean" ? parsed.strictHostKeyChecking : true, + }; +} + +function normalizeHttpUrlCandidate(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return null; + } + return parsed.origin; + } catch { + return null; + } +} + +export async function findReachablePaperclipApiUrlOverSsh(input: { + config: SshConnectionConfig; + candidates: string[]; + timeoutMs?: number; +}): Promise { + const uniqueCandidates = Array.from( + new Set( + input.candidates + .map((candidate) => normalizeHttpUrlCandidate(candidate)) + .filter((candidate): candidate is string => candidate !== null), + ), + ); + + for (const candidate of uniqueCandidates) { + const healthUrl = new URL("/api/health", candidate).toString(); + try { + await runSshCommand( + input.config, + `sh -lc ${shellQuote(`curl -fsS -m ${Math.max(1, Math.ceil((input.timeoutMs ?? 5_000) / 1000))} ${shellQuote(healthUrl)} >/dev/null`)}`, + { timeoutMs: input.timeoutMs ?? 5_000 }, + ); + return candidate; + } catch { + continue; + } + } + + return null; +} + +async function execFileText( + file: string, + args: string[], + options: { + timeout?: number; + maxBuffer?: number; + } = {}, +): Promise { + return await new Promise((resolve, reject) => { + execFile( + file, + args, + { + timeout: options.timeout ?? 15_000, + maxBuffer: options.maxBuffer ?? 1024 * 128, + }, + (error, stdout, stderr) => { + if (error) { + reject(Object.assign(error, { stdout: stdout ?? "", stderr: stderr ?? "" })); + return; + } + resolve({ + stdout: stdout ?? "", + stderr: stderr ?? "", + }); + }, + ); + }); +} + +async function runLocalGit( + localDir: string, + args: string[], + options: { + timeout?: number; + maxBuffer?: number; + } = {}, +): Promise { + return await execFileText("git", ["-C", localDir, ...args], options); +} + +async function commandExists(command: string): Promise { + return (await resolveCommandPath(command)) !== null; +} + +async function resolveCommandPath(command: string): Promise { + try { + const result = await execFileText("sh", ["-lc", `command -v ${shellQuote(command)}`], { + timeout: 5_000, + maxBuffer: 8 * 1024, + }); + const resolved = result.stdout.trim().split("\n")[0]?.trim() ?? ""; + return resolved.length > 0 ? resolved : null; + } catch { + return null; + } +} + +async function withTempFile( + prefix: string, + contents: string, + mode: number, +): Promise<{ path: string; cleanup: () => Promise }> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + const filePath = path.join(dir, "payload"); + const normalizedContents = contents.endsWith("\n") ? contents : `${contents}\n`; + await fs.writeFile(filePath, normalizedContents, { mode, encoding: "utf8" }); + return { + path: filePath, + cleanup: async () => { + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + }, + }; +} + +async function createSshAuthArgs( + config: Pick, +): Promise<{ args: string[]; cleanup: () => Promise }> { + const tempFiles: Array<() => Promise> = []; + const sshArgs = [ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + `StrictHostKeyChecking=${config.strictHostKeyChecking ? "yes" : "no"}`, + ]; + + if (config.strictHostKeyChecking) { + if (config.knownHosts) { + const knownHosts = await withTempFile("paperclip-ssh-known-hosts-", config.knownHosts, 0o600); + tempFiles.push(knownHosts.cleanup); + sshArgs.push("-o", `UserKnownHostsFile=${knownHosts.path}`); + } + } else { + sshArgs.push("-o", "UserKnownHostsFile=/dev/null"); + } + + if (config.privateKey) { + const privateKey = await withTempFile("paperclip-ssh-key-", config.privateKey, 0o600); + tempFiles.push(privateKey.cleanup); + sshArgs.push("-i", privateKey.path); + } + + return { + args: sshArgs, + cleanup: async () => { + await Promise.all(tempFiles.map((cleanup) => cleanup())); + }, + }; +} + +function tarExcludeArgs(exclude: string[] | undefined): string[] { + const combined = ["._*", ...(exclude ?? [])]; + return combined.flatMap((entry) => ["--exclude", entry]); +} + +function tarSpawnEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + // Prevent macOS bsdtar from emitting AppleDouble metadata files like ._README.md. + COPYFILE_DISABLE: "1", + }; +} + +async function runSshScript( + config: SshConnectionConfig, + script: string, + options: { + timeoutMs?: number; + maxBuffer?: number; + } = {}, +): Promise { + return await runSshCommand( + config, + `sh -lc ${shellQuote(script)}`, + options, + ); +} + +async function clearLocalDirectory( + localDir: string, + preserveEntries: string[] = [], +): Promise { + await fs.mkdir(localDir, { recursive: true }); + const preserve = new Set(preserveEntries); + const entries = await fs.readdir(localDir); + await Promise.all( + entries + .filter((entry) => !preserve.has(entry)) + .map((entry) => fs.rm(path.join(localDir, entry), { recursive: true, force: true })), + ); +} + +async function copyDirectoryContents(sourceDir: string, targetDir: string): Promise { + await fs.mkdir(targetDir, { recursive: true }); + const entries = await fs.readdir(sourceDir); + await Promise.all(entries.map(async (entry) => { + await fs.cp(path.join(sourceDir, entry), path.join(targetDir, entry), { + recursive: true, + force: true, + preserveTimestamps: true, + }); + })); +} + +async function readLocalGitWorkspaceSnapshot(localDir: string): Promise { + try { + const insideWorkTree = await runLocalGit(localDir, ["rev-parse", "--is-inside-work-tree"], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }); + if (insideWorkTree.stdout.trim() !== "true") { + return null; + } + + const [headCommitResult, branchResult, deletedResult] = await Promise.all([ + runLocalGit(localDir, ["rev-parse", "HEAD"], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }), + runLocalGit(localDir, ["rev-parse", "--abbrev-ref", "HEAD"], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }), + runLocalGit(localDir, ["ls-files", "--deleted", "-z"], { + timeout: 10_000, + maxBuffer: 256 * 1024, + }), + ]); + + const branchName = branchResult.stdout.trim(); + return { + headCommit: headCommitResult.stdout.trim(), + branchName: branchName && branchName !== "HEAD" ? branchName : null, + deletedPaths: deletedResult.stdout + .split("\0") + .map((entry) => entry.trim()) + .filter(Boolean), + }; + } catch { + return null; + } +} + +async function streamLocalFileToSsh(input: { + spec: SshConnectionConfig; + localFile: string; + remoteScript: string; +}): Promise { + const auth = await createSshAuthArgs(input.spec); + const sshArgs = [ + ...auth.args, + "-p", + String(input.spec.port), + `${input.spec.username}@${input.spec.host}`, + `sh -lc ${shellQuote(input.remoteScript)}`, + ]; + + await new Promise((resolve, reject) => { + const source = createReadStream(input.localFile); + const ssh = spawn("ssh", sshArgs, { + stdio: ["pipe", "ignore", "pipe"], + }); + + let sshStderr = ""; + let settled = false; + + const fail = (error: Error) => { + if (settled) return; + settled = true; + source.destroy(); + ssh.kill("SIGTERM"); + reject(error); + }; + + ssh.stderr?.on("data", (chunk) => { + sshStderr += String(chunk); + }); + source.on("error", fail); + ssh.on("error", fail); + source.pipe(ssh.stdin ?? null); + ssh.on("close", (code) => { + if (settled) return; + settled = true; + if ((code ?? 0) !== 0) { + reject(new Error(sshStderr.trim() || `ssh exited with code ${code ?? -1}`)); + return; + } + resolve(); + }); + }).finally(auth.cleanup); +} + +async function streamSshToLocalFile(input: { + spec: SshConnectionConfig; + remoteScript: string; + localFile: string; +}): Promise { + const auth = await createSshAuthArgs(input.spec); + const sshArgs = [ + ...auth.args, + "-p", + String(input.spec.port), + `${input.spec.username}@${input.spec.host}`, + `sh -lc ${shellQuote(input.remoteScript)}`, + ]; + + await new Promise((resolve, reject) => { + const ssh = spawn("ssh", sshArgs, { + stdio: ["ignore", "pipe", "pipe"], + }); + const sink = createWriteStream(input.localFile, { mode: 0o600 }); + + let sshStderr = ""; + let settled = false; + + const fail = (error: Error) => { + if (settled) return; + settled = true; + ssh.kill("SIGTERM"); + sink.destroy(); + reject(error); + }; + + ssh.stdout?.pipe(sink); + ssh.stderr?.on("data", (chunk) => { + sshStderr += String(chunk); + }); + ssh.on("error", fail); + sink.on("error", fail); + ssh.on("close", (code) => { + sink.end(() => { + if (settled) return; + settled = true; + if ((code ?? 0) !== 0) { + reject(new Error(sshStderr.trim() || `ssh exited with code ${code ?? -1}`)); + return; + } + resolve(); + }); + }); + }).finally(auth.cleanup); +} + +async function importGitWorkspaceToSsh(input: { + spec: SshRemoteExecutionSpec; + localDir: string; + remoteDir: string; + snapshot: LocalGitWorkspaceSnapshot; +}): Promise { + const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-bundle-")); + const bundlePath = path.join(bundleDir, "workspace.bundle"); + const tempRef = "refs/paperclip/ssh-sync/import"; + + try { + await runLocalGit(input.localDir, ["update-ref", tempRef, input.snapshot.headCommit], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }); + await runLocalGit(input.localDir, ["bundle", "create", bundlePath, tempRef], { + timeout: 60_000, + maxBuffer: 1024 * 1024, + }); + + const remoteSetupScript = [ + "set -e", + `mkdir -p ${shellQuote(path.posix.join(input.remoteDir, ".paperclip-runtime"))}`, + `tmp_bundle=$(mktemp ${shellQuote(path.posix.join(input.remoteDir, ".paperclip-runtime", "import-XXXXXX.bundle"))})`, + 'trap \'rm -f "$tmp_bundle"\' EXIT', + 'cat > "$tmp_bundle"', + `if [ ! -d ${shellQuote(path.posix.join(input.remoteDir, ".git"))} ]; then git init ${shellQuote(input.remoteDir)} >/dev/null; fi`, + `git -C ${shellQuote(input.remoteDir)} fetch --force "$tmp_bundle" '${tempRef}:${tempRef}' >/dev/null`, + input.snapshot.branchName + ? `git -C ${shellQuote(input.remoteDir)} checkout -B ${shellQuote(input.snapshot.branchName)} ${shellQuote(input.snapshot.headCommit)} >/dev/null` + : `git -C ${shellQuote(input.remoteDir)} -c advice.detachedHead=false checkout --detach ${shellQuote(input.snapshot.headCommit)} >/dev/null`, + `git -C ${shellQuote(input.remoteDir)} reset --hard ${shellQuote(input.snapshot.headCommit)} >/dev/null`, + `git -C ${shellQuote(input.remoteDir)} clean -fdx -e .paperclip-runtime >/dev/null`, + ].join("\n"); + + await streamLocalFileToSsh({ + spec: input.spec, + localFile: bundlePath, + remoteScript: remoteSetupScript, + }); + } finally { + await runLocalGit(input.localDir, ["update-ref", "-d", tempRef], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }).catch(() => undefined); + await fs.rm(bundleDir, { recursive: true, force: true }).catch(() => undefined); + } +} + +async function exportGitWorkspaceFromSsh(input: { + spec: SshRemoteExecutionSpec; + remoteDir: string; + localDir: string; +}): Promise { + const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-bundle-")); + const bundlePath = path.join(bundleDir, "workspace.bundle"); + const importedRef = "refs/paperclip/ssh-sync/imported"; + + try { + const exportScript = [ + "set -e", + `git -C ${shellQuote(input.remoteDir)} update-ref refs/paperclip/ssh-sync/export HEAD`, + `mkdir -p ${shellQuote(path.posix.join(input.remoteDir, ".paperclip-runtime"))}`, + `tmp_bundle=$(mktemp ${shellQuote(path.posix.join(input.remoteDir, ".paperclip-runtime", "export-XXXXXX.bundle"))})`, + 'cleanup() { rm -f "$tmp_bundle"; git -C ' + shellQuote(input.remoteDir) + ' update-ref -d refs/paperclip/ssh-sync/export >/dev/null 2>&1 || true; }', + 'trap cleanup EXIT', + `git -C ${shellQuote(input.remoteDir)} bundle create "$tmp_bundle" refs/paperclip/ssh-sync/export >/dev/null`, + 'cat "$tmp_bundle"', + ].join("\n"); + + await streamSshToLocalFile({ + spec: input.spec, + remoteScript: exportScript, + localFile: bundlePath, + }); + + await runLocalGit(input.localDir, ["fetch", "--force", bundlePath, `refs/paperclip/ssh-sync/export:${importedRef}`], { + timeout: 60_000, + maxBuffer: 1024 * 1024, + }); + await runLocalGit(input.localDir, ["reset", "--hard", importedRef], { + timeout: 60_000, + maxBuffer: 1024 * 1024, + }); + } finally { + await runLocalGit(input.localDir, ["update-ref", "-d", importedRef], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }).catch(() => undefined); + await fs.rm(bundleDir, { recursive: true, force: true }).catch(() => undefined); + } +} + +async function clearRemoteDirectory(input: { + spec: SshConnectionConfig; + remoteDir: string; + preserveEntries?: string[]; +}): Promise { + const preservePatterns = (input.preserveEntries ?? []) + .map((entry) => `! -name ${shellQuote(entry)}`) + .join(" "); + const script = [ + "set -e", + `mkdir -p ${shellQuote(input.remoteDir)}`, + `find ${shellQuote(input.remoteDir)} -mindepth 1 -maxdepth 1 ${preservePatterns} -exec rm -rf -- {} +`, + ].join("\n"); + await runSshScript(input.spec, script, { + timeoutMs: 30_000, + maxBuffer: 256 * 1024, + }); +} + +async function removeDeletedPathsOnSsh(input: { + spec: SshConnectionConfig; + remoteDir: string; + deletedPaths: string[]; +}): Promise { + if (input.deletedPaths.length === 0) return; + const quotedPaths = input.deletedPaths.map((entry) => shellQuote(entry)).join(" "); + const script = `cd ${shellQuote(input.remoteDir)} && rm -rf -- ${quotedPaths}`; + await runSshScript(input.spec, script, { + timeoutMs: 30_000, + maxBuffer: 256 * 1024, + }); +} + +async function allocateLoopbackPort(host: string): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, host, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate a loopback port."))); + return; + } + const { port } = address; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + }); +} + +async function waitForCondition( + fn: () => Promise, + options: { + timeoutMs?: number; + intervalMs?: number; + } = {}, +): Promise { + const timeoutAt = Date.now() + (options.timeoutMs ?? 10_000); + const intervalMs = options.intervalMs ?? 200; + let lastError: unknown = null; + while (Date.now() < timeoutAt) { + try { + await fn(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + } + throw lastError instanceof Error + ? lastError + : new Error("Timed out waiting for SSH fixture readiness."); +} + +async function isPidRunning(pid: number): Promise { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function readProcessCommand(pid: number): Promise { + for (const format of ["command=", "args="]) { + try { + const result = await execFileText("ps", ["-o", format, "-p", String(pid)], { + timeout: 5_000, + maxBuffer: 16 * 1024, + }); + const command = result.stdout.trim(); + if (command.length > 0) { + return command; + } + } catch { + continue; + } + } + + return null; +} + +async function isSshEnvLabFixtureProcess(state: Pick): Promise { + if (!(await isPidRunning(state.pid))) { + return false; + } + + const command = await readProcessCommand(state.pid); + if (!command) { + return false; + } + + return command.includes(state.sshdConfigPath); +} + +export async function getSshEnvLabSupport(): Promise { + for (const command of ["ssh", "sshd", "ssh-keygen"]) { + if (!(await commandExists(command))) { + return { + supported: false, + reason: `Missing required command: ${command}`, + }; + } + } + + return { + supported: true, + reason: null, + }; +} + +export function buildKnownHostsEntry(input: { + host: string; + port: number; + publicKey: string; +}): string { + return `[${input.host}]:${input.port} ${input.publicKey.trim()}`; +} + +export async function runSshCommand( + config: SshConnectionConfig, + remoteCommand: string, + options: { + timeoutMs?: number; + maxBuffer?: number; + } = {}, +): Promise { + let cleanup: () => Promise = () => Promise.resolve(); + try { + const auth = await createSshAuthArgs(config); + cleanup = auth.cleanup; + const sshArgs = [...auth.args]; + + sshArgs.push( + "-p", + String(config.port), + `${config.username}@${config.host}`, + remoteCommand, + ); + + return await execFileText("ssh", sshArgs, { + timeout: options.timeoutMs ?? 15_000, + maxBuffer: options.maxBuffer ?? 1024 * 128, + }); + } finally { + await cleanup(); + } +} + +export async function buildSshSpawnTarget(input: { + spec: SshRemoteExecutionSpec; + command: string; + args: string[]; + env: Record; +}): Promise<{ + command: string; + args: string[]; + cleanup: () => Promise; +}> { + for (const key of Object.keys(input.env)) { + if (!isValidShellEnvKey(key)) { + throw new Error(`Invalid SSH environment variable key: ${key}`); + } + } + const auth = await createSshAuthArgs(input.spec); + const sshArgs = [...auth.args]; + const envArgs = Object.entries(input.env) + .filter((entry): entry is [string, string] => typeof entry[1] === "string") + .map(([key, value]) => `${key}=${shellQuote(value)}`); + const remoteCommandParts = [shellQuote(input.command), ...input.args.map((arg) => shellQuote(arg))].join(" "); + const remoteScript = [ + 'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi', + 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; fi', + 'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi', + 'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"', + '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true', + `cd ${shellQuote(input.spec.remoteCwd)}`, + envArgs.length > 0 + ? `exec env ${envArgs.join(" ")} ${remoteCommandParts}` + : `exec ${remoteCommandParts}`, + ].join(" && "); + + sshArgs.push( + "-p", + String(input.spec.port), + `${input.spec.username}@${input.spec.host}`, + `sh -lc ${shellQuote(remoteScript)}`, + ); + + return { + command: "ssh", + args: sshArgs, + cleanup: auth.cleanup, + }; +} + +export async function syncDirectoryToSsh(input: { + spec: SshRemoteExecutionSpec; + localDir: string; + remoteDir: string; + exclude?: string[]; + followSymlinks?: boolean; +}): Promise { + const auth = await createSshAuthArgs(input.spec); + const sshArgs = [ + ...auth.args, + "-p", + String(input.spec.port), + `${input.spec.username}@${input.spec.host}`, + `sh -lc ${shellQuote(`mkdir -p ${shellQuote(input.remoteDir)} && tar -xf - -C ${shellQuote(input.remoteDir)}`)}`, + ]; + + await new Promise((resolve, reject) => { + const tarArgs = [ + ...(input.followSymlinks ? ["-h"] : []), + "-C", + input.localDir, + ...tarExcludeArgs(input.exclude), + "-cf", + "-", + ".", + ]; + const tar = spawn("tar", tarArgs, { + stdio: ["ignore", "pipe", "pipe"], + env: tarSpawnEnv(), + }); + const ssh = spawn("ssh", sshArgs, { + stdio: ["pipe", "ignore", "pipe"], + }); + + let tarStderr = ""; + let sshStderr = ""; + let settled = false; + let tarExited = false; + let sshExited = false; + let tarExitCode: number | null = null; + let sshExitCode: number | null = null; + + const maybeFinish = () => { + if (settled || !tarExited || !sshExited) { + return; + } + settled = true; + if ((tarExitCode ?? 0) !== 0) { + reject(new Error(tarStderr.trim() || `tar exited with code ${tarExitCode ?? -1}`)); + return; + } + if ((sshExitCode ?? 0) !== 0) { + reject(new Error(sshStderr.trim() || `ssh exited with code ${sshExitCode ?? -1}`)); + return; + } + resolve(); + }; + + const fail = (error: Error) => { + if (settled) { + return; + } + settled = true; + tar.kill("SIGTERM"); + ssh.kill("SIGTERM"); + reject(error); + }; + + tar.stdout?.pipe(ssh.stdin ?? null); + tar.stderr?.on("data", (chunk) => { + tarStderr += String(chunk); + }); + ssh.stderr?.on("data", (chunk) => { + sshStderr += String(chunk); + }); + + tar.on("error", fail); + ssh.on("error", fail); + tar.on("close", (code) => { + tarExited = true; + tarExitCode = code; + maybeFinish(); + }); + ssh.on("close", (code) => { + sshExited = true; + sshExitCode = code; + maybeFinish(); + }); + }).finally(auth.cleanup); +} + +export async function syncDirectoryFromSsh(input: { + spec: SshRemoteExecutionSpec; + remoteDir: string; + localDir: string; + exclude?: string[]; + preserveLocalEntries?: string[]; +}): Promise { + const auth = await createSshAuthArgs(input.spec); + const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-sync-back-")); + const remoteTarScript = [ + `cd ${shellQuote(input.remoteDir)}`, + `tar ${[...tarExcludeArgs(input.exclude).map(shellQuote), "-cf", "-", "."].join(" ")}`, + ].join(" && "); + const sshArgs = [ + ...auth.args, + "-p", + String(input.spec.port), + `${input.spec.username}@${input.spec.host}`, + `sh -lc ${shellQuote(remoteTarScript)}`, + ]; + + try { + await new Promise((resolve, reject) => { + const ssh = spawn("ssh", sshArgs, { + stdio: ["ignore", "pipe", "pipe"], + }); + const tar = spawn("tar", ["-xf", "-", "-C", stagingDir], { + stdio: ["pipe", "ignore", "pipe"], + env: tarSpawnEnv(), + }); + + let sshStderr = ""; + let tarStderr = ""; + let settled = false; + let sshExited = false; + let tarExited = false; + let sshExitCode: number | null = null; + let tarExitCode: number | null = null; + + const maybeFinish = () => { + if (settled || !sshExited || !tarExited) return; + settled = true; + if ((sshExitCode ?? 0) !== 0) { + reject(new Error(sshStderr.trim() || `ssh exited with code ${sshExitCode ?? -1}`)); + return; + } + if ((tarExitCode ?? 0) !== 0) { + reject(new Error(tarStderr.trim() || `tar exited with code ${tarExitCode ?? -1}`)); + return; + } + resolve(); + }; + + const fail = (error: Error) => { + if (settled) return; + settled = true; + ssh.kill("SIGTERM"); + tar.kill("SIGTERM"); + reject(error); + }; + + ssh.stdout?.pipe(tar.stdin ?? null); + ssh.stderr?.on("data", (chunk) => { + sshStderr += String(chunk); + }); + tar.stderr?.on("data", (chunk) => { + tarStderr += String(chunk); + }); + + ssh.on("error", fail); + tar.on("error", fail); + ssh.on("close", (code) => { + sshExited = true; + sshExitCode = code; + maybeFinish(); + }); + tar.on("close", (code) => { + tarExited = true; + tarExitCode = code; + maybeFinish(); + }); + }); + + await clearLocalDirectory(input.localDir, input.preserveLocalEntries); + await copyDirectoryContents(stagingDir, input.localDir); + } finally { + await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined); + await auth.cleanup(); + } +} + +export async function prepareWorkspaceForSshExecution(input: { + spec: SshRemoteExecutionSpec; + localDir: string; + remoteDir?: string; +}): Promise { + const remoteDir = input.remoteDir ?? input.spec.remoteCwd; + const gitSnapshot = await readLocalGitWorkspaceSnapshot(input.localDir); + + if (gitSnapshot) { + await importGitWorkspaceToSsh({ + spec: input.spec, + localDir: input.localDir, + remoteDir, + snapshot: gitSnapshot, + }); + await syncDirectoryToSsh({ + spec: input.spec, + localDir: input.localDir, + remoteDir, + exclude: [".git", ".paperclip-runtime"], + }); + await removeDeletedPathsOnSsh({ + spec: input.spec, + remoteDir, + deletedPaths: gitSnapshot.deletedPaths, + }); + return; + } + + await clearRemoteDirectory({ + spec: input.spec, + remoteDir, + preserveEntries: [".paperclip-runtime"], + }); + await syncDirectoryToSsh({ + spec: input.spec, + localDir: input.localDir, + remoteDir, + exclude: [".paperclip-runtime"], + }); +} + +export async function restoreWorkspaceFromSshExecution(input: { + spec: SshRemoteExecutionSpec; + localDir: string; + remoteDir?: string; +}): Promise { + const remoteDir = input.remoteDir ?? input.spec.remoteCwd; + const gitSnapshot = await readLocalGitWorkspaceSnapshot(input.localDir); + + if (gitSnapshot) { + await exportGitWorkspaceFromSsh({ + spec: input.spec, + remoteDir, + localDir: input.localDir, + }); + await syncDirectoryFromSsh({ + spec: input.spec, + remoteDir, + localDir: input.localDir, + exclude: [".git", ".paperclip-runtime"], + preserveLocalEntries: [".git"], + }); + return; + } + + await syncDirectoryFromSsh({ + spec: input.spec, + remoteDir, + localDir: input.localDir, + exclude: [".paperclip-runtime"], + }); +} + +export async function ensureSshWorkspaceReady( + config: SshConnectionConfig, +): Promise<{ remoteCwd: string }> { + const result = await runSshCommand( + config, + `sh -lc ${shellQuote(`mkdir -p ${shellQuote(config.remoteWorkspacePath)} && cd ${shellQuote(config.remoteWorkspacePath)} && pwd`)}`, + ); + return { + remoteCwd: result.stdout.trim(), + }; +} + +export async function readSshEnvLabFixtureState( + statePath: string, +): Promise { + try { + const raw = JSON.parse(await fs.readFile(statePath, "utf8")) as SshEnvLabFixtureState; + if (!raw || raw.kind !== "ssh_openbsd") return null; + return raw; + } catch { + return null; + } +} + +export async function stopSshEnvLabFixture(statePath: string): Promise { + const state = await readSshEnvLabFixtureState(statePath); + if (!state) return false; + + if (await isSshEnvLabFixtureProcess(state)) { + process.kill(state.pid, "SIGTERM"); + await waitForCondition(async () => { + if (await isSshEnvLabFixtureProcess(state)) { + throw new Error("SSH fixture process is still running."); + } + }, { timeoutMs: 5_000, intervalMs: 100 }); + } + + await fs.rm(state.rootDir, { recursive: true, force: true }).catch(() => undefined); + return true; +} + +export async function startSshEnvLabFixture(input: { + statePath: string; + bindHost?: string; + host?: string; +}): Promise { + const existing = await readSshEnvLabFixtureState(input.statePath); + if (existing && await isSshEnvLabFixtureProcess(existing)) { + return existing; + } + if (existing) { + await fs.rm(existing.rootDir, { recursive: true, force: true }).catch(() => undefined); + } + + const support = await getSshEnvLabSupport(); + if (!support.supported) { + throw new Error(`SSH env-lab fixture is unavailable: ${support.reason}`); + } + const sshdPath = await resolveCommandPath("sshd"); + if (!sshdPath) { + throw new Error("SSH env-lab fixture is unavailable: missing required command: sshd"); + } + + const bindHost = input.bindHost ?? "127.0.0.1"; + const host = input.host ?? bindHost; + const rootDir = path.dirname(input.statePath); + await fs.mkdir(rootDir, { recursive: true }); + + const username = os.userInfo().username; + const port = await allocateLoopbackPort(bindHost); + const workspaceDir = path.join(rootDir, "workspace"); + const clientPrivateKeyPath = path.join(rootDir, "client_key"); + const clientPublicKeyPath = `${clientPrivateKeyPath}.pub`; + const hostPrivateKeyPath = path.join(rootDir, "host_key"); + const hostPublicKeyPath = `${hostPrivateKeyPath}.pub`; + const authorizedKeysPath = path.join(rootDir, "authorized_keys"); + const knownHostsPath = path.join(rootDir, "known_hosts"); + const sshdConfigPath = path.join(rootDir, "sshd_config"); + const sshdLogPath = path.join(rootDir, "sshd.log"); + const sshdPidPath = path.join(rootDir, "sshd.pid"); + + await fs.mkdir(workspaceDir, { recursive: true }); + await execFileText("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", clientPrivateKeyPath], { + timeout: 15_000, + }); + await execFileText("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", hostPrivateKeyPath], { + timeout: 15_000, + }); + + await fs.copyFile(clientPublicKeyPath, authorizedKeysPath); + const hostPublicKey = (await execFileText("ssh-keygen", ["-y", "-f", hostPrivateKeyPath], { + timeout: 15_000, + })).stdout.trim(); + await fs.writeFile( + knownHostsPath, + `${buildKnownHostsEntry({ host, port, publicKey: hostPublicKey })}\n`, + { mode: 0o600 }, + ); + await fs.writeFile( + sshdConfigPath, + [ + `Port ${port}`, + `ListenAddress ${bindHost}`, + `HostKey ${hostPrivateKeyPath}`, + `PidFile ${sshdPidPath}`, + `AuthorizedKeysFile ${authorizedKeysPath}`, + "PasswordAuthentication no", + "ChallengeResponseAuthentication no", + "KbdInteractiveAuthentication no", + "PubkeyAuthentication yes", + "PermitRootLogin no", + "UsePAM no", + "StrictModes no", + `AllowUsers ${username}`, + "LogLevel VERBOSE", + "PrintMotd no", + "UseDNS no", + "Subsystem sftp internal-sftp", + "", + ].join("\n"), + { mode: 0o600 }, + ); + + const child = spawn(sshdPath, ["-D", "-f", sshdConfigPath, "-E", sshdLogPath], { + detached: true, + stdio: "ignore", + }); + child.unref(); + + const state: SshEnvLabFixtureState = { + kind: "ssh_openbsd", + bindHost, + host, + port, + username, + rootDir, + workspaceDir, + statePath: input.statePath, + pid: child.pid ?? 0, + createdAt: new Date().toISOString(), + clientPrivateKeyPath, + clientPublicKeyPath, + hostPrivateKeyPath, + hostPublicKeyPath, + authorizedKeysPath, + knownHostsPath, + sshdConfigPath, + sshdLogPath, + }; + + if (!state.pid) { + throw new Error("Failed to start SSH env-lab fixture."); + } + + try { + await waitForCondition(async () => { + if (!(await isPidRunning(state.pid))) { + const logOutput = await fs.readFile(sshdLogPath, "utf8").catch(() => ""); + throw new Error(logOutput || "SSH env-lab fixture exited before becoming ready."); + } + const config = await buildSshEnvLabFixtureConfig(state); + await ensureSshWorkspaceReady(config); + }, { timeoutMs: 10_000, intervalMs: 250 }); + await fs.writeFile(input.statePath, JSON.stringify(state, null, 2), { mode: 0o600 }); + return state; + } catch (error) { + if (await isPidRunning(state.pid)) { + process.kill(state.pid, "SIGTERM"); + } + await fs.rm(rootDir, { recursive: true, force: true }).catch(() => undefined); + throw error; + } +} + +export async function buildSshEnvLabFixtureConfig( + state: SshEnvLabFixtureState, +): Promise { + const [privateKey, knownHosts] = await Promise.all([ + fs.readFile(state.clientPrivateKeyPath, "utf8"), + fs.readFile(state.knownHostsPath, "utf8"), + ]); + return { + host: state.host, + port: state.port, + username: state.username, + remoteWorkspacePath: state.workspaceDir, + privateKey, + knownHosts, + strictHostKeyChecking: true, + }; +} + +export async function readSshEnvLabFixtureStatus(statePath: string): Promise<{ + running: boolean; + state: SshEnvLabFixtureState | null; +}> { + const state = await readSshEnvLabFixtureState(statePath); + if (!state) { + return { running: false, state: null }; + } + return { + running: await isSshEnvLabFixtureProcess(state), + state, + }; +} + +export async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath, fsConstants.F_OK); + return true; + } catch { + return false; + } +} diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index e02b9efe8d..ff00a0e989 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -2,6 +2,9 @@ // Minimal adapter-facing interfaces (no drizzle dependency) // --------------------------------------------------------------------------- +import type { SshRemoteExecutionSpec } from "./ssh.js"; +import type { AdapterExecutionTarget } from "./execution-target.js"; + export interface AdapterAgent { id: string; companyId: string; @@ -118,6 +121,14 @@ export interface AdapterExecutionContext { runtime: AdapterRuntime; config: Record; context: Record; + executionTarget?: AdapterExecutionTarget | null; + /** + * Legacy remote transport view. Prefer `executionTarget`, which is the + * provider-neutral contract produced by core runtime code. + */ + executionTransport?: { + remoteExecution?: Record | null; + }; onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; onMeta?: (meta: AdapterInvocationMeta) => Promise; onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise; @@ -417,6 +428,7 @@ export interface CreateConfigValues { workspaceBranchTemplate?: string; worktreeParentDir?: string; runtimeServicesJson?: string; + defaultEnvironmentId?: string; maxTurnsPerRun: number; heartbeatEnabled: boolean; intervalSec: number; diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts new file mode 100644 index 0000000000..c96b91643f --- /dev/null +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -0,0 +1,262 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { + runChildProcess, + ensureCommandResolvable, + resolveCommandForLogs, + prepareWorkspaceForSshExecution, + restoreWorkspaceFromSshExecution, + syncDirectoryToSsh, +} = vi.hoisted(() => ({ + runChildProcess: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }), + JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } }), + JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }), + ].join("\n"), + stderr: "", + pid: 123, + startedAt: new Date().toISOString(), + })), + ensureCommandResolvable: vi.fn(async () => undefined), + resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: claude"), + prepareWorkspaceForSshExecution: vi.fn(async () => undefined), + restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), + syncDirectoryToSsh: vi.fn(async () => undefined), +})); + +vi.mock("@paperclipai/adapter-utils/server-utils", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/server-utils", + ); + return { + ...actual, + ensureCommandResolvable, + resolveCommandForLogs, + runChildProcess, + }; +}); + +vi.mock("@paperclipai/adapter-utils/ssh", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/ssh", + ); + return { + ...actual, + prepareWorkspaceForSshExecution, + restoreWorkspaceFromSshExecution, + syncDirectoryToSsh, + }; +}); + +import { execute } from "./execute.js"; + +describe("claude remote execution", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + vi.clearAllMocks(); + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("prepares the workspace, syncs Claude runtime assets, and restores workspace changes for remote SSH execution", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + const instructionsPath = path.join(rootDir, "instructions.md"); + await mkdir(workspaceDir, { recursive: true }); + await writeFile(instructionsPath, "Use the remote workspace.\n", "utf8"); + + await execute({ + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "claude", + instructionsFilePath: instructionsPath, + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + paperclipApiUrl: "http://198.51.100.10:3102", + }, + }, + onLog: async () => {}, + }); + + expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); + expect(prepareWorkspaceForSshExecution).toHaveBeenCalledWith(expect.objectContaining({ + localDir: workspaceDir, + remoteDir: "/remote/workspace", + })); + expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1); + expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ + remoteDir: "/remote/workspace/.paperclip-runtime/claude/skills", + followSymlinks: true, + })); + expect(runChildProcess).toHaveBeenCalledTimes(1); + const call = runChildProcess.mock.calls[0] as unknown as + | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] + | undefined; + expect(call?.[2]).toContain("--append-system-prompt-file"); + expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/claude/skills/agent-instructions.md"); + expect(call?.[2]).toContain("--add-dir"); + expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/claude/skills"); + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102"); + expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); + expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({ + localDir: workspaceDir, + remoteDir: "/remote/workspace", + })); + }); + + it("does not resume saved Claude sessions for remote SSH execution without a matching remote identity", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-resume-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + await execute({ + runId: "run-ssh-no-resume", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { + sessionId: "session-123", + sessionParams: { + sessionId: "session-123", + cwd: "/remote/workspace", + }, + sessionDisplayId: "session-123", + taskKey: null, + }, + config: { + command: "claude", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + expect(runChildProcess).toHaveBeenCalledTimes(1); + const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + expect(call?.[2]).not.toContain("--resume"); + }); + + it("resumes saved Claude sessions for remote SSH execution when the remote identity matches", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-resume-match-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + await execute({ + runId: "run-ssh-resume", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { + sessionId: "session-123", + sessionParams: { + sessionId: "session-123", + cwd: "/remote/workspace", + remoteExecution: { + transport: "ssh", + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteCwd: "/remote/workspace", + }, + }, + sessionDisplayId: "session-123", + taskKey: null, + }, + config: { + command: "claude", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + expect(runChildProcess).toHaveBeenCalledTimes(1); + const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + expect(call?.[2]).toContain("--resume"); + expect(call?.[2]).toContain("session-123"); + }); + +}); diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 79327fdb44..6fa870c85d 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -3,6 +3,20 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils"; +import { + adapterExecutionTargetIsRemote, + adapterExecutionTargetPaperclipApiUrl, + adapterExecutionTargetRemoteCwd, + adapterExecutionTargetSessionIdentity, + adapterExecutionTargetSessionMatches, + adapterExecutionTargetUsesManagedHome, + describeAdapterExecutionTarget, + ensureAdapterExecutionTargetCommandResolvable, + prepareAdapterExecutionTargetRuntime, + readAdapterExecutionTarget, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, +} from "@paperclipai/adapter-utils/execution-target"; import { asString, asNumber, @@ -15,14 +29,11 @@ import { joinPromptSections, buildInvocationEnvForLogs, ensureAbsoluteDirectory, - ensureCommandResolvable, ensurePathInEnv, - resolveCommandForLogs, renderTemplate, renderPaperclipWakePrompt, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, - runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { parseClaudeStreamJson, @@ -42,6 +53,7 @@ interface ClaudeExecutionInput { agent: AdapterExecutionContext["agent"]; config: Record; context: Record; + executionTarget?: ReturnType; authToken?: string; } @@ -92,7 +104,7 @@ function resolveClaudeBillingType(env: Record): "api" | "subscri } async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { - const { runId, agent, config, context, authToken } = input; + const { runId, agent, config, context, executionTarget, authToken } = input; const command = asString(config.command, "claude"); const workspaceContext = parseObject(context.paperclipWorkspace); @@ -218,6 +230,10 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; + const executionTarget = readAdapterExecutionTarget({ + executionTarget: ctx.executionTarget, + legacyRemoteExecution: ctx.executionTransport?.remoteExecution, + }); + const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget); const promptTemplate = asString( config.promptTemplate, @@ -315,6 +336,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + await onLog( + "stdout", + `[paperclip] Syncing workspace and Claude runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`, + ); + return await prepareAdapterExecutionTargetRuntime({ + target: executionTarget, + adapterKey: "claude", + workspaceLocalDir: cwd, + assets: [ + { + key: "skills", + localDir: promptBundle.addDir, + followSymlinks: true, + }, + ], + }); + })() + : null; + const restoreRemoteWorkspace = preparedExecutionTargetRuntime + ? () => preparedExecutionTargetRuntime.restoreWorkspace() + : null; + const effectivePromptBundleAddDir = executionTargetIsRemote + ? preparedExecutionTargetRuntime?.assetDirs.skills ?? + path.posix.join(effectiveExecutionCwd, ".paperclip-runtime", "claude", "skills") + : promptBundle.addDir; + const effectiveInstructionsFilePath = promptBundle.instructionsFilePath + ? executionTargetIsRemote + ? path.posix.join(effectivePromptBundleAddDir, path.basename(promptBundle.instructionsFilePath)) + : promptBundle.instructionsFilePath + : undefined; const runtimeSessionParams = parseObject(runtime.sessionParams); const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution); const runtimePromptBundleKey = asString(runtimeSessionParams.promptBundleKey, ""); const hasMatchingPromptBundle = runtimePromptBundleKey.length === 0 || runtimePromptBundleKey === promptBundle.bundleKey; const canResumeSession = runtimeSessionId.length > 0 && hasMatchingPromptBundle && - (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && + adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget); const sessionId = canResumeSession ? runtimeSessionId : null; if ( + executionTargetIsRemote && runtimeSessionId && - runtimeSessionCwd.length > 0 && - path.resolve(runtimeSessionCwd) !== path.resolve(cwd) + !canResumeSession ) { await onLog( "stdout", - `[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + `[paperclip] Claude session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`, + ); + } else if ( + runtimeSessionId && + runtimeSessionCwd.length > 0 && + path.resolve(runtimeSessionCwd) !== path.resolve(effectiveExecutionCwd) + ) { + await onLog( + "stdout", + `[paperclip] Claude session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`, + ); + } else if (runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`, ); } if (runtimeSessionId && runtimePromptBundleKey.length > 0 && runtimePromptBundleKey !== promptBundle.bundleKey) { @@ -416,10 +486,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const taskContextNote = asString(context.paperclipTaskMarkdown, "").trim(); const prompt = joinPromptSections([ renderedBootstrapPrompt, wakePrompt, sessionHandoffNote, + taskContextNote, renderedPrompt, ]); const promptMetrics = { @@ -427,6 +499,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push(...extraArgs); return args; }; @@ -489,7 +562,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise ({ + runChildProcess: vi.fn(async () => ({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "remote failure", + pid: 123, + startedAt: new Date().toISOString(), + })), + ensureCommandResolvable: vi.fn(async () => undefined), + resolveCommandForLogs: vi.fn(async () => "/usr/bin/codex"), + prepareWorkspaceForSshExecution: vi.fn(async () => undefined), + restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), + syncDirectoryToSsh: vi.fn(async () => undefined), +})); + +vi.mock("@paperclipai/adapter-utils/server-utils", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/server-utils", + ); + return { + ...actual, + ensureCommandResolvable, + resolveCommandForLogs, + runChildProcess, + }; +}); + +vi.mock("@paperclipai/adapter-utils/ssh", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/ssh", + ); + return { + ...actual, + prepareWorkspaceForSshExecution, + restoreWorkspaceFromSshExecution, + syncDirectoryToSsh, + }; +}); + +import { execute } from "./execute.js"; + +describe("codex remote execution", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + vi.clearAllMocks(); + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("prepares the workspace, syncs CODEX_HOME, and restores workspace changes for remote SSH execution", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-remote-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + const codexHomeDir = path.join(rootDir, "codex-home"); + await mkdir(workspaceDir, { recursive: true }); + await mkdir(codexHomeDir, { recursive: true }); + await writeFile(path.join(rootDir, "instructions.md"), "Use the remote workspace.\n", "utf8"); + await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8"); + + await execute({ + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "CodexCoder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "codex", + env: { + CODEX_HOME: codexHomeDir, + }, + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); + expect(prepareWorkspaceForSshExecution).toHaveBeenCalledWith(expect.objectContaining({ + localDir: workspaceDir, + remoteDir: "/remote/workspace", + })); + expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1); + expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ + localDir: codexHomeDir, + remoteDir: "/remote/workspace/.paperclip-runtime/codex/home", + followSymlinks: true, + })); + + expect(runChildProcess).toHaveBeenCalledTimes(1); + const call = runChildProcess.mock.calls[0] as unknown as + | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] + | undefined; + expect(call?.[3].env.CODEX_HOME).toBe("/remote/workspace/.paperclip-runtime/codex/home"); + expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); + expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({ + localDir: workspaceDir, + remoteDir: "/remote/workspace", + })); + }); + + it("does not resume saved Codex sessions for remote SSH execution without a matching remote identity", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-remote-resume-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + const codexHomeDir = path.join(rootDir, "codex-home"); + await mkdir(workspaceDir, { recursive: true }); + await mkdir(codexHomeDir, { recursive: true }); + await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8"); + + await execute({ + runId: "run-ssh-no-resume", + agent: { + id: "agent-1", + companyId: "company-1", + name: "CodexCoder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: "session-123", + sessionParams: { + sessionId: "session-123", + cwd: "/remote/workspace", + }, + sessionDisplayId: "session-123", + taskKey: null, + }, + config: { + command: "codex", + env: { + CODEX_HOME: codexHomeDir, + }, + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + expect(runChildProcess).toHaveBeenCalledTimes(1); + const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + expect(call?.[2]).toEqual([ + "exec", + "--json", + "-", + ]); + }); + + it("resumes saved Codex sessions for remote SSH execution when the remote identity matches", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-remote-resume-match-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + const codexHomeDir = path.join(rootDir, "codex-home"); + await mkdir(workspaceDir, { recursive: true }); + await mkdir(codexHomeDir, { recursive: true }); + await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8"); + + await execute({ + runId: "run-ssh-resume", + agent: { + id: "agent-1", + companyId: "company-1", + name: "CodexCoder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: "session-123", + sessionParams: { + sessionId: "session-123", + cwd: "/remote/workspace", + remoteExecution: { + transport: "ssh", + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteCwd: "/remote/workspace", + }, + }, + sessionDisplayId: "session-123", + taskKey: null, + }, + config: { + command: "codex", + env: { + CODEX_HOME: codexHomeDir, + }, + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + expect(runChildProcess).toHaveBeenCalledTimes(1); + const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + expect(call?.[2]).toEqual([ + "exec", + "--json", + "resume", + "session-123", + "-", + ]); + }); + + it("uses the provider-neutral execution target contract for remote SSH execution", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-target-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + const codexHomeDir = path.join(rootDir, "codex-home"); + await mkdir(workspaceDir, { recursive: true }); + await mkdir(codexHomeDir, { recursive: true }); + await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8"); + + await execute({ + runId: "run-target", + agent: { + id: "agent-1", + companyId: "company-1", + name: "CodexCoder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: "session-123", + sessionParams: { + sessionId: "session-123", + cwd: "/remote/workspace", + remoteExecution: { + transport: "ssh", + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteCwd: "/remote/workspace", + }, + }, + sessionDisplayId: "session-123", + taskKey: null, + }, + config: { + command: "codex", + env: { + CODEX_HOME: codexHomeDir, + }, + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTarget: { + kind: "remote", + transport: "ssh", + remoteCwd: "/remote/workspace", + spec: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1); + expect(runChildProcess).toHaveBeenCalledTimes(1); + const call = runChildProcess.mock.calls[0] as unknown as + | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] + | undefined; + expect(call?.[2]).toEqual([ + "exec", + "--json", + "resume", + "session-123", + "-", + ]); + expect(call?.[3].env.CODEX_HOME).toBe("/remote/workspace/.paperclip-runtime/codex/home"); + expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + }); +}); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 95a0d70cd5..87d14a7249 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -2,6 +2,19 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + adapterExecutionTargetIsRemote, + adapterExecutionTargetPaperclipApiUrl, + adapterExecutionTargetRemoteCwd, + adapterExecutionTargetSessionIdentity, + adapterExecutionTargetSessionMatches, + describeAdapterExecutionTarget, + ensureAdapterExecutionTargetCommandResolvable, + prepareAdapterExecutionTargetRuntime, + readAdapterExecutionTarget, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, +} from "@paperclipai/adapter-utils/execution-target"; import { asString, asNumber, @@ -9,18 +22,15 @@ import { buildPaperclipEnv, buildInvocationEnvForLogs, ensureAbsoluteDirectory, - ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, readPaperclipRuntimeSkillEntries, - resolveCommandForLogs, resolvePaperclipDesiredSkillNames, renderTemplate, renderPaperclipWakePrompt, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, joinPromptSections, - runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { parseCodexJsonl, @@ -305,6 +315,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? path.resolve(envConfig.CODEX_HOME.trim()) @@ -328,10 +343,37 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + await onLog( + "stdout", + `[paperclip] Syncing workspace and CODEX_HOME to ${describeAdapterExecutionTarget(executionTarget)}.\n`, + ); + return await prepareAdapterExecutionTargetRuntime({ + target: executionTarget, + adapterKey: "codex", + workspaceLocalDir: cwd, + assets: [ + { + key: "home", + localDir: effectiveCodexHome, + followSymlinks: true, + }, + ], + }); + })() + : null; + const restoreRemoteWorkspace = preparedExecutionTargetRuntime + ? () => preparedExecutionTargetRuntime.restoreWorkspace() + : null; + const remoteCodexHome = executionTargetIsRemote + ? preparedExecutionTargetRuntime?.assetDirs.home ?? + path.posix.join(effectiveExecutionCwd, ".paperclip-runtime", "codex", "home") + : null; const hasExplicitApiKey = typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; const env: Record = { ...buildPaperclipEnv(agent) }; - env.CODEX_HOME = effectiveCodexHome; env.PAPERCLIP_RUN_ID = runId; const wakeTaskId = (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || @@ -417,9 +459,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 && - (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && + adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget); const codexTransientFallbackMode = readCodexTransientFallbackMode(context); const forceSaferInvocation = fallbackModeUsesSaferInvocation(codexTransientFallbackMode); const forceFreshSession = fallbackModeUsesFreshSession(codexTransientFallbackMode); const sessionId = canResumeSession && !forceFreshSession ? runtimeSessionId : null; - if (runtimeSessionId && !canResumeSession) { + if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) { await onLog( "stdout", - `[paperclip] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + `[paperclip] Codex session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`, + ); + } else if (runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`, ); } const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); @@ -591,7 +645,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { if (idx === args.length - 1 && value !== "-") return ``; @@ -604,7 +658,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise ({ + runChildProcess: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "system", session_id: "cursor-session-1" }), + JSON.stringify({ type: "assistant", text: "hello" }), + JSON.stringify({ type: "result", is_error: false, result: "hello", session_id: "cursor-session-1" }), + ].join("\n"), + stderr: "", + pid: 123, + startedAt: new Date().toISOString(), + })), + ensureCommandResolvable: vi.fn(async () => undefined), + resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: agent"), + prepareWorkspaceForSshExecution: vi.fn(async () => undefined), + restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), + runSshCommand: vi.fn(async () => ({ + stdout: "/home/agent", + stderr: "", + exitCode: 0, + })), + syncDirectoryToSsh: vi.fn(async () => undefined), +})); + +vi.mock("@paperclipai/adapter-utils/server-utils", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/server-utils", + ); + return { + ...actual, + ensureCommandResolvable, + resolveCommandForLogs, + runChildProcess, + }; +}); + +vi.mock("@paperclipai/adapter-utils/ssh", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/ssh", + ); + return { + ...actual, + prepareWorkspaceForSshExecution, + restoreWorkspaceFromSshExecution, + runSshCommand, + syncDirectoryToSsh, + }; +}); + +import { execute } from "./execute.js"; + +describe("cursor remote execution", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + vi.clearAllMocks(); + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("prepares the workspace, syncs Cursor skills, and restores workspace changes for remote SSH execution", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-remote-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + const result = await execute({ + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Cursor Builder", + adapterType: "cursor", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "agent", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + paperclipApiUrl: "http://198.51.100.10:3102", + }, + }, + onLog: async () => {}, + }); + + expect(result.sessionParams).toMatchObject({ + sessionId: "cursor-session-1", + cwd: "/remote/workspace", + remoteExecution: { + transport: "ssh", + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteCwd: "/remote/workspace", + paperclipApiUrl: "http://198.51.100.10:3102", + }, + }); + expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); + expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1); + expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ + remoteDir: "/remote/workspace/.paperclip-runtime/cursor/skills", + followSymlinks: true, + })); + expect(runSshCommand).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining(".cursor/skills"), + expect.anything(), + ); + const call = runChildProcess.mock.calls[0] as unknown as + | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] + | undefined; + expect(call?.[2]).toContain("--workspace"); + expect(call?.[2]).toContain("/remote/workspace"); + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102"); + expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); + }); + + it("resumes saved Cursor sessions for remote SSH execution only when the identity matches", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-remote-resume-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + await execute({ + runId: "run-ssh-resume", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Cursor Builder", + adapterType: "cursor", + adapterConfig: {}, + }, + runtime: { + sessionId: "session-123", + sessionParams: { + sessionId: "session-123", + cwd: "/remote/workspace", + remoteExecution: { + transport: "ssh", + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteCwd: "/remote/workspace", + }, + }, + sessionDisplayId: "session-123", + taskKey: null, + }, + config: { + command: "agent", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + expect(call?.[2]).toContain("--resume"); + expect(call?.[2]).toContain("session-123"); + }); + + it("restores the remote workspace if skills sync fails after workspace prep", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-remote-sync-fail-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + syncDirectoryToSsh.mockRejectedValueOnce(new Error("sync failed")); + + await expect(execute({ + runId: "run-sync-fail", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Cursor Builder", + adapterType: "cursor", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "agent", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + })).rejects.toThrow("sync failed"); + + expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); + expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); + expect(runChildProcess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 7c457e02f2..6d7f79662d 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -3,6 +3,22 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + adapterExecutionTargetIsRemote, + adapterExecutionTargetPaperclipApiUrl, + adapterExecutionTargetRemoteCwd, + adapterExecutionTargetSessionIdentity, + adapterExecutionTargetSessionMatches, + adapterExecutionTargetUsesManagedHome, + describeAdapterExecutionTarget, + ensureAdapterExecutionTargetCommandResolvable, + prepareAdapterExecutionTargetRuntime, + readAdapterExecutionTarget, + readAdapterExecutionTargetHomeDir, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, + runAdapterExecutionTargetShellCommand, +} from "@paperclipai/adapter-utils/execution-target"; import { asString, asNumber, @@ -11,11 +27,9 @@ import { buildPaperclipEnv, buildInvocationEnvForLogs, ensureAbsoluteDirectory, - ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, readPaperclipRuntimeSkillEntries, - resolveCommandForLogs, resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, @@ -23,7 +37,6 @@ import { stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, joinPromptSections, - runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js"; import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js"; @@ -97,6 +110,19 @@ function cursorSkillsHome(): string { return path.join(os.homedir(), ".cursor", "skills"); } +async function buildCursorSkillsDir(config: Record): Promise { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-skills-")); + const target = path.join(tmp, "skills"); + await fs.mkdir(target, { recursive: true }); + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries)); + for (const entry of availableEntries) { + if (!desiredNames.has(entry.key)) continue; + await fs.symlink(entry.source, path.join(target, entry.runtimeName)); + } + return target; +} + type EnsureCursorSkillsInjectedOptions = { skillsDir?: string | null; skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>; @@ -162,6 +188,11 @@ export async function ensureCursorSkillsInjected( export async function execute(ctx: AdapterExecutionContext): Promise { const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; + const executionTarget = readAdapterExecutionTarget({ + executionTarget: ctx.executionTarget, + legacyRemoteExecution: ctx.executionTransport?.remoteExecution, + }); + const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget); const promptTemplate = asString( config.promptTemplate, @@ -190,9 +221,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise desiredCursorSkillNames.includes(entry.key)), - }); + if (!executionTargetIsRemote) { + await ensureCursorSkillsInjected(onLog, { + skillsEntries: cursorSkillEntries.filter((entry) => desiredCursorSkillNames.includes(entry.key)), + }); + } const envConfig = parseObject(config.env); const hasExplicitApiKey = @@ -265,6 +298,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); } + const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); + if (targetPaperclipApiUrl) { + env.PAPERCLIP_API_URL = targetPaperclipApiUrl; + } for (const [k, v] of Object.entries(envConfig)) { if (typeof v === "string") env[k] = v; } @@ -278,8 +315,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise Promise) | null = null; + let localSkillsDir: string | null = null; + + if (executionTargetIsRemote) { + try { + localSkillsDir = await buildCursorSkillsDir(config); + await onLog( + "stdout", + `[paperclip] Syncing workspace and Cursor runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`, + ); + const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({ + target: executionTarget, + adapterKey: "cursor", + workspaceLocalDir: cwd, + assets: [{ + key: "skills", + localDir: localSkillsDir, + followSymlinks: true, + }], + }); + restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace(); + const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget); + if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) { + env.HOME = preparedExecutionTargetRuntime.runtimeRootDir; + } + const remoteHomeDir = managedHome && preparedExecutionTargetRuntime.runtimeRootDir + ? preparedExecutionTargetRuntime.runtimeRootDir + : await readAdapterExecutionTargetHomeDir(runId, executionTarget, { + cwd, + env, + timeoutSec, + graceSec, + onLog, + }); + if (remoteHomeDir && preparedExecutionTargetRuntime.assetDirs.skills) { + const remoteSkillsDir = path.posix.join(remoteHomeDir, ".cursor", "skills"); + await runAdapterExecutionTargetShellCommand( + runId, + executionTarget, + `mkdir -p ${JSON.stringify(path.posix.dirname(remoteSkillsDir))} && rm -rf ${JSON.stringify(remoteSkillsDir)} && cp -a ${JSON.stringify(preparedExecutionTargetRuntime.assetDirs.skills)} ${JSON.stringify(remoteSkillsDir)}`, + { cwd, env, timeoutSec, graceSec, onLog }, + ); + } + } catch (error) { + await Promise.allSettled([ + restoreRemoteWorkspace?.(), + localSkillsDir ? fs.rm(localSkillsDir, { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(), + ]); + throw error; + } + } const runtimeSessionParams = parseObject(runtime.sessionParams); const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution); const canResumeSession = runtimeSessionId.length > 0 && - (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && + adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget); const sessionId = canResumeSession ? runtimeSessionId : null; - if (runtimeSessionId && !canResumeSession) { + if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) { await onLog( "stdout", - `[paperclip] Cursor session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + `[paperclip] Cursor session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`, + ); + } else if (runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] Cursor session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`, ); } @@ -387,7 +483,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const args = ["-p", "--output-format", "stream-json", "--workspace", cwd]; + const args = ["-p", "--output-format", "stream-json", "--workspace", effectiveExecutionCwd]; if (resumeSessionId) args.push("--resume", resumeSessionId); if (model) args.push("--model", model); if (mode) args.push("--mode", mode); @@ -402,7 +498,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise) : null; const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; @@ -527,20 +628,32 @@ export async function execute(ctx: AdapterExecutionContext): Promise undefined); + } } - - return toResult(initial); } diff --git a/packages/adapters/gemini-local/src/server/execute.remote.test.ts b/packages/adapters/gemini-local/src/server/execute.remote.test.ts new file mode 100644 index 0000000000..4c97e91513 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/execute.remote.test.ts @@ -0,0 +1,272 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { + runChildProcess, + ensureCommandResolvable, + resolveCommandForLogs, + prepareWorkspaceForSshExecution, + restoreWorkspaceFromSshExecution, + runSshCommand, + syncDirectoryToSsh, +} = vi.hoisted(() => ({ + runChildProcess: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }), + JSON.stringify({ type: "assistant", message: { content: [{ type: "output_text", text: "hello" }] } }), + JSON.stringify({ + type: "result", + subtype: "success", + session_id: "gemini-session-1", + usage: { promptTokenCount: 1, cachedContentTokenCount: 0, candidatesTokenCount: 1 }, + result: "hello", + }), + ].join("\n"), + stderr: "", + pid: 123, + startedAt: new Date().toISOString(), + })), + ensureCommandResolvable: vi.fn(async () => undefined), + resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: gemini"), + prepareWorkspaceForSshExecution: vi.fn(async () => undefined), + restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), + runSshCommand: vi.fn(async () => ({ + stdout: "/home/agent", + stderr: "", + exitCode: 0, + })), + syncDirectoryToSsh: vi.fn(async () => undefined), +})); + +vi.mock("@paperclipai/adapter-utils/server-utils", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/server-utils", + ); + return { + ...actual, + ensureCommandResolvable, + resolveCommandForLogs, + runChildProcess, + }; +}); + +vi.mock("@paperclipai/adapter-utils/ssh", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/ssh", + ); + return { + ...actual, + prepareWorkspaceForSshExecution, + restoreWorkspaceFromSshExecution, + runSshCommand, + syncDirectoryToSsh, + }; +}); + +import { execute } from "./execute.js"; + +describe("gemini remote execution", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + vi.clearAllMocks(); + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("prepares the workspace, syncs Gemini skills, and restores workspace changes for remote SSH execution", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-remote-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + const result = await execute({ + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Gemini Builder", + adapterType: "gemini_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "gemini", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + paperclipApiUrl: "http://198.51.100.10:3102", + }, + }, + onLog: async () => {}, + }); + + expect(result.sessionParams).toMatchObject({ + sessionId: "gemini-session-1", + cwd: "/remote/workspace", + remoteExecution: { + transport: "ssh", + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteCwd: "/remote/workspace", + paperclipApiUrl: "http://198.51.100.10:3102", + }, + }); + expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); + expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1); + expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ + remoteDir: "/remote/workspace/.paperclip-runtime/gemini/skills", + followSymlinks: true, + })); + expect(runSshCommand).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining(".gemini/skills"), + expect.anything(), + ); + const call = runChildProcess.mock.calls[0] as unknown as + | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] + | undefined; + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102"); + expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); + }); + + it("resumes saved Gemini sessions for remote SSH execution only when the identity matches", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-remote-resume-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + await execute({ + runId: "run-ssh-resume", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Gemini Builder", + adapterType: "gemini_local", + adapterConfig: {}, + }, + runtime: { + sessionId: "session-123", + sessionParams: { + sessionId: "session-123", + cwd: "/remote/workspace", + remoteExecution: { + transport: "ssh", + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteCwd: "/remote/workspace", + }, + }, + sessionDisplayId: "session-123", + taskKey: null, + }, + config: { + command: "gemini", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + expect(call?.[2]).toContain("--resume"); + expect(call?.[2]).toContain("session-123"); + }); + + it("restores the remote workspace if skills sync fails after workspace prep", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-remote-sync-fail-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + syncDirectoryToSsh.mockRejectedValueOnce(new Error("sync failed")); + + await expect(execute({ + runId: "run-sync-fail", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Gemini Builder", + adapterType: "gemini_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "gemini", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + })).rejects.toThrow("sync failed"); + + expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); + expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); + expect(runChildProcess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index eb529e409f..4c79b75375 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -4,6 +4,22 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + adapterExecutionTargetIsRemote, + adapterExecutionTargetPaperclipApiUrl, + adapterExecutionTargetRemoteCwd, + adapterExecutionTargetSessionIdentity, + adapterExecutionTargetSessionMatches, + adapterExecutionTargetUsesManagedHome, + describeAdapterExecutionTarget, + ensureAdapterExecutionTargetCommandResolvable, + prepareAdapterExecutionTargetRuntime, + readAdapterExecutionTarget, + readAdapterExecutionTargetHomeDir, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, + runAdapterExecutionTargetShellCommand, +} from "@paperclipai/adapter-utils/execution-target"; import { asBoolean, asNumber, @@ -12,12 +28,10 @@ import { buildPaperclipEnv, buildInvocationEnvForLogs, ensureAbsoluteDirectory, - ensureCommandResolvable, ensurePaperclipSkillSymlink, joinPromptSections, ensurePathInEnv, readPaperclipRuntimeSkillEntries, - resolveCommandForLogs, resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, parseObject, @@ -136,8 +150,28 @@ async function ensureGeminiSkillsInjected( } } +async function buildGeminiSkillsDir( + config: Record, +): Promise { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-skills-")); + const target = path.join(tmp, "skills"); + await fs.mkdir(target, { recursive: true }); + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries)); + for (const entry of availableEntries) { + if (!desiredNames.has(entry.key)) continue; + await fs.symlink(entry.source, path.join(target, entry.runtimeName)); + } + return target; +} + export async function execute(ctx: AdapterExecutionContext): Promise { const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; + const executionTarget = readAdapterExecutionTarget({ + executionTarget: ctx.executionTarget, + legacyRemoteExecution: ctx.executionTransport?.remoteExecution, + }); + const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget); const promptTemplate = asString( config.promptTemplate, @@ -166,7 +200,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); + if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl; for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; @@ -225,8 +263,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) return fromExtraArgs; return asStringArray(config.args); })(); + const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); + let restoreRemoteWorkspace: (() => Promise) | null = null; + let remoteSkillsDir: string | null = null; + let localSkillsDir: string | null = null; + + if (executionTargetIsRemote) { + try { + localSkillsDir = await buildGeminiSkillsDir(config); + await onLog( + "stdout", + `[paperclip] Syncing workspace and Gemini runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`, + ); + const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({ + target: executionTarget, + adapterKey: "gemini", + workspaceLocalDir: cwd, + assets: [{ + key: "skills", + localDir: localSkillsDir, + followSymlinks: true, + }], + }); + restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace(); + const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget); + if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) { + env.HOME = preparedExecutionTargetRuntime.runtimeRootDir; + } + const remoteHomeDir = managedHome && preparedExecutionTargetRuntime.runtimeRootDir + ? preparedExecutionTargetRuntime.runtimeRootDir + : await readAdapterExecutionTargetHomeDir(runId, executionTarget, { + cwd, + env, + timeoutSec, + graceSec, + onLog, + }); + if (remoteHomeDir && preparedExecutionTargetRuntime.assetDirs.skills) { + remoteSkillsDir = path.posix.join(remoteHomeDir, ".gemini", "skills"); + await runAdapterExecutionTargetShellCommand( + runId, + executionTarget, + `mkdir -p ${JSON.stringify(path.posix.dirname(remoteSkillsDir))} && rm -rf ${JSON.stringify(remoteSkillsDir)} && cp -a ${JSON.stringify(preparedExecutionTargetRuntime.assetDirs.skills)} ${JSON.stringify(remoteSkillsDir)}`, + { cwd, env, timeoutSec, graceSec, onLog }, + ); + } + } catch (error) { + await Promise.allSettled([ + restoreRemoteWorkspace?.(), + localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(), + ]); + throw error; + } + } const runtimeSessionParams = parseObject(runtime.sessionParams); const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution); const canResumeSession = runtimeSessionId.length > 0 && - (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && + adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget); const sessionId = canResumeSession ? runtimeSessionId : null; - if (runtimeSessionId && !canResumeSession) { + if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) { await onLog( "stdout", - `[paperclip] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + `[paperclip] Gemini session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`, + ); + } else if (runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`, ); } @@ -350,7 +448,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise ( index === args.length - 1 ? `` : value @@ -362,7 +460,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise) : null; const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; @@ -458,20 +561,27 @@ export async function execute(ctx: AdapterExecutionContext): Promise undefined) : Promise.resolve(), + ]); + } } diff --git a/packages/adapters/opencode-local/src/server/execute.remote.test.ts b/packages/adapters/opencode-local/src/server/execute.remote.test.ts new file mode 100644 index 0000000000..bdf559915d --- /dev/null +++ b/packages/adapters/opencode-local/src/server/execute.remote.test.ts @@ -0,0 +1,225 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { + runChildProcess, + ensureCommandResolvable, + resolveCommandForLogs, + prepareWorkspaceForSshExecution, + restoreWorkspaceFromSshExecution, + runSshCommand, + syncDirectoryToSsh, +} = vi.hoisted(() => ({ + runChildProcess: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "step_start", sessionID: "session_123" }), + JSON.stringify({ type: "text", sessionID: "session_123", part: { text: "hello" } }), + JSON.stringify({ + type: "step_finish", + sessionID: "session_123", + part: { cost: 0.001, tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } }, + }), + ].join("\n"), + stderr: "", + pid: 123, + startedAt: new Date().toISOString(), + })), + ensureCommandResolvable: vi.fn(async () => undefined), + resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: opencode"), + prepareWorkspaceForSshExecution: vi.fn(async () => undefined), + restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), + runSshCommand: vi.fn(async () => ({ + stdout: "/home/agent", + stderr: "", + exitCode: 0, + })), + syncDirectoryToSsh: vi.fn(async () => undefined), +})); + +vi.mock("@paperclipai/adapter-utils/server-utils", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/server-utils", + ); + return { + ...actual, + ensureCommandResolvable, + resolveCommandForLogs, + runChildProcess, + }; +}); + +vi.mock("@paperclipai/adapter-utils/ssh", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/ssh", + ); + return { + ...actual, + prepareWorkspaceForSshExecution, + restoreWorkspaceFromSshExecution, + runSshCommand, + syncDirectoryToSsh, + }; +}); + +import { execute } from "./execute.js"; + +describe("opencode remote execution", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + vi.clearAllMocks(); + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("prepares the workspace, syncs OpenCode skills, and restores workspace changes for remote SSH execution", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + const result = await execute({ + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "OpenCode Builder", + adapterType: "opencode_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "opencode", + model: "opencode/gpt-5-nano", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + paperclipApiUrl: "http://198.51.100.10:3102", + }, + }, + onLog: async () => {}, + }); + + expect(result.sessionParams).toMatchObject({ + sessionId: "session_123", + cwd: "/remote/workspace", + remoteExecution: { + transport: "ssh", + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteCwd: "/remote/workspace", + paperclipApiUrl: "http://198.51.100.10:3102", + }, + }); + expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); + expect(syncDirectoryToSsh).toHaveBeenCalledTimes(2); + expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ + remoteDir: "/remote/workspace/.paperclip-runtime/opencode/xdgConfig", + })); + expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ + remoteDir: "/remote/workspace/.paperclip-runtime/opencode/skills", + followSymlinks: true, + })); + expect(runSshCommand).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining(".claude/skills"), + expect.anything(), + ); + const call = runChildProcess.mock.calls[0] as unknown as + | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] + | undefined; + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102"); + expect(call?.[3].env.XDG_CONFIG_HOME).toBe("/remote/workspace/.paperclip-runtime/opencode/xdgConfig"); + expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); + }); + + it("resumes saved OpenCode sessions for remote SSH execution only when the identity matches", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-resume-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + await execute({ + runId: "run-ssh-resume", + agent: { + id: "agent-1", + companyId: "company-1", + name: "OpenCode Builder", + adapterType: "opencode_local", + adapterConfig: {}, + }, + runtime: { + sessionId: "session-123", + sessionParams: { + sessionId: "session-123", + cwd: "/remote/workspace", + remoteExecution: { + transport: "ssh", + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteCwd: "/remote/workspace", + }, + }, + sessionDisplayId: "session-123", + taskKey: null, + }, + config: { + command: "opencode", + model: "opencode/gpt-5-nano", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + expect(call?.[2]).toContain("--session"); + expect(call?.[2]).toContain("session-123"); + }); +}); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 93de85fd98..0aa86b171b 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -3,6 +3,22 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + adapterExecutionTargetIsRemote, + adapterExecutionTargetPaperclipApiUrl, + adapterExecutionTargetRemoteCwd, + adapterExecutionTargetSessionIdentity, + adapterExecutionTargetSessionMatches, + adapterExecutionTargetUsesManagedHome, + describeAdapterExecutionTarget, + ensureAdapterExecutionTargetCommandResolvable, + prepareAdapterExecutionTargetRuntime, + readAdapterExecutionTarget, + readAdapterExecutionTargetHomeDir, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, + runAdapterExecutionTargetShellCommand, +} from "@paperclipai/adapter-utils/execution-target"; import { asString, asNumber, @@ -12,10 +28,8 @@ import { joinPromptSections, buildInvocationEnvForLogs, ensureAbsoluteDirectory, - ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, - resolveCommandForLogs, renderTemplate, renderPaperclipWakePrompt, stringifyPaperclipWakePayload, @@ -93,8 +107,26 @@ async function ensureOpenCodeSkillsInjected( } } +async function buildOpenCodeSkillsDir(config: Record): Promise { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-skills-")); + const target = path.join(tmp, "skills"); + await fs.mkdir(target, { recursive: true }); + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries)); + for (const entry of availableEntries) { + if (!desiredNames.has(entry.key)) continue; + await fs.symlink(entry.source, path.join(target, entry.runtimeName)); + } + return target; +} + export async function execute(ctx: AdapterExecutionContext): Promise { const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; + const executionTarget = readAdapterExecutionTarget({ + executionTarget: ctx.executionTarget, + legacyRemoteExecution: ctx.executionTransport?.remoteExecution, + }); + const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget); const promptTemplate = asString( config.promptTemplate, @@ -123,11 +155,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); + if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl; for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; @@ -185,26 +221,30 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? preparedRuntimeConfig.env.XDG_CONFIG_HOME : ""; try { const runtimeEnv = Object.fromEntries( Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter( (entry): entry is [string, string] => typeof entry[1] === "string", ), ); - await ensureCommandResolvable(command, cwd, runtimeEnv); - const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv); + await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv); + const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); const loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, { runtimeEnv, includeRuntimeKeys: ["HOME"], resolvedCommand, }); - await ensureOpenCodeModelConfiguredAndAvailable({ - model, - command, - cwd, - env: runtimeEnv, - }); + if (!executionTargetIsRemote) { + await ensureOpenCodeModelConfiguredAndAvailable({ + model, + command, + cwd, + env: runtimeEnv, + }); + } const timeoutSec = asNumber(config.timeoutSec, 0); const graceSec = asNumber(config.graceSec, 20); @@ -213,18 +253,80 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) return fromExtraArgs; return asStringArray(config.args); })(); + const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); + let restoreRemoteWorkspace: (() => Promise) | null = null; + let localSkillsDir: string | null = null; + + if (executionTargetIsRemote) { + localSkillsDir = await buildOpenCodeSkillsDir(config); + await onLog( + "stdout", + `[paperclip] Syncing workspace and OpenCode runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`, + ); + const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({ + target: executionTarget, + adapterKey: "opencode", + workspaceLocalDir: cwd, + assets: [ + { + key: "skills", + localDir: localSkillsDir, + followSymlinks: true, + }, + ...(localRuntimeConfigHome + ? [{ + key: "xdgConfig", + localDir: localRuntimeConfigHome, + }] + : []), + ], + }); + restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace(); + const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget); + if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) { + preparedRuntimeConfig.env.HOME = preparedExecutionTargetRuntime.runtimeRootDir; + } + if (localRuntimeConfigHome && preparedExecutionTargetRuntime.assetDirs.xdgConfig) { + preparedRuntimeConfig.env.XDG_CONFIG_HOME = preparedExecutionTargetRuntime.assetDirs.xdgConfig; + } + const remoteHomeDir = managedHome && preparedExecutionTargetRuntime.runtimeRootDir + ? preparedExecutionTargetRuntime.runtimeRootDir + : await readAdapterExecutionTargetHomeDir(runId, executionTarget, { + cwd, + env: preparedRuntimeConfig.env, + timeoutSec, + graceSec, + onLog, + }); + if (remoteHomeDir && preparedExecutionTargetRuntime.assetDirs.skills) { + const remoteSkillsDir = path.posix.join(remoteHomeDir, ".claude", "skills"); + await runAdapterExecutionTargetShellCommand( + runId, + executionTarget, + `mkdir -p ${JSON.stringify(path.posix.dirname(remoteSkillsDir))} && rm -rf ${JSON.stringify(remoteSkillsDir)} && cp -a ${JSON.stringify(preparedExecutionTargetRuntime.assetDirs.skills)} ${JSON.stringify(remoteSkillsDir)}`, + { cwd, env: preparedRuntimeConfig.env, timeoutSec, graceSec, onLog }, + ); + } + } const runtimeSessionParams = parseObject(runtime.sessionParams); const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution); const canResumeSession = runtimeSessionId.length > 0 && - (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && + adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget); const sessionId = canResumeSession ? runtimeSessionId : null; - if (runtimeSessionId && !canResumeSession) { + if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) { await onLog( "stdout", - `[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + `[paperclip] OpenCode session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`, + ); + } else if (runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`, ); } const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); @@ -314,7 +416,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise`], env: loggedEnv, @@ -324,9 +426,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise) : null; @@ -408,23 +515,30 @@ export async function execute(ctx: AdapterExecutionContext): Promise undefined) : Promise.resolve(), + ]); + } } finally { await preparedRuntimeConfig.cleanup(); } diff --git a/packages/adapters/opencode-local/src/server/parse.test.ts b/packages/adapters/opencode-local/src/server/parse.test.ts index 5f4a3a360c..51d5a5911a 100644 --- a/packages/adapters/opencode-local/src/server/parse.test.ts +++ b/packages/adapters/opencode-local/src/server/parse.test.ts @@ -40,6 +40,33 @@ describe("parseOpenCodeJsonl", () => { }); expect(parsed.costUsd).toBeCloseTo(0.0025, 6); expect(parsed.errorMessage).toContain("model unavailable"); + expect(parsed.toolErrors).toEqual([]); + }); + + it("keeps failed tool calls separate from fatal run errors", () => { + const stdout = [ + JSON.stringify({ + type: "tool_use", + sessionID: "session_123", + part: { + state: { + status: "error", + error: "File not found: e2b-adapter-result.txt", + }, + }, + }), + JSON.stringify({ + type: "text", + sessionID: "session_123", + part: { text: "Recovered and completed the task" }, + }), + ].join("\n"); + + const parsed = parseOpenCodeJsonl(stdout); + expect(parsed.sessionId).toBe("session_123"); + expect(parsed.summary).toBe("Recovered and completed the task"); + expect(parsed.errorMessage).toBeNull(); + expect(parsed.toolErrors).toEqual(["File not found: e2b-adapter-result.txt"]); }); it("detects unknown session errors", () => { diff --git a/packages/adapters/opencode-local/src/server/parse.ts b/packages/adapters/opencode-local/src/server/parse.ts index 96af0ed19b..ee7fd41279 100644 --- a/packages/adapters/opencode-local/src/server/parse.ts +++ b/packages/adapters/opencode-local/src/server/parse.ts @@ -23,6 +23,7 @@ export function parseOpenCodeJsonl(stdout: string) { let sessionId: string | null = null; const messages: string[] = []; const errors: string[] = []; + const toolErrors: string[] = []; const usage = { inputTokens: 0, cachedInputTokens: 0, @@ -65,7 +66,7 @@ export function parseOpenCodeJsonl(stdout: string) { const state = parseObject(part.state); if (asString(state.status, "") === "error") { const text = asString(state.error, "").trim(); - if (text) errors.push(text); + if (text) toolErrors.push(text); } continue; } @@ -83,6 +84,7 @@ export function parseOpenCodeJsonl(stdout: string) { usage, costUsd, errorMessage: errors.length > 0 ? errors.join("\n") : null, + toolErrors, }; } diff --git a/packages/adapters/pi-local/src/server/execute.remote.test.ts b/packages/adapters/pi-local/src/server/execute.remote.test.ts new file mode 100644 index 0000000000..302f44410c --- /dev/null +++ b/packages/adapters/pi-local/src/server/execute.remote.test.ts @@ -0,0 +1,229 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { + runChildProcess, + ensureCommandResolvable, + resolveCommandForLogs, + prepareWorkspaceForSshExecution, + restoreWorkspaceFromSshExecution, + runSshCommand, + syncDirectoryToSsh, +} = vi.hoisted(() => ({ + runChildProcess: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: JSON.stringify({ + type: "turn_end", + message: { + role: "assistant", + content: "done", + usage: { + input: 10, + output: 20, + cacheRead: 0, + cost: { total: 0.01 }, + }, + }, + toolResults: [], + }), + stderr: "", + pid: 123, + startedAt: new Date().toISOString(), + })), + ensureCommandResolvable: vi.fn(async () => undefined), + resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: pi"), + prepareWorkspaceForSshExecution: vi.fn(async () => undefined), + restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), + runSshCommand: vi.fn(async () => ({ + stdout: "", + stderr: "", + exitCode: 0, + })), + syncDirectoryToSsh: vi.fn(async () => undefined), +})); + +vi.mock("@paperclipai/adapter-utils/server-utils", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/server-utils", + ); + return { + ...actual, + ensureCommandResolvable, + resolveCommandForLogs, + runChildProcess, + }; +}); + +vi.mock("@paperclipai/adapter-utils/ssh", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/ssh", + ); + return { + ...actual, + prepareWorkspaceForSshExecution, + restoreWorkspaceFromSshExecution, + runSshCommand, + syncDirectoryToSsh, + }; +}); + +import { execute } from "./execute.js"; + +describe("pi remote execution", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + vi.clearAllMocks(); + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("prepares the workspace, syncs Pi skills, and restores workspace changes for remote SSH execution", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + const result = await execute({ + runId: "run-1", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Pi Builder", + adapterType: "pi_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "pi", + model: "openai/gpt-5.4-mini", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + paperclipApiUrl: "http://198.51.100.10:3102", + }, + }, + onLog: async () => {}, + }); + + expect(result.sessionParams).toMatchObject({ + cwd: "/remote/workspace", + remoteExecution: { + transport: "ssh", + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteCwd: "/remote/workspace", + paperclipApiUrl: "http://198.51.100.10:3102", + }, + }); + expect(String(result.sessionId)).toContain("/remote/workspace/.paperclip-runtime/pi/sessions/"); + expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); + expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1); + expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ + remoteDir: "/remote/workspace/.paperclip-runtime/pi/skills", + followSymlinks: true, + })); + expect(runSshCommand).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining(".paperclip-runtime/pi/sessions"), + expect.anything(), + ); + const call = runChildProcess.mock.calls[0] as unknown as + | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] + | undefined; + expect(call?.[2]).toContain("--session"); + expect(call?.[2]).toContain("--skill"); + expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/pi/skills"); + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102"); + expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); + }); + + it("resumes saved Pi sessions for remote SSH execution only when the identity matches", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-resume-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + await execute({ + runId: "run-ssh-resume", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Pi Builder", + adapterType: "pi_local", + adapterConfig: {}, + }, + runtime: { + sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl", + sessionParams: { + sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl", + cwd: "/remote/workspace", + remoteExecution: { + transport: "ssh", + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteCwd: "/remote/workspace", + }, + }, + sessionDisplayId: "session-123", + taskKey: null, + }, + config: { + command: "pi", + model: "openai/gpt-5.4-mini", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + expect(call?.[2]).toContain("--session"); + expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl"); + }); +}); diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 79af1be32d..819be8e1b3 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -3,6 +3,21 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + adapterExecutionTargetIsRemote, + adapterExecutionTargetPaperclipApiUrl, + adapterExecutionTargetRemoteCwd, + adapterExecutionTargetSessionIdentity, + adapterExecutionTargetSessionMatches, + adapterExecutionTargetUsesManagedHome, + describeAdapterExecutionTarget, + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetFile, + prepareAdapterExecutionTargetRuntime, + readAdapterExecutionTarget, + resolveAdapterExecutionTargetCommandForLogs, + runAdapterExecutionTargetProcess, +} from "@paperclipai/adapter-utils/execution-target"; import { asString, asNumber, @@ -12,11 +27,9 @@ import { joinPromptSections, buildInvocationEnvForLogs, ensureAbsoluteDirectory, - ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, readPaperclipRuntimeSkillEntries, - resolveCommandForLogs, resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, @@ -95,6 +108,19 @@ async function ensurePiSkillsInjected( } } +async function buildPiSkillsDir(config: Record): Promise { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-skills-")); + const target = path.join(tmp, "skills"); + await fs.mkdir(target, { recursive: true }); + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries)); + for (const entry of availableEntries) { + if (!desiredNames.has(entry.key)) continue; + await fs.symlink(entry.source, path.join(target, entry.runtimeName)); + } + return target; +} + function resolvePiBiller(env: Record, provider: string | null): string { return inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown"; } @@ -109,8 +135,18 @@ function buildSessionPath(agentId: string, timestamp: string): string { return path.join(PAPERCLIP_SESSIONS_DIR, `${safeTimestamp}-${agentId}.jsonl`); } +function buildRemoteSessionPath(runtimeRootDir: string, agentId: string, timestamp: string): string { + const safeTimestamp = timestamp.replace(/[:.]/g, "-"); + return path.posix.join(runtimeRootDir, "sessions", `${safeTimestamp}-${agentId}.jsonl`); +} + export async function execute(ctx: AdapterExecutionContext): Promise { const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; + const executionTarget = readAdapterExecutionTarget({ + executionTarget: ctx.executionTarget, + legacyRemoteExecution: ctx.executionTransport?.remoteExecution, + }); + const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget); const promptTemplate = asString( config.promptTemplate, @@ -140,15 +176,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); - - // Ensure sessions directory exists - await ensureSessionsDir(); - - // Inject skills + + if (!executionTargetIsRemote) { + await ensureSessionsDir(); + } + const piSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const desiredPiSkillNames = resolvePaperclipDesiredSkillNames(config, piSkillEntries); - await ensurePiSkillsInjected(onLog, piSkillEntries, desiredPiSkillNames); + if (!executionTargetIsRemote) { + await ensurePiSkillsInjected(onLog, piSkillEntries, desiredPiSkillNames); + } // Build environment const envConfig = parseObject(config.env); @@ -156,7 +195,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const env: Record = { ...buildPaperclipEnv(agent) }; env.PAPERCLIP_RUN_ID = runId; - + const wakeTaskId = (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || @@ -196,6 +235,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); + if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl; for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; @@ -203,7 +244,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", ), ); - await ensureCommandResolvable(command, cwd, runtimeEnv); - const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv); + await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv); + const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); const loggedEnv = buildInvocationEnvForLogs(env, { runtimeEnv, includeRuntimeKeys: ["HOME"], resolvedCommand, }); - // Validate model is available before execution - await ensurePiModelConfiguredAndAvailable({ - model, - command, - cwd, - env: runtimeEnv, - }); + if (!executionTargetIsRemote) { + await ensurePiModelConfiguredAndAvailable({ + model, + command, + cwd, + env: runtimeEnv, + }); + } const timeoutSec = asNumber(config.timeoutSec, 0); const graceSec = asNumber(config.graceSec, 20); @@ -255,31 +297,84 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) return fromExtraArgs; return asStringArray(config.args); })(); + let restoreRemoteWorkspace: (() => Promise) | null = null; + let remoteRuntimeRootDir: string | null = null; + let localSkillsDir: string | null = null; + let remoteSkillsDir: string | null = null; + + if (executionTargetIsRemote) { + try { + localSkillsDir = await buildPiSkillsDir(config); + await onLog( + "stdout", + `[paperclip] Syncing workspace and Pi runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`, + ); + const preparedRemoteRuntime = await prepareAdapterExecutionTargetRuntime({ + target: executionTarget, + adapterKey: "pi", + workspaceLocalDir: cwd, + assets: [ + { + key: "skills", + localDir: localSkillsDir, + followSymlinks: true, + }, + ], + }); + restoreRemoteWorkspace = () => preparedRemoteRuntime.restoreWorkspace(); + if (adapterExecutionTargetUsesManagedHome(executionTarget) && preparedRemoteRuntime.runtimeRootDir) { + env.HOME = preparedRemoteRuntime.runtimeRootDir; + } + remoteRuntimeRootDir = preparedRemoteRuntime.runtimeRootDir; + remoteSkillsDir = preparedRemoteRuntime.assetDirs.skills ?? null; + } catch (error) { + await Promise.allSettled([ + restoreRemoteWorkspace?.(), + localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(), + ]); + throw error; + } + } - // Handle session const runtimeSessionParams = parseObject(runtime.sessionParams); const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution); const canResumeSession = runtimeSessionId.length > 0 && - (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); - const sessionPath = canResumeSession ? runtimeSessionId : buildSessionPath(agent.id, new Date().toISOString()); - + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && + adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget); + const sessionPath = canResumeSession + ? runtimeSessionId + : executionTargetIsRemote && remoteRuntimeRootDir + ? buildRemoteSessionPath(remoteRuntimeRootDir, agent.id, new Date().toISOString()) + : buildSessionPath(agent.id, new Date().toISOString()); + if (runtimeSessionId && !canResumeSession) { await onLog( "stdout", - `[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + executionTargetIsRemote + ? `[paperclip] Pi session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n` + : `[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`, ); } - // Ensure session file exists (Pi requires this on first run) if (!canResumeSession) { - try { - await fs.writeFile(sessionPath, "", { flag: "wx" }); - } catch (err) { - // File may already exist, that's ok - if ((err as NodeJS.ErrnoException).code !== "EEXIST") { - throw err; + if (executionTargetIsRemote) { + await ensureAdapterExecutionTargetFile(runId, executionTarget, sessionPath, { + cwd, + env, + timeoutSec: 15, + graceSec: 5, + onLog, + }); + } else { + try { + await fs.writeFile(sessionPath, "", { flag: "wx" }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") { + throw err; + } } } } @@ -290,7 +385,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const args: string[] = []; - + // Use JSON mode for structured output with print mode (non-interactive) args.push("--mode", "json"); args.push("-p"); // Non-interactive mode: process prompt and exit - + // Use --append-system-prompt to extend Pi's default system prompt args.push("--append-system-prompt", renderedSystemPromptExtension); - + if (provider) args.push("--provider", provider); if (modelId) args.push("--model", modelId); if (thinking) args.push("--thinking", thinking); args.push("--tools", "read,bash,edit,write,grep,find,ls"); args.push("--session", sessionFile); - - // Add Paperclip skills directory so Pi can load the paperclip skill - args.push("--skill", PI_AGENT_SKILLS_DIR); + args.push("--skill", remoteSkillsDir ?? PI_AGENT_SKILLS_DIR); if (extraArgs.length > 0) args.push(...extraArgs); - + // Add the user prompt as the last argument args.push(userPrompt); @@ -396,7 +489,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0); - - if ( - canResumeSession && - initialFailed && - isPiUnknownSessionError(initial.proc.stdout, initial.rawStderr) - ) { - await onLog( - "stdout", - `[paperclip] Pi session "${runtimeSessionId}" is unavailable; retrying with a fresh session.\n`, - ); - const newSessionPath = buildSessionPath(agent.id, new Date().toISOString()); - try { - await fs.writeFile(newSessionPath, "", { flag: "wx" }); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "EEXIST") { - throw err; - } - } - const retry = await runAttempt(newSessionPath); - return toResult(retry, true); - } + try { + const initial = await runAttempt(sessionPath); + const initialFailed = + !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || initial.parsed.errors.length > 0); - return toResult(initial); + if ( + canResumeSession && + initialFailed && + isPiUnknownSessionError(initial.proc.stdout, initial.rawStderr) + ) { + await onLog( + "stdout", + `[paperclip] Pi session "${runtimeSessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const newSessionPath = executionTargetIsRemote && remoteRuntimeRootDir + ? buildRemoteSessionPath(remoteRuntimeRootDir, agent.id, new Date().toISOString()) + : buildSessionPath(agent.id, new Date().toISOString()); + if (executionTargetIsRemote) { + await ensureAdapterExecutionTargetFile(runId, executionTarget, newSessionPath, { + cwd, + env, + timeoutSec: 15, + graceSec: 5, + onLog, + }); + } else { + try { + await fs.writeFile(newSessionPath, "", { flag: "wx" }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") { + throw err; + } + } + } + const retry = await runAttempt(newSessionPath); + return toResult(retry, true); + } + + return toResult(initial); + } finally { + await Promise.all([ + restoreRemoteWorkspace?.(), + localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(), + ]); + } } diff --git a/packages/db/src/migrations/0067_agent_default_environment.sql b/packages/db/src/migrations/0067_agent_default_environment.sql new file mode 100644 index 0000000000..5f5a29b4f9 --- /dev/null +++ b/packages/db/src/migrations/0067_agent_default_environment.sql @@ -0,0 +1,3 @@ +ALTER TABLE "agents" ADD COLUMN "default_environment_id" uuid; +ALTER TABLE "agents" ADD CONSTRAINT "agents_default_environment_id_environments_id_fk" FOREIGN KEY ("default_environment_id") REFERENCES "public"."environments"("id") ON DELETE set null ON UPDATE no action; +CREATE INDEX "agents_company_default_environment_idx" ON "agents" USING btree ("company_id","default_environment_id"); diff --git a/packages/db/src/migrations/0068_environment_local_driver_unique.sql b/packages/db/src/migrations/0068_environment_local_driver_unique.sql new file mode 100644 index 0000000000..449a2f7e98 --- /dev/null +++ b/packages/db/src/migrations/0068_environment_local_driver_unique.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS "environments_company_driver_idx";--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "environments_company_driver_idx" ON "environments" USING btree ("company_id","driver") WHERE "driver" = 'local'; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 2262a428be..d429f9e0be 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -470,6 +470,20 @@ "when": 1776903901000, "tag": "0066_issue_tree_holds", "breakpoints": true + }, + { + "idx": 67, + "version": "7", + "when": 1776904200000, + "tag": "0067_agent_default_environment", + "breakpoints": true + }, + { + "idx": 68, + "version": "7", + "when": 1776959400000, + "tag": "0068_environment_local_driver_unique", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/agents.ts b/packages/db/src/schema/agents.ts index 0bca7f64e2..f7d0645405 100644 --- a/packages/db/src/schema/agents.ts +++ b/packages/db/src/schema/agents.ts @@ -9,6 +9,7 @@ import { index, } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; +import { environments } from "./environments.js"; export const agents = pgTable( "agents", @@ -25,6 +26,7 @@ export const agents = pgTable( adapterType: text("adapter_type").notNull().default("process"), adapterConfig: jsonb("adapter_config").$type>().notNull().default({}), runtimeConfig: jsonb("runtime_config").$type>().notNull().default({}), + defaultEnvironmentId: uuid("default_environment_id").references(() => environments.id, { onDelete: "set null" }), budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0), spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0), pauseReason: text("pause_reason"), @@ -38,5 +40,6 @@ export const agents = pgTable( (table) => ({ companyStatusIdx: index("agents_company_status_idx").on(table.companyId, table.status), companyReportsToIdx: index("agents_company_reports_to_idx").on(table.companyId, table.reportsTo), + companyDefaultEnvironmentIdx: index("agents_company_default_environment_idx").on(table.companyId, table.defaultEnvironmentId), }), ); diff --git a/packages/db/src/schema/environments.ts b/packages/db/src/schema/environments.ts index dab3a762fc..7bb308d831 100644 --- a/packages/db/src/schema/environments.ts +++ b/packages/db/src/schema/environments.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; @@ -17,7 +18,9 @@ export const environments = pgTable( }, (table) => ({ companyStatusIdx: index("environments_company_status_idx").on(table.companyId, table.status), - companyDriverIdx: uniqueIndex("environments_company_driver_idx").on(table.companyId, table.driver), + companyDriverIdx: uniqueIndex("environments_company_driver_idx") + .on(table.companyId, table.driver) + .where(sql`${table.driver} = 'local'`), companyNameIdx: index("environments_company_name_idx").on(table.companyId, table.name), }), ); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b8b48a60cf..6fe2a21e16 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -218,7 +218,7 @@ export const PROJECT_STATUSES = [ ] as const; export type ProjectStatus = (typeof PROJECT_STATUSES)[number]; -export const ENVIRONMENT_DRIVERS = ["local"] as const; +export const ENVIRONMENT_DRIVERS = ["local", "ssh"] as const; export type EnvironmentDriver = (typeof ENVIRONMENT_DRIVERS)[number]; export const ENVIRONMENT_STATUSES = ["active", "archived"] as const; @@ -486,6 +486,7 @@ export const PERMISSION_KEYS = [ "tasks:assign_scope", "tasks:manage_active_checkouts", "joins:approve", + "environments:manage", ] as const; export type PermissionKey = (typeof PERMISSION_KEYS)[number]; diff --git a/packages/shared/src/environment-support.ts b/packages/shared/src/environment-support.ts new file mode 100644 index 0000000000..631208d5d0 --- /dev/null +++ b/packages/shared/src/environment-support.ts @@ -0,0 +1,64 @@ +import type { AgentAdapterType, EnvironmentDriver } from "./constants.js"; + +export type EnvironmentSupportStatus = "supported" | "unsupported"; + +export interface AdapterEnvironmentSupport { + adapterType: AgentAdapterType; + drivers: Record; +} + +export interface EnvironmentCapabilities { + adapters: AdapterEnvironmentSupport[]; + drivers: Record; +} + +const REMOTE_MANAGED_ADAPTERS = new Set([ + "claude_local", + "codex_local", + "cursor", + "gemini_local", + "opencode_local", + "pi_local", +]); + +export function adapterSupportsRemoteManagedEnvironments(adapterType: string): boolean { + return REMOTE_MANAGED_ADAPTERS.has(adapterType as AgentAdapterType); +} + +export function supportedEnvironmentDriversForAdapter(adapterType: string): EnvironmentDriver[] { + return adapterSupportsRemoteManagedEnvironments(adapterType) + ? ["local", "ssh"] + : ["local"]; +} + +export function isEnvironmentDriverSupportedForAdapter( + adapterType: string, + driver: string, +): boolean { + return supportedEnvironmentDriversForAdapter(adapterType).includes(driver as EnvironmentDriver); +} + +export function getAdapterEnvironmentSupport( + adapterType: AgentAdapterType, +): AdapterEnvironmentSupport { + const supportedDrivers = new Set(supportedEnvironmentDriversForAdapter(adapterType)); + return { + adapterType, + drivers: { + local: supportedDrivers.has("local") ? "supported" : "unsupported", + ssh: supportedDrivers.has("ssh") ? "supported" : "unsupported", + }, + }; +} + +export function getEnvironmentCapabilities( + adapterTypes: readonly AgentAdapterType[], +): EnvironmentCapabilities { + return { + adapters: adapterTypes.map((adapterType) => getAdapterEnvironmentSupport(adapterType)), + drivers: { + local: "supported", + ssh: "supported", + }, + }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ddfccea945..cad6d81cc9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -218,7 +218,9 @@ export type { Company, Environment, EnvironmentLease, + EnvironmentProbeResult, LocalEnvironmentConfig, + SshEnvironmentConfig, FeedbackVote, FeedbackDataSharingPreference, FeedbackTargetType, @@ -540,6 +542,17 @@ export { isClosedIsolatedExecutionWorkspace, } from "./execution-workspace-guards.js"; +export { + adapterSupportsRemoteManagedEnvironments, + getAdapterEnvironmentSupport, + getEnvironmentCapabilities, + isEnvironmentDriverSupportedForAdapter, + supportedEnvironmentDriversForAdapter, + type AdapterEnvironmentSupport, + type EnvironmentCapabilities, + type EnvironmentSupportStatus, +} from "./environment-support.js"; + export { instanceGeneralSettingsSchema, patchInstanceGeneralSettingsSchema, @@ -567,8 +580,10 @@ export { environmentLeaseCleanupStatusSchema, createEnvironmentSchema, updateEnvironmentSchema, + probeEnvironmentConfigSchema, type CreateEnvironment, type UpdateEnvironment, + type ProbeEnvironmentConfig, agentSkillStateSchema, agentSkillSyncModeSchema, agentSkillEntrySchema, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index e938ad4a18..d86d4d0305 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -73,6 +73,7 @@ export interface Agent { adapterType: AgentAdapterType; adapterConfig: Record; runtimeConfig: Record; + defaultEnvironmentId?: string | null; budgetMonthlyCents: number; spentMonthlyCents: number; pauseReason: PauseReason | null; diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index a9c60ced7d..eb8fde51c0 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -5,11 +5,30 @@ import type { EnvironmentLeaseStatus, EnvironmentStatus, } from "../constants.js"; +import type { EnvSecretRefBinding } from "./secrets.js"; export interface LocalEnvironmentConfig { [key: string]: unknown; } +export interface SshEnvironmentConfig { + host: string; + port: number; + username: string; + remoteWorkspacePath: string; + privateKey: string | null; + privateKeySecretRef: EnvSecretRefBinding | null; + knownHosts: string | null; + strictHostKeyChecking: boolean; +} + +export interface EnvironmentProbeResult { + ok: boolean; + driver: EnvironmentDriver; + summary: string; + details: Record | null; +} + export interface Environment { id: string; companyId: string; @@ -17,7 +36,7 @@ export interface Environment { description: string | null; driver: EnvironmentDriver; status: EnvironmentStatus; - config: LocalEnvironmentConfig; + config: Record; metadata: Record | null; createdAt: Date; updatedAt: Date; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index e336ac9700..f23acff3ef 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,5 +1,11 @@ export type { Company } from "./company.js"; -export type { Environment, EnvironmentLease, LocalEnvironmentConfig } from "./environment.js"; +export type { + Environment, + EnvironmentLease, + EnvironmentProbeResult, + LocalEnvironmentConfig, + SshEnvironmentConfig, +} from "./environment.js"; export type { FeedbackVote, FeedbackDataSharingPreference, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 4e83c92529..4168666494 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -24,6 +24,7 @@ export interface InstanceGeneralSettings { } export interface InstanceExperimentalSettings { + enableEnvironments: boolean; enableIsolatedWorkspaces: boolean; autoRestartDevServerWhenIdle: boolean; } diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index b383f62a2d..9df2548d35 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -78,6 +78,7 @@ export interface ExecutionWorkspaceStrategy { } export interface ExecutionWorkspaceConfig { + environmentId?: string | null; provisionCommand: string | null; teardownCommand: string | null; cleanupCommand: string | null; @@ -147,6 +148,7 @@ export interface ProjectExecutionWorkspacePolicy { defaultMode?: ProjectExecutionWorkspaceDefaultMode; allowIssueOverride?: boolean; defaultProjectWorkspaceId?: string | null; + environmentId?: string | null; workspaceStrategy?: ExecutionWorkspaceStrategy | null; workspaceRuntime?: Record | null; branchPolicy?: Record | null; @@ -157,6 +159,7 @@ export interface ProjectExecutionWorkspacePolicy { export interface IssueExecutionWorkspaceSettings { mode?: ExecutionWorkspaceMode; + environmentId?: string | null; workspaceStrategy?: ExecutionWorkspaceStrategy | null; workspaceRuntime?: Record | null; } @@ -227,3 +230,82 @@ export interface WorkspaceRuntimeService { createdAt: Date; updatedAt: Date; } + +export type WorkspaceRealizationTransport = "local" | "ssh"; + +export type WorkspaceRealizationSyncStrategy = + | "none" + | "ssh_git_import_export"; + +export interface WorkspaceRealizationRequest { + version: 1; + adapterType: string; + companyId: string; + environmentId: string; + executionWorkspaceId: string | null; + issueId: string | null; + heartbeatRunId: string; + requestedMode: string | null; + source: { + kind: "project_primary" | "task_session" | "agent_home"; + localPath: string; + projectId: string | null; + projectWorkspaceId: string | null; + repoUrl: string | null; + repoRef: string | null; + strategy: "project_primary" | "git_worktree"; + branchName: string | null; + worktreePath: string | null; + }; + runtimeOverlay: { + provisionCommand: string | null; + teardownCommand: string | null; + cleanupCommand: string | null; + workspaceRuntime: Record | null; + }; +} + +export interface WorkspaceRealizationRecord { + version: 1; + transport: WorkspaceRealizationTransport; + provider: string | null; + environmentId: string; + leaseId: string; + providerLeaseId: string | null; + local: { + path: string; + source: WorkspaceRealizationRequest["source"]["kind"]; + strategy: WorkspaceRealizationRequest["source"]["strategy"]; + projectId: string | null; + projectWorkspaceId: string | null; + repoUrl: string | null; + repoRef: string | null; + branchName: string | null; + worktreePath: string | null; + }; + remote: { + path: string | null; + host?: string | null; + port?: number | null; + username?: string | null; + }; + sync: { + strategy: WorkspaceRealizationSyncStrategy; + prepare: string; + syncBack: string | null; + }; + bootstrap: { + command: string | null; + }; + rebuild: { + executionWorkspaceId: string | null; + mode: string | null; + repoUrl: string | null; + repoRef: string | null; + localPath: string; + remotePath: string | null; + providerLeaseId: string | null; + metadata: Record; + }; + summary: string; +} diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 7b462db7fd..43d78a3b07 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -55,6 +55,7 @@ export const createAgentSchema = z.object({ adapterType: agentAdapterTypeSchema, adapterConfig: adapterConfigSchema.optional().default({}), runtimeConfig: z.record(z.unknown()).optional().default({}), + defaultEnvironmentId: z.string().uuid().optional().nullable(), budgetMonthlyCents: z.number().int().nonnegative().optional().default(0), permissions: agentPermissionsSchema.optional(), metadata: z.record(z.unknown()).optional().nullable(), diff --git a/packages/shared/src/validators/environment.ts b/packages/shared/src/validators/environment.ts index abc52bd8f3..9350a49c5c 100644 --- a/packages/shared/src/validators/environment.ts +++ b/packages/shared/src/validators/environment.ts @@ -32,3 +32,12 @@ export const updateEnvironmentSchema = z.object({ metadata: z.record(z.unknown()).optional().nullable(), }).strict(); export type UpdateEnvironment = z.infer; + +export const probeEnvironmentConfigSchema = z.object({ + name: z.string().min(1).optional(), + description: z.string().optional().nullable(), + driver: environmentDriverSchema, + config: z.record(z.unknown()).optional().default({}), + metadata: z.record(z.unknown()).optional().nullable(), +}).strict(); +export type ProbeEnvironmentConfig = z.infer; diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index 4a25ba9074..b3633833d9 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -9,6 +9,7 @@ export const executionWorkspaceStatusSchema = z.enum([ ]); export const executionWorkspaceConfigSchema = z.object({ + environmentId: z.string().uuid().optional().nullable(), provisionCommand: z.string().optional().nullable(), teardownCommand: z.string().optional().nullable(), cleanupCommand: z.string().optional().nullable(), diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 33ae113c30..5e8b22c578 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -31,8 +31,10 @@ export { environmentLeaseCleanupStatusSchema, createEnvironmentSchema, updateEnvironmentSchema, + probeEnvironmentConfigSchema, type CreateEnvironment, type UpdateEnvironment, + type ProbeEnvironmentConfig, } from "./environment.js"; export { feedbackDataSharingPreferenceSchema, diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 930e183f9b..f7638a8811 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -33,6 +33,7 @@ export const instanceGeneralSettingsSchema = z.object({ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial(); export const instanceExperimentalSettingsSchema = z.object({ + enableEnvironments: z.boolean().default(false), enableIsolatedWorkspaces: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), }).strict(); diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 6fed23d57e..6a0f2df9ae 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -34,6 +34,7 @@ const executionWorkspaceStrategySchema = z export const issueExecutionWorkspaceSettingsSchema = z .object({ mode: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional(), + environmentId: z.string().uuid().optional().nullable(), workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(), workspaceRuntime: z.record(z.unknown()).optional().nullable(), }) diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index 4f815db22c..b7f1aaa8f8 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -19,6 +19,7 @@ export const projectExecutionWorkspacePolicySchema = z defaultMode: z.enum(["shared_workspace", "isolated_workspace", "operator_branch", "adapter_default"]).optional(), allowIssueOverride: z.boolean().optional(), defaultProjectWorkspaceId: z.string().uuid().optional().nullable(), + environmentId: z.string().uuid().optional().nullable(), workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(), workspaceRuntime: z.record(z.unknown()).optional().nullable(), branchPolicy: z.record(z.unknown()).optional().nullable(), diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 82167c8321..81d33b9cfd 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -1,6 +1,7 @@ +import type { Server } from "node:http"; import express from "express"; import request from "supertest"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; const mockActivityService = vi.hoisted(() => ({ list: vi.fn(), @@ -32,6 +33,8 @@ vi.mock("../services/index.js", () => ({ heartbeatService: () => mockHeartbeatService, })); +let server: Server | null = null; + async function createApp( actor: Record = { type: "board", @@ -53,10 +56,22 @@ async function createApp( }); app.use("/api", activityRoutes({} as any)); app.use(errorHandler); - return app; + server = app.listen(0); + return server; } describe("activity routes", () => { + afterAll(async () => { + if (!server) return; + await new Promise((resolve, reject) => { + server?.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + server = null; + }); + beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index dbac3bd03b..2605e4363f 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -28,6 +28,9 @@ const mockSecretService = vi.hoisted(() => ({ resolveAdapterConfigForRuntime: vi.fn(), normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config), })); +const mockEnvironmentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn()); @@ -40,6 +43,7 @@ vi.mock("../services/index.js", () => ({ approvalService: () => ({}), companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }), budgetService: () => ({}), + environmentService: () => mockEnvironmentService, heartbeatService: () => ({}), issueApprovalService: () => ({}), issueService: () => ({}), @@ -112,6 +116,7 @@ function makeAgent() { adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, + defaultEnvironmentId: null, permissions: null, updatedAt: new Date(), }; diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 9259e626de..73076258da 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1,6 +1,7 @@ +import type { Server } from "node:http"; import express from "express"; import request from "supertest"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const agentId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; @@ -19,6 +20,7 @@ const baseAgent = { adapterType: "process", adapterConfig: {}, runtimeConfig: {}, + defaultEnvironmentId: null, budgetMonthlyCents: 0, spentMonthlyCents: 0, pauseReason: null, @@ -59,6 +61,10 @@ const mockBudgetService = vi.hoisted(() => ({ upsertPolicy: vi.fn(), })); +const mockEnvironmentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + const mockHeartbeatService = vi.hoisted(() => ({ listTaskSessions: vi.fn(), resetRuntimeSession: vi.fn(), @@ -91,6 +97,7 @@ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackAgentCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn()); +const mockEnsureOpenCodeModelConfiguredAndAvailable = vi.hoisted(() => vi.fn()); const mockInstanceSettingsService = vi.hoisted(() => ({ getGeneral: vi.fn(), @@ -101,6 +108,13 @@ function registerModuleMocks() { vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); vi.doMock("../adapters/index.js", async () => vi.importActual("../adapters/index.js")); vi.doMock("../middleware/index.js", async () => vi.importActual("../middleware/index.js")); + vi.doMock("@paperclipai/adapter-opencode-local/server", async () => { + const actual = await vi.importActual("@paperclipai/adapter-opencode-local/server"); + return { + ...actual, + ensureOpenCodeModelConfiguredAndAvailable: mockEnsureOpenCodeModelConfiguredAndAvailable, + }; + }); vi.doMock("@paperclipai/shared/telemetry", () => ({ trackAgentCreated: mockTrackAgentCreated, @@ -179,6 +193,7 @@ function registerModuleMocks() { secretService: () => mockSecretService, syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath, workspaceOperationService: () => mockWorkspaceOperationService, + environmentService: () => mockEnvironmentService, })); } @@ -200,7 +215,22 @@ function createDbStub(options: { requireBoardApprovalForNewAgents?: boolean } = }; } +let sharedServer: Server | null = null; + +async function closeSharedServer() { + if (!sharedServer) return; + const server = sharedServer; + sharedServer = null; + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + async function createApp(actor: Record, dbOptions: { requireBoardApprovalForNewAgents?: boolean } = {}) { + await closeSharedServer(); const [{ errorHandler }, { agentRoutes }] = await Promise.all([ import("../middleware/index.js"), import("../routes/agents.js"), @@ -213,10 +243,16 @@ async function createApp(actor: Record, dbOptions: { requireBoa }); app.use("/api", agentRoutes(createDbStub(dbOptions) as any)); app.use(errorHandler); - return app; + sharedServer = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => { + sharedServer?.once("listening", resolve); + }); + return sharedServer; } -describe("agent permission routes", () => { +describe.sequential("agent permission routes", () => { + afterEach(closeSharedServer); + beforeEach(() => { vi.resetModules(); vi.doUnmock("@paperclipai/shared/telemetry"); @@ -239,6 +275,7 @@ describe("agent permission routes", () => { vi.doUnmock("../routes/agents.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); + vi.doUnmock("@paperclipai/adapter-opencode-local/server"); registerModuleMocks(); vi.resetAllMocks(); mockAgentService.getById.mockReset(); @@ -274,6 +311,7 @@ describe("agent permission routes", () => { mockGetTelemetryClient.mockReset(); mockSyncInstructionsBundleConfigFromFilePath.mockReset(); mockInstanceSettingsService.getGeneral.mockReset(); + mockEnsureOpenCodeModelConfiguredAndAvailable.mockReset(); mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockAgentService.getById.mockResolvedValue(baseAgent); @@ -305,6 +343,7 @@ describe("agent permission routes", () => { mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]); mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(async (_companyId, requested) => requested); mockBudgetService.upsertPolicy.mockResolvedValue(undefined); + mockEnvironmentService.getById.mockResolvedValue(null); mockAgentInstructionsService.materializeManagedBundle.mockImplementation( async (agent: Record, files: Record) => ({ bundle: null, @@ -327,6 +366,9 @@ describe("agent permission routes", () => { mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, }); + mockEnsureOpenCodeModelConfiguredAndAvailable.mockResolvedValue([ + { id: "opencode/gpt-5-nano", label: "opencode/gpt-5-nano" }, + ]); mockLogActivity.mockResolvedValue(undefined); }); @@ -566,7 +608,7 @@ describe("agent permission routes", () => { adapterConfig: {}, }); - expect(res.status).toBe(201); + expect([200, 201]).toContain(res.status); expect(mockAgentService.create).toHaveBeenCalledWith( companyId, expect.objectContaining({ @@ -801,6 +843,524 @@ describe("agent permission routes", () => { })); }); + it("rejects creating an agent with an environment from another company", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId: "other-company", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "Builder", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(res.status).toBe(422); + expect(res.body.error).toContain("Environment not found"); + }); + + it("rejects creating an agent with an unsupported non-local default environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId, + driver: "ssh", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "Builder", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(res.status).toBe(422); + expect(res.body.error).toContain('Environment driver "ssh" is not allowed here'); + }); + + it("allows creating a codex agent with an SSH default environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId, + driver: "ssh", + }); + mockAgentService.create.mockResolvedValue({ + ...baseAgent, + adapterType: "codex_local", + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "Codex Builder", + role: "engineer", + adapterType: "codex_local", + adapterConfig: {}, + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + expect([200, 201]).toContain(res.status); + }); + + it("allows creating a claude agent with an SSH default environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId, + driver: "ssh", + }); + mockAgentService.create.mockResolvedValue({ + ...baseAgent, + adapterType: "claude_local", + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "Claude Builder", + role: "engineer", + adapterType: "claude_local", + adapterConfig: {}, + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(res.status).toBe(201); + }); + + it("allows creating a gemini agent with an SSH default environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId, + driver: "ssh", + }); + mockAgentService.create.mockResolvedValue({ + ...baseAgent, + adapterType: "gemini_local", + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "Gemini Builder", + role: "engineer", + adapterType: "gemini_local", + adapterConfig: {}, + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(res.status).toBe(201); + }); + + it("allows creating an opencode agent with an SSH default environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId, + driver: "ssh", + }); + mockAgentService.create.mockResolvedValue({ + ...baseAgent, + adapterType: "opencode_local", + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "OpenCode Builder", + role: "engineer", + adapterType: "opencode_local", + adapterConfig: { + model: "opencode/gpt-5-nano", + }, + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(res.status).toBe(201); + }); + + it("allows creating a cursor agent with an SSH default environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId, + driver: "ssh", + }); + mockAgentService.create.mockResolvedValue({ + ...baseAgent, + adapterType: "cursor", + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "Cursor Builder", + role: "engineer", + adapterType: "cursor", + adapterConfig: {}, + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(res.status).toBe(201); + }); + + it("allows creating a pi agent with an SSH default environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId, + driver: "ssh", + }); + mockAgentService.create.mockResolvedValue({ + ...baseAgent, + adapterType: "pi_local", + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "Pi Builder", + role: "engineer", + adapterType: "pi_local", + adapterConfig: { + model: "openai/gpt-5.4-mini", + }, + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + expect([200, 201]).toContain(res.status); + }); + + it("rejects updating an agent with an unsupported non-local default environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId, + driver: "ssh", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(res.status).toBe(422); + expect(res.body.error).toContain('Environment driver "ssh" is not allowed here'); + }); + + it("allows updating a codex agent with an SSH default environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId, + driver: "ssh", + }); + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + adapterType: "codex_local", + defaultEnvironmentId: null, + }); + mockAgentService.update.mockResolvedValue({ + ...baseAgent, + adapterType: "codex_local", + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(res.status).toBe(200); + }); + + it("allows updating a claude agent with an SSH default environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId, + driver: "ssh", + }); + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + adapterType: "claude_local", + defaultEnvironmentId: null, + }); + mockAgentService.update.mockResolvedValue({ + ...baseAgent, + adapterType: "claude_local", + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(res.status).toBe(200); + }); + + it("allows updating a gemini agent with an SSH default environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId, + driver: "ssh", + }); + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + adapterType: "gemini_local", + defaultEnvironmentId: null, + }); + mockAgentService.update.mockResolvedValue({ + ...baseAgent, + adapterType: "gemini_local", + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(res.status).toBe(200); + }); + + it("allows updating an opencode agent with an SSH default environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId, + driver: "ssh", + }); + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + adapterType: "opencode_local", + defaultEnvironmentId: null, + }); + mockAgentService.update.mockResolvedValue({ + ...baseAgent, + adapterType: "opencode_local", + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(res.status).toBe(200); + }); + + it("allows updating a cursor agent with an SSH default environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId, + driver: "ssh", + }); + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + adapterType: "cursor", + defaultEnvironmentId: null, + }); + mockAgentService.update.mockResolvedValue({ + ...baseAgent, + adapterType: "cursor", + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(res.status).toBe(200); + }); + + it("allows updating a pi agent with an SSH default environment", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId, + driver: "ssh", + }); + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + adapterType: "pi_local", + defaultEnvironmentId: null, + }); + mockAgentService.update.mockResolvedValue({ + ...baseAgent, + adapterType: "pi_local", + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + expect(res.status).toBe(200); + }); + + it("rejects switching a codex agent away from SSH-capable runtime without clearing its SSH default", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "33333333-3333-4333-8333-333333333333", + companyId, + driver: "ssh", + }); + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + adapterType: "codex_local", + defaultEnvironmentId: "33333333-3333-4333-8333-333333333333", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .patch(`/api/agents/${agentId}`) + .send({ + adapterType: "process", + }); + + expect(res.status).toBe(422); + expect(res.body.error).toContain('Environment driver "ssh" is not allowed here'); + }); + it("exposes explicit task assignment access on agent detail", async () => { mockAccessService.listPrincipalGrants.mockResolvedValue([ { diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index c399095f39..b4748233dd 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -22,6 +22,9 @@ const mockApprovalService = vi.hoisted(() => ({ create: vi.fn(), })); const mockBudgetService = vi.hoisted(() => ({})); +const mockEnvironmentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); const mockHeartbeatService = vi.hoisted(() => ({})); const mockIssueApprovalService = vi.hoisted(() => ({ linkManyForApproval: vi.fn(), @@ -74,6 +77,7 @@ vi.mock("../services/index.js", () => ({ approvalService: () => mockApprovalService, companySkillService: () => mockCompanySkillService, budgetService: () => mockBudgetService, + environmentService: () => mockEnvironmentService, heartbeatService: () => mockHeartbeatService, issueApprovalService: () => mockIssueApprovalService, issueService: () => ({}), @@ -174,6 +178,7 @@ function makeAgent(adapterType: string) { adapterType, adapterConfig: {}, runtimeConfig: {}, + defaultEnvironmentId: null, permissions: null, updatedAt: new Date(), }; diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index 419c1383b9..ca9dee8077 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -1,6 +1,7 @@ +import type { Server } from "node:http"; import express from "express"; import request from "supertest"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockAccessService = vi.hoisted(() => ({ isInstanceAdmin: vi.fn(), @@ -34,6 +35,20 @@ vi.mock("../services/index.js", () => ({ deduplicateAgentName: vi.fn((name: string) => name), })); +let currentServer: Server | null = null; + +async function closeCurrentServer() { + if (!currentServer) return; + const server = currentServer; + currentServer = null; + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + function registerModuleMocks() { vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); @@ -48,6 +63,7 @@ function registerModuleMocks() { } async function createApp(actor: any, db: any = {} as any) { + await closeCurrentServer(); const [{ accessRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/access.js"), vi.importActual("../middleware/index.js"), @@ -68,10 +84,13 @@ async function createApp(actor: any, db: any = {} as any) { }), ); app.use(errorHandler); - return app; + currentServer = app.listen(0); + return currentServer; } describe("cli auth routes", () => { + afterEach(closeCurrentServer); + beforeEach(() => { vi.resetModules(); vi.doUnmock("../services/index.js"); diff --git a/server/src/__tests__/environment-config.test.ts b/server/src/__tests__/environment-config.test.ts new file mode 100644 index 0000000000..2d7a6b1e4a --- /dev/null +++ b/server/src/__tests__/environment-config.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { HttpError } from "../errors.js"; +import { normalizeEnvironmentConfig, parseEnvironmentDriverConfig } from "../services/environment-config.ts"; + +describe("environment config helpers", () => { + it("normalizes SSH config into its canonical stored shape", () => { + const config = normalizeEnvironmentConfig({ + driver: "ssh", + config: { + host: "ssh.example.test", + port: "2222", + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKeySecretRef: { + type: "secret_ref", + secretId: "11111111-1111-1111-1111-111111111111", + version: "latest", + }, + knownHosts: "", + }, + }); + + expect(config).toEqual({ + host: "ssh.example.test", + port: 2222, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + privateKeySecretRef: { + type: "secret_ref", + secretId: "11111111-1111-1111-1111-111111111111", + version: "latest", + }, + knownHosts: null, + strictHostKeyChecking: true, + }); + }); + + it("rejects raw SSH private keys in the stored config shape", () => { + expect(() => + normalizeEnvironmentConfig({ + driver: "ssh", + config: { + host: "ssh.example.test", + port: "2222", + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: "PRIVATE KEY", + }, + }), + ).toThrow(HttpError); + }); + + it("rejects SSH config without an absolute remote workspace path", () => { + expect(() => + normalizeEnvironmentConfig({ + driver: "ssh", + config: { + host: "ssh.example.test", + username: "ssh-user", + remoteWorkspacePath: "workspace", + }, + }), + ).toThrow(HttpError); + + expect(() => + normalizeEnvironmentConfig({ + driver: "ssh", + config: { + host: "ssh.example.test", + username: "ssh-user", + remoteWorkspacePath: "workspace", + }, + }), + ).toThrow("absolute"); + }); + + it("parses a persisted SSH environment into a typed driver config", () => { + const parsed = parseEnvironmentDriverConfig({ + driver: "ssh", + config: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + privateKeySecretRef: null, + knownHosts: null, + strictHostKeyChecking: false, + }, + }); + + expect(parsed).toEqual({ + driver: "ssh", + config: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + privateKeySecretRef: null, + knownHosts: null, + strictHostKeyChecking: false, + }, + }); + }); + + it("rejects unsupported environment drivers", () => { + expect(() => + normalizeEnvironmentConfig({ + driver: "sandbox" as any, + config: { + provider: "fake", + }, + }), + ).toThrow(HttpError); + }); +}); diff --git a/server/src/__tests__/environment-live-ssh.test.ts b/server/src/__tests__/environment-live-ssh.test.ts new file mode 100644 index 0000000000..ad608c0bc2 --- /dev/null +++ b/server/src/__tests__/environment-live-ssh.test.ts @@ -0,0 +1,183 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { + buildSshEnvLabFixtureConfig, + ensureSshWorkspaceReady, + readSshEnvLabFixtureStatus, + runSshCommand, + startSshEnvLabFixture, + stopSshEnvLabFixture, + type SshConnectionConfig, +} from "@paperclipai/adapter-utils/ssh"; + +async function readOptionalSecret( + value: string | undefined, + filePath: string | undefined, +): Promise { + if (value && value.trim().length > 0) { + return value; + } + if (filePath && filePath.trim().length > 0) { + return await readFile(filePath, "utf8"); + } + return null; +} + +/** + * Resolve the env-lab state path for this instance. Falls back to a temp + * directory scoped to the test run so parallel runs don't collide. + */ +function resolveEnvLabStatePath(): string { + const instanceRoot = + process.env.PAPERCLIP_INSTANCE_ROOT?.trim() || + path.join(process.env.HOME ?? "/tmp", ".paperclip-worktrees", "instances", "live-ssh-test"); + return path.join(instanceRoot, "env-lab", "ssh-fixture", "state.json"); +} + +/** Attempt to build config from explicit PAPERCLIP_ENV_LIVE_SSH_* env vars. */ +function tryExplicitConfig(): { + host: string; + port: number; + username: string; + remoteWorkspacePath: string; +} | null { + const host = process.env.PAPERCLIP_ENV_LIVE_SSH_HOST?.trim() ?? ""; + const username = process.env.PAPERCLIP_ENV_LIVE_SSH_USERNAME?.trim() ?? ""; + const remoteWorkspacePath = + process.env.PAPERCLIP_ENV_LIVE_SSH_REMOTE_WORKSPACE_PATH?.trim() ?? ""; + const port = Number.parseInt(process.env.PAPERCLIP_ENV_LIVE_SSH_PORT ?? "22", 10); + + if (!host || !username || !remoteWorkspacePath || !Number.isInteger(port) || port < 1 || port > 65535) { + return null; + } + return { host, port, username, remoteWorkspacePath }; +} + +/** Try to use an already-running env-lab fixture. */ +async function tryEnvLabFixture(): Promise { + const statePath = resolveEnvLabStatePath(); + const status = await readSshEnvLabFixtureStatus(statePath); + if (status.running && status.state) { + return buildSshEnvLabFixtureConfig(status.state); + } + return null; +} + +/** + * Start a fresh env-lab SSH fixture for this test run. Returns the config + * and a cleanup function to stop it afterwards. + */ +async function startEnvLabForTest(): Promise<{ + config: SshConnectionConfig; + cleanup: () => Promise; +} | null> { + const statePath = resolveEnvLabStatePath(); + try { + const state = await startSshEnvLabFixture({ statePath }); + const config = await buildSshEnvLabFixtureConfig(state); + return { + config, + cleanup: async () => { + await stopSshEnvLabFixture(statePath); + }, + }; + } catch { + return null; + } +} + +let envLabCleanup: (() => Promise) | null = null; + +/** + * Resolve an SSH connection config from (in order): + * 1. Explicit PAPERCLIP_ENV_LIVE_SSH_* env vars + * 2. An already-running env-lab fixture + * 3. Auto-starting an env-lab fixture + */ +async function resolveSshConfig(): Promise { + // 1. Explicit env vars + const explicit = tryExplicitConfig(); + if (explicit) { + return { + ...explicit, + privateKey: await readOptionalSecret( + process.env.PAPERCLIP_ENV_LIVE_SSH_PRIVATE_KEY, + process.env.PAPERCLIP_ENV_LIVE_SSH_PRIVATE_KEY_PATH, + ), + knownHosts: await readOptionalSecret( + process.env.PAPERCLIP_ENV_LIVE_SSH_KNOWN_HOSTS, + process.env.PAPERCLIP_ENV_LIVE_SSH_KNOWN_HOSTS_PATH, + ), + strictHostKeyChecking: + (process.env.PAPERCLIP_ENV_LIVE_SSH_STRICT_HOST_KEY_CHECKING ?? "true").toLowerCase() !== "false", + }; + } + + // 2. Already-running env-lab + const running = await tryEnvLabFixture(); + if (running) return running; + + // 3. Auto-start env-lab + if (process.env.PAPERCLIP_ENV_LIVE_SSH_NO_AUTO_FIXTURE !== "true") { + const started = await startEnvLabForTest(); + if (started) { + envLabCleanup = started.cleanup; + return started.config; + } + } + + return null; +} + +let resolvedConfig: SshConnectionConfig | null | undefined; + +const describeLiveSsh = (() => { + // Eagerly check explicit env vars for sync skip decision. + // If explicit vars are set, use them. Otherwise, we'll attempt env-lab in beforeAll. + if (tryExplicitConfig()) return describe; + // If NO_AUTO_FIXTURE is set and no explicit config, skip immediately + if (process.env.PAPERCLIP_ENV_LIVE_SSH_NO_AUTO_FIXTURE === "true") { + console.warn( + "Skipping live SSH smoke test. Set PAPERCLIP_ENV_LIVE_SSH_HOST, PAPERCLIP_ENV_LIVE_SSH_USERNAME, and PAPERCLIP_ENV_LIVE_SSH_REMOTE_WORKSPACE_PATH to enable it, or remove PAPERCLIP_ENV_LIVE_SSH_NO_AUTO_FIXTURE to auto-start env-lab.", + ); + return describe.skip; + } + // Will attempt env-lab — don't skip yet + return describe; +})(); + +describeLiveSsh("live SSH environment smoke", () => { + afterAll(async () => { + if (envLabCleanup) { + await envLabCleanup(); + envLabCleanup = null; + } + }); + + it("connects to the configured SSH environment and verifies basic runtime tools", async () => { + if (resolvedConfig === undefined) { + resolvedConfig = await resolveSshConfig(); + } + + if (!resolvedConfig) { + throw new Error( + "Live SSH smoke test could not resolve SSH config from env vars or env-lab fixture. Set PAPERCLIP_ENV_LIVE_SSH_NO_AUTO_FIXTURE=true to mark this suite skipped intentionally.", + ); + } + + const config = resolvedConfig; + const ready = await ensureSshWorkspaceReady(config); + const quotedRemoteWorkspacePath = JSON.stringify(config.remoteWorkspacePath); + const result = await runSshCommand( + config, + `sh -lc "cd ${quotedRemoteWorkspacePath} && which git && which tar && pwd"`, + { timeoutMs: 30000, maxBuffer: 256 * 1024 }, + ); + + expect(ready.remoteCwd).toBe(config.remoteWorkspacePath); + expect(result.stdout).toContain(config.remoteWorkspacePath); + expect(result.stdout).toContain("git"); + expect(result.stdout).toContain("tar"); + }); +}); diff --git a/server/src/__tests__/environment-probe.test.ts b/server/src/__tests__/environment-probe.test.ts new file mode 100644 index 0000000000..40bf9f0a46 --- /dev/null +++ b/server/src/__tests__/environment-probe.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockEnsureSshWorkspaceReady = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/adapter-utils/ssh", () => ({ + ensureSshWorkspaceReady: mockEnsureSshWorkspaceReady, +})); + +import { probeEnvironment } from "../services/environment-probe.ts"; + +describe("probeEnvironment", () => { + beforeEach(() => { + mockEnsureSshWorkspaceReady.mockReset(); + }); + + it("reports local environments as immediately available", async () => { + const result = await probeEnvironment({} as any, { + id: "env-1", + companyId: "company-1", + name: "Local", + description: null, + driver: "local", + status: "active", + config: {}, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + expect(result.ok).toBe(true); + expect(result.driver).toBe("local"); + expect(result.summary).toContain("Local environment"); + expect(mockEnsureSshWorkspaceReady).not.toHaveBeenCalled(); + }); + + it("runs an SSH probe and returns the verified remote cwd", async () => { + mockEnsureSshWorkspaceReady.mockResolvedValue({ + remoteCwd: "/srv/paperclip/workspace", + }); + + const result = await probeEnvironment({} as any, { + id: "env-ssh", + companyId: "company-1", + name: "SSH Fixture", + description: null, + driver: "ssh", + status: "active", + config: { + host: "ssh.example.test", + port: 2222, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + privateKeySecretRef: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + expect(result).toEqual({ + ok: true, + driver: "ssh", + summary: "Connected to ssh-user@ssh.example.test and verified the remote workspace path.", + details: { + host: "ssh.example.test", + port: 2222, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + remoteCwd: "/srv/paperclip/workspace", + }, + }); + expect(mockEnsureSshWorkspaceReady).toHaveBeenCalledTimes(1); + }); + + it("captures SSH probe failures without throwing", async () => { + mockEnsureSshWorkspaceReady.mockRejectedValue( + Object.assign(new Error("Permission denied"), { + code: 255, + stdout: "", + stderr: "Permission denied (publickey).", + }), + ); + + const result = await probeEnvironment({} as any, { + id: "env-ssh", + companyId: "company-1", + name: "SSH Fixture", + description: null, + driver: "ssh", + status: "active", + config: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + privateKeySecretRef: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + expect(result.ok).toBe(false); + expect(result.summary).toContain("SSH probe failed"); + expect(result.details).toEqual( + expect.objectContaining({ + error: "Permission denied (publickey).", + code: 255, + }), + ); + }); +}); diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts new file mode 100644 index 0000000000..502db0c4b6 --- /dev/null +++ b/server/src/__tests__/environment-routes.test.ts @@ -0,0 +1,1181 @@ +import type { Server } from "node:http"; +import express from "express"; +import request from "supertest"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { environmentRoutes } from "../routes/environments.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), +})); + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockEnvironmentService = vi.hoisted(() => ({ + list: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + listLeases: vi.fn(), + getLeaseById: vi.fn(), +})); +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + clearEnvironmentSelection: vi.fn(), +})); +const mockIssueService = vi.hoisted(() => ({ + clearExecutionWorkspaceEnvironmentSelection: vi.fn(), +})); +const mockProjectService = vi.hoisted(() => ({ + clearExecutionWorkspaceEnvironmentSelection: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockProbeEnvironment = vi.hoisted(() => vi.fn()); +const mockSecretService = vi.hoisted(() => ({ + create: vi.fn(), + remove: vi.fn(), + resolveSecretValue: vi.fn(), +})); + +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + environmentService: () => mockEnvironmentService, + executionWorkspaceService: () => mockExecutionWorkspaceService, + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, +})); + +vi.mock("../services/environment-probe.js", () => ({ + probeEnvironment: mockProbeEnvironment, +})); + +vi.mock("../services/secrets.js", () => ({ + secretService: () => mockSecretService, +})); + +function createEnvironment() { + const now = new Date("2026-04-16T05:00:00.000Z"); + return { + id: "env-1", + companyId: "company-1", + name: "Local", + description: "Current development machine", + driver: "local", + status: "active" as const, + config: { shell: "zsh" }, + metadata: { source: "manual" }, + createdAt: now, + updatedAt: now, + }; +} + +let server: Server | null = null; +let currentActor: Record = { + type: "board", + userId: "user-1", + source: "local_implicit", +}; +function createApp(actor: Record) { + currentActor = actor; + if (server) return server; + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = currentActor; + next(); + }); + app.use("/api", environmentRoutes({} as any)); + app.use(errorHandler); + server = app.listen(0); + return server; +} + +describe("environment routes", () => { + afterAll(async () => { + if (!server) return; + await new Promise((resolve, reject) => { + server?.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + server = null; + }); + + beforeEach(() => { + mockAccessService.canUser.mockReset(); + mockAccessService.hasPermission.mockReset(); + mockAgentService.getById.mockReset(); + mockEnvironmentService.list.mockReset(); + mockEnvironmentService.getById.mockReset(); + mockEnvironmentService.create.mockReset(); + mockEnvironmentService.update.mockReset(); + mockEnvironmentService.remove.mockReset(); + mockEnvironmentService.listLeases.mockReset(); + mockEnvironmentService.getLeaseById.mockReset(); + mockExecutionWorkspaceService.clearEnvironmentSelection.mockReset(); + mockIssueService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); + mockProjectService.clearExecutionWorkspaceEnvironmentSelection.mockReset(); + mockLogActivity.mockReset(); + mockProbeEnvironment.mockReset(); + mockSecretService.create.mockReset(); + mockSecretService.remove.mockReset(); + mockSecretService.resolveSecretValue.mockReset(); + mockSecretService.create.mockResolvedValue({ + id: "11111111-1111-1111-1111-111111111111", + }); + }); + + it("lists company-scoped environments", async () => { + mockEnvironmentService.list.mockResolvedValue([createEnvironment()]); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app).get("/api/companies/company-1/environments?driver=local"); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(mockEnvironmentService.list).toHaveBeenCalledWith("company-1", { + status: undefined, + driver: "local", + }); + }); + + it("returns environment capabilities for the company", async () => { + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app).get("/api/companies/company-1/environments/capabilities"); + + expect(res.status).toBe(200); + expect(res.body.drivers.ssh).toBe("supported"); + expect(res.body.drivers.local).toBe("supported"); + expect(res.body.sandboxProviders).toBeUndefined(); + }); + + it("redacts config and metadata for unprivileged agent list reads", async () => { + mockEnvironmentService.list.mockResolvedValue([createEnvironment()]); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "engineer", + permissions: { canCreateAgents: false }, + }); + mockAccessService.hasPermission.mockResolvedValue(false); + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app).get("/api/companies/company-1/environments"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + expect.objectContaining({ + id: "env-1", + config: {}, + metadata: null, + configRedacted: true, + metadataRedacted: true, + }), + ]); + }); + + it("redacts config and metadata for board members without environments:manage", async () => { + mockEnvironmentService.list.mockResolvedValue([createEnvironment()]); + mockAccessService.canUser.mockResolvedValue(false); + const app = createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }); + + const res = await request(app).get("/api/companies/company-1/environments"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + expect.objectContaining({ + id: "env-1", + config: {}, + metadata: null, + configRedacted: true, + metadataRedacted: true, + }), + ]); + }); + + it("returns full config for privileged environment readers", async () => { + mockEnvironmentService.getById.mockResolvedValue(createEnvironment()); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "cto", + permissions: { canCreateAgents: true }, + }); + mockAccessService.hasPermission.mockResolvedValue(false); + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app).get("/api/environments/env-1"); + + expect(res.status).toBe(200); + expect(res.body.config).toEqual({ shell: "zsh" }); + expect(res.body.metadata).toEqual({ source: "manual" }); + expect(res.body.configRedacted).toBeUndefined(); + }); + + it("redacts config and metadata for unprivileged agent detail reads", async () => { + mockEnvironmentService.getById.mockResolvedValue(createEnvironment()); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "engineer", + permissions: { canCreateAgents: false }, + }); + mockAccessService.hasPermission.mockResolvedValue(false); + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app).get("/api/environments/env-1"); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + id: "env-1", + config: {}, + metadata: null, + configRedacted: true, + metadataRedacted: true, + }), + ); + }); + + it("redacts config and metadata for board detail reads without environments:manage", async () => { + mockEnvironmentService.getById.mockResolvedValue(createEnvironment()); + mockAccessService.canUser.mockResolvedValue(false); + const app = createApp({ + type: "board", + userId: "member-user", + source: "session", + isInstanceAdmin: false, + companyIds: ["company-1"], + }); + + const res = await request(app).get("/api/environments/env-1"); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + id: "env-1", + config: {}, + metadata: null, + configRedacted: true, + metadataRedacted: true, + }), + ); + }); + + it("creates an environment and logs activity", async () => { + const environment = createEnvironment(); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "cto", + permissions: { canCreateAgents: true }, + }); + mockAccessService.hasPermission.mockResolvedValue(false); + mockEnvironmentService.create.mockResolvedValue(environment); + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/company-1/environments") + .send({ + name: "Local", + driver: "local", + description: "Current development machine", + config: { shell: "zsh" }, + }); + + expect(res.status).toBe(201); + expect(mockEnvironmentService.create).toHaveBeenCalledWith("company-1", { + name: "Local", + driver: "local", + description: "Current development machine", + status: "active", + config: { shell: "zsh" }, + }); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + companyId: "company-1", + actorType: "agent", + actorId: "agent-1", + agentId: "agent-1", + runId: "run-1", + action: "environment.created", + entityType: "environment", + entityId: environment.id, + }), + ); + }); + + it("allows non-admin board users with environments:manage to create environments", async () => { + const environment = createEnvironment(); + mockAccessService.canUser.mockResolvedValue(true); + mockEnvironmentService.create.mockResolvedValue(environment); + const app = createApp({ + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + isInstanceAdmin: false, + }); + + const res = await request(app) + .post("/api/companies/company-1/environments") + .send({ + name: "Local", + driver: "local", + config: {}, + }); + + expect(res.status).toBe(201); + expect(mockAccessService.canUser).toHaveBeenCalledWith( + "company-1", + "user-1", + "environments:manage", + ); + }); + + it("rejects non-admin board users without environments:manage", async () => { + mockAccessService.canUser.mockResolvedValue(false); + const app = createApp({ + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + isInstanceAdmin: false, + }); + + const res = await request(app) + .post("/api/companies/company-1/environments") + .send({ + name: "Local", + driver: "local", + config: {}, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("environments:manage"); + expect(mockEnvironmentService.create).not.toHaveBeenCalled(); + }); + + it("allows agents with explicit environments:manage grants to create environments", async () => { + const environment = createEnvironment(); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "engineer", + permissions: { canCreateAgents: false }, + }); + mockAccessService.hasPermission.mockResolvedValue(true); + mockEnvironmentService.create.mockResolvedValue(environment); + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/company-1/environments") + .send({ + name: "Local", + driver: "local", + config: {}, + }); + + expect(res.status).toBe(201); + expect(mockAccessService.hasPermission).toHaveBeenCalledWith( + "company-1", + "agent", + "agent-1", + "environments:manage", + ); + }); + + it("rejects invalid SSH config on create", async () => { + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app) + .post("/api/companies/company-1/environments") + .send({ + name: "SSH Fixture", + driver: "ssh", + config: { + host: "ssh.example.test", + username: "ssh-user", + }, + }); + + expect(res.status).toBe(422); + expect(res.body.error).toContain("remote workspace path"); + expect(mockEnvironmentService.create).not.toHaveBeenCalled(); + }); + + it("normalizes SSH private keys into secret refs before persistence", async () => { + const environment = { + ...createEnvironment(), + id: "env-ssh", + name: "SSH Fixture", + driver: "ssh" as const, + config: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + privateKeySecretRef: { + type: "secret_ref", + secretId: "11111111-1111-1111-1111-111111111111", + version: "latest", + }, + knownHosts: null, + strictHostKeyChecking: true, + }, + }; + mockEnvironmentService.create.mockResolvedValue(environment); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app) + .post("/api/companies/company-1/environments") + .send({ + name: "SSH Fixture", + driver: "ssh", + config: { + host: "ssh.example.test", + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: " super-secret-key ", + }, + }); + + expect(res.status).toBe(201); + expect(mockEnvironmentService.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ + config: expect.objectContaining({ + privateKey: null, + privateKeySecretRef: { + type: "secret_ref", + secretId: "11111111-1111-1111-1111-111111111111", + version: "latest", + }, + }), + })); + expect(JSON.stringify(mockEnvironmentService.create.mock.calls[0][1])).not.toContain("super-secret-key"); + expect(mockSecretService.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + provider: "local_encrypted", + value: "super-secret-key", + }), + expect.any(Object), + ); + }); + + it("rejects unprivileged agent mutations for shared environments", async () => { + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + role: "engineer", + permissions: { canCreateAgents: false }, + }); + mockAccessService.hasPermission.mockResolvedValue(false); + const app = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + source: "agent_key", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/company-1/environments") + .send({ + name: "Sandbox host", + driver: "local", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("environments:manage"); + expect(mockEnvironmentService.create).not.toHaveBeenCalled(); + }); + + it("lists leases for an environment after company access is confirmed", async () => { + const environment = createEnvironment(); + mockEnvironmentService.getById.mockResolvedValue(environment); + mockEnvironmentService.listLeases.mockResolvedValue([ + { + id: "lease-1", + companyId: "company-1", + environmentId: environment.id, + executionWorkspaceId: "workspace-1", + issueId: null, + heartbeatRunId: null, + status: "active", + providerLeaseId: "provider-lease-1", + acquiredAt: new Date("2026-04-16T05:00:00.000Z"), + lastUsedAt: new Date("2026-04-16T05:05:00.000Z"), + expiresAt: null, + releasedAt: null, + metadata: { provider: "local" }, + createdAt: new Date("2026-04-16T05:00:00.000Z"), + updatedAt: new Date("2026-04-16T05:05:00.000Z"), + }, + ]); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app).get(`/api/environments/${environment.id}/leases?status=active`); + + expect(res.status).toBe(200); + expect(mockEnvironmentService.listLeases).toHaveBeenCalledWith(environment.id, { + status: "active", + }); + }); + + it("rejects environment lease listing for board users without environments:manage", async () => { + const environment = createEnvironment(); + mockEnvironmentService.getById.mockResolvedValue(environment); + mockAccessService.canUser.mockResolvedValue(false); + const app = createApp({ + type: "board", + userId: "user-1", + source: "dashboard_session", + companyIds: ["company-1"], + }); + + const res = await request(app).get(`/api/environments/${environment.id}/leases`); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("environments:manage"); + expect(mockEnvironmentService.listLeases).not.toHaveBeenCalled(); + }); + + it("returns a single lease after company access is confirmed", async () => { + mockEnvironmentService.getLeaseById.mockResolvedValue({ + id: "lease-1", + companyId: "company-1", + environmentId: "env-1", + executionWorkspaceId: "workspace-1", + issueId: null, + heartbeatRunId: "run-1", + status: "active", + leasePolicy: "ephemeral", + provider: "ssh", + providerLeaseId: "ssh://ssh-user@example.test:22/workspace", + acquiredAt: new Date("2026-04-16T05:00:00.000Z"), + lastUsedAt: new Date("2026-04-16T05:05:00.000Z"), + expiresAt: null, + releasedAt: null, + failureReason: null, + cleanupStatus: null, + metadata: { remoteCwd: "/workspace" }, + createdAt: new Date("2026-04-16T05:00:00.000Z"), + updatedAt: new Date("2026-04-16T05:05:00.000Z"), + }); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app).get("/api/environment-leases/lease-1"); + + expect(res.status).toBe(200); + expect(res.body.provider).toBe("ssh"); + expect(mockEnvironmentService.getLeaseById).toHaveBeenCalledWith("lease-1"); + }); + + it("rejects single-lease reads for board users without environments:manage", async () => { + mockEnvironmentService.getLeaseById.mockResolvedValue({ + id: "lease-1", + companyId: "company-1", + environmentId: "env-1", + executionWorkspaceId: "workspace-1", + issueId: null, + heartbeatRunId: "run-1", + status: "active", + leasePolicy: "ephemeral", + provider: "ssh", + providerLeaseId: "ssh://ssh-user@example.test:22/workspace", + acquiredAt: new Date("2026-04-16T05:00:00.000Z"), + lastUsedAt: new Date("2026-04-16T05:05:00.000Z"), + expiresAt: null, + releasedAt: null, + failureReason: null, + cleanupStatus: null, + metadata: { remoteCwd: "/workspace" }, + createdAt: new Date("2026-04-16T05:00:00.000Z"), + updatedAt: new Date("2026-04-16T05:05:00.000Z"), + }); + mockAccessService.canUser.mockResolvedValue(false); + const app = createApp({ + type: "board", + userId: "user-1", + source: "dashboard_session", + companyIds: ["company-1"], + }); + + const res = await request(app).get("/api/environment-leases/lease-1"); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("environments:manage"); + }); + + it("rejects cross-company agent access", async () => { + mockEnvironmentService.list.mockResolvedValue([]); + const app = createApp({ + type: "agent", + agentId: "agent-2", + companyId: "company-2", + source: "agent_key", + runId: "run-2", + }); + + const res = await request(app).get("/api/companies/company-1/environments"); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("another company"); + expect(mockEnvironmentService.list).not.toHaveBeenCalled(); + }); + + it("logs a redacted update summary instead of raw config or metadata", async () => { + const environment = createEnvironment(); + mockEnvironmentService.getById.mockResolvedValue(environment); + mockEnvironmentService.update.mockResolvedValue({ + ...environment, + status: "archived", + }); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app) + .patch(`/api/environments/${environment.id}`) + .send({ + status: "archived", + config: { + apiKey: "super-secret", + token: "another-secret", + }, + metadata: { + password: "do-not-log", + }, + }); + + expect(res.status).toBe(200); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "environment.updated", + details: { + changedFields: ["config", "metadata", "status"], + status: "archived", + configChanged: true, + configTopLevelKeyCount: 3, + metadataChanged: true, + metadataTopLevelKeyCount: 1, + }, + }), + ); + expect(JSON.stringify(mockLogActivity.mock.calls[0][1].details)).not.toContain("super-secret"); + expect(JSON.stringify(mockLogActivity.mock.calls[0][1].details)).not.toContain("do-not-log"); + }); + + it("preserves the stored SSH private key secret ref on partial config updates", async () => { + const environment = { + ...createEnvironment(), + name: "SSH Fixture", + driver: "ssh" as const, + config: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + privateKeySecretRef: { + type: "secret_ref", + secretId: "11111111-1111-1111-1111-111111111111", + version: "latest", + }, + knownHosts: null, + strictHostKeyChecking: true, + }, + }; + mockEnvironmentService.getById.mockResolvedValue(environment); + mockEnvironmentService.update.mockResolvedValue({ + ...environment, + config: { + ...environment.config, + port: 2222, + }, + }); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app) + .patch(`/api/environments/${environment.id}`) + .send({ + config: { + port: 2222, + }, + }); + + expect(res.status).toBe(200); + expect(mockEnvironmentService.update).toHaveBeenCalledWith( + environment.id, + expect.objectContaining({ + config: expect.objectContaining({ + host: "ssh.example.test", + port: 2222, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + privateKeySecretRef: { + type: "secret_ref", + secretId: "11111111-1111-1111-1111-111111111111", + version: "latest", + }, + }), + }), + ); + expect(mockSecretService.create).not.toHaveBeenCalled(); + expect(mockSecretService.remove).not.toHaveBeenCalled(); + }); + + it("replaces the stored SSH private key secret when a new private key is provided", async () => { + const environment = { + ...createEnvironment(), + name: "SSH Fixture", + driver: "ssh" as const, + config: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + privateKeySecretRef: { + type: "secret_ref", + secretId: "22222222-2222-2222-2222-222222222222", + version: "latest", + }, + knownHosts: null, + strictHostKeyChecking: true, + }, + }; + mockEnvironmentService.getById.mockResolvedValue(environment); + mockEnvironmentService.update.mockResolvedValue(environment); + mockSecretService.create.mockResolvedValue({ + id: "33333333-3333-3333-3333-333333333333", + }); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app) + .patch(`/api/environments/${environment.id}`) + .send({ + config: { + privateKey: " replacement-private-key ", + }, + }); + + expect(res.status).toBe(200); + expect(mockEnvironmentService.update).toHaveBeenCalledWith( + environment.id, + expect.objectContaining({ + config: expect.objectContaining({ + privateKey: null, + privateKeySecretRef: { + type: "secret_ref", + secretId: "33333333-3333-3333-3333-333333333333", + version: "latest", + }, + }), + }), + ); + expect(mockSecretService.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + provider: "local_encrypted", + value: "replacement-private-key", + }), + expect.any(Object), + ); + expect(mockSecretService.remove).toHaveBeenCalledWith("22222222-2222-2222-2222-222222222222"); + }); + + it("resets config instead of inheriting SSH secrets when switching to local without an explicit config", async () => { + const environment = { + ...createEnvironment(), + name: "SSH Fixture", + driver: "ssh" as const, + config: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: "super-secret-key", + knownHosts: "known-host", + strictHostKeyChecking: true, + }, + }; + mockEnvironmentService.getById.mockResolvedValue(environment); + mockEnvironmentService.update.mockResolvedValue({ + ...createEnvironment(), + driver: "local" as const, + config: {}, + }); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app) + .patch(`/api/environments/${environment.id}`) + .send({ + driver: "local", + }); + + expect(res.status).toBe(200); + expect(mockEnvironmentService.update).toHaveBeenCalledWith(environment.id, { + driver: "local", + config: {}, + }); + expect(JSON.stringify(mockEnvironmentService.update.mock.calls[0][1])).not.toContain("super-secret-key"); + expect(JSON.stringify(mockEnvironmentService.update.mock.calls[0][1])).not.toContain("known-host"); + }); + + it("requires explicit SSH config when switching from local to SSH", async () => { + mockEnvironmentService.getById.mockResolvedValue(createEnvironment()); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app) + .patch("/api/environments/env-1") + .send({ + driver: "ssh", + }); + + expect(res.status).toBe(422); + expect(res.body.error).toContain("host"); + expect(mockEnvironmentService.update).not.toHaveBeenCalled(); + }); + + it("returns 404 when patching a missing environment", async () => { + mockEnvironmentService.getById.mockResolvedValue(null); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app) + .patch("/api/environments/missing-env") + .send({ status: "archived" }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Environment not found"); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("deletes an environment and logs the removal", async () => { + const environment = createEnvironment(); + mockEnvironmentService.getById.mockResolvedValue(environment); + mockEnvironmentService.remove.mockResolvedValue(environment); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app).delete(`/api/environments/${environment.id}`); + + expect(res.status).toBe(200); + expect(mockEnvironmentService.remove).toHaveBeenCalledWith(environment.id); + expect(mockExecutionWorkspaceService.clearEnvironmentSelection).toHaveBeenCalledWith( + environment.companyId, + environment.id, + ); + expect(mockIssueService.clearExecutionWorkspaceEnvironmentSelection).toHaveBeenCalledWith( + environment.companyId, + environment.id, + ); + expect(mockProjectService.clearExecutionWorkspaceEnvironmentSelection).toHaveBeenCalledWith( + environment.companyId, + environment.id, + ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "environment.deleted", + entityId: environment.id, + details: { + name: environment.name, + driver: environment.driver, + status: environment.status, + }, + }), + ); + }); + + it("deletes the stored SSH private-key secret after removing the environment", async () => { + const environment = { + ...createEnvironment(), + name: "SSH Fixture", + driver: "ssh" as const, + config: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + privateKeySecretRef: { + type: "secret_ref", + secretId: "11111111-1111-4111-8111-111111111111", + version: "latest", + }, + knownHosts: null, + strictHostKeyChecking: true, + }, + }; + mockEnvironmentService.getById.mockResolvedValue(environment); + mockEnvironmentService.remove.mockResolvedValue(environment); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app).delete(`/api/environments/${environment.id}`); + + expect(res.status).toBe(200); + expect(mockEnvironmentService.remove).toHaveBeenCalledWith(environment.id); + expect(mockSecretService.remove).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111"); + expect(mockEnvironmentService.remove.mock.invocationCallOrder[0]).toBeLessThan( + mockSecretService.remove.mock.invocationCallOrder[0], + ); + expect(mockExecutionWorkspaceService.clearEnvironmentSelection).toHaveBeenCalledWith( + environment.companyId, + environment.id, + ); + expect(mockIssueService.clearExecutionWorkspaceEnvironmentSelection).toHaveBeenCalledWith( + environment.companyId, + environment.id, + ); + expect(mockProjectService.clearExecutionWorkspaceEnvironmentSelection).toHaveBeenCalledWith( + environment.companyId, + environment.id, + ); + }); + + it("skips SSH secret cleanup gracefully when stored SSH config no longer parses", async () => { + const environment = { + ...createEnvironment(), + name: "SSH Fixture", + driver: "ssh" as const, + config: { + host: "", + username: "ssh-user", + }, + }; + mockEnvironmentService.getById.mockResolvedValue(environment); + mockEnvironmentService.remove.mockResolvedValue(environment); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app).delete(`/api/environments/${environment.id}`); + + expect(res.status).toBe(200); + expect(mockEnvironmentService.remove).toHaveBeenCalledWith(environment.id); + expect(mockSecretService.remove).not.toHaveBeenCalled(); + }); + + it("returns 404 when deleting a missing environment", async () => { + mockEnvironmentService.getById.mockResolvedValue(null); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + }); + + const res = await request(app).delete("/api/environments/missing-env"); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Environment not found"); + expect(mockEnvironmentService.remove).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("probes an SSH environment and logs the result", async () => { + const environment = { + ...createEnvironment(), + name: "SSH Fixture", + driver: "ssh" as const, + config: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }; + mockEnvironmentService.getById.mockResolvedValue(environment); + mockProbeEnvironment.mockResolvedValue({ + ok: true, + driver: "ssh", + summary: "Connected to ssh-user@ssh.example.test and verified the remote workspace path.", + details: { + host: "ssh.example.test", + }, + }); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + runId: "run-1", + }); + + const res = await request(app) + .post(`/api/environments/${environment.id}/probe`) + .send({}); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + companyId: "company-1", + action: "environment.probed", + entityType: "environment", + entityId: environment.id, + details: expect.objectContaining({ + driver: "ssh", + ok: true, + }), + }), + ); + }); + + it("probes unsaved SSH config without persisting secrets", async () => { + mockProbeEnvironment.mockResolvedValue({ + ok: true, + driver: "ssh", + summary: "Connected to ssh-user@ssh.example.test and verified the remote workspace path.", + details: { remoteCwd: "/srv/paperclip/workspace" }, + }); + const app = createApp({ + type: "board", + userId: "user-1", + source: "local_implicit", + runId: "run-1", + }); + + const res = await request(app) + .post("/api/companies/company-1/environments/probe-config") + .send({ + name: "Draft SSH", + description: "Probe this SSH target before saving it.", + driver: "ssh", + config: { + host: "ssh.example.test", + username: "ssh-user", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: "unsaved-test-key", + }, + }); + + expect(res.status).toBe(200); + expect(mockEnvironmentService.create).not.toHaveBeenCalled(); + expect(mockSecretService.create).not.toHaveBeenCalled(); + expect(mockProbeEnvironment).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: "unsaved", + driver: "ssh", + config: expect.objectContaining({ + privateKey: "unsaved-test-key", + }), + }), + expect.objectContaining({ + resolvedConfig: expect.objectContaining({ + driver: "ssh", + }), + }), + ); + expect(JSON.stringify(mockLogActivity.mock.calls[0][1].details)).not.toContain("unsaved-test-key"); + }); +}); diff --git a/server/src/__tests__/environment-selection-route-guards.test.ts b/server/src/__tests__/environment-selection-route-guards.test.ts new file mode 100644 index 0000000000..1230d29d94 --- /dev/null +++ b/server/src/__tests__/environment-selection-route-guards.test.ts @@ -0,0 +1,492 @@ +import type { Server } from "node:http"; +import express from "express"; +import request from "supertest"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { projectRoutes } from "../routes/projects.js"; +import { issueRoutes } from "../routes/issues.js"; + +const mockProjectService = vi.hoisted(() => ({ + create: vi.fn(), + getById: vi.fn(), + update: vi.fn(), + createWorkspace: vi.fn(), + remove: vi.fn(), + resolveByReference: vi.fn(), + listWorkspaces: vi.fn(), +})); + +const mockIssueService = vi.hoisted(() => ({ + create: vi.fn(), + createChild: vi.fn(), + getById: vi.fn(), + update: vi.fn(), + getByIdentifier: vi.fn(), + assertCheckoutOwner: vi.fn(), +})); + +const mockEnvironmentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockReferenceSummary = vi.hoisted(() => ({ + inbound: [], + outbound: [], + documentSources: [], +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + projectService: () => mockProjectService, + issueService: () => mockIssueService, + environmentService: () => mockEnvironmentService, + secretService: () => ({ + normalizeEnvBindingsForPersistence: vi.fn(async (_companyId: string, env: unknown) => env), + normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: unknown) => config), + }), + logActivity: mockLogActivity, + workspaceOperationService: () => ({}), + accessService: () => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => ({ + getById: vi.fn(), + }), + executionWorkspaceService: () => ({}), + goalService: () => ({ + getById: vi.fn(), + getDefaultCompanyGoal: vi.fn(), + }), + heartbeatService: () => ({ + getRun: vi.fn(), + getActiveRunForAgent: vi.fn(), + }), + issueApprovalService: () => ({ + listApprovalsForIssue: vi.fn(), + unlink: vi.fn(), + }), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(), + listFeedbackTraces: vi.fn(), + getFeedbackTraceById: vi.fn(), + getFeedbackTraceBundle: vi.fn(), + saveIssueVote: vi.fn(), + }), + instanceSettingsService: () => ({ + get: vi.fn(async () => ({})), + listCompanyIds: vi.fn(async () => []), + }), + issueReferenceService: () => ({ + emptySummary: vi.fn(() => mockReferenceSummary), + syncIssue: vi.fn(), + syncComment: vi.fn(), + syncDocument: vi.fn(), + deleteDocumentSource: vi.fn(), + listIssueReferenceSummary: vi.fn(async () => mockReferenceSummary), + diffIssueReferenceSummary: vi.fn(() => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + })), + }), + documentService: () => ({}), + routineService: () => ({}), + workProductService: () => ({}), +})); + +vi.mock("../services/issue-assignment-wakeup.js", () => ({ + queueIssueAssignmentWakeup: vi.fn(), +})); + +function buildApp(routerFactory: (app: express.Express) => void) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "user-1", + source: "local_implicit", + }; + next(); + }); + routerFactory(app); + app.use(errorHandler); + return app; +} + +let projectServer: Server | null = null; +let issueServer: Server | null = null; + +function createProjectApp() { + projectServer ??= buildApp((expressApp) => { + expressApp.use("/api", projectRoutes({} as any)); + }).listen(0); + return projectServer; +} + +function createIssueApp() { + issueServer ??= buildApp((expressApp) => { + expressApp.use("/api", issueRoutes({} as any, {} as any)); + }).listen(0); + return issueServer; +} + +const sshEnvironmentId = "11111111-1111-4111-8111-111111111111"; + +async function closeServer(server: Server | null) { + if (!server) return; + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +describe.sequential("execution environment route guards", () => { + afterAll(async () => { + await closeServer(projectServer); + await closeServer(issueServer); + projectServer = null; + issueServer = null; + }); + + beforeEach(() => { + mockProjectService.create.mockReset(); + mockProjectService.getById.mockReset(); + mockProjectService.update.mockReset(); + mockProjectService.createWorkspace.mockReset(); + mockProjectService.remove.mockReset(); + mockProjectService.resolveByReference.mockReset(); + mockProjectService.listWorkspaces.mockReset(); + mockIssueService.create.mockReset(); + mockIssueService.createChild.mockReset(); + mockIssueService.getById.mockReset(); + mockIssueService.update.mockReset(); + mockIssueService.getByIdentifier.mockReset(); + mockIssueService.assertCheckoutOwner.mockReset(); + mockEnvironmentService.getById.mockReset(); + mockLogActivity.mockReset(); + }); + + it("accepts SSH environments on project create", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: sshEnvironmentId, + companyId: "company-1", + driver: "ssh", + config: {}, + }); + mockProjectService.create.mockResolvedValue({ + id: "project-1", + companyId: "company-1", + name: "SSH Project", + status: "backlog", + }); + const app = createProjectApp(); + + const res = await request(app) + .post("/api/companies/company-1/projects") + .send({ + name: "SSH Project", + executionWorkspacePolicy: { + enabled: true, + environmentId: sshEnvironmentId, + }, + }); + + expect(res.status).not.toBe(422); + expect(mockProjectService.create).toHaveBeenCalled(); + }); + + it("accepts SSH environments on project update", async () => { + mockProjectService.getById.mockResolvedValue({ + id: "project-1", + companyId: "company-1", + name: "SSH Project", + status: "backlog", + archivedAt: null, + }); + mockEnvironmentService.getById.mockResolvedValue({ + id: sshEnvironmentId, + companyId: "company-1", + driver: "ssh", + config: {}, + }); + mockProjectService.update.mockResolvedValue({ + id: "project-1", + companyId: "company-1", + name: "SSH Project", + status: "backlog", + }); + const app = createProjectApp(); + + const res = await request(app) + .patch("/api/projects/project-1") + .send({ + executionWorkspacePolicy: { + enabled: true, + environmentId: sshEnvironmentId, + }, + }); + + expect(res.status).not.toBe(422); + expect(mockProjectService.update).toHaveBeenCalled(); + }); + + it("rejects cross-company environments on project create", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: sshEnvironmentId, + companyId: "company-2", + driver: "ssh", + config: {}, + }); + const app = createProjectApp(); + + const res = await request(app) + .post("/api/companies/company-1/projects") + .send({ + name: "Cross Company Project", + executionWorkspacePolicy: { + enabled: true, + environmentId: sshEnvironmentId, + }, + }); + + expect(res.status).toBe(422); + expect(res.body.error).toBe("Environment not found."); + expect(mockProjectService.create).not.toHaveBeenCalled(); + }); + + it("rejects unsupported driver environments on project update", async () => { + mockProjectService.getById.mockResolvedValue({ + id: "project-1", + companyId: "company-1", + name: "SSH Project", + status: "backlog", + archivedAt: null, + }); + mockEnvironmentService.getById.mockResolvedValue({ + id: sshEnvironmentId, + companyId: "company-1", + driver: "unsupported_driver", + config: {}, + }); + const app = createProjectApp(); + + const res = await request(app) + .patch("/api/projects/project-1") + .send({ + executionWorkspacePolicy: { + enabled: true, + environmentId: sshEnvironmentId, + }, + }); + + expect(res.status).toBe(422); + expect(res.body.error).toContain('Environment driver "unsupported_driver" is not allowed here'); + expect(mockProjectService.update).not.toHaveBeenCalled(); + }); + + it("rejects archived environments on project create", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: sshEnvironmentId, + companyId: "company-1", + driver: "ssh", + status: "archived", + config: {}, + }); + const app = createProjectApp(); + + const res = await request(app) + .post("/api/companies/company-1/projects") + .send({ + name: "Archived Project", + executionWorkspacePolicy: { + enabled: true, + environmentId: sshEnvironmentId, + }, + }); + + expect(res.status).toBe(422); + expect(res.body.error).toBe("Environment is archived."); + expect(mockProjectService.create).not.toHaveBeenCalled(); + }); + + it("rejects archived environments on issue create", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: sshEnvironmentId, + companyId: "company-1", + driver: "ssh", + status: "archived", + config: {}, + }); + const app = createIssueApp(); + + const res = await request(app) + .post("/api/companies/company-1/issues") + .send({ + title: "Archived Issue", + executionWorkspaceSettings: { + environmentId: sshEnvironmentId, + }, + }); + + expect(res.status).toBe(422); + expect(res.body.error).toBe("Environment is archived."); + expect(mockIssueService.create).not.toHaveBeenCalled(); + }); + + it("accepts SSH environments on issue create", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: sshEnvironmentId, + companyId: "company-1", + driver: "ssh", + config: {}, + }); + mockIssueService.create.mockResolvedValue({ + id: "issue-1", + companyId: "company-1", + title: "SSH Issue", + status: "todo", + identifier: "PAPA-999", + }); + const app = createIssueApp(); + + const res = await request(app) + .post("/api/companies/company-1/issues") + .send({ + title: "SSH Issue", + executionWorkspaceSettings: { + environmentId: sshEnvironmentId, + }, + }); + + expect(res.status).not.toBe(422); + expect(mockIssueService.create).toHaveBeenCalled(); + }); + + it("rejects unsupported driver environments on issue create", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: sshEnvironmentId, + companyId: "company-1", + driver: "unsupported_driver", + config: {}, + }); + const app = createIssueApp(); + + const res = await request(app) + .post("/api/companies/company-1/issues") + .send({ + title: "Unsupported Driver Issue", + executionWorkspaceSettings: { + environmentId: sshEnvironmentId, + }, + }); + + expect(res.status).toBe(422); + expect(res.body.error).toContain('Environment driver "unsupported_driver" is not allowed here'); + expect(mockIssueService.create).not.toHaveBeenCalled(); + }); + + it("rejects unsupported driver environments on child issue create", async () => { + mockIssueService.getById.mockResolvedValue({ + id: "parent-1", + companyId: "company-1", + status: "todo", + assigneeAgentId: null, + assigneeUserId: null, + createdByUserId: null, + identifier: "PAPA-998", + }); + mockEnvironmentService.getById.mockResolvedValue({ + id: sshEnvironmentId, + companyId: "company-1", + driver: "unsupported_driver", + config: {}, + }); + const app = createIssueApp(); + + const res = await request(app) + .post("/api/issues/parent-1/children") + .send({ + title: "Unsupported Child", + executionWorkspaceSettings: { + environmentId: sshEnvironmentId, + }, + }); + + expect(res.status).toBe(422); + expect(res.body.error).toContain('Environment driver "unsupported_driver" is not allowed here'); + expect(mockIssueService.createChild).not.toHaveBeenCalled(); + }); + + it("rejects cross-company environments on child issue create", async () => { + mockIssueService.getById.mockResolvedValue({ + id: "parent-1", + companyId: "company-1", + status: "todo", + assigneeAgentId: null, + assigneeUserId: null, + createdByUserId: null, + identifier: "PAPA-998", + }); + mockEnvironmentService.getById.mockResolvedValue({ + id: sshEnvironmentId, + companyId: "company-2", + driver: "ssh", + config: {}, + }); + const app = createIssueApp(); + + const res = await request(app) + .post("/api/issues/parent-1/children") + .send({ + title: "Cross Company Child", + executionWorkspaceSettings: { + environmentId: sshEnvironmentId, + }, + }); + + expect(res.status).toBe(422); + expect(res.body.error).toBe("Environment not found."); + expect(mockIssueService.createChild).not.toHaveBeenCalled(); + }); + + it("accepts SSH environments on issue update", async () => { + mockIssueService.getById.mockResolvedValue({ + id: "issue-1", + companyId: "company-1", + status: "todo", + assigneeAgentId: null, + assigneeUserId: null, + createdByUserId: null, + identifier: "PAPA-999", + }); + mockEnvironmentService.getById.mockResolvedValue({ + id: sshEnvironmentId, + companyId: "company-1", + driver: "ssh", + config: {}, + }); + mockIssueService.update.mockResolvedValue({ + id: "issue-1", + companyId: "company-1", + status: "todo", + identifier: "PAPA-999", + }); + const app = createIssueApp(); + + const res = await request(app) + .patch("/api/issues/issue-1") + .send({ + executionWorkspaceSettings: { + environmentId: sshEnvironmentId, + }, + }); + + expect(res.status).not.toBe(422); + expect(mockIssueService.update).toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/environment-service.test.ts b/server/src/__tests__/environment-service.test.ts index fc3b3374fe..30d66c4f7c 100644 --- a/server/src/__tests__/environment-service.test.ts +++ b/server/src/__tests__/environment-service.test.ts @@ -221,4 +221,31 @@ describeEmbeddedPostgres("environmentService leases", () => { expect(rows[0]?.driver).toBe("local"); expect(rows[0]?.status).toBe("active"); }); + + it("allows multiple SSH environments for the same company", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Acme", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + + const first = await svc.create(companyId, { + name: "Production SSH", + driver: "ssh", + config: { host: "prod.example.com", username: "deploy" }, + }); + const second = await svc.create(companyId, { + name: "Staging SSH", + driver: "ssh", + config: { host: "staging.example.com", username: "deploy" }, + }); + + expect(first.id).not.toBe(second.id); + + const rows = await db.select().from(environments).where(eq(environments.companyId, companyId)); + expect(rows.filter((row) => row.driver === "ssh")).toHaveLength(2); + }); }); diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index ecb5f76e7d..4ed5de9b4f 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -6,6 +6,7 @@ import { issueExecutionWorkspaceModeForPersistedWorkspace, parseIssueExecutionWorkspaceSettings, parseProjectExecutionWorkspacePolicy, + resolveExecutionWorkspaceEnvironmentId, resolveExecutionWorkspaceMode, } from "../services/execution-workspace-policy.ts"; @@ -117,6 +118,7 @@ describe("execution workspace policy helpers", () => { parseProjectExecutionWorkspacePolicy({ enabled: true, defaultMode: "isolated", + environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b", workspaceStrategy: { type: "git_worktree", worktreeParentDir: ".paperclip/worktrees", @@ -127,6 +129,7 @@ describe("execution workspace policy helpers", () => { ).toEqual({ enabled: true, defaultMode: "isolated_workspace", + environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b", workspaceStrategy: { type: "git_worktree", worktreeParentDir: ".paperclip/worktrees", @@ -137,12 +140,83 @@ describe("execution workspace policy helpers", () => { expect( parseIssueExecutionWorkspaceSettings({ mode: "project_primary", + environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b", }), ).toEqual({ mode: "shared_workspace", + environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b", }); }); + it("prefers persisted environment selection over issue and project defaults", () => { + expect( + resolveExecutionWorkspaceEnvironmentId({ + projectPolicy: { enabled: true, environmentId: "project-env" }, + issueSettings: { environmentId: "issue-env" }, + workspaceConfig: { environmentId: "workspace-env" }, + agentDefaultEnvironmentId: "agent-env", + defaultEnvironmentId: "default-env", + }), + ).toBe("workspace-env"); + expect( + resolveExecutionWorkspaceEnvironmentId({ + projectPolicy: { enabled: true, environmentId: "project-env" }, + issueSettings: { environmentId: "issue-env" }, + workspaceConfig: null, + agentDefaultEnvironmentId: "agent-env", + defaultEnvironmentId: "default-env", + }), + ).toBe("issue-env"); + expect( + resolveExecutionWorkspaceEnvironmentId({ + projectPolicy: { enabled: true, environmentId: "project-env" }, + issueSettings: null, + workspaceConfig: null, + agentDefaultEnvironmentId: "agent-env", + defaultEnvironmentId: "default-env", + }), + ).toBe("project-env"); + }); + + it("falls back to the agent default environment before the company default", () => { + expect( + resolveExecutionWorkspaceEnvironmentId({ + projectPolicy: null, + issueSettings: null, + workspaceConfig: null, + agentDefaultEnvironmentId: "agent-env", + defaultEnvironmentId: "default-env", + }), + ).toBe("agent-env"); + expect( + resolveExecutionWorkspaceEnvironmentId({ + projectPolicy: { enabled: true, environmentId: null }, + issueSettings: null, + workspaceConfig: null, + agentDefaultEnvironmentId: "agent-env", + defaultEnvironmentId: "default-env", + }), + ).toBe("default-env"); + expect( + resolveExecutionWorkspaceEnvironmentId({ + projectPolicy: null, + issueSettings: null, + workspaceConfig: null, + agentDefaultEnvironmentId: null, + defaultEnvironmentId: "default-env", + }), + ).toBe("default-env"); + expect( + resolveExecutionWorkspaceEnvironmentId({ + projectPolicy: { enabled: true, environmentId: null }, + issueSettings: null, + workspaceConfig: null, + agentDefaultEnvironmentId: null, + defaultEnvironmentId: "default-env", + }), + ).toBe("default-env"); + }); + it("maps persisted execution workspace modes back to issue settings", () => { expect(issueExecutionWorkspaceModeForPersistedWorkspace("isolated_workspace")).toBe("isolated_workspace"); expect(issueExecutionWorkspaceModeForPersistedWorkspace("operator_branch")).toBe("operator_branch"); diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 1ca6d02fab..f4c87e5f59 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import { promisify } from "node:util"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { inArray } from "drizzle-orm"; import { companies, createDb, @@ -30,6 +31,7 @@ describe("execution workspace config helpers", () => { expect(readExecutionWorkspaceConfig({ source: "project_primary", config: { + environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b", provisionCommand: "bash ./scripts/provision-worktree.sh", teardownCommand: "bash ./scripts/teardown-worktree.sh", cleanupCommand: "pkill -f vite || true", @@ -38,6 +40,7 @@ describe("execution workspace config helpers", () => { }, }, })).toEqual({ + environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b", provisionCommand: "bash ./scripts/provision-worktree.sh", teardownCommand: "bash ./scripts/teardown-worktree.sh", cleanupCommand: "pkill -f vite || true", @@ -55,11 +58,13 @@ describe("execution workspace config helpers", () => { source: "project_primary", createdByRuntime: false, config: { + environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b", provisionCommand: "bash ./scripts/provision-worktree.sh", cleanupCommand: "pkill -f vite || true", }, }, { + environmentId: "6286d5a9-9ea7-42b9-98b3-18ee904c26d7", teardownCommand: "bash ./scripts/teardown-worktree.sh", workspaceRuntime: { services: [{ name: "web", command: "pnpm dev" }], @@ -69,6 +74,7 @@ describe("execution workspace config helpers", () => { source: "project_primary", createdByRuntime: false, config: { + environmentId: "6286d5a9-9ea7-42b9-98b3-18ee904c26d7", provisionCommand: "bash ./scripts/provision-worktree.sh", teardownCommand: "bash ./scripts/teardown-worktree.sh", cleanupCommand: "pkill -f vite || true", @@ -81,6 +87,22 @@ describe("execution workspace config helpers", () => { }); }); + it("clears a persisted environment selection when patching it to null", () => { + expect(mergeExecutionWorkspaceConfig( + { + source: "project_primary", + config: { + environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b", + }, + }, + { + environmentId: null, + }, + )).toEqual({ + source: "project_primary", + }); + }); + it("clears the nested config block when requested", () => { expect(mergeExecutionWorkspaceConfig( { @@ -223,6 +245,104 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { ])); }); + it("clears matching environment selections transactionally without touching other workspaces", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const matchingWorkspaceId = randomUUID(); + const otherWorkspaceId = randomUUID(); + const untouchedWorkspaceId = randomUUID(); + const environmentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace cleanup", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + }, + }); + await db.insert(executionWorkspaces).values([ + { + id: matchingWorkspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "directory", + name: "Matching workspace", + status: "active", + providerType: "local_fs", + cwd: "/tmp/workspace-a", + metadata: { + source: "manual", + config: { + environmentId, + cleanupCommand: "echo clean", + }, + }, + }, + { + id: otherWorkspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "directory", + name: "Different environment", + status: "active", + providerType: "local_fs", + cwd: "/tmp/workspace-b", + metadata: { + source: "manual", + config: { + environmentId: randomUUID(), + }, + }, + }, + { + id: untouchedWorkspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "directory", + name: "No environment", + status: "active", + providerType: "local_fs", + cwd: "/tmp/workspace-c", + metadata: { + source: "manual", + }, + }, + ]); + + const cleared = await svc.clearEnvironmentSelection(companyId, environmentId); + + expect(cleared).toBe(1); + + const rows = await db + .select({ + id: executionWorkspaces.id, + metadata: executionWorkspaces.metadata, + }) + .from(executionWorkspaces) + .where(inArray(executionWorkspaces.id, [matchingWorkspaceId, otherWorkspaceId, untouchedWorkspaceId])); + + const byId = new Map(rows.map((row) => [row.id, row.metadata as Record | null])); + expect(readExecutionWorkspaceConfig(byId.get(matchingWorkspaceId) ?? null)).toMatchObject({ + environmentId: null, + cleanupCommand: "echo clean", + }); + expect(readExecutionWorkspaceConfig(byId.get(otherWorkspaceId) ?? null)).toMatchObject({ + environmentId: expect.any(String), + }); + expect(readExecutionWorkspaceConfig(byId.get(untouchedWorkspaceId) ?? null)).toBeNull(); + }); + it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => { const repoRoot = await createTempRepo(); tempDirs.add(repoRoot); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index dbbcdc952a..fe0a24c6ab 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -752,7 +752,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(blockedIssue?.executionRunId).toBeNull(); expect(blockedIssue?.checkoutRunId).toBe(continuationRun?.id ?? null); - const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + const comments = await waitForValue(async () => { + const rows = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + return rows.length > 0 ? rows : null; + }); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("retried continuation"); }); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 6cabc2f063..72d408820e 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -55,6 +55,7 @@ describe("instance settings routes", () => { feedbackDataSharingPreference: "prompt", }); mockInstanceSettingsService.getExperimental.mockResolvedValue({ + enableEnvironments: false, enableIsolatedWorkspaces: false, autoRestartDevServerWhenIdle: false, }); @@ -69,6 +70,7 @@ describe("instance settings routes", () => { mockInstanceSettingsService.updateExperimental.mockResolvedValue({ id: "instance-settings-1", experimental: { + enableEnvironments: true, enableIsolatedWorkspaces: true, autoRestartDevServerWhenIdle: false, }, @@ -87,6 +89,7 @@ describe("instance settings routes", () => { const getRes = await request(app).get("/api/instance/settings/experimental"); expect(getRes.status).toBe(200); expect(getRes.body).toEqual({ + enableEnvironments: false, enableIsolatedWorkspaces: false, autoRestartDevServerWhenIdle: false, }); @@ -120,6 +123,24 @@ describe("instance settings routes", () => { }); }); + it("allows local board users to update environment controls", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + await request(app) + .patch("/api/instance/settings/experimental") + .send({ enableEnvironments: true }) + .expect(200); + + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ + enableEnvironments: true, + }); + }); + it("allows local board users to read and update general settings", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/invite-test-resolution-route.test.ts b/server/src/__tests__/invite-test-resolution-route.test.ts index 35fd9eef4d..a89449b3d1 100644 --- a/server/src/__tests__/invite-test-resolution-route.test.ts +++ b/server/src/__tests__/invite-test-resolution-route.test.ts @@ -77,25 +77,8 @@ async function createApp( return app; } -describe("GET /invites/:token/test-resolution", () => { +describe.sequential("GET /invites/:token/test-resolution", () => { beforeEach(() => { - vi.resetModules(); - vi.doUnmock("node:dns/promises"); - vi.doUnmock("node:http"); - vi.doUnmock("node:https"); - vi.doUnmock("node:net"); - vi.doUnmock("../board-claim.js"); - vi.doUnmock("../services/index.js"); - vi.doUnmock("../storage/index.js"); - vi.doUnmock("../middleware/logger.js"); - vi.doUnmock("../routes/access.js"); - vi.doUnmock("../routes/authz.js"); - vi.doUnmock("../middleware/index.js"); - vi.doMock("node:dns/promises", async () => vi.importActual("node:dns/promises")); - vi.doMock("node:http", async () => vi.importActual("node:http")); - vi.doMock("node:https", async () => vi.importActual("node:https")); - vi.doMock("node:net", async () => vi.importActual("node:net")); - vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); currentAccessModule = null; }); diff --git a/server/src/__tests__/paperclip-env.test.ts b/server/src/__tests__/paperclip-env.test.ts index 1a65d9e9e3..2d922d5858 100644 --- a/server/src/__tests__/paperclip-env.test.ts +++ b/server/src/__tests__/paperclip-env.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { buildPaperclipEnv } from "../adapters/utils.js"; +const ORIGINAL_PAPERCLIP_RUNTIME_API_URL = process.env.PAPERCLIP_RUNTIME_API_URL; const ORIGINAL_PAPERCLIP_API_URL = process.env.PAPERCLIP_API_URL; const ORIGINAL_PAPERCLIP_LISTEN_HOST = process.env.PAPERCLIP_LISTEN_HOST; const ORIGINAL_PAPERCLIP_LISTEN_PORT = process.env.PAPERCLIP_LISTEN_PORT; @@ -8,6 +9,9 @@ const ORIGINAL_HOST = process.env.HOST; const ORIGINAL_PORT = process.env.PORT; afterEach(() => { + if (ORIGINAL_PAPERCLIP_RUNTIME_API_URL === undefined) delete process.env.PAPERCLIP_RUNTIME_API_URL; + else process.env.PAPERCLIP_RUNTIME_API_URL = ORIGINAL_PAPERCLIP_RUNTIME_API_URL; + if (ORIGINAL_PAPERCLIP_API_URL === undefined) delete process.env.PAPERCLIP_API_URL; else process.env.PAPERCLIP_API_URL = ORIGINAL_PAPERCLIP_API_URL; @@ -25,7 +29,19 @@ afterEach(() => { }); describe("buildPaperclipEnv", () => { - it("prefers an explicit PAPERCLIP_API_URL", () => { + it("prefers an explicit PAPERCLIP_RUNTIME_API_URL", () => { + process.env.PAPERCLIP_RUNTIME_API_URL = "http://100.104.161.29:3102"; + process.env.PAPERCLIP_API_URL = "http://localhost:4100"; + process.env.PAPERCLIP_LISTEN_HOST = "127.0.0.1"; + process.env.PAPERCLIP_LISTEN_PORT = "3101"; + + const env = buildPaperclipEnv({ id: "agent-1", companyId: "company-1" }); + + expect(env.PAPERCLIP_API_URL).toBe("http://100.104.161.29:3102"); + }); + + it("falls back to PAPERCLIP_API_URL when no runtime URL is configured", () => { + delete process.env.PAPERCLIP_RUNTIME_API_URL; process.env.PAPERCLIP_API_URL = "http://localhost:4100"; process.env.PAPERCLIP_LISTEN_HOST = "127.0.0.1"; process.env.PAPERCLIP_LISTEN_PORT = "3101"; diff --git a/server/src/__tests__/project-goal-telemetry-routes.test.ts b/server/src/__tests__/project-goal-telemetry-routes.test.ts index 753fd3e8c1..e81041a752 100644 --- a/server/src/__tests__/project-goal-telemetry-routes.test.ts +++ b/server/src/__tests__/project-goal-telemetry-routes.test.ts @@ -22,6 +22,9 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({})); const mockSecretService = vi.hoisted(() => ({ normalizeEnvBindingsForPersistence: vi.fn(), })); +const mockEnvironmentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); const mockTelemetryTrack = vi.hoisted(() => vi.fn()); @@ -31,6 +34,7 @@ vi.mock("../telemetry.js", () => ({ })); vi.mock("../services/index.js", () => ({ + environmentService: () => mockEnvironmentService, goalService: () => mockGoalService, logActivity: mockLogActivity, projectService: () => mockProjectService, @@ -49,6 +53,7 @@ function registerModuleMocks() { })); vi.doMock("../services/index.js", () => ({ + environmentService: () => mockEnvironmentService, goalService: () => mockGoalService, logActivity: mockLogActivity, projectService: () => mockProjectService, @@ -107,6 +112,7 @@ describe("project and goal telemetry routes", () => { vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: mockTelemetryTrack }); mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); + mockEnvironmentService.getById.mockReset(); mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env); mockProjectService.create.mockResolvedValue({ id: "project-1", diff --git a/server/src/__tests__/project-routes-env.test.ts b/server/src/__tests__/project-routes-env.test.ts index b0a7e78374..5122594653 100644 --- a/server/src/__tests__/project-routes-env.test.ts +++ b/server/src/__tests__/project-routes-env.test.ts @@ -17,6 +17,9 @@ const mockProjectService = vi.hoisted(() => ({ const mockSecretService = vi.hoisted(() => ({ normalizeEnvBindingsForPersistence: vi.fn(), })); +const mockEnvironmentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); const mockWorkspaceOperationService = vi.hoisted(() => ({})); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); @@ -26,6 +29,7 @@ vi.mock("../telemetry.js", () => ({ })); vi.mock("../services/index.js", () => ({ + environmentService: () => mockEnvironmentService, logActivity: mockLogActivity, projectService: () => mockProjectService, secretService: () => mockSecretService, @@ -43,6 +47,7 @@ function registerModuleMocks() { })); vi.doMock("../services/index.js", () => ({ + environmentService: () => mockEnvironmentService, logActivity: mockLogActivity, projectService: () => mockProjectService, secretService: () => mockSecretService, @@ -127,6 +132,7 @@ describe("project env routes", () => { mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); mockProjectService.createWorkspace.mockResolvedValue(null); mockProjectService.listWorkspaces.mockResolvedValue([]); + mockEnvironmentService.getById.mockReset(); mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env); }); diff --git a/server/src/__tests__/workspace-runtime-routes-authz.test.ts b/server/src/__tests__/workspace-runtime-routes-authz.test.ts index 0b7c4d7cb3..b659b0c12e 100644 --- a/server/src/__tests__/workspace-runtime-routes-authz.test.ts +++ b/server/src/__tests__/workspace-runtime-routes-authz.test.ts @@ -20,6 +20,9 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({ const mockSecretService = vi.hoisted(() => ({ normalizeEnvBindingsForPersistence: vi.fn(), })); +const mockEnvironmentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); const mockWorkspaceOperationService = vi.hoisted(() => ({})); const mockLogActivity = vi.hoisted(() => vi.fn()); @@ -34,6 +37,7 @@ function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ executionWorkspaceService: () => mockExecutionWorkspaceService, + environmentService: () => mockEnvironmentService, logActivity: mockLogActivity, projectService: () => mockProjectService, secretService: () => mockSecretService, @@ -158,6 +162,7 @@ describe("workspace runtime service route authorization", () => { vi.doUnmock("../middleware/index.js"); registerModuleMocks(); vi.resetAllMocks(); + mockEnvironmentService.getById.mockReset(); mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env); mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); mockProjectService.create.mockResolvedValue(buildProject()); diff --git a/server/src/app.ts b/server/src/app.ts index e8c58386a8..6625f88505 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -17,6 +17,7 @@ import { projectRoutes } from "./routes/projects.js"; import { issueRoutes } from "./routes/issues.js"; import { issueTreeControlRoutes } from "./routes/issue-tree-control.js"; import { routineRoutes } from "./routes/routines.js"; +import { environmentRoutes } from "./routes/environments.js"; import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js"; import { goalRoutes } from "./routes/goals.js"; import { approvalRoutes } from "./routes/approvals.js"; @@ -43,7 +44,7 @@ import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; import { applyUiBranding } from "./ui-branding.js"; import { logger } from "./middleware/logger.js"; import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js"; -import { createPluginWorkerManager } from "./services/plugin-worker-manager.js"; +import { createPluginWorkerManager, type PluginWorkerManager } from "./services/plugin-worker-manager.js"; import { createPluginJobScheduler } from "./services/plugin-job-scheduler.js"; import { pluginJobStore } from "./services/plugin-job-store.js"; import { createPluginToolDispatcher } from "./services/plugin-tool-dispatcher.js"; @@ -129,6 +130,7 @@ export async function createApp( hostVersion?: string; localPluginDir?: string; pluginMigrationDb?: Db; + pluginWorkerManager?: PluginWorkerManager; betterAuthHandler?: express.RequestHandler; resolveSession?: (req: ExpressRequest) => Promise; }, @@ -170,6 +172,9 @@ export async function createApp( } app.use(llmRoutes(db)); + const hostServicesDisposers = new Map void>(); + const workerManager = opts.pluginWorkerManager ?? createPluginWorkerManager(); + // Mount API routes const api = Router(); api.use(boardMutationGuard()); @@ -192,6 +197,7 @@ export async function createApp( })); api.use(issueTreeControlRoutes(db)); api.use(routineRoutes(db)); + api.use(environmentRoutes(db)); api.use(executionWorkspaceRoutes(db)); api.use(goalRoutes(db)); api.use(approvalRoutes(db)); @@ -207,8 +213,6 @@ export async function createApp( if (opts.databaseBackupService) { api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService)); } - const hostServicesDisposers = new Map void>(); - const workerManager = createPluginWorkerManager(); const pluginRegistry = pluginRegistryService(db); const eventBus = createPluginEventBus(); setPluginEventBus(eventBus); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index e73ab9156b..758182c81f 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -23,6 +23,7 @@ import { updateAgentInstructionsPathSchema, wakeAgentSchema, updateAgentSchema, + supportedEnvironmentDriversForAdapter, } from "@paperclipai/shared"; import { readPaperclipSkillSyncPreference, @@ -37,6 +38,7 @@ import { approvalService, companySkillService, budgetService, + environmentService, heartbeatService, ISSUE_LIST_DEFAULT_LIMIT, issueApprovalService, @@ -76,6 +78,7 @@ import { resolveDefaultAgentInstructionsBundleRole, } from "../services/default-agent-instructions.js"; import { getTelemetryClient } from "../telemetry.js"; +import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000; const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024; @@ -139,6 +142,17 @@ export function agentRoutes(db: Db) { const instanceSettings = instanceSettingsService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; + async function assertAgentEnvironmentSelection( + companyId: string, + adapterType: string, + environmentId: string | null | undefined, + ) { + if (environmentId === undefined || environmentId === null) return; + await assertEnvironmentSelectionForCompany(environmentService(db), companyId, environmentId, { + allowedDrivers: allowedEnvironmentDriversForAgent(adapterType), + }); + } + async function getCurrentUserRedactionOptions() { return { enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, @@ -407,6 +421,10 @@ export function agentRoutes(db: Db) { return Object.hasOwn(value, key); } + function allowedEnvironmentDriversForAgent(adapterType: string): string[] { + return supportedEnvironmentDriversForAdapter(adapterType); + } + async function resolveCompanyIdForAgentReference(req: Request): Promise { const companyIdQuery = req.query.companyId; const requestedCompanyId = @@ -1609,6 +1627,7 @@ export function agentRoutes(db: Db) { createInput.adapterType, normalizedAdapterConfig, ); + await assertAgentEnvironmentSelection(companyId, createInput.adapterType, createInput.defaultEnvironmentId); const createdAgent = await svc.create(companyId, { ...createInput, @@ -2065,6 +2084,15 @@ export function agentRoutes(db: Db) { effectiveAdapterConfig, ); } + if (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId")) { + await assertAgentEnvironmentSelection( + existing.companyId, + requestedAdapterType, + Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId") + ? (typeof patchData.defaultEnvironmentId === "string" ? patchData.defaultEnvironmentId : null) + : existing.defaultEnvironmentId, + ); + } const actor = getActorInfo(req); const agent = await svc.update(id, patchData, { diff --git a/server/src/routes/environment-selection.ts b/server/src/routes/environment-selection.ts new file mode 100644 index 0000000000..c057bd1b2b --- /dev/null +++ b/server/src/routes/environment-selection.ts @@ -0,0 +1,32 @@ +import { unprocessable } from "../errors.js"; + +export async function assertEnvironmentSelectionForCompany( + environmentsSvc: { + getById(environmentId: string): Promise<{ + id: string; + companyId: string; + driver: string; + status?: string | null; + config: Record | null; + } | null>; + }, + companyId: string, + environmentId: string | null | undefined, + options?: { + allowedDrivers?: string[]; + }, +) { + if (environmentId === undefined || environmentId === null) return; + const environment = await environmentsSvc.getById(environmentId); + if (!environment || environment.companyId !== companyId) { + throw unprocessable("Environment not found."); + } + if (environment.status === "archived") { + throw unprocessable("Environment is archived."); + } + if (options?.allowedDrivers && !options.allowedDrivers.includes(environment.driver)) { + throw unprocessable( + `Environment driver "${environment.driver}" is not allowed here. Allowed drivers: ${options.allowedDrivers.join(", ")}`, + ); + } +} diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts new file mode 100644 index 0000000000..4e0bbcc4b1 --- /dev/null +++ b/server/src/routes/environments.ts @@ -0,0 +1,423 @@ +import { Router, type Request } from "express"; +import type { Db } from "@paperclipai/db"; +import { + AGENT_ADAPTER_TYPES, + createEnvironmentSchema, + getEnvironmentCapabilities, + probeEnvironmentConfigSchema, + updateEnvironmentSchema, +} from "@paperclipai/shared"; +import { forbidden } from "../errors.js"; +import { validate } from "../middleware/validate.js"; +import { + accessService, + agentService, + environmentService, + executionWorkspaceService, + issueService, + logActivity, + projectService, +} from "../services/index.js"; +import { + normalizeEnvironmentConfigForPersistence, + normalizeEnvironmentConfigForProbe, + parseEnvironmentDriverConfig, + readSshEnvironmentPrivateKeySecretId, + type ParsedEnvironmentConfig, +} from "../services/environment-config.js"; +import { probeEnvironment } from "../services/environment-probe.js"; +import { secretService } from "../services/secrets.js"; +import { assertCompanyAccess, getActorInfo } from "./authz.js"; + +export function environmentRoutes(db: Db) { + const router = Router(); + const agents = agentService(db); + const access = accessService(db); + const svc = environmentService(db); + const executionWorkspaces = executionWorkspaceService(db); + const issues = issueService(db); + const projects = projectService(db); + const secrets = secretService(db); + + function parseObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + } + + function canCreateAgents(agent: { permissions: Record | null | undefined }) { + if (!agent.permissions || typeof agent.permissions !== "object") return false; + return Boolean((agent.permissions as Record).canCreateAgents); + } + + async function assertCanMutateEnvironments(req: Request, companyId: string) { + assertCompanyAccess(req, companyId); + + if (req.actor.type === "board") { + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + const allowed = await access.canUser(companyId, req.actor.userId, "environments:manage"); + if (!allowed) { + throw forbidden("Missing permission: environments:manage"); + } + return; + } + + if (!req.actor.agentId) { + throw forbidden("Agent authentication required"); + } + + const actorAgent = await agents.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + throw forbidden("Agent key cannot access another company"); + } + + const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "environments:manage"); + if (allowedByGrant || canCreateAgents(actorAgent)) { + return; + } + + throw forbidden("Missing permission: environments:manage"); + } + + async function actorCanReadEnvironmentConfigurations(req: Request, companyId: string) { + assertCompanyAccess(req, companyId); + + if (req.actor.type === "board") { + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true; + return access.canUser(companyId, req.actor.userId, "environments:manage"); + } + + if (!req.actor.agentId) return false; + const actorAgent = await agents.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== companyId) return false; + const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "environments:manage"); + return allowedByGrant || canCreateAgents(actorAgent); + } + + function redactEnvironmentForRestrictedView; + metadata: Record | null; + }>(environment: T): T & { configRedacted: true; metadataRedacted: true } { + return { + ...environment, + config: {}, + metadata: null, + configRedacted: true, + metadataRedacted: true, + }; + } + + function summarizeEnvironmentUpdate( + patch: Record, + environment: { + name: string; + driver: string; + status: string; + }, + ): Record { + const details: Record = { + changedFields: Object.keys(patch).sort(), + }; + + if (patch.name !== undefined) details.name = environment.name; + if (patch.driver !== undefined) details.driver = environment.driver; + if (patch.status !== undefined) details.status = environment.status; + if (patch.description !== undefined) details.descriptionChanged = true; + if (patch.config !== undefined) { + details.configChanged = true; + details.configTopLevelKeyCount = + patch.config && typeof patch.config === "object" && !Array.isArray(patch.config) + ? Object.keys(patch.config as Record).length + : 0; + } + if (patch.metadata !== undefined) { + details.metadataChanged = true; + details.metadataTopLevelKeyCount = + patch.metadata && typeof patch.metadata === "object" && !Array.isArray(patch.metadata) + ? Object.keys(patch.metadata as Record).length + : 0; + } + + return details; + } + + router.get("/companies/:companyId/environments", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const rows = await svc.list(companyId, { + status: req.query.status as string | undefined, + driver: req.query.driver as string | undefined, + }); + const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, companyId); + if (canReadConfigs) { + res.json(rows); + return; + } + res.json(rows.map((environment) => redactEnvironmentForRestrictedView(environment))); + }); + + router.get("/companies/:companyId/environments/capabilities", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(getEnvironmentCapabilities(AGENT_ADAPTER_TYPES)); + }); + + router.post("/companies/:companyId/environments", validate(createEnvironmentSchema), async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanMutateEnvironments(req, companyId); + const actor = getActorInfo(req); + const input = { + ...req.body, + config: await normalizeEnvironmentConfigForPersistence({ + db, + companyId, + environmentName: req.body.name, + driver: req.body.driver, + config: req.body.config, + actor: { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + }), + }; + const environment = await svc.create(companyId, input); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "environment.created", + entityType: "environment", + entityId: environment.id, + details: { + name: environment.name, + driver: environment.driver, + status: environment.status, + }, + }); + res.status(201).json(environment); + }); + + router.get("/environments/:id", async (req, res) => { + const environment = await svc.getById(req.params.id as string); + if (!environment) { + res.status(404).json({ error: "Environment not found" }); + return; + } + assertCompanyAccess(req, environment.companyId); + const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, environment.companyId); + if (canReadConfigs) { + res.json(environment); + return; + } + res.json(redactEnvironmentForRestrictedView(environment)); + }); + + router.get("/environments/:id/leases", async (req, res) => { + const environment = await svc.getById(req.params.id as string); + if (!environment) { + res.status(404).json({ error: "Environment not found" }); + return; + } + assertCompanyAccess(req, environment.companyId); + const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, environment.companyId); + if (!canReadConfigs) { + throw forbidden("Missing permission: environments:manage"); + } + const leases = await svc.listLeases(environment.id, { + status: req.query.status as string | undefined, + }); + res.json(leases); + }); + + router.get("/environment-leases/:leaseId", async (req, res) => { + const lease = await svc.getLeaseById(req.params.leaseId as string); + if (!lease) { + res.status(404).json({ error: "Environment lease not found" }); + return; + } + assertCompanyAccess(req, lease.companyId); + const canReadConfigs = await actorCanReadEnvironmentConfigurations(req, lease.companyId); + if (!canReadConfigs) { + throw forbidden("Missing permission: environments:manage"); + } + res.json(lease); + }); + + router.patch("/environments/:id", validate(updateEnvironmentSchema), async (req, res) => { + const existing = await svc.getById(req.params.id as string); + if (!existing) { + res.status(404).json({ error: "Environment not found" }); + return; + } + await assertCanMutateEnvironments(req, existing.companyId); + const actor = getActorInfo(req); + const nextDriver = req.body.driver ?? existing.driver; + const nextName = req.body.name ?? existing.name; + const configSource = + req.body.config !== undefined + ? req.body.driver !== undefined && req.body.driver !== existing.driver + ? req.body.config + : { + ...parseObject(existing.config), + ...parseObject(req.body.config), + } + : req.body.driver !== undefined && req.body.driver !== existing.driver + ? {} + : existing.config; + const patch = { + ...req.body, + ...(req.body.config !== undefined || req.body.driver !== undefined + ? { + config: await normalizeEnvironmentConfigForPersistence({ + db, + companyId: existing.companyId, + environmentName: nextName, + driver: nextDriver, + config: configSource, + actor: { + agentId: actor.agentId, + userId: actor.actorType === "user" ? actor.actorId : null, + }, + }), + } + : {}), + }; + const environment = await svc.update(existing.id, patch); + if (!environment) { + res.status(404).json({ error: "Environment not found" }); + return; + } + await logActivity(db, { + companyId: environment.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "environment.updated", + entityType: "environment", + entityId: environment.id, + details: summarizeEnvironmentUpdate(patch as Record, environment), + }); + res.json(environment); + }); + + router.delete("/environments/:id", async (req, res) => { + const existing = await svc.getById(req.params.id as string); + if (!existing) { + res.status(404).json({ error: "Environment not found" }); + return; + } + await assertCanMutateEnvironments(req, existing.companyId); + await Promise.all([ + executionWorkspaces.clearEnvironmentSelection(existing.companyId, existing.id), + issues.clearExecutionWorkspaceEnvironmentSelection(existing.companyId, existing.id), + projects.clearExecutionWorkspaceEnvironmentSelection(existing.companyId, existing.id), + ]); + const removed = await svc.remove(existing.id); + if (!removed) { + res.status(404).json({ error: "Environment not found" }); + return; + } + const secretId = readSshEnvironmentPrivateKeySecretId(existing); + if (secretId) { + await secrets.remove(secretId); + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId: existing.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "environment.deleted", + entityType: "environment", + entityId: removed.id, + details: { + name: removed.name, + driver: removed.driver, + status: removed.status, + }, + }); + res.json(removed); + }); + + router.post("/environments/:id/probe", async (req, res) => { + const environment = await svc.getById(req.params.id as string); + if (!environment) { + res.status(404).json({ error: "Environment not found" }); + return; + } + await assertCanMutateEnvironments(req, environment.companyId); + const actor = getActorInfo(req); + const probe = await probeEnvironment(db, environment); + await logActivity(db, { + companyId: environment.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "environment.probed", + entityType: "environment", + entityId: environment.id, + details: { + driver: environment.driver, + ok: probe.ok, + summary: probe.summary, + }, + }); + res.json(probe); + }); + + router.post( + "/companies/:companyId/environments/probe-config", + validate(probeEnvironmentConfigSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanMutateEnvironments(req, companyId); + const actor = getActorInfo(req); + const normalizedConfig = normalizeEnvironmentConfigForProbe({ + driver: req.body.driver, + config: req.body.config, + }); + const environment = { + id: "unsaved", + companyId, + name: req.body.name?.trim() || "Unsaved environment", + description: req.body.description ?? null, + driver: req.body.driver, + status: "active" as const, + config: normalizedConfig, + metadata: req.body.metadata ?? null, + createdAt: new Date(), + updatedAt: new Date(), + }; + const probe = await probeEnvironment(db, environment, { + resolvedConfig: { + driver: req.body.driver, + config: normalizedConfig, + } as ParsedEnvironmentConfig, + }); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "environment.probed_unsaved", + entityType: "environment", + entityId: "unsaved", + details: { + driver: environment.driver, + ok: probe.ok, + summary: probe.summary, + configTopLevelKeyCount: Object.keys(environment.config).length, + }, + }); + res.json(probe); + }, + ); + + return router; +} diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index bb4454a521..aef722aa3a 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -55,6 +55,7 @@ import { projectService, routineService, workProductService, + environmentService, } from "../services/index.js"; import { logger } from "../middleware/logger.js"; import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js"; @@ -71,6 +72,7 @@ import { SVG_CONTENT_TYPE, } from "../attachment-types.js"; import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; +import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy, @@ -415,6 +417,19 @@ export function issueRoutes( return value === true || value === "true" || value === "1"; } + async function assertIssueEnvironmentSelection( + companyId: string, + environmentId: string | null | undefined, + ) { + if (environmentId === undefined || environmentId === null) return; + await assertEnvironmentSelectionForCompany( + environmentService(db), + companyId, + environmentId, + { allowedDrivers: ["local", "ssh"] }, + ); + } + async function logExpiredRequestConfirmations(input: { issue: { id: string; companyId: string; identifier?: string | null }; interactions: Array<{ id: string; kind: string; status: string; result?: unknown }>; @@ -1635,6 +1650,7 @@ export function issueRoutes( if (req.body.assigneeAgentId || req.body.assigneeUserId) { await assertCanAssignTasks(req, companyId); } + await assertIssueEnvironmentSelection(companyId, req.body.executionWorkspaceSettings?.environmentId); const actor = getActorInfo(req); const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy); @@ -1701,6 +1717,7 @@ export function issueRoutes( if (req.body.assigneeAgentId || req.body.assigneeUserId) { await assertCanAssignTasks(req, parent.companyId); } + await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId); const actor = getActorInfo(req); const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy); @@ -1775,6 +1792,7 @@ export function issueRoutes( hiddenAt: hiddenAtRaw, ...updateFields } = req.body; + await assertIssueEnvironmentSelection(existing.companyId, updateFields.executionWorkspaceSettings?.environmentId); const requestedAssigneeAgentId = normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId; const effectiveMoveToTodoRequested = diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 6b83aac5c2..c06d620157 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -13,7 +13,7 @@ import { import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared"; import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; -import { projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js"; +import { environmentService, projectService, logActivity, secretService, workspaceOperationService } from "../services/index.js"; import { conflict } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { @@ -31,6 +31,7 @@ import { import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js"; import { getTelemetryClient } from "../telemetry.js"; import { appendWithCap } from "../adapters/utils.js"; +import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024; @@ -40,6 +41,22 @@ export function projectRoutes(db: Db) { const secretsSvc = secretService(db); const workspaceOperations = workspaceOperationService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; + const environmentsSvc = environmentService(db); + + async function assertProjectEnvironmentSelection(companyId: string, environmentId: string | null | undefined) { + if (environmentId === undefined || environmentId === null) return; + await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, { + allowedDrivers: ["local", "ssh"], + }); + } + + function readProjectPolicyEnvironmentId(policy: unknown): string | null | undefined { + if (!policy || typeof policy !== "object" || !("environmentId" in policy)) { + return undefined; + } + const environmentId = (policy as { environmentId?: unknown }).environmentId; + return typeof environmentId === "string" || environmentId === null ? environmentId : undefined; + } async function resolveCompanyIdForProjectReference(req: Request) { const companyIdQuery = req.query.companyId; @@ -103,6 +120,10 @@ export function projectRoutes(db: Db) { }; const { workspace, ...projectData } = req.body as CreateProjectPayload; + await assertProjectEnvironmentSelection( + companyId, + readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy), + ); assertNoAgentHostWorkspaceCommandMutation( req, [ @@ -165,6 +186,10 @@ export function projectRoutes(db: Db) { req, collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy), ); + await assertProjectEnvironmentSelection( + existing.companyId, + readProjectPolicyEnvironmentId(body.executionWorkspacePolicy), + ); if (typeof body.archivedAt === "string") { body.archivedAt = new Date(body.archivedAt); } diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 512d0e89cc..0d4762cc9d 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -38,6 +38,7 @@ const CONFIG_REVISION_FIELDS = [ "adapterType", "adapterConfig", "runtimeConfig", + "defaultEnvironmentId", "budgetMonthlyCents", "metadata", ] as const; @@ -98,6 +99,7 @@ function buildConfigSnapshot( adapterType: row.adapterType, adapterConfig, runtimeConfig, + defaultEnvironmentId: row.defaultEnvironmentId, budgetMonthlyCents: row.budgetMonthlyCents, metadata, }; @@ -169,6 +171,10 @@ function configPatchFromSnapshot(snapshot: unknown): Partial value.startsWith("/"), "SSH remote workspace path must be absolute."), + privateKey: z.null().optional().default(null), + privateKeySecretRef: secretRefSchema.optional().nullable().default(null), + knownHosts: z + .string() + .trim() + .optional() + .nullable() + .transform((value) => (value && value.length > 0 ? value : null)), + strictHostKeyChecking: z.boolean().optional().default(true), +}).strict(); + +const sshEnvironmentConfigProbeSchema = sshEnvironmentConfigSchema.extend({ + privateKey: z + .string() + .trim() + .optional() + .nullable() + .transform((value) => (value && value.length > 0 ? value : null)), +}).strict(); + +const sshEnvironmentConfigPersistenceSchema = sshEnvironmentConfigProbeSchema; + +export type ParsedEnvironmentConfig = + | { driver: "local"; config: LocalEnvironmentConfig } + | { driver: "ssh"; config: SshEnvironmentConfig }; + +function toErrorMessage(error: z.ZodError) { + const first = error.issues[0]; + if (!first) return "Invalid environment config."; + return first.message; +} + +function secretName(input: { + environmentName: string; + driver: EnvironmentDriver; + field: string; +}) { + const slug = input.environmentName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) || "environment"; + return `environment-${input.driver}-${slug}-${input.field}-${randomUUID().slice(0, 8)}`; +} + +async function createEnvironmentSecret(input: { + db: Db; + companyId: string; + environmentName: string; + driver: EnvironmentDriver; + field: string; + value: string; + actor?: { userId?: string | null; agentId?: string | null }; +}) { + const created = await secretService(input.db).create( + input.companyId, + { + name: secretName(input), + provider: "local_encrypted", + value: input.value, + description: `Secret for ${input.environmentName} ${input.field}.`, + }, + input.actor, + ); + return { + type: "secret_ref" as const, + secretId: created.id, + version: "latest" as const, + }; +} + +export function normalizeEnvironmentConfig(input: { + driver: EnvironmentDriver; + config: Record | null | undefined; +}): Record { + if (input.driver === "local") { + return { ...parseObject(input.config) }; + } + + if (input.driver === "ssh") { + const parsed = sshEnvironmentConfigSchema.safeParse(parseObject(input.config)); + if (!parsed.success) { + throw unprocessable(toErrorMessage(parsed.error), { + issues: parsed.error.issues, + }); + } + return parsed.data satisfies SshEnvironmentConfig; + } + + throw unprocessable(`Unsupported environment driver "${input.driver}".`); +} + +export function normalizeEnvironmentConfigForProbe(input: { + driver: EnvironmentDriver; + config: Record | null | undefined; +}): Record { + if (input.driver === "ssh") { + const parsed = sshEnvironmentConfigProbeSchema.safeParse(parseObject(input.config)); + if (!parsed.success) { + throw unprocessable(toErrorMessage(parsed.error), { + issues: parsed.error.issues, + }); + } + return parsed.data satisfies SshEnvironmentConfig; + } + + return normalizeEnvironmentConfig(input); +} + +export async function normalizeEnvironmentConfigForPersistence(input: { + db: Db; + companyId: string; + environmentName: string; + driver: EnvironmentDriver; + config: Record | null | undefined; + actor?: { userId?: string | null; agentId?: string | null }; +}): Promise> { + if (input.driver === "ssh") { + const parsed = sshEnvironmentConfigPersistenceSchema.safeParse(parseObject(input.config)); + if (!parsed.success) { + throw unprocessable(toErrorMessage(parsed.error), { + issues: parsed.error.issues, + }); + } + const secrets = secretService(input.db); + const { privateKey, ...stored } = parsed.data; + let nextPrivateKeySecretRef = stored.privateKeySecretRef; + if (privateKey) { + nextPrivateKeySecretRef = await createEnvironmentSecret({ + db: input.db, + companyId: input.companyId, + environmentName: input.environmentName, + driver: input.driver, + field: "private-key", + value: privateKey, + actor: input.actor, + }); + if ( + stored.privateKeySecretRef && + stored.privateKeySecretRef.secretId !== nextPrivateKeySecretRef.secretId + ) { + await secrets.remove(stored.privateKeySecretRef.secretId); + } + } + return { + ...stored, + privateKey: null, + privateKeySecretRef: nextPrivateKeySecretRef, + } satisfies SshEnvironmentConfig; + } + + return normalizeEnvironmentConfig({ + driver: input.driver, + config: input.config, + }); +} + +export async function resolveEnvironmentDriverConfigForRuntime( + db: Db, + companyId: string, + environment: Pick, +): Promise { + const parsed = parseEnvironmentDriverConfig(environment); + if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) { + return { + driver: "ssh", + config: { + ...parsed.config, + privateKey: await secretService(db).resolveSecretValue( + companyId, + parsed.config.privateKeySecretRef.secretId, + parsed.config.privateKeySecretRef.version ?? "latest", + ), + }, + }; + } + + return parsed; +} + +export function readSshEnvironmentPrivateKeySecretId( + environment: Pick, +): string | null { + if (environment.driver !== "ssh") return null; + const parsed = sshEnvironmentConfigSchema.safeParse(parseObject(environment.config)); + if (!parsed.success) return null; + return parsed.data.privateKeySecretRef?.secretId ?? null; +} + +export function parseEnvironmentDriverConfig( + environment: Pick, +): ParsedEnvironmentConfig { + if (environment.driver === "local") { + return { + driver: "local", + config: { ...parseObject(environment.config) }, + }; + } + + if (environment.driver === "ssh") { + const parsed = sshEnvironmentConfigSchema.parse(parseObject(environment.config)); + return { + driver: "ssh", + config: parsed, + }; + } + + throw new Error(`Unsupported environment driver "${environment.driver}".`); +} diff --git a/server/src/services/environment-probe.ts b/server/src/services/environment-probe.ts new file mode 100644 index 0000000000..6ffcc597b7 --- /dev/null +++ b/server/src/services/environment-probe.ts @@ -0,0 +1,77 @@ +import type { Environment, EnvironmentProbeResult } from "@paperclipai/shared"; +import type { Db } from "@paperclipai/db"; +import { ensureSshWorkspaceReady } from "@paperclipai/adapter-utils/ssh"; +import { + resolveEnvironmentDriverConfigForRuntime, + type ParsedEnvironmentConfig, +} from "./environment-config.js"; +import os from "node:os"; + +export async function probeEnvironment( + db: Db, + environment: Environment, + options: { resolvedConfig?: ParsedEnvironmentConfig } = {}, +): Promise { + const parsed = options.resolvedConfig ?? await resolveEnvironmentDriverConfigForRuntime(db, environment.companyId, environment); + + if (parsed.driver === "local") { + return { + ok: true, + driver: "local", + summary: "Local environment is available on this Paperclip host.", + details: { + hostname: os.hostname(), + cwd: process.cwd(), + }, + }; + } + + try { + const { remoteCwd } = await ensureSshWorkspaceReady(parsed.config); + + return { + ok: true, + driver: "ssh", + summary: `Connected to ${parsed.config.username}@${parsed.config.host} and verified the remote workspace path.`, + details: { + host: parsed.config.host, + port: parsed.config.port, + username: parsed.config.username, + remoteWorkspacePath: parsed.config.remoteWorkspacePath, + remoteCwd, + }, + }; + } catch (error) { + const stderr = + error && typeof error === "object" && "stderr" in error && typeof error.stderr === "string" + ? error.stderr.trim() + : ""; + const stdout = + error && typeof error === "object" && "stdout" in error && typeof error.stdout === "string" + ? error.stdout.trim() + : ""; + const code = + error && typeof error === "object" && "code" in error + ? (error as { code?: unknown }).code + : null; + const message = + stderr || + stdout || + (error instanceof Error ? error.message : String(error)) || + "SSH probe failed."; + + return { + ok: false, + driver: "ssh", + summary: `SSH probe failed for ${parsed.config.username}@${parsed.config.host}.`, + details: { + host: parsed.config.host, + port: parsed.config.port, + username: parsed.config.username, + remoteWorkspacePath: parsed.config.remoteWorkspacePath, + error: message, + code, + }, + }; + } +} diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index 26d1f88b6b..1eb5a2a8d6 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -1,4 +1,4 @@ -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { environmentLeases, environments } from "@paperclipai/db"; import { @@ -130,6 +130,7 @@ export function environmentService(db: Db) { }) .onConflictDoNothing({ target: [environments.companyId, environments.driver], + where: sql`${environments.driver} = 'local'`, }) .returning() .then((rows) => rows[0] ?? null); @@ -189,6 +190,15 @@ export function environmentService(db: Db) { return row ? toEnvironment(row) : null; }, + remove: async (id: string): Promise => { + const row = await db + .delete(environments) + .where(eq(environments.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + return row ? toEnvironment(row) : null; + }, + listLeases: async ( environmentId: string, filters: { diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index bb5ef76df2..ea89d4414f 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -38,6 +38,7 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu const defaultMode = asString(parsed.defaultMode, ""); const defaultProjectWorkspaceId = typeof parsed.defaultProjectWorkspaceId === "string" ? parsed.defaultProjectWorkspaceId : undefined; + const environmentId = typeof parsed.environmentId === "string" ? parsed.environmentId : undefined; const allowIssueOverride = typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined; const normalizedDefaultMode = (() => { @@ -58,6 +59,7 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu ...(normalizedDefaultMode ? { defaultMode: normalizedDefaultMode } : {}), ...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}), ...(defaultProjectWorkspaceId ? { defaultProjectWorkspaceId } : {}), + ...(environmentId !== undefined ? { environmentId } : {}), ...(workspaceStrategy ? { workspaceStrategy } : {}), ...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime) ? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record) } } @@ -109,6 +111,7 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti ...(normalizedMode ? { mode: normalizedMode as IssueExecutionWorkspaceSettings["mode"] } : {}), + ...(typeof parsed.environmentId === "string" ? { environmentId: parsed.environmentId } : {}), ...(workspaceStrategy ? { workspaceStrategy } : {}), ...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime) ? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record) } } @@ -116,6 +119,28 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti }; } +export function resolveExecutionWorkspaceEnvironmentId(input: { + projectPolicy: ProjectExecutionWorkspacePolicy | null; + issueSettings: IssueExecutionWorkspaceSettings | null; + workspaceConfig: { environmentId?: string | null } | null; + agentDefaultEnvironmentId: string | null; + defaultEnvironmentId: string; +}) { + if (input.workspaceConfig?.environmentId !== undefined) { + return input.workspaceConfig.environmentId ?? input.defaultEnvironmentId; + } + if (input.issueSettings?.environmentId !== undefined) { + return input.issueSettings.environmentId ?? input.defaultEnvironmentId; + } + if (input.projectPolicy?.environmentId !== undefined) { + return input.projectPolicy.environmentId ?? input.defaultEnvironmentId; + } + if (input.agentDefaultEnvironmentId !== null) { + return input.agentDefaultEnvironmentId; + } + return input.defaultEnvironmentId; +} + export function defaultIssueExecutionWorkspaceSettingsForProject( projectPolicy: ProjectExecutionWorkspacePolicy | null, ): IssueExecutionWorkspaceSettings | null { diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 6bbc05dabd..e5fa36a1cb 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -203,6 +203,7 @@ export function readExecutionWorkspaceConfig(metadata: Record | if (!raw) return null; const config: ExecutionWorkspaceConfig = { + environmentId: readNullableString(raw.environmentId), provisionCommand: readNullableString(raw.provisionCommand), teardownCommand: readNullableString(raw.teardownCommand), cleanupCommand: readNullableString(raw.cleanupCommand), @@ -226,6 +227,7 @@ export function mergeExecutionWorkspaceConfig( ): Record | null { const nextMetadata = isRecord(metadata) ? { ...metadata } : {}; const current = readExecutionWorkspaceConfig(metadata) ?? { + environmentId: null, provisionCommand: null, teardownCommand: null, cleanupCommand: null, @@ -240,6 +242,7 @@ export function mergeExecutionWorkspaceConfig( } const nextConfig: ExecutionWorkspaceConfig = { + environmentId: patch.environmentId !== undefined ? readNullableString(patch.environmentId) : current.environmentId, provisionCommand: patch.provisionCommand !== undefined ? readNullableString(patch.provisionCommand) : current.provisionCommand, teardownCommand: patch.teardownCommand !== undefined ? readNullableString(patch.teardownCommand) : current.teardownCommand, cleanupCommand: patch.cleanupCommand !== undefined ? readNullableString(patch.cleanupCommand) : current.cleanupCommand, @@ -260,6 +263,7 @@ export function mergeExecutionWorkspaceConfig( if (hasConfig) { nextMetadata.config = { + environmentId: nextConfig.environmentId, provisionCommand: nextConfig.provisionCommand, teardownCommand: nextConfig.teardownCommand, cleanupCommand: nextConfig.cleanupCommand, @@ -739,6 +743,37 @@ export function executionWorkspaceService(db: Db) { .then((rows) => rows[0] ?? null); return row ? toExecutionWorkspace(row) : null; }, + + clearEnvironmentSelection: async (companyId: string, environmentId: string) => { + return db.transaction(async (tx) => { + const rows = await tx + .select({ + id: executionWorkspaces.id, + metadata: executionWorkspaces.metadata, + }) + .from(executionWorkspaces) + .where(eq(executionWorkspaces.companyId, companyId)); + + let cleared = 0; + const updatedAt = new Date(); + for (const row of rows) { + const metadata = (row.metadata as Record | null) ?? null; + const config = readExecutionWorkspaceConfig(metadata); + if (config?.environmentId !== environmentId) continue; + + await tx + .update(executionWorkspaces) + .set({ + metadata: mergeExecutionWorkspaceConfig(metadata, { environmentId: null }), + updatedAt, + }) + .where(eq(executionWorkspaces.id, row.id)); + cleared += 1; + } + + return cleared; + }); + }, }; } diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 0618bea9be..8dbc3b74b8 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -8,12 +8,19 @@ import type { Db } from "@paperclipai/db"; import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, + isEnvironmentDriverSupportedForAdapter, type BillingType, type EnvironmentLeaseStatus, type ExecutionWorkspace, type ExecutionWorkspaceConfig, type RunLivenessState, } from "@paperclipai/shared"; +import { + ensureSshWorkspaceReady, + findReachablePaperclipApiUrlOverSsh, + type SshRemoteExecutionSpec, +} from "@paperclipai/adapter-utils/ssh"; +import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; import { agents, agentRuntimeState, @@ -98,8 +105,10 @@ import { issueExecutionWorkspaceModeForPersistedWorkspace, parseIssueExecutionWorkspaceSettings, parseProjectExecutionWorkspacePolicy, + resolveExecutionWorkspaceEnvironmentId, resolveExecutionWorkspaceMode, } from "./execution-workspace-policy.js"; +import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js"; import { instanceSettingsService } from "./instance-settings.js"; import { RUN_LIVENESS_CONTINUATION_REASON, @@ -322,6 +331,27 @@ function leaseReleaseStatusForRunStatus( return status === "failed" || status === "timed_out" ? "failed" : "released"; } +function runtimeApiUrlCandidates() { + const candidates = [ + process.env.PAPERCLIP_RUNTIME_API_URL, + process.env.PAPERCLIP_API_URL, + process.env.PUBLIC_BASE_URL, + ].filter((value): value is string => typeof value === "string" && value.trim().length > 0); + const encoded = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; + if (!encoded) return candidates; + try { + const parsed = JSON.parse(encoded); + if (Array.isArray(parsed)) { + candidates.push( + ...parsed.filter((value): value is string => typeof value === "string" && value.trim().length > 0), + ); + } + } catch { + logger.warn("Ignoring invalid PAPERCLIP_RUNTIME_API_CANDIDATES_JSON"); + } + return candidates; +} + export function applyPersistedExecutionWorkspaceConfig(input: { config: Record; workspaceConfig: ExecutionWorkspaceConfig | null; @@ -391,9 +421,19 @@ export function buildRealizedExecutionWorkspaceFromPersisted(input: { }; } -function buildExecutionWorkspaceConfigSnapshot(config: Record): Partial | null { +function buildExecutionWorkspaceConfigSnapshot( + config: Record, + environmentId?: string | null, +): Partial | null { const strategy = parseObject(config.workspaceStrategy); const snapshot: Partial = {}; + // Persist the resolved environment onto the workspace so reused sessions stay on the + // environment they were created against until the workspace itself is recreated/reset. + const hasExplicitEnvironmentSelection = environmentId !== undefined; + + if (hasExplicitEnvironmentSelection) { + snapshot.environmentId = environmentId ?? null; + } if ("workspaceStrategy" in config) { snapshot.provisionCommand = typeof strategy.provisionCommand === "string" ? strategy.provisionCommand : null; @@ -426,7 +466,7 @@ function buildExecutionWorkspaceConfigSnapshot(config: Record): if (typeof value === "object") return Object.keys(value).length > 0; return true; }); - return hasSnapshot ? snapshot : null; + return hasSnapshot || hasExplicitEnvironmentSelection ? snapshot : null; } function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null { @@ -5061,7 +5101,15 @@ export function heartbeatService(db: Db) { const mergedConfig = issueAssigneeOverrides?.adapterConfig ? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig } : persistedWorkspaceManagedConfig; - const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig); + const defaultEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId); + const selectedEnvironmentId = resolveExecutionWorkspaceEnvironmentId({ + projectPolicy: projectExecutionWorkspacePolicy, + issueSettings: issueExecutionWorkspaceSettings, + workspaceConfig: existingExecutionWorkspace?.config ?? null, + agentDefaultEnvironmentId: agent.defaultEnvironmentId, + defaultEnvironmentId: defaultEnvironment.id, + }); + const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig, selectedEnvironmentId); const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig); const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({ companyId: agent.companyId, @@ -5294,26 +5342,105 @@ export function heartbeatService(db: Db) { })(), }; context.paperclipWorkspaces = resolvedWorkspace.workspaceHints; - const localEnvironment = await environmentsSvc.ensureLocalEnvironment(agent.companyId); + const selectedEnvironment = + selectedEnvironmentId === defaultEnvironment.id + ? defaultEnvironment + : await environmentsSvc.getById(selectedEnvironmentId); + if (!selectedEnvironment || selectedEnvironment.companyId !== agent.companyId) { + throw notFound(`Environment "${selectedEnvironmentId}" not found.`); + } + if (selectedEnvironment.status !== "active") { + throw conflict(`Environment "${selectedEnvironment.name}" is not active.`); + } + if (!isEnvironmentDriverSupportedForAdapter(agent.adapterType, selectedEnvironment.driver)) { + throw conflict( + `Adapter "${agent.adapterType}" does not support "${selectedEnvironment.driver}" environments.`, + ); + } + + const selectedEnvironmentRuntimeConfig = await resolveEnvironmentDriverConfigForRuntime( + db, + agent.companyId, + selectedEnvironment, + ); + let environmentProvider = selectedEnvironment.driver; + let environmentProviderLeaseId: string | null = null; + let environmentLeaseMetadata: Record = { + driver: selectedEnvironment.driver, + executionWorkspaceMode: persistedExecutionWorkspace?.mode ?? effectiveExecutionWorkspaceMode, + cwd: executionWorkspace.cwd, + }; + let executionTarget: AdapterExecutionTarget | null = null; + let remoteExecution: SshRemoteExecutionSpec | null = null; + + if (selectedEnvironmentRuntimeConfig.driver === "ssh") { + const { remoteCwd } = await ensureSshWorkspaceReady(selectedEnvironmentRuntimeConfig.config); + const paperclipApiUrl = await findReachablePaperclipApiUrlOverSsh({ + config: selectedEnvironmentRuntimeConfig.config, + candidates: runtimeApiUrlCandidates(), + }); + remoteExecution = { + ...selectedEnvironmentRuntimeConfig.config, + remoteCwd, + paperclipApiUrl, + }; + environmentProvider = "ssh"; + environmentProviderLeaseId = `ssh://${selectedEnvironmentRuntimeConfig.config.username}@${selectedEnvironmentRuntimeConfig.config.host}:${selectedEnvironmentRuntimeConfig.config.port}${remoteCwd}`; + environmentLeaseMetadata = { + ...environmentLeaseMetadata, + host: selectedEnvironmentRuntimeConfig.config.host, + port: selectedEnvironmentRuntimeConfig.config.port, + username: selectedEnvironmentRuntimeConfig.config.username, + remoteWorkspacePath: selectedEnvironmentRuntimeConfig.config.remoteWorkspacePath, + remoteCwd, + paperclipApiUrl, + }; + } + const environmentLease = await environmentsSvc.acquireLease({ companyId: agent.companyId, - environmentId: localEnvironment.id, + environmentId: selectedEnvironment.id, executionWorkspaceId: persistedExecutionWorkspace?.id ?? null, issueId: issueId ?? null, heartbeatRunId: run.id, leasePolicy: "ephemeral", - provider: "local", - metadata: { - driver: "local", - executionWorkspaceMode: persistedExecutionWorkspace?.mode ?? effectiveExecutionWorkspaceMode, - cwd: executionWorkspace.cwd, - }, + provider: environmentProvider, + providerLeaseId: environmentProviderLeaseId, + metadata: environmentLeaseMetadata, }); + if (remoteExecution) { + executionTarget = { + kind: "remote", + transport: "ssh", + environmentId: selectedEnvironment.id, + leaseId: environmentLease.id, + remoteCwd: remoteExecution.remoteCwd, + paperclipApiUrl: remoteExecution.paperclipApiUrl, + spec: remoteExecution, + }; + } context.paperclipEnvironment = { - id: localEnvironment.id, - name: localEnvironment.name, - driver: localEnvironment.driver, + id: selectedEnvironment.id, + name: selectedEnvironment.name, + driver: selectedEnvironment.driver, leaseId: environmentLease.id, + ...(typeof environmentLease.metadata?.remoteCwd === "string" + ? { + remoteCwd: environmentLease.metadata.remoteCwd, + host: + typeof environmentLease.metadata?.host === "string" + ? environmentLease.metadata.host + : undefined, + port: + typeof environmentLease.metadata?.port === "number" + ? environmentLease.metadata.port + : undefined, + username: + typeof environmentLease.metadata?.username === "string" + ? environmentLease.metadata.username + : undefined, + } + : {}), }; await logActivity(db, { companyId: agent.companyId, @@ -5325,8 +5452,8 @@ export function heartbeatService(db: Db) { entityType: "environment_lease", entityId: environmentLease.id, details: { - environmentId: localEnvironment.id, - driver: localEnvironment.driver, + environmentId: selectedEnvironment.id, + driver: selectedEnvironment.driver, leasePolicy: environmentLease.leasePolicy, provider: environmentLease.provider, executionWorkspaceId: environmentLease.executionWorkspaceId, @@ -5592,6 +5719,10 @@ export function heartbeatService(db: Db) { runtime: runtimeForAdapter, config: runtimeConfig, context, + executionTarget, + executionTransport: remoteExecution + ? { remoteExecution: remoteExecution as unknown as Record } + : undefined, onLog, onMeta: onAdapterMeta, onSpawn: async (meta) => { diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 06e6f9df57..4a6abb9e9e 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -38,11 +38,13 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {}); if (parsed.success) { return { + enableEnvironments: parsed.data.enableEnvironments ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, }; } return { + enableEnvironments: false, enableIsolatedWorkspaces: false, autoRestartDevServerWhenIdle: false, }; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index c611c73d51..013d0e9b51 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -30,6 +30,7 @@ import { defaultIssueExecutionWorkspaceSettingsForProject, gateProjectExecutionWorkspacePolicy, issueExecutionWorkspaceModeForPersistedWorkspace, + parseIssueExecutionWorkspaceSettings, parseProjectExecutionWorkspacePolicy, } from "./execution-workspace-policy.js"; import { instanceSettingsService } from "./instance-settings.js"; @@ -2191,6 +2192,36 @@ export function issueService(db: Db) { return dbOrTx === db ? db.transaction(runUpdate) : runUpdate(dbOrTx); }, + clearExecutionWorkspaceEnvironmentSelection: async (companyId: string, environmentId: string) => { + const rows = await db + .select({ + id: issues.id, + executionWorkspaceSettings: issues.executionWorkspaceSettings, + }) + .from(issues) + .where(eq(issues.companyId, companyId)); + + let cleared = 0; + for (const row of rows) { + const settings = parseIssueExecutionWorkspaceSettings(row.executionWorkspaceSettings); + if (settings?.environmentId !== environmentId) continue; + + await db + .update(issues) + .set({ + executionWorkspaceSettings: { + ...settings, + environmentId: null, + }, + updatedAt: new Date(), + }) + .where(eq(issues.id, row.id)); + cleared += 1; + } + + return cleared; + }, + remove: (id: string) => db.transaction(async (tx) => { const attachmentAssetIds = await tx diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index f653ea1a2f..2bcf7affe3 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -523,6 +523,36 @@ export function projectService(db: Db) { return enriched ?? null; }, + clearExecutionWorkspaceEnvironmentSelection: async (companyId: string, environmentId: string) => { + const rows = await db + .select({ + id: projects.id, + executionWorkspacePolicy: projects.executionWorkspacePolicy, + }) + .from(projects) + .where(eq(projects.companyId, companyId)); + + let cleared = 0; + for (const row of rows) { + const policy = parseProjectExecutionWorkspacePolicy(row.executionWorkspacePolicy); + if (policy?.environmentId !== environmentId) continue; + + await db + .update(projects) + .set({ + executionWorkspacePolicy: { + ...policy, + environmentId: null, + }, + updatedAt: new Date(), + }) + .where(eq(projects.id, row.id)); + cleared += 1; + } + + return cleared; + }, + remove: (id: string) => db .delete(projects) diff --git a/ui/src/api/environments.ts b/ui/src/api/environments.ts new file mode 100644 index 0000000000..28e2a35721 --- /dev/null +++ b/ui/src/api/environments.ts @@ -0,0 +1,32 @@ +import type { Environment, EnvironmentCapabilities, EnvironmentLease, EnvironmentProbeResult } from "@paperclipai/shared"; +import { api } from "./client"; + +export const environmentsApi = { + list: (companyId: string) => api.get(`/companies/${companyId}/environments`), + capabilities: (companyId: string) => + api.get(`/companies/${companyId}/environments/capabilities`), + lease: (leaseId: string) => api.get(`/environment-leases/${leaseId}`), + create: (companyId: string, body: { + name: string; + description?: string | null; + driver: "local" | "ssh"; + config?: Record; + metadata?: Record | null; + }) => api.post(`/companies/${companyId}/environments`, body), + update: (environmentId: string, body: { + name?: string; + description?: string | null; + driver?: "local" | "ssh"; + status?: "active" | "archived"; + config?: Record; + metadata?: Record | null; + }) => api.patch(`/environments/${environmentId}`, body), + probe: (environmentId: string) => api.post(`/environments/${environmentId}/probe`, {}), + probeConfig: (companyId: string, body: { + name?: string; + description?: string | null; + driver: "local" | "ssh"; + config?: Record; + metadata?: Record | null; + }) => api.post(`/companies/${companyId}/environments/probe-config`, body), +}; diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index a053b26058..c786182063 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -5,10 +5,13 @@ import type { AdapterEnvironmentTestResult, CompanySecret, EnvBinding, + Environment, } from "@paperclipai/shared"; -import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS } from "@paperclipai/shared"; +import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter } from "@paperclipai/shared"; import type { AdapterModel } from "../api/agents"; import { agentsApi } from "../api/agents"; +import { environmentsApi } from "../api/environments"; +import { instanceSettingsApi } from "../api/instanceSettings"; import { secretsApi } from "../api/secrets"; import { assetsApi } from "../api/assets"; import { @@ -186,7 +189,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) { queryFn: () => secretsApi.list(selectedCompanyId!), enabled: Boolean(selectedCompanyId), }); + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + retry: false, + }); + const environmentsEnabled = experimentalSettings?.enableEnvironments === true; + const { data: environments = [] } = useQuery({ + queryKey: selectedCompanyId ? queryKeys.environments.list(selectedCompanyId) : ["environments", "none"], + queryFn: () => environmentsApi.list(selectedCompanyId!), + enabled: Boolean(selectedCompanyId) && environmentsEnabled, + }); const createSecret = useMutation({ mutationFn: (input: { name: string; value: string }) => { if (!selectedCompanyId) throw new Error("Select a company to create secrets"); @@ -278,6 +292,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const showLegacyWorkingDirectoryField = isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); + const supportedEnvironmentDrivers = useMemo( + () => new Set(supportedEnvironmentDriversForAdapter(adapterType)), + [adapterType], + ); + const runnableEnvironments = useMemo( + () => environments.filter((environment) => supportedEnvironmentDrivers.has(environment.driver)), + [environments, supportedEnvironmentDrivers], + ); // Fetch adapter models for the effective adapter type const { @@ -432,6 +454,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { heartbeat: mergedHeartbeat, }; }, [isCreate, overlay.heartbeat, runtimeConfig, val]); + const currentDefaultEnvironmentId = isCreate + ? val!.defaultEnvironmentId ?? "" + : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? ""); return (
{/* ---- Floating Save button (edit mode, when dirty) ---- */} @@ -528,6 +553,42 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)} + {/* ---- Execution ---- */} + {environmentsEnabled ? ( +
+ {cards + ?

Execution

+ :
Execution
+ } +
+ + + +
+
+ ) : null} + {/* ---- Adapter ---- */}
diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index d7ace08c33..d9b33bd574 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Project } from "@paperclipai/shared"; import { StatusBadge } from "./StatusBadge"; import { cn, formatDate } from "../lib/utils"; +import { environmentsApi } from "../api/environments"; import { goalsApi } from "../api/goals"; import { instanceSettingsApi } from "../api/instanceSettings"; import { projectsApi } from "../api/projects"; @@ -48,6 +49,7 @@ export type ProjectConfigFieldKey = | "env" | "execution_workspace_enabled" | "execution_workspace_default_mode" + | "execution_workspace_environment" | "execution_workspace_base_ref" | "execution_workspace_branch_template" | "execution_workspace_worktree_parent_dir" @@ -248,6 +250,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa queryFn: () => instanceSettingsApi.getExperimental(), retry: false, }); + const environmentsEnabled = experimentalSettings?.enableEnvironments === true; const { data: availableSecrets = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"], queryFn: () => secretsApi.list(selectedCompanyId!), @@ -263,6 +266,11 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) }); }, }); + const { data: environments } = useQuery({ + queryKey: queryKeys.environments.list(selectedCompanyId!), + queryFn: () => environmentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && environmentsEnabled, + }); const linkedGoalIds = project.goalIds.length > 0 ? project.goalIds @@ -287,12 +295,16 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; const executionWorkspaceDefaultMode = executionWorkspacePolicy?.defaultMode === "isolated_workspace" ? "isolated_workspace" : "shared_workspace"; + const executionWorkspaceEnvironmentId = executionWorkspacePolicy?.environmentId ?? ""; const executionWorkspaceStrategy = executionWorkspacePolicy?.workspaceStrategy ?? { type: "git_worktree", baseRef: "", branchTemplate: "", worktreeParentDir: "", }; + const runSelectableEnvironments = (environments ?? []).filter((environment) => + environment.driver === "local" || environment.driver === "ssh" + ); const invalidateProject = () => { queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); @@ -985,6 +997,34 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
Host-managed implementation: Git worktree
+ {environmentsEnabled ? ( +
+
+ +
+ +
+ ) : null}