Compare commits
6 Commits
chore/ms22
...
fix/fleet-
| Author | SHA1 | Date | |
|---|---|---|---|
| e85df938a5 | |||
| 99a4567e32 | |||
| 559c6b3831 | |||
| 631e5010b5 | |||
| 09e377ecd7 | |||
| deafcdc84b |
@@ -1,4 +1,5 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { AgentConfigModule } from "../agent-config/agent-config.module";
|
import { AgentConfigModule } from "../agent-config/agent-config.module";
|
||||||
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
|
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
@@ -6,7 +7,7 @@ import { ChatProxyController } from "./chat-proxy.controller";
|
|||||||
import { ChatProxyService } from "./chat-proxy.service";
|
import { ChatProxyService } from "./chat-proxy.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, ContainerLifecycleModule, AgentConfigModule],
|
imports: [AuthModule, PrismaModule, ContainerLifecycleModule, AgentConfigModule],
|
||||||
controllers: [ChatProxyController],
|
controllers: [ChatProxyController],
|
||||||
providers: [ChatProxyService],
|
providers: [ChatProxyService],
|
||||||
exports: [ChatProxyService],
|
exports: [ChatProxyService],
|
||||||
|
|||||||
@@ -87,6 +87,17 @@ describe("CsrfGuard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("State-changing methods requiring CSRF", () => {
|
describe("State-changing methods requiring CSRF", () => {
|
||||||
|
it("should allow POST with Bearer auth without CSRF token", () => {
|
||||||
|
const context = createContext(
|
||||||
|
"POST",
|
||||||
|
{},
|
||||||
|
{ authorization: "Bearer api-token" },
|
||||||
|
false,
|
||||||
|
"user-123"
|
||||||
|
);
|
||||||
|
expect(guard.canActivate(context)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("should reject POST without CSRF token", () => {
|
it("should reject POST without CSRF token", () => {
|
||||||
const context = createContext("POST", {}, {}, false, "user-123");
|
const context = createContext("POST", {}, {}, false, "user-123");
|
||||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export class CsrfGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authHeader = request.headers.authorization;
|
||||||
|
if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Get CSRF token from cookie and header
|
// Get CSRF token from cookie and header
|
||||||
const cookies = request.cookies as Record<string, string> | undefined;
|
const cookies = request.cookies as Record<string, string> | undefined;
|
||||||
const cookieToken = cookies?.["csrf-token"];
|
const cookieToken = cookies?.["csrf-token"];
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigModule } from "@nestjs/config";
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
import { CryptoModule } from "../crypto/crypto.module";
|
import { CryptoModule } from "../crypto/crypto.module";
|
||||||
import { ContainerLifecycleService } from "./container-lifecycle.service";
|
import { ContainerLifecycleService } from "./container-lifecycle.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, CryptoModule],
|
imports: [ConfigModule, PrismaModule, CryptoModule],
|
||||||
providers: [ContainerLifecycleService],
|
providers: [ContainerLifecycleService],
|
||||||
exports: [ContainerLifecycleService],
|
exports: [ContainerLifecycleService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { PrismaModule } from "../prisma/prisma.module";
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
import { CryptoModule } from "../crypto/crypto.module";
|
import { CryptoModule } from "../crypto/crypto.module";
|
||||||
import { FleetSettingsController } from "./fleet-settings.controller";
|
import { FleetSettingsController } from "./fleet-settings.controller";
|
||||||
import { FleetSettingsService } from "./fleet-settings.service";
|
import { FleetSettingsService } from "./fleet-settings.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, CryptoModule],
|
imports: [AuthModule, PrismaModule, CryptoModule],
|
||||||
controllers: [FleetSettingsController],
|
controllers: [FleetSettingsController],
|
||||||
providers: [FleetSettingsService],
|
providers: [FleetSettingsService],
|
||||||
exports: [FleetSettingsService],
|
exports: [FleetSettingsService],
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ const INITIAL_FORM: ProviderFormState = {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function mapProviderTypeToApi(type: string): "ollama" | "openai" | "claude" {
|
||||||
|
if (type === "ollama" || type === "claude") {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "openai";
|
||||||
|
}
|
||||||
|
|
||||||
function getErrorMessage(error: unknown, fallback: string): string {
|
function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
if (error instanceof Error && error.message.trim().length > 0) {
|
if (error instanceof Error && error.message.trim().length > 0) {
|
||||||
return error.message;
|
return error.message;
|
||||||
@@ -93,18 +101,6 @@ function getErrorMessage(error: unknown, fallback: string): string {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProviderName(displayName: string, type: string): string {
|
|
||||||
const slug = displayName
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+/, "")
|
|
||||||
.replace(/-+$/, "");
|
|
||||||
|
|
||||||
const candidate = `${type}-${slug.length > 0 ? slug : "provider"}`;
|
|
||||||
return candidate.slice(0, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProviderModels(models: unknown): FleetProviderModel[] {
|
function normalizeProviderModels(models: unknown): FleetProviderModel[] {
|
||||||
if (!Array.isArray(models)) {
|
if (!Array.isArray(models)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -153,11 +149,11 @@ function modelsToEditorText(models: unknown): string {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseModelsText(value: string): FleetProviderModel[] {
|
function parseModelsText(value: string): string[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
return value
|
return value
|
||||||
.split(/\n|,/g)
|
.split(/\r?\n/g)
|
||||||
.map((segment) => segment.trim())
|
.map((segment) => segment.trim())
|
||||||
.filter((segment) => segment.length > 0)
|
.filter((segment) => segment.length > 0)
|
||||||
.filter((segment) => {
|
.filter((segment) => {
|
||||||
@@ -166,8 +162,7 @@ function parseModelsText(value: string): FleetProviderModel[] {
|
|||||||
}
|
}
|
||||||
seen.add(segment);
|
seen.add(segment);
|
||||||
return true;
|
return true;
|
||||||
})
|
});
|
||||||
.map((id) => ({ id, name: id }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function maskApiKey(value: string): string {
|
function maskApiKey(value: string): string {
|
||||||
@@ -279,6 +274,7 @@ export default function ProvidersSettingsPage(): ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const models = parseModelsText(form.modelsText);
|
const models = parseModelsText(form.modelsText);
|
||||||
|
const providerModels = models.map((id) => ({ id, name: id }));
|
||||||
const baseUrl = form.baseUrl.trim();
|
const baseUrl = form.baseUrl.trim();
|
||||||
const apiKey = form.apiKey.trim();
|
const apiKey = form.apiKey.trim();
|
||||||
|
|
||||||
@@ -289,7 +285,7 @@ export default function ProvidersSettingsPage(): ReactElement {
|
|||||||
const updatePayload: UpdateFleetProviderRequest = {
|
const updatePayload: UpdateFleetProviderRequest = {
|
||||||
displayName,
|
displayName,
|
||||||
isActive: form.isActive,
|
isActive: form.isActive,
|
||||||
models,
|
models: providerModels,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (baseUrl.length > 0) {
|
if (baseUrl.length > 0) {
|
||||||
@@ -303,21 +299,27 @@ export default function ProvidersSettingsPage(): ReactElement {
|
|||||||
await updateFleetProvider(editingProvider.id, updatePayload);
|
await updateFleetProvider(editingProvider.id, updatePayload);
|
||||||
setSuccessMessage(`Updated provider "${displayName}".`);
|
setSuccessMessage(`Updated provider "${displayName}".`);
|
||||||
} else {
|
} else {
|
||||||
const createPayload: CreateFleetProviderRequest = {
|
const config: CreateFleetProviderRequest["config"] = {};
|
||||||
name: buildProviderName(displayName, form.type),
|
|
||||||
displayName,
|
|
||||||
type: form.type,
|
|
||||||
models,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (baseUrl.length > 0) {
|
if (baseUrl.length > 0) {
|
||||||
createPayload.baseUrl = baseUrl;
|
config.endpoint = baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiKey.length > 0) {
|
if (apiKey.length > 0) {
|
||||||
createPayload.apiKey = apiKey;
|
config.apiKey = apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (models.length > 0) {
|
||||||
|
config.models = models;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPayload: CreateFleetProviderRequest = {
|
||||||
|
displayName,
|
||||||
|
providerType: mapProviderTypeToApi(form.type),
|
||||||
|
config,
|
||||||
|
isEnabled: form.isActive,
|
||||||
|
};
|
||||||
|
|
||||||
await createFleetProvider(createPayload);
|
await createFleetProvider(createPayload);
|
||||||
setSuccessMessage(`Added provider "${displayName}".`);
|
setSuccessMessage(`Added provider "${displayName}".`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,17 +34,25 @@ describe("createFleetProvider", (): void => {
|
|||||||
vi.mocked(client.apiPost).mockResolvedValueOnce({ id: "provider-1" } as never);
|
vi.mocked(client.apiPost).mockResolvedValueOnce({ id: "provider-1" } as never);
|
||||||
|
|
||||||
await createFleetProvider({
|
await createFleetProvider({
|
||||||
name: "openai-main",
|
providerType: "openai",
|
||||||
displayName: "OpenAI Main",
|
displayName: "OpenAI Main",
|
||||||
type: "openai",
|
config: {
|
||||||
apiKey: "sk-test",
|
endpoint: "https://api.openai.com/v1",
|
||||||
|
apiKey: "sk-test",
|
||||||
|
models: ["gpt-4.1-mini", "gpt-4o-mini"],
|
||||||
|
},
|
||||||
|
isEnabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(client.apiPost).toHaveBeenCalledWith("/api/fleet-settings/providers", {
|
expect(client.apiPost).toHaveBeenCalledWith("/api/fleet-settings/providers", {
|
||||||
name: "openai-main",
|
providerType: "openai",
|
||||||
displayName: "OpenAI Main",
|
displayName: "OpenAI Main",
|
||||||
type: "openai",
|
config: {
|
||||||
apiKey: "sk-test",
|
endpoint: "https://api.openai.com/v1",
|
||||||
|
apiKey: "sk-test",
|
||||||
|
models: ["gpt-4.1-mini", "gpt-4o-mini"],
|
||||||
|
},
|
||||||
|
isEnabled: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,13 +16,16 @@ export interface FleetProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateFleetProviderRequest {
|
export interface CreateFleetProviderRequest {
|
||||||
name: string;
|
providerType: "ollama" | "openai" | "claude";
|
||||||
displayName: string;
|
displayName: string;
|
||||||
type: string;
|
config: {
|
||||||
baseUrl?: string;
|
endpoint?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
apiType?: string;
|
models?: string[];
|
||||||
models?: FleetProviderModel[];
|
timeout?: number;
|
||||||
|
};
|
||||||
|
isDefault?: boolean;
|
||||||
|
isEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateFleetProviderRequest {
|
export interface UpdateFleetProviderRequest {
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ services:
|
|||||||
OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT}
|
OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT}
|
||||||
OPENBAO_ADDR: ${OPENBAO_ADDR}
|
OPENBAO_ADDR: ${OPENBAO_ADDR}
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||||
|
# MS22: fleet encryption key (AES-256-GCM for provider API keys, agent tokens)
|
||||||
|
MOSAIC_SECRET_KEY: ${MOSAIC_SECRET_KEY}
|
||||||
|
# MS22: Docker socket for per-user container lifecycle (optional: set DOCKER_HOST for TCP)
|
||||||
|
DOCKER_HOST: ${DOCKER_HOST:-}
|
||||||
# Matrix bridge (optional — configure after Synapse is running)
|
# Matrix bridge (optional — configure after Synapse is running)
|
||||||
MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL:-http://synapse:8008}
|
MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL:-http://synapse:8008}
|
||||||
MATRIX_ACCESS_TOKEN: ${MATRIX_ACCESS_TOKEN:-}
|
MATRIX_ACCESS_TOKEN: ${MATRIX_ACCESS_TOKEN:-}
|
||||||
@@ -142,6 +146,8 @@ services:
|
|||||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
|
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
|
||||||
TRUSTED_ORIGINS: ${TRUSTED_ORIGINS:-}
|
TRUSTED_ORIGINS: ${TRUSTED_ORIGINS:-}
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -76,16 +76,16 @@ Remaining estimate: ~143K tokens (Codex budget).
|
|||||||
|
|
||||||
Design doc: `docs/design/MS22-DB-CENTRIC-ARCHITECTURE.md`
|
Design doc: `docs/design/MS22-DB-CENTRIC-ARCHITECTURE.md`
|
||||||
|
|
||||||
| Task ID | Status | Phase | Description | Issue | Scope | Branch | Depends On | Blocks | Assigned Worker | Started | Completed | Est Tokens | Act Tokens | Notes |
|
| Task ID | Status | Phase | Description | Issue | Scope | Branch | Depends On | Blocks | Assigned Worker | Started | Completed | Est Tokens | Act Tokens | Notes |
|
||||||
| -------- | ----------- | -------- | --------------------------------------------------------------------------------------------------------------------- | ----- | ------- | ---------------------------- | ---------- | --------------- | --------------- | ------- | --------- | ---------- | ---------- | ----- |
|
| -------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------- | ----- | ------- | ---------------------------- | ---------- | --------------- | --------------- | ------- | --------- | ---------- | ---------- | ----- |
|
||||||
| MS22-P1a | done | phase-1a | Prisma schema: SystemConfig, BreakglassUser, LlmProvider, UserContainer, SystemContainer, UserAgentConfig + migration | — | api | feat/ms22-p1a-schema | — | P1b,P1c,P1d,P1e | — | — | — | 20K | — | |
|
| MS22-P1a | done | phase-1a | Prisma schema: SystemConfig, BreakglassUser, LlmProvider, UserContainer, SystemContainer, UserAgentConfig + migration | — | api | feat/ms22-p1a-schema | — | P1b,P1c,P1d,P1e | — | — | — | 20K | — | |
|
||||||
| MS22-P1b | done | phase-1b | Encryption service (AES-256-GCM) for API keys and tokens | — | api | feat/ms22-p1b-crypto | — | P1c,P1e,P1g | — | — | — | 15K | — | |
|
| MS22-P1b | done | phase-1b | Encryption service (AES-256-GCM) for API keys and tokens | — | api | feat/ms22-p1b-crypto | — | P1c,P1e,P1g | — | — | — | 15K | — | |
|
||||||
| MS22-P1c | not-started | phase-1c | Internal config endpoint: assemble openclaw.json from DB | — | api | feat/ms22-p1c-config-api | P1a,P1b | P1i,P1j | — | — | — | 20K | — | |
|
| MS22-P1c | done | phase-1c | Internal config endpoint: assemble openclaw.json from DB | — | api | feat/ms22-p1c-config-api | P1a,P1b | P1i,P1j | — | — | — | 20K | — | |
|
||||||
| MS22-P1d | not-started | phase-1d | ContainerLifecycleService: Docker API (dockerode) start/stop/health/reap | — | api | feat/ms22-p1d-container-mgr | P1a | P1i,P1k | — | — | — | 25K | — | |
|
| MS22-P1d | done | phase-1d | ContainerLifecycleService: Docker API (dockerode) start/stop/health/reap | — | api | feat/ms22-p1d-container-mgr | P1a | P1i,P1k | — | — | — | 25K | — | |
|
||||||
| MS22-P1e | not-started | phase-1e | Onboarding API: breakglass, OIDC, provider, agents, complete | — | api | feat/ms22-p1e-onboarding-api | P1a,P1b | P1f | — | — | — | 20K | — | |
|
| MS22-P1e | done | phase-1e | Onboarding API: breakglass, OIDC, provider, agents, complete | — | api | feat/ms22-p1e-onboarding-api | P1a,P1b | P1f | — | — | — | 20K | — | |
|
||||||
| MS22-P1f | not-started | phase-1f | Onboarding wizard WebUI (multi-step form) | — | web | feat/ms22-p1f-onboarding-ui | P1e | — | — | — | — | 25K | — | |
|
| MS22-P1f | done | phase-1f | Onboarding wizard WebUI (multi-step form) | — | web | feat/ms22-p1f-onboarding-ui | P1e | — | — | — | — | 25K | — | |
|
||||||
| MS22-P1g | not-started | phase-1g | Settings API: CRUD providers, agent config, OIDC, breakglass | — | api | feat/ms22-p1g-settings-api | P1a,P1b | P1h | — | — | — | 20K | — | |
|
| MS22-P1g | done | phase-1g | Settings API: CRUD providers, agent config, OIDC, breakglass | — | api | feat/ms22-p1g-settings-api | P1a,P1b | P1h | — | — | — | 20K | — | |
|
||||||
| MS22-P1h | not-started | phase-1h | Settings UI: Providers, Agent Config, Auth pages | — | web | feat/ms22-p1h-settings-ui | P1g | — | — | — | — | 25K | — | |
|
| MS22-P1h | done | phase-1h | Settings UI: Providers, Agent Config, Auth pages | — | web | feat/ms22-p1h-settings-ui | P1g | — | — | — | — | 25K | — | |
|
||||||
| MS22-P1i | not-started | phase-1i | Chat proxy: route WebUI chat to user's OpenClaw container (SSE) | — | api+web | feat/ms22-p1i-chat-proxy | P1c,P1d | — | — | — | — | 20K | — | |
|
| MS22-P1i | done | phase-1i | Chat proxy: route WebUI chat to user's OpenClaw container (SSE) | — | api+web | feat/ms22-p1i-chat-proxy | P1c,P1d | — | — | — | — | 20K | — | |
|
||||||
| MS22-P1j | not-started | phase-1j | Docker entrypoint + health checks + core compose | — | docker | feat/ms22-p1j-docker | P1c | — | — | — | — | 10K | — | |
|
| MS22-P1j | done | phase-1j | Docker entrypoint + health checks + core compose | — | docker | feat/ms22-p1j-docker | P1c | — | — | — | — | 10K | — | |
|
||||||
| MS22-P1k | not-started | phase-1k | Idle reaper cron: stop inactive user containers | — | api | feat/ms22-p1k-idle-reaper | P1d | — | — | — | — | 10K | — | |
|
| MS22-P1k | done | phase-1k | Idle reaper cron: stop inactive user containers | — | api | feat/ms22-p1k-idle-reaper | P1d | — | — | — | — | 10K | — | |
|
||||||
|
|||||||
Reference in New Issue
Block a user