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

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

View File

@@ -82,9 +82,35 @@ export interface AdapterModel {
label: string;
}
export type AdapterEnvironmentCheckLevel = "info" | "warn" | "error";
export interface AdapterEnvironmentCheck {
code: string;
level: AdapterEnvironmentCheckLevel;
message: string;
detail?: string | null;
hint?: string | null;
}
export type AdapterEnvironmentTestStatus = "pass" | "warn" | "fail";
export interface AdapterEnvironmentTestResult {
adapterType: string;
status: AdapterEnvironmentTestStatus;
checks: AdapterEnvironmentCheck[];
testedAt: string;
}
export interface AdapterEnvironmentTestContext {
companyId: string;
adapterType: string;
config: Record<string, unknown>;
}
export interface ServerAdapterModule {
type: string;
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
sessionCodec?: AdapterSessionCodec;
supportsLocalAgentJwt?: boolean;
models?: AdapterModel[];

View File

@@ -1,4 +1,5 @@
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
export { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.js";
import type { AdapterSessionCodec } from "@paperclip/adapter-utils";

View File

@@ -0,0 +1,96 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclip/adapter-utils";
import {
asString,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
} from "@paperclip/adapter-utils/server-utils";
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 isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "claude");
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAbsoluteDirectory(cwd);
checks.push({
code: "claude_cwd_valid",
level: "info",
message: `Working directory is valid: ${cwd}`,
});
} catch (err) {
checks.push({
code: "claude_cwd_invalid",
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
});
}
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: "claude_command_resolvable",
level: "info",
message: `Command is executable: ${command}`,
});
} catch (err) {
checks.push({
code: "claude_command_unresolvable",
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
});
}
const configApiKey = env.ANTHROPIC_API_KEY;
const hostApiKey = process.env.ANTHROPIC_API_KEY;
if (isNonEmpty(configApiKey) || isNonEmpty(hostApiKey)) {
const source = isNonEmpty(configApiKey) ? "adapter config env" : "server environment";
checks.push({
code: "claude_anthropic_api_key_overrides_subscription",
level: "warn",
message:
"ANTHROPIC_API_KEY is set. Claude will use API-key auth instead of subscription credentials.",
detail: `Detected in ${source}.`,
hint: "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.",
});
} else {
checks.push({
code: "claude_subscription_mode_possible",
level: "info",
message: "ANTHROPIC_API_KEY is not set; subscription-based auth can be used if Claude is logged in.",
});
}
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View File

@@ -1,4 +1,5 @@
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
import type { AdapterSessionCodec } from "@paperclip/adapter-utils";

View File

@@ -0,0 +1,95 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclip/adapter-utils";
import {
asString,
parseObject,
ensureAbsoluteDirectory,
ensureCommandResolvable,
ensurePathInEnv,
} from "@paperclip/adapter-utils/server-utils";
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 isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "codex");
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAbsoluteDirectory(cwd);
checks.push({
code: "codex_cwd_valid",
level: "info",
message: `Working directory is valid: ${cwd}`,
});
} catch (err) {
checks.push({
code: "codex_cwd_invalid",
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
});
}
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: "codex_command_resolvable",
level: "info",
message: `Command is executable: ${command}`,
});
} catch (err) {
checks.push({
code: "codex_command_unresolvable",
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
});
}
const configOpenAiKey = env.OPENAI_API_KEY;
const hostOpenAiKey = process.env.OPENAI_API_KEY;
if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) {
const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment";
checks.push({
code: "codex_openai_api_key_present",
level: "info",
message: "OPENAI_API_KEY is set for Codex authentication.",
detail: `Detected in ${source}.`,
});
} else {
checks.push({
code: "codex_openai_api_key_missing",
level: "warn",
message: "OPENAI_API_KEY is not set. Codex runs may fail until authentication is configured.",
hint: "Set OPENAI_API_KEY in adapter env, shell environment, or Codex auth configuration.",
});
}
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}

View File

@@ -43,6 +43,11 @@ export type {
AgentPermissions,
AgentKeyCreated,
AgentConfigRevision,
AdapterEnvironmentCheckLevel,
AdapterEnvironmentTestStatus,
AdapterEnvironmentCheck,
AdapterEnvironmentTestResult,
AssetImage,
Project,
Issue,
IssueComment,
@@ -79,6 +84,7 @@ export {
createAgentKeySchema,
wakeAgentSchema,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
agentPermissionsSchema,
updateAgentPermissionsSchema,
type CreateAgent,
@@ -87,6 +93,7 @@ export {
type CreateAgentKey,
type WakeAgent,
type ResetAgentSession,
type TestAdapterEnvironment,
type UpdateAgentPermissions,
createProjectSchema,
updateProjectSchema,
@@ -130,8 +137,10 @@ export {
type UpdateSecret,
createCostEventSchema,
updateBudgetSchema,
createAssetImageMetadataSchema,
type CreateCostEvent,
type UpdateBudget,
type CreateAssetImageMetadata,
} from "./validators/index.js";
export { API_PREFIX, API } from "./api.js";

View File

@@ -49,3 +49,21 @@ export interface AgentConfigRevision {
afterConfig: Record<string, unknown>;
createdAt: Date;
}
export type AdapterEnvironmentCheckLevel = "info" | "warn" | "error";
export type AdapterEnvironmentTestStatus = "pass" | "warn" | "fail";
export interface AdapterEnvironmentCheck {
code: string;
level: AdapterEnvironmentCheckLevel;
message: string;
detail?: string | null;
hint?: string | null;
}
export interface AdapterEnvironmentTestResult {
adapterType: string;
status: AdapterEnvironmentTestStatus;
checks: AdapterEnvironmentCheck[];
testedAt: string;
}

View File

@@ -0,0 +1,16 @@
export interface AssetImage {
assetId: string;
companyId: string;
provider: string;
objectKey: string;
contentType: string;
byteSize: number;
sha256: string;
originalFilename: string | null;
createdByAgentId: string | null;
createdByUserId: string | null;
createdAt: Date;
updatedAt: Date;
contentPath: string;
}

View File

@@ -1,5 +1,15 @@
export type { Company } from "./company.js";
export type { Agent, AgentPermissions, AgentKeyCreated, AgentConfigRevision } from "./agent.js";
export type {
Agent,
AgentPermissions,
AgentKeyCreated,
AgentConfigRevision,
AdapterEnvironmentCheckLevel,
AdapterEnvironmentTestStatus,
AdapterEnvironmentCheck,
AdapterEnvironmentTestResult,
} from "./agent.js";
export type { AssetImage } from "./asset.js";
export type { Project } from "./project.js";
export type {
Issue,

View File

@@ -79,6 +79,12 @@ export const resetAgentSessionSchema = z.object({
export type ResetAgentSession = z.infer<typeof resetAgentSessionSchema>;
export const testAdapterEnvironmentSchema = z.object({
adapterConfig: adapterConfigSchema.optional().default({}),
});
export type TestAdapterEnvironment = z.infer<typeof testAdapterEnvironmentSchema>;
export const updateAgentPermissionsSchema = z.object({
canCreateAgents: z.boolean(),
});

View File

@@ -0,0 +1,14 @@
import { z } from "zod";
export const createAssetImageMetadataSchema = z.object({
namespace: z
.string()
.trim()
.min(1)
.max(120)
.regex(/^[a-zA-Z0-9/_-]+$/)
.optional(),
});
export type CreateAssetImageMetadata = z.infer<typeof createAssetImageMetadataSchema>;

View File

@@ -12,6 +12,7 @@ export {
createAgentKeySchema,
wakeAgentSchema,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
agentPermissionsSchema,
updateAgentPermissionsSchema,
type CreateAgent,
@@ -20,6 +21,7 @@ export {
type CreateAgentKey,
type WakeAgent,
type ResetAgentSession,
type TestAdapterEnvironment,
type UpdateAgentPermissions,
} from "./agent.js";
@@ -84,3 +86,8 @@ export {
type CreateCostEvent,
type UpdateBudget,
} from "./cost.js";
export {
createAssetImageMetadataSchema,
type CreateAssetImageMetadata,
} from "./asset.js";