From 6f21eae0a4f2b986c5a23ce14c86606ee2b8688d Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 26 Jun 2026 03:34:39 +0000 Subject: [PATCH] Secretize Hermes join defaults Co-Authored-By: Paperclip --- .../invite-accept-gateway-defaults.test.ts | 139 +++++++++++++++++- server/src/routes/access.ts | 37 ++++- 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/invite-accept-gateway-defaults.test.ts b/server/src/__tests__/invite-accept-gateway-defaults.test.ts index a831eb944a7..cec0f913b63 100644 --- a/server/src/__tests__/invite-accept-gateway-defaults.test.ts +++ b/server/src/__tests__/invite-accept-gateway-defaults.test.ts @@ -1,8 +1,36 @@ -import { describe, expect, it } from "vitest"; +import { randomUUID } from "node:crypto"; +import { mkdirSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { + companies, + companySecretProviderConfigs, + companySecretVersions, + companySecrets, + createDb, + invites, + joinRequests, +} from "@paperclipai/db"; import { buildJoinDefaultsPayloadForAccept, normalizeAgentDefaultsForJoin, + prepareAgentDefaultsPayloadForJoinPersistence, } from "../routes/access.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres invite accept gateway defaults tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} describe("buildJoinDefaultsPayloadForAccept (openclaw_gateway)", () => { it("leaves non-gateway payloads unchanged", () => { @@ -167,3 +195,112 @@ describe("normalizeAgentDefaultsForJoin (hermes_gateway)", () => { ); }); }); + +describeEmbeddedPostgres("prepareAgentDefaultsPayloadForJoinPersistence (hermes_gateway)", () => { + let stopDb: (() => Promise) | null = null; + let db!: ReturnType; + const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + const secretsTmpDir = path.join(os.tmpdir(), `paperclip-hermes-join-defaults-${randomUUID()}`); + + beforeAll(async () => { + mkdirSync(secretsTmpDir, { recursive: true }); + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key"); + const started = await startEmbeddedPostgresTestDatabase("hermes-join-defaults"); + stopDb = started.cleanup; + db = createDb(started.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(joinRequests); + await db.delete(invites); + await db.delete(companySecretVersions); + await db.delete(companySecrets); + await db.delete(companySecretProviderConfigs); + await db.delete(companies); + }); + + afterAll(async () => { + await stopDb?.(); + if (previousKeyFile === undefined) { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + } else { + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile; + } + rmSync(secretsTmpDir, { recursive: true, force: true }); + }); + + it("stores a secret ref instead of the literal apiKey in join request defaults", async () => { + const companyId = randomUUID(); + const inviteId = randomUUID(); + const joinRequestId = randomUUID(); + const literalApiKey = `hermes-key-${randomUUID()}`; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(invites).values({ + id: inviteId, + companyId, + inviteType: "company_join", + tokenHash: `invite-token-${randomUUID()}`, + allowedJoinTypes: "agent", + defaultsPayload: null, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + }); + + const joinDefaults = normalizeAgentDefaultsForJoin({ + adapterType: "hermes_gateway", + defaultsPayload: { + apiBaseUrl: "https://hermes.example", + apiKey: literalApiKey, + paperclipApiUrl: "https://paperclip.example", + }, + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }); + expect(joinDefaults.fatalErrors).toEqual([]); + + const persistedDefaults = await prepareAgentDefaultsPayloadForJoinPersistence({ + db, + companyId, + adapterType: "hermes_gateway", + normalized: joinDefaults.normalized, + }); + + await db.insert(joinRequests).values({ + id: joinRequestId, + inviteId, + companyId, + requestType: "agent", + status: "pending_approval", + requestIp: "127.0.0.1", + agentName: "Hermes Gateway", + adapterType: "hermes_gateway", + capabilities: "Hermes gateway agent", + agentDefaultsPayload: persistedDefaults, + claimSecretHash: "claim-secret-hash", + claimSecretExpiresAt: new Date("2027-03-11T00:00:00.000Z"), + }); + + const persistedJoinRequest = await db + .select() + .from(joinRequests) + .where(eq(joinRequests.id, joinRequestId)) + .then((rows) => rows[0]); + const storedPayload = persistedJoinRequest?.agentDefaultsPayload as Record; + expect(JSON.stringify(storedPayload)).not.toContain(literalApiKey); + expect(storedPayload.apiKey).toMatchObject({ + type: "secret_ref", + version: "latest", + }); + + const storedSecrets = await db.select().from(companySecrets); + expect(storedSecrets).toHaveLength(1); + expect((storedPayload.apiKey as { secretId: string }).secretId).toBe(storedSecrets[0]?.id); + }); +}); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index f0c549ae122..0de06e57c79 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -83,6 +83,7 @@ import { } from "../board-claim.js"; import { claimFirstInstanceAdmin } from "../first-admin-claim.js"; import { getStorageService } from "../storage/index.js"; +import { secretService } from "../services/secrets.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -1010,6 +1011,24 @@ export function normalizeAgentDefaultsForJoin(input: { return { normalized, diagnostics, fatalErrors }; } +export async function prepareAgentDefaultsPayloadForJoinPersistence(input: { + db: Db; + companyId: string; + adapterType: string | null; + normalized: Record | null; + actor?: { userId?: string | null; agentId?: string | null }; +}): Promise | null> { + if (input.adapterType !== "hermes_gateway" || !input.normalized) { + return input.normalized; + } + + return secretService(input.db).normalizeAdapterConfigForPersistence( + input.companyId, + input.normalized, + { adapterType: input.adapterType, actor: input.actor }, + ); +} + function toInviteSummaryResponse( req: Request, token: string, @@ -3687,6 +3706,20 @@ export function accessRoutes( throw badRequest(joinDefaults.fatalErrors.join("; ")); } + const persistedJoinDefaultsPayload = + requestType === "agent" + ? await prepareAgentDefaultsPayloadForJoinPersistence({ + db, + companyId, + adapterType, + normalized: joinDefaults.normalized, + actor: { + userId: req.actor.userId ?? null, + agentId: req.actor.agentId ?? null + } + }) + : null; + if (requestType === "agent" && adapterType === "openclaw_gateway") { logger.info( { @@ -3780,7 +3813,7 @@ export function accessRoutes( ? req.body.capabilities ?? null : null, agentDefaultsPayload: - requestType === "agent" ? joinDefaults.normalized : null, + requestType === "agent" ? persistedJoinDefaultsPayload : null, claimSecretHash, claimSecretExpiresAt }) @@ -3806,7 +3839,7 @@ export function accessRoutes( : null, adapterType: requestType === "agent" ? adapterType : null, agentDefaultsPayload: - requestType === "agent" ? joinDefaults.normalized : null, + requestType === "agent" ? persistedJoinDefaultsPayload : null, updatedAt: new Date() }) .where(eq(joinRequests.id, replayJoinRequestId as string))