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

@@ -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,