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

@ -180,4 +180,42 @@ describe("claude_local environment diagnostics", () => {
expect(stats.isDirectory()).toBe(true);
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
});
it("defaults remote probes to the environment remote cwd when adapter cwd is unset", async () => {
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: {
command: process.execPath,
},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "test-provider",
remoteCwd: "/srv/paperclip/workspace",
runner: {
execute: async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
}),
},
},
environmentName: "Linux Box",
});
expect(result.checks.some((check) => check.code === "claude_cwd_valid")).toBe(true);
expect(
result.checks.some(
(check) =>
check.code === "claude_cwd_valid" &&
check.message === "Working directory is valid: /srv/paperclip/workspace",
),
).toBe(true);
expect(result.checks.some((check) => check.code === "claude_cwd_invalid")).toBe(false);
});
});

View file

@ -54,6 +54,9 @@ import {
} from "./workspace-command-authz.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { environmentService } from "../services/environments.js";
import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.js";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils";
import { secretService } from "../services/secrets.js";
import {
detectAdapterModel,
@ -169,6 +172,111 @@ export function agentRoutes(
});
}
/**
* Resolve the execution target the adapter should run its test probes against.
*
* - No environmentId / local environment returns a local target so the
* adapter probes the Paperclip host (legacy behavior).
* - SSH environment builds an SSH execution target from the environment
* config so the adapter probes the remote box. No lease is required:
* the SSH spec is fully derived from the saved environment config.
* - Sandbox / plugin environments currently fall back to local probing
* with a warning check, since lifting a temporary sandbox lease for an
* ad-hoc test invocation is out of scope for this iteration.
*/
async function resolveAdapterTestExecutionContext(input: {
companyId: string;
adapterType: string;
environmentId: string | null;
}): Promise<{
executionTarget: AdapterExecutionTarget | null;
environmentName: string | null;
fallbackChecks: AdapterEnvironmentCheck[];
}> {
if (!input.environmentId) {
return { executionTarget: null, environmentName: null, fallbackChecks: [] };
}
const environment = await environmentsSvc.getById(input.environmentId);
if (!environment || environment.companyId !== input.companyId) {
return {
executionTarget: null,
environmentName: null,
fallbackChecks: [
{
code: "environment_not_found",
level: "warn",
message: "Selected environment was not found. Falling back to a local probe.",
},
],
};
}
if (environment.driver === "local") {
return { executionTarget: null, environmentName: environment.name, fallbackChecks: [] };
}
if (environment.driver === "ssh") {
try {
const target = await resolveEnvironmentExecutionTarget({
db,
companyId: input.companyId,
adapterType: input.adapterType,
environment: {
id: environment.id,
driver: environment.driver,
config: environment.config ?? null,
},
leaseMetadata: null,
});
if (target) {
return { executionTarget: target, environmentName: environment.name, fallbackChecks: [] };
}
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_target_unavailable",
level: "warn",
message:
`Could not resolve an execution target for environment "${environment.name}". Falling back to a local probe.`,
},
],
};
} catch (err) {
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_target_failed",
level: "warn",
message:
`Could not connect to environment "${environment.name}" to run the test. Falling back to a local probe.`,
detail: err instanceof Error ? err.message : String(err),
},
],
};
}
}
// sandbox / plugin / other drivers: not yet supported for ad-hoc adapter tests.
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_driver_not_supported_for_test",
level: "warn",
message:
`Adapter testing inside ${environment.driver} environments is not yet supported. Falling back to a local probe; results may not reflect runs in "${environment.name}".`,
hint: "Run a real heartbeat in the environment to verify end-to-end behavior.",
},
],
};
}
async function getCurrentUserRedactionOptions() {
return {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
@ -977,6 +1085,10 @@ export function agentRoutes(
const inputAdapterConfig =
(req.body?.adapterConfig ?? {}) as Record<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(
companyId,
inputAdapterConfig,
@ -987,12 +1099,32 @@ export function agentRoutes(
normalizedAdapterConfig,
);
const { executionTarget, environmentName, fallbackChecks } =
await resolveAdapterTestExecutionContext({
companyId,
adapterType: type,
environmentId: requestedEnvironmentId,
});
const result = await adapter.testEnvironment({
companyId,
adapterType: type,
config: runtimeAdapterConfig,
executionTarget,
environmentName,
});
if (fallbackChecks.length > 0) {
const checks = [...fallbackChecks, ...result.checks];
const status: typeof result.status = checks.some((c) => c.level === "error")
? "fail"
: checks.some((c) => c.level === "warn")
? "warn"
: result.status;
res.json({ ...result, checks, status });
return;
}
res.json(result);
},
);