diff --git a/packages/adapters/hermes-gateway/src/server/config-schema.ts b/packages/adapters/hermes-gateway/src/server/config-schema.ts index 4b631224f0c..ad27054d233 100644 --- a/packages/adapters/hermes-gateway/src/server/config-schema.ts +++ b/packages/adapters/hermes-gateway/src/server/config-schema.ts @@ -1,5 +1,6 @@ import type { AdapterConfigSchema } from "@paperclipai/adapter-utils"; import { DEFAULT_EVENT_RECONNECT_MS, DEFAULT_TIMEOUT_SEC } from "../shared/constants.js"; +import { INSECURE_REMOTE_HTTP_ESCAPE_HATCH } from "./transport-security.js"; export function getConfigSchema(): AdapterConfigSchema { return { @@ -16,9 +17,16 @@ export function getConfigSchema(): AdapterConfigSchema { label: "API key", type: "text", required: true, - hint: "Hermes API_SERVER_KEY. Stored in adapter config; avoid sharing exported configs.", + hint: "Hermes API_SERVER_KEY. Stored as a Paperclip secret reference.", meta: { secret: true }, }, + { + key: INSECURE_REMOTE_HTTP_ESCAPE_HATCH, + label: "Dangerously allow remote HTTP", + type: "toggle", + default: false, + hint: "Unsafe dev-only escape hatch. Remote Hermes gateways should use HTTPS; loopback HTTP remains allowed.", + }, { key: "sessionKeyStrategy", label: "Session key strategy", diff --git a/packages/adapters/hermes-gateway/src/server/execute.test.ts b/packages/adapters/hermes-gateway/src/server/execute.test.ts index 7c67a2b8f89..f26f82d441c 100644 --- a/packages/adapters/hermes-gateway/src/server/execute.test.ts +++ b/packages/adapters/hermes-gateway/src/server/execute.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, afterEach } from "vitest"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; import { execute, mapFinalResultForTest, parseSseFramesForTest, resolveSessionKey } from "./execute.js"; +import { testEnvironment } from "./test.js"; function makeCtx(config: Record): AdapterExecutionContext { return { @@ -79,6 +80,20 @@ describe("parseSseFramesForTest", () => { }); describe("execute", () => { + it("rejects remote plain HTTP unless the unsafe dev escape hatch is enabled", async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ run_id: "unexpected" }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const result = await execute(makeCtx({ + apiBaseUrl: "http://192.168.1.25:8642", + apiKey: "secret-key", + })); + + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("hermes_gateway_plain_http_remote_denied"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("constructs POST /v1/runs with auth, idempotency, and Hermes session headers", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); @@ -199,6 +214,62 @@ describe("execute", () => { }); }); +describe("testEnvironment", () => { + it("fails remote plain HTTP before probing health", async () => { + const fetchMock = vi.fn(async () => new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "hermes_gateway", + config: { + apiBaseUrl: "http://hermes.example:8642", + apiKey: "secret-key", + }, + }); + + expect(result.status).toBe("fail"); + expect(result.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "hermes_gateway_plain_http_remote_denied", + level: "error", + }), + ]), + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("allows remote plain HTTP only with the unsafe dev escape hatch", async () => { + const fetchMock = vi.fn(async () => new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "hermes_gateway", + config: { + apiBaseUrl: "http://hermes.example:8642", + apiKey: "secret-key", + dangerouslyAllowInsecureRemoteHttp: true, + }, + }); + + expect(result.status).toBe("warn"); + expect(result.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "hermes_gateway_plain_http_remote_unsafe_allowed", + level: "warn", + }), + expect.objectContaining({ + code: "hermes_gateway_health_ok", + }), + ]), + ); + expect(fetchMock).toHaveBeenCalled(); + }); +}); + describe("mapFinalResultForTest", () => { it("maps failed statuses into adapter errors", () => { const result = mapFinalResultForTest({ diff --git a/packages/adapters/hermes-gateway/src/server/execute.ts b/packages/adapters/hermes-gateway/src/server/execute.ts index aa41253a6f1..7ff7c6ed609 100644 --- a/packages/adapters/hermes-gateway/src/server/execute.ts +++ b/packages/adapters/hermes-gateway/src/server/execute.ts @@ -18,6 +18,11 @@ import { DEFAULT_TIMEOUT_SEC, STOP_GRACE_MS, } from "../shared/constants.js"; +import { + allowsInsecureRemoteHttp, + isRemotePlainHttp, + remotePlainHttpDeniedMessage, +} from "./transport-security.js"; type SessionKeyStrategy = "issue" | "agent" | "run" | "none"; @@ -699,6 +704,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise check.level === "error")) return "fail"; @@ -21,11 +27,6 @@ function normalizeBaseUrl(value: string): URL | null { } } -function isLoopback(hostname: string): boolean { - const value = hostname.toLowerCase(); - return value === "localhost" || value === "127.0.0.1" || value === "::1"; -} - export async function testEnvironment( ctx: AdapterEnvironmentTestContext, ): Promise { @@ -60,12 +61,25 @@ export async function testEnvironment( }); } - if (parsed?.protocol === "http:" && !isLoopback(parsed.hostname)) { + if (parsed && isRemotePlainHttp(parsed) && !allowsInsecureRemoteHttp(ctx.config)) { checks.push({ - code: "hermes_gateway_plain_http_remote", + code: "hermes_gateway_plain_http_remote_denied", + level: "error", + message: remotePlainHttpDeniedMessage(parsed.hostname), + hint: "Use https:// for remote Hermes gateways. Loopback http://localhost and http://127.0.0.1 remain allowed.", + }); + } else if (parsed && isRemotePlainHttp(parsed)) { + checks.push({ + code: "hermes_gateway_plain_http_remote_unsafe_allowed", level: "warn", - message: "Non-loopback HTTP exposes Hermes traffic without transport encryption.", - hint: "Prefer HTTPS or a private overlay network for remote Hermes hosts.", + message: "Unsafe dev escape hatch enabled for non-loopback HTTP Hermes traffic.", + hint: "Remove the escape hatch and use HTTPS before using this gateway for real credentials.", + }); + } else if (parsed?.protocol === "http:" && isLoopbackHostname(parsed.hostname)) { + checks.push({ + code: "hermes_gateway_loopback_http_allowed", + level: "info", + message: "Loopback HTTP Hermes gateway URL is allowed.", }); } @@ -111,4 +125,3 @@ export async function testEnvironment( testedAt: new Date().toISOString(), }; } - diff --git a/packages/adapters/hermes-gateway/src/server/transport-security.ts b/packages/adapters/hermes-gateway/src/server/transport-security.ts new file mode 100644 index 00000000000..00d7decefff --- /dev/null +++ b/packages/adapters/hermes-gateway/src/server/transport-security.ts @@ -0,0 +1,36 @@ +export const INSECURE_REMOTE_HTTP_ESCAPE_HATCH = "dangerouslyAllowInsecureRemoteHttp"; + +export function parseBooleanLike(value: unknown): boolean | null { + if (typeof value === "boolean") return value; + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + return null; +} + +export function isLoopbackHostname(hostname: string): boolean { + const value = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + return ( + value === "localhost" || + value === "::1" || + value === "0:0:0:0:0:0:0:1" || + value === "127.0.0.1" || + /^127(?:\.\d{1,3}){3}$/.test(value) + ); +} + +export function isRemotePlainHttp(url: URL): boolean { + return url.protocol === "http:" && !isLoopbackHostname(url.hostname); +} + +export function allowsInsecureRemoteHttp(config: Record): boolean { + return parseBooleanLike(config[INSECURE_REMOTE_HTTP_ESCAPE_HATCH]) === true; +} + +export function remotePlainHttpDeniedMessage(hostname: string): string { + return ( + `Hermes gateway apiBaseUrl uses remote plain HTTP for "${hostname}". ` + + `Use HTTPS or set ${INSECURE_REMOTE_HTTP_ESCAPE_HATCH}=true only for unsafe local development.` + ); +} diff --git a/server/src/__tests__/agents-service-secret-bindings.test.ts b/server/src/__tests__/agents-service-secret-bindings.test.ts index 634086cf5b4..d27cc58b5c3 100644 --- a/server/src/__tests__/agents-service-secret-bindings.test.ts +++ b/server/src/__tests__/agents-service-secret-bindings.test.ts @@ -115,6 +115,65 @@ describeEmbeddedPostgres("agent service secret binding sync", () => { }); }); + it("converts Hermes gateway apiKey strings into persisted secret refs", async () => { + const companyId = await seedCompany(); + const literalApiKey = `hermes-key-${randomUUID()}`; + + const created = await agentService(db).create(companyId, { + name: "Hermes Gateway", + role: "engineer", + status: "idle", + adapterType: "hermes_gateway", + adapterConfig: { + apiBaseUrl: "https://hermes.example", + apiKey: literalApiKey, + }, + runtimeConfig: {}, + spentMonthlyCents: 0, + lastHeartbeatAt: null, + }); + + const persistedRows = await db + .select() + .from(agents) + .where(eq(agents.id, created.id)); + const persistedConfig = persistedRows[0]?.adapterConfig as Record; + expect(JSON.stringify(persistedConfig)).not.toContain(literalApiKey); + expect(persistedConfig.apiKey).toMatchObject({ + type: "secret_ref", + version: "latest", + }); + + const secretId = (persistedConfig.apiKey as { secretId: string }).secretId; + const bindings = await db + .select() + .from(companySecretBindings) + .where(and( + eq(companySecretBindings.companyId, companyId), + eq(companySecretBindings.targetType, "agent"), + eq(companySecretBindings.targetId, created.id), + )); + expect(bindings).toHaveLength(1); + expect(bindings[0]).toMatchObject({ + secretId, + configPath: "apiKey", + versionSelector: "latest", + required: true, + }); + + const resolved = await secretService(db).resolveAdapterConfigForRuntime( + companyId, + persistedConfig, + { + consumerType: "agent", + consumerId: created.id, + }, + { adapterType: "hermes_gateway" }, + ); + expect(resolved.config.apiKey).toBe(literalApiKey); + expect(JSON.stringify(persistedConfig)).not.toContain(literalApiKey); + }); + it("replaces agent secret bindings when adapterConfig env changes", async () => { const companyId = await seedCompany(); const secrets = secretService(db); diff --git a/server/src/__tests__/invite-accept-gateway-defaults.test.ts b/server/src/__tests__/invite-accept-gateway-defaults.test.ts index 3ff239f628a..a831eb944a7 100644 --- a/server/src/__tests__/invite-accept-gateway-defaults.test.ts +++ b/server/src/__tests__/invite-accept-gateway-defaults.test.ts @@ -117,3 +117,53 @@ describe("normalizeAgentDefaultsForJoin (openclaw_gateway)", () => { expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined(); }); }); + +describe("normalizeAgentDefaultsForJoin (hermes_gateway)", () => { + it("rejects remote plain HTTP by default", () => { + const normalized = normalizeAgentDefaultsForJoin({ + adapterType: "hermes_gateway", + defaultsPayload: { + apiBaseUrl: "http://192.168.1.25:8642", + apiKey: "hermes-key-1234567890", + }, + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }); + + expect(normalized.fatalErrors.join("\n")).toContain("remote plain HTTP"); + expect(normalized.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "hermes_gateway_plain_http_remote_denied", + }), + ]), + ); + }); + + it("allows remote plain HTTP only with the explicit unsafe flag", () => { + const normalized = normalizeAgentDefaultsForJoin({ + adapterType: "hermes_gateway", + defaultsPayload: { + apiBaseUrl: "http://192.168.1.25:8642", + apiKey: "hermes-key-1234567890", + dangerouslyAllowInsecureRemoteHttp: true, + }, + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }); + + expect(normalized.fatalErrors).toEqual([]); + expect(normalized.normalized?.apiBaseUrl).toBe("http://192.168.1.25:8642/"); + expect(normalized.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "hermes_gateway_plain_http_remote_unsafe_allowed", + }), + ]), + ); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index f646d34b5f5..f0c549ae122 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -294,8 +294,14 @@ function isPlainObject(value: unknown): value is Record { } function isLoopbackHost(hostname: string): boolean { - const value = hostname.trim().toLowerCase(); - return value === "localhost" || value === "127.0.0.1" || value === "::1"; + const value = hostname.trim().toLowerCase().replace(/^\[|\]$/g, ""); + return ( + value === "localhost" || + value === "::1" || + value === "0:0:0:0:0:0:0:1" || + value === "127.0.0.1" || + /^127(?:\.\d{1,3}){3}$/.test(value) + ); } function normalizeHostname(value: string | null | undefined): string | null { @@ -474,8 +480,8 @@ function parseBooleanLike(value: unknown): boolean | null { if (typeof value === "boolean") return value; if (typeof value !== "string") return null; const normalized = value.trim().toLowerCase(); - if (normalized === "true" || normalized === "1") return true; - if (normalized === "false" || normalized === "0") return false; + if (["true", "1", "yes", "on"].includes(normalized)) return true; + if (["false", "0", "no", "off"].includes(normalized)) return false; return null; } @@ -676,6 +682,91 @@ export function normalizeAgentDefaultsForJoin(input: { }) { const fatalErrors: string[] = []; const diagnostics: JoinDiagnostic[] = []; + if (input.adapterType === "hermes_gateway") { + if (!isPlainObject(input.defaultsPayload)) { + diagnostics.push({ + code: "hermes_gateway_defaults_missing", + level: "warn", + message: "No Hermes gateway config was provided in agentDefaultsPayload.", + hint: "Include agentDefaultsPayload.apiBaseUrl and agentDefaultsPayload.apiKey for Hermes gateway joins.", + }); + fatalErrors.push("agentDefaultsPayload is required for adapterType=hermes_gateway"); + return { normalized: null as Record | null, diagnostics, fatalErrors }; + } + + const defaults = input.defaultsPayload as Record; + const normalized = { ...defaults }; + const rawApiBaseUrl = nonEmptyTrimmedString(defaults.apiBaseUrl ?? defaults.url); + if (!rawApiBaseUrl) { + diagnostics.push({ + code: "hermes_gateway_api_base_url_missing", + level: "warn", + message: "Hermes gateway apiBaseUrl is missing.", + hint: "Set agentDefaultsPayload.apiBaseUrl to the Hermes API server URL.", + }); + fatalErrors.push("agentDefaultsPayload.apiBaseUrl is required"); + } else { + try { + const apiBaseUrl = new URL(rawApiBaseUrl); + if (apiBaseUrl.protocol !== "http:" && apiBaseUrl.protocol !== "https:") { + diagnostics.push({ + code: "hermes_gateway_api_base_url_protocol", + level: "warn", + message: `Hermes gateway apiBaseUrl must use http:// or https:// (got ${apiBaseUrl.protocol}).`, + }); + fatalErrors.push("agentDefaultsPayload.apiBaseUrl must use http:// or https:// for hermes_gateway"); + } else if ( + apiBaseUrl.protocol === "http:" && + !isLoopbackHost(apiBaseUrl.hostname) && + parseBooleanLike(defaults.dangerouslyAllowInsecureRemoteHttp) !== true + ) { + diagnostics.push({ + code: "hermes_gateway_plain_http_remote_denied", + level: "warn", + message: "Remote plain HTTP Hermes gateway traffic is denied by default.", + hint: "Use https:// or set agentDefaultsPayload.dangerouslyAllowInsecureRemoteHttp=true only for unsafe local development.", + }); + fatalErrors.push( + "agentDefaultsPayload.apiBaseUrl uses remote plain HTTP; use HTTPS or set dangerouslyAllowInsecureRemoteHttp=true for unsafe local development", + ); + } else { + normalized.apiBaseUrl = apiBaseUrl.toString(); + if (apiBaseUrl.protocol === "http:" && !isLoopbackHost(apiBaseUrl.hostname)) { + diagnostics.push({ + code: "hermes_gateway_plain_http_remote_unsafe_allowed", + level: "warn", + message: "Unsafe dev escape hatch enabled for non-loopback HTTP Hermes traffic.", + }); + } else { + diagnostics.push({ + code: "hermes_gateway_api_base_url_configured", + level: "info", + message: `Hermes gateway endpoint set to ${apiBaseUrl.toString()}`, + }); + } + } + } catch { + diagnostics.push({ + code: "hermes_gateway_api_base_url_invalid", + level: "warn", + message: `Invalid Hermes gateway apiBaseUrl: ${rawApiBaseUrl}`, + }); + fatalErrors.push("agentDefaultsPayload.apiBaseUrl is not a valid URL"); + } + } + + if (!nonEmptyTrimmedString(defaults.apiKey)) { + diagnostics.push({ + code: "hermes_gateway_api_key_missing", + level: "warn", + message: "Hermes gateway API key is missing.", + hint: "Set agentDefaultsPayload.apiKey to the Hermes API_SERVER_KEY value.", + }); + fatalErrors.push("agentDefaultsPayload.apiKey is required"); + } + + return { normalized, diagnostics, fatalErrors }; + } if (input.adapterType !== "openclaw_gateway") { const normalized = isPlainObject(input.defaultsPayload) ? (input.defaultsPayload as Record) diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 9c92b3c9435..4fcef1ac2be 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1078,7 +1078,10 @@ export function agentRoutes( const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( input.companyId, input.adapterConfig, - { strictMode: strictSecretsMode }, + { + strictMode: strictSecretsMode, + adapterType: input.adapterType ?? null, + }, ); await assertAdapterConfigConstraints( input.adapterType, @@ -1616,11 +1619,13 @@ export function agentRoutes( const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( companyId, inputAdapterConfig, - { strictMode: strictSecretsMode }, + { strictMode: strictSecretsMode, adapterType: type }, ); const { config: runtimeAdapterConfig } = await secretsSvc.resolveAdapterConfigForRuntime( companyId, normalizedAdapterConfig, + undefined, + { adapterType: type }, ); const { executionTarget, environmentName, fallbackChecks, release } = @@ -2571,7 +2576,7 @@ export function agentRoutes( const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( existing.companyId, syncedAdapterConfig, - { strictMode: strictSecretsMode }, + { strictMode: strictSecretsMode, adapterType: existing.adapterType }, ); const actor = getActorInfo(req); const agent = await svc.update( @@ -2642,7 +2647,7 @@ export function agentRoutes( const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( existing.companyId, adapterConfig, - { strictMode: strictSecretsMode }, + { strictMode: strictSecretsMode, adapterType: existing.adapterType }, ); await svc.update( id, @@ -2710,7 +2715,7 @@ export function agentRoutes( const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( existing.companyId, result.adapterConfig, - { strictMode: strictSecretsMode }, + { strictMode: strictSecretsMode, adapterType: existing.adapterType }, ); await svc.update( id, diff --git a/server/src/services/agent-secret-bindings.ts b/server/src/services/agent-secret-bindings.ts index 4afb096b243..9e266f21b63 100644 --- a/server/src/services/agent-secret-bindings.ts +++ b/server/src/services/agent-secret-bindings.ts @@ -1,4 +1,18 @@ +import { envBindingSchema, type SecretVersionSelector } from "@paperclipai/shared"; + interface AgentSecretBindingSyncService { + syncSecretRefsForTarget?: ( + companyId: string, + target: { targetType: "agent"; targetId: string }, + refs: Array<{ + secretId: string; + configPath: string; + versionSelector?: SecretVersionSelector; + required?: boolean; + label?: string | null; + }>, + options?: { replaceAll?: boolean }, + ) => Promise; syncEnvBindingsForTarget?: ( companyId: string, target: { targetType: "agent"; targetId: string; pathPrefix?: string }, @@ -11,12 +25,63 @@ function asRecord(value: unknown): Record | null { return value as Record; } +function collectSecretRefs(adapterConfig: unknown): Array<{ + secretId: string; + configPath: string; + versionSelector?: SecretVersionSelector; +}> { + const config = asRecord(adapterConfig); + if (!config) return []; + const refs: Array<{ + secretId: string; + configPath: string; + versionSelector?: SecretVersionSelector; + }> = []; + + const envValue = asRecord(config.env); + for (const [key, rawBinding] of Object.entries(envValue ?? {})) { + const parsed = envBindingSchema.safeParse(rawBinding); + if (!parsed.success) continue; + const binding = parsed.data; + if (typeof binding !== "object" || binding === null || binding.type !== "secret_ref") continue; + refs.push({ + secretId: binding.secretId, + configPath: `env.${key}`, + versionSelector: binding.version ?? "latest", + }); + } + + for (const [key, rawBinding] of Object.entries(config)) { + if (key === "env") continue; + const parsed = envBindingSchema.safeParse(rawBinding); + if (!parsed.success) continue; + const binding = parsed.data; + if (typeof binding !== "object" || binding === null || binding.type !== "secret_ref") continue; + refs.push({ + secretId: binding.secretId, + configPath: key, + versionSelector: binding.version ?? "latest", + }); + } + + return refs; +} + export async function syncAgentAdapterEnvBindings(input: { secretsSvc: AgentSecretBindingSyncService; companyId: string; agentId: string; adapterConfig: unknown; }) { + if (input.secretsSvc.syncSecretRefsForTarget) { + await input.secretsSvc.syncSecretRefsForTarget( + input.companyId, + { targetType: "agent", targetId: input.agentId }, + collectSecretRefs(input.adapterConfig), + { replaceAll: true }, + ); + return; + } const envValue = asRecord(asRecord(input.adapterConfig)?.env); await input.secretsSvc.syncEnvBindingsForTarget?.( input.companyId, diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 2c695589444..d83bb790aa4 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -428,6 +428,16 @@ export function agentService(db: Db) { const role = (data.role ?? existing.role) as string; normalizedPatch.permissions = normalizeAgentPermissions(data.permissions, role); } + if ( + Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig") && + isPlainRecord(normalizedPatch.adapterConfig) + ) { + normalizedPatch.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + existing.companyId, + normalizedPatch.adapterConfig, + { adapterType: (normalizedPatch.adapterType ?? existing.adapterType) as string }, + ); + } const shouldRecordRevision = Boolean(options?.recordRevision) && hasConfigPatchFields(normalizedPatch); const beforeConfig = shouldRecordRevision ? buildConfigSnapshot(existing) : null; @@ -503,11 +513,24 @@ export function agentService(db: Db) { const role = data.role ?? "general"; const normalizedPermissions = normalizeAgentPermissions(data.permissions, role); const runtimeConfig = normalizeRuntimeConfigForNewAgent(data.runtimeConfig); + const adapterType = data.adapterType ?? "process"; + const adapterConfig = isPlainRecord(data.adapterConfig) + ? await secretsSvc.normalizeAdapterConfigForPersistence(companyId, data.adapterConfig, { adapterType }) + : {}; return db.transaction(async (tx) => { const txDb = tx as unknown as Db; const created = await tx .insert(agents) - .values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions, runtimeConfig }) + .values({ + ...data, + name: uniqueName, + companyId, + role, + adapterType, + adapterConfig, + permissions: normalizedPermissions, + runtimeConfig, + }) .returning() .then((rows) => rows[0]); await syncAgentSecretBindings(created, txDb); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 1024a871b74..b1c1c7216cb 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -3050,7 +3050,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const normalizedAdapterConfig = await secrets.normalizeAdapterConfigForPersistence( companyId, nextAdapterConfig, - { strictMode: strictSecretsMode }, + { strictMode: strictSecretsMode, adapterType: effectiveAdapterType }, ); await assertImportAdapterConfigConstraints(effectiveAdapterType, normalizedAdapterConfig); return { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 83475c152ee..81c4a3af9f9 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -412,7 +412,10 @@ const INLINE_BASE64_IMAGE_DATA_RE = /("type":"image","source":\{"type":"base64", type RuntimeConfigSecretResolver = Pick< ReturnType, - "resolveAdapterConfigForRuntime" | "resolveEnvBindings" | "collectMissingRuntimeBindings" + | "resolveAdapterConfigForRuntime" + | "resolveEnvBindings" + | "collectMissingRuntimeBindings" + | "collectMissingAdapterConfigRuntimeBindings" >; function formatMissingBindingForOperator(missing: MissingRuntimeBinding): string { @@ -494,6 +497,7 @@ function assertLowTrustEnvConfigAllowed(envValue: unknown, source: string) { export async function resolveExecutionRunAdapterConfig(input: { companyId: string; agentId?: string | null; + adapterType?: string | null; issueId?: string | null; heartbeatRunId?: string | null; environmentId?: string | null; @@ -574,6 +578,16 @@ export async function resolveExecutionRunAdapterConfig(input: { { consumerType: "agent", consumerId: input.agentId }, )), ); + if (typeof input.secretsSvc.collectMissingAdapterConfigRuntimeBindings === "function") { + missingBindings.push( + ...(await input.secretsSvc.collectMissingAdapterConfigRuntimeBindings( + input.companyId, + executionRunConfig, + input.adapterType ?? null, + { consumerType: "agent", consumerId: input.agentId }, + )), + ); + } } if (projectEnv && input.projectId) { missingBindings.push( @@ -666,6 +680,7 @@ export async function resolveExecutionRunAdapterConfig(input: { ...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}), } : undefined, + { adapterType: input.adapterType ?? null }, ); if (Object.keys(environmentEnvResolution.env).length > 0) { resolvedConfig.env = { @@ -8943,6 +8958,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({ companyId: agent.companyId, agentId: agent.id, + adapterType: agent.adapterType, issueId, heartbeatRunId: run.id, environmentId: selectedEnvironmentForConfig?.id ?? null, diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 25b1228d8e0..2d3c9002d62 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { and, desc, eq, inArray, like, ne, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { @@ -55,6 +56,7 @@ import type { } from "../secrets/types.js"; import { isSecretProviderClientError } from "../secrets/types.js"; import { authorizationService } from "./authorization.js"; +import { findActiveServerAdapter } from "../adapters/index.js"; const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const SENSITIVE_ENV_KEY_RE = @@ -64,6 +66,9 @@ const COMING_SOON_SECRET_PROVIDERS: ReadonlySet = new Set([ "gcp_secret_manager", "vault", ]); +const FALLBACK_ADAPTER_SCHEMA_SECRET_FIELDS: Readonly> = { + hermes_gateway: ["apiKey"], +}; type DbTransaction = Parameters[0]>[0]; type SecretBindingDb = Pick; @@ -357,6 +362,11 @@ export function secretService(db: Db) { strictMode?: boolean; fieldPath?: string; }; + type NormalizeAdapterConfigOptions = { + strictMode?: boolean; + adapterType?: string | null; + actor?: { userId?: string | null; agentId?: string | null }; + }; async function getById(id: string, source: Pick = db) { return source @@ -809,16 +819,209 @@ export function secretService(db: Db) { async function normalizeAdapterConfigForPersistenceInternal( companyId: string, adapterConfig: Record, - opts?: { strictMode?: boolean }, + opts?: NormalizeAdapterConfigOptions, ) { const normalized = { ...adapterConfig }; - if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { - return normalized; + if (Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { + normalized.env = await normalizeEnvConfig(companyId, adapterConfig.env, opts); + } + const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(opts?.adapterType); + for (const key of secretFieldKeys) { + if (!Object.prototype.hasOwnProperty.call(adapterConfig, key)) continue; + const value = await normalizeSchemaSecretFieldForPersistence(companyId, { + adapterType: opts?.adapterType ?? null, + key, + rawValue: adapterConfig[key], + actor: opts?.actor, + }); + if (value === undefined) { + delete normalized[key]; + } else { + normalized[key] = value; + } } - normalized.env = await normalizeEnvConfig(companyId, adapterConfig.env, opts); return normalized; } + async function listAdapterSchemaSecretFieldKeys(adapterType: string | null | undefined): Promise { + if (!adapterType) return []; + const adapter = findActiveServerAdapter(adapterType); + const fallback = [...(FALLBACK_ADAPTER_SCHEMA_SECRET_FIELDS[adapterType] ?? [])]; + if (!adapter?.getConfigSchema) return fallback; + try { + const schema = await adapter.getConfigSchema(); + return [...new Set([ + ...fallback, + ...schema.fields + .filter((field) => field.meta?.secret === true) + .map((field) => field.key), + ])]; + } catch (err) { + logger.warn({ err, adapterType }, "adapter config schema unavailable while normalizing secret fields"); + return fallback; + } + } + + async function normalizeSchemaSecretFieldForPersistence( + companyId: string, + input: { + adapterType: string | null; + key: string; + rawValue: unknown; + actor?: { userId?: string | null; agentId?: string | null }; + }, + ): Promise { + if (input.rawValue === null || input.rawValue === undefined) return undefined; + const parsed = envBindingSchema.safeParse(input.rawValue); + if (!parsed.success) { + throw unprocessable(`${input.key} must be a string, plain binding, or secret reference`); + } + const binding = canonicalizeBinding(parsed.data as EnvBinding); + if (binding.type === "secret_ref") { + await assertSecretInCompany(companyId, binding.secretId); + return { + type: "secret_ref", + secretId: binding.secretId, + version: binding.version, + }; + } + const value = binding.value.trim(); + if (!value) return undefined; + if (value === REDACTED_SENTINEL) { + throw unprocessable(`Refusing to persist redacted placeholder for key: ${input.key}`); + } + const id = randomUUID(); + const adapterPart = normalizeSecretKey(input.adapterType ?? "adapter"); + const fieldPart = normalizeSecretKey(input.key); + const secret = await createManagedLocalSecret(companyId, { + name: `${adapterPart}.${fieldPart}.${id}`, + key: `${adapterPart}.${fieldPart}.${id}`, + value, + description: `Adapter config secret for ${input.adapterType ?? "adapter"}.${input.key}`, + }, input.actor); + return { + type: "secret_ref", + secretId: secret.id, + version: "latest", + }; + } + + async function createManagedLocalSecret( + companyId: string, + input: { + name: string; + key: string; + value: string; + description?: string | null; + }, + actor?: { userId?: string | null; agentId?: string | null }, + ) { + const existing = await getByName(companyId, input.name); + if (existing) throw conflict(`Secret already exists: ${input.name}`); + const key = normalizeSecretKey(input.key); + if (!key) throw unprocessable("Secret key is required"); + const duplicateKey = await db + .select() + .from(companySecrets) + .where(and( + eq(companySecrets.companyId, companyId), + eq(companySecrets.key, key), + ne(companySecrets.status, "deleted"), + )) + .then((rows) => rows[0] ?? null); + if (duplicateKey) throw conflict(`Secret key already exists: ${key}`); + + const provider = getSecretProvider("local_encrypted"); + const providerConfig = await getSelectableRuntimeProviderConfig({ + companyId, + provider: "local_encrypted", + providerConfigId: null, + }); + const providerWriteContext = { + companyId, + secretKey: key, + secretName: input.name, + version: 1, + }; + const reservedSecret = await db + .insert(companySecrets) + .values({ + companyId, + key, + name: input.name, + provider: "local_encrypted", + providerConfigId: null, + status: "archived", + managedMode: "paperclip_managed", + externalRef: null, + providerMetadata: null, + latestVersion: 0, + description: input.description ?? null, + createdByAgentId: actor?.agentId ?? null, + createdByUserId: actor?.userId ?? null, + }) + .returning() + .then((rows) => rows[0]); + + let prepared: PreparedSecretVersion | null = null; + try { + prepared = await provider.createSecret({ + value: input.value, + externalRef: null, + providerConfig, + context: providerWriteContext, + }); + const preparedSecret = prepared; + await db.insert(companySecretVersions).values({ + secretId: reservedSecret.id, + version: 1, + material: preparedSecret.material, + valueSha256: preparedSecret.valueSha256, + fingerprintSha256: preparedSecret.fingerprintSha256 ?? preparedSecret.valueSha256, + providerVersionRef: preparedSecret.providerVersionRef ?? null, + status: "disabled", + createdByAgentId: actor?.agentId ?? null, + createdByUserId: actor?.userId ?? null, + }); + return await db.transaction(async (tx) => { + await tx + .update(companySecretVersions) + .set({ status: "current" }) + .where(and( + eq(companySecretVersions.secretId, reservedSecret.id), + eq(companySecretVersions.version, 1), + )); + const secret = await tx + .update(companySecrets) + .set({ + status: "active", + externalRef: preparedSecret.externalRef, + latestVersion: 1, + lastRotatedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(companySecrets.id, reservedSecret.id)) + .returning() + .then((rows) => rows[0]); + if (!secret) throw notFound("Secret not found"); + return secret; + }); + } catch (error) { + if (prepared) { + await cleanupPreparedProviderWrite({ + provider, + prepared, + providerConfig, + context: providerWriteContext, + mode: "delete", + operation: "adapter_config_secret.create_rollback", + }).catch(() => false); + } + await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined); + throw error; + } + } + function collectTargetIds( bindings: Array, targetType: SecretBindingTargetType, @@ -2159,6 +2362,7 @@ export function secretService(db: Db) { required?: boolean; label?: string | null; }>, + options?: { replaceAll?: boolean }, ) => { const normalizedRefs: Array<{ secretId: string; @@ -2181,7 +2385,17 @@ export function secretService(db: Db) { const pathPrefixes = [...new Set(normalizedRefs.map((ref) => ref.configPath.split(".")[0]))]; await db.transaction(async (tx) => { - if (pathPrefixes.length > 0) { + if (options?.replaceAll) { + await tx + .delete(companySecretBindings) + .where( + and( + eq(companySecretBindings.companyId, companyId), + eq(companySecretBindings.targetType, target.targetType), + eq(companySecretBindings.targetId, target.targetId), + ), + ); + } else if (pathPrefixes.length > 0) { for (const pathPrefix of pathPrefixes) { await tx .delete(companySecretBindings) @@ -2351,7 +2565,7 @@ export function secretService(db: Db) { normalizeAdapterConfigForPersistence: async ( companyId: string, adapterConfig: Record, - opts?: { strictMode?: boolean }, + opts?: NormalizeAdapterConfigOptions, ) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts), normalizeEnvBindingsForPersistence: async ( @@ -2363,7 +2577,7 @@ export function secretService(db: Db) { normalizeHireApprovalPayloadForPersistence: async ( companyId: string, payload: Record, - opts?: { strictMode?: boolean }, + opts?: NormalizeAdapterConfigOptions, ) => { const normalized = { ...payload }; const adapterConfig = asRecord(payload.adapterConfig); @@ -2473,52 +2687,121 @@ export function secretService(db: Db) { })); }, + collectMissingAdapterConfigRuntimeBindings: async ( + companyId: string, + adapterConfig: Record, + adapterType: string | null | undefined, + context: Omit, + ): Promise => { + const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(adapterType); + const secretRefs = secretFieldKeys.flatMap((key) => { + const parsed = envBindingSchema.safeParse(adapterConfig[key]); + if (!parsed.success) return []; + const binding = canonicalizeBinding(parsed.data as EnvBinding); + if (binding.type !== "secret_ref") return []; + return [{ key, configPath: key, secretId: binding.secretId }]; + }); + if (secretRefs.length === 0) return []; + const bindingChecks = await Promise.all(secretRefs.map(async (entry) => ({ + entry, + found: await getBinding({ + companyId, + secretId: entry.secretId, + consumerType: context.consumerType, + consumerId: context.consumerId, + configPath: entry.configPath, + }), + }))); + const missingEntries = bindingChecks + .filter((check) => !check.found) + .map((check) => check.entry); + if (missingEntries.length === 0) return []; + + const secretRows = await Promise.all( + [...new Set(missingEntries.map((entry) => entry.secretId))].map(async (secretId) => [ + secretId, + await getById(secretId).catch(() => null), + ] as const), + ); + const secretsById = new Map(secretRows); + + return missingEntries.map((entry) => ({ + consumerType: context.consumerType, + consumerId: context.consumerId, + configPath: entry.configPath, + envKey: entry.key, + secretId: entry.secretId, + secretName: secretsById.get(entry.secretId)?.name ?? null, + })); + }, + resolveAdapterConfigForRuntime: async ( companyId: string, adapterConfig: Record, context?: Omit, + opts?: { adapterType?: string | null }, ): Promise<{ config: Record; secretKeys: Set; manifest: RuntimeSecretManifestEntry[] }> => { const resolved = { ...adapterConfig }; const secretKeys = new Set(); const manifest: RuntimeSecretManifestEntry[] = []; - if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { - return { config: resolved, secretKeys, manifest }; - } - const record = asRecord(adapterConfig.env); - if (!record) { - resolved.env = {}; - return { config: resolved, secretKeys, manifest }; - } - const env: Record = {}; - for (const [key, rawBinding] of Object.entries(record)) { - if (!ENV_KEY_RE.test(key)) { - throw unprocessable(`Invalid environment variable name: ${key}`); - } - const parsed = envBindingSchema.safeParse(rawBinding); - if (!parsed.success) { - throw unprocessable(`Invalid environment binding for key: ${key}`); - } - const binding = canonicalizeBinding(parsed.data as EnvBinding); - if (binding.type === "plain") { - env[key] = binding.value; + if (Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { + const record = asRecord(adapterConfig.env); + if (!record) { + resolved.env = {}; } else { - const secretResolution = await resolveSecretValueInternal( - companyId, - binding.secretId, - binding.version, - context - ? { - bindingContext: { ...context, configPath: `env.${key}` }, - accessContext: { ...context, configPath: `env.${key}` }, - } - : undefined, - ); - env[key] = secretResolution.value; - manifest.push(secretResolution.manifestEntry); - secretKeys.add(key); + const env: Record = {}; + for (const [key, rawBinding] of Object.entries(record)) { + if (!ENV_KEY_RE.test(key)) { + throw unprocessable(`Invalid environment variable name: ${key}`); + } + const parsed = envBindingSchema.safeParse(rawBinding); + if (!parsed.success) { + throw unprocessable(`Invalid environment binding for key: ${key}`); + } + const binding = canonicalizeBinding(parsed.data as EnvBinding); + if (binding.type === "plain") { + env[key] = binding.value; + } else { + const secretResolution = await resolveSecretValueInternal( + companyId, + binding.secretId, + binding.version, + context + ? { + bindingContext: { ...context, configPath: `env.${key}` }, + accessContext: { ...context, configPath: `env.${key}` }, + } + : undefined, + ); + env[key] = secretResolution.value; + manifest.push(secretResolution.manifestEntry); + secretKeys.add(key); + } + } + resolved.env = env; } } - resolved.env = env; + const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(opts?.adapterType); + for (const key of secretFieldKeys) { + const parsed = envBindingSchema.safeParse(adapterConfig[key]); + if (!parsed.success) continue; + const binding = canonicalizeBinding(parsed.data as EnvBinding); + if (binding.type === "plain") continue; + const secretResolution = await resolveSecretValueInternal( + companyId, + binding.secretId, + binding.version, + context + ? { + bindingContext: { ...context, configPath: key }, + accessContext: { ...context, configPath: key }, + } + : undefined, + ); + resolved[key] = secretResolution.value; + manifest.push(secretResolution.manifestEntry); + secretKeys.add(key); + } return { config: resolved, secretKeys, manifest }; }, };