Compare commits
6 Commits
feat/ms22-
...
feat/ms22-
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d7a1be7f5 | |||
| 89767e26ef | |||
| 50f0dc6018 | |||
| 11136e2f23 | |||
| 256171cc62 | |||
| 512a29a240 |
@@ -80,8 +80,8 @@
|
||||
"session_id": "sess-002",
|
||||
"runtime": "unknown",
|
||||
"started_at": "2026-02-28T20:30:13Z",
|
||||
"ended_at": "2026-03-01T14:04:00Z",
|
||||
"ended_reason": "completed",
|
||||
"ended_at": "",
|
||||
"ended_reason": "",
|
||||
"milestone_at_end": "",
|
||||
"tasks_completed": [],
|
||||
"last_task_id": ""
|
||||
|
||||
8
.mosaic/orchestrator/session.lock
Normal file
8
.mosaic/orchestrator/session.lock
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"session_id": "sess-002",
|
||||
"runtime": "unknown",
|
||||
"pid": 3178395,
|
||||
"started_at": "2026-02-28T20:30:13Z",
|
||||
"project_path": "/tmp/ms21-ui-001",
|
||||
"milestone_id": ""
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
-- 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,19 +1407,6 @@ 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
|
||||
|
||||
@@ -49,7 +49,6 @@ 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({
|
||||
@@ -122,7 +121,6 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
||||
TeamsModule,
|
||||
ImportModule,
|
||||
ConversationArchiveModule,
|
||||
OpenClawGatewayModule,
|
||||
],
|
||||
controllers: [AppController, CsrfController],
|
||||
providers: [
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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 {}
|
||||
@@ -1,212 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,273 +0,0 @@
|
||||
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,17 +1,19 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
|
||||
import { WorkspaceMemberRole } from "@mosaic/shared";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } 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";
|
||||
|
||||
@@ -39,48 +41,80 @@ 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);
|
||||
|
||||
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 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,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
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()),
|
||||
};
|
||||
}
|
||||
|
||||
describe("UsersSettingsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const adminUsersResponse = makeAdminUsersResponse();
|
||||
|
||||
fetchAdminUsersMock.mockResolvedValue(adminUsersResponse);
|
||||
fetchUserWorkspacesMock.mockResolvedValue([
|
||||
{
|
||||
@@ -97,10 +131,7 @@ describe("UsersSettingsPage", () => {
|
||||
email: "new@example.com",
|
||||
invitedAt: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
const firstUser = adminUsersResponse.data[0];
|
||||
if (!firstUser) {
|
||||
throw new Error("Expected at least one admin user in test fixtures");
|
||||
}
|
||||
const firstUser = adminUsersResponse.data[0] ?? makeAdminUser();
|
||||
|
||||
updateUserMock.mockResolvedValue(firstUser);
|
||||
deactivateUserMock.mockResolvedValue(firstUser);
|
||||
@@ -116,6 +147,8 @@ describe("UsersSettingsPage", () => {
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
|
||||
useAuthMock.mockReturnValue(makeAuthState("user-current"));
|
||||
});
|
||||
|
||||
it("shows access denied to non-admin users", async () => {
|
||||
@@ -174,4 +207,146 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ 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";
|
||||
|
||||
@@ -77,6 +78,7 @@ const INITIAL_DETAIL_FORM = {
|
||||
workspaceId: null as string | null,
|
||||
workspaceName: null as string | null,
|
||||
};
|
||||
const USERS_PAGE_SIZE = 50;
|
||||
|
||||
interface DetailInitialState {
|
||||
name: string;
|
||||
@@ -104,8 +106,11 @@ 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);
|
||||
@@ -127,25 +132,35 @@ 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 loadUsers = useCallback(
|
||||
async (showLoadingState: boolean): Promise<void> => {
|
||||
try {
|
||||
if (showLoadingState) {
|
||||
setIsLoading(true);
|
||||
} else {
|
||||
setIsRefreshing(true);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
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]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserWorkspaces()
|
||||
@@ -170,7 +185,7 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
}
|
||||
|
||||
void loadUsers(true);
|
||||
}, [isAdmin, loadUsers]);
|
||||
}, [isAdmin, loadUsers, page]);
|
||||
|
||||
function resetInviteForm(): void {
|
||||
setInviteForm(INITIAL_INVITE_FORM);
|
||||
@@ -324,6 +339,12 @@ 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);
|
||||
@@ -481,7 +502,13 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
Loading users...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
@@ -489,14 +516,6 @@ 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>
|
||||
@@ -514,6 +533,7 @@ 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
|
||||
@@ -529,7 +549,14 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="font-semibold truncate">{user.name || "Unnamed User"}</p>
|
||||
<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="text-sm text-muted-foreground truncate">{user.email}</p>
|
||||
</div>
|
||||
|
||||
@@ -540,7 +567,7 @@ export default function UsersSettingsPage(): ReactElement {
|
||||
<Badge variant={isActive ? "secondary" : "destructive"}>
|
||||
{isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
{isActive ? (
|
||||
{isActive && !isCurrentUser ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@@ -557,6 +584,36 @@ 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>
|
||||
)}
|
||||
|
||||
49
docker/OPENCLAW-FLEET.md
Normal file
49
docker/OPENCLAW-FLEET.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Mosaic Agent Fleet
|
||||
|
||||
Multi-agent deployment for Mosaic Stack using OpenClaw containers on Docker Swarm.
|
||||
|
||||
## Architecture
|
||||
|
||||
Each agent runs as an isolated OpenClaw Gateway instance with its own:
|
||||
|
||||
- **Workspace** — persistent volume for agent files and memory
|
||||
- **State** — persistent volume for auth tokens and sessions
|
||||
- **Config** — template rendered at startup from environment variables
|
||||
|
||||
Agents communicate with the Mosaic API via the OpenAI-compatible
|
||||
`/v1/chat/completions` endpoint. The Mosaic WebUI routes chat requests
|
||||
to agents through the `OpenClawGatewayModule`.
|
||||
|
||||
## Default Agent Roles
|
||||
|
||||
| Agent | Role | Description |
|
||||
| ----------------- | ------------ | ------------------------------------------- |
|
||||
| mosaic-main | Orchestrator | User-facing gateway, routes to other agents |
|
||||
| mosaic-projects | Developer | Implementation, coding, PRs |
|
||||
| mosaic-research | Research | Web search, analysis, discovery |
|
||||
| mosaic-operations | Operations | Monitoring, health checks, alerts |
|
||||
|
||||
> **Models and providers are configured per-deployment** via environment
|
||||
> variables and the Mosaic Settings UI — not hardcoded in these files.
|
||||
> See the [Setup Guide](openclaw-instances/README.md) for env var reference.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Swarm initialized on target host
|
||||
- Mosaic Stack running (`mosaic-stack_internal` network available)
|
||||
- At least one LLM provider API key (Z.ai, OpenAI, Anthropic, etc.)
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Configure** — Fill in `docker/openclaw-instances/*.env` files
|
||||
2. **Deploy** — `docker stack deploy -c docker/openclaw-compose.yml mosaic-agents`
|
||||
3. **Auth** — If needed, run `openclaw auth` inside a container (or via Mosaic terminal)
|
||||
4. **Verify** — `docker stack services mosaic-agents`
|
||||
|
||||
See [openclaw-instances/README.md](openclaw-instances/README.md) for detailed setup.
|
||||
|
||||
## Future: Onboarding Wizard
|
||||
|
||||
Model assignments, provider configuration, and agent customization will be
|
||||
managed through the Mosaic WebUI onboarding wizard and Settings pages (MS22-P4).
|
||||
Until then, use environment variables per the README.
|
||||
150
docker/openclaw-compose.yml
Normal file
150
docker/openclaw-compose.yml
Normal file
@@ -0,0 +1,150 @@
|
||||
# Mosaic Agent Fleet — OpenClaw Docker Swarm Stack
|
||||
# Deploy: docker stack deploy -c docker/openclaw-compose.yml mosaic-agents
|
||||
# All config via env vars — see openclaw-instances/*.env
|
||||
|
||||
services:
|
||||
mosaic-main:
|
||||
image: alpine/openclaw:latest
|
||||
command: ["/config/entrypoint.sh"]
|
||||
env_file:
|
||||
- ./openclaw-instances/mosaic-main.env
|
||||
environment:
|
||||
OPENCLAW_CONFIG_PATH: /tmp/openclaw.json
|
||||
volumes:
|
||||
- mosaic-main-config:/config:ro
|
||||
- mosaic-main-state:/home/node/.openclaw
|
||||
networks:
|
||||
- mosaic-stack_internal
|
||||
healthcheck:
|
||||
test: ["CMD", "openclaw", "gateway", "health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
reservations:
|
||||
memory: 512M
|
||||
labels:
|
||||
- com.mosaic.agent=mosaic-main
|
||||
- com.mosaic.role=orchestrator
|
||||
|
||||
mosaic-projects:
|
||||
image: alpine/openclaw:latest
|
||||
command: ["/config/entrypoint.sh"]
|
||||
env_file:
|
||||
- ./openclaw-instances/mosaic-projects.env
|
||||
environment:
|
||||
OPENCLAW_CONFIG_PATH: /tmp/openclaw.json
|
||||
volumes:
|
||||
- mosaic-projects-config:/config:ro
|
||||
- mosaic-projects-state:/home/node/.openclaw
|
||||
networks:
|
||||
- mosaic-stack_internal
|
||||
healthcheck:
|
||||
test: ["CMD", "openclaw", "gateway", "health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 10s
|
||||
max_attempts: 3
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
reservations:
|
||||
memory: 1G
|
||||
labels:
|
||||
- com.mosaic.agent=mosaic-projects
|
||||
- com.mosaic.role=developer
|
||||
|
||||
mosaic-research:
|
||||
image: alpine/openclaw:latest
|
||||
command: ["/config/entrypoint.sh"]
|
||||
env_file:
|
||||
- ./openclaw-instances/mosaic-research.env
|
||||
environment:
|
||||
OPENCLAW_CONFIG_PATH: /tmp/openclaw.json
|
||||
volumes:
|
||||
- mosaic-research-config:/config:ro
|
||||
- mosaic-research-state:/home/node/.openclaw
|
||||
networks:
|
||||
- mosaic-stack_internal
|
||||
healthcheck:
|
||||
test: ["CMD", "openclaw", "gateway", "health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 10s
|
||||
max_attempts: 3
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
reservations:
|
||||
memory: 256M
|
||||
labels:
|
||||
- com.mosaic.agent=mosaic-research
|
||||
- com.mosaic.role=research
|
||||
|
||||
mosaic-operations:
|
||||
image: alpine/openclaw:latest
|
||||
command: ["/config/entrypoint.sh"]
|
||||
env_file:
|
||||
- ./openclaw-instances/mosaic-operations.env
|
||||
environment:
|
||||
OPENCLAW_CONFIG_PATH: /tmp/openclaw.json
|
||||
volumes:
|
||||
- mosaic-operations-config:/config:ro
|
||||
- mosaic-operations-state:/home/node/.openclaw
|
||||
networks:
|
||||
- mosaic-stack_internal
|
||||
healthcheck:
|
||||
test: ["CMD", "openclaw", "gateway", "health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 10s
|
||||
max_attempts: 3
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
reservations:
|
||||
memory: 256M
|
||||
labels:
|
||||
- com.mosaic.agent=mosaic-operations
|
||||
- com.mosaic.role=operations
|
||||
|
||||
networks:
|
||||
mosaic-stack_internal:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
mosaic-main-config:
|
||||
mosaic-main-state:
|
||||
mosaic-projects-config:
|
||||
mosaic-projects-state:
|
||||
mosaic-research-config:
|
||||
mosaic-research-state:
|
||||
mosaic-operations-config:
|
||||
mosaic-operations-state:
|
||||
97
docker/openclaw-instances/README.md
Normal file
97
docker/openclaw-instances/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Mosaic Agent Fleet — Setup Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Swarm initialized on target host
|
||||
- Mosaic Stack running (Postgres, Valkey on `mosaic-stack_internal` network)
|
||||
|
||||
## 1. Configure Environment Variables
|
||||
|
||||
Copy and fill in each agent's `.env` file:
|
||||
|
||||
```bash
|
||||
cd docker/openclaw-instances
|
||||
|
||||
# Required for each agent:
|
||||
# ZAI_API_KEY — Your Z.ai API key (or other LLM provider key)
|
||||
# OPENCLAW_GATEWAY_TOKEN — Unique bearer token per agent
|
||||
|
||||
# Generate unique tokens:
|
||||
for agent in main projects research operations; do
|
||||
echo "OPENCLAW_GATEWAY_TOKEN=$(openssl rand -hex 32)"
|
||||
done
|
||||
```
|
||||
|
||||
### Optional: Local Ollama
|
||||
|
||||
If you have an Ollama instance, add to any agent's `.env`:
|
||||
|
||||
```bash
|
||||
OLLAMA_BASE_URL=http://your-ollama-host:11434
|
||||
OLLAMA_MODEL=cogito # or any model you have pulled
|
||||
```
|
||||
|
||||
The entrypoint script will automatically inject the Ollama provider at startup.
|
||||
|
||||
### Optional: Override Default Model
|
||||
|
||||
```bash
|
||||
OPENCLAW_MODEL=anthropic/claude-sonnet-4-6
|
||||
```
|
||||
|
||||
## 2. Populate Config Volumes
|
||||
|
||||
Each agent needs its `.json.template` file in its config volume:
|
||||
|
||||
```bash
|
||||
# Create config directories and copy templates
|
||||
for agent in main projects research operations; do
|
||||
mkdir -p /var/lib/docker/volumes/mosaic-agents_mosaic-${agent}-config/_data/
|
||||
cp openclaw-instances/mosaic-${agent}.json.template \
|
||||
/var/lib/docker/volumes/mosaic-agents_mosaic-${agent}-config/_data/openclaw.json.template
|
||||
cp openclaw-instances/entrypoint.sh \
|
||||
/var/lib/docker/volumes/mosaic-agents_mosaic-${agent}-config/_data/entrypoint.sh
|
||||
done
|
||||
```
|
||||
|
||||
## 3. Deploy
|
||||
|
||||
```bash
|
||||
docker stack deploy -c docker/openclaw-compose.yml mosaic-agents
|
||||
docker stack services mosaic-agents
|
||||
```
|
||||
|
||||
## 4. First-Time Auth (if needed)
|
||||
|
||||
For providers requiring OAuth (e.g., Anthropic):
|
||||
|
||||
```bash
|
||||
docker exec -it $(docker ps -q -f name=mosaic-main) openclaw auth
|
||||
```
|
||||
|
||||
Follow the device-code flow in your browser. Tokens persist in the state volume.
|
||||
|
||||
You can also use the Mosaic WebUI terminal (xterm.js) for this.
|
||||
|
||||
## 5. Verify
|
||||
|
||||
```bash
|
||||
# Check health
|
||||
curl http://localhost:18789/health
|
||||
|
||||
# Test chat completions endpoint
|
||||
curl http://localhost:18789/v1/chat/completions \
|
||||
-H "Authorization: Bearer YOUR_GATEWAY_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"openclaw:main","messages":[{"role":"user","content":"hello"}]}'
|
||||
```
|
||||
|
||||
## Environment Variable Reference
|
||||
|
||||
| Variable | Required | Description |
|
||||
| ------------------------ | -------- | ------------------------------------------------- |
|
||||
| `ZAI_API_KEY` | Yes\* | Z.ai API key (\*or other provider key) |
|
||||
| `OPENCLAW_GATEWAY_TOKEN` | Yes | Bearer token for this agent (unique per instance) |
|
||||
| `OPENCLAW_MODEL` | No | Override default model (default: `zai/glm-5`) |
|
||||
| `OLLAMA_BASE_URL` | No | Ollama endpoint (e.g., `http://10.1.1.42:11434`) |
|
||||
| `OLLAMA_MODEL` | No | Ollama model name (default: `cogito`) |
|
||||
53
docker/openclaw-instances/entrypoint.sh
Executable file
53
docker/openclaw-instances/entrypoint.sh
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/bin/sh
|
||||
# Mosaic Agent Fleet — OpenClaw container entrypoint
|
||||
# Renders config template from env vars, optionally adds Ollama provider, starts gateway
|
||||
set -e
|
||||
|
||||
TEMPLATE="/config/openclaw.json.template"
|
||||
CONFIG="/tmp/openclaw.json"
|
||||
|
||||
if [ ! -f "$TEMPLATE" ]; then
|
||||
echo "ERROR: Config template not found at $TEMPLATE"
|
||||
echo "Mount your config volume at /config with a .json.template file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate required env vars
|
||||
: "${OPENCLAW_GATEWAY_TOKEN:?OPENCLAW_GATEWAY_TOKEN is required (generate: openssl rand -hex 32)}"
|
||||
|
||||
# Render template with env var substitution
|
||||
envsubst < "$TEMPLATE" > "$CONFIG"
|
||||
|
||||
# If OLLAMA_BASE_URL is set, inject Ollama provider into config
|
||||
if [ -n "$OLLAMA_BASE_URL" ]; then
|
||||
# Use python3 if available, fall back to node
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
with open('$CONFIG') as f: cfg = json.load(f)
|
||||
cfg.setdefault('models', {})['mode'] = 'merge'
|
||||
cfg['models'].setdefault('providers', {})['ollama'] = {
|
||||
'baseUrl': '$OLLAMA_BASE_URL/v1',
|
||||
'api': 'openai-completions',
|
||||
'models': [{'id': '${OLLAMA_MODEL:-cogito}', 'name': '${OLLAMA_MODEL:-cogito} (Local)', 'reasoning': False, 'input': ['text'], 'cost': {'input':0,'output':0,'cacheRead':0,'cacheWrite':0}, 'contextWindow': 128000, 'maxTokens': 8192}]
|
||||
}
|
||||
with open('$CONFIG','w') as f: json.dump(cfg, f, indent=2)
|
||||
"
|
||||
echo "Ollama provider added: $OLLAMA_BASE_URL (model: ${OLLAMA_MODEL:-cogito})"
|
||||
elif command -v node >/dev/null 2>&1; then
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const cfg = JSON.parse(fs.readFileSync('$CONFIG','utf8'));
|
||||
cfg.models = cfg.models || {}; cfg.models.mode = 'merge';
|
||||
cfg.models.providers = cfg.models.providers || {};
|
||||
cfg.models.providers.ollama = {baseUrl:'$OLLAMA_BASE_URL/v1',api:'openai-completions',models:[{id:'${OLLAMA_MODEL:-cogito}',name:'${OLLAMA_MODEL:-cogito} (Local)',reasoning:false,input:['text'],cost:{input:0,output:0,cacheRead:0,cacheWrite:0},contextWindow:128000,maxTokens:8192}]};
|
||||
fs.writeFileSync('$CONFIG', JSON.stringify(cfg, null, 2));
|
||||
"
|
||||
echo "Ollama provider added: $OLLAMA_BASE_URL (model: ${OLLAMA_MODEL:-cogito})"
|
||||
else
|
||||
echo "WARNING: OLLAMA_BASE_URL set but no python3/node available to inject provider"
|
||||
fi
|
||||
fi
|
||||
|
||||
export OPENCLAW_CONFIG_PATH="$CONFIG"
|
||||
exec openclaw gateway run --bind lan --auth token "$@"
|
||||
14
docker/openclaw-instances/mosaic-main.env
Normal file
14
docker/openclaw-instances/mosaic-main.env
Normal file
@@ -0,0 +1,14 @@
|
||||
# Mosaic Agent: main
|
||||
# Fill in all values before deploying.
|
||||
|
||||
# Required: LLM provider API key (Z.ai, OpenAI, etc.)
|
||||
ZAI_API_KEY=
|
||||
|
||||
# Required: unique bearer token for this agent instance (generate: openssl rand -hex 32)
|
||||
OPENCLAW_GATEWAY_TOKEN=
|
||||
|
||||
# Optional: override default model (default: zai/glm-5)
|
||||
# OPENCLAW_MODEL=zai/glm-5
|
||||
|
||||
# Optional: Ollama endpoint for local inference (uncomment to enable)
|
||||
# OLLAMA_BASE_URL=
|
||||
19
docker/openclaw-instances/mosaic-main.json.template
Normal file
19
docker/openclaw-instances/mosaic-main.json.template
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"gateway": {
|
||||
"mode": "local",
|
||||
"port": 18789,
|
||||
"bind": "lan",
|
||||
"auth": { "mode": "token" },
|
||||
"http": {
|
||||
"endpoints": {
|
||||
"chatCompletions": { "enabled": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/home/node/workspace",
|
||||
"model": { "primary": "${OPENCLAW_MODEL:-zai/glm-5}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
14
docker/openclaw-instances/mosaic-operations.env
Normal file
14
docker/openclaw-instances/mosaic-operations.env
Normal file
@@ -0,0 +1,14 @@
|
||||
# Mosaic Agent: operations
|
||||
# Fill in all values before deploying.
|
||||
|
||||
# Required: LLM provider API key (Z.ai, OpenAI, etc.)
|
||||
ZAI_API_KEY=
|
||||
|
||||
# Required: unique bearer token for this agent instance (generate: openssl rand -hex 32)
|
||||
OPENCLAW_GATEWAY_TOKEN=
|
||||
|
||||
# Optional: override default model (default: zai/glm-5)
|
||||
# OPENCLAW_MODEL=zai/glm-5
|
||||
|
||||
# Optional: Ollama endpoint for local inference (uncomment to enable)
|
||||
# OLLAMA_BASE_URL=
|
||||
19
docker/openclaw-instances/mosaic-operations.json.template
Normal file
19
docker/openclaw-instances/mosaic-operations.json.template
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"gateway": {
|
||||
"mode": "local",
|
||||
"port": 18789,
|
||||
"bind": "lan",
|
||||
"auth": { "mode": "token" },
|
||||
"http": {
|
||||
"endpoints": {
|
||||
"chatCompletions": { "enabled": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/home/node/workspace",
|
||||
"model": { "primary": "${OPENCLAW_MODEL:-zai/glm-5}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
14
docker/openclaw-instances/mosaic-projects.env
Normal file
14
docker/openclaw-instances/mosaic-projects.env
Normal file
@@ -0,0 +1,14 @@
|
||||
# Mosaic Agent: projects
|
||||
# Fill in all values before deploying.
|
||||
|
||||
# Required: LLM provider API key (Z.ai, OpenAI, etc.)
|
||||
ZAI_API_KEY=
|
||||
|
||||
# Required: unique bearer token for this agent instance (generate: openssl rand -hex 32)
|
||||
OPENCLAW_GATEWAY_TOKEN=
|
||||
|
||||
# Optional: override default model (default: zai/glm-5)
|
||||
# OPENCLAW_MODEL=zai/glm-5
|
||||
|
||||
# Optional: Ollama endpoint for local inference (uncomment to enable)
|
||||
# OLLAMA_BASE_URL=
|
||||
19
docker/openclaw-instances/mosaic-projects.json.template
Normal file
19
docker/openclaw-instances/mosaic-projects.json.template
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"gateway": {
|
||||
"mode": "local",
|
||||
"port": 18789,
|
||||
"bind": "lan",
|
||||
"auth": { "mode": "token" },
|
||||
"http": {
|
||||
"endpoints": {
|
||||
"chatCompletions": { "enabled": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/home/node/workspace",
|
||||
"model": { "primary": "${OPENCLAW_MODEL:-zai/glm-5}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
14
docker/openclaw-instances/mosaic-research.env
Normal file
14
docker/openclaw-instances/mosaic-research.env
Normal file
@@ -0,0 +1,14 @@
|
||||
# Mosaic Agent: research
|
||||
# Fill in all values before deploying.
|
||||
|
||||
# Required: LLM provider API key (Z.ai, OpenAI, etc.)
|
||||
ZAI_API_KEY=
|
||||
|
||||
# Required: unique bearer token for this agent instance (generate: openssl rand -hex 32)
|
||||
OPENCLAW_GATEWAY_TOKEN=
|
||||
|
||||
# Optional: override default model (default: zai/glm-5)
|
||||
# OPENCLAW_MODEL=zai/glm-5
|
||||
|
||||
# Optional: Ollama endpoint for local inference (uncomment to enable)
|
||||
# OLLAMA_BASE_URL=
|
||||
19
docker/openclaw-instances/mosaic-research.json.template
Normal file
19
docker/openclaw-instances/mosaic-research.json.template
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"gateway": {
|
||||
"mode": "local",
|
||||
"port": 18789,
|
||||
"bind": "lan",
|
||||
"auth": { "mode": "token" },
|
||||
"http": {
|
||||
"endpoints": {
|
||||
"chatCompletions": { "enabled": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/home/node/workspace",
|
||||
"model": { "primary": "${OPENCLAW_MODEL:-zai/glm-5}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user