Compare commits
2 Commits
chore/ms22
...
feat/ms22-
| Author | SHA1 | Date | |
|---|---|---|---|
| b13ff68e22 | |||
| c847b74bda |
@@ -80,8 +80,8 @@
|
|||||||
"session_id": "sess-002",
|
"session_id": "sess-002",
|
||||||
"runtime": "unknown",
|
"runtime": "unknown",
|
||||||
"started_at": "2026-02-28T20:30:13Z",
|
"started_at": "2026-02-28T20:30:13Z",
|
||||||
"ended_at": "",
|
"ended_at": "2026-03-01T14:04:00Z",
|
||||||
"ended_reason": "",
|
"ended_reason": "completed",
|
||||||
"milestone_at_end": "",
|
"milestone_at_end": "",
|
||||||
"tasks_completed": [],
|
"tasks_completed": [],
|
||||||
"last_task_id": ""
|
"last_task_id": ""
|
||||||
|
|||||||
@@ -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": ""
|
|
||||||
}
|
|
||||||
@@ -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");
|
||||||
@@ -1407,6 +1407,19 @@ model Instance {
|
|||||||
@@map("instances")
|
@@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 {
|
model FederationConnection {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
workspaceId String @map("workspace_id") @db.Uuid
|
workspaceId String @map("workspace_id") @db.Uuid
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { AdminModule } from "./admin/admin.module";
|
|||||||
import { TeamsModule } from "./teams/teams.module";
|
import { TeamsModule } from "./teams/teams.module";
|
||||||
import { ImportModule } from "./import/import.module";
|
import { ImportModule } from "./import/import.module";
|
||||||
import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.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";
|
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -121,6 +122,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
TeamsModule,
|
TeamsModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
ConversationArchiveModule,
|
ConversationArchiveModule,
|
||||||
|
OpenClawGatewayModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, CsrfController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
36
apps/api/src/openclaw-gateway/agent-registry.controller.ts
Normal file
36
apps/api/src/openclaw-gateway/agent-registry.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
apps/api/src/openclaw-gateway/agent-registry.service.ts
Normal file
65
apps/api/src/openclaw-gateway/agent-registry.service.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/api/src/openclaw-gateway/openclaw-gateway.controller.ts
Normal file
40
apps/api/src/openclaw-gateway/openclaw-gateway.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
apps/api/src/openclaw-gateway/openclaw-gateway.dto.ts
Normal file
115
apps/api/src/openclaw-gateway/openclaw-gateway.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
21
apps/api/src/openclaw-gateway/openclaw-gateway.module.ts
Normal file
21
apps/api/src/openclaw-gateway/openclaw-gateway.module.ts
Normal 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 {}
|
||||||
212
apps/api/src/openclaw-gateway/openclaw-gateway.service.spec.ts
Normal file
212
apps/api/src/openclaw-gateway/openclaw-gateway.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
273
apps/api/src/openclaw-gateway/openclaw-gateway.service.ts
Normal file
273
apps/api/src/openclaw-gateway/openclaw-gateway.service.ts
Normal 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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
|
|
||||||
import { WorkspaceMemberRole } from "@mosaic/shared";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type AdminUser,
|
|
||||||
deactivateUser,
|
deactivateUser,
|
||||||
fetchAdminUsers,
|
fetchAdminUsers,
|
||||||
inviteUser,
|
inviteUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
type AdminUsersResponse,
|
type AdminUsersResponse,
|
||||||
} from "@/lib/api/admin";
|
} from "@/lib/api/admin";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
|
||||||
import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces";
|
import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces";
|
||||||
import UsersSettingsPage from "./page";
|
import UsersSettingsPage from "./page";
|
||||||
|
|
||||||
@@ -41,80 +39,48 @@ vi.mock("@/lib/api/workspaces", () => ({
|
|||||||
updateWorkspaceMemberRole: vi.fn(),
|
updateWorkspaceMemberRole: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/auth/auth-context", () => ({
|
|
||||||
useAuth: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const fetchAdminUsersMock = vi.mocked(fetchAdminUsers);
|
const fetchAdminUsersMock = vi.mocked(fetchAdminUsers);
|
||||||
const inviteUserMock = vi.mocked(inviteUser);
|
const inviteUserMock = vi.mocked(inviteUser);
|
||||||
const updateUserMock = vi.mocked(updateUser);
|
const updateUserMock = vi.mocked(updateUser);
|
||||||
const deactivateUserMock = vi.mocked(deactivateUser);
|
const deactivateUserMock = vi.mocked(deactivateUser);
|
||||||
const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces);
|
const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces);
|
||||||
const updateWorkspaceMemberRoleMock = vi.mocked(updateWorkspaceMemberRole);
|
const updateWorkspaceMemberRoleMock = vi.mocked(updateWorkspaceMemberRole);
|
||||||
const useAuthMock = vi.mocked(useAuth);
|
|
||||||
|
|
||||||
function makeAdminUser(overrides?: Partial<AdminUser>): AdminUser {
|
const adminUsersResponse: AdminUsersResponse = {
|
||||||
return {
|
data: [
|
||||||
id: "user-1",
|
{
|
||||||
name: "Alice",
|
id: "user-1",
|
||||||
email: "alice@example.com",
|
name: "Alice",
|
||||||
emailVerified: true,
|
email: "alice@example.com",
|
||||||
image: null,
|
emailVerified: true,
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
image: null,
|
||||||
deactivatedAt: null,
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
isLocalAuth: false,
|
deactivatedAt: null,
|
||||||
invitedAt: null,
|
isLocalAuth: false,
|
||||||
invitedBy: null,
|
invitedAt: null,
|
||||||
workspaceMemberships: [
|
invitedBy: null,
|
||||||
{
|
workspaceMemberships: [
|
||||||
workspaceId: "workspace-1",
|
{
|
||||||
workspaceName: "Personal Workspace",
|
workspaceId: "workspace-1",
|
||||||
role: WorkspaceMemberRole.ADMIN,
|
workspaceName: "Personal Workspace",
|
||||||
joinedAt: "2026-01-01T00:00:00.000Z",
|
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,
|
|
||||||
},
|
},
|
||||||
};
|
],
|
||||||
}
|
meta: {
|
||||||
|
total: 1,
|
||||||
function makeAuthState(userId: string): ReturnType<typeof useAuth> {
|
page: 1,
|
||||||
return {
|
limit: 50,
|
||||||
user: { id: userId, email: `${userId}@example.com`, name: "Current User" },
|
totalPages: 1,
|
||||||
isLoading: false,
|
},
|
||||||
isAuthenticated: true,
|
};
|
||||||
authError: null,
|
|
||||||
sessionExpiring: false,
|
|
||||||
sessionMinutesRemaining: 0,
|
|
||||||
signOut: vi.fn(() => Promise.resolve()),
|
|
||||||
refreshSession: vi.fn(() => Promise.resolve()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("UsersSettingsPage", () => {
|
describe("UsersSettingsPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
const adminUsersResponse = makeAdminUsersResponse();
|
|
||||||
|
|
||||||
fetchAdminUsersMock.mockResolvedValue(adminUsersResponse);
|
fetchAdminUsersMock.mockResolvedValue(adminUsersResponse);
|
||||||
fetchUserWorkspacesMock.mockResolvedValue([
|
fetchUserWorkspacesMock.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -131,7 +97,10 @@ describe("UsersSettingsPage", () => {
|
|||||||
email: "new@example.com",
|
email: "new@example.com",
|
||||||
invitedAt: "2026-01-02T00:00:00.000Z",
|
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);
|
updateUserMock.mockResolvedValue(firstUser);
|
||||||
deactivateUserMock.mockResolvedValue(firstUser);
|
deactivateUserMock.mockResolvedValue(firstUser);
|
||||||
@@ -147,8 +116,6 @@ describe("UsersSettingsPage", () => {
|
|||||||
image: null,
|
image: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useAuthMock.mockReturnValue(makeAuthState("user-current"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows access denied to non-admin users", async () => {
|
it("shows access denied to non-admin users", async () => {
|
||||||
@@ -207,146 +174,4 @@ describe("UsersSettingsPage", () => {
|
|||||||
|
|
||||||
expect(updateWorkspaceMemberRoleMock).not.toHaveBeenCalled();
|
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ import {
|
|||||||
type InviteUserDto,
|
type InviteUserDto,
|
||||||
type UpdateUserDto,
|
type UpdateUserDto,
|
||||||
} from "@/lib/api/admin";
|
} from "@/lib/api/admin";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
|
||||||
import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces";
|
import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces";
|
||||||
import { SettingsAccessDenied } from "@/components/settings/SettingsAccessDenied";
|
import { SettingsAccessDenied } from "@/components/settings/SettingsAccessDenied";
|
||||||
|
|
||||||
@@ -78,7 +77,6 @@ const INITIAL_DETAIL_FORM = {
|
|||||||
workspaceId: null as string | null,
|
workspaceId: null as string | null,
|
||||||
workspaceName: null as string | null,
|
workspaceName: null as string | null,
|
||||||
};
|
};
|
||||||
const USERS_PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
interface DetailInitialState {
|
interface DetailInitialState {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -106,11 +104,8 @@ function getPrimaryMembership(user: AdminUser): AdminWorkspaceMembership | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function UsersSettingsPage(): ReactElement {
|
export default function UsersSettingsPage(): ReactElement {
|
||||||
const { user: authUser } = useAuth();
|
|
||||||
|
|
||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
const [meta, setMeta] = useState<AdminUsersResponse["meta"] | null>(null);
|
const [meta, setMeta] = useState<AdminUsersResponse["meta"] | null>(null);
|
||||||
const [page, setPage] = useState<number>(1);
|
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -132,35 +127,25 @@ export default function UsersSettingsPage(): ReactElement {
|
|||||||
const [deactivateTarget, setDeactivateTarget] = useState<AdminUser | null>(null);
|
const [deactivateTarget, setDeactivateTarget] = useState<AdminUser | null>(null);
|
||||||
const [isDeactivating, setIsDeactivating] = useState<boolean>(false);
|
const [isDeactivating, setIsDeactivating] = useState<boolean>(false);
|
||||||
|
|
||||||
const loadUsers = useCallback(
|
const loadUsers = useCallback(async (showLoadingState: boolean): Promise<void> => {
|
||||||
async (showLoadingState: boolean): Promise<void> => {
|
try {
|
||||||
try {
|
if (showLoadingState) {
|
||||||
if (showLoadingState) {
|
setIsLoading(true);
|
||||||
setIsLoading(true);
|
} else {
|
||||||
} else {
|
setIsRefreshing(true);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[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(() => {
|
useEffect(() => {
|
||||||
fetchUserWorkspaces()
|
fetchUserWorkspaces()
|
||||||
@@ -185,7 +170,7 @@ export default function UsersSettingsPage(): ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void loadUsers(true);
|
void loadUsers(true);
|
||||||
}, [isAdmin, loadUsers, page]);
|
}, [isAdmin, loadUsers]);
|
||||||
|
|
||||||
function resetInviteForm(): void {
|
function resetInviteForm(): void {
|
||||||
setInviteForm(INITIAL_INVITE_FORM);
|
setInviteForm(INITIAL_INVITE_FORM);
|
||||||
@@ -339,12 +324,6 @@ export default function UsersSettingsPage(): ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authUser?.id === deactivateTarget.id) {
|
|
||||||
setDeactivateTarget(null);
|
|
||||||
setError("You cannot deactivate your own account.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsDeactivating(true);
|
setIsDeactivating(true);
|
||||||
await deactivateUser(deactivateTarget.id);
|
await deactivateUser(deactivateTarget.id);
|
||||||
@@ -502,13 +481,7 @@ export default function UsersSettingsPage(): ReactElement {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{error ? (
|
||||||
<Card>
|
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
|
||||||
Loading users...
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : error ? (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<p className="text-sm text-destructive" role="alert">
|
<p className="text-sm text-destructive" role="alert">
|
||||||
@@ -516,6 +489,14 @@ export default function UsersSettingsPage(): ReactElement {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
Loading users...
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -533,7 +514,6 @@ export default function UsersSettingsPage(): ReactElement {
|
|||||||
{users.map((user) => {
|
{users.map((user) => {
|
||||||
const primaryMembership = getPrimaryMembership(user);
|
const primaryMembership = getPrimaryMembership(user);
|
||||||
const isActive = user.deactivatedAt === null;
|
const isActive = user.deactivatedAt === null;
|
||||||
const isCurrentUser = authUser?.id === user.id;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -549,14 +529,7 @@ export default function UsersSettingsPage(): ReactElement {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="space-y-1 min-w-0">
|
<div className="space-y-1 min-w-0">
|
||||||
<p className="font-semibold truncate">
|
<p className="font-semibold truncate">{user.name || "Unnamed User"}</p>
|
||||||
{user.name || "Unnamed User"}
|
|
||||||
{isCurrentUser ? (
|
|
||||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
|
||||||
(You)
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
|
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -567,7 +540,7 @@ export default function UsersSettingsPage(): ReactElement {
|
|||||||
<Badge variant={isActive ? "secondary" : "destructive"}>
|
<Badge variant={isActive ? "secondary" : "destructive"}>
|
||||||
{isActive ? "Active" : "Inactive"}
|
{isActive ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{isActive && !isCurrentUser ? (
|
{isActive ? (
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -584,36 +557,6 @@ export default function UsersSettingsPage(): ReactElement {
|
|||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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-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-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 | 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-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-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-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-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-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-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 | — | |
|
| MS21-RBAC-003 | done | phase-5 | Action button permission gating | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
|
||||||
@@ -71,21 +71,3 @@ 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-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-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-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 | not-started | phase-1a | Prisma schema: SystemConfig, BreakglassUser, LlmProvider, UserContainer, SystemContainer, UserAgentConfig + migration | — | api | feat/ms22-p1a-schema | — | P1b,P1c,P1d,P1e | — | — | — | 20K | — | |
|
|
||||||
| MS22-P1b | not-started | 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 | — | |
|
|
||||||
|
|||||||
@@ -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).
|
|
||||||
Reference in New Issue
Block a user