Compare commits
7 Commits
feat/ms22-
...
feat/ms22-
| Author | SHA1 | Date | |
|---|---|---|---|
| 496244c8ef | |||
| a3a0d7afca | |||
| ab2b68c93c | |||
| c1ec0ad7ef | |||
| e5b772f7cb | |||
| 3688f89c37 | |||
| fab833a710 |
40
apps/api/src/agent-config/agent-config.controller.ts
Normal file
40
apps/api/src/agent-config/agent-config.controller.ts
Normal file
@@ -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<object> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
43
apps/api/src/agent-config/agent-config.guard.ts
Normal file
43
apps/api/src/agent-config/agent-config.guard.ts
Normal file
@@ -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<boolean> {
|
||||
const request = context.switchToHttp().getRequest<AgentConfigRequest>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
14
apps/api/src/agent-config/agent-config.module.ts
Normal file
14
apps/api/src/agent-config/agent-config.module.ts
Normal file
@@ -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 {}
|
||||
215
apps/api/src/agent-config/agent-config.service.spec.ts
Normal file
215
apps/api/src/agent-config/agent-config.service.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
288
apps/api/src/agent-config/agent-config.service.ts
Normal file
288
apps/api/src/agent-config/agent-config.service.ts
Normal file
@@ -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<string, Record<string, never>>;
|
||||
|
||||
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<string, OpenClawProviderConfig>;
|
||||
};
|
||||
}
|
||||
|
||||
@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<OpenClawConfig> {
|
||||
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<OpenClawConfig> {
|
||||
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<OpenClawConfig> {
|
||||
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<ContainerTokenValidation | null> {
|
||||
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<string, OpenClawProviderConfig> {
|
||||
const providerConfig: Record<string, OpenClawProviderConfig> = {};
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
114
docs/PRD-MS22.md
Normal file
114
docs/PRD-MS22.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# PRD: MS22 — Fleet Evolution (DB-Centric Agent Architecture)
|
||||
|
||||
## Metadata
|
||||
|
||||
- Owner: Jason Woltje
|
||||
- Date: 2026-03-01
|
||||
- Status: in-progress
|
||||
- Design Doc: `docs/design/MS22-DB-CENTRIC-ARCHITECTURE.md`
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Mosaic Stack needs a multi-user agent fleet where each user gets their own isolated OpenClaw instance with their own LLM provider credentials and agent config. The system must be Docker-first with minimal environment variables and all configuration managed through the WebUI.
|
||||
|
||||
## Objectives
|
||||
|
||||
1. **Minimal bootstrap** — 2 env vars (`DATABASE_URL`, `MOSAIC_SECRET_KEY`) to start the entire stack
|
||||
2. **DB-centric config** — All runtime config in Postgres, managed via WebUI
|
||||
3. **Per-user isolation** — Each user gets their own OpenClaw container with own API keys, memory, sessions
|
||||
4. **Onboarding wizard** — First-boot experience: breakglass admin → OIDC → LLM provider → agent config
|
||||
5. **Settings UI** — Runtime management of providers, agents, and auth config
|
||||
6. **Mosaic as gatekeeper** — Users never talk to OpenClaw directly; Mosaic proxies all requests
|
||||
7. **Zero cross-user access** — Full container, volume, and DB isolation between users
|
||||
|
||||
## Security Requirements
|
||||
|
||||
- User A cannot access User B's API keys, chat history, or agent memory
|
||||
- All API keys stored encrypted (AES-256-GCM) in database
|
||||
- Breakglass admin always works as OIDC fallback
|
||||
- OIDC config stored in DB (not env vars) — configured via settings UI
|
||||
- Container-to-container communication blocked by default
|
||||
- Admin cannot decrypt other users' API keys
|
||||
|
||||
## Phase 0: Knowledge Layer — COMPLETE
|
||||
|
||||
- Findings API (pgvector, CRUD, similarity search)
|
||||
- AgentMemory API (key/value store)
|
||||
- ConversationArchive API (pgvector, ingest, search)
|
||||
- OpenClaw mosaic skill
|
||||
- Session log ingestion pipeline
|
||||
|
||||
## Phase 1: DB-Centric Agent Fleet
|
||||
|
||||
### Phase 1a: DB Schema — COMPLETE
|
||||
|
||||
- SystemConfig, BreakglassUser, LlmProvider, UserContainer, SystemContainer, UserAgentConfig tables
|
||||
|
||||
### Phase 1b: Encryption Service — COMPLETE
|
||||
|
||||
- CryptoService (AES-256-GCM using MOSAIC_SECRET_KEY)
|
||||
|
||||
### Phase 1c: Internal Config API
|
||||
|
||||
- `GET /api/internal/agent-config/:id` — assembles openclaw.json from DB
|
||||
- Auth: bearer token (container's own gateway token)
|
||||
- Returns complete openclaw.json with decrypted provider credentials
|
||||
|
||||
### Phase 1d: Container Lifecycle Manager
|
||||
|
||||
- Docker API integration via `dockerode` npm package
|
||||
- Start/stop/health-check/reap user containers
|
||||
- Auto-generate gateway tokens, assign ports
|
||||
- Docker socket access required (`/var/run/docker.sock`)
|
||||
|
||||
### Phase 1e: Onboarding API
|
||||
|
||||
- First-boot detection (`SystemConfig.onboarding.completed`)
|
||||
- `POST /api/onboarding/breakglass` — create admin user
|
||||
- `POST /api/onboarding/oidc` — save OIDC provider config
|
||||
- `POST /api/onboarding/provider` — add LLM provider + test connection
|
||||
- `POST /api/onboarding/complete` — mark done
|
||||
|
||||
### Phase 1f: Onboarding Wizard UI
|
||||
|
||||
- Multi-step wizard component
|
||||
- Skip-able OIDC step
|
||||
- LLM provider connection test
|
||||
|
||||
### Phase 1g: Settings API
|
||||
|
||||
- CRUD: LLM providers (per-user scoped)
|
||||
- CRUD: Agent config (model assignments, personalities)
|
||||
- CRUD: OIDC config (admin only)
|
||||
- Breakglass password reset (admin only)
|
||||
|
||||
### Phase 1h: Settings UI
|
||||
|
||||
- Settings/Providers page
|
||||
- Settings/Agent Config page
|
||||
- Settings/Auth page (OIDC + breakglass)
|
||||
|
||||
### Phase 1i: Chat Proxy
|
||||
|
||||
- Route WebUI chat to user's OpenClaw container
|
||||
- SSE streaming pass-through
|
||||
- Ensure container is running before proxying (auto-start)
|
||||
|
||||
### Phase 1j: Docker Compose + Entrypoint
|
||||
|
||||
- Simplified compose (core services only — user containers are dynamic)
|
||||
- Entrypoint: fetch config from API, write openclaw.json, start gateway
|
||||
- Health check integration
|
||||
|
||||
### Phase 1k: Idle Reaper
|
||||
|
||||
- Cron job to stop inactive user containers
|
||||
- Configurable idle timeout (default 30min)
|
||||
- Preserve state volumes
|
||||
|
||||
## Future Phases (out of scope)
|
||||
|
||||
- Phase 2: Agent fleet standup (predefined agent roles)
|
||||
- Phase 3: WebUI chat + task management integration
|
||||
- Phase 4: Multi-LLM provider management UI (advanced)
|
||||
- Team workspaces (shared agent contexts) — explicitly out of scope
|
||||
@@ -71,3 +71,21 @@ Remaining estimate: ~143K tokens (Codex budget).
|
||||
| MS22-SKILL-001 | done | p0-knowledge | OpenClaw mosaic skill (agents read/write findings/memory) | TASKS:P0 | stack | feat/ms22-openclaw-skill | MS22-API-001,MS22-API-002 | MS22-VER-P0 | — | — | — | 15K | — | Skill in ~/.agents/skills/mosaic/ |
|
||||
| MS22-INGEST-001 | done | p0-knowledge | Session log ingestion pipeline (OpenClaw logs → ConvArchive) | TASKS:P0 | stack | feat/ms22-ingest | MS22-API-004 | MS22-VER-P0 | — | — | — | 20K | — | Script to batch-ingest existing logs |
|
||||
| MS22-VER-P0 | done | p0-knowledge | Phase 0 verification: all modules deployed + smoke tested | TASKS:P0 | stack | — | MS22-TEST-001,MS22-SKILL-001,MS22-INGEST-001,MS22-API-003 | — | — | — | — | 5K | — | |
|
||||
|
||||
## MS22 Phase 1: DB-Centric Agent Fleet (reworked)
|
||||
|
||||
Design doc: `docs/design/MS22-DB-CENTRIC-ARCHITECTURE.md`
|
||||
|
||||
| Task ID | Status | Phase | Description | Issue | Scope | Branch | Depends On | Blocks | Assigned Worker | Started | Completed | Est Tokens | Act Tokens | Notes |
|
||||
| -------- | ----------- | -------- | --------------------------------------------------------------------------------------------------------------------- | ----- | ------- | ---------------------------- | ---------- | --------------- | --------------- | ------- | --------- | ---------- | ---------- | ----- |
|
||||
| MS22-P1a | done | phase-1a | Prisma schema: SystemConfig, BreakglassUser, LlmProvider, UserContainer, SystemContainer, UserAgentConfig + migration | — | api | feat/ms22-p1a-schema | — | P1b,P1c,P1d,P1e | — | — | — | 20K | — | |
|
||||
| MS22-P1b | done | phase-1b | Encryption service (AES-256-GCM) for API keys and tokens | — | api | feat/ms22-p1b-crypto | — | P1c,P1e,P1g | — | — | — | 15K | — | |
|
||||
| MS22-P1c | not-started | phase-1c | Internal config endpoint: assemble openclaw.json from DB | — | api | feat/ms22-p1c-config-api | P1a,P1b | P1i,P1j | — | — | — | 20K | — | |
|
||||
| MS22-P1d | not-started | phase-1d | ContainerLifecycleService: Docker API (dockerode) start/stop/health/reap | — | api | feat/ms22-p1d-container-mgr | P1a | P1i,P1k | — | — | — | 25K | — | |
|
||||
| MS22-P1e | not-started | phase-1e | Onboarding API: breakglass, OIDC, provider, agents, complete | — | api | feat/ms22-p1e-onboarding-api | P1a,P1b | P1f | — | — | — | 20K | — | |
|
||||
| MS22-P1f | not-started | phase-1f | Onboarding wizard WebUI (multi-step form) | — | web | feat/ms22-p1f-onboarding-ui | P1e | — | — | — | — | 25K | — | |
|
||||
| MS22-P1g | not-started | phase-1g | Settings API: CRUD providers, agent config, OIDC, breakglass | — | api | feat/ms22-p1g-settings-api | P1a,P1b | P1h | — | — | — | 20K | — | |
|
||||
| MS22-P1h | not-started | phase-1h | Settings UI: Providers, Agent Config, Auth pages | — | web | feat/ms22-p1h-settings-ui | P1g | — | — | — | — | 25K | — | |
|
||||
| MS22-P1i | not-started | phase-1i | Chat proxy: route WebUI chat to user's OpenClaw container (SSE) | — | api+web | feat/ms22-p1i-chat-proxy | P1c,P1d | — | — | — | — | 20K | — | |
|
||||
| MS22-P1j | not-started | phase-1j | Docker entrypoint + health checks + core compose | — | docker | feat/ms22-p1j-docker | P1c | — | — | — | — | 10K | — | |
|
||||
| MS22-P1k | not-started | phase-1k | Idle reaper cron: stop inactive user containers | — | api | feat/ms22-p1k-idle-reaper | P1d | — | — | — | — | 10K | — | |
|
||||
|
||||
Reference in New Issue
Block a user