Compare commits

..

2 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,7 @@ import { AdminModule } from "./admin/admin.module";
import { TeamsModule } from "./teams/teams.module";
import { ImportModule } from "./import/import.module";
import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module";
import { OpenClawGatewayModule } from "./openclaw-gateway/openclaw-gateway.module";
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
@Module({
@@ -121,6 +122,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
TeamsModule,
ImportModule,
ConversationArchiveModule,
OpenClawGatewayModule,
],
controllers: [AppController, CsrfController],
providers: [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,49 +0,0 @@
# 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.

View File

@@ -1,150 +0,0 @@
# 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:

View File

@@ -1,97 +0,0 @@
# 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`) |

View File

@@ -1,53 +0,0 @@
#!/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 "$@"

View File

@@ -1,14 +0,0 @@
# 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=

View File

@@ -1,19 +0,0 @@
{
"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}" }
}
}
}

View File

@@ -1,14 +0,0 @@
# 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=

View File

@@ -1,19 +0,0 @@
{
"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}" }
}
}
}

View File

@@ -1,14 +0,0 @@
# 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=

View File

@@ -1,19 +0,0 @@
{
"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}" }
}
}
}

View File

@@ -1,14 +0,0 @@
# 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=

View File

@@ -1,19 +0,0 @@
{
"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}" }
}
}
}