Add dedicated environment settings page and test-in-environment (#4798)

## 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
This commit is contained in:
Devin Foley 2026-04-29 15:56:13 -07:00 committed by GitHub
parent 3494e84a29
commit 9b99d30330
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1509 additions and 846 deletions

View file

@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import * as ssh from "./ssh.js"; import * as ssh from "./ssh.js";
import { import {
adapterExecutionTargetUsesManagedHome, adapterExecutionTargetUsesManagedHome,
resolveAdapterExecutionTargetCwd,
runAdapterExecutionTargetShellCommand, runAdapterExecutionTargetShellCommand,
} from "./execution-target.js"; } from "./execution-target.js";
@ -159,3 +160,49 @@ describe("runAdapterExecutionTargetShellCommand", () => {
})).toBe(false); })).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",
);
});
});

View file

@ -130,6 +130,17 @@ export function adapterExecutionTargetRemoteCwd(
return target?.kind === "remote" ? target.remoteCwd : localCwd; 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( export function adapterExecutionTargetPaperclipApiUrl(
target: AdapterExecutionTarget | null | undefined, target: AdapterExecutionTarget | null | undefined,
): string | null { ): 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<void> {
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( export function adapterExecutionTargetSessionIdentity(
target: AdapterExecutionTarget | null | undefined, target: AdapterExecutionTarget | null | undefined,
): Record<string, unknown> | null { ): Record<string, unknown> | null {

View file

@ -216,6 +216,20 @@ export interface AdapterEnvironmentTestContext {
companyId: string; companyId: string;
adapterType: string; adapterType: string;
config: Record<string, unknown>; config: Record<string, unknown>;
/**
* 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?: { deployment?: {
mode?: "local_trusted" | "authenticated"; mode?: "local_trusted" | "authenticated";
exposure?: "private" | "public"; exposure?: "private" | "public";

View file

@ -9,11 +9,15 @@ import {
asNumber, asNumber,
asStringArray, asStringArray,
parseObject, parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv, ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils"; } from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import path from "node:path"; import path from "node:path";
import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js"; import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js";
import { isBedrockModelId } from "./models.js"; import { isBedrockModelId } from "./models.js";
@ -56,10 +60,28 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = []; const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config); const config = parseObject(ctx.config);
const command = asString(config.command, "claude"); 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 { try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: true,
});
checks.push({ checks.push({
code: "claude_cwd_valid", code: "claude_cwd_valid",
level: "info", level: "info",
@ -81,7 +103,7 @@ export async function testEnvironment(
} }
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try { try {
await ensureCommandResolvable(command, cwd, runtimeEnv); await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({ checks.push({
code: "claude_command_resolvable", code: "claude_command_resolvable",
level: "info", 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 = const hasBedrock =
env.CLAUDE_CODE_USE_BEDROCK === "1" || env.CLAUDE_CODE_USE_BEDROCK === "1" ||
env.CLAUDE_CODE_USE_BEDROCK === "true" || env.CLAUDE_CODE_USE_BEDROCK === "true" ||
process.env.CLAUDE_CODE_USE_BEDROCK === "1" || (considerHostEnv && process.env.CLAUDE_CODE_USE_BEDROCK === "1") ||
process.env.CLAUDE_CODE_USE_BEDROCK === "true" || (considerHostEnv && process.env.CLAUDE_CODE_USE_BEDROCK === "true") ||
isNonEmpty(env.ANTHROPIC_BEDROCK_BASE_URL) || 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 configApiKey = env.ANTHROPIC_API_KEY;
const hostApiKey = process.env.ANTHROPIC_API_KEY; const hostApiKey = considerHostEnv ? process.env.ANTHROPIC_API_KEY : undefined;
if (hasBedrock) { if (hasBedrock) {
const source = const source =
env.CLAUDE_CODE_USE_BEDROCK === "1" || env.CLAUDE_CODE_USE_BEDROCK === "1" ||
@ -130,7 +157,7 @@ export async function testEnvironment(
detail: `Detected in ${source}.`, detail: `Detected in ${source}.`,
hint: "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.", hint: "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.",
}); });
} else { } else if (!targetIsRemote) {
checks.push({ checks.push({
code: "claude_subscription_mode_possible", code: "claude_subscription_mode_possible",
level: "info", level: "info",
@ -172,8 +199,9 @@ export async function testEnvironment(
if (maxTurns > 0) args.push("--max-turns", String(maxTurns)); if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
if (extraArgs.length > 0) args.push(...extraArgs); if (extraArgs.length > 0) args.push(...extraArgs);
const probe = await runChildProcess( const probe = await runAdapterExecutionTargetProcess(
`claude-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, runId,
target,
command, command,
args, args,
{ {

View file

@ -6,11 +6,15 @@ import type {
import { import {
asString, asString,
parseObject, parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv, ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils"; } from "@paperclipai/adapter-utils/server-utils";
import {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import path from "node:path"; import path from "node:path";
import { parseCodexJsonl } from "./parse.js"; import { parseCodexJsonl } from "./parse.js";
import { codexHomeDir, readCodexAuthInfo } from "./quota.js"; import { codexHomeDir, readCodexAuthInfo } from "./quota.js";
@ -57,10 +61,28 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = []; const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config); const config = parseObject(ctx.config);
const command = asString(config.command, "codex"); 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 { try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: true,
});
checks.push({ checks.push({
code: "codex_cwd_valid", code: "codex_cwd_valid",
level: "info", level: "info",
@ -82,7 +104,7 @@ export async function testEnvironment(
} }
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try { try {
await ensureCommandResolvable(command, cwd, runtimeEnv); await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({ checks.push({
code: "codex_command_resolvable", code: "codex_command_resolvable",
level: "info", level: "info",
@ -98,7 +120,7 @@ export async function testEnvironment(
} }
const configOpenAiKey = env.OPENAI_API_KEY; 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)) { if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) {
const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment"; const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment";
checks.push({ checks.push({
@ -107,7 +129,9 @@ export async function testEnvironment(
message: "OPENAI_API_KEY is set for Codex authentication.", message: "OPENAI_API_KEY is set for Codex authentication.",
detail: `Detected in ${source}.`, 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 codexHome = isNonEmpty(env.CODEX_HOME) ? env.CODEX_HOME : undefined;
const codexAuth = await readCodexAuthInfo(codexHome).catch(() => null); const codexAuth = await readCodexAuthInfo(codexHome).catch(() => null);
if (codexAuth) { if (codexAuth) {
@ -150,8 +174,9 @@ export async function testEnvironment(
}); });
} }
const probe = await runChildProcess( const probe = await runAdapterExecutionTargetProcess(
`codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, runId,
target,
command, command,
args, args,
{ {

View file

@ -7,11 +7,15 @@ import {
asString, asString,
asStringArray, asStringArray,
parseObject, parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv, ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils"; } 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 fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@ -95,10 +99,28 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = []; const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config); const config = parseObject(ctx.config);
const command = asString(config.command, "agent"); 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 { try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: true,
});
checks.push({ checks.push({
code: "cursor_cwd_valid", code: "cursor_cwd_valid",
level: "info", level: "info",
@ -120,7 +142,7 @@ export async function testEnvironment(
} }
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try { try {
await ensureCommandResolvable(command, cwd, runtimeEnv); await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({ checks.push({
code: "cursor_command_resolvable", code: "cursor_command_resolvable",
level: "info", level: "info",
@ -136,7 +158,7 @@ export async function testEnvironment(
} }
const configCursorApiKey = env.CURSOR_API_KEY; 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)) { if (isNonEmpty(configCursorApiKey) || isNonEmpty(hostCursorApiKey)) {
const source = isNonEmpty(configCursorApiKey) ? "adapter config env" : "server environment"; const source = isNonEmpty(configCursorApiKey) ? "adapter config env" : "server environment";
checks.push({ checks.push({
@ -145,7 +167,7 @@ export async function testEnvironment(
message: "CURSOR_API_KEY is set for Cursor authentication.", message: "CURSOR_API_KEY is set for Cursor authentication.",
detail: `Detected in ${source}.`, detail: `Detected in ${source}.`,
}); });
} else { } else if (!targetIsRemote) {
const cursorHome = isNonEmpty(env.CURSOR_HOME) ? env.CURSOR_HOME : undefined; const cursorHome = isNonEmpty(env.CURSOR_HOME) ? env.CURSOR_HOME : undefined;
const cursorAuth = await readCursorAuthInfo(cursorHome).catch(() => null); const cursorAuth = await readCursorAuthInfo(cursorHome).catch(() => null);
if (cursorAuth) { if (cursorAuth) {
@ -192,8 +214,9 @@ export async function testEnvironment(
if (extraArgs.length > 0) args.push(...extraArgs); if (extraArgs.length > 0) args.push(...extraArgs);
args.push("Respond with hello."); args.push("Respond with hello.");
const probe = await runChildProcess( const probe = await runAdapterExecutionTargetProcess(
`cursor-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, runId,
target,
command, command,
args, args,
{ {

View file

@ -9,12 +9,16 @@ import {
asNumber, asNumber,
asString, asString,
asStringArray, asStringArray,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv, ensurePathInEnv,
parseObject, parseObject,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils"; } 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 { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import { detectGeminiAuthRequired, detectGeminiQuotaExhausted, parseGeminiJsonl } from "./parse.js"; import { detectGeminiAuthRequired, detectGeminiQuotaExhausted, parseGeminiJsonl } from "./parse.js";
import { firstNonEmptyLine } from "./utils.js"; import { firstNonEmptyLine } from "./utils.js";
@ -48,10 +52,28 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = []; const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config); const config = parseObject(ctx.config);
const command = asString(config.command, "gemini"); 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 { try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: true,
});
checks.push({ checks.push({
code: "gemini_cwd_valid", code: "gemini_cwd_valid",
level: "info", level: "info",
@ -73,7 +95,7 @@ export async function testEnvironment(
} }
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try { try {
await ensureCommandResolvable(command, cwd, runtimeEnv); await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({ checks.push({
code: "gemini_command_resolvable", code: "gemini_command_resolvable",
level: "info", level: "info",
@ -89,10 +111,10 @@ export async function testEnvironment(
} }
const configGeminiApiKey = env.GEMINI_API_KEY; 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 configGoogleApiKey = env.GOOGLE_API_KEY;
const hostGoogleApiKey = process.env.GOOGLE_API_KEY; const hostGoogleApiKey = targetIsRemote ? undefined : process.env.GOOGLE_API_KEY;
const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true"; const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || (!targetIsRemote && process.env.GOOGLE_GENAI_USE_GCA === "true");
if ( if (
isNonEmpty(configGeminiApiKey) || isNonEmpty(configGeminiApiKey) ||
isNonEmpty(hostGeminiApiKey) || isNonEmpty(hostGeminiApiKey) ||
@ -152,8 +174,9 @@ export async function testEnvironment(
} }
if (extraArgs.length > 0) args.push(...extraArgs); if (extraArgs.length > 0) args.push(...extraArgs);
const probe = await runChildProcess( const probe = await runAdapterExecutionTargetProcess(
`gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, runId,
target,
command, command,
args, args,
{ {

View file

@ -8,11 +8,15 @@ import {
asString, asString,
asStringArray, asStringArray,
parseObject, parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv, ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils"; } 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 { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
import { parseOpenCodeJsonl } from "./parse.js"; import { parseOpenCodeJsonl } from "./parse.js";
import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js";
@ -58,10 +62,28 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = []; const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config); const config = parseObject(ctx.config);
const command = asString(config.command, "opencode"); 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 { try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: false }); await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: false,
});
checks.push({ checks.push({
code: "opencode_cwd_valid", code: "opencode_cwd_valid",
level: "info", level: "info",
@ -115,7 +137,7 @@ export async function testEnvironment(
}); });
} else { } else {
try { try {
await ensureCommandResolvable(command, cwd, runtimeEnv); await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({ checks.push({
code: "opencode_command_resolvable", code: "opencode_command_resolvable",
level: "info", level: "info",
@ -137,7 +159,19 @@ export async function testEnvironment(
let modelValidationPassed = false; let modelValidationPassed = false;
const configuredModel = asString(config.model, "").trim(); 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 { try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) { if (discovered.length > 0) {
@ -173,7 +207,7 @@ export async function testEnvironment(
}); });
} }
} }
} else if (canRunProbe && !configuredModel) { } else if (!targetIsRemote && canRunProbe && !configuredModel) {
try { try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) { if (discovered.length > 0) {
@ -207,7 +241,7 @@ export async function testEnvironment(
const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable"); const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable");
if (!configuredModel && !modelUnavailable) { if (!configuredModel && !modelUnavailable) {
// No model configured skip model requirement if no model-related checks exist // No model configured skip model requirement if no model-related checks exist
} else if (configuredModel && canRunProbe) { } else if (!targetIsRemote && configuredModel && canRunProbe) {
try { try {
await ensureOpenCodeModelConfiguredAndAvailable({ await ensureOpenCodeModelConfiguredAndAvailable({
model: configuredModel, model: configuredModel,
@ -246,8 +280,9 @@ export async function testEnvironment(
if (extraArgs.length > 0) args.push(...extraArgs); if (extraArgs.length > 0) args.push(...extraArgs);
try { try {
const probe = await runChildProcess( const probe = await runAdapterExecutionTargetProcess(
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, runId,
target,
command, command,
args, args,
{ {

View file

@ -5,15 +5,17 @@ import type {
} from "@paperclipai/adapter-utils"; } from "@paperclipai/adapter-utils";
import { import {
asString, asString,
asStringArray,
parseObject, parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv, ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils"; } from "@paperclipai/adapter-utils/server-utils";
import { import {
asStringArray, ensureAdapterExecutionTargetCommandResolvable,
} from "@paperclipai/adapter-utils/server-utils"; ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
describeAdapterExecutionTarget,
resolveAdapterExecutionTargetCwd,
} from "@paperclipai/adapter-utils/execution-target";
import { discoverPiModelsCached } from "./models.js"; import { discoverPiModelsCached } from "./models.js";
import { parsePiJsonl } from "./parse.js"; import { parsePiJsonl } from "./parse.js";
@ -78,10 +80,28 @@ export async function testEnvironment(
const checks: AdapterEnvironmentCheck[] = []; const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config); const config = parseObject(ctx.config);
const command = asString(config.command, "pi"); 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 { try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: false }); await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: false,
});
checks.push({ checks.push({
code: "pi_cwd_valid", code: "pi_cwd_valid",
level: "info", level: "info",
@ -113,7 +133,7 @@ export async function testEnvironment(
}); });
} else { } else {
try { try {
await ensureCommandResolvable(command, cwd, runtimeEnv); await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({ checks.push({
code: "pi_command_resolvable", code: "pi_command_resolvable",
level: "info", level: "info",
@ -132,7 +152,10 @@ export async function testEnvironment(
const canRunProbe = const canRunProbe =
checks.every((check) => check.code !== "pi_cwd_invalid" && check.code !== "pi_command_unresolvable"); 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 { try {
const discovered = await discoverPiModelsCached({ command, cwd, env: runtimeEnv }); const discovered = await discoverPiModelsCached({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) { if (discovered.length > 0) {
@ -166,6 +189,12 @@ export async function testEnvironment(
message: "Pi requires a configured model in provider/model format.", message: "Pi requires a configured model in provider/model format.",
hint: "Set adapterConfig.model using an ID from `pi --list-models`.", 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) { } else if (canRunProbe) {
// Verify model is in the list // Verify model is in the list
try { try {
@ -218,8 +247,9 @@ export async function testEnvironment(
if (extraArgs.length > 0) args.push(...extraArgs); if (extraArgs.length > 0) args.push(...extraArgs);
try { try {
const probe = await runChildProcess( const probe = await runAdapterExecutionTargetProcess(
`pi-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, runId,
target,
command, command,
args, args,
{ {

View file

@ -132,6 +132,13 @@ export type ResetAgentSession = z.infer<typeof resetAgentSessionSchema>;
export const testAdapterEnvironmentSchema = z.object({ export const testAdapterEnvironmentSchema = z.object({
adapterConfig: adapterConfigSchema.optional().default({}), 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<typeof testAdapterEnvironmentSchema>; export type TestAdapterEnvironment = z.infer<typeof testAdapterEnvironmentSchema>;

View file

@ -180,4 +180,42 @@ describe("claude_local environment diagnostics", () => {
expect(stats.isDirectory()).toBe(true); expect(stats.isDirectory()).toBe(true);
await fs.rm(path.dirname(cwd), { recursive: true, force: 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);
});
}); });

View file

@ -54,6 +54,9 @@ import {
} from "./workspace-command-authz.js"; } from "./workspace-command-authz.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { environmentService } from "../services/environments.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 { secretService } from "../services/secrets.js";
import { import {
detectAdapterModel, 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() { async function getCurrentUserRedactionOptions() {
return { return {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
@ -977,6 +1085,10 @@ export function agentRoutes(
const inputAdapterConfig = const inputAdapterConfig =
(req.body?.adapterConfig ?? {}) as Record<string, unknown>; (req.body?.adapterConfig ?? {}) as Record<string, unknown>;
const requestedEnvironmentId =
typeof req.body?.environmentId === "string" && req.body.environmentId.trim().length > 0
? (req.body.environmentId as string)
: null;
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
companyId, companyId,
inputAdapterConfig, inputAdapterConfig,
@ -987,12 +1099,32 @@ export function agentRoutes(
normalizedAdapterConfig, normalizedAdapterConfig,
); );
const { executionTarget, environmentName, fallbackChecks } =
await resolveAdapterTestExecutionContext({
companyId,
adapterType: type,
environmentId: requestedEnvironmentId,
});
const result = await adapter.testEnvironment({ const result = await adapter.testEnvironment({
companyId, companyId,
adapterType: type, adapterType: type,
config: runtimeAdapterConfig, 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); res.json(result);
}, },
); );

View file

@ -27,6 +27,7 @@ import { Costs } from "./pages/Costs";
import { Activity } from "./pages/Activity"; import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox"; import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings"; import { CompanySettings } from "./pages/CompanySettings";
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
import { CompanyAccess } from "./pages/CompanyAccess"; import { CompanyAccess } from "./pages/CompanyAccess";
import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanyInvites } from "./pages/CompanyInvites";
import { CompanySkills } from "./pages/CompanySkills"; import { CompanySkills } from "./pages/CompanySkills";
@ -64,6 +65,7 @@ function boardRoutes() {
<Route path="onboarding" element={<OnboardingRoutePage />} /> <Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="companies" element={<Companies />} /> <Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} /> <Route path="company/settings" element={<CompanySettings />} />
<Route path="company/settings/environments" element={<CompanyEnvironments />} />
<Route path="company/settings/access" element={<CompanyAccess />} /> <Route path="company/settings/access" element={<CompanyAccess />} />
<Route path="company/settings/invites" element={<CompanyInvites />} /> <Route path="company/settings/invites" element={<CompanyInvites />} />
<Route path="company/export/*" element={<CompanyExport />} /> <Route path="company/export/*" element={<CompanyExport />} />

View file

@ -175,7 +175,10 @@ export const agentsApi = {
testEnvironment: ( testEnvironment: (
companyId: string, companyId: string,
type: string, type: string,
data: { adapterConfig: Record<string, unknown> }, data: {
adapterConfig: Record<string, unknown>;
environmentId?: string | null;
},
) => ) =>
api.post<AdapterEnvironmentTestResult>( api.post<AdapterEnvironmentTestResult>(
`/companies/${companyId}/adapters/${type}/test-environment`, `/companies/${companyId}/adapters/${type}/test-environment`,

View file

@ -70,6 +70,12 @@ type AgentConfigFormProps = {
onDirtyChange?: (dirty: boolean) => void; onDirtyChange?: (dirty: boolean) => void;
onSaveActionChange?: (save: (() => void) | null) => void; onSaveActionChange?: (save: (() => void) | null) => void;
onCancelActionChange?: (cancel: (() => 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; hideInlineSave?: boolean;
showAdapterTypeField?: boolean; showAdapterTypeField?: boolean;
showAdapterTestEnvironmentButton?: boolean; showAdapterTestEnvironmentButton?: boolean;
@ -176,6 +182,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const cards = props.sectionLayout === "cards"; const cards = props.sectionLayout === "cards";
const showAdapterTypeField = props.showAdapterTypeField ?? true; const showAdapterTypeField = props.showAdapterTypeField ?? true;
const showAdapterTestEnvironmentButton = props.showAdapterTestEnvironmentButton ?? true; const showAdapterTestEnvironmentButton = props.showAdapterTestEnvironmentButton ?? true;
const showInlineAdapterTestEnvironmentButton =
showAdapterTestEnvironmentButton && !props.onTestActionChange;
const showInlineAdapterTestEnvironmentFeedback = !props.onTestFeedbackChange;
const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true; const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true;
const hideInstructionsFile = props.hideInstructionsFile ?? false; const hideInstructionsFile = props.hideInstructionsFile ?? false;
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
@ -398,11 +407,62 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
if (!selectedCompanyId) { if (!selectedCompanyId) {
throw new Error("Select a company to test adapter environment"); 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, { return agentsApi.testEnvironment(selectedCompanyId, adapterType, {
adapterConfig: buildAdapterConfigForTest(), 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 // Current model for display
const currentModelId = isCreate const currentModelId = isCreate
@ -618,16 +678,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? <h3 className="text-sm font-medium">Adapter</h3> ? <h3 className="text-sm font-medium">Adapter</h3>
: <span className="text-xs font-medium text-muted-foreground">Adapter</span> : <span className="text-xs font-medium text-muted-foreground">Adapter</span>
} }
{showAdapterTestEnvironmentButton && ( {showInlineAdapterTestEnvironmentButton && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="h-7 px-2.5 text-xs" className="h-7 px-2.5 text-xs"
onClick={() => testEnvironment.mutate()} onClick={triggerTestEnvironment}
disabled={testEnvironment.isPending || !selectedCompanyId} disabled={testEnvironmentDisabled}
> >
{testEnvironment.isPending ? "Testing..." : "Test environment"} {testEnvironment.isPending ? "Testing..." : "Test"}
</Button> </Button>
)} )}
</div> </div>
@ -687,7 +747,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</Field> </Field>
)} )}
{testEnvironment.error && ( {showInlineAdapterTestEnvironmentFeedback && testEnvironment.error && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"> <div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{testEnvironment.error instanceof Error {testEnvironment.error instanceof Error
? testEnvironment.error.message ? testEnvironment.error.message
@ -695,7 +755,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</div> </div>
)} )}
{testEnvironment.data && ( {showInlineAdapterTestEnvironmentFeedback && testEnvironment.data && (
<AdapterEnvironmentResult result={testEnvironment.data} /> <AdapterEnvironmentResult result={testEnvironment.data} />
)} )}
@ -1047,7 +1107,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
); );
} }
function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestResult }) { export function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestResult }) {
const statusLabel = const statusLabel =
result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed"; result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed";
const statusClass = const statusClass =

View file

@ -105,6 +105,7 @@ describe("CompanySettingsSidebar", () => {
expect(container.textContent).toContain("Paperclip"); expect(container.textContent).toContain("Paperclip");
expect(container.textContent).toContain("Company Settings"); expect(container.textContent).toContain("Company Settings");
expect(container.textContent).toContain("General"); expect(container.textContent).toContain("General");
expect(container.textContent).toContain("Environments");
expect(container.textContent).toContain("Access"); expect(container.textContent).toContain("Access");
expect(container.textContent).toContain("Invites"); expect(container.textContent).toContain("Invites");
expect(sidebarNavItemMock).toHaveBeenCalledWith( expect(sidebarNavItemMock).toHaveBeenCalledWith(
@ -114,6 +115,13 @@ describe("CompanySettingsSidebar", () => {
end: true, end: true,
}), }),
); );
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings/environments",
label: "Environments",
end: true,
}),
);
expect(sidebarNavItemMock).toHaveBeenCalledWith( expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
to: "/company/settings/access", to: "/company/settings/access",

View file

@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query"; 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 { sidebarBadgesApi } from "@/api/sidebarBadges";
import { ApiError } from "@/api/client"; import { ApiError } from "@/api/client";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
@ -54,6 +54,12 @@ export function CompanySettingsSidebar() {
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2"> <nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide px-3 py-2">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<SidebarNavItem to="/company/settings" label="General" icon={SlidersHorizontal} end /> <SidebarNavItem to="/company/settings" label="General" icon={SlidersHorizontal} end />
<SidebarNavItem
to="/company/settings/environments"
label="Environments"
icon={MonitorCog}
end
/>
<SidebarNavItem <SidebarNavItem
to="/company/settings/access" to="/company/settings/access"
label="Access" label="Access"

View file

@ -58,6 +58,8 @@ describe("CompanySettingsNav", () => {
it("maps company settings routes to the expected shared tab value", () => { it("maps company settings routes to the expected shared tab value", () => {
expect(getCompanySettingsTab("/company/settings")).toBe("general"); expect(getCompanySettingsTab("/company/settings")).toBe("general");
expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general"); expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general");
expect(getCompanySettingsTab("/company/settings/environments")).toBe("environments");
expect(getCompanySettingsTab("/PAP/company/settings/environments")).toBe("environments");
expect(getCompanySettingsTab("/company/settings/access")).toBe("access"); expect(getCompanySettingsTab("/company/settings/access")).toBe("access");
expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("access"); expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("access");
expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites"); expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites");
@ -77,6 +79,7 @@ describe("CompanySettingsNav", () => {
value: "access", value: "access",
items: [ items: [
{ value: "general", label: "General" }, { value: "general", label: "General" },
{ value: "environments", label: "Environments" },
{ value: "access", label: "Access" }, { value: "access", label: "Access" },
{ value: "invites", label: "Invites" }, { value: "invites", label: "Invites" },
], ],

View file

@ -4,6 +4,7 @@ import { useLocation, useNavigate } from "@/lib/router";
const items = [ const items = [
{ value: "general", label: "General", href: "/company/settings" }, { value: "general", label: "General", href: "/company/settings" },
{ value: "environments", label: "Environments", href: "/company/settings/environments" },
{ value: "access", label: "Access", href: "/company/settings/access" }, { value: "access", label: "Access", href: "/company/settings/access" },
{ value: "invites", label: "Invites", href: "/company/settings/invites" }, { value: "invites", label: "Invites", href: "/company/settings/invites" },
] as const; ] as const;
@ -11,6 +12,10 @@ const items = [
type CompanySettingsTab = (typeof items)[number]["value"]; type CompanySettingsTab = (typeof items)[number]["value"];
export function getCompanySettingsTab(pathname: string): CompanySettingsTab { export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
if (pathname.includes("/company/settings/environments")) {
return "environments";
}
if (pathname.includes("/company/settings/access")) { if (pathname.includes("/company/settings/access")) {
return "access"; return "access";
} }

View file

@ -0,0 +1,805 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AGENT_ADAPTER_TYPES,
getAdapterEnvironmentSupport,
type Environment,
type EnvironmentProbeResult,
type JsonSchema,
} from "@paperclipai/shared";
import { Check, Settings } from "lucide-react";
import { environmentsApi } from "@/api/environments";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { secretsApi } from "@/api/secrets";
import { Button } from "@/components/ui/button";
import { JsonSchemaForm, getDefaultValues, validateJsonSchemaForm } from "@/components/JsonSchemaForm";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import {
Field,
ToggleField,
adapterLabels,
} from "../components/agent-config-primitives";
type EnvironmentFormState = {
name: string;
description: string;
driver: "local" | "ssh" | "sandbox";
sshHost: string;
sshPort: string;
sshUsername: string;
sshRemoteWorkspacePath: string;
sshPrivateKey: string;
sshPrivateKeySecretId: string;
sshKnownHosts: string;
sshStrictHostKeyChecking: boolean;
sandboxProvider: string;
sandboxConfig: Record<string, unknown>;
};
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,
}
: form.driver === "sandbox"
? {
provider: form.sandboxProvider.trim(),
...form.sandboxConfig,
}
: {},
} as const;
}
function createEmptyEnvironmentForm(): EnvironmentFormState {
return {
name: "",
description: "",
driver: "ssh",
sshHost: "",
sshPort: "22",
sshUsername: "",
sshRemoteWorkspacePath: "",
sshPrivateKey: "",
sshPrivateKeySecretId: "",
sshKnownHosts: "",
sshStrictHostKeyChecking: true,
sandboxProvider: "",
sandboxConfig: {},
};
}
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 readSandboxConfig(environment: Environment) {
const config = environment.config ?? {};
const { provider: rawProvider, ...providerConfig } = config;
return {
provider: typeof rawProvider === "string" && rawProvider.trim().length > 0
? rawProvider
: "fake",
config: providerConfig,
};
}
function normalizeJsonSchema(schema: unknown): JsonSchema | null {
return schema && typeof schema === "object" && !Array.isArray(schema)
? schema as JsonSchema
: null;
}
function summarizeSandboxConfig(config: Record<string, unknown>): string | null {
for (const key of ["template", "image", "region", "workspacePath"]) {
const value = config[key];
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
}
return null;
}
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>
);
}
export function CompanyEnvironments() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [editingEnvironmentId, setEditingEnvironmentId] = useState<string | null>(null);
const [environmentForm, setEnvironmentForm] = useState<EnvironmentFormState>(createEmptyEnvironmentForm);
const [probeResults, setProbeResults] = useState<Record<string, EnvironmentProbeResult | null>>({});
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/company/settings" },
{ label: "Environments" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
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),
});
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",
});
},
});
useEffect(() => {
setEditingEnvironmentId(null);
setEnvironmentForm(createEmptyEnvironmentForm());
setProbeResults({});
}, [selectedCompanyId]);
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;
}
if (environment.driver === "sandbox") {
const sandbox = readSandboxConfig(environment);
setEnvironmentForm({
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "sandbox",
sandboxProvider: sandbox.provider,
sandboxConfig: sandbox.config,
});
return;
}
setEnvironmentForm({
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "local",
});
}
function handleCancelEnvironmentEdit() {
setEditingEnvironmentId(null);
setEnvironmentForm(createEmptyEnvironmentForm());
}
const discoveredPluginSandboxProviders = Object.entries(environmentCapabilities?.sandboxProviders ?? {})
.filter(([provider, capability]) => provider !== "fake" && capability.supportsRunExecution)
.map(([provider, capability]) => ({
provider,
displayName: capability.displayName || provider,
description: capability.description,
configSchema: normalizeJsonSchema(capability.configSchema),
}))
.sort((left, right) => left.displayName.localeCompare(right.displayName));
const sandboxCreationEnabled = discoveredPluginSandboxProviders.length > 0;
const sandboxSupportVisible = sandboxCreationEnabled;
const pluginSandboxProviders =
environmentForm.sandboxProvider.trim().length > 0 &&
environmentForm.sandboxProvider !== "fake" &&
!discoveredPluginSandboxProviders.some((provider) => provider.provider === environmentForm.sandboxProvider)
? [
...discoveredPluginSandboxProviders,
{ provider: environmentForm.sandboxProvider, displayName: environmentForm.sandboxProvider, description: undefined, configSchema: null },
]
: discoveredPluginSandboxProviders;
const selectedSandboxProvider = pluginSandboxProviders.find(
(provider) => provider.provider === environmentForm.sandboxProvider,
) ?? null;
const selectedSandboxSchema = selectedSandboxProvider?.configSchema ?? null;
const sandboxConfigErrors =
environmentForm.driver === "sandbox" && selectedSandboxSchema
? validateJsonSchemaForm(selectedSandboxSchema as any, environmentForm.sandboxConfig)
: {};
useEffect(() => {
if (environmentForm.driver !== "sandbox") return;
if (environmentForm.sandboxProvider.trim().length > 0 && environmentForm.sandboxProvider !== "fake") return;
const firstProvider = discoveredPluginSandboxProviders[0]?.provider;
if (!firstProvider) return;
const firstSchema = discoveredPluginSandboxProviders[0]?.configSchema;
setEnvironmentForm((current) => (
current.driver !== "sandbox" || (current.sandboxProvider.trim().length > 0 && current.sandboxProvider !== "fake")
? current
: {
...current,
sandboxProvider: firstProvider,
sandboxConfig: firstSchema ? getDefaultValues(firstSchema as any) : {},
}
));
}, [discoveredPluginSandboxProviders, environmentForm.driver, environmentForm.sandboxProvider]);
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
)) &&
(environmentForm.driver !== "sandbox" ||
environmentForm.sandboxProvider.trim().length > 0 &&
environmentForm.sandboxProvider !== "fake" &&
Object.keys(sandboxConfigErrors).length === 0);
if (!selectedCompanyId) {
return <div className="text-sm text-muted-foreground">Select a company to manage environments.</div>;
}
if (!environmentsEnabled) {
return (
<div className="max-w-3xl space-y-4">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Company Environments</h1>
</div>
<div className="rounded-md border border-border px-4 py-4 text-sm text-muted-foreground">
Enable Environments in instance experimental settings to manage company execution targets.
</div>
</div>
);
}
return (
<div className="max-w-5xl space-y-6" data-testid="company-settings-environments-section">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Company Environments</h1>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
Define reusable execution targets for projects, issue workspaces, and remote-capable adapters.
</p>
</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 is always available for
remote-managed adapters, and sandbox environments appear only when a run-capable sandbox provider plugin is
installed.
</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>
{sandboxSupportVisible ? (
<th className="px-3 py-2 font-medium">Sandbox</th>
) : null}
</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>
{sandboxSupportVisible ? (
<td className="px-3 py-2">
<SupportMark
supported={discoveredPluginSandboxProviders.some((provider) =>
support.sandboxProviders[provider.provider] === "supported")}
/>
</td>
) : null}
</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>
) : environment.driver === "sandbox" ? (
<div className="text-xs text-muted-foreground">
{(() => {
const provider =
typeof environment.config.provider === "string" ? environment.config.provider : "sandbox";
const displayName =
environmentCapabilities?.sandboxProviders?.[provider]?.displayName ?? provider;
const summary = summarizeSandboxConfig(environment.config as Record<string, unknown>);
return `${displayName} sandbox provider${summary ? ` · ${summary}` : ""}`;
})()}
</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..."
: environment.driver === "ssh"
? "Test connection"
: "Test provider"}
</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. Sandbox stores plugin-backed provider config on the shared environment seam.">
<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,
sandboxProvider:
e.target.value === "sandbox"
? current.sandboxProvider.trim() || discoveredPluginSandboxProviders[0]?.provider || ""
: current.sandboxProvider,
sandboxConfig:
e.target.value === "sandbox"
? (
current.sandboxProvider.trim().length > 0 && current.driver === "sandbox"
? current.sandboxConfig
: discoveredPluginSandboxProviders[0]?.configSchema
? getDefaultValues(discoveredPluginSandboxProviders[0].configSchema as any)
: {}
)
: current.sandboxConfig,
driver:
e.target.value === "local"
? "local"
: e.target.value === "sandbox"
? "sandbox"
: "ssh",
}))}
>
<option value="ssh">SSH</option>
{sandboxCreationEnabled || environmentForm.driver === "sandbox" ? (
<option value="sandbox">Sandbox</option>
) : null}
<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}
{environmentForm.driver === "sandbox" ? (
<div className="grid gap-3 md:grid-cols-2">
<Field label="Provider" hint="Installed run-capable sandbox provider plugins appear here.">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.sandboxProvider}
onChange={(e) => {
const nextProviderKey = e.target.value;
const nextProvider = pluginSandboxProviders.find((provider) => provider.provider === nextProviderKey) ?? null;
setEnvironmentForm((current) => ({
...current,
sandboxProvider: nextProviderKey,
sandboxConfig:
current.sandboxProvider === nextProviderKey
? current.sandboxConfig
: nextProvider?.configSchema
? getDefaultValues(nextProvider.configSchema as any)
: {},
}));
}}
>
{pluginSandboxProviders.map((provider) => (
<option key={provider.provider} value={provider.provider}>
{provider.displayName}
</option>
))}
</select>
</Field>
<div className="md:col-span-2 space-y-3">
{selectedSandboxProvider?.description ? (
<div className="text-xs text-muted-foreground">
{selectedSandboxProvider.description}
</div>
) : null}
{selectedSandboxSchema ? (
<JsonSchemaForm
schema={selectedSandboxSchema as any}
values={environmentForm.sandboxConfig}
onChange={(values) =>
setEnvironmentForm((current) => ({ ...current, sandboxConfig: values }))}
errors={sandboxConfigErrors}
/>
) : (
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
This provider does not declare additional configuration fields.
</div>
)}
</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>
);
}

View file

@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AGENT_ADAPTER_TYPES, getEnvironmentCapabilities } from "@paperclipai/shared"; import { AGENT_ADAPTER_TYPES, getEnvironmentCapabilities } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CompanySettings } from "./CompanySettings"; import { CompanyEnvironments } from "./CompanyEnvironments";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
const mockCompaniesApi = vi.hoisted(() => ({ const mockCompaniesApi = vi.hoisted(() => ({
@ -105,7 +105,7 @@ async function flushReact() {
}); });
} }
describe("CompanySettings", () => { describe("CompanyEnvironments", () => {
let container: HTMLDivElement; let container: HTMLDivElement;
beforeEach(() => { beforeEach(() => {
@ -146,7 +146,7 @@ describe("CompanySettings", () => {
root.render( root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TooltipProvider> <TooltipProvider>
<CompanySettings /> <CompanyEnvironments />
</TooltipProvider> </TooltipProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
@ -212,7 +212,7 @@ describe("CompanySettings", () => {
root.render( root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TooltipProvider> <TooltipProvider>
<CompanySettings /> <CompanyEnvironments />
</TooltipProvider> </TooltipProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );

View file

@ -1,31 +1,18 @@
import { ChangeEvent, useEffect, useState } from "react"; import { ChangeEvent, useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AGENT_ADAPTER_TYPES,
getAdapterEnvironmentSupport,
type Environment,
type EnvironmentProbeResult,
type JsonSchema,
} from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } 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";
import { CompanyPatternIcon } from "../components/CompanyPatternIcon"; import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
import { JsonSchemaForm, getDefaultValues, validateJsonSchemaForm } from "@/components/JsonSchemaForm";
import { import {
Field, Field,
ToggleField, ToggleField,
HintIcon, HintIcon,
adapterLabels,
} from "../components/agent-config-primitives"; } from "../components/agent-config-primitives";
type AgentSnippetInput = { type AgentSnippetInput = {
@ -34,141 +21,6 @@ type AgentSnippetInput = {
testResolutionUrl?: string | null; testResolutionUrl?: string | null;
}; };
type EnvironmentFormState = {
name: string;
description: string;
driver: "local" | "ssh" | "sandbox";
sshHost: string;
sshPort: string;
sshUsername: string;
sshRemoteWorkspacePath: string;
sshPrivateKey: string;
sshPrivateKeySecretId: string;
sshKnownHosts: string;
sshStrictHostKeyChecking: boolean;
sandboxProvider: string;
sandboxConfig: Record<string, unknown>;
};
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,
}
: form.driver === "sandbox"
? {
provider: form.sandboxProvider.trim(),
...form.sandboxConfig,
}
: {},
} as const;
}
function createEmptyEnvironmentForm(): EnvironmentFormState {
return {
name: "",
description: "",
driver: "ssh",
sshHost: "",
sshPort: "22",
sshUsername: "",
sshRemoteWorkspacePath: "",
sshPrivateKey: "",
sshPrivateKeySecretId: "",
sshKnownHosts: "",
sshStrictHostKeyChecking: true,
sandboxProvider: "",
sandboxConfig: {},
};
}
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 readSandboxConfig(environment: Environment) {
const config = environment.config ?? {};
const { provider: rawProvider, ...providerConfig } = config;
return {
provider: typeof rawProvider === "string" && rawProvider.trim().length > 0
? rawProvider
: "fake",
config: providerConfig,
};
}
function normalizeJsonSchema(schema: unknown): JsonSchema | null {
return schema && typeof schema === "object" && !Array.isArray(schema)
? schema as JsonSchema
: null;
}
function summarizeSandboxConfig(config: Record<string, unknown>): string | null {
for (const key of ["template", "image", "region", "workspacePath"]) {
const value = config[key];
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
}
return null;
}
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>
);
}
export function CompanySettings() { export function CompanySettings() {
const { const {
companies, companies,
@ -177,7 +29,6 @@ export function CompanySettings() {
setSelectedCompanyId setSelectedCompanyId
} = useCompany(); } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// General settings local state // General settings local state
const [companyName, setCompanyName] = useState(""); const [companyName, setCompanyName] = useState("");
@ -185,9 +36,6 @@ 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(() => {
@ -203,30 +51,6 @@ 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),
});
const generalDirty = const generalDirty =
!!selectedCompany && !!selectedCompany &&
(companyName !== selectedCompany.name || (companyName !== selectedCompany.name ||
@ -331,90 +155,6 @@ 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 = "";
@ -432,9 +172,6 @@ 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({
@ -481,113 +218,6 @@ 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;
}
if (environment.driver === "sandbox") {
const sandbox = readSandboxConfig(environment);
setEnvironmentForm({
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "sandbox",
sandboxProvider: sandbox.provider,
sandboxConfig: sandbox.config,
});
return;
}
setEnvironmentForm({
...createEmptyEnvironmentForm(),
name: environment.name,
description: environment.description ?? "",
driver: "local",
});
}
function handleCancelEnvironmentEdit() {
setEditingEnvironmentId(null);
setEnvironmentForm(createEmptyEnvironmentForm());
}
const discoveredPluginSandboxProviders = Object.entries(environmentCapabilities?.sandboxProviders ?? {})
.filter(([provider, capability]) => provider !== "fake" && capability.supportsRunExecution)
.map(([provider, capability]) => ({
provider,
displayName: capability.displayName || provider,
description: capability.description,
configSchema: normalizeJsonSchema(capability.configSchema),
}))
.sort((left, right) => left.displayName.localeCompare(right.displayName));
const sandboxCreationEnabled = discoveredPluginSandboxProviders.length > 0;
const sandboxSupportVisible = sandboxCreationEnabled;
const pluginSandboxProviders =
environmentForm.sandboxProvider.trim().length > 0 &&
environmentForm.sandboxProvider !== "fake" &&
!discoveredPluginSandboxProviders.some((provider) => provider.provider === environmentForm.sandboxProvider)
? [
...discoveredPluginSandboxProviders,
{ provider: environmentForm.sandboxProvider, displayName: environmentForm.sandboxProvider, description: undefined, configSchema: null },
]
: discoveredPluginSandboxProviders;
const selectedSandboxProvider = pluginSandboxProviders.find(
(provider) => provider.provider === environmentForm.sandboxProvider,
) ?? null;
const selectedSandboxSchema = selectedSandboxProvider?.configSchema ?? null;
const sandboxConfigErrors =
environmentForm.driver === "sandbox" && selectedSandboxSchema
? validateJsonSchemaForm(selectedSandboxSchema as any, environmentForm.sandboxConfig)
: {};
useEffect(() => {
if (environmentForm.driver !== "sandbox") return;
if (environmentForm.sandboxProvider.trim().length > 0 && environmentForm.sandboxProvider !== "fake") return;
const firstProvider = discoveredPluginSandboxProviders[0]?.provider;
if (!firstProvider) return;
const firstSchema = discoveredPluginSandboxProviders[0]?.configSchema;
setEnvironmentForm((current) => (
current.driver !== "sandbox" || (current.sandboxProvider.trim().length > 0 && current.sandboxProvider !== "fake")
? current
: {
...current,
sandboxProvider: firstProvider,
sandboxConfig: firstSchema ? getDefaultValues(firstSchema as any) : {},
}
));
}, [discoveredPluginSandboxProviders, environmentForm.driver, environmentForm.sandboxProvider]);
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
)) &&
(environmentForm.driver !== "sandbox" ||
environmentForm.sandboxProvider.trim().length > 0 &&
environmentForm.sandboxProvider !== "fake" &&
Object.keys(sandboxConfigErrors).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">
@ -744,388 +374,6 @@ 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 is always available for
remote-managed adapters, and sandbox environments appear only when a run-capable sandbox provider plugin is
installed.
</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>
{sandboxSupportVisible ? (
<th className="px-3 py-2 font-medium">Sandbox</th>
) : null}
</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>
{sandboxSupportVisible ? (
<td className="px-3 py-2">
<SupportMark
supported={discoveredPluginSandboxProviders.some((provider) =>
support.sandboxProviders[provider.provider] === "supported")}
/>
</td>
) : null}
</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>
) : environment.driver === "sandbox" ? (
<div className="text-xs text-muted-foreground">
{(() => {
const provider =
typeof environment.config.provider === "string" ? environment.config.provider : "sandbox";
const displayName =
environmentCapabilities?.sandboxProviders?.[provider]?.displayName ?? provider;
const summary = summarizeSandboxConfig(environment.config as Record<string, unknown>);
return `${displayName} sandbox provider${summary ? ` · ${summary}` : ""}`;
})()}
</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..."
: environment.driver === "ssh"
? "Test connection"
: "Test provider"}
</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. Sandbox stores plugin-backed provider config on the shared environment seam.">
<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,
sandboxProvider:
e.target.value === "sandbox"
? current.sandboxProvider.trim() || discoveredPluginSandboxProviders[0]?.provider || ""
: current.sandboxProvider,
sandboxConfig:
e.target.value === "sandbox"
? (
current.sandboxProvider.trim().length > 0 && current.driver === "sandbox"
? current.sandboxConfig
: discoveredPluginSandboxProviders[0]?.configSchema
? getDefaultValues(discoveredPluginSandboxProviders[0].configSchema as any)
: {}
)
: current.sandboxConfig,
driver:
e.target.value === "local"
? "local"
: e.target.value === "sandbox"
? "sandbox"
: "ssh",
}))}
>
<option value="ssh">SSH</option>
{sandboxCreationEnabled || environmentForm.driver === "sandbox" ? (
<option value="sandbox">Sandbox</option>
) : null}
<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}
{environmentForm.driver === "sandbox" ? (
<div className="grid gap-3 md:grid-cols-2">
<Field label="Provider" hint="Installed run-capable sandbox provider plugins appear here.">
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={environmentForm.sandboxProvider}
onChange={(e) => {
const nextProviderKey = e.target.value;
const nextProvider = pluginSandboxProviders.find((provider) => provider.provider === nextProviderKey) ?? null;
setEnvironmentForm((current) => ({
...current,
sandboxProvider: nextProviderKey,
sandboxConfig:
current.sandboxProvider === nextProviderKey
? current.sandboxConfig
: nextProvider?.configSchema
? getDefaultValues(nextProvider.configSchema as any)
: {},
}));
}}
>
{pluginSandboxProviders.map((provider) => (
<option key={provider.provider} value={provider.provider}>
{provider.displayName}
</option>
))}
</select>
</Field>
<div className="md:col-span-2 space-y-3">
{selectedSandboxProvider?.description ? (
<div className="text-xs text-muted-foreground">
{selectedSandboxProvider.description}
</div>
) : null}
{selectedSandboxSchema ? (
<JsonSchemaForm
schema={selectedSandboxSchema as any}
values={environmentForm.sandboxConfig}
onChange={(values) =>
setEnvironmentForm((current) => ({ ...current, sandboxConfig: values }))}
errors={sandboxConfigErrors}
/>
) : (
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
This provider does not declare additional configuration fields.
</div>
)}
</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">

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "@/lib/router"; import { useNavigate, useSearchParams } from "@/lib/router";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
@ -6,7 +6,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { companySkillsApi } from "../api/companySkills"; import { companySkillsApi } from "../api/companySkills";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { AGENT_ROLES } from "@paperclipai/shared"; import { AGENT_ROLES, type AdapterEnvironmentTestResult } from "@paperclipai/shared";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { import {
@ -17,7 +17,11 @@ import {
import { Shield } from "lucide-react"; import { Shield } from "lucide-react";
import { cn, agentUrl } from "../lib/utils"; import { cn, agentUrl } from "../lib/utils";
import { roleLabels } from "../components/agent-config-primitives"; import { roleLabels } from "../components/agent-config-primitives";
import { AgentConfigForm, type CreateConfigValues } from "../components/AgentConfigForm"; import {
AgentConfigForm,
AdapterEnvironmentResult,
type CreateConfigValues,
} from "../components/AgentConfigForm";
import { defaultCreateValues } from "../components/agent-config-defaults"; import { defaultCreateValues } from "../components/agent-config-defaults";
import { getUIAdapter, listUIAdapters } from "../adapters"; import { getUIAdapter, listUIAdapters } from "../adapters";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
@ -66,6 +70,15 @@ export function NewAgent() {
const [selectedSkillKeys, setSelectedSkillKeys] = useState<string[]>([]); const [selectedSkillKeys, setSelectedSkillKeys] = useState<string[]>([]);
const [roleOpen, setRoleOpen] = useState(false); const [roleOpen, setRoleOpen] = useState(false);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [testAgentAction, setTestAgentAction] = useState<(() => void) | null>(null);
const [testAgentState, setTestAgentState] = useState({ disabled: true, pending: false });
const [testAgentFeedback, setTestAgentFeedback] = useState<{
errorMessage: string | null;
result: AdapterEnvironmentTestResult | null;
}>({
errorMessage: null,
result: null,
});
const { data: agents } = useQuery({ const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!), queryKey: queryKeys.agents.list(selectedCompanyId!),
@ -192,6 +205,21 @@ export function NewAgent() {
}); });
} }
const handleTestAgentActionChange = useCallback((fn: (() => void) | null) => {
setTestAgentAction(() => fn);
}, []);
const handleTestAgentStateChange = useCallback((state: { disabled: boolean; pending: boolean }) => {
setTestAgentState(state);
}, []);
const handleTestAgentFeedbackChange = useCallback((feedback: {
errorMessage: string | null;
result: AdapterEnvironmentTestResult | null;
}) => {
setTestAgentFeedback(feedback);
}, []);
return ( return (
<div className="mx-auto max-w-2xl space-y-6"> <div className="mx-auto max-w-2xl space-y-6">
<div> <div>
@ -268,6 +296,9 @@ export function NewAgent() {
values={configValues} values={configValues}
onChange={(patch) => setConfigValues((prev) => ({ ...prev, ...patch }))} onChange={(patch) => setConfigValues((prev) => ({ ...prev, ...patch }))}
adapterModels={adapterModels} adapterModels={adapterModels}
onTestActionChange={handleTestAgentActionChange}
onTestActionStateChange={handleTestAgentStateChange}
onTestFeedbackChange={handleTestAgentFeedbackChange}
/> />
<div className="border-t border-border px-4 py-4"> <div className="border-t border-border px-4 py-4">
@ -316,17 +347,38 @@ export function NewAgent() {
{formError && ( {formError && (
<p className="text-xs text-destructive mb-2">{formError}</p> <p className="text-xs text-destructive mb-2">{formError}</p>
)} )}
<div className="flex items-center justify-end gap-2"> <div className="space-y-3">
<Button variant="outline" size="sm" onClick={() => navigate("/agents")}> {testAgentFeedback.errorMessage && (
Cancel <div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
</Button> {testAgentFeedback.errorMessage}
<Button </div>
size="sm" )}
disabled={!name.trim() || createAgent.isPending} {testAgentFeedback.result && (
onClick={handleSubmit} <AdapterEnvironmentResult result={testAgentFeedback.result} />
> )}
{createAgent.isPending ? "Creating…" : "Create agent"} <div className="flex items-center justify-between gap-2">
</Button> <Button variant="outline" size="sm" onClick={() => navigate("/agents")}>
Cancel
</Button>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={testAgentState.disabled}
onClick={() => testAgentAction?.()}
>
{testAgentState.pending ? "Testing..." : "Test Agent"}
</Button>
<Button
size="sm"
disabled={!name.trim() || createAgent.isPending}
onClick={handleSubmit}
>
{createAgent.isPending ? "Creating…" : "Create agent"}
</Button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>