Compare commits

..

2 Commits

Author SHA1 Message Date
b13ff68e22 fix(api): use generic mosaic-* naming in OpenClawGateway schema and tests
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Replaces all jarvis-* references with mosaic-* for generic multi-user deployment.
2026-03-01 08:04:55 -06:00
c847b74bda feat(api): add OpenClawGatewayModule with agent registry (MS22-P1b)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-01 07:59:39 -06:00
19 changed files with 863 additions and 886 deletions

View File

@@ -80,8 +80,8 @@
"session_id": "sess-002",
"runtime": "unknown",
"started_at": "2026-02-28T20:30:13Z",
"ended_at": "",
"ended_reason": "",
"ended_at": "2026-03-01T14:04:00Z",
"ended_reason": "completed",
"milestone_at_end": "",
"tasks_completed": [],
"last_task_id": ""

View File

@@ -1,8 +0,0 @@
{
"session_id": "sess-002",
"runtime": "unknown",
"pid": 3178395,
"started_at": "2026-02-28T20:30:13Z",
"project_path": "/tmp/ms21-ui-001",
"milestone_id": ""
}

View File

@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "OpenClawAgent" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"role" TEXT NOT NULL,
"gatewayUrl" TEXT NOT NULL,
"agentId" TEXT NOT NULL DEFAULT 'main',
"model" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OpenClawAgent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "OpenClawAgent_name_key" ON "OpenClawAgent"("name");

View File

@@ -1407,6 +1407,19 @@ model Instance {
@@map("instances")
}
model OpenClawAgent {
id String @id @default(cuid())
name String @unique // "mosaic-main", "mosaic-projects", etc.
displayName String // "Main Orchestrator", "Projects", etc.
role String // "orchestrator" | "developer" | "researcher" | "operations"
gatewayUrl String // "http://mosaic-main:18789"
agentId String @default("main") // OpenClaw agent id within that instance
model String // "zai/glm-5", "ollama/cogito"
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model FederationConnection {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid

View File

@@ -39,7 +39,6 @@ import { JobStepsModule } from "./job-steps/job-steps.module";
import { CoordinatorIntegrationModule } from "./coordinator-integration/coordinator-integration.module";
import { FederationModule } from "./federation/federation.module";
import { CredentialsModule } from "./credentials/credentials.module";
import { CryptoModule } from "./crypto/crypto.module";
import { MosaicTelemetryModule } from "./mosaic-telemetry";
import { SpeechModule } from "./speech/speech.module";
import { DashboardModule } from "./dashboard/dashboard.module";
@@ -50,6 +49,7 @@ import { AdminModule } from "./admin/admin.module";
import { TeamsModule } from "./teams/teams.module";
import { ImportModule } from "./import/import.module";
import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module";
import { OpenClawGatewayModule } from "./openclaw-gateway/openclaw-gateway.module";
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
@Module({
@@ -112,7 +112,6 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
CoordinatorIntegrationModule,
FederationModule,
CredentialsModule,
CryptoModule,
MosaicTelemetryModule,
SpeechModule,
DashboardModule,
@@ -123,6 +122,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
TeamsModule,
ImportModule,
ConversationArchiveModule,
OpenClawGatewayModule,
],
controllers: [AppController, CsrfController],
providers: [

View File

@@ -1,10 +0,0 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { CryptoService } from "./crypto.service";
@Module({
imports: [ConfigModule],
providers: [CryptoService],
exports: [CryptoService],
})
export class CryptoModule {}

View File

@@ -1,71 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import { ConfigService } from "@nestjs/config";
import { CryptoService } from "./crypto.service";
function createConfigService(secret?: string): ConfigService {
return {
get: (key: string) => {
if (key === "MOSAIC_SECRET_KEY") {
return secret;
}
return undefined;
},
} as unknown as ConfigService;
}
describe("CryptoService", () => {
let service: CryptoService;
beforeEach(() => {
service = new CryptoService(createConfigService("this-is-a-test-secret-key-with-32+chars"));
});
it("encrypt -> decrypt roundtrip", () => {
const plaintext = "my-secret-api-key";
const encrypted = service.encrypt(plaintext);
const decrypted = service.decrypt(encrypted);
expect(encrypted.startsWith("enc:")).toBe(true);
expect(decrypted).toBe(plaintext);
});
it("decrypt rejects tampered ciphertext", () => {
const encrypted = service.encrypt("sensitive-token");
const payload = encrypted.slice(4);
const bytes = Buffer.from(payload, "base64");
bytes[bytes.length - 1] = bytes[bytes.length - 1]! ^ 0xff;
const tampered = `enc:${bytes.toString("base64")}`;
expect(() => service.decrypt(tampered)).toThrow();
});
it("decrypt rejects non-encrypted string", () => {
expect(() => service.decrypt("plain-text-value")).toThrow();
});
it("isEncrypted detects prefix correctly", () => {
expect(service.isEncrypted("enc:abc")).toBe(true);
expect(service.isEncrypted("ENC:abc")).toBe(false);
expect(service.isEncrypted("plain-text")).toBe(false);
});
it("generateToken returns 64-char hex string", () => {
const token = service.generateToken();
expect(token).toMatch(/^[0-9a-f]{64}$/);
});
it("different plaintexts produce different ciphertexts (random IV)", () => {
const encryptedA = service.encrypt("value-a");
const encryptedB = service.encrypt("value-b");
expect(encryptedA).not.toBe(encryptedB);
});
it("missing MOSAIC_SECRET_KEY throws on construction", () => {
expect(() => new CryptoService(createConfigService(undefined))).toThrow();
});
});

View File

@@ -1,82 +0,0 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "crypto";
const ALGORITHM = "aes-256-gcm";
const ENCRYPTED_PREFIX = "enc:";
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const DERIVED_KEY_LENGTH = 32;
const HKDF_SALT = "mosaic.crypto.v1";
const HKDF_INFO = "mosaic-db-secret-encryption";
@Injectable()
export class CryptoService {
private readonly key: Buffer;
constructor(private readonly config: ConfigService) {
const secret = this.config.get<string>("MOSAIC_SECRET_KEY");
if (!secret) {
throw new Error("MOSAIC_SECRET_KEY environment variable is required");
}
if (secret.length < 32) {
throw new Error("MOSAIC_SECRET_KEY must be at least 32 characters");
}
this.key = Buffer.from(
hkdfSync(
"sha256",
Buffer.from(secret, "utf8"),
Buffer.from(HKDF_SALT, "utf8"),
Buffer.from(HKDF_INFO, "utf8"),
DERIVED_KEY_LENGTH
)
);
}
encrypt(plaintext: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, this.key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
const payload = Buffer.concat([iv, ciphertext, authTag]).toString("base64");
return `${ENCRYPTED_PREFIX}${payload}`;
}
decrypt(encrypted: string): string {
if (!this.isEncrypted(encrypted)) {
throw new Error("Value is not encrypted");
}
const payloadBase64 = encrypted.slice(ENCRYPTED_PREFIX.length);
try {
const payload = Buffer.from(payloadBase64, "base64");
if (payload.length < IV_LENGTH + AUTH_TAG_LENGTH) {
throw new Error("Encrypted payload is too short");
}
const iv = payload.subarray(0, IV_LENGTH);
const authTag = payload.subarray(payload.length - AUTH_TAG_LENGTH);
const ciphertext = payload.subarray(IV_LENGTH, payload.length - AUTH_TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, this.key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
} catch {
throw new Error("Failed to decrypt value");
}
}
isEncrypted(value: string): boolean {
return value.startsWith(ENCRYPTED_PREFIX);
}
generateToken(): string {
return randomBytes(32).toString("hex");
}
}

View File

@@ -0,0 +1,36 @@
import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common";
import type { OpenClawAgent } from "@prisma/client";
import { AgentRegistryService } from "./agent-registry.service";
import type {
CreateOpenClawAgentDto,
QueryOpenClawAgentsDto,
UpdateOpenClawAgentDto,
} from "./openclaw-gateway.dto";
@Controller("openclaw/agents")
export class AgentRegistryController {
constructor(private readonly agentRegistryService: AgentRegistryService) {}
@Get()
async listAgents(@Query() query: QueryOpenClawAgentsDto): Promise<OpenClawAgent[]> {
return this.agentRegistryService.listAgents(query);
}
@Get(":name")
async getAgent(@Param("name") name: string): Promise<OpenClawAgent> {
return this.agentRegistryService.getAgent(name);
}
@Post()
async createAgent(@Body() dto: CreateOpenClawAgentDto): Promise<OpenClawAgent> {
return this.agentRegistryService.createAgent(dto);
}
@Patch(":name")
async updateAgent(
@Param("name") name: string,
@Body() dto: UpdateOpenClawAgentDto
): Promise<OpenClawAgent> {
return this.agentRegistryService.updateAgent(name, dto);
}
}

View File

@@ -0,0 +1,65 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, type OpenClawAgent } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import type {
CreateOpenClawAgentDto,
QueryOpenClawAgentsDto,
UpdateOpenClawAgentDto,
} from "./openclaw-gateway.dto";
@Injectable()
export class AgentRegistryService {
constructor(private readonly prisma: PrismaService) {}
async listAgents(query: QueryOpenClawAgentsDto): Promise<OpenClawAgent[]> {
const where = query.isActive === undefined ? {} : { isActive: query.isActive };
return this.prisma.openClawAgent.findMany({
where,
orderBy: { name: "asc" },
});
}
async getAgent(name: string): Promise<OpenClawAgent> {
const agent = await this.prisma.openClawAgent.findUnique({
where: { name },
});
if (!agent) {
throw new NotFoundException(`OpenClaw agent '${name}' not found`);
}
return agent;
}
async createAgent(dto: CreateOpenClawAgentDto): Promise<OpenClawAgent> {
return this.prisma.openClawAgent.create({
data: {
name: dto.name,
displayName: dto.displayName,
role: dto.role,
gatewayUrl: dto.gatewayUrl,
agentId: dto.agentId ?? "main",
model: dto.model,
isActive: dto.isActive ?? true,
},
});
}
async updateAgent(name: string, dto: UpdateOpenClawAgentDto): Promise<OpenClawAgent> {
await this.getAgent(name);
const data: Prisma.OpenClawAgentUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.displayName !== undefined) data.displayName = dto.displayName;
if (dto.role !== undefined) data.role = dto.role;
if (dto.gatewayUrl !== undefined) data.gatewayUrl = dto.gatewayUrl;
if (dto.agentId !== undefined) data.agentId = dto.agentId;
if (dto.model !== undefined) data.model = dto.model;
if (dto.isActive !== undefined) data.isActive = dto.isActive;
return this.prisma.openClawAgent.update({
where: { name },
data,
});
}
}

View File

@@ -0,0 +1,40 @@
import { Body, Controller, HttpCode, HttpStatus, Post, Res } from "@nestjs/common";
import type { Response } from "express";
import { OpenClawGatewayService } from "./openclaw-gateway.service";
import { ChatRequestDto } from "./openclaw-gateway.dto";
@Controller("openclaw")
export class OpenClawGatewayController {
constructor(private readonly openClawGatewayService: OpenClawGatewayService) {}
@Post("chat")
@HttpCode(HttpStatus.OK)
async chat(@Body() dto: ChatRequestDto, @Res() res: Response): Promise<void> {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
if (typeof res.flushHeaders === "function") {
res.flushHeaders();
}
try {
for await (const content of this.openClawGatewayService.streamChat(
dto.agent,
dto.messages,
dto.workspaceId
)) {
res.write(`data: ${JSON.stringify({ content })}\n\n`);
}
res.write("data: [DONE]\n\n");
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
res.write("event: error\n");
res.write(`data: ${JSON.stringify({ error: errorMessage })}\n\n`);
} finally {
res.end();
}
}
}

View File

@@ -0,0 +1,115 @@
import { PartialType } from "@nestjs/mapped-types";
import {
IsArray,
IsBoolean,
IsIn,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
ValidateNested,
} from "class-validator";
import { Type, Transform } from "class-transformer";
export type ChatRole = "system" | "user" | "assistant";
export interface ChatMessage {
role: ChatRole;
content: string;
}
export class ChatMessageDto implements ChatMessage {
@IsString({ message: "role must be a string" })
@IsIn(["system", "user", "assistant"], {
message: "role must be one of: system, user, assistant",
})
role!: ChatRole;
@IsString({ message: "content must be a string" })
@MinLength(1, { message: "content must not be empty" })
content!: string;
}
export class ChatRequestDto {
@IsString({ message: "agent must be a string" })
@MinLength(1, { message: "agent must not be empty" })
@MaxLength(100, { message: "agent must not exceed 100 characters" })
@Matches(/^[a-z0-9-]+$/, {
message: "agent must contain only lowercase letters, numbers, and hyphens",
})
agent!: string;
@IsArray({ message: "messages must be an array" })
@ValidateNested({ each: true })
@Type(() => ChatMessageDto)
messages!: ChatMessageDto[];
@IsOptional()
@IsString({ message: "workspaceId must be a string" })
workspaceId?: string;
}
export class CreateOpenClawAgentDto {
@IsString({ message: "name must be a string" })
@MinLength(1, { message: "name must not be empty" })
@MaxLength(100, { message: "name must not exceed 100 characters" })
@Matches(/^[a-z0-9-]+$/, {
message: "name must contain only lowercase letters, numbers, and hyphens",
})
name!: string;
@IsString({ message: "displayName must be a string" })
@MinLength(1, { message: "displayName must not be empty" })
@MaxLength(255, { message: "displayName must not exceed 255 characters" })
displayName!: string;
@IsString({ message: "role must be a string" })
@IsIn(["orchestrator", "developer", "researcher", "operations"], {
message: "role must be one of: orchestrator, developer, researcher, operations",
})
role!: string;
@IsString({ message: "gatewayUrl must be a string" })
@MinLength(1, { message: "gatewayUrl must not be empty" })
@MaxLength(2048, { message: "gatewayUrl must not exceed 2048 characters" })
@Matches(/^https?:\/\/[^\s]+$/i, {
message: "gatewayUrl must be a valid HTTP(S) URL",
})
gatewayUrl!: string;
@IsOptional()
@IsString({ message: "agentId must be a string" })
@MinLength(1, { message: "agentId must not be empty" })
@MaxLength(100, { message: "agentId must not exceed 100 characters" })
agentId?: string;
@IsString({ message: "model must be a string" })
@MinLength(1, { message: "model must not be empty" })
@MaxLength(255, { message: "model must not exceed 255 characters" })
model!: string;
@IsOptional()
@IsBoolean({ message: "isActive must be a boolean" })
isActive?: boolean;
}
export class UpdateOpenClawAgentDto extends PartialType(CreateOpenClawAgentDto) {}
export class QueryOpenClawAgentsDto {
@IsOptional()
@Transform(({ value }: { value: unknown }) => {
if (typeof value === "boolean") {
return value;
}
if (value === "true" || value === "1") {
return true;
}
if (value === "false" || value === "0") {
return false;
}
return value;
})
@IsBoolean({ message: "isActive must be a boolean" })
isActive?: boolean;
}

View File

@@ -0,0 +1,21 @@
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { PrismaModule } from "../prisma/prisma.module";
import { OpenClawGatewayService } from "./openclaw-gateway.service";
import { OpenClawGatewayController } from "./openclaw-gateway.controller";
import { AgentRegistryService } from "./agent-registry.service";
import { AgentRegistryController } from "./agent-registry.controller";
@Module({
imports: [
PrismaModule,
HttpModule.register({
timeout: 120000,
maxRedirects: 3,
}),
],
controllers: [OpenClawGatewayController, AgentRegistryController],
providers: [OpenClawGatewayService, AgentRegistryService],
exports: [OpenClawGatewayService, AgentRegistryService],
})
export class OpenClawGatewayModule {}

View File

@@ -0,0 +1,212 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import {
NotFoundException,
ServiceUnavailableException,
UnauthorizedException,
} from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { Readable } from "node:stream";
import { PrismaService } from "../prisma/prisma.service";
import { OpenClawGatewayService } from "./openclaw-gateway.service";
import type { ChatMessage } from "./openclaw-gateway.dto";
describe("OpenClawGatewayService", () => {
let service: OpenClawGatewayService;
const mockPrisma = {
openClawAgent: {
findUnique: vi.fn(),
},
};
const mockHttpService = {
axiosRef: {
post: vi.fn(),
},
};
const tokenEnvKey = "OPENCLAW_TOKEN_MOSAIC_MAIN";
beforeEach(async () => {
vi.clearAllMocks();
process.env[tokenEnvKey] = "test-token";
const module: TestingModule = await Test.createTestingModule({
providers: [
OpenClawGatewayService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: HttpService, useValue: mockHttpService },
],
}).compile();
service = module.get<OpenClawGatewayService>(OpenClawGatewayService);
});
afterEach(() => {
delete process.env[tokenEnvKey];
});
it("streams content chunks from OpenClaw SSE responses", async () => {
const messages: ChatMessage[] = [{ role: "user", content: "Hello" }];
mockPrisma.openClawAgent.findUnique.mockResolvedValue({
id: "agent-1",
name: "mosaic-main",
displayName: "Main Orchestrator",
role: "orchestrator",
gatewayUrl: "http://mosaic-main:18789",
agentId: "main",
model: "zai/glm-5",
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
mockHttpService.axiosRef.post.mockResolvedValue({
data: Readable.from([
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
"data: [DONE]\n\n",
]),
});
const chunks: string[] = [];
for await (const chunk of service.streamChat("mosaic-main", messages)) {
chunks.push(chunk);
}
expect(chunks).toEqual(["Hello", " world"]);
expect(mockHttpService.axiosRef.post).toHaveBeenCalledWith(
"http://mosaic-main:18789/v1/chat/completions",
{
model: "openclaw:main",
messages,
stream: true,
},
expect.objectContaining({
responseType: "stream",
headers: expect.objectContaining({
Authorization: "Bearer test-token",
"Content-Type": "application/json",
}),
})
);
});
it("throws NotFoundException when agent is not registered", async () => {
mockPrisma.openClawAgent.findUnique.mockResolvedValue(null);
await expect(
(async () => {
for await (const _chunk of service.streamChat("missing-agent", [])) {
// no-op
}
})()
).rejects.toBeInstanceOf(NotFoundException);
});
it("throws ServiceUnavailableException when agent is inactive", async () => {
mockPrisma.openClawAgent.findUnique.mockResolvedValue({
id: "agent-1",
name: "mosaic-main",
displayName: "Main Orchestrator",
role: "orchestrator",
gatewayUrl: "http://mosaic-main:18789",
agentId: "main",
model: "zai/glm-5",
isActive: false,
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(
(async () => {
for await (const _chunk of service.streamChat("mosaic-main", [])) {
// no-op
}
})()
).rejects.toBeInstanceOf(ServiceUnavailableException);
});
it("throws ServiceUnavailableException when token env var is missing", async () => {
delete process.env[tokenEnvKey];
mockPrisma.openClawAgent.findUnique.mockResolvedValue({
id: "agent-1",
name: "mosaic-main",
displayName: "Main Orchestrator",
role: "orchestrator",
gatewayUrl: "http://mosaic-main:18789",
agentId: "main",
model: "zai/glm-5",
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(
(async () => {
for await (const _chunk of service.streamChat("mosaic-main", [])) {
// no-op
}
})()
).rejects.toBeInstanceOf(ServiceUnavailableException);
});
it("throws UnauthorizedException when OpenClaw returns 401", async () => {
mockPrisma.openClawAgent.findUnique.mockResolvedValue({
id: "agent-1",
name: "mosaic-main",
displayName: "Main Orchestrator",
role: "orchestrator",
gatewayUrl: "http://mosaic-main:18789",
agentId: "main",
model: "zai/glm-5",
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
mockHttpService.axiosRef.post.mockRejectedValue({
message: "Request failed with status code 401",
response: { status: 401 },
});
await expect(
(async () => {
for await (const _chunk of service.streamChat("mosaic-main", [])) {
// no-op
}
})()
).rejects.toBeInstanceOf(UnauthorizedException);
});
it("throws ServiceUnavailableException when gateway is offline", async () => {
mockPrisma.openClawAgent.findUnique.mockResolvedValue({
id: "agent-1",
name: "mosaic-main",
displayName: "Main Orchestrator",
role: "orchestrator",
gatewayUrl: "http://mosaic-main:18789",
agentId: "main",
model: "zai/glm-5",
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
mockHttpService.axiosRef.post.mockRejectedValue({
message: "connect ECONNREFUSED 127.0.0.1:18789",
code: "ECONNREFUSED",
});
await expect(
(async () => {
for await (const _chunk of service.streamChat("mosaic-main", [])) {
// no-op
}
})()
).rejects.toBeInstanceOf(ServiceUnavailableException);
});
});

View File

@@ -0,0 +1,273 @@
import { HttpService } from "@nestjs/axios";
import {
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
UnauthorizedException,
} from "@nestjs/common";
import type { OpenClawAgent } from "@prisma/client";
import type { Readable } from "node:stream";
import { PrismaService } from "../prisma/prisma.service";
import type { ChatMessage } from "./openclaw-gateway.dto";
interface OpenAiSseChoiceDelta {
content?: string;
}
interface OpenAiSseChoice {
delta?: OpenAiSseChoiceDelta;
}
interface OpenAiSseError {
message?: string;
}
interface OpenAiSsePayload {
choices?: OpenAiSseChoice[];
error?: OpenAiSseError;
}
type ParsedSseEvent = { done: true } | { done: false; content: string } | null;
interface GatewayErrorLike {
message?: string;
code?: string;
response?: {
status?: number;
};
}
@Injectable()
export class OpenClawGatewayService {
private readonly logger = new Logger(OpenClawGatewayService.name);
constructor(
private readonly prisma: PrismaService,
private readonly httpService: HttpService
) {}
async *streamChat(
agentName: string,
messages: ChatMessage[],
workspaceId?: string
): AsyncGenerator<string> {
const agent = await this.prisma.openClawAgent.findUnique({
where: { name: agentName },
});
if (!agent) {
throw new NotFoundException(`OpenClaw agent '${agentName}' not found`);
}
if (!agent.isActive) {
throw new ServiceUnavailableException(`OpenClaw agent '${agentName}' is inactive`);
}
const token = this.resolveGatewayToken(agent.name);
const endpoint = this.buildChatEndpoint(agent.gatewayUrl);
try {
const response = await this.httpService.axiosRef.post<Readable>(
endpoint,
{
model: `openclaw:${agent.agentId}`,
messages,
stream: true,
},
{
responseType: "stream",
timeout: 120000,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
for await (const chunk of this.extractContentChunks(response.data)) {
yield chunk;
}
} catch (error: unknown) {
this.throwGatewayError(agent, endpoint, workspaceId, error);
}
}
private resolveGatewayToken(agentName: string): string {
const envKey = this.getTokenEnvKey(agentName);
const token = process.env[envKey];
if (!token) {
throw new ServiceUnavailableException(
`Missing gateway token for agent '${agentName}'. Set ${envKey}.`
);
}
return token;
}
private getTokenEnvKey(agentName: string): string {
return `OPENCLAW_TOKEN_${agentName.replace(/-/g, "_").toUpperCase()}`;
}
private buildChatEndpoint(gatewayUrl: string): string {
const sanitizedBaseUrl = gatewayUrl.replace(/\/+$/, "");
return `${sanitizedBaseUrl}/v1/chat/completions`;
}
private async *extractContentChunks(stream: Readable): AsyncGenerator<string> {
let buffer = "";
for await (const rawChunk of stream) {
buffer += this.chunkToString(rawChunk);
for (;;) {
const delimiterMatch = /\r?\n\r?\n/.exec(buffer);
const delimiterIndex = delimiterMatch?.index;
if (delimiterMatch === null || delimiterIndex === undefined) {
break;
}
const rawEvent = buffer.slice(0, delimiterIndex);
buffer = buffer.slice(delimiterIndex + delimiterMatch[0].length);
const parsed = this.parseSseEvent(rawEvent);
if (parsed === null) {
continue;
}
if (parsed.done) {
return;
}
yield parsed.content;
}
}
const trailingEvent = this.parseSseEvent(buffer);
if (trailingEvent !== null && !trailingEvent.done) {
yield trailingEvent.content;
}
}
private parseSseEvent(rawEvent: string): ParsedSseEvent {
const payload = this.extractSseDataPayload(rawEvent);
if (!payload) {
return null;
}
if (payload === "[DONE]") {
return { done: true };
}
let parsedPayload: OpenAiSsePayload;
try {
parsedPayload = JSON.parse(payload) as OpenAiSsePayload;
} catch {
this.logger.debug(`Skipping non-JSON OpenClaw SSE payload: ${payload}`);
return null;
}
if (parsedPayload.error?.message) {
throw new ServiceUnavailableException(
`OpenClaw gateway error: ${parsedPayload.error.message}`
);
}
const content = parsedPayload.choices?.[0]?.delta?.content;
if (typeof content === "string" && content.length > 0) {
return { done: false, content };
}
return null;
}
private extractSseDataPayload(rawEvent: string): string | null {
if (rawEvent.trim().length === 0) {
return null;
}
const dataLines = rawEvent
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart());
if (dataLines.length === 0) {
return null;
}
return dataLines.join("\n").trim();
}
private chunkToString(chunk: unknown): string {
if (typeof chunk === "string") {
return chunk;
}
if (Buffer.isBuffer(chunk)) {
return chunk.toString("utf8");
}
return String(chunk);
}
private throwGatewayError(
agent: OpenClawAgent,
endpoint: string,
workspaceId: string | undefined,
error: unknown
): never {
if (error instanceof NotFoundException) {
throw error;
}
if (error instanceof UnauthorizedException) {
throw error;
}
if (error instanceof ServiceUnavailableException) {
throw error;
}
const gatewayError = error as GatewayErrorLike;
const statusCode = gatewayError.response?.status;
const errorCode = gatewayError.code;
const message = gatewayError.message ?? String(error);
const workspaceSuffix = workspaceId ? ` (workspace ${workspaceId})` : "";
if (statusCode === 401 || statusCode === 403) {
this.logger.error(
`OpenClaw auth failed for agent '${agent.name}' at ${endpoint}${workspaceSuffix}: ${message}`
);
throw new UnauthorizedException(`OpenClaw authentication failed for agent '${agent.name}'`);
}
const isGatewayOfflineCode =
errorCode === "ECONNREFUSED" ||
errorCode === "ENOTFOUND" ||
errorCode === "ETIMEDOUT" ||
errorCode === "ECONNRESET";
const isGatewayOfflineStatus =
statusCode === 502 || statusCode === 503 || statusCode === 504 || statusCode === 522;
if (isGatewayOfflineCode || isGatewayOfflineStatus) {
this.logger.warn(
`OpenClaw gateway offline for agent '${agent.name}' at ${endpoint}${workspaceSuffix}: ${message}`
);
throw new ServiceUnavailableException(
`OpenClaw gateway for agent '${agent.name}' is unavailable`
);
}
this.logger.error(
`OpenClaw request failed for agent '${agent.name}' at ${endpoint}${workspaceSuffix}: ${message}`
);
throw new ServiceUnavailableException(
`OpenClaw request failed for agent '${agent.name}': ${message}`
);
}
}

View File

@@ -1,19 +1,17 @@
import type { ReactElement, ReactNode } from "react";
import { WorkspaceMemberRole } from "@mosaic/shared";
import { render, screen, waitFor, within } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
type AdminUser,
deactivateUser,
fetchAdminUsers,
inviteUser,
updateUser,
type AdminUsersResponse,
} from "@/lib/api/admin";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces";
import UsersSettingsPage from "./page";
@@ -41,80 +39,48 @@ vi.mock("@/lib/api/workspaces", () => ({
updateWorkspaceMemberRole: vi.fn(),
}));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: vi.fn(),
}));
const fetchAdminUsersMock = vi.mocked(fetchAdminUsers);
const inviteUserMock = vi.mocked(inviteUser);
const updateUserMock = vi.mocked(updateUser);
const deactivateUserMock = vi.mocked(deactivateUser);
const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces);
const updateWorkspaceMemberRoleMock = vi.mocked(updateWorkspaceMemberRole);
const useAuthMock = vi.mocked(useAuth);
function makeAdminUser(overrides?: Partial<AdminUser>): AdminUser {
return {
id: "user-1",
name: "Alice",
email: "alice@example.com",
emailVerified: true,
image: null,
createdAt: "2026-01-01T00:00:00.000Z",
deactivatedAt: null,
isLocalAuth: false,
invitedAt: null,
invitedBy: null,
workspaceMemberships: [
{
workspaceId: "workspace-1",
workspaceName: "Personal Workspace",
role: WorkspaceMemberRole.ADMIN,
joinedAt: "2026-01-01T00:00:00.000Z",
},
],
...overrides,
};
}
function makeAdminUsersResponse(options?: {
data?: AdminUser[];
page?: number;
totalPages?: number;
total?: number;
limit?: number;
}): AdminUsersResponse {
const data = options?.data ?? [makeAdminUser()];
return {
data,
meta: {
total: options?.total ?? data.length,
page: options?.page ?? 1,
limit: options?.limit ?? 50,
totalPages: options?.totalPages ?? 1,
const adminUsersResponse: AdminUsersResponse = {
data: [
{
id: "user-1",
name: "Alice",
email: "alice@example.com",
emailVerified: true,
image: null,
createdAt: "2026-01-01T00:00:00.000Z",
deactivatedAt: null,
isLocalAuth: false,
invitedAt: null,
invitedBy: null,
workspaceMemberships: [
{
workspaceId: "workspace-1",
workspaceName: "Personal Workspace",
role: WorkspaceMemberRole.ADMIN,
joinedAt: "2026-01-01T00:00:00.000Z",
},
],
},
};
}
function makeAuthState(userId: string): ReturnType<typeof useAuth> {
return {
user: { id: userId, email: `${userId}@example.com`, name: "Current User" },
isLoading: false,
isAuthenticated: true,
authError: null,
sessionExpiring: false,
sessionMinutesRemaining: 0,
signOut: vi.fn(() => Promise.resolve()),
refreshSession: vi.fn(() => Promise.resolve()),
};
}
],
meta: {
total: 1,
page: 1,
limit: 50,
totalPages: 1,
},
};
describe("UsersSettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
const adminUsersResponse = makeAdminUsersResponse();
fetchAdminUsersMock.mockResolvedValue(adminUsersResponse);
fetchUserWorkspacesMock.mockResolvedValue([
{
@@ -131,7 +97,10 @@ describe("UsersSettingsPage", () => {
email: "new@example.com",
invitedAt: "2026-01-02T00:00:00.000Z",
});
const firstUser = adminUsersResponse.data[0] ?? makeAdminUser();
const firstUser = adminUsersResponse.data[0];
if (!firstUser) {
throw new Error("Expected at least one admin user in test fixtures");
}
updateUserMock.mockResolvedValue(firstUser);
deactivateUserMock.mockResolvedValue(firstUser);
@@ -147,8 +116,6 @@ describe("UsersSettingsPage", () => {
image: null,
},
});
useAuthMock.mockReturnValue(makeAuthState("user-current"));
});
it("shows access denied to non-admin users", async () => {
@@ -207,146 +174,4 @@ describe("UsersSettingsPage", () => {
expect(updateWorkspaceMemberRoleMock).not.toHaveBeenCalled();
});
it("caps pagination to the last valid page after deactivation shrinks the dataset", async () => {
const user = userEvent.setup();
const pageOneUser = makeAdminUser({
id: "user-1",
name: "Alice",
email: "alice@example.com",
});
const pageTwoUser = makeAdminUser({
id: "user-2",
name: "Bob",
email: "bob@example.com",
});
fetchAdminUsersMock.mockReset();
const responses = [
{
expectedPage: 1,
response: makeAdminUsersResponse({
data: [pageOneUser],
page: 1,
totalPages: 2,
total: 2,
}),
},
{
expectedPage: 2,
response: makeAdminUsersResponse({
data: [pageTwoUser],
page: 2,
totalPages: 2,
total: 2,
}),
},
{
expectedPage: 2,
response: makeAdminUsersResponse({
data: [],
page: 2,
totalPages: 1,
total: 1,
}),
},
{
expectedPage: 1,
response: makeAdminUsersResponse({
data: [pageOneUser],
page: 1,
totalPages: 1,
total: 1,
}),
},
];
fetchAdminUsersMock.mockImplementation((page = 1) => {
const next = responses.shift();
if (!next) {
throw new Error("Unexpected fetchAdminUsers call in pagination-cap test");
}
expect(page).toBe(next.expectedPage);
return Promise.resolve(next.response);
});
render(<UsersSettingsPage />);
expect(await screen.findByText("alice@example.com")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Next" }));
expect(await screen.findByText("bob@example.com")).toBeInTheDocument();
const pageTwoRow = screen.getByText("bob@example.com").closest('[role="button"]');
if (!(pageTwoRow instanceof HTMLElement)) {
throw new Error("Expected Bob's row to exist");
}
await user.click(within(pageTwoRow).getByRole("button", { name: "Deactivate" }));
const deactivateButtons = await screen.findAllByRole("button", { name: "Deactivate" });
const confirmDeactivateButton = deactivateButtons[deactivateButtons.length - 1];
if (!confirmDeactivateButton) {
throw new Error("Expected confirmation deactivate button to be rendered");
}
await user.click(confirmDeactivateButton);
expect(await screen.findByText("alice@example.com")).toBeInTheDocument();
expect(screen.queryByText("No Users Yet")).not.toBeInTheDocument();
expect(deactivateUserMock).toHaveBeenCalledWith("user-2");
const requestedPages = fetchAdminUsersMock.mock.calls.map(([requestedPage]) => requestedPage);
expect(requestedPages.slice(-2)).toEqual([2, 1]);
});
it("shows the API error state without rendering the empty-state message", async () => {
fetchAdminUsersMock.mockRejectedValueOnce(new Error("Unable to load users"));
render(<UsersSettingsPage />);
expect(await screen.findByText("Unable to load users")).toBeInTheDocument();
expect(screen.queryByText("No Users Yet")).not.toBeInTheDocument();
expect(screen.queryByText("Invite the first user to get started.")).not.toBeInTheDocument();
});
it("prevents the current user from deactivating their own account", async () => {
useAuthMock.mockReturnValue(makeAuthState("user-1"));
const selfUser = makeAdminUser({
id: "user-1",
name: "Alice",
email: "alice@example.com",
});
const otherUser = makeAdminUser({
id: "user-2",
name: "Bob",
email: "bob@example.com",
});
fetchAdminUsersMock.mockResolvedValueOnce(
makeAdminUsersResponse({
data: [selfUser, otherUser],
page: 1,
totalPages: 1,
total: 2,
})
);
render(<UsersSettingsPage />);
expect(await screen.findByText("alice@example.com")).toBeInTheDocument();
expect(screen.getByText("bob@example.com")).toBeInTheDocument();
const selfRow = screen.getByText("alice@example.com").closest('[role="button"]');
if (!(selfRow instanceof HTMLElement)) {
throw new Error("Expected current-user row to exist");
}
expect(within(selfRow).queryByRole("button", { name: "Deactivate" })).not.toBeInTheDocument();
const otherRow = screen.getByText("bob@example.com").closest('[role="button"]');
if (!(otherRow instanceof HTMLElement)) {
throw new Error("Expected other-user row to exist");
}
expect(within(otherRow).getByRole("button", { name: "Deactivate" })).toBeInTheDocument();
expect(deactivateUserMock).not.toHaveBeenCalled();
});
});

View File

@@ -55,7 +55,6 @@ import {
type InviteUserDto,
type UpdateUserDto,
} from "@/lib/api/admin";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces";
import { SettingsAccessDenied } from "@/components/settings/SettingsAccessDenied";
@@ -78,7 +77,6 @@ const INITIAL_DETAIL_FORM = {
workspaceId: null as string | null,
workspaceName: null as string | null,
};
const USERS_PAGE_SIZE = 50;
interface DetailInitialState {
name: string;
@@ -106,11 +104,8 @@ function getPrimaryMembership(user: AdminUser): AdminWorkspaceMembership | null
}
export default function UsersSettingsPage(): ReactElement {
const { user: authUser } = useAuth();
const [users, setUsers] = useState<AdminUser[]>([]);
const [meta, setMeta] = useState<AdminUsersResponse["meta"] | null>(null);
const [page, setPage] = useState<number>(1);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
@@ -132,35 +127,25 @@ export default function UsersSettingsPage(): ReactElement {
const [deactivateTarget, setDeactivateTarget] = useState<AdminUser | null>(null);
const [isDeactivating, setIsDeactivating] = useState<boolean>(false);
const loadUsers = useCallback(
async (showLoadingState: boolean): Promise<void> => {
try {
if (showLoadingState) {
setIsLoading(true);
} else {
setIsRefreshing(true);
}
const response = await fetchAdminUsers(page, USERS_PAGE_SIZE);
const lastValidPage = Math.max(1, response.meta.totalPages);
if (page > lastValidPage) {
setPage(lastValidPage);
return;
}
setUsers(response.data);
setMeta(response.meta);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load admin users");
} finally {
setIsLoading(false);
setIsRefreshing(false);
const loadUsers = useCallback(async (showLoadingState: boolean): Promise<void> => {
try {
if (showLoadingState) {
setIsLoading(true);
} else {
setIsRefreshing(true);
}
},
[page]
);
const response = await fetchAdminUsers(1, 50);
setUsers(response.data);
setMeta(response.meta);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load admin users");
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, []);
useEffect(() => {
fetchUserWorkspaces()
@@ -185,7 +170,7 @@ export default function UsersSettingsPage(): ReactElement {
}
void loadUsers(true);
}, [isAdmin, loadUsers, page]);
}, [isAdmin, loadUsers]);
function resetInviteForm(): void {
setInviteForm(INITIAL_INVITE_FORM);
@@ -339,12 +324,6 @@ export default function UsersSettingsPage(): ReactElement {
return;
}
if (authUser?.id === deactivateTarget.id) {
setDeactivateTarget(null);
setError("You cannot deactivate your own account.");
return;
}
try {
setIsDeactivating(true);
await deactivateUser(deactivateTarget.id);
@@ -502,13 +481,7 @@ export default function UsersSettingsPage(): ReactElement {
</Link>
</div>
{isLoading ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Loading users...
</CardContent>
</Card>
) : error ? (
{error ? (
<Card>
<CardContent className="py-4">
<p className="text-sm text-destructive" role="alert">
@@ -516,6 +489,14 @@ export default function UsersSettingsPage(): ReactElement {
</p>
</CardContent>
</Card>
) : null}
{isLoading ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Loading users...
</CardContent>
</Card>
) : users.length === 0 ? (
<Card>
<CardHeader>
@@ -533,7 +514,6 @@ export default function UsersSettingsPage(): ReactElement {
{users.map((user) => {
const primaryMembership = getPrimaryMembership(user);
const isActive = user.deactivatedAt === null;
const isCurrentUser = authUser?.id === user.id;
return (
<div
@@ -549,14 +529,7 @@ export default function UsersSettingsPage(): ReactElement {
}}
>
<div className="space-y-1 min-w-0">
<p className="font-semibold truncate">
{user.name || "Unnamed User"}
{isCurrentUser ? (
<span className="ml-2 text-xs font-normal text-muted-foreground">
(You)
</span>
) : null}
</p>
<p className="font-semibold truncate">{user.name || "Unnamed User"}</p>
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
</div>
@@ -567,7 +540,7 @@ export default function UsersSettingsPage(): ReactElement {
<Badge variant={isActive ? "secondary" : "destructive"}>
{isActive ? "Active" : "Inactive"}
</Badge>
{isActive && !isCurrentUser ? (
{isActive ? (
<Button
variant="destructive"
size="sm"
@@ -584,36 +557,6 @@ export default function UsersSettingsPage(): ReactElement {
</div>
);
})}
{meta && meta.totalPages > 1 ? (
<div className="flex items-center justify-between pt-3 mt-1 border-t">
<p className="text-sm text-muted-foreground">
Page {page} of {meta.totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 1}
onClick={() => {
setPage((previousPage) => Math.max(1, previousPage - 1));
}}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= meta.totalPages}
onClick={() => {
setPage((previousPage) => Math.min(meta.totalPages, previousPage + 1));
}}
>
Next
</Button>
</div>
</div>
) : null}
</CardContent>
</Card>
)}

View File

@@ -25,12 +25,12 @@
| MS21-MIG-003 | not-started | phase-3 | Run migration on production database | #568 | api | — | MS21-MIG-001,MS21-TEST-003 | MS21-VER-001 | — | — | — | 5K | — | Needs deploy coordination; not automatable |
| MS21-MIG-004 | done | phase-3 | Import API endpoints (6/6 tests) | #568 | api | feat/ms21-import-api | MS21-DB-001 | — | codex | 2026-02-28 | 2026-02-28 | 20K | 24K | PR #567 merged, CI green. Review: 0 blockers, 4 should-fix, 1 medium sec (no audit log). |
| MS21-UI-001 | done | phase-4 | Settings/users page | #569 | web | feat/ms21-ui-users | MS21-API-001,MS21-API-002 | — | codex | 2026-02-28 | 2026-02-28 | 20K | ~30K | PR #573 merged. Review: 0 blockers, 4 should-fix → MS21-UI-001-QA |
| MS21-UI-001-QA | done | phase-4 | QA: fix 4 review findings (pagination, error state, self-deactivate guard, tests) | #569 | web | fix/ms21-ui-001-qa | MS21-UI-001 | — | — | — | — | 15K | — | 0 blockers; merged per framework. Should-fix: pagination cap, error/empty collision, self-deactivate guard, no tests. |
| MS21-UI-001-QA | in-progress | phase-4 | QA: fix 4 review findings (pagination, error state, self-deactivate guard, tests) | #569 | web | fix/ms21-ui-001-qa | MS21-UI-001 | — | — | — | — | 15K | — | 0 blockers; merged per framework. Should-fix: pagination cap, error/empty collision, self-deactivate guard, no tests. |
| MS21-UI-002 | done | phase-4 | User detail/edit and invite dialogs | #569 | web | feat/ms21-ui-users | MS21-UI-001 | — | — | — | — | 15K | — | |
| MS21-UI-003 | done | phase-4 | Settings/workspaces page (wire to real API) | #569 | web | feat/ms21-ui-workspaces | MS21-API-003 | — | codex | 2026-02-28 | 2026-02-28 | 15K | ~25K | PR #574 merged. Review: 0 critical, 1 low (raw errors in UI) |
| MS21-UI-004 | done | phase-4 | Workspace member management UI | #569 | web | feat/ms21-ui-workspaces | MS21-UI-003,MS21-API-003 | — | — | — | — | 15K | — | Components exist |
| MS21-UI-005 | done | phase-4 | Settings/teams page | #569 | web | feat/ms21-ui-teams | MS21-API-004 | — | — | — | — | 15K | — | |
| MS21-TEST-004 | done | phase-4 | Frontend component tests | #569 | web | test/ms21-ui | MS21-UI-001,MS21-UI-002,MS21-UI-003,MS21-UI-004,MS21-UI-005 | — | — | — | — | 20K | — | |
| MS21-TEST-004 | in-progress | phase-4 | Frontend component tests | #569 | web | test/ms21-ui | MS21-UI-001,MS21-UI-002,MS21-UI-003,MS21-UI-004,MS21-UI-005 | — | — | — | — | 20K | — | |
| MS21-RBAC-001 | done | phase-5 | Sidebar navigation role gating | #570 | web | feat/ms21-rbac | MS21-UI-001 | — | — | — | — | 10K | — | |
| MS21-RBAC-002 | done | phase-5 | Settings page access restriction | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
| MS21-RBAC-003 | done | phase-5 | Action button permission gating | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |

View File

@@ -1,413 +0,0 @@
# MS22 Phase 1: DB-Centric Multi-User Agent Architecture
## Design Principles
1. **2 env vars to bootstrap**`DATABASE_URL` + `MOSAIC_SECRET_KEY`
2. **DB-centric config** — All runtime config in Postgres, managed via WebUI
3. **Mosaic is the gatekeeper** — Users authenticate to Mosaic, never to OpenClaw directly
4. **Per-user agent isolation** — Each user gets their own OpenClaw container(s) with their own credentials
5. **Onboarding-first** — Breakglass user + wizard on first boot
6. **Generic product** — No hardcoded names, models, providers, or endpoints
## Architecture Overview
```
┌─────────────────────────────────────────────────────┐
│ MOSAIC WEBUI │
│ (Auth: breakglass local + OIDC via settings) │
└──────────────────────┬──────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ MOSAIC API │
│ │
│ ┌──────────────┐ ┌────────────────┐ ┌─────────┐ │
│ │ Onboarding │ │ Container │ │ Config │ │
│ │ Wizard │ │ Lifecycle Mgr │ │ Store │ │
│ └──────────────┘ └───────┬────────┘ └─────────┘ │
│ │ │
└────────────────────────────┼────────────────────────┘
│ Docker API
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ OpenClaw │ │ OpenClaw │ │ OpenClaw │
│ User A │ │ User B │ │ System │
│ │ │ │ │ (admin) │
│ Claude Max │ │ Z.ai key │ │ Shared key │
│ own memory │ │ own memory │ │ monitoring │
└─────────────┘ └─────────────┘ └─────────────┘
Scale to zero Scale to zero Always on
after idle after idle
```
## Container Lifecycle
### User containers (on-demand)
1. User logs in → Mosaic checks `UserContainer` table
2. No running container → Mosaic calls Docker API to create one
3. Injects user's encrypted API keys via config endpoint
4. Routes chat requests to user's container
5. Idle timeout (configurable, default 30min) → scale to zero
6. State volume persists (sessions, memory, auth tokens)
7. Next request → container restarts, picks up state from volume
### System containers (always-on, optional)
- Admin-provisioned for system tasks (monitoring, scheduled jobs)
- Use admin-configured shared API keys
- Not tied to any user
## Auth Layers
| Flow | Method |
| ------------------------------- | ---------------------------------------------------------------------- |
| User → Mosaic WebUI | Breakglass (local) or OIDC (configured in settings) |
| Mosaic API → OpenClaw container | Bearer token (auto-generated per container, stored encrypted in DB) |
| OpenClaw → LLM providers | User's own API keys (delivered via config endpoint, decrypted from DB) |
| Admin → System settings | RBAC (admin role required) |
| Internal config endpoint | Bearer token (container authenticates to fetch its config) |
## Database Schema
### System Tables
```prisma
model SystemConfig {
id String @id @default(cuid())
key String @unique // "oidc.issuerUrl", "oidc.clientId", "onboarding.completed"
value String // plaintext or encrypted (prefix: "enc:")
encrypted Boolean @default(false)
updatedAt DateTime @updatedAt
}
model BreakglassUser {
id String @id @default(cuid())
username String @unique
passwordHash String // bcrypt
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
### Provider Tables (per-user)
```prisma
model LlmProvider {
id String @id @default(cuid())
userId String // owner — each user manages their own providers
name String // "my-zai", "work-openai", "local-ollama"
displayName String // "Z.ai", "OpenAI (Work)", "Local Ollama"
type String // "zai" | "openai" | "anthropic" | "ollama" | "custom"
baseUrl String? // null for built-in, URL for custom/ollama
apiKey String? // encrypted
apiType String @default("openai-completions")
models Json @default("[]") // [{id, name, contextWindow, maxTokens}]
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, name])
}
```
### Container Tables
```prisma
model UserContainer {
id String @id @default(cuid())
userId String @unique
containerId String? // Docker container ID (null = not running)
containerName String // "mosaic-user-{userId}"
gatewayPort Int? // assigned port (null = not running)
gatewayToken String // encrypted — auto-generated
status String @default("stopped") // "running" | "stopped" | "starting" | "error"
lastActiveAt DateTime?
idleTimeoutMin Int @default(30)
config Json @default("{}") // cached openclaw.json for this user
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model SystemContainer {
id String @id @default(cuid())
name String @unique // "mosaic-system-ops", "mosaic-system-monitor"
role String // "operations" | "monitor" | "scheduler"
containerId String?
gatewayPort Int?
gatewayToken String // encrypted
status String @default("stopped")
providerId String? // references admin-level LlmProvider
primaryModel String // "zai/glm-5", etc.
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
### User Agent Preferences
```prisma
model UserAgentConfig {
id String @id @default(cuid())
userId String @unique
primaryModel String? // user's preferred model
fallbackModels Json @default("[]")
personality String? // custom SOUL.md content
providerId String? // default provider for this user
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
## Internal Config Endpoint
`GET /api/internal/agent-config/:containerType/:id`
- Auth: Bearer token (container's own gateway token)
- Returns: Complete `openclaw.json` generated from DB
- For user containers: includes user's providers, model prefs, personality
- For system containers: includes admin provider config
Response assembles openclaw.json dynamically:
```json
{
"gateway": { "mode": "local", "port": 18789, "bind": "lan", "auth": { "mode": "token" } ... },
"agents": { "defaults": { "model": { "primary": "<from UserAgentConfig>" } } },
"models": { "providers": { "<from LlmProvider rows>": { ... } } }
}
```
## Container Lifecycle Manager
NestJS service that manages Docker containers:
```typescript
class ContainerLifecycleService {
// Create and start a user's OpenClaw container
async ensureRunning(userId: string): Promise<{ url: string; token: string }>;
// Stop idle containers (called by cron/scheduler)
async reapIdle(): Promise<number>;
// Stop a specific user's container
async stop(userId: string): Promise<void>;
// Health check all running containers
async healthCheckAll(): Promise<HealthStatus[]>;
// Restart container with updated config
async restart(userId: string): Promise<void>;
}
```
Uses Docker Engine API (`/var/run/docker.sock` or TCP) via `dockerode` npm package.
## Onboarding Wizard
### First-Boot Detection
- API checks: `SystemConfig.get("onboarding.completed")` → null = first boot
- WebUI redirects to `/onboarding` if not completed
### Steps
**Step 1: Create Breakglass Admin**
- Username + password → bcrypt → `BreakglassUser` table
- This user always works, even if OIDC is misconfigured
**Step 2: Configure Authentication (optional)**
- OIDC: provider URL, client ID, client secret → encrypted in `SystemConfig`
- Skip = breakglass-only auth (can add OIDC later in settings)
**Step 3: Add Your First LLM Provider**
- Pick type → enter API key/endpoint → test connection → save to `LlmProvider`
- This becomes the admin's default provider
**Step 4: System Agents (optional)**
- Configure always-on system agents for monitoring/ops
- Or skip — users can just use their own personal agents
**Step 5: Complete**
- Sets `SystemConfig("onboarding.completed") = true`
- Redirects to dashboard
### Post-Onboarding: User Self-Service
- Each user adds their own LLM providers in profile settings
- Each user configures their preferred model, personality
- First chat request triggers container creation
## Docker Compose (final)
```yaml
services:
mosaic-api:
image: mosaic/api:latest
environment:
DATABASE_URL: ${DATABASE_URL}
MOSAIC_SECRET_KEY: ${MOSAIC_SECRET_KEY}
volumes:
- /var/run/docker.sock:/var/run/docker.sock # Docker API access
networks:
- internal
mosaic-web:
image: mosaic/web:latest
environment:
NEXT_PUBLIC_API_URL: http://mosaic-api:4000
networks:
- internal
postgres:
image: postgres:17
environment:
POSTGRES_DB: mosaic
POSTGRES_USER: mosaic
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- internal
# System agent (optional, admin-provisioned)
# mosaic-system:
# image: alpine/openclaw:latest
# ... (managed by ContainerLifecycleService)
# User containers are NOT in this file —
# they are dynamically created by ContainerLifecycleService
# via the Docker API at runtime.
networks:
internal:
driver: overlay
volumes:
postgres-data:
```
Note: User OpenClaw containers are **not** defined in docker-compose. They are
created dynamically by the `ContainerLifecycleService` when users start chatting.
## Entrypoint (for dynamically created containers)
```sh
#!/bin/sh
set -e
: "${MOSAIC_API_URL:?required}"
: "${AGENT_TOKEN:?required}"
: "${AGENT_ID:?required}"
# Fetch config from Mosaic API
curl -sf "${MOSAIC_API_URL}/api/internal/agent-config/${AGENT_ID}" \
-H "Authorization: Bearer ${AGENT_TOKEN}" \
-o /tmp/openclaw.json
export OPENCLAW_CONFIG_PATH=/tmp/openclaw.json
exec openclaw gateway run --bind lan --auth token
```
Container env vars (injected by ContainerLifecycleService):
- `MOSAIC_API_URL` — internal API URL
- `AGENT_TOKEN` — this container's bearer token (from DB)
- `AGENT_ID` — container ID for config lookup
## Config Update Strategy
When a user changes settings (model, provider, personality):
1. Mosaic API updates DB
2. API calls `ContainerLifecycleService.restart(userId)`
3. Container restarts, fetches fresh config from API
4. OpenClaw gateway starts with new config
5. State volume preserves sessions/memory across restarts
## Task Breakdown
| Task | Phase | Scope | Dependencies |
| -------- | -------------- | --------------------------------------------------------------------------------------------------------------------- | ------------ |
| MS22-P1a | Schema | Prisma models: SystemConfig, BreakglassUser, LlmProvider, UserContainer, SystemContainer, UserAgentConfig. Migration. | — |
| MS22-P1b | Crypto | Encryption service for API keys/tokens (AES-256-GCM using MOSAIC_SECRET_KEY) | P1a |
| MS22-P1c | Config API | Internal config endpoint: assembles openclaw.json from DB | P1a, P1b |
| MS22-P1d | Container Mgr | ContainerLifecycleService: Docker API integration (dockerode), start/stop/health/reap | P1a |
| MS22-P1e | Onboarding API | Onboarding endpoints: breakglass, OIDC, provider, complete | P1a, P1b |
| MS22-P1f | Onboarding UI | Multi-step wizard in WebUI | P1e |
| MS22-P1g | Settings API | CRUD: providers, agent config, OIDC, breakglass | P1a, P1b |
| MS22-P1h | Settings UI | Settings pages: Providers, Agent Config, Auth | P1g |
| MS22-P1i | Chat Proxy | Route WebUI chat → user's OpenClaw container (SSE) | P1c, P1d |
| MS22-P1j | Docker | Entrypoint script, health checks, compose for core services | P1c |
| MS22-P1k | Idle Reaper | Cron service to stop idle user containers | P1d |
## Open Questions (Resolved)
1. ~~Config updates → restart?~~ **Yes.** Mosaic restarts the container, fresh config on boot.
2. ~~CLI alternative for breakglass?~~ **Yes.** Both WebUI wizard and CLI (`mosaic admin create-breakglass`).
3. ~~Config cache TTL?~~ **Yes.** Config fetched once at startup, changes trigger restart.
## Security Isolation Model
### Core Principle: ZERO cross-user access
Every user is fully sandboxed. No exceptions.
### Container Isolation
- Each user gets their **own** OpenClaw container (separate process, PID namespace)
- Each container has its **own** Docker volume (sessions, memory, workspace)
- Containers run on an **internal-only** Docker network — no external exposure
- Users NEVER talk to OpenClaw directly — Mosaic proxies all requests
- Container gateway tokens are unique per-user and single-purpose
### Data Isolation (enforced at API + DB level)
| Data | Isolation | Enforcement |
| ---------------- | ------------------------- | --------------------------------------------------------------------------------- |
| LLM API keys | Per-user, encrypted | `LlmProvider.userId` — all queries scoped by authenticated user |
| Chat history | Per-user container volume | Separate Docker volume per user, not shared |
| Agent memory | Per-user container volume | Separate Docker volume per user |
| Agent config | Per-user | `UserAgentConfig.userId` — scoped queries |
| Container access | Per-user | `UserContainer.userId` — Mosaic validates user owns the container before proxying |
### API Enforcement
- **All user-facing endpoints** include `WHERE userId = authenticatedUser.id`
- **No admin endpoint** exposes another user's API keys (even to admins)
- **Chat proxy** validates: authenticated user → owns target container → forwards request
- **Config endpoint** validates: container token matches the container requesting config
- **Provider CRUD** is fully user-scoped — User A cannot list, read, or modify User B's providers
### What admins CAN see
- Container status (running/stopped) — not contents
- User list and roles
- System-level config (OIDC, system agents)
- Aggregate usage metrics (not individual conversations)
### What admins CANNOT see
- Other users' API keys (encrypted, no decrypt endpoint)
- Other users' chat history (in container volumes, not in Mosaic DB)
- Other users' agent memory/workspace contents
### Future: Team Workspaces (NOT in scope)
Team/shared workspaces are a potential future feature where users opt-in to
shared agent contexts. This requires explicit consent, shared-key management,
and a different isolation model. **Not designed here. Not built now.**
### Attack Surface Notes
- Docker socket access (`/var/run/docker.sock`) is required by Mosaic API for container management. This is a privileged operation — the Mosaic API container must be trusted.
- `MOSAIC_SECRET_KEY` is the root of trust for encryption. Rotation requires re-encrypting all secrets in DB.
- Container-to-container communication is blocked by default (no shared network between user containers unless explicitly configured).