mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
Harden Hermes gateway transport and secrets
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
401da17d37
commit
f0e2bffe42
14 files changed
+799
-65
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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<string, unknown>): 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({
|
||||
|
||||
@@ -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<AdapterExec
|
||||
errorMessage: `Invalid Hermes gateway apiBaseUrl: ${apiBaseUrlValue}`,
|
||||
};
|
||||
}
|
||||
if (isRemotePlainHttp(baseUrl) && !allowsInsecureRemoteHttp(ctx.config)) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorCode: "hermes_gateway_plain_http_remote_denied",
|
||||
errorMessage: remotePlainHttpDeniedMessage(baseUrl.hostname),
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = nonEmpty(ctx.config.apiKey) ?? nonEmpty(ctx.config.token);
|
||||
if (!apiKey) {
|
||||
|
||||
@@ -4,6 +4,12 @@ import type {
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import { asString } from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
allowsInsecureRemoteHttp,
|
||||
isLoopbackHostname,
|
||||
isRemotePlainHttp,
|
||||
remotePlainHttpDeniedMessage,
|
||||
} from "./transport-security.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => 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<AdapterEnvironmentTestResult> {
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): 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.`
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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);
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -294,8 +294,14 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
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<string, unknown> | null, diagnostics, fatalErrors };
|
||||
}
|
||||
|
||||
const defaults = input.defaultsPayload as Record<string, unknown>;
|
||||
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<string, unknown>)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<unknown>;
|
||||
syncEnvBindingsForTarget?: (
|
||||
companyId: string,
|
||||
target: { targetType: "agent"; targetId: string; pathPrefix?: string },
|
||||
@@ -11,12 +25,63 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -412,7 +412,10 @@ const INLINE_BASE64_IMAGE_DATA_RE = /("type":"image","source":\{"type":"base64",
|
||||
|
||||
type RuntimeConfigSecretResolver = Pick<
|
||||
ReturnType<typeof secretService>,
|
||||
"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,
|
||||
|
||||
+325
-42
@@ -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<SecretProvider> = new Set([
|
||||
"gcp_secret_manager",
|
||||
"vault",
|
||||
]);
|
||||
const FALLBACK_ADAPTER_SCHEMA_SECRET_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
||||
hermes_gateway: ["apiKey"],
|
||||
};
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type SecretBindingDb = Pick<Db | DbTransaction, "select" | "delete" | "insert">;
|
||||
|
||||
@@ -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 | DbTransaction, "select"> = db) {
|
||||
return source
|
||||
@@ -809,16 +819,209 @@ export function secretService(db: Db) {
|
||||
async function normalizeAdapterConfigForPersistenceInternal(
|
||||
companyId: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
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<string[]> {
|
||||
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<EnvBinding | undefined> {
|
||||
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<typeof companySecretBindings.$inferSelect>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
adapterType: string | null | undefined,
|
||||
context: Omit<SecretConsumerContext, "configPath">,
|
||||
): Promise<MissingRuntimeBinding[]> => {
|
||||
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<string, unknown>,
|
||||
context?: Omit<SecretConsumerContext, "configPath">,
|
||||
opts?: { adapterType?: string | null },
|
||||
): Promise<{ config: Record<string, unknown>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => {
|
||||
const resolved = { ...adapterConfig };
|
||||
const secretKeys = new Set<string>();
|
||||
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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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 };
|
||||
},
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user