From 9b99d3033059fb85173b6d026409f2d7d04e660a Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 29 Apr 2026 15:56:13 -0700 Subject: [PATCH] Add dedicated environment settings page and test-in-environment (#4798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Agents run inside environments (local, SSH, E2B sandbox) > - Operators need to configure and manage these environments > - But environment settings were buried inside the general company settings page, making them hard to find > - Additionally, when testing an agent from the configuration form, the test always ran locally regardless of which environment was selected > - This PR moves environments into a dedicated top-level company settings section and wires the "Test Environment" button to run inside the selected environment > - The benefit is operators can find and manage environments more easily, and the test button now validates the actual environment the agent will use ## What Changed - Added a dedicated `CompanyEnvironments` settings page with its own route and sidebar entry - Updated `CompanySettingsSidebar` and `CompanySettingsNav` to include the new environments section - Modified the agent test route (`POST /agents/:id/test`) to accept an optional `environmentId` parameter - Updated all adapter `test.ts` handlers to resolve and use the specified execution target environment - Added `resolveTestExecutionTarget` to `execution-target.ts` for remote environment test resolution with cwd fallback - Moved the "Test Environment" button and its feedback display into the `NewAgent` page footer for better UX flow ## Verification - `pnpm test` — all existing and new tests pass - `pnpm typecheck` — clean - Manual: navigate to Company Settings, confirm "Environments" appears as a top-level section - Manual: configure an agent with a non-local environment, click "Test Environment", confirm the test runs inside that environment ## Risks - Low risk. UI-only routing change for the settings page. The test-in-environment change adds an optional parameter with a local fallback, so existing behavior is preserved when no environment is specified. ## Model Used Codex GPT 5.4 high via Paperclip. ## 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 - [x] 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 --- .../src/execution-target.test.ts | 47 + .../adapter-utils/src/execution-target.ts | 69 ++ packages/adapter-utils/src/types.ts | 14 + .../adapters/claude-local/src/server/test.ts | 54 +- .../adapters/codex-local/src/server/test.ts | 45 +- .../adapters/cursor-local/src/server/test.ts | 43 +- .../adapters/gemini-local/src/server/test.ts | 45 +- .../opencode-local/src/server/test.ts | 57 +- packages/adapters/pi-local/src/server/test.ts | 52 +- packages/shared/src/validators/agent.ts | 7 + .../claude-local-adapter-environment.test.ts | 38 + server/src/routes/agents.ts | 132 +++ ui/src/App.tsx | 2 + ui/src/api/agents.ts | 5 +- ui/src/components/AgentConfigForm.tsx | 74 +- .../CompanySettingsSidebar.test.tsx | 8 + ui/src/components/CompanySettingsSidebar.tsx | 8 +- .../access/CompanySettingsNav.test.tsx | 3 + .../components/access/CompanySettingsNav.tsx | 5 + ui/src/pages/CompanyEnvironments.tsx | 805 ++++++++++++++++++ ui/src/pages/CompanySettings.test.tsx | 8 +- ui/src/pages/CompanySettings.tsx | 754 +--------------- ui/src/pages/NewAgent.tsx | 80 +- 23 files changed, 1509 insertions(+), 846 deletions(-) create mode 100644 ui/src/pages/CompanyEnvironments.tsx diff --git a/packages/adapter-utils/src/execution-target.test.ts b/packages/adapter-utils/src/execution-target.test.ts index b68c8c10..8a3b1ddb 100644 --- a/packages/adapter-utils/src/execution-target.test.ts +++ b/packages/adapter-utils/src/execution-target.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import * as ssh from "./ssh.js"; import { adapterExecutionTargetUsesManagedHome, + resolveAdapterExecutionTargetCwd, runAdapterExecutionTargetShellCommand, } from "./execution-target.js"; @@ -159,3 +160,49 @@ describe("runAdapterExecutionTargetShellCommand", () => { })).toBe(false); }); }); + +describe("resolveAdapterExecutionTargetCwd", () => { + const sshTarget = { + kind: "remote" as const, + transport: "ssh" as const, + 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, + }, + }; + + it("falls back to the remote cwd when no adapter cwd is configured", () => { + expect(resolveAdapterExecutionTargetCwd(sshTarget, "", "/Users/host/repo/server")).toBe( + "/srv/paperclip/workspace", + ); + expect(resolveAdapterExecutionTargetCwd(sshTarget, " ", "/Users/host/repo/server")).toBe( + "/srv/paperclip/workspace", + ); + expect(resolveAdapterExecutionTargetCwd(sshTarget, null, "/Users/host/repo/server")).toBe( + "/srv/paperclip/workspace", + ); + }); + + it("preserves an explicit adapter cwd when one is configured", () => { + expect( + resolveAdapterExecutionTargetCwd( + sshTarget, + "/srv/paperclip/custom-agent-dir", + "/Users/host/repo/server", + ), + ).toBe("/srv/paperclip/custom-agent-dir"); + }); + + it("keeps the local fallback cwd for local targets", () => { + expect(resolveAdapterExecutionTargetCwd(null, "", "/Users/host/repo/server")).toBe( + "/Users/host/repo/server", + ); + }); +}); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 4b4f47cd..861fb025 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -130,6 +130,17 @@ export function adapterExecutionTargetRemoteCwd( return target?.kind === "remote" ? target.remoteCwd : localCwd; } +export function resolveAdapterExecutionTargetCwd( + target: AdapterExecutionTarget | null | undefined, + configuredCwd: string | null | undefined, + localFallbackCwd: string, +): string { + if (typeof configuredCwd === "string" && configuredCwd.trim().length > 0) { + return configuredCwd; + } + return adapterExecutionTargetRemoteCwd(target, localFallbackCwd); +} + export function adapterExecutionTargetPaperclipApiUrl( target: AdapterExecutionTarget | null | undefined, ): string | null { @@ -336,6 +347,64 @@ export async function ensureAdapterExecutionTargetFile( ); } +/** + * Ensure a working directory exists (and is a directory) on the execution target. + * + * For local targets this delegates to the local `ensureAbsoluteDirectory` helper + * (Node fs). For remote (SSH/sandbox) targets it shells out and runs + * `mkdir -p` (when allowed) followed by a `[ -d ]` check so the result reflects + * the directory state inside the environment, not on the Paperclip host. + * + * Throws an Error with a human-readable message on failure. + */ +export async function ensureAdapterExecutionTargetDirectory( + runId: string, + target: AdapterExecutionTarget | null | undefined, + cwd: string, + options: AdapterExecutionTargetShellOptions & { createIfMissing?: boolean }, +): Promise { + const createIfMissing = options.createIfMissing ?? false; + + if (!target || target.kind === "local") { + const { ensureAbsoluteDirectory } = await import("./server-utils.js"); + await ensureAbsoluteDirectory(cwd, { createIfMissing }); + return; + } + + // Remote (SSH or sandbox): both expect POSIX absolute paths inside the env. + if (!cwd.startsWith("/")) { + throw new Error(`Working directory must be an absolute POSIX path on the remote target: "${cwd}"`); + } + + const quoted = shellQuote(cwd); + const script = createIfMissing + ? `mkdir -p ${quoted} && [ -d ${quoted} ]` + : `[ -d ${quoted} ]`; + + const result = await runAdapterExecutionTargetShellCommand(runId, target, script, { + cwd: target.kind === "remote" ? target.remoteCwd : cwd, + env: options.env, + timeoutSec: options.timeoutSec ?? 15, + graceSec: options.graceSec ?? 5, + onLog: options.onLog, + }); + + if (result.timedOut) { + throw new Error(`Timed out checking working directory on remote target: "${cwd}"`); + } + if ((result.exitCode ?? 1) !== 0) { + const detail = (result.stderr || result.stdout || "").trim(); + if (createIfMissing) { + throw new Error( + `Could not create working directory "${cwd}" on remote target${detail ? `: ${detail}` : "."}`, + ); + } + throw new Error( + `Working directory does not exist on remote target: "${cwd}"${detail ? ` (${detail})` : ""}`, + ); + } +} + export function adapterExecutionTargetSessionIdentity( target: AdapterExecutionTarget | null | undefined, ): Record | null { diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 89df7585..034c1e60 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -216,6 +216,20 @@ export interface AdapterEnvironmentTestContext { companyId: string; adapterType: string; config: Record; + /** + * Optional execution target the adapter should run probes against. + * + * If omitted (or `kind === "local"`), the adapter tests on the Paperclip + * host. For SSH/sandbox targets the adapter should run command/auth probes + * inside the remote environment so the result reflects what an agent run + * would actually see at execution time. + */ + executionTarget?: AdapterExecutionTarget | null; + /** + * Friendly name of the environment being tested (when `executionTarget` is set). + * Surfaced in check messages so users see which environment the probe ran in. + */ + environmentName?: string | null; deployment?: { mode?: "local_trusted" | "authenticated"; exposure?: "private" | "public"; diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index be29c0e4..17481965 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -9,11 +9,15 @@ import { asNumber, asStringArray, parseObject, - ensureAbsoluteDirectory, - ensureCommandResolvable, ensurePathInEnv, - runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; +import { + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetDirectory, + runAdapterExecutionTargetProcess, + describeAdapterExecutionTarget, + resolveAdapterExecutionTargetCwd, +} from "@paperclipai/adapter-utils/execution-target"; import path from "node:path"; import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js"; import { isBedrockModelId } from "./models.js"; @@ -56,10 +60,28 @@ export async function testEnvironment( const checks: AdapterEnvironmentCheck[] = []; const config = parseObject(ctx.config); const command = asString(config.command, "claude"); - const cwd = asString(config.cwd, process.cwd()); + const target = ctx.executionTarget ?? null; + const targetIsRemote = target?.kind === "remote"; + const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); + const targetLabel = targetIsRemote + ? ctx.environmentName ?? describeAdapterExecutionTarget(target) ?? "remote environment" + : null; + const runId = `claude-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + if (targetLabel) { + checks.push({ + code: "claude_environment_target", + level: "info", + message: `Probing inside environment: ${targetLabel}`, + }); + } try { - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + await ensureAdapterExecutionTargetDirectory(runId, target, cwd, { + cwd, + env: {}, + createIfMissing: true, + }); checks.push({ code: "claude_cwd_valid", level: "info", @@ -81,7 +103,7 @@ export async function testEnvironment( } const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); try { - await ensureCommandResolvable(command, cwd, runtimeEnv); + await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv); checks.push({ code: "claude_command_resolvable", level: "info", @@ -96,16 +118,21 @@ export async function testEnvironment( }); } + // When probing a remote target, the Paperclip host's process.env does not + // reflect what the agent will actually see at runtime. Only consider env + // vars from the adapter config in that case; the probe itself will surface + // any auth issues on the remote box. + const considerHostEnv = !targetIsRemote; const hasBedrock = env.CLAUDE_CODE_USE_BEDROCK === "1" || env.CLAUDE_CODE_USE_BEDROCK === "true" || - process.env.CLAUDE_CODE_USE_BEDROCK === "1" || - process.env.CLAUDE_CODE_USE_BEDROCK === "true" || + (considerHostEnv && process.env.CLAUDE_CODE_USE_BEDROCK === "1") || + (considerHostEnv && process.env.CLAUDE_CODE_USE_BEDROCK === "true") || isNonEmpty(env.ANTHROPIC_BEDROCK_BASE_URL) || - isNonEmpty(process.env.ANTHROPIC_BEDROCK_BASE_URL); + (considerHostEnv && isNonEmpty(process.env.ANTHROPIC_BEDROCK_BASE_URL)); const configApiKey = env.ANTHROPIC_API_KEY; - const hostApiKey = process.env.ANTHROPIC_API_KEY; + const hostApiKey = considerHostEnv ? process.env.ANTHROPIC_API_KEY : undefined; if (hasBedrock) { const source = env.CLAUDE_CODE_USE_BEDROCK === "1" || @@ -130,7 +157,7 @@ export async function testEnvironment( detail: `Detected in ${source}.`, hint: "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.", }); - } else { + } else if (!targetIsRemote) { checks.push({ code: "claude_subscription_mode_possible", level: "info", @@ -172,8 +199,9 @@ export async function testEnvironment( if (maxTurns > 0) args.push("--max-turns", String(maxTurns)); if (extraArgs.length > 0) args.push(...extraArgs); - const probe = await runChildProcess( - `claude-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + const probe = await runAdapterExecutionTargetProcess( + runId, + target, command, args, { diff --git a/packages/adapters/codex-local/src/server/test.ts b/packages/adapters/codex-local/src/server/test.ts index 5e4a0656..50f1a149 100644 --- a/packages/adapters/codex-local/src/server/test.ts +++ b/packages/adapters/codex-local/src/server/test.ts @@ -6,11 +6,15 @@ import type { import { asString, parseObject, - ensureAbsoluteDirectory, - ensureCommandResolvable, ensurePathInEnv, - runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; +import { + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetDirectory, + runAdapterExecutionTargetProcess, + describeAdapterExecutionTarget, + resolveAdapterExecutionTargetCwd, +} from "@paperclipai/adapter-utils/execution-target"; import path from "node:path"; import { parseCodexJsonl } from "./parse.js"; import { codexHomeDir, readCodexAuthInfo } from "./quota.js"; @@ -57,10 +61,28 @@ export async function testEnvironment( const checks: AdapterEnvironmentCheck[] = []; const config = parseObject(ctx.config); const command = asString(config.command, "codex"); - const cwd = asString(config.cwd, process.cwd()); + const target = ctx.executionTarget ?? null; + const targetIsRemote = target?.kind === "remote"; + const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); + const targetLabel = targetIsRemote + ? ctx.environmentName ?? describeAdapterExecutionTarget(target) ?? "remote environment" + : null; + const runId = `codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + if (targetLabel) { + checks.push({ + code: "codex_environment_target", + level: "info", + message: `Probing inside environment: ${targetLabel}`, + }); + } try { - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + await ensureAdapterExecutionTargetDirectory(runId, target, cwd, { + cwd, + env: {}, + createIfMissing: true, + }); checks.push({ code: "codex_cwd_valid", level: "info", @@ -82,7 +104,7 @@ export async function testEnvironment( } const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); try { - await ensureCommandResolvable(command, cwd, runtimeEnv); + await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv); checks.push({ code: "codex_command_resolvable", level: "info", @@ -98,7 +120,7 @@ export async function testEnvironment( } const configOpenAiKey = env.OPENAI_API_KEY; - const hostOpenAiKey = process.env.OPENAI_API_KEY; + const hostOpenAiKey = targetIsRemote ? undefined : process.env.OPENAI_API_KEY; if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) { const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment"; checks.push({ @@ -107,7 +129,9 @@ export async function testEnvironment( message: "OPENAI_API_KEY is set for Codex authentication.", detail: `Detected in ${source}.`, }); - } else { + } else if (!targetIsRemote) { + // Local-only auth file check. On remote targets, the probe will surface + // any missing-auth errors directly from the remote `codex` invocation. const codexHome = isNonEmpty(env.CODEX_HOME) ? env.CODEX_HOME : undefined; const codexAuth = await readCodexAuthInfo(codexHome).catch(() => null); if (codexAuth) { @@ -150,8 +174,9 @@ export async function testEnvironment( }); } - const probe = await runChildProcess( - `codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + const probe = await runAdapterExecutionTargetProcess( + runId, + target, command, args, { diff --git a/packages/adapters/cursor-local/src/server/test.ts b/packages/adapters/cursor-local/src/server/test.ts index c8e53b98..bdddf34c 100644 --- a/packages/adapters/cursor-local/src/server/test.ts +++ b/packages/adapters/cursor-local/src/server/test.ts @@ -7,11 +7,15 @@ import { asString, asStringArray, parseObject, - ensureAbsoluteDirectory, - ensureCommandResolvable, ensurePathInEnv, - runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; +import { + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetDirectory, + runAdapterExecutionTargetProcess, + describeAdapterExecutionTarget, + resolveAdapterExecutionTargetCwd, +} from "@paperclipai/adapter-utils/execution-target"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -95,10 +99,28 @@ export async function testEnvironment( const checks: AdapterEnvironmentCheck[] = []; const config = parseObject(ctx.config); const command = asString(config.command, "agent"); - const cwd = asString(config.cwd, process.cwd()); + const target = ctx.executionTarget ?? null; + const targetIsRemote = target?.kind === "remote"; + const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); + const targetLabel = targetIsRemote + ? ctx.environmentName ?? describeAdapterExecutionTarget(target) ?? "remote environment" + : null; + const runId = `cursor-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + if (targetLabel) { + checks.push({ + code: "cursor_environment_target", + level: "info", + message: `Probing inside environment: ${targetLabel}`, + }); + } try { - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + await ensureAdapterExecutionTargetDirectory(runId, target, cwd, { + cwd, + env: {}, + createIfMissing: true, + }); checks.push({ code: "cursor_cwd_valid", level: "info", @@ -120,7 +142,7 @@ export async function testEnvironment( } const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); try { - await ensureCommandResolvable(command, cwd, runtimeEnv); + await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv); checks.push({ code: "cursor_command_resolvable", level: "info", @@ -136,7 +158,7 @@ export async function testEnvironment( } const configCursorApiKey = env.CURSOR_API_KEY; - const hostCursorApiKey = process.env.CURSOR_API_KEY; + const hostCursorApiKey = targetIsRemote ? undefined : process.env.CURSOR_API_KEY; if (isNonEmpty(configCursorApiKey) || isNonEmpty(hostCursorApiKey)) { const source = isNonEmpty(configCursorApiKey) ? "adapter config env" : "server environment"; checks.push({ @@ -145,7 +167,7 @@ export async function testEnvironment( message: "CURSOR_API_KEY is set for Cursor authentication.", detail: `Detected in ${source}.`, }); - } else { + } else if (!targetIsRemote) { const cursorHome = isNonEmpty(env.CURSOR_HOME) ? env.CURSOR_HOME : undefined; const cursorAuth = await readCursorAuthInfo(cursorHome).catch(() => null); if (cursorAuth) { @@ -192,8 +214,9 @@ export async function testEnvironment( if (extraArgs.length > 0) args.push(...extraArgs); args.push("Respond with hello."); - const probe = await runChildProcess( - `cursor-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + const probe = await runAdapterExecutionTargetProcess( + runId, + target, command, args, { diff --git a/packages/adapters/gemini-local/src/server/test.ts b/packages/adapters/gemini-local/src/server/test.ts index 145c3b7a..8d87a8a8 100644 --- a/packages/adapters/gemini-local/src/server/test.ts +++ b/packages/adapters/gemini-local/src/server/test.ts @@ -9,12 +9,16 @@ import { asNumber, asString, asStringArray, - ensureAbsoluteDirectory, - ensureCommandResolvable, ensurePathInEnv, parseObject, - runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; +import { + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetDirectory, + runAdapterExecutionTargetProcess, + describeAdapterExecutionTarget, + resolveAdapterExecutionTargetCwd, +} from "@paperclipai/adapter-utils/execution-target"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; import { detectGeminiAuthRequired, detectGeminiQuotaExhausted, parseGeminiJsonl } from "./parse.js"; import { firstNonEmptyLine } from "./utils.js"; @@ -48,10 +52,28 @@ export async function testEnvironment( const checks: AdapterEnvironmentCheck[] = []; const config = parseObject(ctx.config); const command = asString(config.command, "gemini"); - const cwd = asString(config.cwd, process.cwd()); + const target = ctx.executionTarget ?? null; + const targetIsRemote = target?.kind === "remote"; + const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); + const targetLabel = targetIsRemote + ? ctx.environmentName ?? describeAdapterExecutionTarget(target) ?? "remote environment" + : null; + const runId = `gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + if (targetLabel) { + checks.push({ + code: "gemini_environment_target", + level: "info", + message: `Probing inside environment: ${targetLabel}`, + }); + } try { - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + await ensureAdapterExecutionTargetDirectory(runId, target, cwd, { + cwd, + env: {}, + createIfMissing: true, + }); checks.push({ code: "gemini_cwd_valid", level: "info", @@ -73,7 +95,7 @@ export async function testEnvironment( } const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); try { - await ensureCommandResolvable(command, cwd, runtimeEnv); + await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv); checks.push({ code: "gemini_command_resolvable", level: "info", @@ -89,10 +111,10 @@ export async function testEnvironment( } const configGeminiApiKey = env.GEMINI_API_KEY; - const hostGeminiApiKey = process.env.GEMINI_API_KEY; + const hostGeminiApiKey = targetIsRemote ? undefined : process.env.GEMINI_API_KEY; const configGoogleApiKey = env.GOOGLE_API_KEY; - const hostGoogleApiKey = process.env.GOOGLE_API_KEY; - const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true"; + const hostGoogleApiKey = targetIsRemote ? undefined : process.env.GOOGLE_API_KEY; + const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || (!targetIsRemote && process.env.GOOGLE_GENAI_USE_GCA === "true"); if ( isNonEmpty(configGeminiApiKey) || isNonEmpty(hostGeminiApiKey) || @@ -152,8 +174,9 @@ export async function testEnvironment( } if (extraArgs.length > 0) args.push(...extraArgs); - const probe = await runChildProcess( - `gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + const probe = await runAdapterExecutionTargetProcess( + runId, + target, command, args, { diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index 1d6ef459..24c4981e 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -8,11 +8,15 @@ import { asString, asStringArray, parseObject, - ensureAbsoluteDirectory, - ensureCommandResolvable, ensurePathInEnv, - runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; +import { + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetDirectory, + runAdapterExecutionTargetProcess, + describeAdapterExecutionTarget, + resolveAdapterExecutionTargetCwd, +} from "@paperclipai/adapter-utils/execution-target"; import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; import { parseOpenCodeJsonl } from "./parse.js"; import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; @@ -58,10 +62,28 @@ export async function testEnvironment( const checks: AdapterEnvironmentCheck[] = []; const config = parseObject(ctx.config); const command = asString(config.command, "opencode"); - const cwd = asString(config.cwd, process.cwd()); + const target = ctx.executionTarget ?? null; + const targetIsRemote = target?.kind === "remote"; + const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); + const targetLabel = targetIsRemote + ? ctx.environmentName ?? describeAdapterExecutionTarget(target) ?? "remote environment" + : null; + const runId = `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + if (targetLabel) { + checks.push({ + code: "opencode_environment_target", + level: "info", + message: `Probing inside environment: ${targetLabel}`, + }); + } try { - await ensureAbsoluteDirectory(cwd, { createIfMissing: false }); + await ensureAdapterExecutionTargetDirectory(runId, target, cwd, { + cwd, + env: {}, + createIfMissing: false, + }); checks.push({ code: "opencode_cwd_valid", level: "info", @@ -115,7 +137,7 @@ export async function testEnvironment( }); } else { try { - await ensureCommandResolvable(command, cwd, runtimeEnv); + await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv); checks.push({ code: "opencode_command_resolvable", level: "info", @@ -137,7 +159,19 @@ export async function testEnvironment( let modelValidationPassed = false; const configuredModel = asString(config.model, "").trim(); - if (canRunProbe && configuredModel) { + // Model discovery and validation use local child processes against + // OpenCode's `models` subcommand and JSON config; these are not yet + // wired through the execution target. When probing a remote env, skip + // discovery/validation and rely on the remote hello probe to surface + // model/auth issues directly. + if (targetIsRemote && configuredModel) { + checks.push({ + code: "opencode_model_validation_skipped_remote", + level: "info", + message: `Skipped local model validation; will be validated by the hello probe inside ${targetLabel}.`, + }); + modelValidationPassed = true; + } else if (canRunProbe && configuredModel) { try { const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); if (discovered.length > 0) { @@ -173,7 +207,7 @@ export async function testEnvironment( }); } } - } else if (canRunProbe && !configuredModel) { + } else if (!targetIsRemote && canRunProbe && !configuredModel) { try { const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); if (discovered.length > 0) { @@ -207,7 +241,7 @@ export async function testEnvironment( const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable"); if (!configuredModel && !modelUnavailable) { // No model configured – skip model requirement if no model-related checks exist - } else if (configuredModel && canRunProbe) { + } else if (!targetIsRemote && configuredModel && canRunProbe) { try { await ensureOpenCodeModelConfiguredAndAvailable({ model: configuredModel, @@ -246,8 +280,9 @@ export async function testEnvironment( if (extraArgs.length > 0) args.push(...extraArgs); try { - const probe = await runChildProcess( - `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + const probe = await runAdapterExecutionTargetProcess( + runId, + target, command, args, { diff --git a/packages/adapters/pi-local/src/server/test.ts b/packages/adapters/pi-local/src/server/test.ts index 57ba14d6..6baf99bf 100644 --- a/packages/adapters/pi-local/src/server/test.ts +++ b/packages/adapters/pi-local/src/server/test.ts @@ -5,15 +5,17 @@ import type { } from "@paperclipai/adapter-utils"; import { asString, + asStringArray, parseObject, - ensureAbsoluteDirectory, - ensureCommandResolvable, ensurePathInEnv, - runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { - asStringArray, -} from "@paperclipai/adapter-utils/server-utils"; + ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetDirectory, + runAdapterExecutionTargetProcess, + describeAdapterExecutionTarget, + resolveAdapterExecutionTargetCwd, +} from "@paperclipai/adapter-utils/execution-target"; import { discoverPiModelsCached } from "./models.js"; import { parsePiJsonl } from "./parse.js"; @@ -78,10 +80,28 @@ export async function testEnvironment( const checks: AdapterEnvironmentCheck[] = []; const config = parseObject(ctx.config); const command = asString(config.command, "pi"); - const cwd = asString(config.cwd, process.cwd()); + const target = ctx.executionTarget ?? null; + const targetIsRemote = target?.kind === "remote"; + const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); + const targetLabel = targetIsRemote + ? ctx.environmentName ?? describeAdapterExecutionTarget(target) ?? "remote environment" + : null; + const runId = `pi-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + if (targetLabel) { + checks.push({ + code: "pi_environment_target", + level: "info", + message: `Probing inside environment: ${targetLabel}`, + }); + } try { - await ensureAbsoluteDirectory(cwd, { createIfMissing: false }); + await ensureAdapterExecutionTargetDirectory(runId, target, cwd, { + cwd, + env: {}, + createIfMissing: false, + }); checks.push({ code: "pi_cwd_valid", level: "info", @@ -113,7 +133,7 @@ export async function testEnvironment( }); } else { try { - await ensureCommandResolvable(command, cwd, runtimeEnv); + await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv); checks.push({ code: "pi_command_resolvable", level: "info", @@ -132,7 +152,10 @@ export async function testEnvironment( const canRunProbe = checks.every((check) => check.code !== "pi_cwd_invalid" && check.code !== "pi_command_unresolvable"); - if (canRunProbe) { + // Pi model discovery shells out to `pi --list-models` locally; when probing a + // remote target we skip discovery and let the remote hello probe surface + // model/auth issues directly. + if (!targetIsRemote && canRunProbe) { try { const discovered = await discoverPiModelsCached({ command, cwd, env: runtimeEnv }); if (discovered.length > 0) { @@ -166,6 +189,12 @@ export async function testEnvironment( message: "Pi requires a configured model in provider/model format.", hint: "Set adapterConfig.model using an ID from `pi --list-models`.", }); + } else if (targetIsRemote) { + checks.push({ + code: "pi_model_validation_skipped_remote", + level: "info", + message: `Skipped local model validation; will be validated by the hello probe inside ${targetLabel}.`, + }); } else if (canRunProbe) { // Verify model is in the list try { @@ -218,8 +247,9 @@ export async function testEnvironment( if (extraArgs.length > 0) args.push(...extraArgs); try { - const probe = await runChildProcess( - `pi-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + const probe = await runAdapterExecutionTargetProcess( + runId, + target, command, args, { diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 3f33ae77..a7150774 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -132,6 +132,13 @@ export type ResetAgentSession = z.infer; export const testAdapterEnvironmentSchema = z.object({ adapterConfig: adapterConfigSchema.optional().default({}), + /** + * Optional environment to run the adapter test inside. When omitted, the + * test runs against the local Paperclip host. When provided and the + * environment is non-local (SSH/sandbox), the test probes are executed + * inside that environment so the result reflects real agent execution. + */ + environmentId: z.string().uuid().optional().nullable(), }); export type TestAdapterEnvironment = z.infer; diff --git a/server/src/__tests__/claude-local-adapter-environment.test.ts b/server/src/__tests__/claude-local-adapter-environment.test.ts index 8aa4313b..71eaa125 100644 --- a/server/src/__tests__/claude-local-adapter-environment.test.ts +++ b/server/src/__tests__/claude-local-adapter-environment.test.ts @@ -180,4 +180,42 @@ describe("claude_local environment diagnostics", () => { expect(stats.isDirectory()).toBe(true); await fs.rm(path.dirname(cwd), { recursive: true, force: true }); }); + + it("defaults remote probes to the environment remote cwd when adapter cwd is unset", async () => { + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "claude_local", + config: { + command: process.execPath, + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "test-provider", + remoteCwd: "/srv/paperclip/workspace", + runner: { + execute: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }), + }, + }, + environmentName: "Linux Box", + }); + + expect(result.checks.some((check) => check.code === "claude_cwd_valid")).toBe(true); + expect( + result.checks.some( + (check) => + check.code === "claude_cwd_valid" && + check.message === "Working directory is valid: /srv/paperclip/workspace", + ), + ).toBe(true); + expect(result.checks.some((check) => check.code === "claude_cwd_invalid")).toBe(false); + }); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 78172f32..b3c7701b 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -54,6 +54,9 @@ import { } from "./workspace-command-authz.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import { environmentService } from "../services/environments.js"; +import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.js"; +import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; +import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils"; import { secretService } from "../services/secrets.js"; import { detectAdapterModel, @@ -169,6 +172,111 @@ export function agentRoutes( }); } + /** + * Resolve the execution target the adapter should run its test probes against. + * + * - No environmentId / local environment → returns a local target so the + * adapter probes the Paperclip host (legacy behavior). + * - SSH environment → builds an SSH execution target from the environment + * config so the adapter probes the remote box. No lease is required: + * the SSH spec is fully derived from the saved environment config. + * - Sandbox / plugin environments → currently fall back to local probing + * with a warning check, since lifting a temporary sandbox lease for an + * ad-hoc test invocation is out of scope for this iteration. + */ + async function resolveAdapterTestExecutionContext(input: { + companyId: string; + adapterType: string; + environmentId: string | null; + }): Promise<{ + executionTarget: AdapterExecutionTarget | null; + environmentName: string | null; + fallbackChecks: AdapterEnvironmentCheck[]; + }> { + if (!input.environmentId) { + return { executionTarget: null, environmentName: null, fallbackChecks: [] }; + } + + const environment = await environmentsSvc.getById(input.environmentId); + if (!environment || environment.companyId !== input.companyId) { + return { + executionTarget: null, + environmentName: null, + fallbackChecks: [ + { + code: "environment_not_found", + level: "warn", + message: "Selected environment was not found. Falling back to a local probe.", + }, + ], + }; + } + + if (environment.driver === "local") { + return { executionTarget: null, environmentName: environment.name, fallbackChecks: [] }; + } + + if (environment.driver === "ssh") { + try { + const target = await resolveEnvironmentExecutionTarget({ + db, + companyId: input.companyId, + adapterType: input.adapterType, + environment: { + id: environment.id, + driver: environment.driver, + config: environment.config ?? null, + }, + leaseMetadata: null, + }); + if (target) { + return { executionTarget: target, environmentName: environment.name, fallbackChecks: [] }; + } + return { + executionTarget: null, + environmentName: environment.name, + fallbackChecks: [ + { + code: "environment_target_unavailable", + level: "warn", + message: + `Could not resolve an execution target for environment "${environment.name}". Falling back to a local probe.`, + }, + ], + }; + } catch (err) { + return { + executionTarget: null, + environmentName: environment.name, + fallbackChecks: [ + { + code: "environment_target_failed", + level: "warn", + message: + `Could not connect to environment "${environment.name}" to run the test. Falling back to a local probe.`, + detail: err instanceof Error ? err.message : String(err), + }, + ], + }; + } + } + + // sandbox / plugin / other drivers: not yet supported for ad-hoc adapter tests. + return { + executionTarget: null, + environmentName: environment.name, + fallbackChecks: [ + { + code: "environment_driver_not_supported_for_test", + level: "warn", + message: + `Adapter testing inside ${environment.driver} environments is not yet supported. Falling back to a local probe; results may not reflect runs in "${environment.name}".`, + hint: "Run a real heartbeat in the environment to verify end-to-end behavior.", + }, + ], + }; + } + async function getCurrentUserRedactionOptions() { return { enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, @@ -977,6 +1085,10 @@ export function agentRoutes( const inputAdapterConfig = (req.body?.adapterConfig ?? {}) as Record; + const requestedEnvironmentId = + typeof req.body?.environmentId === "string" && req.body.environmentId.trim().length > 0 + ? (req.body.environmentId as string) + : null; const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( companyId, inputAdapterConfig, @@ -987,12 +1099,32 @@ export function agentRoutes( normalizedAdapterConfig, ); + const { executionTarget, environmentName, fallbackChecks } = + await resolveAdapterTestExecutionContext({ + companyId, + adapterType: type, + environmentId: requestedEnvironmentId, + }); + const result = await adapter.testEnvironment({ companyId, adapterType: type, config: runtimeAdapterConfig, + executionTarget, + environmentName, }); + if (fallbackChecks.length > 0) { + const checks = [...fallbackChecks, ...result.checks]; + const status: typeof result.status = checks.some((c) => c.level === "error") + ? "fail" + : checks.some((c) => c.level === "warn") + ? "warn" + : result.status; + res.json({ ...result, checks, status }); + return; + } + res.json(result); }, ); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ec82de9d..3c52304e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -27,6 +27,7 @@ import { Costs } from "./pages/Costs"; import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; +import { CompanyEnvironments } from "./pages/CompanyEnvironments"; import { CompanyAccess } from "./pages/CompanyAccess"; import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; @@ -64,6 +65,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 0091be1d..713adba2 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -175,7 +175,10 @@ export const agentsApi = { testEnvironment: ( companyId: string, type: string, - data: { adapterConfig: Record }, + data: { + adapterConfig: Record; + environmentId?: string | null; + }, ) => api.post( `/companies/${companyId}/adapters/${type}/test-environment`, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index b34eee10..99b3240a 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -70,6 +70,12 @@ type AgentConfigFormProps = { onDirtyChange?: (dirty: boolean) => void; onSaveActionChange?: (save: (() => void) | null) => void; onCancelActionChange?: (cancel: (() => void) | null) => void; + onTestActionChange?: (test: (() => void) | null) => void; + onTestActionStateChange?: (state: { disabled: boolean; pending: boolean }) => void; + onTestFeedbackChange?: (feedback: { + errorMessage: string | null; + result: AdapterEnvironmentTestResult | null; + }) => void; hideInlineSave?: boolean; showAdapterTypeField?: boolean; showAdapterTestEnvironmentButton?: boolean; @@ -176,6 +182,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const cards = props.sectionLayout === "cards"; const showAdapterTypeField = props.showAdapterTypeField ?? true; const showAdapterTestEnvironmentButton = props.showAdapterTestEnvironmentButton ?? true; + const showInlineAdapterTestEnvironmentButton = + showAdapterTestEnvironmentButton && !props.onTestActionChange; + const showInlineAdapterTestEnvironmentFeedback = !props.onTestFeedbackChange; const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true; const hideInstructionsFile = props.hideInstructionsFile ?? false; const { selectedCompanyId } = useCompany(); @@ -398,11 +407,62 @@ export function AgentConfigForm(props: AgentConfigFormProps) { if (!selectedCompanyId) { throw new Error("Select a company to test adapter environment"); } + const selectedEnvironmentId = isCreate + ? val!.defaultEnvironmentId ?? null + : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? null); return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig: buildAdapterConfigForTest(), + environmentId: + typeof selectedEnvironmentId === "string" && selectedEnvironmentId.length > 0 + ? selectedEnvironmentId + : null, }); }, }); + const testEnvironmentDisabled = testEnvironment.isPending || !selectedCompanyId; + const triggerTestEnvironment = useCallback(() => { + if (testEnvironmentDisabled) return; + testEnvironment.mutate(); + }, [testEnvironment.mutate, testEnvironmentDisabled]); + + useEffect(() => { + if (!showAdapterTestEnvironmentButton || !props.onTestActionChange) return; + props.onTestActionChange(triggerTestEnvironment); + return () => { + props.onTestActionChange?.(null); + }; + }, [showAdapterTestEnvironmentButton, props.onTestActionChange, triggerTestEnvironment]); + + useEffect(() => { + if (!showAdapterTestEnvironmentButton || !props.onTestActionStateChange) return; + props.onTestActionStateChange({ + disabled: testEnvironmentDisabled, + pending: testEnvironment.isPending, + }); + return () => { + props.onTestActionStateChange?.({ disabled: true, pending: false }); + }; + }, [ + showAdapterTestEnvironmentButton, + props.onTestActionStateChange, + testEnvironmentDisabled, + testEnvironment.isPending, + ]); + + useEffect(() => { + if (!props.onTestFeedbackChange) return; + props.onTestFeedbackChange({ + errorMessage: testEnvironment.error instanceof Error + ? testEnvironment.error.message + : testEnvironment.error + ? "Environment test failed" + : null, + result: testEnvironment.data ?? null, + }); + return () => { + props.onTestFeedbackChange?.({ errorMessage: null, result: null }); + }; + }, [props.onTestFeedbackChange, testEnvironment.data, testEnvironment.error]); // Current model for display const currentModelId = isCreate @@ -618,16 +678,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ?

Adapter

: Adapter } - {showAdapterTestEnvironmentButton && ( + {showInlineAdapterTestEnvironmentButton && ( )} @@ -687,7 +747,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} - {testEnvironment.error && ( + {showInlineAdapterTestEnvironmentFeedback && testEnvironment.error && (
{testEnvironment.error instanceof Error ? testEnvironment.error.message @@ -695,7 +755,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)} - {testEnvironment.data && ( + {showInlineAdapterTestEnvironmentFeedback && testEnvironment.data && ( )} @@ -1047,7 +1107,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ); } -function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestResult }) { +export function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestResult }) { const statusLabel = result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed"; const statusClass = diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index fa9a777a..452219b3 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -105,6 +105,7 @@ describe("CompanySettingsSidebar", () => { expect(container.textContent).toContain("Paperclip"); expect(container.textContent).toContain("Company Settings"); expect(container.textContent).toContain("General"); + expect(container.textContent).toContain("Environments"); expect(container.textContent).toContain("Access"); expect(container.textContent).toContain("Invites"); expect(sidebarNavItemMock).toHaveBeenCalledWith( @@ -114,6 +115,13 @@ describe("CompanySettingsSidebar", () => { end: true, }), ); + expect(sidebarNavItemMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "/company/settings/environments", + label: "Environments", + end: true, + }), + ); expect(sidebarNavItemMock).toHaveBeenCalledWith( expect.objectContaining({ to: "/company/settings/access", diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index 6339cf1f..f0a2b378 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { ChevronLeft, MailPlus, Settings, Shield, SlidersHorizontal } from "lucide-react"; +import { ChevronLeft, MailPlus, MonitorCog, Settings, Shield, SlidersHorizontal } from "lucide-react"; import { sidebarBadgesApi } from "@/api/sidebarBadges"; import { ApiError } from "@/api/client"; import { Link } from "@/lib/router"; @@ -54,6 +54,12 @@ export function CompanySettingsSidebar() {