Add adapter environment testing infrastructure

Introduce testEnvironment() on ServerAdapterModule with structured
pass/warn/fail diagnostics for all four adapter types (claude_local,
codex_local, process, http). Adds POST test-environment endpoint,
shared types/validators, adapter test implementations, and UI API
client. Includes asset type foundations used by related features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-20 12:50:23 -06:00
parent de3efdd16b
commit f80a802592
26 changed files with 720 additions and 6 deletions

View File

@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it } from "vitest";
import { testEnvironment } from "@paperclip/adapter-claude-local/server";
const ORIGINAL_ANTHROPIC = process.env.ANTHROPIC_API_KEY;
afterEach(() => {
if (ORIGINAL_ANTHROPIC === undefined) {
delete process.env.ANTHROPIC_API_KEY;
} else {
process.env.ANTHROPIC_API_KEY = ORIGINAL_ANTHROPIC;
}
});
describe("claude_local environment diagnostics", () => {
it("returns a warning (not an error) when ANTHROPIC_API_KEY is set in host environment", async () => {
process.env.ANTHROPIC_API_KEY = "sk-test-host";
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: {
command: process.execPath,
cwd: process.cwd(),
},
});
expect(result.status).toBe("warn");
expect(
result.checks.some(
(check) =>
check.code === "claude_anthropic_api_key_overrides_subscription" &&
check.level === "warn",
),
).toBe(true);
expect(result.checks.some((check) => check.level === "error")).toBe(false);
});
it("returns a warning (not an error) when ANTHROPIC_API_KEY is set in adapter env", async () => {
delete process.env.ANTHROPIC_API_KEY;
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: {
command: process.execPath,
cwd: process.cwd(),
env: {
ANTHROPIC_API_KEY: "sk-test-config",
},
},
});
expect(result.status).toBe("warn");
expect(
result.checks.some(
(check) =>
check.code === "claude_anthropic_api_key_overrides_subscription" &&
check.level === "warn",
),
).toBe(true);
expect(result.checks.some((check) => check.level === "error")).toBe(false);
});
});

View File

@@ -1,9 +1,11 @@
import type { ServerAdapterModule } from "../types.js";
import { execute } from "./execute.js";
import { testEnvironment } from "./test.js";
export const httpAdapter: ServerAdapterModule = {
type: "http",
execute,
testEnvironment,
models: [],
agentConfigurationDoc: `# http agent configuration

View File

@@ -0,0 +1,116 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "../types.js";
import { asString, parseObject } from "../utils.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function normalizeMethod(input: string): string {
const trimmed = input.trim();
return trimmed.length > 0 ? trimmed.toUpperCase() : "POST";
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const urlValue = asString(config.url, "");
const method = normalizeMethod(asString(config.method, "POST"));
if (!urlValue) {
checks.push({
code: "http_url_missing",
level: "error",
message: "HTTP adapter requires a URL.",
hint: "Set adapterConfig.url to an absolute http(s) endpoint.",
});
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}
let url: URL | null = null;
try {
url = new URL(urlValue);
} catch {
checks.push({
code: "http_url_invalid",
level: "error",
message: `Invalid URL: ${urlValue}`,
});
}
if (url && url.protocol !== "http:" && url.protocol !== "https:") {
checks.push({
code: "http_url_protocol_invalid",
level: "error",
message: `Unsupported URL protocol: ${url.protocol}`,
hint: "Use an http:// or https:// endpoint.",
});
}
if (url) {
checks.push({
code: "http_url_valid",
level: "info",
message: `Configured endpoint: ${url.toString()}`,
});
}
checks.push({
code: "http_method_configured",
level: "info",
message: `Configured method: ${method}`,
});
if (url && (url.protocol === "http:" || url.protocol === "https:")) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(url, {
method: "HEAD",
signal: controller.signal,
});
if (!response.ok && response.status !== 405 && response.status !== 501) {
checks.push({
code: "http_endpoint_probe_unexpected_status",
level: "warn",
message: `Endpoint probe returned HTTP ${response.status}.`,
hint: "Verify the endpoint is reachable from the Paperclip server host.",
});
} else {
checks.push({
code: "http_endpoint_probe_ok",
level: "info",
message: "Endpoint responded to a HEAD probe.",
});
}
} catch (err) {
checks.push({
code: "http_endpoint_probe_failed",
level: "warn",
message: err instanceof Error ? err.message : "Endpoint probe failed",
hint: "This may be expected in restricted networks; verify connectivity when invoking runs.",
});
} finally {
clearTimeout(timeout);
}
}
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View File

@@ -1,9 +1,14 @@
export { getServerAdapter, listAdapterModels, listServerAdapters } from "./registry.js";
export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter } from "./registry.js";
export type {
ServerAdapterModule,
AdapterExecutionContext,
AdapterExecutionResult,
AdapterInvocationMeta,
AdapterEnvironmentCheckLevel,
AdapterEnvironmentCheck,
AdapterEnvironmentTestStatus,
AdapterEnvironmentTestResult,
AdapterEnvironmentTestContext,
AdapterSessionCodec,
UsageSummary,
AdapterAgent,

View File

@@ -1,9 +1,11 @@
import type { ServerAdapterModule } from "../types.js";
import { execute } from "./execute.js";
import { testEnvironment } from "./test.js";
export const processAdapter: ServerAdapterModule = {
type: "process",
execute,
testEnvironment,
models: [],
agentConfigurationDoc: `# process agent configuration

View File

@@ -0,0 +1,89 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "../types.js";
import {
asString,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
} from "../utils.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "");
const cwd = asString(config.cwd, process.cwd());
if (!command) {
checks.push({
code: "process_command_missing",
level: "error",
message: "Process adapter requires a command.",
hint: "Set adapterConfig.command to an executable command.",
});
} else {
checks.push({
code: "process_command_present",
level: "info",
message: `Configured command: ${command}`,
});
}
try {
await ensureAbsoluteDirectory(cwd);
checks.push({
code: "process_cwd_valid",
level: "info",
message: `Working directory is valid: ${cwd}`,
});
} catch (err) {
checks.push({
code: "process_cwd_invalid",
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
});
}
if (command) {
const envConfig = parseObject(config.env);
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
await ensureCommandResolvable(command, cwd, runtimeEnv);
checks.push({
code: "process_command_resolvable",
level: "info",
message: `Command is executable: ${command}`,
});
} catch (err) {
checks.push({
code: "process_command_unresolvable",
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
});
}
}
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View File

@@ -1,7 +1,15 @@
import type { ServerAdapterModule } from "./types.js";
import { execute as claudeExecute, sessionCodec as claudeSessionCodec } from "@paperclip/adapter-claude-local/server";
import {
execute as claudeExecute,
testEnvironment as claudeTestEnvironment,
sessionCodec as claudeSessionCodec,
} from "@paperclip/adapter-claude-local/server";
import { agentConfigurationDoc as claudeAgentConfigurationDoc, models as claudeModels } from "@paperclip/adapter-claude-local";
import { execute as codexExecute, sessionCodec as codexSessionCodec } from "@paperclip/adapter-codex-local/server";
import {
execute as codexExecute,
testEnvironment as codexTestEnvironment,
sessionCodec as codexSessionCodec,
} from "@paperclip/adapter-codex-local/server";
import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclip/adapter-codex-local";
import { listCodexModels } from "./codex-models.js";
import { processAdapter } from "./process/index.js";
@@ -10,6 +18,7 @@ import { httpAdapter } from "./http/index.js";
const claudeLocalAdapter: ServerAdapterModule = {
type: "claude_local",
execute: claudeExecute,
testEnvironment: claudeTestEnvironment,
sessionCodec: claudeSessionCodec,
models: claudeModels,
supportsLocalAgentJwt: true,
@@ -19,6 +28,7 @@ const claudeLocalAdapter: ServerAdapterModule = {
const codexLocalAdapter: ServerAdapterModule = {
type: "codex_local",
execute: codexExecute,
testEnvironment: codexTestEnvironment,
sessionCodec: codexSessionCodec,
models: codexModels,
listModels: listCodexModels,
@@ -52,3 +62,7 @@ export async function listAdapterModels(type: string): Promise<{ id: string; lab
export function listServerAdapters(): ServerAdapterModule[] {
return Array.from(adaptersByType.values());
}
export function findServerAdapter(type: string): ServerAdapterModule | null {
return adaptersByType.get(type) ?? null;
}

View File

@@ -8,6 +8,11 @@ export type {
AdapterExecutionResult,
AdapterInvocationMeta,
AdapterExecutionContext,
AdapterEnvironmentCheckLevel,
AdapterEnvironmentCheck,
AdapterEnvironmentTestStatus,
AdapterEnvironmentTestResult,
AdapterEnvironmentTestContext,
AdapterSessionCodec,
AdapterModel,
ServerAdapterModule,

View File

@@ -7,6 +7,7 @@ import {
createAgentHireSchema,
createAgentSchema,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
updateAgentPermissionsSchema,
wakeAgentSchema,
updateAgentSchema,
@@ -23,7 +24,7 @@ import {
} from "../services/index.js";
import { forbidden } from "../errors.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { listAdapterModels } from "../adapters/index.js";
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
import { redactEventPayload } from "../redaction.js";
export function agentRoutes(db: Db) {
@@ -195,6 +196,42 @@ export function agentRoutes(db: Db) {
res.json(models);
});
router.post(
"/companies/:companyId/adapters/:type/test-environment",
validate(testAdapterEnvironmentSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const type = req.params.type as string;
await assertCanReadConfigurations(req, companyId);
const adapter = findServerAdapter(type);
if (!adapter) {
res.status(404).json({ error: `Unknown adapter type: ${type}` });
return;
}
const inputAdapterConfig =
(req.body?.adapterConfig ?? {}) as Record<string, unknown>;
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
companyId,
inputAdapterConfig,
{ strictMode: strictSecretsMode },
);
const runtimeAdapterConfig = await secretsSvc.resolveAdapterConfigForRuntime(
companyId,
normalizedAdapterConfig,
);
const result = await adapter.testEnvironment({
companyId,
adapterType: type,
config: runtimeAdapterConfig,
});
res.json(result);
},
);
router.get("/companies/:companyId/agents", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);