mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
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
This commit is contained in:
parent
f98c348e2b
commit
e4995bbb1c
95 changed files with 10162 additions and 315 deletions
24
cli/src/__tests__/env-lab.test.ts
Normal file
24
cli/src/__tests__/env-lab.test.ts
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
174
cli/src/commands/env-lab.ts
Normal file
174
cli/src/commands/env-lab.ts
Normal file
|
|
@ -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 <id>", "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 <id>", "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 <id>", "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 <id>", "Paperclip instance id (default: current/default)")
|
||||||
|
.option("--json", "Print machine-readable diagnostic details")
|
||||||
|
.action(envLabDoctorCommand);
|
||||||
|
}
|
||||||
|
|
@ -1311,6 +1311,7 @@ async function seedWorktreeDatabase(input: {
|
||||||
backupDir: path.resolve(input.targetPaths.backupDir, "seed"),
|
backupDir: path.resolve(input.targetPaths.backupDir, "seed"),
|
||||||
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
|
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
|
||||||
filenamePrefix: `${input.instanceId}-seed`,
|
filenamePrefix: `${input.instanceId}-seed`,
|
||||||
|
backupEngine: "javascript",
|
||||||
includeMigrationJournal: true,
|
includeMigrationJournal: true,
|
||||||
excludeTables: seedPlan.excludedTables,
|
excludeTables: seedPlan.excludedTables,
|
||||||
nullifyColumns: seedPlan.nullifyColumns,
|
nullifyColumns: seedPlan.nullifyColumns,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { heartbeatRun } from "./commands/heartbeat-run.js";
|
||||||
import { runCommand } from "./commands/run.js";
|
import { runCommand } from "./commands/run.js";
|
||||||
import { bootstrapCeoInvite } from "./commands/auth-bootstrap-ceo.js";
|
import { bootstrapCeoInvite } from "./commands/auth-bootstrap-ceo.js";
|
||||||
import { dbBackupCommand } from "./commands/db-backup.js";
|
import { dbBackupCommand } from "./commands/db-backup.js";
|
||||||
|
import { registerEnvLabCommands } from "./commands/env-lab.js";
|
||||||
import { registerContextCommands } from "./commands/client/context.js";
|
import { registerContextCommands } from "./commands/client/context.js";
|
||||||
import { registerCompanyCommands } from "./commands/client/company.js";
|
import { registerCompanyCommands } from "./commands/client/company.js";
|
||||||
import { registerIssueCommands } from "./commands/client/issue.js";
|
import { registerIssueCommands } from "./commands/client/issue.js";
|
||||||
|
|
@ -147,6 +148,7 @@ registerDashboardCommands(program);
|
||||||
registerRoutineCommands(program);
|
registerRoutineCommands(program);
|
||||||
registerFeedbackCommands(program);
|
registerFeedbackCommands(program);
|
||||||
registerWorktreeCommands(program);
|
registerWorktreeCommands(program);
|
||||||
|
registerEnvLabCommands(program);
|
||||||
registerPluginCommands(program);
|
registerPluginCommands(program);
|
||||||
|
|
||||||
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
||||||
|
|
|
||||||
11
doc/CLI.md
11
doc/CLI.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Paperclip CLI now supports both:
|
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)
|
- control-plane client operations (issues, approvals, agents, activity, dashboard)
|
||||||
|
|
||||||
## Base Usage
|
## Base Usage
|
||||||
|
|
@ -45,6 +45,15 @@ Allow an authenticated/private hostname (for example custom Tailscale DNS):
|
||||||
pnpm paperclipai allowed-hostname dotta-macbook-pro
|
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:
|
All client commands support:
|
||||||
|
|
||||||
- `--data-dir <path>`
|
- `--data-dir <path>`
|
||||||
|
|
|
||||||
161
packages/adapter-utils/src/execution-target.test.ts
Normal file
161
packages/adapter-utils/src/execution-target.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
399
packages/adapter-utils/src/execution-target.ts
Normal file
399
packages/adapter-utils/src/execution-target.ts
Normal file
|
|
@ -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<string, string>;
|
||||||
|
restoreWorkspace(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdapterExecutionTargetProcessOptions {
|
||||||
|
cwd: string;
|
||||||
|
env: Record<string, string>;
|
||||||
|
stdin?: string;
|
||||||
|
timeoutSec: number;
|
||||||
|
graceSec: number;
|
||||||
|
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||||
|
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
||||||
|
terminalResultCleanup?: TerminalResultCleanupOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdapterExecutionTargetShellOptions {
|
||||||
|
cwd: string;
|
||||||
|
env: Record<string, string>;
|
||||||
|
timeoutSec?: number;
|
||||||
|
graceSec?: number;
|
||||||
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObject(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringMeta(parsed: Record<string, unknown>, 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<string> {
|
||||||
|
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<RunProcessResult> {
|
||||||
|
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<RunProcessResult> {
|
||||||
|
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<string | null> {
|
||||||
|
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<void> {
|
||||||
|
await runAdapterExecutionTargetShellCommand(
|
||||||
|
runId,
|
||||||
|
target,
|
||||||
|
`mkdir -p ${shellQuote(path.posix.dirname(filePath))} && : > ${shellQuote(filePath)}`,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adapterExecutionTargetSessionIdentity(
|
||||||
|
target: AdapterExecutionTarget | null | undefined,
|
||||||
|
): Record<string, unknown> | 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<AdapterLocalExecutionTarget, "environmentId" | "leaseId"> = {},
|
||||||
|
): 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<PreparedAdapterExecutionTargetRuntime> {
|
||||||
|
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<PreparedAdapterExecutionTargetRuntime, "assetDirs">,
|
||||||
|
key: string,
|
||||||
|
fallbackRemoteCwd: string,
|
||||||
|
): string {
|
||||||
|
return prepared.assetDirs[key] ?? path.posix.join(fallbackRemoteCwd, ".paperclip-runtime", key);
|
||||||
|
}
|
||||||
118
packages/adapter-utils/src/remote-managed-runtime.ts
Normal file
118
packages/adapter-utils/src/remote-managed-runtime.ts
Normal file
|
|
@ -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<string, string>;
|
||||||
|
restoreWorkspace(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObject(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PreparedRemoteManagedRuntime> {
|
||||||
|
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<string, string> = {};
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { constants as fsConstants, promises as fs, type Dirent } from "node:fs";
|
import { constants as fsConstants, promises as fs, type Dirent } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { buildSshSpawnTarget, type SshRemoteExecutionSpec } from "./ssh.js";
|
||||||
import type {
|
import type {
|
||||||
AdapterSkillEntry,
|
AdapterSkillEntry,
|
||||||
AdapterSkillSnapshot,
|
AdapterSkillSnapshot,
|
||||||
|
|
@ -30,8 +31,12 @@ interface RunningProcess {
|
||||||
interface SpawnTarget {
|
interface SpawnTarget {
|
||||||
command: string;
|
command: string;
|
||||||
args: string[];
|
args: string[];
|
||||||
|
cwd?: string;
|
||||||
|
cleanup?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RemoteExecutionSpec = SshRemoteExecutionSpec;
|
||||||
|
|
||||||
type ChildProcessWithEvents = ChildProcess & {
|
type ChildProcessWithEvents = ChildProcess & {
|
||||||
on(event: "error", listener: (err: Error) => void): ChildProcess;
|
on(event: "error", listener: (err: Error) => void): ChildProcess;
|
||||||
on(
|
on(
|
||||||
|
|
@ -806,11 +811,26 @@ export function buildPaperclipEnv(agent: { id: string; companyId: string }): Rec
|
||||||
process.env.PAPERCLIP_LISTEN_HOST ?? process.env.HOST ?? "localhost",
|
process.env.PAPERCLIP_LISTEN_HOST ?? process.env.HOST ?? "localhost",
|
||||||
);
|
);
|
||||||
const runtimePort = process.env.PAPERCLIP_LISTEN_PORT ?? process.env.PORT ?? "3100";
|
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;
|
vars.PAPERCLIP_API_URL = apiUrl;
|
||||||
return vars;
|
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() {
|
export function defaultPathForPlatform() {
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
return "C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\Wbem";
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveCommandForLogs(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise<string> {
|
export async function resolveCommandForLogs(
|
||||||
|
command: string,
|
||||||
|
cwd: string,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
options: {
|
||||||
|
remoteExecution?: RemoteExecutionSpec | null;
|
||||||
|
} = {},
|
||||||
|
): Promise<string> {
|
||||||
|
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;
|
return (await resolveCommandPath(command, cwd, env)) ?? command;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -879,7 +910,33 @@ async function resolveSpawnTarget(
|
||||||
args: string[],
|
args: string[],
|
||||||
cwd: string,
|
cwd: string,
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
|
options: {
|
||||||
|
remoteExecution?: RemoteExecutionSpec | null;
|
||||||
|
remoteEnv?: Record<string, string> | null;
|
||||||
|
} = {},
|
||||||
): Promise<SpawnTarget> {
|
): Promise<SpawnTarget> {
|
||||||
|
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 resolved = await resolveCommandPath(command, cwd, env);
|
||||||
const executable = resolved ?? command;
|
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);
|
const resolved = await resolveCommandPath(command, cwd, env);
|
||||||
if (resolved) return;
|
if (resolved) return;
|
||||||
if (command.includes("/") || command.includes("\\")) {
|
if (command.includes("/") || command.includes("\\")) {
|
||||||
|
|
@ -1330,12 +1399,15 @@ export async function runChildProcess(
|
||||||
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
||||||
terminalResultCleanup?: TerminalResultCleanupOptions;
|
terminalResultCleanup?: TerminalResultCleanupOptions;
|
||||||
stdin?: string;
|
stdin?: string;
|
||||||
|
remoteExecution?: RemoteExecutionSpec | null;
|
||||||
},
|
},
|
||||||
): Promise<RunProcessResult> {
|
): Promise<RunProcessResult> {
|
||||||
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
|
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
|
||||||
|
|
||||||
return new Promise<RunProcessResult>((resolve, reject) => {
|
return new Promise<RunProcessResult>((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
|
// Strip Claude Code nesting-guard env vars so spawned `claude` processes
|
||||||
// don't refuse to start with "cannot be launched inside another session".
|
// don't refuse to start with "cannot be launched inside another session".
|
||||||
|
|
@ -1353,10 +1425,13 @@ export async function runChildProcess(
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedEnv = ensurePathInEnv(rawMerged);
|
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) => {
|
.then((target) => {
|
||||||
const child = spawn(target.command, target.args, {
|
const child = spawn(target.command, target.args, {
|
||||||
cwd: opts.cwd,
|
cwd: target.cwd ?? opts.cwd,
|
||||||
env: mergedEnv,
|
env: mergedEnv,
|
||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
shell: false,
|
shell: false,
|
||||||
|
|
@ -1484,6 +1559,7 @@ export async function runChildProcess(
|
||||||
if (timeout) clearTimeout(timeout);
|
if (timeout) clearTimeout(timeout);
|
||||||
clearTerminalCleanupTimers();
|
clearTerminalCleanupTimers();
|
||||||
runningProcesses.delete(runId);
|
runningProcesses.delete(runId);
|
||||||
|
void target.cleanup?.();
|
||||||
const errno = (err as NodeJS.ErrnoException).code;
|
const errno = (err as NodeJS.ErrnoException).code;
|
||||||
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
|
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
|
||||||
const msg =
|
const msg =
|
||||||
|
|
@ -1502,15 +1578,19 @@ export async function runChildProcess(
|
||||||
clearTerminalCleanupTimers();
|
clearTerminalCleanupTimers();
|
||||||
runningProcesses.delete(runId);
|
runningProcesses.delete(runId);
|
||||||
void logChain.finally(() => {
|
void logChain.finally(() => {
|
||||||
resolve({
|
void Promise.resolve()
|
||||||
exitCode: code,
|
.then(() => target.cleanup?.())
|
||||||
signal,
|
.finally(() => {
|
||||||
timedOut,
|
resolve({
|
||||||
stdout,
|
exitCode: code,
|
||||||
stderr,
|
signal,
|
||||||
pid: child.pid ?? null,
|
timedOut,
|
||||||
startedAt,
|
stdout,
|
||||||
});
|
stderr,
|
||||||
|
pid: child.pid ?? null,
|
||||||
|
startedAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
|
||||||
275
packages/adapter-utils/src/ssh-fixture.test.ts
Normal file
275
packages/adapter-utils/src/ssh-fixture.test.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
1233
packages/adapter-utils/src/ssh.ts
Normal file
1233
packages/adapter-utils/src/ssh.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,6 +2,9 @@
|
||||||
// Minimal adapter-facing interfaces (no drizzle dependency)
|
// Minimal adapter-facing interfaces (no drizzle dependency)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import type { SshRemoteExecutionSpec } from "./ssh.js";
|
||||||
|
import type { AdapterExecutionTarget } from "./execution-target.js";
|
||||||
|
|
||||||
export interface AdapterAgent {
|
export interface AdapterAgent {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
|
@ -118,6 +121,14 @@ export interface AdapterExecutionContext {
|
||||||
runtime: AdapterRuntime;
|
runtime: AdapterRuntime;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
context: Record<string, unknown>;
|
context: Record<string, unknown>;
|
||||||
|
executionTarget?: AdapterExecutionTarget | null;
|
||||||
|
/**
|
||||||
|
* Legacy remote transport view. Prefer `executionTarget`, which is the
|
||||||
|
* provider-neutral contract produced by core runtime code.
|
||||||
|
*/
|
||||||
|
executionTransport?: {
|
||||||
|
remoteExecution?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
|
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
|
||||||
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
||||||
|
|
@ -417,6 +428,7 @@ export interface CreateConfigValues {
|
||||||
workspaceBranchTemplate?: string;
|
workspaceBranchTemplate?: string;
|
||||||
worktreeParentDir?: string;
|
worktreeParentDir?: string;
|
||||||
runtimeServicesJson?: string;
|
runtimeServicesJson?: string;
|
||||||
|
defaultEnvironmentId?: string;
|
||||||
maxTurnsPerRun: number;
|
maxTurnsPerRun: number;
|
||||||
heartbeatEnabled: boolean;
|
heartbeatEnabled: boolean;
|
||||||
intervalSec: number;
|
intervalSec: number;
|
||||||
|
|
|
||||||
262
packages/adapters/claude-local/src/server/execute.remote.test.ts
Normal file
262
packages/adapters/claude-local/src/server/execute.remote.test.ts
Normal file
|
|
@ -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<typeof import("@paperclipai/adapter-utils/server-utils")>(
|
||||||
|
"@paperclipai/adapter-utils/server-utils",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
ensureCommandResolvable,
|
||||||
|
resolveCommandForLogs,
|
||||||
|
runChildProcess,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@paperclipai/adapter-utils/ssh", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/ssh")>(
|
||||||
|
"@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<string, string>; 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,20 @@ import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||||
import type { RunProcessResult } from "@paperclipai/adapter-utils/server-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 {
|
import {
|
||||||
asString,
|
asString,
|
||||||
asNumber,
|
asNumber,
|
||||||
|
|
@ -15,14 +29,11 @@ import {
|
||||||
joinPromptSections,
|
joinPromptSections,
|
||||||
buildInvocationEnvForLogs,
|
buildInvocationEnvForLogs,
|
||||||
ensureAbsoluteDirectory,
|
ensureAbsoluteDirectory,
|
||||||
ensureCommandResolvable,
|
|
||||||
ensurePathInEnv,
|
ensurePathInEnv,
|
||||||
resolveCommandForLogs,
|
|
||||||
renderTemplate,
|
renderTemplate,
|
||||||
renderPaperclipWakePrompt,
|
renderPaperclipWakePrompt,
|
||||||
stringifyPaperclipWakePayload,
|
stringifyPaperclipWakePayload,
|
||||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||||
runChildProcess,
|
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import {
|
import {
|
||||||
parseClaudeStreamJson,
|
parseClaudeStreamJson,
|
||||||
|
|
@ -42,6 +53,7 @@ interface ClaudeExecutionInput {
|
||||||
agent: AdapterExecutionContext["agent"];
|
agent: AdapterExecutionContext["agent"];
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
context: Record<string, unknown>;
|
context: Record<string, unknown>;
|
||||||
|
executionTarget?: ReturnType<typeof readAdapterExecutionTarget>;
|
||||||
authToken?: string;
|
authToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,7 +104,7 @@ function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscri
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
|
async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
|
||||||
const { runId, agent, config, context, authToken } = input;
|
const { runId, agent, config, context, executionTarget, authToken } = input;
|
||||||
|
|
||||||
const command = asString(config.command, "claude");
|
const command = asString(config.command, "claude");
|
||||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||||
|
|
@ -218,6 +230,10 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||||
if (runtimePrimaryUrl) {
|
if (runtimePrimaryUrl) {
|
||||||
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||||
}
|
}
|
||||||
|
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
|
||||||
|
if (targetPaperclipApiUrl) {
|
||||||
|
env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(envConfig)) {
|
for (const [key, value] of Object.entries(envConfig)) {
|
||||||
if (typeof value === "string") env[key] = value;
|
if (typeof value === "string") env[key] = value;
|
||||||
|
|
@ -228,8 +244,8 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||||
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
|
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||||
runtimeEnv,
|
runtimeEnv,
|
||||||
includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"],
|
includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"],
|
||||||
|
|
@ -276,7 +292,7 @@ export async function runClaudeLogin(input: {
|
||||||
authToken: input.authToken,
|
authToken: input.authToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
const proc = await runChildProcess(input.runId, runtime.command, ["login"], {
|
const proc = await runAdapterExecutionTargetProcess(input.runId, null, runtime.command, ["login"], {
|
||||||
cwd: runtime.cwd,
|
cwd: runtime.cwd,
|
||||||
env: runtime.env,
|
env: runtime.env,
|
||||||
timeoutSec: runtime.timeoutSec,
|
timeoutSec: runtime.timeoutSec,
|
||||||
|
|
@ -298,6 +314,11 @@ export async function runClaudeLogin(input: {
|
||||||
|
|
||||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
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(
|
const promptTemplate = asString(
|
||||||
config.promptTemplate,
|
config.promptTemplate,
|
||||||
|
|
@ -315,6 +336,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
agent,
|
agent,
|
||||||
config,
|
config,
|
||||||
context,
|
context,
|
||||||
|
executionTarget,
|
||||||
authToken,
|
authToken,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
|
|
@ -330,6 +352,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
graceSec,
|
graceSec,
|
||||||
extraArgs,
|
extraArgs,
|
||||||
} = runtimeConfig;
|
} = runtimeConfig;
|
||||||
|
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||||
const terminalResultCleanupGraceMs = Math.max(
|
const terminalResultCleanupGraceMs = Math.max(
|
||||||
0,
|
0,
|
||||||
asNumber(config.terminalResultCleanupGraceMs, 5_000),
|
asNumber(config.terminalResultCleanupGraceMs, 5_000),
|
||||||
|
|
@ -369,27 +392,74 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
instructionsContents: combinedInstructionsContents,
|
instructionsContents: combinedInstructionsContents,
|
||||||
onLog,
|
onLog,
|
||||||
});
|
});
|
||||||
const effectiveInstructionsFilePath = promptBundle.instructionsFilePath ?? undefined;
|
const preparedExecutionTargetRuntime = executionTargetIsRemote
|
||||||
|
? await (async () => {
|
||||||
|
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 runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||||
|
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
|
||||||
const runtimePromptBundleKey = asString(runtimeSessionParams.promptBundleKey, "");
|
const runtimePromptBundleKey = asString(runtimeSessionParams.promptBundleKey, "");
|
||||||
const hasMatchingPromptBundle =
|
const hasMatchingPromptBundle =
|
||||||
runtimePromptBundleKey.length === 0 || runtimePromptBundleKey === promptBundle.bundleKey;
|
runtimePromptBundleKey.length === 0 || runtimePromptBundleKey === promptBundle.bundleKey;
|
||||||
const canResumeSession =
|
const canResumeSession =
|
||||||
runtimeSessionId.length > 0 &&
|
runtimeSessionId.length > 0 &&
|
||||||
hasMatchingPromptBundle &&
|
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;
|
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||||
if (
|
if (
|
||||||
|
executionTargetIsRemote &&
|
||||||
runtimeSessionId &&
|
runtimeSessionId &&
|
||||||
runtimeSessionCwd.length > 0 &&
|
!canResumeSession
|
||||||
path.resolve(runtimeSessionCwd) !== path.resolve(cwd)
|
|
||||||
) {
|
) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stdout",
|
"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) {
|
if (runtimeSessionId && runtimePromptBundleKey.length > 0 && runtimePromptBundleKey !== promptBundle.bundleKey) {
|
||||||
|
|
@ -416,10 +486,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||||
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
||||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||||
|
const taskContextNote = asString(context.paperclipTaskMarkdown, "").trim();
|
||||||
const prompt = joinPromptSections([
|
const prompt = joinPromptSections([
|
||||||
renderedBootstrapPrompt,
|
renderedBootstrapPrompt,
|
||||||
wakePrompt,
|
wakePrompt,
|
||||||
sessionHandoffNote,
|
sessionHandoffNote,
|
||||||
|
taskContextNote,
|
||||||
renderedPrompt,
|
renderedPrompt,
|
||||||
]);
|
]);
|
||||||
const promptMetrics = {
|
const promptMetrics = {
|
||||||
|
|
@ -427,6 +499,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||||
wakePromptChars: wakePrompt.length,
|
wakePromptChars: wakePrompt.length,
|
||||||
sessionHandoffChars: sessionHandoffNote.length,
|
sessionHandoffChars: sessionHandoffNote.length,
|
||||||
|
taskContextChars: taskContextNote.length,
|
||||||
heartbeatPromptChars: renderedPrompt.length,
|
heartbeatPromptChars: renderedPrompt.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -452,7 +525,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (attemptInstructionsFilePath && !resumeSessionId) {
|
if (attemptInstructionsFilePath && !resumeSessionId) {
|
||||||
args.push("--append-system-prompt-file", attemptInstructionsFilePath);
|
args.push("--append-system-prompt-file", attemptInstructionsFilePath);
|
||||||
}
|
}
|
||||||
args.push("--add-dir", promptBundle.addDir);
|
args.push("--add-dir", effectivePromptBundleAddDir);
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
return args;
|
return args;
|
||||||
};
|
};
|
||||||
|
|
@ -489,7 +562,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
await onMeta({
|
await onMeta({
|
||||||
adapterType: "claude_local",
|
adapterType: "claude_local",
|
||||||
command: resolvedCommand,
|
command: resolvedCommand,
|
||||||
cwd,
|
cwd: effectiveExecutionCwd,
|
||||||
commandArgs: args,
|
commandArgs: args,
|
||||||
commandNotes,
|
commandNotes,
|
||||||
env: loggedEnv,
|
env: loggedEnv,
|
||||||
|
|
@ -499,7 +572,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const proc = await runChildProcess(runId, command, args, {
|
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
stdin: prompt,
|
stdin: prompt,
|
||||||
|
|
@ -584,8 +657,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const resolvedSessionParams = resolvedSessionId
|
const resolvedSessionParams = resolvedSessionId
|
||||||
? ({
|
? ({
|
||||||
sessionId: resolvedSessionId,
|
sessionId: resolvedSessionId,
|
||||||
cwd,
|
cwd: effectiveExecutionCwd,
|
||||||
promptBundleKey: promptBundle.bundleKey,
|
promptBundleKey: promptBundle.bundleKey,
|
||||||
|
...(executionTargetIsRemote
|
||||||
|
? {
|
||||||
|
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||||
|
|
@ -618,21 +696,31 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const initial = await runAttempt(sessionId ?? null);
|
try {
|
||||||
if (
|
const initial = await runAttempt(sessionId ?? null);
|
||||||
sessionId &&
|
if (
|
||||||
!initial.proc.timedOut &&
|
sessionId &&
|
||||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
!initial.proc.timedOut &&
|
||||||
initial.parsed &&
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||||
isClaudeUnknownSessionError(initial.parsed)
|
initial.parsed &&
|
||||||
) {
|
isClaudeUnknownSessionError(initial.parsed)
|
||||||
await onLog(
|
) {
|
||||||
"stdout",
|
await onLog(
|
||||||
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
"stdout",
|
||||||
);
|
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||||
const retry = await runAttempt(null);
|
);
|
||||||
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
const retry = await runAttempt(null);
|
||||||
}
|
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
||||||
|
}
|
||||||
|
|
||||||
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
|
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
|
||||||
|
} finally {
|
||||||
|
if (restoreRemoteWorkspace) {
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Restoring workspace changes from ${describeAdapterExecutionTarget(executionTarget)}.\n`,
|
||||||
|
);
|
||||||
|
await restoreRemoteWorkspace();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
packages/adapters/claude-local/vitest.config.ts
Normal file
7
packages/adapters/claude-local/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
|
});
|
||||||
359
packages/adapters/codex-local/src/server/execute.remote.test.ts
Normal file
359
packages/adapters/codex-local/src/server/execute.remote.test.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
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: 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<typeof import("@paperclipai/adapter-utils/server-utils")>(
|
||||||
|
"@paperclipai/adapter-utils/server-utils",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
ensureCommandResolvable,
|
||||||
|
resolveCommandForLogs,
|
||||||
|
runChildProcess,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@paperclipai/adapter-utils/ssh", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/ssh")>(
|
||||||
|
"@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<string, string>; 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<string, string>; 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,19 @@ import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
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 {
|
import {
|
||||||
asString,
|
asString,
|
||||||
asNumber,
|
asNumber,
|
||||||
|
|
@ -9,18 +22,15 @@ import {
|
||||||
buildPaperclipEnv,
|
buildPaperclipEnv,
|
||||||
buildInvocationEnvForLogs,
|
buildInvocationEnvForLogs,
|
||||||
ensureAbsoluteDirectory,
|
ensureAbsoluteDirectory,
|
||||||
ensureCommandResolvable,
|
|
||||||
ensurePaperclipSkillSymlink,
|
ensurePaperclipSkillSymlink,
|
||||||
ensurePathInEnv,
|
ensurePathInEnv,
|
||||||
readPaperclipRuntimeSkillEntries,
|
readPaperclipRuntimeSkillEntries,
|
||||||
resolveCommandForLogs,
|
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
renderTemplate,
|
renderTemplate,
|
||||||
renderPaperclipWakePrompt,
|
renderPaperclipWakePrompt,
|
||||||
stringifyPaperclipWakePayload,
|
stringifyPaperclipWakePayload,
|
||||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||||
joinPromptSections,
|
joinPromptSections,
|
||||||
runChildProcess,
|
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import {
|
import {
|
||||||
parseCodexJsonl,
|
parseCodexJsonl,
|
||||||
|
|
@ -305,6 +315,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||||
const envConfig = parseObject(config.env);
|
const envConfig = parseObject(config.env);
|
||||||
|
const executionTarget = readAdapterExecutionTarget({
|
||||||
|
executionTarget: ctx.executionTarget,
|
||||||
|
legacyRemoteExecution: ctx.executionTransport?.remoteExecution,
|
||||||
|
});
|
||||||
|
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
|
||||||
const configuredCodexHome =
|
const configuredCodexHome =
|
||||||
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
|
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
|
||||||
? path.resolve(envConfig.CODEX_HOME.trim())
|
? path.resolve(envConfig.CODEX_HOME.trim())
|
||||||
|
|
@ -328,10 +343,37 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
desiredSkillNames,
|
desiredSkillNames,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||||
|
const preparedExecutionTargetRuntime = executionTargetIsRemote
|
||||||
|
? await (async () => {
|
||||||
|
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 =
|
const hasExplicitApiKey =
|
||||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||||
env.CODEX_HOME = effectiveCodexHome;
|
|
||||||
env.PAPERCLIP_RUN_ID = runId;
|
env.PAPERCLIP_RUN_ID = runId;
|
||||||
const wakeTaskId =
|
const wakeTaskId =
|
||||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||||
|
|
@ -417,9 +459,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (runtimePrimaryUrl) {
|
if (runtimePrimaryUrl) {
|
||||||
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||||
}
|
}
|
||||||
|
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
|
||||||
|
if (targetPaperclipApiUrl) {
|
||||||
|
env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
|
||||||
|
}
|
||||||
for (const [k, v] of Object.entries(envConfig)) {
|
for (const [k, v] of Object.entries(envConfig)) {
|
||||||
if (typeof v === "string") env[k] = v;
|
if (typeof v === "string") env[k] = v;
|
||||||
}
|
}
|
||||||
|
env.CODEX_HOME = remoteCodexHome ?? effectiveCodexHome;
|
||||||
if (!hasExplicitApiKey && authToken) {
|
if (!hasExplicitApiKey && authToken) {
|
||||||
env.PAPERCLIP_API_KEY = authToken;
|
env.PAPERCLIP_API_KEY = authToken;
|
||||||
}
|
}
|
||||||
|
|
@ -430,8 +477,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
);
|
);
|
||||||
const billingType = resolveCodexBillingType(effectiveEnv);
|
const billingType = resolveCodexBillingType(effectiveEnv);
|
||||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||||
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
|
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||||
runtimeEnv,
|
runtimeEnv,
|
||||||
includeRuntimeKeys: ["HOME"],
|
includeRuntimeKeys: ["HOME"],
|
||||||
|
|
@ -444,17 +491,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||||
|
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
|
||||||
const canResumeSession =
|
const canResumeSession =
|
||||||
runtimeSessionId.length > 0 &&
|
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 codexTransientFallbackMode = readCodexTransientFallbackMode(context);
|
const codexTransientFallbackMode = readCodexTransientFallbackMode(context);
|
||||||
const forceSaferInvocation = fallbackModeUsesSaferInvocation(codexTransientFallbackMode);
|
const forceSaferInvocation = fallbackModeUsesSaferInvocation(codexTransientFallbackMode);
|
||||||
const forceFreshSession = fallbackModeUsesFreshSession(codexTransientFallbackMode);
|
const forceFreshSession = fallbackModeUsesFreshSession(codexTransientFallbackMode);
|
||||||
const sessionId = canResumeSession && !forceFreshSession ? runtimeSessionId : null;
|
const sessionId = canResumeSession && !forceFreshSession ? runtimeSessionId : null;
|
||||||
if (runtimeSessionId && !canResumeSession) {
|
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stdout",
|
"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();
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||||
|
|
@ -591,7 +645,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
await onMeta({
|
await onMeta({
|
||||||
adapterType: "codex_local",
|
adapterType: "codex_local",
|
||||||
command: resolvedCommand,
|
command: resolvedCommand,
|
||||||
cwd,
|
cwd: effectiveExecutionCwd,
|
||||||
commandNotes: commandNotesWithFastMode,
|
commandNotes: commandNotesWithFastMode,
|
||||||
commandArgs: args.map((value, idx) => {
|
commandArgs: args.map((value, idx) => {
|
||||||
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
|
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
|
||||||
|
|
@ -604,7 +658,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const proc = await runChildProcess(runId, command, args, {
|
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
stdin: prompt,
|
stdin: prompt,
|
||||||
|
|
@ -654,7 +708,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const resolvedSessionParams = resolvedSessionId
|
const resolvedSessionParams = resolvedSessionId
|
||||||
? ({
|
? ({
|
||||||
sessionId: resolvedSessionId,
|
sessionId: resolvedSessionId,
|
||||||
cwd,
|
cwd: effectiveExecutionCwd,
|
||||||
|
...(executionTargetIsRemote
|
||||||
|
? {
|
||||||
|
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||||
|
|
@ -702,20 +761,30 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const initial = await runAttempt(sessionId);
|
try {
|
||||||
if (
|
const initial = await runAttempt(sessionId);
|
||||||
sessionId &&
|
if (
|
||||||
!initial.proc.timedOut &&
|
sessionId &&
|
||||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
!initial.proc.timedOut &&
|
||||||
isCodexUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||||
) {
|
isCodexUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||||
await onLog(
|
) {
|
||||||
"stdout",
|
await onLog(
|
||||||
`[paperclip] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
"stdout",
|
||||||
);
|
`[paperclip] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||||
const retry = await runAttempt(null);
|
);
|
||||||
return toResult(retry, true, true);
|
const retry = await runAttempt(null);
|
||||||
}
|
return toResult(retry, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
return toResult(initial, false, false);
|
return toResult(initial, false, false);
|
||||||
|
} finally {
|
||||||
|
if (restoreRemoteWorkspace) {
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Restoring workspace changes from ${describeAdapterExecutionTarget(executionTarget)}.\n`,
|
||||||
|
);
|
||||||
|
await restoreRemoteWorkspace();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
268
packages/adapters/cursor-local/src/server/execute.remote.test.ts
Normal file
268
packages/adapters/cursor-local/src/server/execute.remote.test.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
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", 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<typeof import("@paperclipai/adapter-utils/server-utils")>(
|
||||||
|
"@paperclipai/adapter-utils/server-utils",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
ensureCommandResolvable,
|
||||||
|
resolveCommandForLogs,
|
||||||
|
runChildProcess,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@paperclipai/adapter-utils/ssh", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/ssh")>(
|
||||||
|
"@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<string, string>; 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,22 @@ import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
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 {
|
import {
|
||||||
asString,
|
asString,
|
||||||
asNumber,
|
asNumber,
|
||||||
|
|
@ -11,11 +27,9 @@ import {
|
||||||
buildPaperclipEnv,
|
buildPaperclipEnv,
|
||||||
buildInvocationEnvForLogs,
|
buildInvocationEnvForLogs,
|
||||||
ensureAbsoluteDirectory,
|
ensureAbsoluteDirectory,
|
||||||
ensureCommandResolvable,
|
|
||||||
ensurePaperclipSkillSymlink,
|
ensurePaperclipSkillSymlink,
|
||||||
ensurePathInEnv,
|
ensurePathInEnv,
|
||||||
readPaperclipRuntimeSkillEntries,
|
readPaperclipRuntimeSkillEntries,
|
||||||
resolveCommandForLogs,
|
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
removeMaintainerOnlySkillSymlinks,
|
removeMaintainerOnlySkillSymlinks,
|
||||||
renderTemplate,
|
renderTemplate,
|
||||||
|
|
@ -23,7 +37,6 @@ import {
|
||||||
stringifyPaperclipWakePayload,
|
stringifyPaperclipWakePayload,
|
||||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||||
joinPromptSections,
|
joinPromptSections,
|
||||||
runChildProcess,
|
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
||||||
import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
|
import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
|
||||||
|
|
@ -97,6 +110,19 @@ function cursorSkillsHome(): string {
|
||||||
return path.join(os.homedir(), ".cursor", "skills");
|
return path.join(os.homedir(), ".cursor", "skills");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildCursorSkillsDir(config: Record<string, unknown>): Promise<string> {
|
||||||
|
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 = {
|
type EnsureCursorSkillsInjectedOptions = {
|
||||||
skillsDir?: string | null;
|
skillsDir?: string | null;
|
||||||
skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>;
|
skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>;
|
||||||
|
|
@ -162,6 +188,11 @@ export async function ensureCursorSkillsInjected(
|
||||||
|
|
||||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
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(
|
const promptTemplate = asString(
|
||||||
config.promptTemplate,
|
config.promptTemplate,
|
||||||
|
|
@ -190,9 +221,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||||
const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
|
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
|
||||||
await ensureCursorSkillsInjected(onLog, {
|
if (!executionTargetIsRemote) {
|
||||||
skillsEntries: cursorSkillEntries.filter((entry) => desiredCursorSkillNames.includes(entry.key)),
|
await ensureCursorSkillsInjected(onLog, {
|
||||||
});
|
skillsEntries: cursorSkillEntries.filter((entry) => desiredCursorSkillNames.includes(entry.key)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const envConfig = parseObject(config.env);
|
const envConfig = parseObject(config.env);
|
||||||
const hasExplicitApiKey =
|
const hasExplicitApiKey =
|
||||||
|
|
@ -265,6 +298,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (workspaceHints.length > 0) {
|
if (workspaceHints.length > 0) {
|
||||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
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)) {
|
for (const [k, v] of Object.entries(envConfig)) {
|
||||||
if (typeof v === "string") env[k] = v;
|
if (typeof v === "string") env[k] = v;
|
||||||
}
|
}
|
||||||
|
|
@ -278,8 +315,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
);
|
);
|
||||||
const billingType = resolveCursorBillingType(effectiveEnv);
|
const billingType = resolveCursorBillingType(effectiveEnv);
|
||||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||||
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
|
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||||
runtimeEnv,
|
runtimeEnv,
|
||||||
includeRuntimeKeys: ["HOME"],
|
includeRuntimeKeys: ["HOME"],
|
||||||
|
|
@ -294,18 +331,77 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
return asStringArray(config.args);
|
return asStringArray(config.args);
|
||||||
})();
|
})();
|
||||||
const autoTrustEnabled = !hasCursorTrustBypassArg(extraArgs);
|
const autoTrustEnabled = !hasCursorTrustBypassArg(extraArgs);
|
||||||
|
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||||
|
let restoreRemoteWorkspace: (() => Promise<void>) | 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 runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||||
|
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
|
||||||
const canResumeSession =
|
const canResumeSession =
|
||||||
runtimeSessionId.length > 0 &&
|
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;
|
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||||
if (runtimeSessionId && !canResumeSession) {
|
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stdout",
|
"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<AdapterExec
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildArgs = (resumeSessionId: string | null) => {
|
const buildArgs = (resumeSessionId: string | null) => {
|
||||||
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 (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||||
if (model) args.push("--model", model);
|
if (model) args.push("--model", model);
|
||||||
if (mode) args.push("--mode", mode);
|
if (mode) args.push("--mode", mode);
|
||||||
|
|
@ -402,7 +498,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
await onMeta({
|
await onMeta({
|
||||||
adapterType: "cursor",
|
adapterType: "cursor",
|
||||||
command: resolvedCommand,
|
command: resolvedCommand,
|
||||||
cwd,
|
cwd: effectiveExecutionCwd,
|
||||||
commandNotes,
|
commandNotes,
|
||||||
commandArgs: args,
|
commandArgs: args,
|
||||||
env: loggedEnv,
|
env: loggedEnv,
|
||||||
|
|
@ -436,7 +532,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const proc = await runChildProcess(runId, command, args, {
|
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
timeoutSec,
|
timeoutSec,
|
||||||
|
|
@ -488,10 +584,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const resolvedSessionParams = resolvedSessionId
|
const resolvedSessionParams = resolvedSessionId
|
||||||
? ({
|
? ({
|
||||||
sessionId: resolvedSessionId,
|
sessionId: resolvedSessionId,
|
||||||
cwd,
|
cwd: effectiveExecutionCwd,
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||||
|
...(executionTargetIsRemote
|
||||||
|
? {
|
||||||
|
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
} as Record<string, unknown>)
|
} as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||||
|
|
@ -527,20 +628,32 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const initial = await runAttempt(sessionId);
|
try {
|
||||||
if (
|
const initial = await runAttempt(sessionId);
|
||||||
sessionId &&
|
if (
|
||||||
!initial.proc.timedOut &&
|
sessionId &&
|
||||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
!initial.proc.timedOut &&
|
||||||
isCursorUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||||
) {
|
isCursorUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||||
await onLog(
|
) {
|
||||||
"stdout",
|
await onLog(
|
||||||
`[paperclip] Cursor resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
"stdout",
|
||||||
);
|
`[paperclip] Cursor resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||||
const retry = await runAttempt(null);
|
);
|
||||||
return toResult(retry, true);
|
const retry = await runAttempt(null);
|
||||||
|
return toResult(retry, true);
|
||||||
|
}
|
||||||
|
return toResult(initial);
|
||||||
|
} finally {
|
||||||
|
if (restoreRemoteWorkspace) {
|
||||||
|
await onLog(
|
||||||
|
"stdout",
|
||||||
|
`[paperclip] Restoring workspace changes from ${describeAdapterExecutionTarget(executionTarget)}.\n`,
|
||||||
|
);
|
||||||
|
await restoreRemoteWorkspace();
|
||||||
|
}
|
||||||
|
if (localSkillsDir) {
|
||||||
|
await fs.rm(localSkillsDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return toResult(initial);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
272
packages/adapters/gemini-local/src/server/execute.remote.test.ts
Normal file
272
packages/adapters/gemini-local/src/server/execute.remote.test.ts
Normal file
|
|
@ -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<typeof import("@paperclipai/adapter-utils/server-utils")>(
|
||||||
|
"@paperclipai/adapter-utils/server-utils",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
ensureCommandResolvable,
|
||||||
|
resolveCommandForLogs,
|
||||||
|
runChildProcess,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@paperclipai/adapter-utils/ssh", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/ssh")>(
|
||||||
|
"@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<string, string>; 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,6 +4,22 @@ import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
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 {
|
import {
|
||||||
asBoolean,
|
asBoolean,
|
||||||
asNumber,
|
asNumber,
|
||||||
|
|
@ -12,12 +28,10 @@ import {
|
||||||
buildPaperclipEnv,
|
buildPaperclipEnv,
|
||||||
buildInvocationEnvForLogs,
|
buildInvocationEnvForLogs,
|
||||||
ensureAbsoluteDirectory,
|
ensureAbsoluteDirectory,
|
||||||
ensureCommandResolvable,
|
|
||||||
ensurePaperclipSkillSymlink,
|
ensurePaperclipSkillSymlink,
|
||||||
joinPromptSections,
|
joinPromptSections,
|
||||||
ensurePathInEnv,
|
ensurePathInEnv,
|
||||||
readPaperclipRuntimeSkillEntries,
|
readPaperclipRuntimeSkillEntries,
|
||||||
resolveCommandForLogs,
|
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
removeMaintainerOnlySkillSymlinks,
|
removeMaintainerOnlySkillSymlinks,
|
||||||
parseObject,
|
parseObject,
|
||||||
|
|
@ -136,8 +150,28 @@ async function ensureGeminiSkillsInjected(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildGeminiSkillsDir(
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): Promise<string> {
|
||||||
|
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<AdapterExecutionResult> {
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
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(
|
const promptTemplate = asString(
|
||||||
config.promptTemplate,
|
config.promptTemplate,
|
||||||
|
|
@ -166,7 +200,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||||
const geminiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
const geminiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
const desiredGeminiSkillNames = resolvePaperclipDesiredSkillNames(config, geminiSkillEntries);
|
const desiredGeminiSkillNames = resolvePaperclipDesiredSkillNames(config, geminiSkillEntries);
|
||||||
await ensureGeminiSkillsInjected(onLog, geminiSkillEntries, desiredGeminiSkillNames);
|
if (!executionTargetIsRemote) {
|
||||||
|
await ensureGeminiSkillsInjected(onLog, geminiSkillEntries, desiredGeminiSkillNames);
|
||||||
|
}
|
||||||
|
|
||||||
const envConfig = parseObject(config.env);
|
const envConfig = parseObject(config.env);
|
||||||
const hasExplicitApiKey =
|
const hasExplicitApiKey =
|
||||||
|
|
@ -211,6 +247,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||||
if (agentHome) env.AGENT_HOME = agentHome;
|
if (agentHome) env.AGENT_HOME = agentHome;
|
||||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
if (workspaceHints.length > 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)) {
|
for (const [key, value] of Object.entries(envConfig)) {
|
||||||
if (typeof value === "string") env[key] = value;
|
if (typeof value === "string") env[key] = value;
|
||||||
|
|
@ -225,8 +263,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
);
|
);
|
||||||
const billingType = resolveGeminiBillingType(effectiveEnv);
|
const billingType = resolveGeminiBillingType(effectiveEnv);
|
||||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||||
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
|
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||||
runtimeEnv,
|
runtimeEnv,
|
||||||
includeRuntimeKeys: ["HOME"],
|
includeRuntimeKeys: ["HOME"],
|
||||||
|
|
@ -240,18 +278,78 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||||
return asStringArray(config.args);
|
return asStringArray(config.args);
|
||||||
})();
|
})();
|
||||||
|
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||||
|
let restoreRemoteWorkspace: (() => Promise<void>) | 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 runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||||
|
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
|
||||||
const canResumeSession =
|
const canResumeSession =
|
||||||
runtimeSessionId.length > 0 &&
|
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;
|
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||||
if (runtimeSessionId && !canResumeSession) {
|
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stdout",
|
"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<AdapterExec
|
||||||
await onMeta({
|
await onMeta({
|
||||||
adapterType: "gemini_local",
|
adapterType: "gemini_local",
|
||||||
command: resolvedCommand,
|
command: resolvedCommand,
|
||||||
cwd,
|
cwd: effectiveExecutionCwd,
|
||||||
commandNotes,
|
commandNotes,
|
||||||
commandArgs: args.map((value, index) => (
|
commandArgs: args.map((value, index) => (
|
||||||
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
|
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
|
||||||
|
|
@ -362,7 +460,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const proc = await runChildProcess(runId, command, args, {
|
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
timeoutSec,
|
timeoutSec,
|
||||||
|
|
@ -416,10 +514,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const resolvedSessionParams = resolvedSessionId
|
const resolvedSessionParams = resolvedSessionId
|
||||||
? ({
|
? ({
|
||||||
sessionId: resolvedSessionId,
|
sessionId: resolvedSessionId,
|
||||||
cwd,
|
cwd: effectiveExecutionCwd,
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||||
|
...(executionTargetIsRemote
|
||||||
|
? {
|
||||||
|
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
} as Record<string, unknown>)
|
} as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||||
|
|
@ -458,20 +561,27 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const initial = await runAttempt(sessionId);
|
try {
|
||||||
if (
|
const initial = await runAttempt(sessionId);
|
||||||
sessionId &&
|
if (
|
||||||
!initial.proc.timedOut &&
|
sessionId &&
|
||||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
!initial.proc.timedOut &&
|
||||||
isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||||
) {
|
isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||||
await onLog(
|
) {
|
||||||
"stdout",
|
await onLog(
|
||||||
`[paperclip] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
"stdout",
|
||||||
);
|
`[paperclip] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||||
const retry = await runAttempt(null);
|
);
|
||||||
return toResult(retry, true, true);
|
const retry = await runAttempt(null);
|
||||||
}
|
return toResult(retry, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
return toResult(initial);
|
return toResult(initial);
|
||||||
|
} finally {
|
||||||
|
await Promise.all([
|
||||||
|
restoreRemoteWorkspace?.(),
|
||||||
|
localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<typeof import("@paperclipai/adapter-utils/server-utils")>(
|
||||||
|
"@paperclipai/adapter-utils/server-utils",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
ensureCommandResolvable,
|
||||||
|
resolveCommandForLogs,
|
||||||
|
runChildProcess,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@paperclipai/adapter-utils/ssh", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/ssh")>(
|
||||||
|
"@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<string, string>; 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,22 @@ import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
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 {
|
import {
|
||||||
asString,
|
asString,
|
||||||
asNumber,
|
asNumber,
|
||||||
|
|
@ -12,10 +28,8 @@ import {
|
||||||
joinPromptSections,
|
joinPromptSections,
|
||||||
buildInvocationEnvForLogs,
|
buildInvocationEnvForLogs,
|
||||||
ensureAbsoluteDirectory,
|
ensureAbsoluteDirectory,
|
||||||
ensureCommandResolvable,
|
|
||||||
ensurePaperclipSkillSymlink,
|
ensurePaperclipSkillSymlink,
|
||||||
ensurePathInEnv,
|
ensurePathInEnv,
|
||||||
resolveCommandForLogs,
|
|
||||||
renderTemplate,
|
renderTemplate,
|
||||||
renderPaperclipWakePrompt,
|
renderPaperclipWakePrompt,
|
||||||
stringifyPaperclipWakePayload,
|
stringifyPaperclipWakePayload,
|
||||||
|
|
@ -93,8 +107,26 @@ async function ensureOpenCodeSkillsInjected(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildOpenCodeSkillsDir(config: Record<string, unknown>): Promise<string> {
|
||||||
|
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<AdapterExecutionResult> {
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
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(
|
const promptTemplate = asString(
|
||||||
config.promptTemplate,
|
config.promptTemplate,
|
||||||
|
|
@ -123,11 +155,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||||
const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
const desiredOpenCodeSkillNames = resolvePaperclipDesiredSkillNames(config, openCodeSkillEntries);
|
const desiredOpenCodeSkillNames = resolvePaperclipDesiredSkillNames(config, openCodeSkillEntries);
|
||||||
await ensureOpenCodeSkillsInjected(
|
if (!executionTargetIsRemote) {
|
||||||
onLog,
|
await ensureOpenCodeSkillsInjected(
|
||||||
openCodeSkillEntries,
|
onLog,
|
||||||
desiredOpenCodeSkillNames,
|
openCodeSkillEntries,
|
||||||
);
|
desiredOpenCodeSkillNames,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const envConfig = parseObject(config.env);
|
const envConfig = parseObject(config.env);
|
||||||
const hasExplicitApiKey =
|
const hasExplicitApiKey =
|
||||||
|
|
@ -172,6 +206,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||||
if (agentHome) env.AGENT_HOME = agentHome;
|
if (agentHome) env.AGENT_HOME = agentHome;
|
||||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
if (workspaceHints.length > 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)) {
|
for (const [key, value] of Object.entries(envConfig)) {
|
||||||
if (typeof value === "string") env[key] = value;
|
if (typeof value === "string") env[key] = value;
|
||||||
|
|
@ -185,26 +221,30 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
env.PAPERCLIP_API_KEY = authToken;
|
env.PAPERCLIP_API_KEY = authToken;
|
||||||
}
|
}
|
||||||
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
|
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
|
||||||
|
const localRuntimeConfigHome =
|
||||||
|
preparedRuntimeConfig.notes.length > 0 ? preparedRuntimeConfig.env.XDG_CONFIG_HOME : "";
|
||||||
try {
|
try {
|
||||||
const runtimeEnv = Object.fromEntries(
|
const runtimeEnv = Object.fromEntries(
|
||||||
Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter(
|
Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter(
|
||||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||||
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
|
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||||
const loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
|
const loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
|
||||||
runtimeEnv,
|
runtimeEnv,
|
||||||
includeRuntimeKeys: ["HOME"],
|
includeRuntimeKeys: ["HOME"],
|
||||||
resolvedCommand,
|
resolvedCommand,
|
||||||
});
|
});
|
||||||
|
|
||||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
if (!executionTargetIsRemote) {
|
||||||
model,
|
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||||
command,
|
model,
|
||||||
cwd,
|
command,
|
||||||
env: runtimeEnv,
|
cwd,
|
||||||
});
|
env: runtimeEnv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||||
const graceSec = asNumber(config.graceSec, 20);
|
const graceSec = asNumber(config.graceSec, 20);
|
||||||
|
|
@ -213,18 +253,80 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||||
return asStringArray(config.args);
|
return asStringArray(config.args);
|
||||||
})();
|
})();
|
||||||
|
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||||
|
let restoreRemoteWorkspace: (() => Promise<void>) | 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 runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||||
|
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
|
||||||
const canResumeSession =
|
const canResumeSession =
|
||||||
runtimeSessionId.length > 0 &&
|
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;
|
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||||
if (runtimeSessionId && !canResumeSession) {
|
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stdout",
|
"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();
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||||
|
|
@ -314,7 +416,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
await onMeta({
|
await onMeta({
|
||||||
adapterType: "opencode_local",
|
adapterType: "opencode_local",
|
||||||
command: resolvedCommand,
|
command: resolvedCommand,
|
||||||
cwd,
|
cwd: effectiveExecutionCwd,
|
||||||
commandNotes,
|
commandNotes,
|
||||||
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
||||||
env: loggedEnv,
|
env: loggedEnv,
|
||||||
|
|
@ -324,9 +426,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const proc = await runChildProcess(runId, command, args, {
|
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
|
||||||
cwd,
|
cwd,
|
||||||
env: runtimeEnv,
|
env: preparedRuntimeConfig.env,
|
||||||
stdin: prompt,
|
stdin: prompt,
|
||||||
timeoutSec,
|
timeoutSec,
|
||||||
graceSec,
|
graceSec,
|
||||||
|
|
@ -364,10 +466,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const resolvedSessionParams = resolvedSessionId
|
const resolvedSessionParams = resolvedSessionId
|
||||||
? ({
|
? ({
|
||||||
sessionId: resolvedSessionId,
|
sessionId: resolvedSessionId,
|
||||||
cwd,
|
cwd: effectiveExecutionCwd,
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||||
|
...(executionTargetIsRemote
|
||||||
|
? {
|
||||||
|
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
} as Record<string, unknown>)
|
} as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|
@ -408,23 +515,30 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const initial = await runAttempt(sessionId);
|
try {
|
||||||
const initialFailed =
|
const initial = await runAttempt(sessionId);
|
||||||
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
|
const initialFailed =
|
||||||
if (
|
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
|
||||||
sessionId &&
|
if (
|
||||||
initialFailed &&
|
sessionId &&
|
||||||
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
initialFailed &&
|
||||||
) {
|
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||||
await onLog(
|
) {
|
||||||
"stdout",
|
await onLog(
|
||||||
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
"stdout",
|
||||||
);
|
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||||
const retry = await runAttempt(null);
|
);
|
||||||
return toResult(retry, true);
|
const retry = await runAttempt(null);
|
||||||
}
|
return toResult(retry, true);
|
||||||
|
}
|
||||||
|
|
||||||
return toResult(initial);
|
return toResult(initial);
|
||||||
|
} finally {
|
||||||
|
await Promise.all([
|
||||||
|
restoreRemoteWorkspace?.(),
|
||||||
|
localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await preparedRuntimeConfig.cleanup();
|
await preparedRuntimeConfig.cleanup();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,33 @@ describe("parseOpenCodeJsonl", () => {
|
||||||
});
|
});
|
||||||
expect(parsed.costUsd).toBeCloseTo(0.0025, 6);
|
expect(parsed.costUsd).toBeCloseTo(0.0025, 6);
|
||||||
expect(parsed.errorMessage).toContain("model unavailable");
|
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", () => {
|
it("detects unknown session errors", () => {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export function parseOpenCodeJsonl(stdout: string) {
|
||||||
let sessionId: string | null = null;
|
let sessionId: string | null = null;
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
const toolErrors: string[] = [];
|
||||||
const usage = {
|
const usage = {
|
||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
cachedInputTokens: 0,
|
cachedInputTokens: 0,
|
||||||
|
|
@ -65,7 +66,7 @@ export function parseOpenCodeJsonl(stdout: string) {
|
||||||
const state = parseObject(part.state);
|
const state = parseObject(part.state);
|
||||||
if (asString(state.status, "") === "error") {
|
if (asString(state.status, "") === "error") {
|
||||||
const text = asString(state.error, "").trim();
|
const text = asString(state.error, "").trim();
|
||||||
if (text) errors.push(text);
|
if (text) toolErrors.push(text);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -83,6 +84,7 @@ export function parseOpenCodeJsonl(stdout: string) {
|
||||||
usage,
|
usage,
|
||||||
costUsd,
|
costUsd,
|
||||||
errorMessage: errors.length > 0 ? errors.join("\n") : null,
|
errorMessage: errors.length > 0 ? errors.join("\n") : null,
|
||||||
|
toolErrors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
229
packages/adapters/pi-local/src/server/execute.remote.test.ts
Normal file
229
packages/adapters/pi-local/src/server/execute.remote.test.ts
Normal file
|
|
@ -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<typeof import("@paperclipai/adapter-utils/server-utils")>(
|
||||||
|
"@paperclipai/adapter-utils/server-utils",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
ensureCommandResolvable,
|
||||||
|
resolveCommandForLogs,
|
||||||
|
runChildProcess,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@paperclipai/adapter-utils/ssh", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/ssh")>(
|
||||||
|
"@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<string, string>; 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,21 @@ import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
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 {
|
import {
|
||||||
asString,
|
asString,
|
||||||
asNumber,
|
asNumber,
|
||||||
|
|
@ -12,11 +27,9 @@ import {
|
||||||
joinPromptSections,
|
joinPromptSections,
|
||||||
buildInvocationEnvForLogs,
|
buildInvocationEnvForLogs,
|
||||||
ensureAbsoluteDirectory,
|
ensureAbsoluteDirectory,
|
||||||
ensureCommandResolvable,
|
|
||||||
ensurePaperclipSkillSymlink,
|
ensurePaperclipSkillSymlink,
|
||||||
ensurePathInEnv,
|
ensurePathInEnv,
|
||||||
readPaperclipRuntimeSkillEntries,
|
readPaperclipRuntimeSkillEntries,
|
||||||
resolveCommandForLogs,
|
|
||||||
resolvePaperclipDesiredSkillNames,
|
resolvePaperclipDesiredSkillNames,
|
||||||
removeMaintainerOnlySkillSymlinks,
|
removeMaintainerOnlySkillSymlinks,
|
||||||
renderTemplate,
|
renderTemplate,
|
||||||
|
|
@ -95,6 +108,19 @@ async function ensurePiSkillsInjected(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildPiSkillsDir(config: Record<string, unknown>): Promise<string> {
|
||||||
|
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<string, string>, provider: string | null): string {
|
function resolvePiBiller(env: Record<string, string>, provider: string | null): string {
|
||||||
return inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown";
|
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`);
|
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<AdapterExecutionResult> {
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
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(
|
const promptTemplate = asString(
|
||||||
config.promptTemplate,
|
config.promptTemplate,
|
||||||
|
|
@ -140,15 +176,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||||
|
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
||||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||||
|
|
||||||
// Ensure sessions directory exists
|
if (!executionTargetIsRemote) {
|
||||||
await ensureSessionsDir();
|
await ensureSessionsDir();
|
||||||
|
}
|
||||||
// Inject skills
|
|
||||||
const piSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
const piSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||||
const desiredPiSkillNames = resolvePaperclipDesiredSkillNames(config, piSkillEntries);
|
const desiredPiSkillNames = resolvePaperclipDesiredSkillNames(config, piSkillEntries);
|
||||||
await ensurePiSkillsInjected(onLog, piSkillEntries, desiredPiSkillNames);
|
if (!executionTargetIsRemote) {
|
||||||
|
await ensurePiSkillsInjected(onLog, piSkillEntries, desiredPiSkillNames);
|
||||||
|
}
|
||||||
|
|
||||||
// Build environment
|
// Build environment
|
||||||
const envConfig = parseObject(config.env);
|
const envConfig = parseObject(config.env);
|
||||||
|
|
@ -156,7 +195,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||||
env.PAPERCLIP_RUN_ID = runId;
|
env.PAPERCLIP_RUN_ID = runId;
|
||||||
|
|
||||||
const wakeTaskId =
|
const wakeTaskId =
|
||||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||||
|
|
@ -196,6 +235,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||||
if (agentHome) env.AGENT_HOME = agentHome;
|
if (agentHome) env.AGENT_HOME = agentHome;
|
||||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
if (workspaceHints.length > 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)) {
|
for (const [key, value] of Object.entries(envConfig)) {
|
||||||
if (typeof value === "string") env[key] = value;
|
if (typeof value === "string") env[key] = value;
|
||||||
|
|
@ -203,7 +244,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (!hasExplicitApiKey && authToken) {
|
if (!hasExplicitApiKey && authToken) {
|
||||||
env.PAPERCLIP_API_KEY = authToken;
|
env.PAPERCLIP_API_KEY = authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepend installed skill `bin/` dirs to PATH so an agent's bash tool can
|
// Prepend installed skill `bin/` dirs to PATH so an agent's bash tool can
|
||||||
// invoke skill binaries (e.g. `paperclip-get-issue`) by name. Without this,
|
// invoke skill binaries (e.g. `paperclip-get-issue`) by name. Without this,
|
||||||
// any pi_local agent whose AGENTS.md calls a skill command via bash hits
|
// any pi_local agent whose AGENTS.md calls a skill command via bash hits
|
||||||
|
|
@ -232,21 +273,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv);
|
||||||
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
|
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
||||||
const loggedEnv = buildInvocationEnvForLogs(env, {
|
const loggedEnv = buildInvocationEnvForLogs(env, {
|
||||||
runtimeEnv,
|
runtimeEnv,
|
||||||
includeRuntimeKeys: ["HOME"],
|
includeRuntimeKeys: ["HOME"],
|
||||||
resolvedCommand,
|
resolvedCommand,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate model is available before execution
|
if (!executionTargetIsRemote) {
|
||||||
await ensurePiModelConfiguredAndAvailable({
|
await ensurePiModelConfiguredAndAvailable({
|
||||||
model,
|
model,
|
||||||
command,
|
command,
|
||||||
cwd,
|
cwd,
|
||||||
env: runtimeEnv,
|
env: runtimeEnv,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||||
const graceSec = asNumber(config.graceSec, 20);
|
const graceSec = asNumber(config.graceSec, 20);
|
||||||
|
|
@ -255,31 +297,84 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||||
return asStringArray(config.args);
|
return asStringArray(config.args);
|
||||||
})();
|
})();
|
||||||
|
let restoreRemoteWorkspace: (() => Promise<void>) | 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 runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||||
|
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
|
||||||
const canResumeSession =
|
const canResumeSession =
|
||||||
runtimeSessionId.length > 0 &&
|
runtimeSessionId.length > 0 &&
|
||||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
|
||||||
const sessionPath = canResumeSession ? runtimeSessionId : buildSessionPath(agent.id, new Date().toISOString());
|
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) {
|
if (runtimeSessionId && !canResumeSession) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stdout",
|
"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) {
|
if (!canResumeSession) {
|
||||||
try {
|
if (executionTargetIsRemote) {
|
||||||
await fs.writeFile(sessionPath, "", { flag: "wx" });
|
await ensureAdapterExecutionTargetFile(runId, executionTarget, sessionPath, {
|
||||||
} catch (err) {
|
cwd,
|
||||||
// File may already exist, that's ok
|
env,
|
||||||
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
|
timeoutSec: 15,
|
||||||
throw err;
|
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<AdapterExec
|
||||||
? path.resolve(cwd, instructionsFilePath)
|
? path.resolve(cwd, instructionsFilePath)
|
||||||
: "";
|
: "";
|
||||||
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||||
|
|
||||||
let systemPromptExtension = "";
|
let systemPromptExtension = "";
|
||||||
let instructionsReadFailed = false;
|
let instructionsReadFailed = false;
|
||||||
if (resolvedInstructionsFilePath) {
|
if (resolvedInstructionsFilePath) {
|
||||||
|
|
@ -364,26 +459,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
|
|
||||||
const buildArgs = (sessionFile: string): string[] => {
|
const buildArgs = (sessionFile: string): string[] => {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|
||||||
// Use JSON mode for structured output with print mode (non-interactive)
|
// Use JSON mode for structured output with print mode (non-interactive)
|
||||||
args.push("--mode", "json");
|
args.push("--mode", "json");
|
||||||
args.push("-p"); // Non-interactive mode: process prompt and exit
|
args.push("-p"); // Non-interactive mode: process prompt and exit
|
||||||
|
|
||||||
// Use --append-system-prompt to extend Pi's default system prompt
|
// Use --append-system-prompt to extend Pi's default system prompt
|
||||||
args.push("--append-system-prompt", renderedSystemPromptExtension);
|
args.push("--append-system-prompt", renderedSystemPromptExtension);
|
||||||
|
|
||||||
if (provider) args.push("--provider", provider);
|
if (provider) args.push("--provider", provider);
|
||||||
if (modelId) args.push("--model", modelId);
|
if (modelId) args.push("--model", modelId);
|
||||||
if (thinking) args.push("--thinking", thinking);
|
if (thinking) args.push("--thinking", thinking);
|
||||||
|
|
||||||
args.push("--tools", "read,bash,edit,write,grep,find,ls");
|
args.push("--tools", "read,bash,edit,write,grep,find,ls");
|
||||||
args.push("--session", sessionFile);
|
args.push("--session", sessionFile);
|
||||||
|
args.push("--skill", remoteSkillsDir ?? PI_AGENT_SKILLS_DIR);
|
||||||
// Add Paperclip skills directory so Pi can load the paperclip skill
|
|
||||||
args.push("--skill", PI_AGENT_SKILLS_DIR);
|
|
||||||
|
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
|
|
||||||
// Add the user prompt as the last argument
|
// Add the user prompt as the last argument
|
||||||
args.push(userPrompt);
|
args.push(userPrompt);
|
||||||
|
|
||||||
|
|
@ -396,7 +489,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
await onMeta({
|
await onMeta({
|
||||||
adapterType: "pi_local",
|
adapterType: "pi_local",
|
||||||
command: resolvedCommand,
|
command: resolvedCommand,
|
||||||
cwd,
|
cwd: effectiveExecutionCwd,
|
||||||
commandNotes,
|
commandNotes,
|
||||||
commandArgs: args,
|
commandArgs: args,
|
||||||
env: loggedEnv,
|
env: loggedEnv,
|
||||||
|
|
@ -414,13 +507,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
await onLog(stream, chunk);
|
await onLog(stream, chunk);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buffer stdout and emit only complete lines
|
// Buffer stdout and emit only complete lines
|
||||||
stdoutBuffer += chunk;
|
stdoutBuffer += chunk;
|
||||||
const lines = stdoutBuffer.split("\n");
|
const lines = stdoutBuffer.split("\n");
|
||||||
// Keep the last (potentially incomplete) line in the buffer
|
// Keep the last (potentially incomplete) line in the buffer
|
||||||
stdoutBuffer = lines.pop() || "";
|
stdoutBuffer = lines.pop() || "";
|
||||||
|
|
||||||
// Emit complete lines
|
// Emit complete lines
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line) {
|
if (line) {
|
||||||
|
|
@ -429,20 +522,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const proc = await runChildProcess(runId, command, args, {
|
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
|
||||||
cwd,
|
cwd,
|
||||||
env: runtimeEnv,
|
env: executionTargetIsRemote ? env : runtimeEnv,
|
||||||
timeoutSec,
|
timeoutSec,
|
||||||
graceSec,
|
graceSec,
|
||||||
onSpawn,
|
onSpawn,
|
||||||
onLog: bufferedOnLog,
|
onLog: bufferedOnLog,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Flush any remaining buffer content
|
// Flush any remaining buffer content
|
||||||
if (stdoutBuffer) {
|
if (stdoutBuffer) {
|
||||||
await onLog("stdout", stdoutBuffer);
|
await onLog("stdout", stdoutBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
proc,
|
proc,
|
||||||
rawStderr: proc.stderr,
|
rawStderr: proc.stderr,
|
||||||
|
|
@ -470,7 +563,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
|
|
||||||
const resolvedSessionId = clearSessionOnMissingSession ? null : sessionPath;
|
const resolvedSessionId = clearSessionOnMissingSession ? null : sessionPath;
|
||||||
const resolvedSessionParams = resolvedSessionId
|
const resolvedSessionParams = resolvedSessionId
|
||||||
? { sessionId: resolvedSessionId, cwd }
|
? {
|
||||||
|
sessionId: resolvedSessionId,
|
||||||
|
cwd: effectiveExecutionCwd,
|
||||||
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||||
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||||
|
...(executionTargetIsRemote
|
||||||
|
? {
|
||||||
|
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||||
|
|
@ -506,30 +610,49 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const initial = await runAttempt(sessionPath);
|
try {
|
||||||
const initialFailed =
|
const initial = await runAttempt(sessionPath);
|
||||||
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || initial.parsed.errors.length > 0);
|
const initialFailed =
|
||||||
|
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || initial.parsed.errors.length > 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -470,6 +470,20 @@
|
||||||
"when": 1776903901000,
|
"when": 1776903901000,
|
||||||
"tag": "0066_issue_tree_holds",
|
"tag": "0066_issue_tree_holds",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
index,
|
index,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { companies } from "./companies.js";
|
import { companies } from "./companies.js";
|
||||||
|
import { environments } from "./environments.js";
|
||||||
|
|
||||||
export const agents = pgTable(
|
export const agents = pgTable(
|
||||||
"agents",
|
"agents",
|
||||||
|
|
@ -25,6 +26,7 @@ export const agents = pgTable(
|
||||||
adapterType: text("adapter_type").notNull().default("process"),
|
adapterType: text("adapter_type").notNull().default("process"),
|
||||||
adapterConfig: jsonb("adapter_config").$type<Record<string, unknown>>().notNull().default({}),
|
adapterConfig: jsonb("adapter_config").$type<Record<string, unknown>>().notNull().default({}),
|
||||||
runtimeConfig: jsonb("runtime_config").$type<Record<string, unknown>>().notNull().default({}),
|
runtimeConfig: jsonb("runtime_config").$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
defaultEnvironmentId: uuid("default_environment_id").references(() => environments.id, { onDelete: "set null" }),
|
||||||
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),
|
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),
|
||||||
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
|
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
|
||||||
pauseReason: text("pause_reason"),
|
pauseReason: text("pause_reason"),
|
||||||
|
|
@ -38,5 +40,6 @@ export const agents = pgTable(
|
||||||
(table) => ({
|
(table) => ({
|
||||||
companyStatusIdx: index("agents_company_status_idx").on(table.companyId, table.status),
|
companyStatusIdx: index("agents_company_status_idx").on(table.companyId, table.status),
|
||||||
companyReportsToIdx: index("agents_company_reports_to_idx").on(table.companyId, table.reportsTo),
|
companyReportsToIdx: index("agents_company_reports_to_idx").on(table.companyId, table.reportsTo),
|
||||||
|
companyDefaultEnvironmentIdx: index("agents_company_default_environment_idx").on(table.companyId, table.defaultEnvironmentId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||||
import { companies } from "./companies.js";
|
import { companies } from "./companies.js";
|
||||||
|
|
||||||
|
|
@ -17,7 +18,9 @@ export const environments = pgTable(
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
companyStatusIdx: index("environments_company_status_idx").on(table.companyId, table.status),
|
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),
|
companyNameIdx: index("environments_company_name_idx").on(table.companyId, table.name),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ export const PROJECT_STATUSES = [
|
||||||
] as const;
|
] as const;
|
||||||
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
|
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 type EnvironmentDriver = (typeof ENVIRONMENT_DRIVERS)[number];
|
||||||
|
|
||||||
export const ENVIRONMENT_STATUSES = ["active", "archived"] as const;
|
export const ENVIRONMENT_STATUSES = ["active", "archived"] as const;
|
||||||
|
|
@ -486,6 +486,7 @@ export const PERMISSION_KEYS = [
|
||||||
"tasks:assign_scope",
|
"tasks:assign_scope",
|
||||||
"tasks:manage_active_checkouts",
|
"tasks:manage_active_checkouts",
|
||||||
"joins:approve",
|
"joins:approve",
|
||||||
|
"environments:manage",
|
||||||
] as const;
|
] as const;
|
||||||
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
||||||
|
|
||||||
|
|
|
||||||
64
packages/shared/src/environment-support.ts
Normal file
64
packages/shared/src/environment-support.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import type { AgentAdapterType, EnvironmentDriver } from "./constants.js";
|
||||||
|
|
||||||
|
export type EnvironmentSupportStatus = "supported" | "unsupported";
|
||||||
|
|
||||||
|
export interface AdapterEnvironmentSupport {
|
||||||
|
adapterType: AgentAdapterType;
|
||||||
|
drivers: Record<EnvironmentDriver, EnvironmentSupportStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvironmentCapabilities {
|
||||||
|
adapters: AdapterEnvironmentSupport[];
|
||||||
|
drivers: Record<EnvironmentDriver, EnvironmentSupportStatus>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REMOTE_MANAGED_ADAPTERS = new Set<AgentAdapterType>([
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -218,7 +218,9 @@ export type {
|
||||||
Company,
|
Company,
|
||||||
Environment,
|
Environment,
|
||||||
EnvironmentLease,
|
EnvironmentLease,
|
||||||
|
EnvironmentProbeResult,
|
||||||
LocalEnvironmentConfig,
|
LocalEnvironmentConfig,
|
||||||
|
SshEnvironmentConfig,
|
||||||
FeedbackVote,
|
FeedbackVote,
|
||||||
FeedbackDataSharingPreference,
|
FeedbackDataSharingPreference,
|
||||||
FeedbackTargetType,
|
FeedbackTargetType,
|
||||||
|
|
@ -540,6 +542,17 @@ export {
|
||||||
isClosedIsolatedExecutionWorkspace,
|
isClosedIsolatedExecutionWorkspace,
|
||||||
} from "./execution-workspace-guards.js";
|
} from "./execution-workspace-guards.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
adapterSupportsRemoteManagedEnvironments,
|
||||||
|
getAdapterEnvironmentSupport,
|
||||||
|
getEnvironmentCapabilities,
|
||||||
|
isEnvironmentDriverSupportedForAdapter,
|
||||||
|
supportedEnvironmentDriversForAdapter,
|
||||||
|
type AdapterEnvironmentSupport,
|
||||||
|
type EnvironmentCapabilities,
|
||||||
|
type EnvironmentSupportStatus,
|
||||||
|
} from "./environment-support.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
instanceGeneralSettingsSchema,
|
instanceGeneralSettingsSchema,
|
||||||
patchInstanceGeneralSettingsSchema,
|
patchInstanceGeneralSettingsSchema,
|
||||||
|
|
@ -567,8 +580,10 @@ export {
|
||||||
environmentLeaseCleanupStatusSchema,
|
environmentLeaseCleanupStatusSchema,
|
||||||
createEnvironmentSchema,
|
createEnvironmentSchema,
|
||||||
updateEnvironmentSchema,
|
updateEnvironmentSchema,
|
||||||
|
probeEnvironmentConfigSchema,
|
||||||
type CreateEnvironment,
|
type CreateEnvironment,
|
||||||
type UpdateEnvironment,
|
type UpdateEnvironment,
|
||||||
|
type ProbeEnvironmentConfig,
|
||||||
agentSkillStateSchema,
|
agentSkillStateSchema,
|
||||||
agentSkillSyncModeSchema,
|
agentSkillSyncModeSchema,
|
||||||
agentSkillEntrySchema,
|
agentSkillEntrySchema,
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export interface Agent {
|
||||||
adapterType: AgentAdapterType;
|
adapterType: AgentAdapterType;
|
||||||
adapterConfig: Record<string, unknown>;
|
adapterConfig: Record<string, unknown>;
|
||||||
runtimeConfig: Record<string, unknown>;
|
runtimeConfig: Record<string, unknown>;
|
||||||
|
defaultEnvironmentId?: string | null;
|
||||||
budgetMonthlyCents: number;
|
budgetMonthlyCents: number;
|
||||||
spentMonthlyCents: number;
|
spentMonthlyCents: number;
|
||||||
pauseReason: PauseReason | null;
|
pauseReason: PauseReason | null;
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,30 @@ import type {
|
||||||
EnvironmentLeaseStatus,
|
EnvironmentLeaseStatus,
|
||||||
EnvironmentStatus,
|
EnvironmentStatus,
|
||||||
} from "../constants.js";
|
} from "../constants.js";
|
||||||
|
import type { EnvSecretRefBinding } from "./secrets.js";
|
||||||
|
|
||||||
export interface LocalEnvironmentConfig {
|
export interface LocalEnvironmentConfig {
|
||||||
[key: string]: unknown;
|
[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<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Environment {
|
export interface Environment {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
|
|
@ -17,7 +36,7 @@ export interface Environment {
|
||||||
description: string | null;
|
description: string | null;
|
||||||
driver: EnvironmentDriver;
|
driver: EnvironmentDriver;
|
||||||
status: EnvironmentStatus;
|
status: EnvironmentStatus;
|
||||||
config: LocalEnvironmentConfig;
|
config: Record<string, unknown>;
|
||||||
metadata: Record<string, unknown> | null;
|
metadata: Record<string, unknown> | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
export type { Company } from "./company.js";
|
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 {
|
export type {
|
||||||
FeedbackVote,
|
FeedbackVote,
|
||||||
FeedbackDataSharingPreference,
|
FeedbackDataSharingPreference,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export interface InstanceGeneralSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstanceExperimentalSettings {
|
export interface InstanceExperimentalSettings {
|
||||||
|
enableEnvironments: boolean;
|
||||||
enableIsolatedWorkspaces: boolean;
|
enableIsolatedWorkspaces: boolean;
|
||||||
autoRestartDevServerWhenIdle: boolean;
|
autoRestartDevServerWhenIdle: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ export interface ExecutionWorkspaceStrategy {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExecutionWorkspaceConfig {
|
export interface ExecutionWorkspaceConfig {
|
||||||
|
environmentId?: string | null;
|
||||||
provisionCommand: string | null;
|
provisionCommand: string | null;
|
||||||
teardownCommand: string | null;
|
teardownCommand: string | null;
|
||||||
cleanupCommand: string | null;
|
cleanupCommand: string | null;
|
||||||
|
|
@ -147,6 +148,7 @@ export interface ProjectExecutionWorkspacePolicy {
|
||||||
defaultMode?: ProjectExecutionWorkspaceDefaultMode;
|
defaultMode?: ProjectExecutionWorkspaceDefaultMode;
|
||||||
allowIssueOverride?: boolean;
|
allowIssueOverride?: boolean;
|
||||||
defaultProjectWorkspaceId?: string | null;
|
defaultProjectWorkspaceId?: string | null;
|
||||||
|
environmentId?: string | null;
|
||||||
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
|
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
|
||||||
workspaceRuntime?: Record<string, unknown> | null;
|
workspaceRuntime?: Record<string, unknown> | null;
|
||||||
branchPolicy?: Record<string, unknown> | null;
|
branchPolicy?: Record<string, unknown> | null;
|
||||||
|
|
@ -157,6 +159,7 @@ export interface ProjectExecutionWorkspacePolicy {
|
||||||
|
|
||||||
export interface IssueExecutionWorkspaceSettings {
|
export interface IssueExecutionWorkspaceSettings {
|
||||||
mode?: ExecutionWorkspaceMode;
|
mode?: ExecutionWorkspaceMode;
|
||||||
|
environmentId?: string | null;
|
||||||
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
|
workspaceStrategy?: ExecutionWorkspaceStrategy | null;
|
||||||
workspaceRuntime?: Record<string, unknown> | null;
|
workspaceRuntime?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
@ -227,3 +230,82 @@ export interface WorkspaceRuntimeService {
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: 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<string, unknown> | 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<string, unknown>;
|
||||||
|
};
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ export const createAgentSchema = z.object({
|
||||||
adapterType: agentAdapterTypeSchema,
|
adapterType: agentAdapterTypeSchema,
|
||||||
adapterConfig: adapterConfigSchema.optional().default({}),
|
adapterConfig: adapterConfigSchema.optional().default({}),
|
||||||
runtimeConfig: z.record(z.unknown()).optional().default({}),
|
runtimeConfig: z.record(z.unknown()).optional().default({}),
|
||||||
|
defaultEnvironmentId: z.string().uuid().optional().nullable(),
|
||||||
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
|
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
|
||||||
permissions: agentPermissionsSchema.optional(),
|
permissions: agentPermissionsSchema.optional(),
|
||||||
metadata: z.record(z.unknown()).optional().nullable(),
|
metadata: z.record(z.unknown()).optional().nullable(),
|
||||||
|
|
|
||||||
|
|
@ -32,3 +32,12 @@ export const updateEnvironmentSchema = z.object({
|
||||||
metadata: z.record(z.unknown()).optional().nullable(),
|
metadata: z.record(z.unknown()).optional().nullable(),
|
||||||
}).strict();
|
}).strict();
|
||||||
export type UpdateEnvironment = z.infer<typeof updateEnvironmentSchema>;
|
export type UpdateEnvironment = z.infer<typeof updateEnvironmentSchema>;
|
||||||
|
|
||||||
|
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<typeof probeEnvironmentConfigSchema>;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export const executionWorkspaceStatusSchema = z.enum([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const executionWorkspaceConfigSchema = z.object({
|
export const executionWorkspaceConfigSchema = z.object({
|
||||||
|
environmentId: z.string().uuid().optional().nullable(),
|
||||||
provisionCommand: z.string().optional().nullable(),
|
provisionCommand: z.string().optional().nullable(),
|
||||||
teardownCommand: z.string().optional().nullable(),
|
teardownCommand: z.string().optional().nullable(),
|
||||||
cleanupCommand: z.string().optional().nullable(),
|
cleanupCommand: z.string().optional().nullable(),
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,10 @@ export {
|
||||||
environmentLeaseCleanupStatusSchema,
|
environmentLeaseCleanupStatusSchema,
|
||||||
createEnvironmentSchema,
|
createEnvironmentSchema,
|
||||||
updateEnvironmentSchema,
|
updateEnvironmentSchema,
|
||||||
|
probeEnvironmentConfigSchema,
|
||||||
type CreateEnvironment,
|
type CreateEnvironment,
|
||||||
type UpdateEnvironment,
|
type UpdateEnvironment,
|
||||||
|
type ProbeEnvironmentConfig,
|
||||||
} from "./environment.js";
|
} from "./environment.js";
|
||||||
export {
|
export {
|
||||||
feedbackDataSharingPreferenceSchema,
|
feedbackDataSharingPreferenceSchema,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export const instanceGeneralSettingsSchema = z.object({
|
||||||
export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial();
|
export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial();
|
||||||
|
|
||||||
export const instanceExperimentalSettingsSchema = z.object({
|
export const instanceExperimentalSettingsSchema = z.object({
|
||||||
|
enableEnvironments: z.boolean().default(false),
|
||||||
enableIsolatedWorkspaces: z.boolean().default(false),
|
enableIsolatedWorkspaces: z.boolean().default(false),
|
||||||
autoRestartDevServerWhenIdle: z.boolean().default(false),
|
autoRestartDevServerWhenIdle: z.boolean().default(false),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const executionWorkspaceStrategySchema = z
|
||||||
export const issueExecutionWorkspaceSettingsSchema = z
|
export const issueExecutionWorkspaceSettingsSchema = z
|
||||||
.object({
|
.object({
|
||||||
mode: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional(),
|
mode: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional(),
|
||||||
|
environmentId: z.string().uuid().optional().nullable(),
|
||||||
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
|
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
|
||||||
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export const projectExecutionWorkspacePolicySchema = z
|
||||||
defaultMode: z.enum(["shared_workspace", "isolated_workspace", "operator_branch", "adapter_default"]).optional(),
|
defaultMode: z.enum(["shared_workspace", "isolated_workspace", "operator_branch", "adapter_default"]).optional(),
|
||||||
allowIssueOverride: z.boolean().optional(),
|
allowIssueOverride: z.boolean().optional(),
|
||||||
defaultProjectWorkspaceId: z.string().uuid().optional().nullable(),
|
defaultProjectWorkspaceId: z.string().uuid().optional().nullable(),
|
||||||
|
environmentId: z.string().uuid().optional().nullable(),
|
||||||
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
|
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
|
||||||
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
||||||
branchPolicy: z.record(z.unknown()).optional().nullable(),
|
branchPolicy: z.record(z.unknown()).optional().nullable(),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
import type { Server } from "node:http";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import request from "supertest";
|
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(() => ({
|
const mockActivityService = vi.hoisted(() => ({
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
|
|
@ -32,6 +33,8 @@ vi.mock("../services/index.js", () => ({
|
||||||
heartbeatService: () => mockHeartbeatService,
|
heartbeatService: () => mockHeartbeatService,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let server: Server | null = null;
|
||||||
|
|
||||||
async function createApp(
|
async function createApp(
|
||||||
actor: Record<string, unknown> = {
|
actor: Record<string, unknown> = {
|
||||||
type: "board",
|
type: "board",
|
||||||
|
|
@ -53,10 +56,22 @@ async function createApp(
|
||||||
});
|
});
|
||||||
app.use("/api", activityRoutes({} as any));
|
app.use("/api", activityRoutes({} as any));
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
return app;
|
server = app.listen(0);
|
||||||
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("activity routes", () => {
|
describe("activity routes", () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!server) return;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server?.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
server = null;
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ const mockSecretService = vi.hoisted(() => ({
|
||||||
resolveAdapterConfigForRuntime: vi.fn(),
|
resolveAdapterConfigForRuntime: vi.fn(),
|
||||||
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
|
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
|
||||||
}));
|
}));
|
||||||
|
const mockEnvironmentService = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||||
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
|
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
|
||||||
|
|
@ -40,6 +43,7 @@ vi.mock("../services/index.js", () => ({
|
||||||
approvalService: () => ({}),
|
approvalService: () => ({}),
|
||||||
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
|
||||||
budgetService: () => ({}),
|
budgetService: () => ({}),
|
||||||
|
environmentService: () => mockEnvironmentService,
|
||||||
heartbeatService: () => ({}),
|
heartbeatService: () => ({}),
|
||||||
issueApprovalService: () => ({}),
|
issueApprovalService: () => ({}),
|
||||||
issueService: () => ({}),
|
issueService: () => ({}),
|
||||||
|
|
@ -112,6 +116,7 @@ function makeAgent() {
|
||||||
adapterType: "codex_local",
|
adapterType: "codex_local",
|
||||||
adapterConfig: {},
|
adapterConfig: {},
|
||||||
runtimeConfig: {},
|
runtimeConfig: {},
|
||||||
|
defaultEnvironmentId: null,
|
||||||
permissions: null,
|
permissions: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
import type { Server } from "node:http";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import request from "supertest";
|
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 agentId = "11111111-1111-4111-8111-111111111111";
|
||||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||||
|
|
@ -19,6 +20,7 @@ const baseAgent = {
|
||||||
adapterType: "process",
|
adapterType: "process",
|
||||||
adapterConfig: {},
|
adapterConfig: {},
|
||||||
runtimeConfig: {},
|
runtimeConfig: {},
|
||||||
|
defaultEnvironmentId: null,
|
||||||
budgetMonthlyCents: 0,
|
budgetMonthlyCents: 0,
|
||||||
spentMonthlyCents: 0,
|
spentMonthlyCents: 0,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
|
|
@ -59,6 +61,10 @@ const mockBudgetService = vi.hoisted(() => ({
|
||||||
upsertPolicy: vi.fn(),
|
upsertPolicy: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockEnvironmentService = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockHeartbeatService = vi.hoisted(() => ({
|
const mockHeartbeatService = vi.hoisted(() => ({
|
||||||
listTaskSessions: vi.fn(),
|
listTaskSessions: vi.fn(),
|
||||||
resetRuntimeSession: vi.fn(),
|
resetRuntimeSession: vi.fn(),
|
||||||
|
|
@ -91,6 +97,7 @@ const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||||
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
|
const mockTrackAgentCreated = vi.hoisted(() => vi.fn());
|
||||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||||
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
|
const mockSyncInstructionsBundleConfigFromFilePath = vi.hoisted(() => vi.fn());
|
||||||
|
const mockEnsureOpenCodeModelConfiguredAndAvailable = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
const mockInstanceSettingsService = vi.hoisted(() => ({
|
const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||||
getGeneral: vi.fn(),
|
getGeneral: vi.fn(),
|
||||||
|
|
@ -101,6 +108,13 @@ function registerModuleMocks() {
|
||||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||||
vi.doMock("../adapters/index.js", async () => vi.importActual("../adapters/index.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("../middleware/index.js", async () => vi.importActual("../middleware/index.js"));
|
||||||
|
vi.doMock("@paperclipai/adapter-opencode-local/server", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@paperclipai/adapter-opencode-local/server")>("@paperclipai/adapter-opencode-local/server");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
ensureOpenCodeModelConfiguredAndAvailable: mockEnsureOpenCodeModelConfiguredAndAvailable,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
vi.doMock("@paperclipai/shared/telemetry", () => ({
|
||||||
trackAgentCreated: mockTrackAgentCreated,
|
trackAgentCreated: mockTrackAgentCreated,
|
||||||
|
|
@ -179,6 +193,7 @@ function registerModuleMocks() {
|
||||||
secretService: () => mockSecretService,
|
secretService: () => mockSecretService,
|
||||||
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
|
syncInstructionsBundleConfigFromFilePath: mockSyncInstructionsBundleConfigFromFilePath,
|
||||||
workspaceOperationService: () => mockWorkspaceOperationService,
|
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<void>((resolve, reject) => {
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function createApp(actor: Record<string, unknown>, dbOptions: { requireBoardApprovalForNewAgents?: boolean } = {}) {
|
async function createApp(actor: Record<string, unknown>, dbOptions: { requireBoardApprovalForNewAgents?: boolean } = {}) {
|
||||||
|
await closeSharedServer();
|
||||||
const [{ errorHandler }, { agentRoutes }] = await Promise.all([
|
const [{ errorHandler }, { agentRoutes }] = await Promise.all([
|
||||||
import("../middleware/index.js"),
|
import("../middleware/index.js"),
|
||||||
import("../routes/agents.js"),
|
import("../routes/agents.js"),
|
||||||
|
|
@ -213,10 +243,16 @@ async function createApp(actor: Record<string, unknown>, dbOptions: { requireBoa
|
||||||
});
|
});
|
||||||
app.use("/api", agentRoutes(createDbStub(dbOptions) as any));
|
app.use("/api", agentRoutes(createDbStub(dbOptions) as any));
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
return app;
|
sharedServer = app.listen(0, "127.0.0.1");
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
sharedServer?.once("listening", resolve);
|
||||||
|
});
|
||||||
|
return sharedServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("agent permission routes", () => {
|
describe.sequential("agent permission routes", () => {
|
||||||
|
afterEach(closeSharedServer);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.doUnmock("@paperclipai/shared/telemetry");
|
vi.doUnmock("@paperclipai/shared/telemetry");
|
||||||
|
|
@ -239,6 +275,7 @@ describe("agent permission routes", () => {
|
||||||
vi.doUnmock("../routes/agents.js");
|
vi.doUnmock("../routes/agents.js");
|
||||||
vi.doUnmock("../routes/authz.js");
|
vi.doUnmock("../routes/authz.js");
|
||||||
vi.doUnmock("../middleware/index.js");
|
vi.doUnmock("../middleware/index.js");
|
||||||
|
vi.doUnmock("@paperclipai/adapter-opencode-local/server");
|
||||||
registerModuleMocks();
|
registerModuleMocks();
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
mockAgentService.getById.mockReset();
|
mockAgentService.getById.mockReset();
|
||||||
|
|
@ -274,6 +311,7 @@ describe("agent permission routes", () => {
|
||||||
mockGetTelemetryClient.mockReset();
|
mockGetTelemetryClient.mockReset();
|
||||||
mockSyncInstructionsBundleConfigFromFilePath.mockReset();
|
mockSyncInstructionsBundleConfigFromFilePath.mockReset();
|
||||||
mockInstanceSettingsService.getGeneral.mockReset();
|
mockInstanceSettingsService.getGeneral.mockReset();
|
||||||
|
mockEnsureOpenCodeModelConfiguredAndAvailable.mockReset();
|
||||||
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
|
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
|
||||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||||
mockAgentService.getById.mockResolvedValue(baseAgent);
|
mockAgentService.getById.mockResolvedValue(baseAgent);
|
||||||
|
|
@ -305,6 +343,7 @@ describe("agent permission routes", () => {
|
||||||
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
|
mockCompanySkillService.listRuntimeSkillEntries.mockResolvedValue([]);
|
||||||
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(async (_companyId, requested) => requested);
|
mockCompanySkillService.resolveRequestedSkillKeys.mockImplementation(async (_companyId, requested) => requested);
|
||||||
mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
|
mockBudgetService.upsertPolicy.mockResolvedValue(undefined);
|
||||||
|
mockEnvironmentService.getById.mockResolvedValue(null);
|
||||||
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(
|
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(
|
||||||
async (agent: Record<string, unknown>, files: Record<string, string>) => ({
|
async (agent: Record<string, unknown>, files: Record<string, string>) => ({
|
||||||
bundle: null,
|
bundle: null,
|
||||||
|
|
@ -327,6 +366,9 @@ describe("agent permission routes", () => {
|
||||||
mockInstanceSettingsService.getGeneral.mockResolvedValue({
|
mockInstanceSettingsService.getGeneral.mockResolvedValue({
|
||||||
censorUsernameInLogs: false,
|
censorUsernameInLogs: false,
|
||||||
});
|
});
|
||||||
|
mockEnsureOpenCodeModelConfiguredAndAvailable.mockResolvedValue([
|
||||||
|
{ id: "opencode/gpt-5-nano", label: "opencode/gpt-5-nano" },
|
||||||
|
]);
|
||||||
mockLogActivity.mockResolvedValue(undefined);
|
mockLogActivity.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -566,7 +608,7 @@ describe("agent permission routes", () => {
|
||||||
adapterConfig: {},
|
adapterConfig: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect([200, 201]).toContain(res.status);
|
||||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||||
companyId,
|
companyId,
|
||||||
expect.objectContaining({
|
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 () => {
|
it("exposes explicit task assignment access on agent detail", async () => {
|
||||||
mockAccessService.listPrincipalGrants.mockResolvedValue([
|
mockAccessService.listPrincipalGrants.mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ const mockApprovalService = vi.hoisted(() => ({
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
}));
|
}));
|
||||||
const mockBudgetService = vi.hoisted(() => ({}));
|
const mockBudgetService = vi.hoisted(() => ({}));
|
||||||
|
const mockEnvironmentService = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
}));
|
||||||
const mockHeartbeatService = vi.hoisted(() => ({}));
|
const mockHeartbeatService = vi.hoisted(() => ({}));
|
||||||
const mockIssueApprovalService = vi.hoisted(() => ({
|
const mockIssueApprovalService = vi.hoisted(() => ({
|
||||||
linkManyForApproval: vi.fn(),
|
linkManyForApproval: vi.fn(),
|
||||||
|
|
@ -74,6 +77,7 @@ vi.mock("../services/index.js", () => ({
|
||||||
approvalService: () => mockApprovalService,
|
approvalService: () => mockApprovalService,
|
||||||
companySkillService: () => mockCompanySkillService,
|
companySkillService: () => mockCompanySkillService,
|
||||||
budgetService: () => mockBudgetService,
|
budgetService: () => mockBudgetService,
|
||||||
|
environmentService: () => mockEnvironmentService,
|
||||||
heartbeatService: () => mockHeartbeatService,
|
heartbeatService: () => mockHeartbeatService,
|
||||||
issueApprovalService: () => mockIssueApprovalService,
|
issueApprovalService: () => mockIssueApprovalService,
|
||||||
issueService: () => ({}),
|
issueService: () => ({}),
|
||||||
|
|
@ -174,6 +178,7 @@ function makeAgent(adapterType: string) {
|
||||||
adapterType,
|
adapterType,
|
||||||
adapterConfig: {},
|
adapterConfig: {},
|
||||||
runtimeConfig: {},
|
runtimeConfig: {},
|
||||||
|
defaultEnvironmentId: null,
|
||||||
permissions: null,
|
permissions: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
import type { Server } from "node:http";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import request from "supertest";
|
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(() => ({
|
const mockAccessService = vi.hoisted(() => ({
|
||||||
isInstanceAdmin: vi.fn(),
|
isInstanceAdmin: vi.fn(),
|
||||||
|
|
@ -34,6 +35,20 @@ vi.mock("../services/index.js", () => ({
|
||||||
deduplicateAgentName: vi.fn((name: string) => name),
|
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<void>((resolve, reject) => {
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function registerModuleMocks() {
|
function registerModuleMocks() {
|
||||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
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) {
|
async function createApp(actor: any, db: any = {} as any) {
|
||||||
|
await closeCurrentServer();
|
||||||
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
|
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
|
||||||
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
|
vi.importActual<typeof import("../routes/access.js")>("../routes/access.js"),
|
||||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||||
|
|
@ -68,10 +84,13 @@ async function createApp(actor: any, db: any = {} as any) {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
return app;
|
currentServer = app.listen(0);
|
||||||
|
return currentServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("cli auth routes", () => {
|
describe("cli auth routes", () => {
|
||||||
|
afterEach(closeCurrentServer);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.doUnmock("../services/index.js");
|
vi.doUnmock("../services/index.js");
|
||||||
|
|
|
||||||
118
server/src/__tests__/environment-config.test.ts
Normal file
118
server/src/__tests__/environment-config.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
183
server/src/__tests__/environment-live-ssh.test.ts
Normal file
183
server/src/__tests__/environment-live-ssh.test.ts
Normal file
|
|
@ -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<string | null> {
|
||||||
|
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<SshConnectionConfig | null> {
|
||||||
|
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<void>;
|
||||||
|
} | 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<void>) | 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<SshConnectionConfig | null> {
|
||||||
|
// 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
118
server/src/__tests__/environment-probe.test.ts
Normal file
118
server/src/__tests__/environment-probe.test.ts
Normal file
|
|
@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
1181
server/src/__tests__/environment-routes.test.ts
Normal file
1181
server/src/__tests__/environment-routes.test.ts
Normal file
File diff suppressed because it is too large
Load diff
492
server/src/__tests__/environment-selection-route-guards.test.ts
Normal file
492
server/src/__tests__/environment-selection-route-guards.test.ts
Normal file
|
|
@ -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<void>((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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -221,4 +221,31 @@ describeEmbeddedPostgres("environmentService leases", () => {
|
||||||
expect(rows[0]?.driver).toBe("local");
|
expect(rows[0]?.driver).toBe("local");
|
||||||
expect(rows[0]?.status).toBe("active");
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||||
parseIssueExecutionWorkspaceSettings,
|
parseIssueExecutionWorkspaceSettings,
|
||||||
parseProjectExecutionWorkspacePolicy,
|
parseProjectExecutionWorkspacePolicy,
|
||||||
|
resolveExecutionWorkspaceEnvironmentId,
|
||||||
resolveExecutionWorkspaceMode,
|
resolveExecutionWorkspaceMode,
|
||||||
} from "../services/execution-workspace-policy.ts";
|
} from "../services/execution-workspace-policy.ts";
|
||||||
|
|
||||||
|
|
@ -117,6 +118,7 @@ describe("execution workspace policy helpers", () => {
|
||||||
parseProjectExecutionWorkspacePolicy({
|
parseProjectExecutionWorkspacePolicy({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
defaultMode: "isolated",
|
defaultMode: "isolated",
|
||||||
|
environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b",
|
||||||
workspaceStrategy: {
|
workspaceStrategy: {
|
||||||
type: "git_worktree",
|
type: "git_worktree",
|
||||||
worktreeParentDir: ".paperclip/worktrees",
|
worktreeParentDir: ".paperclip/worktrees",
|
||||||
|
|
@ -127,6 +129,7 @@ describe("execution workspace policy helpers", () => {
|
||||||
).toEqual({
|
).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
defaultMode: "isolated_workspace",
|
defaultMode: "isolated_workspace",
|
||||||
|
environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b",
|
||||||
workspaceStrategy: {
|
workspaceStrategy: {
|
||||||
type: "git_worktree",
|
type: "git_worktree",
|
||||||
worktreeParentDir: ".paperclip/worktrees",
|
worktreeParentDir: ".paperclip/worktrees",
|
||||||
|
|
@ -137,12 +140,83 @@ describe("execution workspace policy helpers", () => {
|
||||||
expect(
|
expect(
|
||||||
parseIssueExecutionWorkspaceSettings({
|
parseIssueExecutionWorkspaceSettings({
|
||||||
mode: "project_primary",
|
mode: "project_primary",
|
||||||
|
environmentId: "8f8ab8f2-d95f-4315-9f08-d683a1e0f73b",
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
mode: "shared_workspace",
|
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", () => {
|
it("maps persisted execution workspace modes back to issue settings", () => {
|
||||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace("isolated_workspace")).toBe("isolated_workspace");
|
expect(issueExecutionWorkspaceModeForPersistedWorkspace("isolated_workspace")).toBe("isolated_workspace");
|
||||||
expect(issueExecutionWorkspaceModeForPersistedWorkspace("operator_branch")).toBe("operator_branch");
|
expect(issueExecutionWorkspaceModeForPersistedWorkspace("operator_branch")).toBe("operator_branch");
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import path from "node:path";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { inArray } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
companies,
|
companies,
|
||||||
createDb,
|
createDb,
|
||||||
|
|
@ -30,6 +31,7 @@ describe("execution workspace config helpers", () => {
|
||||||
expect(readExecutionWorkspaceConfig({
|
expect(readExecutionWorkspaceConfig({
|
||||||
source: "project_primary",
|
source: "project_primary",
|
||||||
config: {
|
config: {
|
||||||
|
environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b",
|
||||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||||
cleanupCommand: "pkill -f vite || true",
|
cleanupCommand: "pkill -f vite || true",
|
||||||
|
|
@ -38,6 +40,7 @@ describe("execution workspace config helpers", () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
|
environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b",
|
||||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||||
cleanupCommand: "pkill -f vite || true",
|
cleanupCommand: "pkill -f vite || true",
|
||||||
|
|
@ -55,11 +58,13 @@ describe("execution workspace config helpers", () => {
|
||||||
source: "project_primary",
|
source: "project_primary",
|
||||||
createdByRuntime: false,
|
createdByRuntime: false,
|
||||||
config: {
|
config: {
|
||||||
|
environmentId: "32e0464c-2a0b-4ce9-886d-2cc99e6f3e7b",
|
||||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||||
cleanupCommand: "pkill -f vite || true",
|
cleanupCommand: "pkill -f vite || true",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
environmentId: "6286d5a9-9ea7-42b9-98b3-18ee904c26d7",
|
||||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||||
workspaceRuntime: {
|
workspaceRuntime: {
|
||||||
services: [{ name: "web", command: "pnpm dev" }],
|
services: [{ name: "web", command: "pnpm dev" }],
|
||||||
|
|
@ -69,6 +74,7 @@ describe("execution workspace config helpers", () => {
|
||||||
source: "project_primary",
|
source: "project_primary",
|
||||||
createdByRuntime: false,
|
createdByRuntime: false,
|
||||||
config: {
|
config: {
|
||||||
|
environmentId: "6286d5a9-9ea7-42b9-98b3-18ee904c26d7",
|
||||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||||
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
teardownCommand: "bash ./scripts/teardown-worktree.sh",
|
||||||
cleanupCommand: "pkill -f vite || true",
|
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", () => {
|
it("clears the nested config block when requested", () => {
|
||||||
expect(mergeExecutionWorkspaceConfig(
|
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<string, unknown> | 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 () => {
|
it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => {
|
||||||
const repoRoot = await createTempRepo();
|
const repoRoot = await createTempRepo();
|
||||||
tempDirs.add(repoRoot);
|
tempDirs.add(repoRoot);
|
||||||
|
|
|
||||||
|
|
@ -752,7 +752,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||||
expect(blockedIssue?.executionRunId).toBeNull();
|
expect(blockedIssue?.executionRunId).toBeNull();
|
||||||
expect(blockedIssue?.checkoutRunId).toBe(continuationRun?.id ?? null);
|
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).toHaveLength(1);
|
||||||
expect(comments[0]?.body).toContain("retried continuation");
|
expect(comments[0]?.body).toContain("retried continuation");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ describe("instance settings routes", () => {
|
||||||
feedbackDataSharingPreference: "prompt",
|
feedbackDataSharingPreference: "prompt",
|
||||||
});
|
});
|
||||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
mockInstanceSettingsService.getExperimental.mockResolvedValue({
|
||||||
|
enableEnvironments: false,
|
||||||
enableIsolatedWorkspaces: false,
|
enableIsolatedWorkspaces: false,
|
||||||
autoRestartDevServerWhenIdle: false,
|
autoRestartDevServerWhenIdle: false,
|
||||||
});
|
});
|
||||||
|
|
@ -69,6 +70,7 @@ describe("instance settings routes", () => {
|
||||||
mockInstanceSettingsService.updateExperimental.mockResolvedValue({
|
mockInstanceSettingsService.updateExperimental.mockResolvedValue({
|
||||||
id: "instance-settings-1",
|
id: "instance-settings-1",
|
||||||
experimental: {
|
experimental: {
|
||||||
|
enableEnvironments: true,
|
||||||
enableIsolatedWorkspaces: true,
|
enableIsolatedWorkspaces: true,
|
||||||
autoRestartDevServerWhenIdle: false,
|
autoRestartDevServerWhenIdle: false,
|
||||||
},
|
},
|
||||||
|
|
@ -87,6 +89,7 @@ describe("instance settings routes", () => {
|
||||||
const getRes = await request(app).get("/api/instance/settings/experimental");
|
const getRes = await request(app).get("/api/instance/settings/experimental");
|
||||||
expect(getRes.status).toBe(200);
|
expect(getRes.status).toBe(200);
|
||||||
expect(getRes.body).toEqual({
|
expect(getRes.body).toEqual({
|
||||||
|
enableEnvironments: false,
|
||||||
enableIsolatedWorkspaces: false,
|
enableIsolatedWorkspaces: false,
|
||||||
autoRestartDevServerWhenIdle: 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 () => {
|
it("allows local board users to read and update general settings", async () => {
|
||||||
const app = await createApp({
|
const app = await createApp({
|
||||||
type: "board",
|
type: "board",
|
||||||
|
|
|
||||||
|
|
@ -77,25 +77,8 @@ async function createApp(
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("GET /invites/:token/test-resolution", () => {
|
describe.sequential("GET /invites/:token/test-resolution", () => {
|
||||||
beforeEach(() => {
|
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;
|
currentAccessModule = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { buildPaperclipEnv } from "../adapters/utils.js";
|
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_API_URL = process.env.PAPERCLIP_API_URL;
|
||||||
const ORIGINAL_PAPERCLIP_LISTEN_HOST = process.env.PAPERCLIP_LISTEN_HOST;
|
const ORIGINAL_PAPERCLIP_LISTEN_HOST = process.env.PAPERCLIP_LISTEN_HOST;
|
||||||
const ORIGINAL_PAPERCLIP_LISTEN_PORT = process.env.PAPERCLIP_LISTEN_PORT;
|
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;
|
const ORIGINAL_PORT = process.env.PORT;
|
||||||
|
|
||||||
afterEach(() => {
|
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;
|
if (ORIGINAL_PAPERCLIP_API_URL === undefined) delete process.env.PAPERCLIP_API_URL;
|
||||||
else process.env.PAPERCLIP_API_URL = ORIGINAL_PAPERCLIP_API_URL;
|
else process.env.PAPERCLIP_API_URL = ORIGINAL_PAPERCLIP_API_URL;
|
||||||
|
|
||||||
|
|
@ -25,7 +29,19 @@ afterEach(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildPaperclipEnv", () => {
|
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_API_URL = "http://localhost:4100";
|
||||||
process.env.PAPERCLIP_LISTEN_HOST = "127.0.0.1";
|
process.env.PAPERCLIP_LISTEN_HOST = "127.0.0.1";
|
||||||
process.env.PAPERCLIP_LISTEN_PORT = "3101";
|
process.env.PAPERCLIP_LISTEN_PORT = "3101";
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||||
const mockSecretService = vi.hoisted(() => ({
|
const mockSecretService = vi.hoisted(() => ({
|
||||||
normalizeEnvBindingsForPersistence: vi.fn(),
|
normalizeEnvBindingsForPersistence: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
const mockEnvironmentService = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
}));
|
||||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||||
const mockTelemetryTrack = vi.hoisted(() => vi.fn());
|
const mockTelemetryTrack = vi.hoisted(() => vi.fn());
|
||||||
|
|
@ -31,6 +34,7 @@ vi.mock("../telemetry.js", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../services/index.js", () => ({
|
vi.mock("../services/index.js", () => ({
|
||||||
|
environmentService: () => mockEnvironmentService,
|
||||||
goalService: () => mockGoalService,
|
goalService: () => mockGoalService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => mockProjectService,
|
projectService: () => mockProjectService,
|
||||||
|
|
@ -49,6 +53,7 @@ function registerModuleMocks() {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock("../services/index.js", () => ({
|
vi.doMock("../services/index.js", () => ({
|
||||||
|
environmentService: () => mockEnvironmentService,
|
||||||
goalService: () => mockGoalService,
|
goalService: () => mockGoalService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => mockProjectService,
|
projectService: () => mockProjectService,
|
||||||
|
|
@ -107,6 +112,7 @@ describe("project and goal telemetry routes", () => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
mockGetTelemetryClient.mockReturnValue({ track: mockTelemetryTrack });
|
mockGetTelemetryClient.mockReturnValue({ track: mockTelemetryTrack });
|
||||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||||
|
mockEnvironmentService.getById.mockReset();
|
||||||
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
||||||
mockProjectService.create.mockResolvedValue({
|
mockProjectService.create.mockResolvedValue({
|
||||||
id: "project-1",
|
id: "project-1",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ const mockProjectService = vi.hoisted(() => ({
|
||||||
const mockSecretService = vi.hoisted(() => ({
|
const mockSecretService = vi.hoisted(() => ({
|
||||||
normalizeEnvBindingsForPersistence: vi.fn(),
|
normalizeEnvBindingsForPersistence: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
const mockEnvironmentService = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
}));
|
||||||
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||||
|
|
@ -26,6 +29,7 @@ vi.mock("../telemetry.js", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../services/index.js", () => ({
|
vi.mock("../services/index.js", () => ({
|
||||||
|
environmentService: () => mockEnvironmentService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => mockProjectService,
|
projectService: () => mockProjectService,
|
||||||
secretService: () => mockSecretService,
|
secretService: () => mockSecretService,
|
||||||
|
|
@ -43,6 +47,7 @@ function registerModuleMocks() {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock("../services/index.js", () => ({
|
vi.doMock("../services/index.js", () => ({
|
||||||
|
environmentService: () => mockEnvironmentService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => mockProjectService,
|
projectService: () => mockProjectService,
|
||||||
secretService: () => mockSecretService,
|
secretService: () => mockSecretService,
|
||||||
|
|
@ -127,6 +132,7 @@ describe("project env routes", () => {
|
||||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||||
mockProjectService.createWorkspace.mockResolvedValue(null);
|
mockProjectService.createWorkspace.mockResolvedValue(null);
|
||||||
mockProjectService.listWorkspaces.mockResolvedValue([]);
|
mockProjectService.listWorkspaces.mockResolvedValue([]);
|
||||||
|
mockEnvironmentService.getById.mockReset();
|
||||||
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({
|
||||||
const mockSecretService = vi.hoisted(() => ({
|
const mockSecretService = vi.hoisted(() => ({
|
||||||
normalizeEnvBindingsForPersistence: vi.fn(),
|
normalizeEnvBindingsForPersistence: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
const mockEnvironmentService = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
|
||||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||||
|
|
@ -34,6 +37,7 @@ function registerModuleMocks() {
|
||||||
|
|
||||||
vi.doMock("../services/index.js", () => ({
|
vi.doMock("../services/index.js", () => ({
|
||||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||||
|
environmentService: () => mockEnvironmentService,
|
||||||
logActivity: mockLogActivity,
|
logActivity: mockLogActivity,
|
||||||
projectService: () => mockProjectService,
|
projectService: () => mockProjectService,
|
||||||
secretService: () => mockSecretService,
|
secretService: () => mockSecretService,
|
||||||
|
|
@ -158,6 +162,7 @@ describe("workspace runtime service route authorization", () => {
|
||||||
vi.doUnmock("../middleware/index.js");
|
vi.doUnmock("../middleware/index.js");
|
||||||
registerModuleMocks();
|
registerModuleMocks();
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
mockEnvironmentService.getById.mockReset();
|
||||||
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
|
||||||
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
|
||||||
mockProjectService.create.mockResolvedValue(buildProject());
|
mockProjectService.create.mockResolvedValue(buildProject());
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { projectRoutes } from "./routes/projects.js";
|
||||||
import { issueRoutes } from "./routes/issues.js";
|
import { issueRoutes } from "./routes/issues.js";
|
||||||
import { issueTreeControlRoutes } from "./routes/issue-tree-control.js";
|
import { issueTreeControlRoutes } from "./routes/issue-tree-control.js";
|
||||||
import { routineRoutes } from "./routes/routines.js";
|
import { routineRoutes } from "./routes/routines.js";
|
||||||
|
import { environmentRoutes } from "./routes/environments.js";
|
||||||
import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js";
|
import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js";
|
||||||
import { goalRoutes } from "./routes/goals.js";
|
import { goalRoutes } from "./routes/goals.js";
|
||||||
import { approvalRoutes } from "./routes/approvals.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 { applyUiBranding } from "./ui-branding.js";
|
||||||
import { logger } from "./middleware/logger.js";
|
import { logger } from "./middleware/logger.js";
|
||||||
import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.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 { createPluginJobScheduler } from "./services/plugin-job-scheduler.js";
|
||||||
import { pluginJobStore } from "./services/plugin-job-store.js";
|
import { pluginJobStore } from "./services/plugin-job-store.js";
|
||||||
import { createPluginToolDispatcher } from "./services/plugin-tool-dispatcher.js";
|
import { createPluginToolDispatcher } from "./services/plugin-tool-dispatcher.js";
|
||||||
|
|
@ -129,6 +130,7 @@ export async function createApp(
|
||||||
hostVersion?: string;
|
hostVersion?: string;
|
||||||
localPluginDir?: string;
|
localPluginDir?: string;
|
||||||
pluginMigrationDb?: Db;
|
pluginMigrationDb?: Db;
|
||||||
|
pluginWorkerManager?: PluginWorkerManager;
|
||||||
betterAuthHandler?: express.RequestHandler;
|
betterAuthHandler?: express.RequestHandler;
|
||||||
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
||||||
},
|
},
|
||||||
|
|
@ -170,6 +172,9 @@ export async function createApp(
|
||||||
}
|
}
|
||||||
app.use(llmRoutes(db));
|
app.use(llmRoutes(db));
|
||||||
|
|
||||||
|
const hostServicesDisposers = new Map<string, () => void>();
|
||||||
|
const workerManager = opts.pluginWorkerManager ?? createPluginWorkerManager();
|
||||||
|
|
||||||
// Mount API routes
|
// Mount API routes
|
||||||
const api = Router();
|
const api = Router();
|
||||||
api.use(boardMutationGuard());
|
api.use(boardMutationGuard());
|
||||||
|
|
@ -192,6 +197,7 @@ export async function createApp(
|
||||||
}));
|
}));
|
||||||
api.use(issueTreeControlRoutes(db));
|
api.use(issueTreeControlRoutes(db));
|
||||||
api.use(routineRoutes(db));
|
api.use(routineRoutes(db));
|
||||||
|
api.use(environmentRoutes(db));
|
||||||
api.use(executionWorkspaceRoutes(db));
|
api.use(executionWorkspaceRoutes(db));
|
||||||
api.use(goalRoutes(db));
|
api.use(goalRoutes(db));
|
||||||
api.use(approvalRoutes(db));
|
api.use(approvalRoutes(db));
|
||||||
|
|
@ -207,8 +213,6 @@ export async function createApp(
|
||||||
if (opts.databaseBackupService) {
|
if (opts.databaseBackupService) {
|
||||||
api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService));
|
api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService));
|
||||||
}
|
}
|
||||||
const hostServicesDisposers = new Map<string, () => void>();
|
|
||||||
const workerManager = createPluginWorkerManager();
|
|
||||||
const pluginRegistry = pluginRegistryService(db);
|
const pluginRegistry = pluginRegistryService(db);
|
||||||
const eventBus = createPluginEventBus();
|
const eventBus = createPluginEventBus();
|
||||||
setPluginEventBus(eventBus);
|
setPluginEventBus(eventBus);
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
updateAgentInstructionsPathSchema,
|
updateAgentInstructionsPathSchema,
|
||||||
wakeAgentSchema,
|
wakeAgentSchema,
|
||||||
updateAgentSchema,
|
updateAgentSchema,
|
||||||
|
supportedEnvironmentDriversForAdapter,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
readPaperclipSkillSyncPreference,
|
readPaperclipSkillSyncPreference,
|
||||||
|
|
@ -37,6 +38,7 @@ import {
|
||||||
approvalService,
|
approvalService,
|
||||||
companySkillService,
|
companySkillService,
|
||||||
budgetService,
|
budgetService,
|
||||||
|
environmentService,
|
||||||
heartbeatService,
|
heartbeatService,
|
||||||
ISSUE_LIST_DEFAULT_LIMIT,
|
ISSUE_LIST_DEFAULT_LIMIT,
|
||||||
issueApprovalService,
|
issueApprovalService,
|
||||||
|
|
@ -76,6 +78,7 @@ import {
|
||||||
resolveDefaultAgentInstructionsBundleRole,
|
resolveDefaultAgentInstructionsBundleRole,
|
||||||
} from "../services/default-agent-instructions.js";
|
} from "../services/default-agent-instructions.js";
|
||||||
import { getTelemetryClient } from "../telemetry.js";
|
import { getTelemetryClient } from "../telemetry.js";
|
||||||
|
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
|
||||||
|
|
||||||
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
|
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
|
||||||
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
|
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
|
||||||
|
|
@ -139,6 +142,17 @@ export function agentRoutes(db: Db) {
|
||||||
const instanceSettings = instanceSettingsService(db);
|
const instanceSettings = instanceSettingsService(db);
|
||||||
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
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() {
|
async function getCurrentUserRedactionOptions() {
|
||||||
return {
|
return {
|
||||||
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
|
||||||
|
|
@ -407,6 +421,10 @@ export function agentRoutes(db: Db) {
|
||||||
return Object.hasOwn(value, key);
|
return Object.hasOwn(value, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function allowedEnvironmentDriversForAgent(adapterType: string): string[] {
|
||||||
|
return supportedEnvironmentDriversForAdapter(adapterType);
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveCompanyIdForAgentReference(req: Request): Promise<string | null> {
|
async function resolveCompanyIdForAgentReference(req: Request): Promise<string | null> {
|
||||||
const companyIdQuery = req.query.companyId;
|
const companyIdQuery = req.query.companyId;
|
||||||
const requestedCompanyId =
|
const requestedCompanyId =
|
||||||
|
|
@ -1609,6 +1627,7 @@ export function agentRoutes(db: Db) {
|
||||||
createInput.adapterType,
|
createInput.adapterType,
|
||||||
normalizedAdapterConfig,
|
normalizedAdapterConfig,
|
||||||
);
|
);
|
||||||
|
await assertAgentEnvironmentSelection(companyId, createInput.adapterType, createInput.defaultEnvironmentId);
|
||||||
|
|
||||||
const createdAgent = await svc.create(companyId, {
|
const createdAgent = await svc.create(companyId, {
|
||||||
...createInput,
|
...createInput,
|
||||||
|
|
@ -2065,6 +2084,15 @@ export function agentRoutes(db: Db) {
|
||||||
effectiveAdapterConfig,
|
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 actor = getActorInfo(req);
|
||||||
const agent = await svc.update(id, patchData, {
|
const agent = await svc.update(id, patchData, {
|
||||||
|
|
|
||||||
32
server/src/routes/environment-selection.ts
Normal file
32
server/src/routes/environment-selection.ts
Normal file
|
|
@ -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<string, unknown> | 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(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
423
server/src/routes/environments.ts
Normal file
423
server/src/routes/environments.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCreateAgents(agent: { permissions: Record<string, unknown> | null | undefined }) {
|
||||||
|
if (!agent.permissions || typeof agent.permissions !== "object") return false;
|
||||||
|
return Boolean((agent.permissions as Record<string, unknown>).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<T extends {
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
}>(environment: T): T & { configRedacted: true; metadataRedacted: true } {
|
||||||
|
return {
|
||||||
|
...environment,
|
||||||
|
config: {},
|
||||||
|
metadata: null,
|
||||||
|
configRedacted: true,
|
||||||
|
metadataRedacted: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeEnvironmentUpdate(
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
environment: {
|
||||||
|
name: string;
|
||||||
|
driver: string;
|
||||||
|
status: string;
|
||||||
|
},
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const details: Record<string, unknown> = {
|
||||||
|
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<string, unknown>).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<string, unknown>).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<string, unknown>, 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;
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,7 @@ import {
|
||||||
projectService,
|
projectService,
|
||||||
routineService,
|
routineService,
|
||||||
workProductService,
|
workProductService,
|
||||||
|
environmentService,
|
||||||
} from "../services/index.js";
|
} from "../services/index.js";
|
||||||
import { logger } from "../middleware/logger.js";
|
import { logger } from "../middleware/logger.js";
|
||||||
import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js";
|
import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js";
|
||||||
|
|
@ -71,6 +72,7 @@ import {
|
||||||
SVG_CONTENT_TYPE,
|
SVG_CONTENT_TYPE,
|
||||||
} from "../attachment-types.js";
|
} from "../attachment-types.js";
|
||||||
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
||||||
|
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
|
||||||
import {
|
import {
|
||||||
applyIssueExecutionPolicyTransition,
|
applyIssueExecutionPolicyTransition,
|
||||||
normalizeIssueExecutionPolicy,
|
normalizeIssueExecutionPolicy,
|
||||||
|
|
@ -415,6 +417,19 @@ export function issueRoutes(
|
||||||
return value === true || value === "true" || value === "1";
|
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: {
|
async function logExpiredRequestConfirmations(input: {
|
||||||
issue: { id: string; companyId: string; identifier?: string | null };
|
issue: { id: string; companyId: string; identifier?: string | null };
|
||||||
interactions: Array<{ id: string; kind: string; status: string; result?: unknown }>;
|
interactions: Array<{ id: string; kind: string; status: string; result?: unknown }>;
|
||||||
|
|
@ -1635,6 +1650,7 @@ export function issueRoutes(
|
||||||
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
||||||
await assertCanAssignTasks(req, companyId);
|
await assertCanAssignTasks(req, companyId);
|
||||||
}
|
}
|
||||||
|
await assertIssueEnvironmentSelection(companyId, req.body.executionWorkspaceSettings?.environmentId);
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
||||||
|
|
@ -1701,6 +1717,7 @@ export function issueRoutes(
|
||||||
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
||||||
await assertCanAssignTasks(req, parent.companyId);
|
await assertCanAssignTasks(req, parent.companyId);
|
||||||
}
|
}
|
||||||
|
await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId);
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
||||||
|
|
@ -1775,6 +1792,7 @@ export function issueRoutes(
|
||||||
hiddenAt: hiddenAtRaw,
|
hiddenAt: hiddenAtRaw,
|
||||||
...updateFields
|
...updateFields
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
await assertIssueEnvironmentSelection(existing.companyId, updateFields.executionWorkspaceSettings?.environmentId);
|
||||||
const requestedAssigneeAgentId =
|
const requestedAssigneeAgentId =
|
||||||
normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId;
|
normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId;
|
||||||
const effectiveMoveToTodoRequested =
|
const effectiveMoveToTodoRequested =
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
|
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
|
||||||
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
|
||||||
import { validate } from "../middleware/validate.js";
|
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 { conflict } from "../errors.js";
|
||||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js";
|
import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js";
|
||||||
import { getTelemetryClient } from "../telemetry.js";
|
import { getTelemetryClient } from "../telemetry.js";
|
||||||
import { appendWithCap } from "../adapters/utils.js";
|
import { appendWithCap } from "../adapters/utils.js";
|
||||||
|
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
|
||||||
|
|
||||||
const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024;
|
const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024;
|
||||||
|
|
||||||
|
|
@ -40,6 +41,22 @@ export function projectRoutes(db: Db) {
|
||||||
const secretsSvc = secretService(db);
|
const secretsSvc = secretService(db);
|
||||||
const workspaceOperations = workspaceOperationService(db);
|
const workspaceOperations = workspaceOperationService(db);
|
||||||
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
|
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) {
|
async function resolveCompanyIdForProjectReference(req: Request) {
|
||||||
const companyIdQuery = req.query.companyId;
|
const companyIdQuery = req.query.companyId;
|
||||||
|
|
@ -103,6 +120,10 @@ export function projectRoutes(db: Db) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const { workspace, ...projectData } = req.body as CreateProjectPayload;
|
const { workspace, ...projectData } = req.body as CreateProjectPayload;
|
||||||
|
await assertProjectEnvironmentSelection(
|
||||||
|
companyId,
|
||||||
|
readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy),
|
||||||
|
);
|
||||||
assertNoAgentHostWorkspaceCommandMutation(
|
assertNoAgentHostWorkspaceCommandMutation(
|
||||||
req,
|
req,
|
||||||
[
|
[
|
||||||
|
|
@ -165,6 +186,10 @@ export function projectRoutes(db: Db) {
|
||||||
req,
|
req,
|
||||||
collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy),
|
collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy),
|
||||||
);
|
);
|
||||||
|
await assertProjectEnvironmentSelection(
|
||||||
|
existing.companyId,
|
||||||
|
readProjectPolicyEnvironmentId(body.executionWorkspacePolicy),
|
||||||
|
);
|
||||||
if (typeof body.archivedAt === "string") {
|
if (typeof body.archivedAt === "string") {
|
||||||
body.archivedAt = new Date(body.archivedAt);
|
body.archivedAt = new Date(body.archivedAt);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ const CONFIG_REVISION_FIELDS = [
|
||||||
"adapterType",
|
"adapterType",
|
||||||
"adapterConfig",
|
"adapterConfig",
|
||||||
"runtimeConfig",
|
"runtimeConfig",
|
||||||
|
"defaultEnvironmentId",
|
||||||
"budgetMonthlyCents",
|
"budgetMonthlyCents",
|
||||||
"metadata",
|
"metadata",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
@ -98,6 +99,7 @@ function buildConfigSnapshot(
|
||||||
adapterType: row.adapterType,
|
adapterType: row.adapterType,
|
||||||
adapterConfig,
|
adapterConfig,
|
||||||
runtimeConfig,
|
runtimeConfig,
|
||||||
|
defaultEnvironmentId: row.defaultEnvironmentId,
|
||||||
budgetMonthlyCents: row.budgetMonthlyCents,
|
budgetMonthlyCents: row.budgetMonthlyCents,
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
|
|
@ -169,6 +171,10 @@ function configPatchFromSnapshot(snapshot: unknown): Partial<typeof agents.$infe
|
||||||
adapterType: snapshot.adapterType,
|
adapterType: snapshot.adapterType,
|
||||||
adapterConfig: isPlainRecord(snapshot.adapterConfig) ? snapshot.adapterConfig : {},
|
adapterConfig: isPlainRecord(snapshot.adapterConfig) ? snapshot.adapterConfig : {},
|
||||||
runtimeConfig: isPlainRecord(snapshot.runtimeConfig) ? snapshot.runtimeConfig : {},
|
runtimeConfig: isPlainRecord(snapshot.runtimeConfig) ? snapshot.runtimeConfig : {},
|
||||||
|
defaultEnvironmentId:
|
||||||
|
typeof snapshot.defaultEnvironmentId === "string" || snapshot.defaultEnvironmentId === null
|
||||||
|
? snapshot.defaultEnvironmentId
|
||||||
|
: null,
|
||||||
budgetMonthlyCents: Math.max(0, Math.floor(snapshot.budgetMonthlyCents)),
|
budgetMonthlyCents: Math.max(0, Math.floor(snapshot.budgetMonthlyCents)),
|
||||||
metadata: isPlainRecord(snapshot.metadata) || snapshot.metadata === null ? snapshot.metadata : null,
|
metadata: isPlainRecord(snapshot.metadata) || snapshot.metadata === null ? snapshot.metadata : null,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
237
server/src/services/environment-config.ts
Normal file
237
server/src/services/environment-config.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import type {
|
||||||
|
Environment,
|
||||||
|
EnvironmentDriver,
|
||||||
|
LocalEnvironmentConfig,
|
||||||
|
SshEnvironmentConfig,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
import { unprocessable } from "../errors.js";
|
||||||
|
import { parseObject } from "../adapters/utils.js";
|
||||||
|
import { secretService } from "./secrets.js";
|
||||||
|
|
||||||
|
const secretRefSchema = z.object({
|
||||||
|
type: z.literal("secret_ref"),
|
||||||
|
secretId: z.string().uuid(),
|
||||||
|
version: z.union([z.literal("latest"), z.number().int().positive()]).optional().default("latest"),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const sshEnvironmentConfigSchema = z.object({
|
||||||
|
host: z.string({ required_error: "SSH environments require a host." }).trim().min(1, "SSH environments require a host."),
|
||||||
|
port: z.coerce.number().int().min(1).max(65535).default(22),
|
||||||
|
username: z.string({ required_error: "SSH environments require a username." }).trim().min(1, "SSH environments require a username."),
|
||||||
|
remoteWorkspacePath: z
|
||||||
|
.string({ required_error: "SSH environments require a remote workspace path." })
|
||||||
|
.trim()
|
||||||
|
.min(1, "SSH environments require a remote workspace path.")
|
||||||
|
.refine((value) => 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<string, unknown> | null | undefined;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
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<string, unknown> | null | undefined;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
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<string, unknown> | null | undefined;
|
||||||
|
actor?: { userId?: string | null; agentId?: string | null };
|
||||||
|
}): Promise<Record<string, unknown>> {
|
||||||
|
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<Environment, "driver" | "config">,
|
||||||
|
): Promise<ParsedEnvironmentConfig> {
|
||||||
|
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<Environment, "driver" | "config">,
|
||||||
|
): 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<Environment, "driver" | "config">,
|
||||||
|
): 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}".`);
|
||||||
|
}
|
||||||
77
server/src/services/environment-probe.ts
Normal file
77
server/src/services/environment-probe.ts
Normal file
|
|
@ -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<EnvironmentProbeResult> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 type { Db } from "@paperclipai/db";
|
||||||
import { environmentLeases, environments } from "@paperclipai/db";
|
import { environmentLeases, environments } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
|
|
@ -130,6 +130,7 @@ export function environmentService(db: Db) {
|
||||||
})
|
})
|
||||||
.onConflictDoNothing({
|
.onConflictDoNothing({
|
||||||
target: [environments.companyId, environments.driver],
|
target: [environments.companyId, environments.driver],
|
||||||
|
where: sql`${environments.driver} = 'local'`,
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
@ -189,6 +190,15 @@ export function environmentService(db: Db) {
|
||||||
return row ? toEnvironment(row) : null;
|
return row ? toEnvironment(row) : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
remove: async (id: string): Promise<Environment | null> => {
|
||||||
|
const row = await db
|
||||||
|
.delete(environments)
|
||||||
|
.where(eq(environments.id, id))
|
||||||
|
.returning()
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
return row ? toEnvironment(row) : null;
|
||||||
|
},
|
||||||
|
|
||||||
listLeases: async (
|
listLeases: async (
|
||||||
environmentId: string,
|
environmentId: string,
|
||||||
filters: {
|
filters: {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu
|
||||||
const defaultMode = asString(parsed.defaultMode, "");
|
const defaultMode = asString(parsed.defaultMode, "");
|
||||||
const defaultProjectWorkspaceId =
|
const defaultProjectWorkspaceId =
|
||||||
typeof parsed.defaultProjectWorkspaceId === "string" ? parsed.defaultProjectWorkspaceId : undefined;
|
typeof parsed.defaultProjectWorkspaceId === "string" ? parsed.defaultProjectWorkspaceId : undefined;
|
||||||
|
const environmentId = typeof parsed.environmentId === "string" ? parsed.environmentId : undefined;
|
||||||
const allowIssueOverride =
|
const allowIssueOverride =
|
||||||
typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined;
|
typeof parsed.allowIssueOverride === "boolean" ? parsed.allowIssueOverride : undefined;
|
||||||
const normalizedDefaultMode = (() => {
|
const normalizedDefaultMode = (() => {
|
||||||
|
|
@ -58,6 +59,7 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu
|
||||||
...(normalizedDefaultMode ? { defaultMode: normalizedDefaultMode } : {}),
|
...(normalizedDefaultMode ? { defaultMode: normalizedDefaultMode } : {}),
|
||||||
...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}),
|
...(allowIssueOverride !== undefined ? { allowIssueOverride } : {}),
|
||||||
...(defaultProjectWorkspaceId ? { defaultProjectWorkspaceId } : {}),
|
...(defaultProjectWorkspaceId ? { defaultProjectWorkspaceId } : {}),
|
||||||
|
...(environmentId !== undefined ? { environmentId } : {}),
|
||||||
...(workspaceStrategy ? { workspaceStrategy } : {}),
|
...(workspaceStrategy ? { workspaceStrategy } : {}),
|
||||||
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
|
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
|
||||||
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
|
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
|
||||||
|
|
@ -109,6 +111,7 @@ export function parseIssueExecutionWorkspaceSettings(raw: unknown): IssueExecuti
|
||||||
...(normalizedMode
|
...(normalizedMode
|
||||||
? { mode: normalizedMode as IssueExecutionWorkspaceSettings["mode"] }
|
? { mode: normalizedMode as IssueExecutionWorkspaceSettings["mode"] }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(typeof parsed.environmentId === "string" ? { environmentId: parsed.environmentId } : {}),
|
||||||
...(workspaceStrategy ? { workspaceStrategy } : {}),
|
...(workspaceStrategy ? { workspaceStrategy } : {}),
|
||||||
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
|
...(parsed.workspaceRuntime && typeof parsed.workspaceRuntime === "object" && !Array.isArray(parsed.workspaceRuntime)
|
||||||
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
|
? { workspaceRuntime: { ...(parsed.workspaceRuntime as Record<string, unknown>) } }
|
||||||
|
|
@ -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(
|
export function defaultIssueExecutionWorkspaceSettingsForProject(
|
||||||
projectPolicy: ProjectExecutionWorkspacePolicy | null,
|
projectPolicy: ProjectExecutionWorkspacePolicy | null,
|
||||||
): IssueExecutionWorkspaceSettings | null {
|
): IssueExecutionWorkspaceSettings | null {
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,7 @@ export function readExecutionWorkspaceConfig(metadata: Record<string, unknown> |
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
|
|
||||||
const config: ExecutionWorkspaceConfig = {
|
const config: ExecutionWorkspaceConfig = {
|
||||||
|
environmentId: readNullableString(raw.environmentId),
|
||||||
provisionCommand: readNullableString(raw.provisionCommand),
|
provisionCommand: readNullableString(raw.provisionCommand),
|
||||||
teardownCommand: readNullableString(raw.teardownCommand),
|
teardownCommand: readNullableString(raw.teardownCommand),
|
||||||
cleanupCommand: readNullableString(raw.cleanupCommand),
|
cleanupCommand: readNullableString(raw.cleanupCommand),
|
||||||
|
|
@ -226,6 +227,7 @@ export function mergeExecutionWorkspaceConfig(
|
||||||
): Record<string, unknown> | null {
|
): Record<string, unknown> | null {
|
||||||
const nextMetadata = isRecord(metadata) ? { ...metadata } : {};
|
const nextMetadata = isRecord(metadata) ? { ...metadata } : {};
|
||||||
const current = readExecutionWorkspaceConfig(metadata) ?? {
|
const current = readExecutionWorkspaceConfig(metadata) ?? {
|
||||||
|
environmentId: null,
|
||||||
provisionCommand: null,
|
provisionCommand: null,
|
||||||
teardownCommand: null,
|
teardownCommand: null,
|
||||||
cleanupCommand: null,
|
cleanupCommand: null,
|
||||||
|
|
@ -240,6 +242,7 @@ export function mergeExecutionWorkspaceConfig(
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextConfig: ExecutionWorkspaceConfig = {
|
const nextConfig: ExecutionWorkspaceConfig = {
|
||||||
|
environmentId: patch.environmentId !== undefined ? readNullableString(patch.environmentId) : current.environmentId,
|
||||||
provisionCommand: patch.provisionCommand !== undefined ? readNullableString(patch.provisionCommand) : current.provisionCommand,
|
provisionCommand: patch.provisionCommand !== undefined ? readNullableString(patch.provisionCommand) : current.provisionCommand,
|
||||||
teardownCommand: patch.teardownCommand !== undefined ? readNullableString(patch.teardownCommand) : current.teardownCommand,
|
teardownCommand: patch.teardownCommand !== undefined ? readNullableString(patch.teardownCommand) : current.teardownCommand,
|
||||||
cleanupCommand: patch.cleanupCommand !== undefined ? readNullableString(patch.cleanupCommand) : current.cleanupCommand,
|
cleanupCommand: patch.cleanupCommand !== undefined ? readNullableString(patch.cleanupCommand) : current.cleanupCommand,
|
||||||
|
|
@ -260,6 +263,7 @@ export function mergeExecutionWorkspaceConfig(
|
||||||
|
|
||||||
if (hasConfig) {
|
if (hasConfig) {
|
||||||
nextMetadata.config = {
|
nextMetadata.config = {
|
||||||
|
environmentId: nextConfig.environmentId,
|
||||||
provisionCommand: nextConfig.provisionCommand,
|
provisionCommand: nextConfig.provisionCommand,
|
||||||
teardownCommand: nextConfig.teardownCommand,
|
teardownCommand: nextConfig.teardownCommand,
|
||||||
cleanupCommand: nextConfig.cleanupCommand,
|
cleanupCommand: nextConfig.cleanupCommand,
|
||||||
|
|
@ -739,6 +743,37 @@ export function executionWorkspaceService(db: Db) {
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
return row ? toExecutionWorkspace(row) : 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<string, unknown> | 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;
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,19 @@ import type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||||
|
isEnvironmentDriverSupportedForAdapter,
|
||||||
type BillingType,
|
type BillingType,
|
||||||
type EnvironmentLeaseStatus,
|
type EnvironmentLeaseStatus,
|
||||||
type ExecutionWorkspace,
|
type ExecutionWorkspace,
|
||||||
type ExecutionWorkspaceConfig,
|
type ExecutionWorkspaceConfig,
|
||||||
type RunLivenessState,
|
type RunLivenessState,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
ensureSshWorkspaceReady,
|
||||||
|
findReachablePaperclipApiUrlOverSsh,
|
||||||
|
type SshRemoteExecutionSpec,
|
||||||
|
} from "@paperclipai/adapter-utils/ssh";
|
||||||
|
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||||
import {
|
import {
|
||||||
agents,
|
agents,
|
||||||
agentRuntimeState,
|
agentRuntimeState,
|
||||||
|
|
@ -98,8 +105,10 @@ import {
|
||||||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||||
parseIssueExecutionWorkspaceSettings,
|
parseIssueExecutionWorkspaceSettings,
|
||||||
parseProjectExecutionWorkspacePolicy,
|
parseProjectExecutionWorkspacePolicy,
|
||||||
|
resolveExecutionWorkspaceEnvironmentId,
|
||||||
resolveExecutionWorkspaceMode,
|
resolveExecutionWorkspaceMode,
|
||||||
} from "./execution-workspace-policy.js";
|
} from "./execution-workspace-policy.js";
|
||||||
|
import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js";
|
||||||
import { instanceSettingsService } from "./instance-settings.js";
|
import { instanceSettingsService } from "./instance-settings.js";
|
||||||
import {
|
import {
|
||||||
RUN_LIVENESS_CONTINUATION_REASON,
|
RUN_LIVENESS_CONTINUATION_REASON,
|
||||||
|
|
@ -322,6 +331,27 @@ function leaseReleaseStatusForRunStatus(
|
||||||
return status === "failed" || status === "timed_out" ? "failed" : "released";
|
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: {
|
export function applyPersistedExecutionWorkspaceConfig(input: {
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
workspaceConfig: ExecutionWorkspaceConfig | null;
|
workspaceConfig: ExecutionWorkspaceConfig | null;
|
||||||
|
|
@ -391,9 +421,19 @@ export function buildRealizedExecutionWorkspaceFromPersisted(input: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildExecutionWorkspaceConfigSnapshot(config: Record<string, unknown>): Partial<ExecutionWorkspaceConfig> | null {
|
function buildExecutionWorkspaceConfigSnapshot(
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
environmentId?: string | null,
|
||||||
|
): Partial<ExecutionWorkspaceConfig> | null {
|
||||||
const strategy = parseObject(config.workspaceStrategy);
|
const strategy = parseObject(config.workspaceStrategy);
|
||||||
const snapshot: Partial<ExecutionWorkspaceConfig> = {};
|
const snapshot: Partial<ExecutionWorkspaceConfig> = {};
|
||||||
|
// 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) {
|
if ("workspaceStrategy" in config) {
|
||||||
snapshot.provisionCommand = typeof strategy.provisionCommand === "string" ? strategy.provisionCommand : null;
|
snapshot.provisionCommand = typeof strategy.provisionCommand === "string" ? strategy.provisionCommand : null;
|
||||||
|
|
@ -426,7 +466,7 @@ function buildExecutionWorkspaceConfigSnapshot(config: Record<string, unknown>):
|
||||||
if (typeof value === "object") return Object.keys(value).length > 0;
|
if (typeof value === "object") return Object.keys(value).length > 0;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return hasSnapshot ? snapshot : null;
|
return hasSnapshot || hasExplicitEnvironmentSelection ? snapshot : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null {
|
function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null {
|
||||||
|
|
@ -5061,7 +5101,15 @@ export function heartbeatService(db: Db) {
|
||||||
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
||||||
? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
|
? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
|
||||||
: persistedWorkspaceManagedConfig;
|
: 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 executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
|
||||||
const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
|
const { resolvedConfig, secretKeys } = await resolveExecutionRunAdapterConfig({
|
||||||
companyId: agent.companyId,
|
companyId: agent.companyId,
|
||||||
|
|
@ -5294,26 +5342,105 @@ export function heartbeatService(db: Db) {
|
||||||
})(),
|
})(),
|
||||||
};
|
};
|
||||||
context.paperclipWorkspaces = resolvedWorkspace.workspaceHints;
|
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<string, unknown> = {
|
||||||
|
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({
|
const environmentLease = await environmentsSvc.acquireLease({
|
||||||
companyId: agent.companyId,
|
companyId: agent.companyId,
|
||||||
environmentId: localEnvironment.id,
|
environmentId: selectedEnvironment.id,
|
||||||
executionWorkspaceId: persistedExecutionWorkspace?.id ?? null,
|
executionWorkspaceId: persistedExecutionWorkspace?.id ?? null,
|
||||||
issueId: issueId ?? null,
|
issueId: issueId ?? null,
|
||||||
heartbeatRunId: run.id,
|
heartbeatRunId: run.id,
|
||||||
leasePolicy: "ephemeral",
|
leasePolicy: "ephemeral",
|
||||||
provider: "local",
|
provider: environmentProvider,
|
||||||
metadata: {
|
providerLeaseId: environmentProviderLeaseId,
|
||||||
driver: "local",
|
metadata: environmentLeaseMetadata,
|
||||||
executionWorkspaceMode: persistedExecutionWorkspace?.mode ?? effectiveExecutionWorkspaceMode,
|
|
||||||
cwd: executionWorkspace.cwd,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (remoteExecution) {
|
||||||
|
executionTarget = {
|
||||||
|
kind: "remote",
|
||||||
|
transport: "ssh",
|
||||||
|
environmentId: selectedEnvironment.id,
|
||||||
|
leaseId: environmentLease.id,
|
||||||
|
remoteCwd: remoteExecution.remoteCwd,
|
||||||
|
paperclipApiUrl: remoteExecution.paperclipApiUrl,
|
||||||
|
spec: remoteExecution,
|
||||||
|
};
|
||||||
|
}
|
||||||
context.paperclipEnvironment = {
|
context.paperclipEnvironment = {
|
||||||
id: localEnvironment.id,
|
id: selectedEnvironment.id,
|
||||||
name: localEnvironment.name,
|
name: selectedEnvironment.name,
|
||||||
driver: localEnvironment.driver,
|
driver: selectedEnvironment.driver,
|
||||||
leaseId: environmentLease.id,
|
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, {
|
await logActivity(db, {
|
||||||
companyId: agent.companyId,
|
companyId: agent.companyId,
|
||||||
|
|
@ -5325,8 +5452,8 @@ export function heartbeatService(db: Db) {
|
||||||
entityType: "environment_lease",
|
entityType: "environment_lease",
|
||||||
entityId: environmentLease.id,
|
entityId: environmentLease.id,
|
||||||
details: {
|
details: {
|
||||||
environmentId: localEnvironment.id,
|
environmentId: selectedEnvironment.id,
|
||||||
driver: localEnvironment.driver,
|
driver: selectedEnvironment.driver,
|
||||||
leasePolicy: environmentLease.leasePolicy,
|
leasePolicy: environmentLease.leasePolicy,
|
||||||
provider: environmentLease.provider,
|
provider: environmentLease.provider,
|
||||||
executionWorkspaceId: environmentLease.executionWorkspaceId,
|
executionWorkspaceId: environmentLease.executionWorkspaceId,
|
||||||
|
|
@ -5592,6 +5719,10 @@ export function heartbeatService(db: Db) {
|
||||||
runtime: runtimeForAdapter,
|
runtime: runtimeForAdapter,
|
||||||
config: runtimeConfig,
|
config: runtimeConfig,
|
||||||
context,
|
context,
|
||||||
|
executionTarget,
|
||||||
|
executionTransport: remoteExecution
|
||||||
|
? { remoteExecution: remoteExecution as unknown as Record<string, unknown> }
|
||||||
|
: undefined,
|
||||||
onLog,
|
onLog,
|
||||||
onMeta: onAdapterMeta,
|
onMeta: onAdapterMeta,
|
||||||
onSpawn: async (meta) => {
|
onSpawn: async (meta) => {
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,13 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin
|
||||||
const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {});
|
const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {});
|
||||||
if (parsed.success) {
|
if (parsed.success) {
|
||||||
return {
|
return {
|
||||||
|
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
||||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||||
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
enableEnvironments: false,
|
||||||
enableIsolatedWorkspaces: false,
|
enableIsolatedWorkspaces: false,
|
||||||
autoRestartDevServerWhenIdle: false,
|
autoRestartDevServerWhenIdle: false,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||||
gateProjectExecutionWorkspacePolicy,
|
gateProjectExecutionWorkspacePolicy,
|
||||||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||||
|
parseIssueExecutionWorkspaceSettings,
|
||||||
parseProjectExecutionWorkspacePolicy,
|
parseProjectExecutionWorkspacePolicy,
|
||||||
} from "./execution-workspace-policy.js";
|
} from "./execution-workspace-policy.js";
|
||||||
import { instanceSettingsService } from "./instance-settings.js";
|
import { instanceSettingsService } from "./instance-settings.js";
|
||||||
|
|
@ -2191,6 +2192,36 @@ export function issueService(db: Db) {
|
||||||
return dbOrTx === db ? db.transaction(runUpdate) : runUpdate(dbOrTx);
|
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) =>
|
remove: (id: string) =>
|
||||||
db.transaction(async (tx) => {
|
db.transaction(async (tx) => {
|
||||||
const attachmentAssetIds = await tx
|
const attachmentAssetIds = await tx
|
||||||
|
|
|
||||||
|
|
@ -523,6 +523,36 @@ export function projectService(db: Db) {
|
||||||
return enriched ?? null;
|
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) =>
|
remove: (id: string) =>
|
||||||
db
|
db
|
||||||
.delete(projects)
|
.delete(projects)
|
||||||
|
|
|
||||||
32
ui/src/api/environments.ts
Normal file
32
ui/src/api/environments.ts
Normal file
|
|
@ -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<Environment[]>(`/companies/${companyId}/environments`),
|
||||||
|
capabilities: (companyId: string) =>
|
||||||
|
api.get<EnvironmentCapabilities>(`/companies/${companyId}/environments/capabilities`),
|
||||||
|
lease: (leaseId: string) => api.get<EnvironmentLease>(`/environment-leases/${leaseId}`),
|
||||||
|
create: (companyId: string, body: {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
driver: "local" | "ssh";
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}) => api.post<Environment>(`/companies/${companyId}/environments`, body),
|
||||||
|
update: (environmentId: string, body: {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
driver?: "local" | "ssh";
|
||||||
|
status?: "active" | "archived";
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}) => api.patch<Environment>(`/environments/${environmentId}`, body),
|
||||||
|
probe: (environmentId: string) => api.post<EnvironmentProbeResult>(`/environments/${environmentId}/probe`, {}),
|
||||||
|
probeConfig: (companyId: string, body: {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
driver: "local" | "ssh";
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}) => api.post<EnvironmentProbeResult>(`/companies/${companyId}/environments/probe-config`, body),
|
||||||
|
};
|
||||||
|
|
@ -5,10 +5,13 @@ import type {
|
||||||
AdapterEnvironmentTestResult,
|
AdapterEnvironmentTestResult,
|
||||||
CompanySecret,
|
CompanySecret,
|
||||||
EnvBinding,
|
EnvBinding,
|
||||||
|
Environment,
|
||||||
} from "@paperclipai/shared";
|
} 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 type { AdapterModel } from "../api/agents";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
|
import { environmentsApi } from "../api/environments";
|
||||||
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { secretsApi } from "../api/secrets";
|
import { secretsApi } from "../api/secrets";
|
||||||
import { assetsApi } from "../api/assets";
|
import { assetsApi } from "../api/assets";
|
||||||
import {
|
import {
|
||||||
|
|
@ -186,7 +189,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
queryFn: () => secretsApi.list(selectedCompanyId!),
|
queryFn: () => secretsApi.list(selectedCompanyId!),
|
||||||
enabled: Boolean(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<Environment[]>({
|
||||||
|
queryKey: selectedCompanyId ? queryKeys.environments.list(selectedCompanyId) : ["environments", "none"],
|
||||||
|
queryFn: () => environmentsApi.list(selectedCompanyId!),
|
||||||
|
enabled: Boolean(selectedCompanyId) && environmentsEnabled,
|
||||||
|
});
|
||||||
const createSecret = useMutation({
|
const createSecret = useMutation({
|
||||||
mutationFn: (input: { name: string; value: string }) => {
|
mutationFn: (input: { name: string; value: string }) => {
|
||||||
if (!selectedCompanyId) throw new Error("Select a company to create secrets");
|
if (!selectedCompanyId) throw new Error("Select a company to create secrets");
|
||||||
|
|
@ -278,6 +292,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
const showLegacyWorkingDirectoryField =
|
const showLegacyWorkingDirectoryField =
|
||||||
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
|
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
|
||||||
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
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
|
// Fetch adapter models for the effective adapter type
|
||||||
const {
|
const {
|
||||||
|
|
@ -432,6 +454,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
heartbeat: mergedHeartbeat,
|
heartbeat: mergedHeartbeat,
|
||||||
};
|
};
|
||||||
}, [isCreate, overlay.heartbeat, runtimeConfig, val]);
|
}, [isCreate, overlay.heartbeat, runtimeConfig, val]);
|
||||||
|
const currentDefaultEnvironmentId = isCreate
|
||||||
|
? val!.defaultEnvironmentId ?? ""
|
||||||
|
: eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? "");
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative", cards && "space-y-6")}>
|
<div className={cn("relative", cards && "space-y-6")}>
|
||||||
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
||||||
|
|
@ -528,6 +553,42 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ---- Execution ---- */}
|
||||||
|
{environmentsEnabled ? (
|
||||||
|
<div className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
||||||
|
{cards
|
||||||
|
? <h3 className="text-sm font-medium mb-3">Execution</h3>
|
||||||
|
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Execution</div>
|
||||||
|
}
|
||||||
|
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
||||||
|
<Field
|
||||||
|
label="Default environment"
|
||||||
|
hint="Agent-level default execution target. Project and issue settings can still override this."
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={currentDefaultEnvironmentId}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value;
|
||||||
|
if (isCreate) {
|
||||||
|
set!({ defaultEnvironmentId: nextValue });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mark("identity", "defaultEnvironmentId", nextValue || null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Company default (Local)</option>
|
||||||
|
{runnableEnvironments.map((environment) => (
|
||||||
|
<option key={environment.id} value={environment.id}>
|
||||||
|
{environment.name} · {environment.driver}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* ---- Adapter ---- */}
|
{/* ---- Adapter ---- */}
|
||||||
<div className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
<div className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
||||||
<div className={cn(cards ? "flex items-center justify-between mb-3" : "px-4 py-2 flex items-center justify-between gap-2")}>
|
<div className={cn(cards ? "flex items-center justify-between mb-3" : "px-4 py-2 flex items-center justify-between gap-2")}>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Project } from "@paperclipai/shared";
|
import type { Project } from "@paperclipai/shared";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { StatusBadge } from "./StatusBadge";
|
||||||
import { cn, formatDate } from "../lib/utils";
|
import { cn, formatDate } from "../lib/utils";
|
||||||
|
import { environmentsApi } from "../api/environments";
|
||||||
import { goalsApi } from "../api/goals";
|
import { goalsApi } from "../api/goals";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
|
|
@ -48,6 +49,7 @@ export type ProjectConfigFieldKey =
|
||||||
| "env"
|
| "env"
|
||||||
| "execution_workspace_enabled"
|
| "execution_workspace_enabled"
|
||||||
| "execution_workspace_default_mode"
|
| "execution_workspace_default_mode"
|
||||||
|
| "execution_workspace_environment"
|
||||||
| "execution_workspace_base_ref"
|
| "execution_workspace_base_ref"
|
||||||
| "execution_workspace_branch_template"
|
| "execution_workspace_branch_template"
|
||||||
| "execution_workspace_worktree_parent_dir"
|
| "execution_workspace_worktree_parent_dir"
|
||||||
|
|
@ -248,6 +250,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
const environmentsEnabled = experimentalSettings?.enableEnvironments === true;
|
||||||
const { data: availableSecrets = [] } = useQuery({
|
const { data: availableSecrets = [] } = useQuery({
|
||||||
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
|
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
|
||||||
queryFn: () => secretsApi.list(selectedCompanyId!),
|
queryFn: () => secretsApi.list(selectedCompanyId!),
|
||||||
|
|
@ -263,6 +266,11 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
|
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
|
const linkedGoalIds = project.goalIds.length > 0
|
||||||
? project.goalIds
|
? project.goalIds
|
||||||
|
|
@ -287,12 +295,16 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||||
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||||
const executionWorkspaceDefaultMode =
|
const executionWorkspaceDefaultMode =
|
||||||
executionWorkspacePolicy?.defaultMode === "isolated_workspace" ? "isolated_workspace" : "shared_workspace";
|
executionWorkspacePolicy?.defaultMode === "isolated_workspace" ? "isolated_workspace" : "shared_workspace";
|
||||||
|
const executionWorkspaceEnvironmentId = executionWorkspacePolicy?.environmentId ?? "";
|
||||||
const executionWorkspaceStrategy = executionWorkspacePolicy?.workspaceStrategy ?? {
|
const executionWorkspaceStrategy = executionWorkspacePolicy?.workspaceStrategy ?? {
|
||||||
type: "git_worktree",
|
type: "git_worktree",
|
||||||
baseRef: "",
|
baseRef: "",
|
||||||
branchTemplate: "",
|
branchTemplate: "",
|
||||||
worktreeParentDir: "",
|
worktreeParentDir: "",
|
||||||
};
|
};
|
||||||
|
const runSelectableEnvironments = (environments ?? []).filter((environment) =>
|
||||||
|
environment.driver === "local" || environment.driver === "ssh"
|
||||||
|
);
|
||||||
|
|
||||||
const invalidateProject = () => {
|
const invalidateProject = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
|
||||||
|
|
@ -985,6 +997,34 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Host-managed implementation: <span className="text-foreground">Git worktree</span>
|
Host-managed implementation: <span className="text-foreground">Git worktree</span>
|
||||||
</div>
|
</div>
|
||||||
|
{environmentsEnabled ? (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>Environment</span>
|
||||||
|
<SaveIndicator state={fieldState("execution_workspace_environment")} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
|
||||||
|
value={executionWorkspaceEnvironmentId}
|
||||||
|
onChange={(e) =>
|
||||||
|
commitField(
|
||||||
|
"execution_workspace_environment",
|
||||||
|
updateExecutionWorkspacePolicy({
|
||||||
|
environmentId: e.target.value || null,
|
||||||
|
})!,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">No environment</option>
|
||||||
|
{runSelectableEnvironments.map((environment) => (
|
||||||
|
<option key={environment.id} value={environment.id}>
|
||||||
|
{environment.name} · {environment.driver}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 flex items-center gap-1.5">
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ function makeAgent(id: string, name: string): Agent {
|
||||||
adapterType: "process",
|
adapterType: "process",
|
||||||
adapterConfig: {},
|
adapterConfig: {},
|
||||||
runtimeConfig: {},
|
runtimeConfig: {},
|
||||||
|
defaultEnvironmentId: null,
|
||||||
budgetMonthlyCents: 0,
|
budgetMonthlyCents: 0,
|
||||||
spentMonthlyCents: 0,
|
spentMonthlyCents: 0,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
|
|
|
||||||
44
ui/src/lib/new-agent-hire-payload.test.ts
Normal file
44
ui/src/lib/new-agent-hire-payload.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildNewAgentHirePayload } from "./new-agent-hire-payload";
|
||||||
|
import { defaultCreateValues } from "../components/agent-config-defaults";
|
||||||
|
|
||||||
|
describe("buildNewAgentHirePayload", () => {
|
||||||
|
it("persists the selected default environment id", () => {
|
||||||
|
expect(
|
||||||
|
buildNewAgentHirePayload({
|
||||||
|
name: "Linux Claude",
|
||||||
|
effectiveRole: "general",
|
||||||
|
configValues: {
|
||||||
|
...defaultCreateValues,
|
||||||
|
adapterType: "claude_local",
|
||||||
|
defaultEnvironmentId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
},
|
||||||
|
adapterConfig: { foo: "bar" },
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
name: "Linux Claude",
|
||||||
|
role: "general",
|
||||||
|
adapterType: "claude_local",
|
||||||
|
defaultEnvironmentId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
adapterConfig: { foo: "bar" },
|
||||||
|
budgetMonthlyCents: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends null when no default environment is selected", () => {
|
||||||
|
expect(
|
||||||
|
buildNewAgentHirePayload({
|
||||||
|
name: "Local Claude",
|
||||||
|
effectiveRole: "general",
|
||||||
|
configValues: {
|
||||||
|
...defaultCreateValues,
|
||||||
|
adapterType: "claude_local",
|
||||||
|
},
|
||||||
|
adapterConfig: {},
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
defaultEnvironmentId: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
38
ui/src/lib/new-agent-hire-payload.ts
Normal file
38
ui/src/lib/new-agent-hire-payload.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import type { CreateConfigValues } from "../components/AgentConfigForm";
|
||||||
|
import { buildNewAgentRuntimeConfig } from "./new-agent-runtime-config";
|
||||||
|
|
||||||
|
export function buildNewAgentHirePayload(input: {
|
||||||
|
name: string;
|
||||||
|
effectiveRole: string;
|
||||||
|
title?: string;
|
||||||
|
reportsTo?: string | null;
|
||||||
|
selectedSkillKeys?: string[];
|
||||||
|
configValues: CreateConfigValues;
|
||||||
|
adapterConfig: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
effectiveRole,
|
||||||
|
title,
|
||||||
|
reportsTo,
|
||||||
|
selectedSkillKeys = [],
|
||||||
|
configValues,
|
||||||
|
adapterConfig,
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: name.trim(),
|
||||||
|
role: effectiveRole,
|
||||||
|
...(title?.trim() ? { title: title.trim() } : {}),
|
||||||
|
...(reportsTo ? { reportsTo } : {}),
|
||||||
|
...(selectedSkillKeys.length > 0 ? { desiredSkills: selectedSkillKeys } : {}),
|
||||||
|
adapterType: configValues.adapterType,
|
||||||
|
defaultEnvironmentId: configValues.defaultEnvironmentId ?? null,
|
||||||
|
adapterConfig,
|
||||||
|
runtimeConfig: buildNewAgentRuntimeConfig({
|
||||||
|
heartbeatEnabled: configValues.heartbeatEnabled,
|
||||||
|
intervalSec: configValues.intervalSec,
|
||||||
|
}),
|
||||||
|
budgetMonthlyCents: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -73,6 +73,9 @@ export const queryKeys = {
|
||||||
closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const,
|
closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const,
|
||||||
workspaceOperations: (id: string) => ["execution-workspaces", "workspace-operations", id] as const,
|
workspaceOperations: (id: string) => ["execution-workspaces", "workspace-operations", id] as const,
|
||||||
},
|
},
|
||||||
|
environments: {
|
||||||
|
list: (companyId: string) => ["environments", companyId] as const,
|
||||||
|
},
|
||||||
projects: {
|
projects: {
|
||||||
list: (companyId: string) => ["projects", companyId] as const,
|
list: (companyId: string) => ["projects", companyId] as const,
|
||||||
detail: (id: string) => ["projects", "detail", id] as const,
|
detail: (id: string) => ["projects", "detail", id] as const,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ const permissionLabels: Record<PermissionKey, string> = {
|
||||||
"tasks:assign_scope": "Assign scoped tasks",
|
"tasks:assign_scope": "Assign scoped tasks",
|
||||||
"tasks:manage_active_checkouts": "Manage active task checkouts",
|
"tasks:manage_active_checkouts": "Manage active task checkouts",
|
||||||
"joins:approve": "Approve join requests",
|
"joins:approve": "Approve join requests",
|
||||||
|
"environments:manage": "Manage environments",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatGrantSummary(member: CompanyMember) {
|
function formatGrantSummary(member: CompanyMember) {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,22 @@
|
||||||
import { ChangeEvent, useEffect, useState } from "react";
|
import { ChangeEvent, useEffect, useState } from "react";
|
||||||
import { Link } from "@/lib/router";
|
import { Link } from "@/lib/router";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION } from "@paperclipai/shared";
|
import {
|
||||||
|
AGENT_ADAPTER_TYPES,
|
||||||
|
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
|
||||||
|
getAdapterEnvironmentSupport,
|
||||||
|
type Environment,
|
||||||
|
type EnvironmentProbeResult,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useToastActions } from "../context/ToastContext";
|
import { useToastActions } from "../context/ToastContext";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
import { accessApi } from "../api/access";
|
import { accessApi } from "../api/access";
|
||||||
import { assetsApi } from "../api/assets";
|
import { assetsApi } from "../api/assets";
|
||||||
|
import { environmentsApi } from "../api/environments";
|
||||||
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
|
import { secretsApi } from "../api/secrets";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Settings, Check, Download, Upload } from "lucide-react";
|
import { Settings, Check, Download, Upload } from "lucide-react";
|
||||||
|
|
@ -15,7 +24,8 @@ import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
ToggleField,
|
ToggleField,
|
||||||
HintIcon
|
HintIcon,
|
||||||
|
adapterLabels,
|
||||||
} from "../components/agent-config-primitives";
|
} from "../components/agent-config-primitives";
|
||||||
|
|
||||||
type AgentSnippetInput = {
|
type AgentSnippetInput = {
|
||||||
|
|
@ -24,6 +34,104 @@ type AgentSnippetInput = {
|
||||||
testResolutionUrl?: string | null;
|
testResolutionUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EnvironmentFormState = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
driver: "local" | "ssh";
|
||||||
|
sshHost: string;
|
||||||
|
sshPort: string;
|
||||||
|
sshUsername: string;
|
||||||
|
sshRemoteWorkspacePath: string;
|
||||||
|
sshPrivateKey: string;
|
||||||
|
sshPrivateKeySecretId: string;
|
||||||
|
sshKnownHosts: string;
|
||||||
|
sshStrictHostKeyChecking: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENVIRONMENT_SUPPORT_ROWS = AGENT_ADAPTER_TYPES.map((adapterType) => ({
|
||||||
|
adapterType,
|
||||||
|
support: getAdapterEnvironmentSupport(adapterType),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function buildEnvironmentPayload(form: EnvironmentFormState) {
|
||||||
|
return {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim() || null,
|
||||||
|
driver: form.driver,
|
||||||
|
config:
|
||||||
|
form.driver === "ssh"
|
||||||
|
? {
|
||||||
|
host: form.sshHost.trim(),
|
||||||
|
port: Number.parseInt(form.sshPort || "22", 10) || 22,
|
||||||
|
username: form.sshUsername.trim(),
|
||||||
|
remoteWorkspacePath: form.sshRemoteWorkspacePath.trim(),
|
||||||
|
privateKey: form.sshPrivateKey.trim() || null,
|
||||||
|
privateKeySecretRef:
|
||||||
|
form.sshPrivateKey.trim().length > 0 || !form.sshPrivateKeySecretId
|
||||||
|
? null
|
||||||
|
: { type: "secret_ref" as const, secretId: form.sshPrivateKeySecretId, version: "latest" as const },
|
||||||
|
knownHosts: form.sshKnownHosts.trim() || null,
|
||||||
|
strictHostKeyChecking: form.sshStrictHostKeyChecking,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyEnvironmentForm(): EnvironmentFormState {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
driver: "ssh",
|
||||||
|
sshHost: "",
|
||||||
|
sshPort: "22",
|
||||||
|
sshUsername: "",
|
||||||
|
sshRemoteWorkspacePath: "",
|
||||||
|
sshPrivateKey: "",
|
||||||
|
sshPrivateKeySecretId: "",
|
||||||
|
sshKnownHosts: "",
|
||||||
|
sshStrictHostKeyChecking: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSshConfig(environment: Environment) {
|
||||||
|
const config = environment.config ?? {};
|
||||||
|
return {
|
||||||
|
host: typeof config.host === "string" ? config.host : "",
|
||||||
|
port:
|
||||||
|
typeof config.port === "number"
|
||||||
|
? String(config.port)
|
||||||
|
: typeof config.port === "string"
|
||||||
|
? config.port
|
||||||
|
: "22",
|
||||||
|
username: typeof config.username === "string" ? config.username : "",
|
||||||
|
remoteWorkspacePath: typeof config.remoteWorkspacePath === "string" ? config.remoteWorkspacePath : "",
|
||||||
|
privateKey: "",
|
||||||
|
privateKeySecretId:
|
||||||
|
config.privateKeySecretRef &&
|
||||||
|
typeof config.privateKeySecretRef === "object" &&
|
||||||
|
!Array.isArray(config.privateKeySecretRef) &&
|
||||||
|
typeof (config.privateKeySecretRef as { secretId?: unknown }).secretId === "string"
|
||||||
|
? String((config.privateKeySecretRef as { secretId: string }).secretId)
|
||||||
|
: "",
|
||||||
|
knownHosts: typeof config.knownHosts === "string" ? config.knownHosts : "",
|
||||||
|
strictHostKeyChecking:
|
||||||
|
typeof config.strictHostKeyChecking === "boolean"
|
||||||
|
? config.strictHostKeyChecking
|
||||||
|
: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function SupportMark({ supported }: { supported: boolean }) {
|
||||||
|
return supported ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-green-700 dark:text-green-400">
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
Yes
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">No</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
||||||
|
|
||||||
export function CompanySettings() {
|
export function CompanySettings() {
|
||||||
|
|
@ -42,6 +150,9 @@ export function CompanySettings() {
|
||||||
const [brandColor, setBrandColor] = useState("");
|
const [brandColor, setBrandColor] = useState("");
|
||||||
const [logoUrl, setLogoUrl] = useState("");
|
const [logoUrl, setLogoUrl] = useState("");
|
||||||
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
||||||
|
const [editingEnvironmentId, setEditingEnvironmentId] = useState<string | null>(null);
|
||||||
|
const [environmentForm, setEnvironmentForm] = useState<EnvironmentFormState>(createEmptyEnvironmentForm);
|
||||||
|
const [probeResults, setProbeResults] = useState<Record<string, EnvironmentProbeResult | null>>({});
|
||||||
|
|
||||||
// Sync local state from selected company
|
// Sync local state from selected company
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -57,6 +168,30 @@ export function CompanySettings() {
|
||||||
const [snippetCopied, setSnippetCopied] = useState(false);
|
const [snippetCopied, setSnippetCopied] = useState(false);
|
||||||
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
|
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
|
||||||
|
|
||||||
|
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 { data: environmentCapabilities } = useQuery({
|
||||||
|
queryKey: selectedCompanyId ? ["environment-capabilities", selectedCompanyId] : ["environment-capabilities", "none"],
|
||||||
|
queryFn: () => environmentsApi.capabilities(selectedCompanyId!),
|
||||||
|
enabled: Boolean(selectedCompanyId) && environmentsEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: secrets } = useQuery({
|
||||||
|
queryKey: selectedCompanyId ? ["company-secrets", selectedCompanyId] : ["company-secrets", "none"],
|
||||||
|
queryFn: () => secretsApi.list(selectedCompanyId!),
|
||||||
|
enabled: Boolean(selectedCompanyId) && environmentsEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
const generalDirty =
|
const generalDirty =
|
||||||
!!selectedCompany &&
|
!!selectedCompany &&
|
||||||
(companyName !== selectedCompany.name ||
|
(companyName !== selectedCompany.name ||
|
||||||
|
|
@ -182,6 +317,90 @@ export function CompanySettings() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const environmentMutation = useMutation({
|
||||||
|
mutationFn: async (form: EnvironmentFormState) => {
|
||||||
|
const body = buildEnvironmentPayload(form);
|
||||||
|
|
||||||
|
if (editingEnvironmentId) {
|
||||||
|
return await environmentsApi.update(editingEnvironmentId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await environmentsApi.create(selectedCompanyId!, body);
|
||||||
|
},
|
||||||
|
onSuccess: async (environment) => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.environments.list(selectedCompanyId!),
|
||||||
|
});
|
||||||
|
setEditingEnvironmentId(null);
|
||||||
|
setEnvironmentForm(createEmptyEnvironmentForm());
|
||||||
|
pushToast({
|
||||||
|
title: editingEnvironmentId ? "Environment updated" : "Environment created",
|
||||||
|
body: `${environment.name} is ready.`,
|
||||||
|
tone: "success",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
pushToast({
|
||||||
|
title: "Failed to save environment",
|
||||||
|
body: error instanceof Error ? error.message : "Environment save failed.",
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const environmentProbeMutation = useMutation({
|
||||||
|
mutationFn: async (environmentId: string) => await environmentsApi.probe(environmentId),
|
||||||
|
onSuccess: (probe, environmentId) => {
|
||||||
|
setProbeResults((current) => ({
|
||||||
|
...current,
|
||||||
|
[environmentId]: probe,
|
||||||
|
}));
|
||||||
|
pushToast({
|
||||||
|
title: probe.ok ? "Environment probe passed" : "Environment probe failed",
|
||||||
|
body: probe.summary,
|
||||||
|
tone: probe.ok ? "success" : "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error, environmentId) => {
|
||||||
|
const failedEnvironment = (environments ?? []).find((environment) => environment.id === environmentId);
|
||||||
|
setProbeResults((current) => ({
|
||||||
|
...current,
|
||||||
|
[environmentId]: {
|
||||||
|
ok: false,
|
||||||
|
driver: failedEnvironment?.driver ?? "local",
|
||||||
|
summary: error instanceof Error ? error.message : "Environment probe failed.",
|
||||||
|
details: null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
pushToast({
|
||||||
|
title: "Environment probe failed",
|
||||||
|
body: error instanceof Error ? error.message : "Environment probe failed.",
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const draftEnvironmentProbeMutation = useMutation({
|
||||||
|
mutationFn: async (form: EnvironmentFormState) => {
|
||||||
|
const body = buildEnvironmentPayload(form);
|
||||||
|
return await environmentsApi.probeConfig(selectedCompanyId!, body);
|
||||||
|
},
|
||||||
|
onSuccess: (probe) => {
|
||||||
|
pushToast({
|
||||||
|
title: probe.ok ? "Draft probe passed" : "Draft probe failed",
|
||||||
|
body: probe.summary,
|
||||||
|
tone: probe.ok ? "success" : "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
pushToast({
|
||||||
|
title: "Draft probe failed",
|
||||||
|
body: error instanceof Error ? error.message : "Environment probe failed.",
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function handleLogoFileChange(event: ChangeEvent<HTMLInputElement>) {
|
function handleLogoFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0] ?? null;
|
const file = event.target.files?.[0] ?? null;
|
||||||
event.currentTarget.value = "";
|
event.currentTarget.value = "";
|
||||||
|
|
@ -199,6 +418,9 @@ export function CompanySettings() {
|
||||||
setInviteSnippet(null);
|
setInviteSnippet(null);
|
||||||
setSnippetCopied(false);
|
setSnippetCopied(false);
|
||||||
setSnippetCopyDelightId(0);
|
setSnippetCopyDelightId(0);
|
||||||
|
setEditingEnvironmentId(null);
|
||||||
|
setEnvironmentForm(createEmptyEnvironmentForm());
|
||||||
|
setProbeResults({});
|
||||||
}, [selectedCompanyId]);
|
}, [selectedCompanyId]);
|
||||||
|
|
||||||
const archiveMutation = useMutation({
|
const archiveMutation = useMutation({
|
||||||
|
|
@ -245,6 +467,49 @@ export function CompanySettings() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEditEnvironment(environment: Environment) {
|
||||||
|
setEditingEnvironmentId(environment.id);
|
||||||
|
if (environment.driver === "ssh") {
|
||||||
|
const ssh = readSshConfig(environment);
|
||||||
|
setEnvironmentForm({
|
||||||
|
...createEmptyEnvironmentForm(),
|
||||||
|
name: environment.name,
|
||||||
|
description: environment.description ?? "",
|
||||||
|
driver: "ssh",
|
||||||
|
sshHost: ssh.host,
|
||||||
|
sshPort: ssh.port,
|
||||||
|
sshUsername: ssh.username,
|
||||||
|
sshRemoteWorkspacePath: ssh.remoteWorkspacePath,
|
||||||
|
sshPrivateKey: ssh.privateKey,
|
||||||
|
sshPrivateKeySecretId: ssh.privateKeySecretId,
|
||||||
|
sshKnownHosts: ssh.knownHosts,
|
||||||
|
sshStrictHostKeyChecking: ssh.strictHostKeyChecking,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnvironmentForm({
|
||||||
|
...createEmptyEnvironmentForm(),
|
||||||
|
name: environment.name,
|
||||||
|
description: environment.description ?? "",
|
||||||
|
driver: "local",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelEnvironmentEdit() {
|
||||||
|
setEditingEnvironmentId(null);
|
||||||
|
setEnvironmentForm(createEmptyEnvironmentForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
const environmentFormValid =
|
||||||
|
environmentForm.name.trim().length > 0 &&
|
||||||
|
(environmentForm.driver !== "ssh" ||
|
||||||
|
(
|
||||||
|
environmentForm.sshHost.trim().length > 0 &&
|
||||||
|
environmentForm.sshUsername.trim().length > 0 &&
|
||||||
|
environmentForm.sshRemoteWorkspacePath.trim().length > 0
|
||||||
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="max-w-2xl space-y-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -401,6 +666,290 @@ export function CompanySettings() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{environmentsEnabled ? (
|
||||||
|
<div className="space-y-4" data-testid="company-settings-environments-section">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Environments
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 rounded-md border border-border px-4 py-4">
|
||||||
|
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
Environment choices use the same adapter support matrix as agent defaults. SSH environments
|
||||||
|
are available for remote-managed adapters.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[34rem] text-left text-xs">
|
||||||
|
<caption className="sr-only">Environment support by adapter</caption>
|
||||||
|
<thead className="border-b border-border text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 pr-3 font-medium">Adapter</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Local</th>
|
||||||
|
<th className="px-3 py-2 font-medium">SSH</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/60">
|
||||||
|
{(environmentCapabilities?.adapters.map((support) => ({
|
||||||
|
adapterType: support.adapterType,
|
||||||
|
support,
|
||||||
|
})) ?? ENVIRONMENT_SUPPORT_ROWS).map(({ adapterType, support }) => (
|
||||||
|
<tr key={adapterType}>
|
||||||
|
<td className="py-2 pr-3 font-medium">
|
||||||
|
{adapterLabels[adapterType] ?? adapterType}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<SupportMark supported={support.drivers.local === "supported"} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<SupportMark supported={support.drivers.ssh === "supported"} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(environments ?? []).length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">No environments saved for this company yet.</div>
|
||||||
|
) : (
|
||||||
|
(environments ?? []).map((environment) => {
|
||||||
|
const probe = probeResults[environment.id] ?? null;
|
||||||
|
const isEditing = editingEnvironmentId === environment.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={environment.id}
|
||||||
|
className="rounded-md border border-border/70 px-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{environment.name} <span className="text-muted-foreground">· {environment.driver}</span>
|
||||||
|
</div>
|
||||||
|
{environment.description ? (
|
||||||
|
<div className="text-xs text-muted-foreground">{environment.description}</div>
|
||||||
|
) : null}
|
||||||
|
{environment.driver === "ssh" ? (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{typeof environment.config.host === "string" ? environment.config.host : "SSH host"} ·{" "}
|
||||||
|
{typeof environment.config.username === "string" ? environment.config.username : "user"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-muted-foreground">Runs on this Paperclip host.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{environment.driver !== "local" ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => environmentProbeMutation.mutate(environment.id)}
|
||||||
|
disabled={environmentProbeMutation.isPending}
|
||||||
|
>
|
||||||
|
{environmentProbeMutation.isPending
|
||||||
|
? "Testing..."
|
||||||
|
: "Test connection"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleEditEnvironment(environment)}
|
||||||
|
>
|
||||||
|
{isEditing ? "Editing" : "Edit"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{probe ? (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
probe.ok
|
||||||
|
? "mt-3 rounded border border-green-500/30 bg-green-500/5 px-2.5 py-2 text-xs text-green-700"
|
||||||
|
: "mt-3 rounded border border-destructive/30 bg-destructive/5 px-2.5 py-2 text-xs text-destructive"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{probe.summary}</div>
|
||||||
|
{probe.details?.error && typeof probe.details.error === "string" ? (
|
||||||
|
<div className="mt-1 font-mono text-[11px]">{probe.details.error}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-4">
|
||||||
|
<div className="mb-3 text-sm font-medium">
|
||||||
|
{editingEnvironmentId ? "Edit environment" : "Add environment"}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="Name" hint="Operator-facing name for this execution target.">
|
||||||
|
<input
|
||||||
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||||
|
type="text"
|
||||||
|
value={environmentForm.name}
|
||||||
|
onChange={(e) => setEnvironmentForm((current) => ({ ...current, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Description" hint="Optional note about what this machine is for.">
|
||||||
|
<input
|
||||||
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||||
|
type="text"
|
||||||
|
value={environmentForm.description}
|
||||||
|
onChange={(e) => setEnvironmentForm((current) => ({ ...current, description: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Driver" hint="Local runs on this host. SSH stores a remote machine target.">
|
||||||
|
<select
|
||||||
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||||
|
value={environmentForm.driver}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEnvironmentForm((current) => ({
|
||||||
|
...current,
|
||||||
|
driver: e.target.value === "local" ? "local" : "ssh",
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<option value="ssh">SSH</option>
|
||||||
|
<option value="local">Local</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{environmentForm.driver === "ssh" ? (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<Field label="Host" hint="DNS name or IP address for the remote machine.">
|
||||||
|
<input
|
||||||
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||||
|
type="text"
|
||||||
|
value={environmentForm.sshHost}
|
||||||
|
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshHost: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Port" hint="Defaults to 22.">
|
||||||
|
<input
|
||||||
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
value={environmentForm.sshPort}
|
||||||
|
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshPort: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Username" hint="SSH login user.">
|
||||||
|
<input
|
||||||
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||||
|
type="text"
|
||||||
|
value={environmentForm.sshUsername}
|
||||||
|
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshUsername: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Remote workspace path" hint="Absolute path that Paperclip will verify during SSH connection tests.">
|
||||||
|
<input
|
||||||
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||||
|
type="text"
|
||||||
|
placeholder="/Users/paperclip/workspace"
|
||||||
|
value={environmentForm.sshRemoteWorkspacePath}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEnvironmentForm((current) => ({ ...current, sshRemoteWorkspacePath: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Private key" hint="Optional PEM private key. Leave blank to rely on the server's SSH agent or default keychain.">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<select
|
||||||
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||||
|
value={environmentForm.sshPrivateKeySecretId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEnvironmentForm((current) => ({
|
||||||
|
...current,
|
||||||
|
sshPrivateKeySecretId: e.target.value,
|
||||||
|
sshPrivateKey: e.target.value ? "" : current.sshPrivateKey,
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<option value="">No saved secret</option>
|
||||||
|
{(secrets ?? []).map((secret) => (
|
||||||
|
<option key={secret.id} value={secret.id}>{secret.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
className="h-32 w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-xs font-mono outline-none"
|
||||||
|
value={environmentForm.sshPrivateKey}
|
||||||
|
disabled={!!environmentForm.sshPrivateKeySecretId}
|
||||||
|
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshPrivateKey: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Known hosts" hint="Optional known_hosts block used when strict host key checking is enabled.">
|
||||||
|
<textarea
|
||||||
|
className="h-32 w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-xs font-mono outline-none"
|
||||||
|
value={environmentForm.sshKnownHosts}
|
||||||
|
onChange={(e) => setEnvironmentForm((current) => ({ ...current, sshKnownHosts: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<ToggleField
|
||||||
|
label="Strict host key checking"
|
||||||
|
hint="Keep this on unless you deliberately want probe-time host key acceptance disabled."
|
||||||
|
checked={environmentForm.sshStrictHostKeyChecking}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setEnvironmentForm((current) => ({ ...current, sshStrictHostKeyChecking: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => environmentMutation.mutate(environmentForm)}
|
||||||
|
disabled={environmentMutation.isPending || !environmentFormValid}
|
||||||
|
>
|
||||||
|
{environmentMutation.isPending
|
||||||
|
? editingEnvironmentId
|
||||||
|
? "Saving..."
|
||||||
|
: "Creating..."
|
||||||
|
: editingEnvironmentId
|
||||||
|
? "Save environment"
|
||||||
|
: "Create environment"}
|
||||||
|
</Button>
|
||||||
|
{editingEnvironmentId ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleCancelEnvironmentEdit}
|
||||||
|
disabled={environmentMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{environmentForm.driver !== "local" ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => draftEnvironmentProbeMutation.mutate(environmentForm)}
|
||||||
|
disabled={draftEnvironmentProbeMutation.isPending || !environmentFormValid}
|
||||||
|
>
|
||||||
|
{draftEnvironmentProbeMutation.isPending ? "Testing..." : "Test draft"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{environmentMutation.isError ? (
|
||||||
|
<span className="text-xs text-destructive">
|
||||||
|
{environmentMutation.error instanceof Error
|
||||||
|
? environmentMutation.error.message
|
||||||
|
: "Failed to save environment"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{draftEnvironmentProbeMutation.data ? (
|
||||||
|
<span className={draftEnvironmentProbeMutation.data.ok ? "text-xs text-green-600" : "text-xs text-destructive"}>
|
||||||
|
{draftEnvironmentProbeMutation.data.summary}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Hiring */}
|
{/* Hiring */}
|
||||||
<div className="space-y-4" data-testid="company-settings-team-section">
|
<div className="space-y-4" data-testid="company-settings-team-section">
|
||||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { FlaskConical } from "lucide-react";
|
import { FlaskConical } from "lucide-react";
|
||||||
|
import type { PatchInstanceExperimentalSettings } from "@paperclipai/shared";
|
||||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
|
@ -24,7 +25,7 @@ export function InstanceExperimentalSettings() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleMutation = useMutation({
|
const toggleMutation = useMutation({
|
||||||
mutationFn: async (patch: { enableIsolatedWorkspaces?: boolean; autoRestartDevServerWhenIdle?: boolean }) =>
|
mutationFn: async (patch: PatchInstanceExperimentalSettings) =>
|
||||||
instanceSettingsApi.updateExperimental(patch),
|
instanceSettingsApi.updateExperimental(patch),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
|
|
@ -52,6 +53,7 @@ export function InstanceExperimentalSettings() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
|
||||||
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
||||||
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
|
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
|
||||||
|
|
||||||
|
|
@ -73,6 +75,24 @@ export function InstanceExperimentalSettings() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-border bg-card p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-sm font-semibold">Enable Environments</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Show environment management in company settings and allow project and agent environment assignment
|
||||||
|
controls.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={enableEnvironments}
|
||||||
|
onCheckedChange={() => toggleMutation.mutate({ enableEnvironments: !enableEnvironments })}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
aria-label="Toggle environments experimental setting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-border bg-card p-5">
|
<section className="rounded-xl border border-border bg-card p-5">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import { getUIAdapter, listUIAdapters } from "../adapters";
|
||||||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||||
import { isValidAdapterType } from "../adapters/metadata";
|
import { isValidAdapterType } from "../adapters/metadata";
|
||||||
import { ReportsToPicker } from "../components/ReportsToPicker";
|
import { ReportsToPicker } from "../components/ReportsToPicker";
|
||||||
import { buildNewAgentRuntimeConfig } from "../lib/new-agent-runtime-config";
|
import { buildNewAgentHirePayload } from "../lib/new-agent-hire-payload";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||||
DEFAULT_CODEX_LOCAL_MODEL,
|
DEFAULT_CODEX_LOCAL_MODEL,
|
||||||
|
|
@ -168,20 +168,17 @@ export function NewAgent() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createAgent.mutate({
|
createAgent.mutate(
|
||||||
name: name.trim(),
|
buildNewAgentHirePayload({
|
||||||
role: effectiveRole,
|
name,
|
||||||
...(title.trim() ? { title: title.trim() } : {}),
|
effectiveRole,
|
||||||
...(reportsTo ? { reportsTo } : {}),
|
title,
|
||||||
...(selectedSkillKeys.length > 0 ? { desiredSkills: selectedSkillKeys } : {}),
|
reportsTo,
|
||||||
adapterType: configValues.adapterType,
|
selectedSkillKeys,
|
||||||
adapterConfig: buildAdapterConfig(),
|
configValues,
|
||||||
runtimeConfig: buildNewAgentRuntimeConfig({
|
adapterConfig: buildAdapterConfig(),
|
||||||
heartbeatEnabled: configValues.heartbeatEnabled,
|
|
||||||
intervalSec: configValues.intervalSec,
|
|
||||||
}),
|
}),
|
||||||
budgetMonthlyCents: 0,
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableSkills = (companySkills ?? []).filter((skill) => !skill.key.startsWith("paperclipai/paperclip/"));
|
const availableSkills = (companySkills ?? []).filter((skill) => !skill.key.startsWith("paperclipai/paperclip/"));
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,12 @@ export default defineConfig({
|
||||||
"packages/shared",
|
"packages/shared",
|
||||||
"packages/db",
|
"packages/db",
|
||||||
"packages/adapter-utils",
|
"packages/adapter-utils",
|
||||||
|
"packages/adapters/claude-local",
|
||||||
"packages/adapters/codex-local",
|
"packages/adapters/codex-local",
|
||||||
|
"packages/adapters/cursor-local",
|
||||||
|
"packages/adapters/gemini-local",
|
||||||
"packages/adapters/opencode-local",
|
"packages/adapters/opencode-local",
|
||||||
|
"packages/adapters/pi-local",
|
||||||
"server",
|
"server",
|
||||||
"ui",
|
"ui",
|
||||||
"cli",
|
"cli",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue