Compare commits
12 Commits
feat/ms23-
...
fix/missio
| Author | SHA1 | Date | |
|---|---|---|---|
| e80b624ca6 | |||
| 65536fcb75 | |||
| 53915dc621 | |||
| 398ee06920 | |||
| 2182717f59 | |||
| fe55363f38 | |||
| d60165572a | |||
| ff73fbd391 | |||
| 95ec63a868 | |||
| 2ab736b68b | |||
| 30e0168983 | |||
| 54ee5cf945 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/orchestrator",
|
"name": "@mosaic/orchestrator",
|
||||||
"version": "0.0.20",
|
"version": "0.0.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { PrismaModule } from "../../prisma/prisma.module";
|
import { PrismaModule } from "../../prisma/prisma.module";
|
||||||
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
|
import { OrchestratorApiKeyGuard } from "../../common/guards/api-key.guard";
|
||||||
|
import { EncryptionService } from "../../security/encryption.service";
|
||||||
import { AgentProvidersController } from "./agent-providers.controller";
|
import { AgentProvidersController } from "./agent-providers.controller";
|
||||||
import { AgentProvidersService } from "./agent-providers.service";
|
import { AgentProvidersService } from "./agent-providers.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
controllers: [AgentProvidersController],
|
controllers: [AgentProvidersController],
|
||||||
providers: [OrchestratorApiKeyGuard, AgentProvidersService],
|
providers: [OrchestratorApiKeyGuard, EncryptionService, AgentProvidersService],
|
||||||
})
|
})
|
||||||
export class AgentProvidersModule {}
|
export class AgentProvidersModule {}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { NotFoundException } from "@nestjs/common";
|
import { NotFoundException } from "@nestjs/common";
|
||||||
|
import { EncryptionService } from "../../security/encryption.service";
|
||||||
import { AgentProvidersService } from "./agent-providers.service";
|
import { AgentProvidersService } from "./agent-providers.service";
|
||||||
import { PrismaService } from "../../prisma/prisma.service";
|
import { PrismaService } from "../../prisma/prisma.service";
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ describe("AgentProvidersService", () => {
|
|||||||
delete: ReturnType<typeof vi.fn>;
|
delete: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
let encryptionService: {
|
||||||
|
encryptIfNeeded: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prisma = {
|
prisma = {
|
||||||
@@ -26,7 +30,14 @@ describe("AgentProvidersService", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
service = new AgentProvidersService(prisma as unknown as PrismaService);
|
encryptionService = {
|
||||||
|
encryptIfNeeded: vi.fn((value: string) => `enc:${value}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
service = new AgentProvidersService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
encryptionService as unknown as EncryptionService
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists all provider configs", async () => {
|
it("lists all provider configs", async () => {
|
||||||
@@ -111,6 +122,42 @@ describe("AgentProvidersService", () => {
|
|||||||
credentials: {},
|
credentials: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(encryptionService.encryptIfNeeded).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encrypts openclaw token credentials when creating provider config", async () => {
|
||||||
|
const created = {
|
||||||
|
id: "cfg-openclaw",
|
||||||
|
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
|
||||||
|
name: "OpenClaw",
|
||||||
|
provider: "openclaw",
|
||||||
|
gatewayUrl: "https://openclaw.example.com",
|
||||||
|
credentials: { apiToken: "enc:top-secret" },
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2026-03-07T18:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-07T18:00:00.000Z"),
|
||||||
|
};
|
||||||
|
prisma.agentProviderConfig.create.mockResolvedValue(created);
|
||||||
|
|
||||||
|
const result = await service.create({
|
||||||
|
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
|
||||||
|
name: "OpenClaw",
|
||||||
|
provider: "openclaw",
|
||||||
|
gatewayUrl: "https://openclaw.example.com",
|
||||||
|
credentials: { apiToken: "top-secret" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(encryptionService.encryptIfNeeded).toHaveBeenCalledWith("top-secret");
|
||||||
|
expect(prisma.agentProviderConfig.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
|
||||||
|
name: "OpenClaw",
|
||||||
|
provider: "openclaw",
|
||||||
|
gatewayUrl: "https://openclaw.example.com",
|
||||||
|
credentials: { apiToken: "enc:top-secret" },
|
||||||
|
},
|
||||||
|
});
|
||||||
expect(result).toEqual(created);
|
expect(result).toEqual(created);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,6 +203,47 @@ describe("AgentProvidersService", () => {
|
|||||||
isActive: false,
|
isActive: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(encryptionService.encryptIfNeeded).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encrypts openclaw token credentials when updating provider config", async () => {
|
||||||
|
prisma.agentProviderConfig.findUnique.mockResolvedValue({
|
||||||
|
id: "cfg-openclaw",
|
||||||
|
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
|
||||||
|
name: "OpenClaw",
|
||||||
|
provider: "openclaw",
|
||||||
|
gatewayUrl: "https://openclaw.example.com",
|
||||||
|
credentials: { apiToken: "enc:existing" },
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2026-03-07T18:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-07T18:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
id: "cfg-openclaw",
|
||||||
|
workspaceId: "8bcd7eda-a122-4d6c-adfd-b152f6f75369",
|
||||||
|
name: "OpenClaw",
|
||||||
|
provider: "openclaw",
|
||||||
|
gatewayUrl: "https://openclaw.example.com",
|
||||||
|
credentials: { apiToken: "enc:rotated-token" },
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2026-03-07T18:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-07T19:00:00.000Z"),
|
||||||
|
};
|
||||||
|
prisma.agentProviderConfig.update.mockResolvedValue(updated);
|
||||||
|
|
||||||
|
const result = await service.update("cfg-openclaw", {
|
||||||
|
credentials: { apiToken: "rotated-token" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(encryptionService.encryptIfNeeded).toHaveBeenCalledWith("rotated-token");
|
||||||
|
expect(prisma.agentProviderConfig.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: "cfg-openclaw" },
|
||||||
|
data: {
|
||||||
|
credentials: { apiToken: "enc:rotated-token" },
|
||||||
|
},
|
||||||
|
});
|
||||||
expect(result).toEqual(updated);
|
expect(result).toEqual(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import type { AgentProviderConfig, Prisma } from "@prisma/client";
|
import type { AgentProviderConfig, Prisma } from "@prisma/client";
|
||||||
|
import { EncryptionService } from "../../security/encryption.service";
|
||||||
import { PrismaService } from "../../prisma/prisma.service";
|
import { PrismaService } from "../../prisma/prisma.service";
|
||||||
import { CreateAgentProviderDto } from "./dto/create-agent-provider.dto";
|
import { CreateAgentProviderDto } from "./dto/create-agent-provider.dto";
|
||||||
import { UpdateAgentProviderDto } from "./dto/update-agent-provider.dto";
|
import { UpdateAgentProviderDto } from "./dto/update-agent-provider.dto";
|
||||||
|
|
||||||
|
const OPENCLAW_PROVIDER_TYPE = "openclaw";
|
||||||
|
const OPENCLAW_TOKEN_KEYS = ["apiToken", "token", "bearerToken"] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AgentProvidersService {
|
export class AgentProvidersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly encryptionService: EncryptionService
|
||||||
|
) {}
|
||||||
|
|
||||||
async list(): Promise<AgentProviderConfig[]> {
|
async list(): Promise<AgentProviderConfig[]> {
|
||||||
return this.prisma.agentProviderConfig.findMany({
|
return this.prisma.agentProviderConfig.findMany({
|
||||||
@@ -27,20 +34,23 @@ export class AgentProvidersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateAgentProviderDto): Promise<AgentProviderConfig> {
|
async create(dto: CreateAgentProviderDto): Promise<AgentProviderConfig> {
|
||||||
|
const credentials = this.sanitizeCredentials(dto.provider, dto.credentials ?? {});
|
||||||
|
|
||||||
return this.prisma.agentProviderConfig.create({
|
return this.prisma.agentProviderConfig.create({
|
||||||
data: {
|
data: {
|
||||||
workspaceId: dto.workspaceId,
|
workspaceId: dto.workspaceId,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
provider: dto.provider,
|
provider: dto.provider,
|
||||||
gatewayUrl: dto.gatewayUrl,
|
gatewayUrl: dto.gatewayUrl,
|
||||||
credentials: this.toJsonValue(dto.credentials ?? {}),
|
credentials: this.toJsonValue(credentials),
|
||||||
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateAgentProviderDto): Promise<AgentProviderConfig> {
|
async update(id: string, dto: UpdateAgentProviderDto): Promise<AgentProviderConfig> {
|
||||||
await this.getById(id);
|
const existingConfig = await this.getById(id);
|
||||||
|
const provider = dto.provider ?? existingConfig.provider;
|
||||||
|
|
||||||
const data: Prisma.AgentProviderConfigUpdateInput = {
|
const data: Prisma.AgentProviderConfigUpdateInput = {
|
||||||
...(dto.workspaceId !== undefined ? { workspaceId: dto.workspaceId } : {}),
|
...(dto.workspaceId !== undefined ? { workspaceId: dto.workspaceId } : {}),
|
||||||
@@ -48,7 +58,9 @@ export class AgentProvidersService {
|
|||||||
...(dto.provider !== undefined ? { provider: dto.provider } : {}),
|
...(dto.provider !== undefined ? { provider: dto.provider } : {}),
|
||||||
...(dto.gatewayUrl !== undefined ? { gatewayUrl: dto.gatewayUrl } : {}),
|
...(dto.gatewayUrl !== undefined ? { gatewayUrl: dto.gatewayUrl } : {}),
|
||||||
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||||
...(dto.credentials !== undefined ? { credentials: this.toJsonValue(dto.credentials) } : {}),
|
...(dto.credentials !== undefined
|
||||||
|
? { credentials: this.toJsonValue(this.sanitizeCredentials(provider, dto.credentials)) }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.prisma.agentProviderConfig.update({
|
return this.prisma.agentProviderConfig.update({
|
||||||
@@ -65,6 +77,25 @@ export class AgentProvidersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sanitizeCredentials(
|
||||||
|
provider: string,
|
||||||
|
credentials: Record<string, unknown>
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (provider.toLowerCase() !== OPENCLAW_PROVIDER_TYPE) {
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCredentials: Record<string, unknown> = { ...credentials };
|
||||||
|
for (const key of OPENCLAW_TOKEN_KEYS) {
|
||||||
|
const tokenValue = nextCredentials[key];
|
||||||
|
if (typeof tokenValue === "string" && tokenValue.length > 0) {
|
||||||
|
nextCredentials[key] = this.encryptionService.encryptIfNeeded(tokenValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextCredentials;
|
||||||
|
}
|
||||||
|
|
||||||
private toJsonValue(value: Record<string, unknown>): Prisma.InputJsonValue {
|
private toJsonValue(value: Record<string, unknown>): Prisma.InputJsonValue {
|
||||||
return value as Prisma.InputJsonValue;
|
return value as Prisma.InputJsonValue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export class AgentsController {
|
|||||||
* Return recent orchestrator events for non-streaming consumers.
|
* Return recent orchestrator events for non-streaming consumers.
|
||||||
*/
|
*/
|
||||||
@Get("events/recent")
|
@Get("events/recent")
|
||||||
@Throttle({ status: { limit: 200, ttl: 60000 } })
|
@Throttle({ default: { limit: 1000, ttl: 60000 } })
|
||||||
getRecentEvents(@Query("limit") limit?: string): {
|
getRecentEvents(@Query("limit") limit?: string): {
|
||||||
events: ReturnType<AgentEventsService["getRecentEvents"]>;
|
events: ReturnType<AgentEventsService["getRecentEvents"]>;
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type { HttpService } from "@nestjs/axios";
|
||||||
|
import { ServiceUnavailableException } from "@nestjs/common";
|
||||||
|
import type { AgentMessage } from "@mosaic/shared";
|
||||||
|
import type { AgentProviderConfig } from "@prisma/client";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { EncryptionService } from "../../../security/encryption.service";
|
||||||
|
import { OpenClawSseBridge } from "./openclaw-sse.bridge";
|
||||||
|
import { OpenClawProvider } from "./openclaw.provider";
|
||||||
|
|
||||||
|
describe("Phase 3 gate: OpenClaw provider config registered in DB → provider loaded on boot → sessions returned from /api/mission-control/sessions → inject/pause/kill proxied to gateway", () => {
|
||||||
|
let provider: OpenClawProvider;
|
||||||
|
let httpService: {
|
||||||
|
axiosRef: {
|
||||||
|
get: ReturnType<typeof vi.fn>;
|
||||||
|
post: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let encryptionService: {
|
||||||
|
decryptIfNeeded: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: AgentProviderConfig = {
|
||||||
|
id: "cfg-openclaw-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
name: "openclaw-home",
|
||||||
|
provider: "openclaw",
|
||||||
|
gatewayUrl: "https://gateway.example.com",
|
||||||
|
credentials: {
|
||||||
|
apiToken: "enc:token",
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2026-03-07T15:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-07T15:00:00.000Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
httpService = {
|
||||||
|
axiosRef: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
encryptionService = {
|
||||||
|
decryptIfNeeded: vi.fn().mockReturnValue("plain-token"),
|
||||||
|
};
|
||||||
|
|
||||||
|
provider = new OpenClawProvider(
|
||||||
|
config,
|
||||||
|
encryptionService as unknown as EncryptionService,
|
||||||
|
httpService as unknown as HttpService,
|
||||||
|
new OpenClawSseBridge(httpService as unknown as HttpService)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps listSessions from mocked OpenClaw gateway HTTP responses", async () => {
|
||||||
|
httpService.axiosRef.get.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
status: "running",
|
||||||
|
createdAt: "2026-03-07T15:01:00.000Z",
|
||||||
|
updatedAt: "2026-03-07T15:02:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(provider.listSessions()).resolves.toEqual({
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
providerId: "openclaw-home",
|
||||||
|
providerType: "openclaw",
|
||||||
|
status: "active",
|
||||||
|
createdAt: new Date("2026-03-07T15:01:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-07T15:02:00.000Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(httpService.axiosRef.get).toHaveBeenCalledWith(
|
||||||
|
"https://gateway.example.com/api/sessions",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer plain-token",
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
limit: 50,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps streamMessages from mock SSE events into AgentMessage output", async () => {
|
||||||
|
httpService.axiosRef.get.mockResolvedValue({
|
||||||
|
data: Readable.from([
|
||||||
|
'event: message\ndata: {"id":"msg-1","role":"assistant","content":"hello from stream","timestamp":"2026-03-07T15:03:00.000Z"}\n\n',
|
||||||
|
'event: status\ndata: {"status":"paused","timestamp":"2026-03-07T15:04:00.000Z"}\n\n',
|
||||||
|
"data: [DONE]\n\n",
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = await collectMessages(provider.streamMessages("session-1"));
|
||||||
|
|
||||||
|
expect(messages).toEqual([
|
||||||
|
{
|
||||||
|
id: "msg-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "hello from stream",
|
||||||
|
timestamp: new Date("2026-03-07T15:03:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: expect.any(String),
|
||||||
|
sessionId: "session-1",
|
||||||
|
role: "system",
|
||||||
|
content: "Session status changed to paused",
|
||||||
|
timestamp: new Date("2026-03-07T15:04:00.000Z"),
|
||||||
|
metadata: {
|
||||||
|
status: "paused",
|
||||||
|
timestamp: "2026-03-07T15:04:00.000Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles unavailable gateway errors", async () => {
|
||||||
|
httpService.axiosRef.get.mockRejectedValue(new Error("gateway unavailable"));
|
||||||
|
|
||||||
|
await expect(provider.listSessions()).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
await expect(provider.listSessions()).rejects.toThrow("gateway unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles bad token decryption errors", async () => {
|
||||||
|
encryptionService.decryptIfNeeded.mockImplementation(() => {
|
||||||
|
throw new Error("bad token");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(provider.listSessions()).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
await expect(provider.listSessions()).rejects.toThrow("Failed to decrypt API token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles malformed SSE stream responses", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
httpService.axiosRef.get.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
malformed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const streamPromise = collectMessages(provider.streamMessages("session-malformed"));
|
||||||
|
const rejection = expect(streamPromise).rejects.toThrow(
|
||||||
|
"OpenClaw provider openclaw-home failed to stream messages for session session-malformed"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rejection;
|
||||||
|
expect(httpService.axiosRef.get).toHaveBeenCalledTimes(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function collectMessages(stream: AsyncIterable<AgentMessage>): Promise<AgentMessage[]> {
|
||||||
|
const messages: AgentMessage[] = [];
|
||||||
|
|
||||||
|
for await (const message of stream) {
|
||||||
|
messages.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
@@ -4,6 +4,6 @@ import { AuthGuard } from "./guards/auth.guard";
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [OrchestratorApiKeyGuard, AuthGuard],
|
providers: [OrchestratorApiKeyGuard, AuthGuard],
|
||||||
exports: [AuthGuard],
|
exports: [OrchestratorApiKeyGuard, AuthGuard],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { createDecipheriv, hkdfSync } from "node:crypto";
|
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
|
||||||
|
|
||||||
const ALGORITHM = "aes-256-gcm";
|
const ALGORITHM = "aes-256-gcm";
|
||||||
const ENCRYPTED_PREFIX = "enc:";
|
const ENCRYPTED_PREFIX = "enc:";
|
||||||
@@ -16,6 +16,27 @@ export class EncryptionService {
|
|||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
encryptIfNeeded(value: string): string {
|
||||||
|
if (this.isEncrypted(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.encrypt(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypt(plaintext: string): string {
|
||||||
|
try {
|
||||||
|
const iv = randomBytes(IV_LENGTH);
|
||||||
|
const cipher = createCipheriv(ALGORITHM, this.getOrCreateKey(), iv);
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
const payload = Buffer.concat([iv, ciphertext, authTag]);
|
||||||
|
return `${ENCRYPTED_PREFIX}${payload.toString("base64")}`;
|
||||||
|
} catch {
|
||||||
|
throw new Error("Failed to encrypt value");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
decryptIfNeeded(value: string): string {
|
decryptIfNeeded(value: string): string {
|
||||||
if (!this.isEncrypted(value)) {
|
if (!this.isEncrypted(value)) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
315
apps/orchestrator/tests/integration/ms23-p3-gate.spec.ts
Normal file
315
apps/orchestrator/tests/integration/ms23-p3-gate.spec.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import type { HttpService } from "@nestjs/axios";
|
||||||
|
import type {
|
||||||
|
AgentMessage,
|
||||||
|
AgentSession,
|
||||||
|
AgentSessionList,
|
||||||
|
IAgentProvider,
|
||||||
|
InjectResult,
|
||||||
|
} from "@mosaic/shared";
|
||||||
|
import type { AgentProviderConfig } from "@prisma/client";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { InternalAgentProvider } from "../../src/api/agents/internal-agent.provider";
|
||||||
|
import { AgentProviderRegistry } from "../../src/api/agents/agent-provider.registry";
|
||||||
|
import { MissionControlController } from "../../src/api/mission-control/mission-control.controller";
|
||||||
|
import { MissionControlService } from "../../src/api/mission-control/mission-control.service";
|
||||||
|
import { OpenClawProviderFactory } from "../../src/api/providers/openclaw/openclaw.provider-factory";
|
||||||
|
import { OpenClawSseBridge } from "../../src/api/providers/openclaw/openclaw-sse.bridge";
|
||||||
|
import { ProvidersModule } from "../../src/api/providers/providers.module";
|
||||||
|
import type { PrismaService } from "../../src/prisma/prisma.service";
|
||||||
|
import type { EncryptionService } from "../../src/security/encryption.service";
|
||||||
|
|
||||||
|
type MockProvider = IAgentProvider & {
|
||||||
|
listSessions: ReturnType<typeof vi.fn>;
|
||||||
|
getSession: ReturnType<typeof vi.fn>;
|
||||||
|
injectMessage: ReturnType<typeof vi.fn>;
|
||||||
|
pauseSession: ReturnType<typeof vi.fn>;
|
||||||
|
killSession: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MockPrisma = {
|
||||||
|
agentProviderConfig: {
|
||||||
|
create: ReturnType<typeof vi.fn>;
|
||||||
|
findMany: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
operatorAuditLog: {
|
||||||
|
create: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyMessageStream = async function* (): AsyncIterable<AgentMessage> {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("MS23-P3-004 API integration", () => {
|
||||||
|
let controller: MissionControlController;
|
||||||
|
let providersModule: ProvidersModule;
|
||||||
|
let registry: AgentProviderRegistry;
|
||||||
|
let prisma: MockPrisma;
|
||||||
|
let httpService: {
|
||||||
|
axiosRef: {
|
||||||
|
get: ReturnType<typeof vi.fn>;
|
||||||
|
post: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const gatewayUrl = "https://openclaw-gateway.example.com";
|
||||||
|
const internalSession: AgentSession = {
|
||||||
|
id: "session-internal-1",
|
||||||
|
providerId: "internal",
|
||||||
|
providerType: "internal",
|
||||||
|
status: "active",
|
||||||
|
createdAt: new Date("2026-03-07T16:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-07T16:02:00.000Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const openClawGatewaySession = {
|
||||||
|
id: "session-openclaw-1",
|
||||||
|
status: "running",
|
||||||
|
createdAt: "2026-03-07T16:01:00.000Z",
|
||||||
|
updatedAt: "2026-03-07T16:03:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const createInternalProvider = (session: AgentSession): MockProvider => ({
|
||||||
|
providerId: "internal",
|
||||||
|
providerType: "internal",
|
||||||
|
displayName: "Internal",
|
||||||
|
listSessions: vi.fn().mockResolvedValue({ sessions: [session], total: 1 } as AgentSessionList),
|
||||||
|
getSession: vi.fn().mockImplementation(async (sessionId: string) => {
|
||||||
|
return sessionId === session.id ? session : null;
|
||||||
|
}),
|
||||||
|
getMessages: vi.fn().mockResolvedValue([]),
|
||||||
|
injectMessage: vi.fn().mockResolvedValue({ accepted: true } as InjectResult),
|
||||||
|
pauseSession: vi.fn().mockResolvedValue(undefined),
|
||||||
|
resumeSession: vi.fn().mockResolvedValue(undefined),
|
||||||
|
killSession: vi.fn().mockResolvedValue(undefined),
|
||||||
|
streamMessages: vi.fn().mockReturnValue(emptyMessageStream()),
|
||||||
|
isAvailable: vi.fn().mockResolvedValue(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const providerConfigs: AgentProviderConfig[] = [];
|
||||||
|
|
||||||
|
prisma = {
|
||||||
|
agentProviderConfig: {
|
||||||
|
create: vi.fn().mockImplementation(async (args: { data: Record<string, unknown> }) => {
|
||||||
|
const now = new Date("2026-03-07T15:00:00.000Z");
|
||||||
|
const record: AgentProviderConfig = {
|
||||||
|
id: `cfg-${String(providerConfigs.length + 1)}`,
|
||||||
|
workspaceId: String(args.data.workspaceId),
|
||||||
|
name: String(args.data.name),
|
||||||
|
provider: String(args.data.provider),
|
||||||
|
gatewayUrl: String(args.data.gatewayUrl),
|
||||||
|
credentials: (args.data.credentials ?? {}) as AgentProviderConfig["credentials"],
|
||||||
|
isActive: args.data.isActive !== false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
providerConfigs.push(record);
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
findMany: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(
|
||||||
|
async (args: { where?: { provider?: string; isActive?: boolean } }) => {
|
||||||
|
const where = args.where ?? {};
|
||||||
|
return providerConfigs.filter((config) => {
|
||||||
|
if (where.provider !== undefined && config.provider !== where.provider) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (where.isActive !== undefined && config.isActive !== where.isActive) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
operatorAuditLog: {
|
||||||
|
create: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
httpService = {
|
||||||
|
axiosRef: {
|
||||||
|
get: vi.fn().mockImplementation(async (url: string) => {
|
||||||
|
if (url === `${gatewayUrl}/api/sessions`) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
sessions: [openClawGatewaySession],
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === `${gatewayUrl}/api/sessions/${openClawGatewaySession.id}`) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
session: openClawGatewaySession,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected GET ${url}`);
|
||||||
|
}),
|
||||||
|
post: vi.fn().mockImplementation(async (url: string) => {
|
||||||
|
if (url.endsWith("/inject")) {
|
||||||
|
return { data: { accepted: true, messageId: "msg-inject-1" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith("/pause") || url.endsWith("/kill")) {
|
||||||
|
return { data: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected POST ${url}`);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const internalProvider = createInternalProvider(internalSession);
|
||||||
|
registry = new AgentProviderRegistry(internalProvider as unknown as InternalAgentProvider);
|
||||||
|
registry.onModuleInit();
|
||||||
|
|
||||||
|
const encryptionService = {
|
||||||
|
decryptIfNeeded: vi.fn().mockReturnValue("plain-openclaw-token"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const sseBridge = new OpenClawSseBridge(httpService as unknown as HttpService);
|
||||||
|
const openClawProviderFactory = new OpenClawProviderFactory(
|
||||||
|
encryptionService as unknown as EncryptionService,
|
||||||
|
httpService as unknown as HttpService,
|
||||||
|
sseBridge
|
||||||
|
);
|
||||||
|
|
||||||
|
providersModule = new ProvidersModule(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
registry,
|
||||||
|
openClawProviderFactory
|
||||||
|
);
|
||||||
|
|
||||||
|
const missionControlService = new MissionControlService(
|
||||||
|
registry,
|
||||||
|
prisma as unknown as PrismaService
|
||||||
|
);
|
||||||
|
|
||||||
|
controller = new MissionControlController(missionControlService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Phase 3 gate: OpenClaw provider config registered in DB → provider loaded on boot → sessions returned from /api/mission-control/sessions → inject/pause/kill proxied to gateway", async () => {
|
||||||
|
await prisma.agentProviderConfig.create({
|
||||||
|
data: {
|
||||||
|
workspaceId: "workspace-ms23",
|
||||||
|
name: "openclaw-home",
|
||||||
|
provider: "openclaw",
|
||||||
|
gatewayUrl,
|
||||||
|
credentials: {
|
||||||
|
apiToken: "enc:test-openclaw-token",
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await providersModule.onModuleInit();
|
||||||
|
|
||||||
|
// Equivalent to GET /api/mission-control/sessions
|
||||||
|
const sessionsResponse = await controller.listSessions();
|
||||||
|
|
||||||
|
expect(sessionsResponse.sessions.map((session) => session.id)).toEqual([
|
||||||
|
"session-openclaw-1",
|
||||||
|
"session-internal-1",
|
||||||
|
]);
|
||||||
|
expect(sessionsResponse.sessions).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "session-internal-1",
|
||||||
|
providerId: "internal",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "session-openclaw-1",
|
||||||
|
providerId: "openclaw-home",
|
||||||
|
providerType: "openclaw",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const operatorRequest = {
|
||||||
|
user: {
|
||||||
|
id: "operator-ms23",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.injectMessage(
|
||||||
|
"session-openclaw-1",
|
||||||
|
{
|
||||||
|
message: "Ship it",
|
||||||
|
},
|
||||||
|
operatorRequest
|
||||||
|
)
|
||||||
|
).resolves.toEqual({ accepted: true, messageId: "msg-inject-1" });
|
||||||
|
|
||||||
|
await expect(controller.pauseSession("session-openclaw-1", operatorRequest)).resolves.toEqual({
|
||||||
|
message: "Session session-openclaw-1 paused",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.killSession(
|
||||||
|
"session-openclaw-1",
|
||||||
|
{
|
||||||
|
force: false,
|
||||||
|
},
|
||||||
|
operatorRequest
|
||||||
|
)
|
||||||
|
).resolves.toEqual({ message: "Session session-openclaw-1 killed" });
|
||||||
|
|
||||||
|
expect(httpService.axiosRef.post).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
`${gatewayUrl}/api/sessions/session-openclaw-1/inject`,
|
||||||
|
{ content: "Ship it" },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer plain-openclaw-token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(httpService.axiosRef.post).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
`${gatewayUrl}/api/sessions/session-openclaw-1/pause`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer plain-openclaw-token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(httpService.axiosRef.post).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
`${gatewayUrl}/api/sessions/session-openclaw-1/kill`,
|
||||||
|
{ force: false },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer plain-openclaw-token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prisma.operatorAuditLog.create).toHaveBeenNthCalledWith(1, {
|
||||||
|
data: {
|
||||||
|
sessionId: "session-openclaw-1",
|
||||||
|
userId: "operator-ms23",
|
||||||
|
provider: "openclaw-home",
|
||||||
|
action: "inject",
|
||||||
|
content: "Ship it",
|
||||||
|
metadata: {
|
||||||
|
payload: {
|
||||||
|
message: "Ship it",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["**/*.e2e-spec.ts"],
|
include: ["tests/integration/**/*.e2e-spec.ts", "tests/integration/**/*.spec.ts"],
|
||||||
testTimeout: 30000,
|
testTimeout: 30000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/web",
|
"name": "@mosaic/web",
|
||||||
"version": "0.0.20",
|
"version": "0.0.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|||||||
@@ -0,0 +1,528 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type ReactElement,
|
||||||
|
type SyntheticEvent,
|
||||||
|
} from "react";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { FleetSettingsNav } from "@/components/settings/FleetSettingsNav";
|
||||||
|
import {
|
||||||
|
createAgentProvider,
|
||||||
|
deleteAgentProvider,
|
||||||
|
fetchAgentProviders,
|
||||||
|
updateAgentProvider,
|
||||||
|
type AgentProviderConfig,
|
||||||
|
type CreateAgentProviderRequest,
|
||||||
|
type UpdateAgentProviderRequest,
|
||||||
|
} from "@/lib/api/agent-providers";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
|
interface ProviderFormData {
|
||||||
|
name: string;
|
||||||
|
provider: "openclaw";
|
||||||
|
gatewayUrl: string;
|
||||||
|
apiToken: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAME_PATTERN = /^[a-zA-Z0-9-]+$/;
|
||||||
|
|
||||||
|
const INITIAL_FORM: ProviderFormData = {
|
||||||
|
name: "",
|
||||||
|
provider: "openclaw",
|
||||||
|
gatewayUrl: "",
|
||||||
|
apiToken: "",
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof Error && error.message.trim().length > 0) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidHttpsUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
return parsed.protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCreatedDate(value: string): string {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(form: ProviderFormData, isEditing: boolean): string | null {
|
||||||
|
const name = form.name.trim();
|
||||||
|
if (name.length === 0) {
|
||||||
|
return "Name is required.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!NAME_PATTERN.test(name)) {
|
||||||
|
return "Name must contain only letters, numbers, and hyphens.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const gatewayUrl = form.gatewayUrl.trim();
|
||||||
|
if (gatewayUrl.length === 0) {
|
||||||
|
return "Gateway URL is required.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidHttpsUrl(gatewayUrl)) {
|
||||||
|
return "Gateway URL must be a valid https:// URL.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditing && form.apiToken.trim().length === 0) {
|
||||||
|
return "API token is required when creating a provider.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentProvidersSettingsPage(): ReactElement {
|
||||||
|
const [providers, setProviders] = useState<AgentProviderConfig[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||||
|
const [editingProvider, setEditingProvider] = useState<AgentProviderConfig | null>(null);
|
||||||
|
const [form, setForm] = useState<ProviderFormData>(INITIAL_FORM);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AgentProviderConfig | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const loadProviders = useCallback(async (showLoadingState: boolean): Promise<void> => {
|
||||||
|
if (showLoadingState) {
|
||||||
|
setIsLoading(true);
|
||||||
|
} else {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchAgentProviders();
|
||||||
|
setProviders(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (loadError: unknown) {
|
||||||
|
setError(getErrorMessage(loadError, "Failed to load agent providers."));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadProviders(true);
|
||||||
|
}, [loadProviders]);
|
||||||
|
|
||||||
|
function openCreateDialog(): void {
|
||||||
|
setEditingProvider(null);
|
||||||
|
setForm(INITIAL_FORM);
|
||||||
|
setFormError(null);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(provider: AgentProviderConfig): void {
|
||||||
|
setEditingProvider(provider);
|
||||||
|
setForm({
|
||||||
|
name: provider.name,
|
||||||
|
provider: "openclaw",
|
||||||
|
gatewayUrl: provider.gatewayUrl,
|
||||||
|
apiToken: "",
|
||||||
|
isActive: provider.isActive,
|
||||||
|
});
|
||||||
|
setFormError(null);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog(): void {
|
||||||
|
if (isSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setEditingProvider(null);
|
||||||
|
setForm(INITIAL_FORM);
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: SyntheticEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
|
||||||
|
const validationError = validateForm(form, editingProvider !== null);
|
||||||
|
if (validationError !== null) {
|
||||||
|
setFormError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = form.name.trim();
|
||||||
|
const gatewayUrl = form.gatewayUrl.trim();
|
||||||
|
const apiToken = form.apiToken.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
if (editingProvider) {
|
||||||
|
const updatePayload: UpdateAgentProviderRequest = {
|
||||||
|
name,
|
||||||
|
provider: form.provider,
|
||||||
|
gatewayUrl,
|
||||||
|
isActive: form.isActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (apiToken.length > 0) {
|
||||||
|
updatePayload.credentials = { apiToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAgentProvider(editingProvider.id, updatePayload);
|
||||||
|
setSuccessMessage(`Updated provider "${name}".`);
|
||||||
|
} else {
|
||||||
|
const createPayload: CreateAgentProviderRequest = {
|
||||||
|
name,
|
||||||
|
provider: form.provider,
|
||||||
|
gatewayUrl,
|
||||||
|
credentials: { apiToken },
|
||||||
|
isActive: form.isActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
await createAgentProvider(createPayload);
|
||||||
|
setSuccessMessage(`Added provider "${name}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setEditingProvider(null);
|
||||||
|
setForm(INITIAL_FORM);
|
||||||
|
await loadProviders(false);
|
||||||
|
} catch (saveError: unknown) {
|
||||||
|
setFormError(getErrorMessage(saveError, "Unable to save agent provider."));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteProvider(): Promise<void> {
|
||||||
|
if (!deleteTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsDeleting(true);
|
||||||
|
await deleteAgentProvider(deleteTarget.id);
|
||||||
|
setSuccessMessage(`Deleted provider "${deleteTarget.name}".`);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await loadProviders(false);
|
||||||
|
} catch (deleteError: unknown) {
|
||||||
|
setError(getErrorMessage(deleteError, "Failed to delete agent provider."));
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Agent Providers</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Register OpenClaw gateways and API tokens used for external agent sessions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<FleetSettingsNav />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>OpenClaw Gateways</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Add one or more OpenClaw gateway endpoints and control which ones are active.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
void loadProviders(false);
|
||||||
|
}}
|
||||||
|
disabled={isLoading || isRefreshing}
|
||||||
|
>
|
||||||
|
{isRefreshing ? "Refreshing..." : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={openCreateDialog}>Add Provider</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{successMessage ? <p className="text-sm text-emerald-600">{successMessage}</p> : null}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading agent providers...</p>
|
||||||
|
) : providers.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No agent providers configured yet. Add one to register an OpenClaw gateway.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
providers.map((provider) => (
|
||||||
|
<div
|
||||||
|
key={provider.id}
|
||||||
|
className="rounded-lg border p-4 flex flex-col gap-4 md:flex-row md:items-start md:justify-between"
|
||||||
|
>
|
||||||
|
<div className="space-y-2 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="font-semibold truncate">{provider.name}</p>
|
||||||
|
<Badge variant={provider.isActive ? "default" : "secondary"}>
|
||||||
|
{provider.isActive ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">{provider.provider}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground break-all">
|
||||||
|
Gateway URL: {provider.gatewayUrl}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Created: {formatCreatedDate(provider.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
openEditDialog(provider);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteTarget(provider);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isDialogOpen}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen) {
|
||||||
|
closeDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingProvider ? "Edit Agent Provider" : "Add Agent Provider"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure an OpenClaw gateway URL and API token for agent provider registration.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={(event) => void handleSubmit(event)} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="agent-provider-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="agent-provider-name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setForm((previous) => ({ ...previous, name: event.target.value }));
|
||||||
|
}}
|
||||||
|
placeholder="openclaw-primary"
|
||||||
|
maxLength={100}
|
||||||
|
disabled={isSaving}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Use letters, numbers, and hyphens only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="agent-provider-type">Provider Type</Label>
|
||||||
|
<Select
|
||||||
|
value={form.provider}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === "openclaw") {
|
||||||
|
setForm((previous) => ({ ...previous, provider: value }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="agent-provider-type">
|
||||||
|
<SelectValue placeholder="Select provider type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="openclaw">openclaw</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="agent-provider-gateway-url">Gateway URL</Label>
|
||||||
|
<Input
|
||||||
|
id="agent-provider-gateway-url"
|
||||||
|
value={form.gatewayUrl}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setForm((previous) => ({ ...previous, gatewayUrl: event.target.value }));
|
||||||
|
}}
|
||||||
|
placeholder="https://my-openclaw.example.com"
|
||||||
|
disabled={isSaving}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="agent-provider-api-token">API Token</Label>
|
||||||
|
<Input
|
||||||
|
id="agent-provider-api-token"
|
||||||
|
type="password"
|
||||||
|
value={form.apiToken}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setForm((previous) => ({ ...previous, apiToken: event.target.value }));
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
editingProvider ? "Leave blank to keep existing token" : "Enter API token"
|
||||||
|
}
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{editingProvider
|
||||||
|
? "Leave blank to keep the currently stored token."
|
||||||
|
: "Required when creating a provider."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="agent-provider-active">Provider Status</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Inactive providers remain saved but are excluded from routing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="agent-provider-active"
|
||||||
|
checked={form.isActive}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setForm((previous) => ({ ...previous, isActive: checked }));
|
||||||
|
}}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError ? (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={closeDialog} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSaving}>
|
||||||
|
{isSaving ? "Saving..." : editingProvider ? "Save Changes" : "Create Provider"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !isDeleting) {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Agent Provider</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Delete provider "{deleteTarget?.name}"? This permanently removes its gateway and token
|
||||||
|
configuration.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDeleteProvider} disabled={isDeleting}>
|
||||||
|
{isDeleting ? "Deleting..." : "Delete Provider"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -227,6 +227,33 @@ const categories: CategoryConfig[] = [
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Agent Providers",
|
||||||
|
description:
|
||||||
|
"Register OpenClaw gateway URLs and API tokens for external agent provider routing.",
|
||||||
|
href: "/settings/agent-providers",
|
||||||
|
accent: "var(--ms-blue-400)",
|
||||||
|
iconBg: "rgba(47, 128, 255, 0.12)",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M4 6.5h12" />
|
||||||
|
<path d="M6.5 10h7" />
|
||||||
|
<path d="M4 13.5h12" />
|
||||||
|
<circle cx="5.5" cy="10" r="1.5" />
|
||||||
|
<circle cx="14.5" cy="10" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Agent Config",
|
title: "Agent Config",
|
||||||
description: "Choose primary and fallback models, plus optional personality/SOUL instructions.",
|
description: "Choose primary and fallback models, plus optional personality/SOUL instructions.",
|
||||||
|
|||||||
@@ -44,6 +44,25 @@ interface AuditLogResponse {
|
|||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
pages: number;
|
pages: number;
|
||||||
|
notice?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyAuditLogResponse(page: number, notice?: string): AuditLogResponse {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page,
|
||||||
|
pages: 0,
|
||||||
|
...(notice !== undefined ? { notice } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRateLimitError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /429|rate limit|too many requests/i.test(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
@@ -138,7 +157,15 @@ async function fetchAuditLog(
|
|||||||
params.set("sessionId", normalizedSessionId);
|
params.set("sessionId", normalizedSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiGet<AuditLogResponse>(`/api/mission-control/audit-log?${params.toString()}`);
|
try {
|
||||||
|
return await apiGet<AuditLogResponse>(`/api/mission-control/audit-log?${params.toString()}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (isRateLimitError(error)) {
|
||||||
|
return createEmptyAuditLogResponse(page, "Rate limited - retrying...");
|
||||||
|
}
|
||||||
|
|
||||||
|
return createEmptyAuditLogResponse(page);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): React.JSX.Element {
|
export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): React.JSX.Element {
|
||||||
@@ -180,11 +207,10 @@ export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): Rea
|
|||||||
const totalItems = auditLogQuery.data?.total ?? 0;
|
const totalItems = auditLogQuery.data?.total ?? 0;
|
||||||
const totalPages = auditLogQuery.data?.pages ?? 0;
|
const totalPages = auditLogQuery.data?.pages ?? 0;
|
||||||
const items = auditLogQuery.data?.items ?? [];
|
const items = auditLogQuery.data?.items ?? [];
|
||||||
|
const notice = auditLogQuery.data?.notice;
|
||||||
|
|
||||||
const canGoPrevious = page > 1;
|
const canGoPrevious = page > 1;
|
||||||
const canGoNext = totalPages > 0 && page < totalPages;
|
const canGoNext = totalPages > 0 && page < totalPages;
|
||||||
const errorMessage =
|
|
||||||
auditLogQuery.error instanceof Error ? auditLogQuery.error.message : "Failed to load audit log";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
@@ -237,10 +263,10 @@ export function AuditLogDrawer({ sessionId, trigger }: AuditLogDrawerProps): Rea
|
|||||||
Loading audit log...
|
Loading audit log...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : auditLogQuery.error ? (
|
) : notice ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-3 py-6 text-center text-sm text-red-500">
|
<td colSpan={5} className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
{errorMessage}
|
{notice}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface FleetSettingsLink {
|
|||||||
|
|
||||||
const FLEET_SETTINGS_LINKS: FleetSettingsLink[] = [
|
const FLEET_SETTINGS_LINKS: FleetSettingsLink[] = [
|
||||||
{ href: "/settings/providers", label: "Providers" },
|
{ href: "/settings/providers", label: "Providers" },
|
||||||
|
{ href: "/settings/agent-providers", label: "Agent Providers" },
|
||||||
{ href: "/settings/agent-config", label: "Agent Config" },
|
{ href: "/settings/agent-config", label: "Agent Config" },
|
||||||
{ href: "/settings/auth", label: "Authentication" },
|
{ href: "/settings/auth", label: "Authentication" },
|
||||||
];
|
];
|
||||||
|
|||||||
79
apps/web/src/lib/api/agent-providers.test.ts
Normal file
79
apps/web/src/lib/api/agent-providers.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import * as client from "./client";
|
||||||
|
import {
|
||||||
|
createAgentProvider,
|
||||||
|
deleteAgentProvider,
|
||||||
|
fetchAgentProviders,
|
||||||
|
updateAgentProvider,
|
||||||
|
} from "./agent-providers";
|
||||||
|
|
||||||
|
vi.mock("./client");
|
||||||
|
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchAgentProviders", (): void => {
|
||||||
|
it("calls provider list endpoint", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
|
||||||
|
|
||||||
|
await fetchAgentProviders();
|
||||||
|
|
||||||
|
expect(client.apiGet).toHaveBeenCalledWith("/api/agent-providers");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createAgentProvider", (): void => {
|
||||||
|
it("posts create payload", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiPost).mockResolvedValueOnce({ id: "provider-1" } as never);
|
||||||
|
|
||||||
|
await createAgentProvider({
|
||||||
|
name: "openclaw-primary",
|
||||||
|
provider: "openclaw",
|
||||||
|
gatewayUrl: "https://openclaw.example.com",
|
||||||
|
credentials: {
|
||||||
|
apiToken: "top-secret",
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.apiPost).toHaveBeenCalledWith("/api/agent-providers", {
|
||||||
|
name: "openclaw-primary",
|
||||||
|
provider: "openclaw",
|
||||||
|
gatewayUrl: "https://openclaw.example.com",
|
||||||
|
credentials: {
|
||||||
|
apiToken: "top-secret",
|
||||||
|
},
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateAgentProvider", (): void => {
|
||||||
|
it("sends PUT request with update payload", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiRequest).mockResolvedValueOnce({ id: "provider-1" } as never);
|
||||||
|
|
||||||
|
await updateAgentProvider("provider-1", {
|
||||||
|
gatewayUrl: "https://new-openclaw.example.com",
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.apiRequest).toHaveBeenCalledWith("/api/agent-providers/provider-1", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
gatewayUrl: "https://new-openclaw.example.com",
|
||||||
|
isActive: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteAgentProvider", (): void => {
|
||||||
|
it("calls delete endpoint", async (): Promise<void> => {
|
||||||
|
vi.mocked(client.apiDelete).mockResolvedValueOnce(undefined as never);
|
||||||
|
|
||||||
|
await deleteAgentProvider("provider-1");
|
||||||
|
|
||||||
|
expect(client.apiDelete).toHaveBeenCalledWith("/api/agent-providers/provider-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
61
apps/web/src/lib/api/agent-providers.ts
Normal file
61
apps/web/src/lib/api/agent-providers.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { apiDelete, apiGet, apiPost, apiRequest } from "./client";
|
||||||
|
|
||||||
|
export type AgentProviderType = "openclaw";
|
||||||
|
|
||||||
|
export interface AgentProviderCredentials {
|
||||||
|
apiToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentProviderConfig {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
provider: AgentProviderType;
|
||||||
|
gatewayUrl: string;
|
||||||
|
credentials: AgentProviderCredentials | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAgentProviderRequest {
|
||||||
|
name: string;
|
||||||
|
provider: AgentProviderType;
|
||||||
|
gatewayUrl: string;
|
||||||
|
credentials: {
|
||||||
|
apiToken: string;
|
||||||
|
};
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAgentProviderRequest {
|
||||||
|
name?: string;
|
||||||
|
provider?: AgentProviderType;
|
||||||
|
gatewayUrl?: string;
|
||||||
|
credentials?: AgentProviderCredentials;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAgentProviders(): Promise<AgentProviderConfig[]> {
|
||||||
|
return apiGet<AgentProviderConfig[]>("/api/agent-providers");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAgentProvider(
|
||||||
|
data: CreateAgentProviderRequest
|
||||||
|
): Promise<AgentProviderConfig> {
|
||||||
|
return apiPost<AgentProviderConfig>("/api/agent-providers", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAgentProvider(
|
||||||
|
providerId: string,
|
||||||
|
data: UpdateAgentProviderRequest
|
||||||
|
): Promise<AgentProviderConfig> {
|
||||||
|
return apiRequest<AgentProviderConfig>(`/api/agent-providers/${providerId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAgentProvider(providerId: string): Promise<void> {
|
||||||
|
await apiDelete<unknown>(`/api/agent-providers/${providerId}`);
|
||||||
|
}
|
||||||
@@ -18,4 +18,5 @@ export * from "./projects";
|
|||||||
export * from "./workspaces";
|
export * from "./workspaces";
|
||||||
export * from "./admin";
|
export * from "./admin";
|
||||||
export * from "./fleet-settings";
|
export * from "./fleet-settings";
|
||||||
|
export * from "./agent-providers";
|
||||||
export * from "./activity";
|
export * from "./activity";
|
||||||
|
|||||||
@@ -62,6 +62,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Updated Makefile with Traefik deployment shortcuts
|
- Updated Makefile with Traefik deployment shortcuts
|
||||||
- Enhanced docker-compose.override.yml.example with Traefik examples
|
- Enhanced docker-compose.override.yml.example with Traefik examples
|
||||||
|
|
||||||
|
## [0.0.23] - 2026-03-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Mission Control Dashboard** — real-time agent orchestration UI at `/mission-control`
|
||||||
|
- Live SSE message streams per agent (`OrchestratorPanel`)
|
||||||
|
- Barge-in input with optional pause-before-send
|
||||||
|
- Pause / Resume / Graceful Kill / Force Kill controls per agent panel
|
||||||
|
- Global agent roster sidebar with tree view and per-agent kill
|
||||||
|
- KillAllDialog with scope selector (requires typing `KILL ALL` to confirm)
|
||||||
|
- AuditLogDrawer with paginated operator action history
|
||||||
|
- Responsive panel grid: up to 6 panels, add/remove, full-screen expand
|
||||||
|
- **Agent Provider Interface** — extensible `IAgentProvider` plugin system
|
||||||
|
- `InternalAgentProvider` wrapping existing orchestrator services
|
||||||
|
- `AgentProviderRegistry` aggregating sessions across providers
|
||||||
|
- `AgentProviderConfig` CRUD API (`/api/agent-providers`)
|
||||||
|
- Mission Control proxy API (`/api/mission-control/*`) with SSE proxying and audit log
|
||||||
|
- **OpenClaw Provider Adapter** — connect external OpenClaw instances
|
||||||
|
- `OpenClawProvider` implementing `IAgentProvider` against OpenClaw REST API
|
||||||
|
- Dedicated `OpenClawSseBridge` with retry logic (5 retries, 2s backoff)
|
||||||
|
- Provider config UI in Settings for registering OpenClaw gateways
|
||||||
|
- Tokens encrypted at rest via `EncryptionService` (AES-256-GCM)
|
||||||
|
- **OperatorAuditLog** — every inject/pause/resume/kill persisted to DB
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Orchestrator app: extended with `AgentsModule` exports for provider registry
|
||||||
|
- Settings navigation: added "Agent Providers" section
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Flaky web tests: async query timing in Kanban and OnboardingWizard tests
|
||||||
|
|
||||||
## [0.0.1] - 2026-01-28
|
## [0.0.1] - 2026-01-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -79,5 +112,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Documentation structure (Bookstack-compatible hierarchy)
|
- Documentation structure (Bookstack-compatible hierarchy)
|
||||||
- Development workflow and coding standards
|
- Development workflow and coding standards
|
||||||
|
|
||||||
[Unreleased]: https://git.mosaicstack.dev/mosaic/stack/compare/v0.0.1...HEAD
|
[Unreleased]: https://git.mosaicstack.dev/mosaic/stack/compare/v0.0.23...HEAD
|
||||||
|
[0.0.23]: https://git.mosaicstack.dev/mosaic/stack/releases/tag/v0.0.23
|
||||||
[0.0.1]: https://git.mosaicstack.dev/mosaic/stack/releases/tag/v0.0.1
|
[0.0.1]: https://git.mosaicstack.dev/mosaic/stack/releases/tag/v0.0.1
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Mosaic Stack Roadmap
|
# Mosaic Stack Roadmap
|
||||||
|
|
||||||
**Last Updated:** 2026-01-29
|
**Last Updated:** 2026-03-07
|
||||||
**Authoritative Source:** [Issues & Milestones](https://git.mosaicstack.dev/mosaic/stack/issues)
|
**Authoritative Source:** [Issues & Milestones](https://git.mosaicstack.dev/mosaic/stack/issues)
|
||||||
|
|
||||||
## Versioning Policy
|
## Versioning Policy
|
||||||
@@ -12,6 +12,20 @@
|
|||||||
| `0.x.y` | Pre-stable iteration, API may change with notice |
|
| `0.x.y` | Pre-stable iteration, API may change with notice |
|
||||||
| `1.0.0` | Stable release, public API contract |
|
| `1.0.0` | Stable release, public API contract |
|
||||||
|
|
||||||
|
## Release Track (Current)
|
||||||
|
|
||||||
|
### ✅ v0.0.23 — Mission Control Dashboard (Complete)
|
||||||
|
|
||||||
|
- Mission Control dashboard shipped at `/mission-control`
|
||||||
|
- Agent provider plugin system and Mission Control proxy API shipped
|
||||||
|
- OpenClaw provider adapter shipped with encrypted token storage
|
||||||
|
- Operator audit logging persisted for inject/pause/resume/kill actions
|
||||||
|
|
||||||
|
### 📋 v0.0.24 — Placeholder
|
||||||
|
|
||||||
|
- Scope TBD (to be defined after v0.0.23 production deployment)
|
||||||
|
- Initial release notes and roadmap breakdown pending
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Milestone Overview
|
## Milestone Overview
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mosaic-stack",
|
"name": "mosaic-stack",
|
||||||
"version": "0.0.20",
|
"version": "0.0.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.19.0",
|
"packageManager": "pnpm@10.19.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user