diff --git a/docs/agents-runtime.md b/docs/agents-runtime.md index a12a902e..07558a79 100644 --- a/docs/agents-runtime.md +++ b/docs/agents-runtime.md @@ -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. diff --git a/docs/specs/agent-config-ui.md b/docs/specs/agent-config-ui.md index 16d4ffd9..dc068b58 100644 --- a/docs/specs/agent-config-ui.md +++ b/docs/specs/agent-config-ui.md @@ -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) | diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 0a3f4d06..22cab24b 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -5,6 +5,11 @@ export type { AdapterExecutionResult, AdapterInvocationMeta, AdapterExecutionContext, + AdapterEnvironmentCheckLevel, + AdapterEnvironmentCheck, + AdapterEnvironmentTestStatus, + AdapterEnvironmentTestResult, + AdapterEnvironmentTestContext, AdapterSessionCodec, AdapterModel, ServerAdapterModule, diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 1f767809..b3d437b0 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -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; +} + export interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; + testEnvironment(ctx: AdapterEnvironmentTestContext): Promise; sessionCodec?: AdapterSessionCodec; supportsLocalAgentJwt?: boolean; models?: AdapterModel[]; diff --git a/packages/adapters/claude-local/src/server/index.ts b/packages/adapters/claude-local/src/server/index.ts index 870b25e0..d721462a 100644 --- a/packages/adapters/claude-local/src/server/index.ts +++ b/packages/adapters/claude-local/src/server/index.ts @@ -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"; diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts new file mode 100644 index 00000000..41d6869a --- /dev/null +++ b/packages/adapters/claude-local/src/server/test.ts @@ -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 { + 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 = {}; + 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(), + }; +} diff --git a/packages/adapters/codex-local/src/server/index.ts b/packages/adapters/codex-local/src/server/index.ts index d5fe5d1c..59560069 100644 --- a/packages/adapters/codex-local/src/server/index.ts +++ b/packages/adapters/codex-local/src/server/index.ts @@ -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"; diff --git a/packages/adapters/codex-local/src/server/test.ts b/packages/adapters/codex-local/src/server/test.ts new file mode 100644 index 00000000..db60fa9f --- /dev/null +++ b/packages/adapters/codex-local/src/server/test.ts @@ -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 { + 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 = {}; + 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(), + }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3996ff79..a054c21e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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"; diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 816d6cc0..b7c4edf4 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -49,3 +49,21 @@ export interface AgentConfigRevision { afterConfig: Record; 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; +} diff --git a/packages/shared/src/types/asset.ts b/packages/shared/src/types/asset.ts new file mode 100644 index 00000000..2520de40 --- /dev/null +++ b/packages/shared/src/types/asset.ts @@ -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; +} + diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 36d249ef..c38064fe 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -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, diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 2db1c833..1af0ee7c 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -79,6 +79,12 @@ export const resetAgentSessionSchema = z.object({ export type ResetAgentSession = z.infer; +export const testAdapterEnvironmentSchema = z.object({ + adapterConfig: adapterConfigSchema.optional().default({}), +}); + +export type TestAdapterEnvironment = z.infer; + export const updateAgentPermissionsSchema = z.object({ canCreateAgents: z.boolean(), }); diff --git a/packages/shared/src/validators/asset.ts b/packages/shared/src/validators/asset.ts new file mode 100644 index 00000000..4283e31a --- /dev/null +++ b/packages/shared/src/validators/asset.ts @@ -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; + diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index e9e913e9..98841a12 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -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"; diff --git a/server/src/__tests__/claude-local-adapter-environment.test.ts b/server/src/__tests__/claude-local-adapter-environment.test.ts new file mode 100644 index 00000000..3c3c3496 --- /dev/null +++ b/server/src/__tests__/claude-local-adapter-environment.test.ts @@ -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); + }); +}); diff --git a/server/src/adapters/http/index.ts b/server/src/adapters/http/index.ts index fe1f969f..0ed9f3c8 100644 --- a/server/src/adapters/http/index.ts +++ b/server/src/adapters/http/index.ts @@ -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 diff --git a/server/src/adapters/http/test.ts b/server/src/adapters/http/test.ts new file mode 100644 index 00000000..a1a8fd34 --- /dev/null +++ b/server/src/adapters/http/test.ts @@ -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 { + 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(), + }; +} diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts index d02700d1..7f218a83 100644 --- a/server/src/adapters/index.ts +++ b/server/src/adapters/index.ts @@ -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, diff --git a/server/src/adapters/process/index.ts b/server/src/adapters/process/index.ts index 905f3a11..650b6a7b 100644 --- a/server/src/adapters/process/index.ts +++ b/server/src/adapters/process/index.ts @@ -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 diff --git a/server/src/adapters/process/test.ts b/server/src/adapters/process/test.ts new file mode 100644 index 00000000..df0521c8 --- /dev/null +++ b/server/src/adapters/process/test.ts @@ -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 { + 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 = {}; + 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(), + }; +} diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 78cbb148..8ec19af2 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -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; +} diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index 629afc87..4ac0086b 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -8,6 +8,11 @@ export type { AdapterExecutionResult, AdapterInvocationMeta, AdapterExecutionContext, + AdapterEnvironmentCheckLevel, + AdapterEnvironmentCheck, + AdapterEnvironmentTestStatus, + AdapterEnvironmentTestResult, + AdapterEnvironmentTestContext, AdapterSessionCodec, AdapterModel, ServerAdapterModule, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 8e6ace2c..8ef5b13f 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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; + 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); diff --git a/skills/create-agent-adapter/SKILL.md b/skills/create-agent-adapter/SKILL.md index af0c9ac6..7c24ffb2 100644 --- a/skills/create-agent-adapter/SKILL.md +++ b/skills/create-agent-adapter/SKILL.md @@ -95,6 +95,7 @@ interface AdapterSessionCodec { interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; + testEnvironment(ctx: AdapterEnvironmentTestContext): Promise; 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; // 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.test.ts`. Test: - [ ] `packages/adapters//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//config-fields.tsx` React component for agent form diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 92ee6636..f19a49ab 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -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(`/agents/${id}/runtime-state/reset-session`, { taskKey: taskKey ?? null }), adapterModels: (type: string) => api.get(`/adapters/${type}/models`), + testEnvironment: ( + companyId: string, + type: string, + data: { adapterConfig: Record }, + ) => + api.post( + `/companies/${companyId}/adapters/${type}/test-environment`, + data, + ), invoke: (id: string) => api.post(`/agents/${id}/heartbeat/invoke`, {}), wakeup: ( id: string,