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:
@@ -59,6 +59,7 @@ For local adapters, set:
|
||||
- `timeoutSec` (max runtime per heartbeat)
|
||||
- `graceSec` (time before force-kill after timeout/cancel)
|
||||
- optional env vars and extra CLI args
|
||||
- use **Test environment** in agent configuration to run adapter-specific diagnostics before saving
|
||||
|
||||
## 3.4 Prompt templates
|
||||
|
||||
@@ -148,6 +149,10 @@ Typical failure causes:
|
||||
- prompt too broad or missing constraints
|
||||
- process timeout
|
||||
|
||||
Claude-specific note:
|
||||
|
||||
- If `ANTHROPIC_API_KEY` is set in adapter env or host environment, Claude uses API-key auth instead of subscription login. Paperclip surfaces this as a warning in environment tests, not a hard error.
|
||||
|
||||
## 9. Security and risk notes
|
||||
|
||||
Local CLI adapters run unsandboxed on the host machine.
|
||||
|
||||
@@ -33,6 +33,7 @@ Follows the existing `NewIssueDialog` / `NewProjectDialog` pattern: a `Dialog` c
|
||||
| Field | Control | Default | Notes |
|
||||
|-------|---------|---------|-------|
|
||||
| Adapter Type | Chip popover (select) | `claude_local` | `claude_local`, `codex_local`, `process`, `http` |
|
||||
| Test environment | Button | -- | Runs adapter-specific diagnostics and returns pass/warn/fail checks for current unsaved config |
|
||||
| CWD | Text input | -- | Working directory for local adapters |
|
||||
| Prompt Template | Textarea | -- | Supports `{{ agent.id }}`, `{{ agent.name }}` etc. |
|
||||
| Bootstrap Prompt | Textarea | -- | Optional, used for first run (no existing session) |
|
||||
|
||||
@@ -5,6 +5,11 @@ export type {
|
||||
AdapterExecutionResult,
|
||||
AdapterInvocationMeta,
|
||||
AdapterExecutionContext,
|
||||
AdapterEnvironmentCheckLevel,
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestStatus,
|
||||
AdapterEnvironmentTestResult,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterSessionCodec,
|
||||
AdapterModel,
|
||||
ServerAdapterModule,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
96
packages/adapters/claude-local/src/server/test.ts
Normal file
96
packages/adapters/claude-local/src/server/test.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
95
packages/adapters/codex-local/src/server/test.ts
Normal file
95
packages/adapters/codex-local/src/server/test.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
16
packages/shared/src/types/asset.ts
Normal file
16
packages/shared/src/types/asset.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
14
packages/shared/src/validators/asset.ts
Normal file
14
packages/shared/src/validators/asset.ts
Normal 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>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
116
server/src/adapters/http/test.ts
Normal file
116
server/src/adapters/http/test.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
89
server/src/adapters/process/test.ts
Normal file
89
server/src/adapters/process/test.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ export type {
|
||||
AdapterExecutionResult,
|
||||
AdapterInvocationMeta,
|
||||
AdapterExecutionContext,
|
||||
AdapterEnvironmentCheckLevel,
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestStatus,
|
||||
AdapterEnvironmentTestResult,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterSessionCodec,
|
||||
AdapterModel,
|
||||
ServerAdapterModule,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -95,6 +95,7 @@ interface AdapterSessionCodec {
|
||||
interface ServerAdapterModule {
|
||||
type: string;
|
||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
|
||||
sessionCodec?: AdapterSessionCodec;
|
||||
supportsLocalAgentJwt?: boolean;
|
||||
models?: { id: string; label: string }[];
|
||||
@@ -119,6 +120,48 @@ interface CLIAdapterModule {
|
||||
|
||||
---
|
||||
|
||||
## 2.1 Adapter Environment Test Contract
|
||||
|
||||
Every server adapter must implement `testEnvironment(...)`. This powers the board UI "Test environment" button in agent configuration.
|
||||
|
||||
```ts
|
||||
type AdapterEnvironmentCheckLevel = "info" | "warn" | "error";
|
||||
type AdapterEnvironmentTestStatus = "pass" | "warn" | "fail";
|
||||
|
||||
interface AdapterEnvironmentCheck {
|
||||
code: string;
|
||||
level: AdapterEnvironmentCheckLevel;
|
||||
message: string;
|
||||
detail?: string | null;
|
||||
hint?: string | null;
|
||||
}
|
||||
|
||||
interface AdapterEnvironmentTestResult {
|
||||
adapterType: string;
|
||||
status: AdapterEnvironmentTestStatus;
|
||||
checks: AdapterEnvironmentCheck[];
|
||||
testedAt: string; // ISO timestamp
|
||||
}
|
||||
|
||||
interface AdapterEnvironmentTestContext {
|
||||
companyId: string;
|
||||
adapterType: string;
|
||||
config: Record<string, unknown>; // runtime-resolved adapterConfig
|
||||
}
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Return structured diagnostics, never throw for expected findings.
|
||||
- Use `error` for invalid/unusable runtime setup (bad cwd, missing command, invalid URL).
|
||||
- Use `warn` for non-blocking but important situations.
|
||||
- Use `info` for successful checks and context.
|
||||
|
||||
Severity policy is product-critical: warnings are not save blockers.
|
||||
Example: for `claude_local`, detected `ANTHROPIC_API_KEY` must be a `warn`, not an `error`, because Claude can still run (it just uses API-key auth instead of subscription auth).
|
||||
|
||||
---
|
||||
|
||||
## 3. Step-by-Step: Creating a New Adapter
|
||||
|
||||
### 3.1 Create the Package
|
||||
@@ -269,6 +312,7 @@ Parse the agent's stdout format into structured data. Must handle:
|
||||
|
||||
```ts
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export { parseMyAgentOutput, isMyAgentUnknownSessionError } from "./parse.js";
|
||||
|
||||
// Session codec — required for session persistence
|
||||
@@ -279,6 +323,22 @@ export const sessionCodec: AdapterSessionCodec = {
|
||||
};
|
||||
```
|
||||
|
||||
#### `server/test.ts` — Environment Diagnostics
|
||||
|
||||
Implement adapter-specific preflight checks used by the UI test button.
|
||||
|
||||
Minimum expectations:
|
||||
|
||||
1. Validate required config primitives (paths, commands, URLs, auth assumptions)
|
||||
2. Return check objects with deterministic `code` values
|
||||
3. Map severity consistently (`info` / `warn` / `error`)
|
||||
4. Compute final status:
|
||||
- `fail` if any `error`
|
||||
- `warn` if no errors and at least one warning
|
||||
- `pass` otherwise
|
||||
|
||||
This operation should be lightweight and side-effect free.
|
||||
|
||||
### 3.4 UI Module
|
||||
|
||||
#### `ui/parse-stdout.ts` — Transcript Parser
|
||||
@@ -643,8 +703,9 @@ Create tests in `server/src/__tests__/<adapter-name>-adapter.test.ts`. Test:
|
||||
- [ ] `packages/adapters/<name>/package.json` with four exports (`.`, `./server`, `./ui`, `./cli`)
|
||||
- [ ] Root `index.ts` with `type`, `label`, `models`, `agentConfigurationDoc`
|
||||
- [ ] `server/execute.ts` implementing `AdapterExecutionContext -> AdapterExecutionResult`
|
||||
- [ ] `server/test.ts` implementing `AdapterEnvironmentTestContext -> AdapterEnvironmentTestResult`
|
||||
- [ ] `server/parse.ts` with output parser and unknown-session detector
|
||||
- [ ] `server/index.ts` exporting `execute`, `sessionCodec`, parse helpers
|
||||
- [ ] `server/index.ts` exporting `execute`, `testEnvironment`, `sessionCodec`, parse helpers
|
||||
- [ ] `ui/parse-stdout.ts` with `StdoutLineParser` for the run viewer
|
||||
- [ ] `ui/build-config.ts` with `CreateConfigValues -> adapterConfig` builder
|
||||
- [ ] `ui/src/adapters/<name>/config-fields.tsx` React component for agent form
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Agent,
|
||||
AdapterEnvironmentTestResult,
|
||||
AgentKeyCreated,
|
||||
AgentRuntimeState,
|
||||
AgentTaskSession,
|
||||
@@ -66,6 +67,15 @@ export const agentsApi = {
|
||||
resetSession: (id: string, taskKey?: string | null) =>
|
||||
api.post<void>(`/agents/${id}/runtime-state/reset-session`, { taskKey: taskKey ?? null }),
|
||||
adapterModels: (type: string) => api.get<AdapterModel[]>(`/adapters/${type}/models`),
|
||||
testEnvironment: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
data: { adapterConfig: Record<string, unknown> },
|
||||
) =>
|
||||
api.post<AdapterEnvironmentTestResult>(
|
||||
`/companies/${companyId}/adapters/${type}/test-environment`,
|
||||
data,
|
||||
),
|
||||
invoke: (id: string) => api.post<HeartbeatRun>(`/agents/${id}/heartbeat/invoke`, {}),
|
||||
wakeup: (
|
||||
id: string,
|
||||
|
||||
Reference in New Issue
Block a user