From 496244c8ef426a49f5baacb6daec320d067f7734 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 1 Mar 2026 09:12:44 -0600 Subject: [PATCH] feat(api): internal agent config endpoint (MS22-P1c) --- .../agent-config/agent-config.controller.ts | 40 +++ .../src/agent-config/agent-config.guard.ts | 43 +++ .../src/agent-config/agent-config.module.ts | 14 + .../agent-config/agent-config.service.spec.ts | 215 +++++++++++++ .../src/agent-config/agent-config.service.ts | 288 ++++++++++++++++++ apps/api/src/app.module.ts | 2 + 6 files changed, 602 insertions(+) create mode 100644 apps/api/src/agent-config/agent-config.controller.ts create mode 100644 apps/api/src/agent-config/agent-config.guard.ts create mode 100644 apps/api/src/agent-config/agent-config.module.ts create mode 100644 apps/api/src/agent-config/agent-config.service.spec.ts create mode 100644 apps/api/src/agent-config/agent-config.service.ts diff --git a/apps/api/src/agent-config/agent-config.controller.ts b/apps/api/src/agent-config/agent-config.controller.ts new file mode 100644 index 0000000..551d185 --- /dev/null +++ b/apps/api/src/agent-config/agent-config.controller.ts @@ -0,0 +1,40 @@ +import { + Controller, + ForbiddenException, + Get, + Param, + Req, + UnauthorizedException, + UseGuards, +} from "@nestjs/common"; +import { AgentConfigService } from "./agent-config.service"; +import { AgentConfigGuard, type AgentConfigRequest } from "./agent-config.guard"; + +@Controller("internal") +@UseGuards(AgentConfigGuard) +export class AgentConfigController { + constructor(private readonly agentConfigService: AgentConfigService) {} + + // GET /api/internal/agent-config/:id + // Auth: Bearer token (validated against UserContainer.gatewayToken or SystemContainer.gatewayToken) + // Returns: assembled openclaw.json + // + // The :id param is the container record ID (cuid) + // Token must match the container requesting its own config + @Get("agent-config/:id") + async getAgentConfig( + @Param("id") id: string, + @Req() request: AgentConfigRequest + ): Promise { + const containerAuth = request.containerAuth; + if (!containerAuth) { + throw new UnauthorizedException("Missing container authentication context"); + } + + if (containerAuth.id !== id) { + throw new ForbiddenException("Token is not authorized for the requested container"); + } + + return this.agentConfigService.generateConfigForContainer(containerAuth.type, id); + } +} diff --git a/apps/api/src/agent-config/agent-config.guard.ts b/apps/api/src/agent-config/agent-config.guard.ts new file mode 100644 index 0000000..17907de --- /dev/null +++ b/apps/api/src/agent-config/agent-config.guard.ts @@ -0,0 +1,43 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"; +import type { Request } from "express"; +import { AgentConfigService, type ContainerTokenValidation } from "./agent-config.service"; + +export interface AgentConfigRequest extends Request { + containerAuth?: ContainerTokenValidation; +} + +@Injectable() +export class AgentConfigGuard implements CanActivate { + constructor(private readonly agentConfigService: AgentConfigService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractBearerToken(request.headers.authorization); + + if (!token) { + throw new UnauthorizedException("Missing Bearer token"); + } + + const containerAuth = await this.agentConfigService.validateContainerToken(token); + if (!containerAuth) { + throw new UnauthorizedException("Invalid container token"); + } + + request.containerAuth = containerAuth; + return true; + } + + private extractBearerToken(headerValue: string | string[] | undefined): string | null { + const normalizedHeader = Array.isArray(headerValue) ? headerValue[0] : headerValue; + if (!normalizedHeader) { + return null; + } + + const [scheme, token] = normalizedHeader.split(" "); + if (!scheme || !token || scheme.toLowerCase() !== "bearer") { + return null; + } + + return token; + } +} diff --git a/apps/api/src/agent-config/agent-config.module.ts b/apps/api/src/agent-config/agent-config.module.ts new file mode 100644 index 0000000..c1cf405 --- /dev/null +++ b/apps/api/src/agent-config/agent-config.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { PrismaModule } from "../prisma/prisma.module"; +import { CryptoModule } from "../crypto/crypto.module"; +import { AgentConfigController } from "./agent-config.controller"; +import { AgentConfigService } from "./agent-config.service"; +import { AgentConfigGuard } from "./agent-config.guard"; + +@Module({ + imports: [PrismaModule, CryptoModule], + controllers: [AgentConfigController], + providers: [AgentConfigService, AgentConfigGuard], + exports: [AgentConfigService], +}) +export class AgentConfigModule {} diff --git a/apps/api/src/agent-config/agent-config.service.spec.ts b/apps/api/src/agent-config/agent-config.service.spec.ts new file mode 100644 index 0000000..5cb98b4 --- /dev/null +++ b/apps/api/src/agent-config/agent-config.service.spec.ts @@ -0,0 +1,215 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AgentConfigService } from "./agent-config.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { CryptoService } from "../crypto/crypto.service"; + +describe("AgentConfigService", () => { + let service: AgentConfigService; + + const mockPrismaService = { + userAgentConfig: { + findUnique: vi.fn(), + }, + llmProvider: { + findMany: vi.fn(), + }, + userContainer: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, + systemContainer: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, + }; + + const mockCryptoService = { + isEncrypted: vi.fn((value: string) => value.startsWith("enc:")), + decrypt: vi.fn((value: string) => value.replace(/^enc:/, "")), + }; + + beforeEach(() => { + vi.clearAllMocks(); + + service = new AgentConfigService( + mockPrismaService as unknown as PrismaService, + mockCryptoService as unknown as CryptoService + ); + }); + + it("generateUserConfig returns valid openclaw.json structure", async () => { + mockPrismaService.userAgentConfig.findUnique.mockResolvedValue({ + id: "cfg-1", + userId: "user-1", + primaryModel: "my-zai/glm-5", + }); + + mockPrismaService.userContainer.findUnique.mockResolvedValue({ + id: "container-1", + userId: "user-1", + gatewayPort: 19001, + }); + + mockPrismaService.llmProvider.findMany.mockResolvedValue([ + { + id: "provider-1", + userId: "user-1", + name: "my-zai", + displayName: "Z.ai", + type: "zai", + baseUrl: "https://api.z.ai/v1", + apiKey: "enc:secret-zai-key", + apiType: "openai-completions", + models: [{ id: "glm-5" }], + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + const result = await service.generateUserConfig("user-1"); + + expect(result).toEqual({ + gateway: { + mode: "local", + port: 19001, + bind: "lan", + auth: { mode: "token" }, + http: { + endpoints: { + chatCompletions: { enabled: true }, + }, + }, + }, + agents: { + defaults: { + model: { + primary: "my-zai/glm-5", + }, + }, + }, + models: { + providers: { + "my-zai": { + apiKey: "secret-zai-key", + baseUrl: "https://api.z.ai/v1", + models: { + "glm-5": {}, + }, + }, + }, + }, + }); + }); + + it("generateUserConfig decrypts API keys correctly", async () => { + mockPrismaService.userAgentConfig.findUnique.mockResolvedValue({ + id: "cfg-1", + userId: "user-1", + primaryModel: "openai-work/gpt-4.1", + }); + + mockPrismaService.userContainer.findUnique.mockResolvedValue({ + id: "container-1", + userId: "user-1", + gatewayPort: 18789, + }); + + mockPrismaService.llmProvider.findMany.mockResolvedValue([ + { + id: "provider-1", + userId: "user-1", + name: "openai-work", + displayName: "OpenAI Work", + type: "openai", + baseUrl: "https://api.openai.com/v1", + apiKey: "enc:encrypted-openai-key", + apiType: "openai-completions", + models: [{ id: "gpt-4.1" }], + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + + const result = await service.generateUserConfig("user-1"); + + expect(mockCryptoService.decrypt).toHaveBeenCalledWith("enc:encrypted-openai-key"); + expect(result.models.providers["openai-work"]?.apiKey).toBe("encrypted-openai-key"); + }); + + it("generateUserConfig handles user with no providers", async () => { + mockPrismaService.userAgentConfig.findUnique.mockResolvedValue({ + id: "cfg-1", + userId: "user-2", + primaryModel: "openai/gpt-4o-mini", + }); + + mockPrismaService.userContainer.findUnique.mockResolvedValue({ + id: "container-2", + userId: "user-2", + gatewayPort: null, + }); + + mockPrismaService.llmProvider.findMany.mockResolvedValue([]); + + const result = await service.generateUserConfig("user-2"); + + expect(result.models.providers).toEqual({}); + expect(result.gateway.port).toBe(18789); + }); + + it("validateContainerToken returns correct type for user container", async () => { + mockPrismaService.userContainer.findMany.mockResolvedValue([ + { + id: "user-container-1", + gatewayToken: "enc:user-token-1", + }, + ]); + mockPrismaService.systemContainer.findMany.mockResolvedValue([]); + + const result = await service.validateContainerToken("user-token-1"); + + expect(result).toEqual({ + type: "user", + id: "user-container-1", + }); + }); + + it("validateContainerToken returns correct type for system container", async () => { + mockPrismaService.userContainer.findMany.mockResolvedValue([]); + mockPrismaService.systemContainer.findMany.mockResolvedValue([ + { + id: "system-container-1", + gatewayToken: "enc:system-token-1", + }, + ]); + + const result = await service.validateContainerToken("system-token-1"); + + expect(result).toEqual({ + type: "system", + id: "system-container-1", + }); + }); + + it("validateContainerToken returns null for invalid token", async () => { + mockPrismaService.userContainer.findMany.mockResolvedValue([ + { + id: "user-container-1", + gatewayToken: "enc:user-token-1", + }, + ]); + + mockPrismaService.systemContainer.findMany.mockResolvedValue([ + { + id: "system-container-1", + gatewayToken: "enc:system-token-1", + }, + ]); + + const result = await service.validateContainerToken("no-match"); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/api/src/agent-config/agent-config.service.ts b/apps/api/src/agent-config/agent-config.service.ts new file mode 100644 index 0000000..f4917c5 --- /dev/null +++ b/apps/api/src/agent-config/agent-config.service.ts @@ -0,0 +1,288 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import type { LlmProvider } from "@prisma/client"; +import { timingSafeEqual } from "node:crypto"; +import { PrismaService } from "../prisma/prisma.service"; +import { CryptoService } from "../crypto/crypto.service"; + +const DEFAULT_GATEWAY_PORT = 18789; +const DEFAULT_PRIMARY_MODEL = "openai/gpt-4o-mini"; + +type ContainerType = "user" | "system"; + +export interface ContainerTokenValidation { + type: ContainerType; + id: string; +} + +type OpenClawModelMap = Record>; + +interface OpenClawProviderConfig { + apiKey?: string; + baseUrl?: string; + models: OpenClawModelMap; +} + +interface OpenClawConfig { + gateway: { + mode: "local"; + port: number; + bind: "lan"; + auth: { mode: "token" }; + http: { + endpoints: { + chatCompletions: { enabled: true }; + }; + }; + }; + agents: { + defaults: { + model: { + primary: string; + }; + }; + }; + models: { + providers: Record; + }; +} + +@Injectable() +export class AgentConfigService { + constructor( + private readonly prisma: PrismaService, + private readonly crypto: CryptoService + ) {} + + // Generate complete openclaw.json for a user container + async generateUserConfig(userId: string): Promise { + const [userAgentConfig, providers, userContainer] = await Promise.all([ + this.prisma.userAgentConfig.findUnique({ + where: { userId }, + }), + this.prisma.llmProvider.findMany({ + where: { + userId, + isActive: true, + }, + orderBy: { + createdAt: "asc", + }, + }), + this.prisma.userContainer.findUnique({ + where: { userId }, + }), + ]); + + if (!userContainer) { + throw new NotFoundException(`User container not found for user ${userId}`); + } + + const primaryModel = + userAgentConfig?.primaryModel ?? + this.resolvePrimaryModelFromProviders(providers) ?? + DEFAULT_PRIMARY_MODEL; + + return this.buildOpenClawConfig(primaryModel, userContainer.gatewayPort, providers); + } + + // Generate config for a system container + async generateSystemConfig(containerId: string): Promise { + const systemContainer = await this.prisma.systemContainer.findUnique({ + where: { id: containerId }, + }); + + if (!systemContainer) { + throw new NotFoundException(`System container ${containerId} not found`); + } + + return this.buildOpenClawConfig( + systemContainer.primaryModel || DEFAULT_PRIMARY_MODEL, + systemContainer.gatewayPort, + [] + ); + } + + async generateConfigForContainer( + type: ContainerType, + containerId: string + ): Promise { + if (type === "system") { + return this.generateSystemConfig(containerId); + } + + const userContainer = await this.prisma.userContainer.findUnique({ + where: { id: containerId }, + select: { userId: true }, + }); + + if (!userContainer) { + throw new NotFoundException(`User container ${containerId} not found`); + } + + return this.generateUserConfig(userContainer.userId); + } + + // Validate a container's bearer token + async validateContainerToken(token: string): Promise { + if (!token) { + return null; + } + + const [userContainers, systemContainers] = await Promise.all([ + this.prisma.userContainer.findMany({ + select: { + id: true, + gatewayToken: true, + }, + }), + this.prisma.systemContainer.findMany({ + select: { + id: true, + gatewayToken: true, + }, + }), + ]); + + for (const container of userContainers) { + const storedToken = this.decryptContainerToken(container.gatewayToken); + if (storedToken && this.tokensEqual(storedToken, token)) { + return { type: "user", id: container.id }; + } + } + + for (const container of systemContainers) { + const storedToken = this.decryptContainerToken(container.gatewayToken); + if (storedToken && this.tokensEqual(storedToken, token)) { + return { type: "system", id: container.id }; + } + } + + return null; + } + + private buildOpenClawConfig( + primaryModel: string, + gatewayPort: number | null, + providers: LlmProvider[] + ): OpenClawConfig { + return { + gateway: { + mode: "local", + port: gatewayPort ?? DEFAULT_GATEWAY_PORT, + bind: "lan", + auth: { mode: "token" }, + http: { + endpoints: { + chatCompletions: { enabled: true }, + }, + }, + }, + agents: { + defaults: { + model: { + primary: primaryModel, + }, + }, + }, + models: { + providers: this.buildProviderConfig(providers), + }, + }; + } + + private buildProviderConfig(providers: LlmProvider[]): Record { + const providerConfig: Record = {}; + + for (const provider of providers) { + const config: OpenClawProviderConfig = { + models: this.extractModels(provider.models), + }; + + const apiKey = this.decryptIfNeeded(provider.apiKey); + if (apiKey) { + config.apiKey = apiKey; + } + + if (provider.baseUrl) { + config.baseUrl = provider.baseUrl; + } + + providerConfig[provider.name] = config; + } + + return providerConfig; + } + + private extractModels(models: unknown): OpenClawModelMap { + const modelMap: OpenClawModelMap = {}; + + if (!Array.isArray(models)) { + return modelMap; + } + + for (const modelEntry of models) { + if (typeof modelEntry === "string") { + modelMap[modelEntry] = {}; + continue; + } + + if (this.hasModelId(modelEntry)) { + modelMap[modelEntry.id] = {}; + } + } + + return modelMap; + } + + private resolvePrimaryModelFromProviders(providers: LlmProvider[]): string | null { + for (const provider of providers) { + const modelIds = Object.keys(this.extractModels(provider.models)); + const firstModelId = modelIds[0]; + + if (firstModelId) { + return `${provider.name}/${firstModelId}`; + } + } + + return null; + } + + private decryptIfNeeded(value: string | null | undefined): string | undefined { + if (!value) { + return undefined; + } + + if (this.crypto.isEncrypted(value)) { + return this.crypto.decrypt(value); + } + + return value; + } + + private decryptContainerToken(value: string): string | null { + try { + return this.decryptIfNeeded(value) ?? null; + } catch { + return null; + } + } + + private tokensEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, "utf8"); + const rightBuffer = Buffer.from(right, "utf8"); + + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + + return timingSafeEqual(leftBuffer, rightBuffer); + } + + private hasModelId(modelEntry: unknown): modelEntry is { id: string } { + if (typeof modelEntry !== "object" || modelEntry === null || !("id" in modelEntry)) { + return false; + } + + return typeof (modelEntry as { id?: unknown }).id === "string"; + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 59d358a..d3bc776 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -51,6 +51,7 @@ import { TeamsModule } from "./teams/teams.module"; import { ImportModule } from "./import/import.module"; import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module"; import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; +import { AgentConfigModule } from "./agent-config/agent-config.module"; @Module({ imports: [ @@ -123,6 +124,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce TeamsModule, ImportModule, ConversationArchiveModule, + AgentConfigModule, ], controllers: [AppController, CsrfController], providers: [ -- 2.49.1