Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@

# Database
DATABASE_URL="postgresql://clawix:clawix_dev@localhost:5433/clawix?schema=public"
# Production: set POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB env vars
# Production (docker-compose.prod.yml): these are interpolated at compose-parse
# time, so they must live in .env (not just env_file). POSTGRES_PASSWORD is
# required — `docker compose -f docker-compose.prod.yml build` fails without it.
# POSTGRES_USER and POSTGRES_DB default to "clawix" if unset.
# POSTGRES_USER=clawix
# POSTGRES_PASSWORD=change-me-strong-secret
# POSTGRES_DB=clawix

# Redis
REDIS_URL="redis://localhost:6379"
Expand Down
1 change: 1 addition & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ services:
- ./packages/api/prisma.config.ts:/app/packages/api/prisma.config.ts
- ./packages/api/package.json:/app/packages/api/package.json
- ./packages/api/tsconfig.json:/app/packages/api/tsconfig.json
- ./packages/api/nest-cli.json:/app/packages/api/nest-cli.json
- ./packages/shared/src:/app/packages/shared/src
- ./packages/shared/package.json:/app/packages/shared/package.json
- ./packages/shared/tsconfig.json:/app/packages/shared/tsconfig.json
Expand Down
2 changes: 2 additions & 0 deletions packages/api/nest-cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"assets": ["**/*.md"],
"watchAssets": true,
"tsConfigPath": "tsconfig.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Policy" ADD COLUMN "maxSubAgentRunMs" INTEGER NOT NULL DEFAULT 300000;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SessionMessage" ADD COLUMN "hiddenInHistory" BOOLEAN NOT NULL DEFAULT false;
12 changes: 9 additions & 3 deletions packages/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ model Policy {
maxPythonTimeoutSecs Int @default(60)
maxPythonCpuCores Int @default(1)
maxConcurrentPythonRuns Int @default(2)
maxSubAgentRunMs Int @default(300000) // wall-clock cap per spawned sub-agent run (ms)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down Expand Up @@ -347,6 +348,11 @@ model SessionMessage {
ordering Int
createdAt DateTime @default(now())
archivedAt DateTime?
// When true, this message is omitted from the chat-history display endpoint.
// Set on intermediate reasoning steps of a non-streamed run so reopened
// history mirrors the single combined reply the user saw live. The row is
// still persisted in full (needed to reconstruct conversation context).
hiddenInHistory Boolean @default(false)

session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)

Expand Down Expand Up @@ -517,9 +523,9 @@ model WikiShare {
revokedAt DateTime?
isRevoked Boolean @default(false)

page WikiPage @relation(fields: [pageId], references: [id], onDelete: Cascade)
group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade)
sharedByUser User? @relation("WikiSharedBy", fields: [sharedBy], references: [id], onDelete: SetNull)
page WikiPage @relation(fields: [pageId], references: [id], onDelete: Cascade)
group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade)
sharedByUser User? @relation("WikiSharedBy", fields: [sharedBy], references: [id], onDelete: SetNull)

@@index([pageId, isRevoked])
@@index([groupId, isRevoked])
Expand Down
6 changes: 6 additions & 0 deletions packages/api/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ async function main(): Promise<void> {
maxPythonTimeoutSecs: 60,
maxPythonCpuCores: 1,
maxConcurrentPythonRuns: 2,
maxSubAgentRunMs: 300000, // 5 min
},
create: {
name: 'Standard',
Expand All @@ -149,6 +150,7 @@ async function main(): Promise<void> {
maxPythonTimeoutSecs: 60,
maxPythonCpuCores: 1,
maxConcurrentPythonRuns: 2,
maxSubAgentRunMs: 300000, // 5 min
},
});
console.log(` Policy: ${standardPolicy.name}`);
Expand All @@ -163,6 +165,7 @@ async function main(): Promise<void> {
maxPythonTimeoutSecs: 300,
maxPythonCpuCores: 2,
maxConcurrentPythonRuns: 3,
maxSubAgentRunMs: 480000, // 8 min
},
create: {
name: 'Extended',
Expand All @@ -181,6 +184,7 @@ async function main(): Promise<void> {
maxPythonTimeoutSecs: 300,
maxPythonCpuCores: 2,
maxConcurrentPythonRuns: 3,
maxSubAgentRunMs: 480000, // 8 min
},
});
console.log(` Policy: ${extendedPolicy.name}`);
Expand All @@ -195,6 +199,7 @@ async function main(): Promise<void> {
maxPythonTimeoutSecs: 600,
maxPythonCpuCores: 4,
maxConcurrentPythonRuns: 5,
maxSubAgentRunMs: 540000, // 9 min (kept under the 10-min stale-run reaper)
},
create: {
name: 'Unrestricted',
Expand All @@ -213,6 +218,7 @@ async function main(): Promise<void> {
maxPythonTimeoutSecs: 600,
maxPythonCpuCores: 4,
maxConcurrentPythonRuns: 5,
maxSubAgentRunMs: 540000, // 9 min (kept under the 10-min stale-run reaper)
},
});
console.log(` Policy: ${unrestrictedPolicy.name}`);
Expand Down
60 changes: 60 additions & 0 deletions packages/api/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,66 @@ async function main(): Promise<void> {
}));
console.log(`[bootstrap] Primary agent: ${primaryAgent.name}`);

// --- Named workers (coder, researcher) — created only if missing ---
// These mirror the dev `seed.ts` workers so that production deployments
// (which run bootstrap, not the seed) can spawn named sub-agents. Skills
// such as projector-creator spawn `agent_name="coder"`; without this row
// the named spawn fails and silently falls back to the anonymous
// default-worker (losing the worker's specialized system prompt).
const namedWorkers = [
{
name: 'coder',
description: 'Writes, reviews, and tests code — optimized for code generation',
systemPrompt:
'You are a skilled software engineer. Write clean, complete, functional code. Never use placeholders or TODO comments. Always verify your output is complete. Use the tools available to read, write, and execute code in the workspace.',
maxTokensPerRun: 100000,
containerConfig: {
image: process.env['AGENT_CONTAINER_IMAGE'] ?? 'clawix-agent:latest',
cpuLimit: '1',
memoryLimit: '512m',
timeoutSeconds: 300,
readOnlyRootfs: false,
allowedMounts: [],
},
},
{
name: 'researcher',
description: 'Searches the web and summarizes findings',
systemPrompt:
'You are a research specialist. Search the web for information, analyze sources, and provide clear, well-organized summaries with citations.',
maxTokensPerRun: 50000,
containerConfig: {
image: process.env['AGENT_CONTAINER_IMAGE'] ?? 'clawix-agent:latest',
cpuLimit: '0.5',
memoryLimit: '256m',
timeoutSeconds: 120,
readOnlyRootfs: true,
allowedMounts: [],
},
},
];
for (const worker of namedWorkers) {
const existingWorker = await prisma.agentDefinition.findFirst({
where: { name: worker.name, role: 'worker' },
});
if (!existingWorker) {
await prisma.agentDefinition.create({
data: {
name: worker.name,
description: worker.description,
systemPrompt: worker.systemPrompt,
role: 'worker',
provider: defaultProvider,
model: defaultModel,
maxTokensPerRun: worker.maxTokensPerRun,
containerConfig: worker.containerConfig,
isActive: true,
},
});
console.log(`[bootstrap] Worker seeded: ${worker.name}`);
}
}

// --- Default worker (only if none exists) ---
const existingDefaultWorker = await prisma.agentDefinition.findFirst({
where: { name: 'default-worker', role: 'worker' },
Expand Down
20 changes: 19 additions & 1 deletion packages/api/src/channels/web/__tests__/web.gateway.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { IncomingMessage } from 'node:http';
import { WebChatGateway } from '../web.gateway.js';
import { WebChatGateway, isWsOriginAllowed } from '../web.gateway.js';

// Mock logger
vi.mock('@clawix/shared', async (importOriginal) => {
Expand Down Expand Up @@ -62,6 +62,24 @@ describe('WebChatGateway', () => {
gateway.setAdapter(mockAdapter as never);
});

describe('isWsOriginAllowed', () => {
const allowed = ['http://localhost:3000', 'https://app.example.com'];

it('allows an origin present in the allowlist', () => {
expect(isWsOriginAllowed('http://localhost:3000', allowed)).toBe(true);
expect(isWsOriginAllowed('https://app.example.com', allowed)).toBe(true);
});

it('rejects an origin not in the allowlist (cross-site)', () => {
expect(isWsOriginAllowed('https://evil.example.com', allowed)).toBe(false);
});

it('allows a missing or empty Origin header (non-browser clients)', () => {
expect(isWsOriginAllowed(undefined, allowed)).toBe(true);
expect(isWsOriginAllowed('', allowed)).toBe(true);
});
});

describe('handleConnection — valid JWT', () => {
it('calls adapter.addConnection and sends connection.ack', async () => {
const payload = { sub: 'user-1', email: 'test@example.com', role: 'developer' };
Expand Down
26 changes: 26 additions & 0 deletions packages/api/src/channels/web/web.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,22 @@ import { createLogger } from '@clawix/shared';

import type { WebAdapterExtended } from './web.adapter.js';
import { serializeServerMessage } from './web.protocol.js';
import { getAllowedOrigins } from '../../common/security.config.js';

const logger = createLogger('channels:web:gateway');

const HEARTBEAT_INTERVAL = 30_000;

/**
* Validate a WebSocket upgrade's `Origin` header against the allowlist to block
* cross-site WebSocket hijacking. Non-browser clients omit `Origin`; allow
* those (browsers always send it, so a malicious page is still rejected).
*/
export function isWsOriginAllowed(origin: string | undefined, allowed: string[]): boolean {
if (origin === undefined || origin === '') return true;
return allowed.includes(origin);
}

interface SocketWithAlive extends WebSocket {
isAlive?: boolean;
heartbeatInterval?: ReturnType<typeof setInterval>;
Expand All @@ -34,6 +45,7 @@ interface JwtPayload {
export class WebChatGateway implements OnModuleInit, OnModuleDestroy {
private adapter: WebAdapterExtended | null = null;
private wss: WebSocketServer | null = null;
private allowedOrigins: string[] = [];

constructor(
private readonly jwtService: JwtService,
Expand All @@ -43,6 +55,9 @@ export class WebChatGateway implements OnModuleInit, OnModuleDestroy {

onModuleInit(): void {
const server = this.httpAdapterHost.httpAdapter.getHttpServer();
// Resolve the Origin allowlist once at startup (throws on a misconfigured
// wildcard, surfacing the error early) — shared with the HTTP CORS layer.
this.allowedOrigins = getAllowedOrigins();
// noServer mode: we manually route only matching paths so that other
// WebSocketServers (e.g. /ws/notifications) can coexist on the same
// HTTP server without one tearing down the other's upgrade.
Expand All @@ -55,6 +70,17 @@ export class WebChatGateway implements OnModuleInit, OnModuleDestroy {
server.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {
const url = new URL(req.url ?? '/', 'http://localhost');
if (url.pathname !== '/ws/chat') return;
// Block cross-site WebSocket hijacking: reject upgrades whose Origin is
// not allowlisted before completing the handshake.
if (!isWsOriginAllowed(req.headers.origin, this.allowedOrigins)) {
logger.warn(
{ origin: req.headers.origin },
'WebSocket upgrade rejected — origin not allowed',
);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
this.wss?.handleUpgrade(req, socket, head, (ws) => {
this.wss?.emit('connection', ws, req);
});
Expand Down
21 changes: 19 additions & 2 deletions packages/api/src/chat/__tests__/chat.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,30 @@ describe('ChatController', () => {
meta: { total: 2, page: 1, limit: 50 },
});
expect(mockPrisma.sessionMessage.findMany).toHaveBeenCalledWith({
where: { sessionId: 'sess-1', archivedAt: null },
where: { sessionId: 'sess-1', archivedAt: null, hiddenInHistory: false },
orderBy: { ordering: 'desc' },
skip: 0,
take: 50,
});
});

it('excludes hiddenInHistory rows from both the page query and the total count', async () => {
mockSessionRepo.findById.mockResolvedValue({ id: 'sess-1', userId: 'user-1' });
mockPrisma.sessionMessage.findMany.mockResolvedValue([]);
mockPrisma.sessionMessage.count.mockResolvedValue(0);

const controller = createController();
const req = { user: { sub: 'user-1' } };
await controller.listMessages(req as never, 'sess-1', { page: 1, limit: 50 });

const expectedWhere = { sessionId: 'sess-1', archivedAt: null, hiddenInHistory: false };
expect(mockPrisma.sessionMessage.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: expectedWhere }),
);
// Count must apply the same filter or pagination math drifts.
expect(mockPrisma.sessionMessage.count).toHaveBeenCalledWith({ where: expectedWhere });
});

it('throws NotFoundException when session belongs to another user', async () => {
mockSessionRepo.findById.mockResolvedValue({ id: 'sess-1', userId: 'other-user' });

Expand All @@ -168,7 +185,7 @@ describe('ChatController', () => {
await controller.listMessages(req as never, 'sess-1', {});

expect(mockPrisma.sessionMessage.findMany).toHaveBeenCalledWith({
where: { sessionId: 'sess-1', archivedAt: null },
where: { sessionId: 'sess-1', archivedAt: null, hiddenInHistory: false },
orderBy: { ordering: 'desc' },
skip: 0,
take: 50,
Expand Down
8 changes: 6 additions & 2 deletions packages/api/src/chat/chat.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,18 @@ export class ChatController {
const limit = Math.min(Number(query.limit) || 50, 100);
const skip = (page - 1) * limit;

// `hiddenInHistory` rows are intermediate reasoning steps of a non-streamed
// run — excluded so reopened history mirrors the single combined reply the
// user saw live. Both queries filter identically to keep pagination correct.
const where = { sessionId, archivedAt: null, hiddenInHistory: false };
const [data, total] = await Promise.all([
this.prisma.sessionMessage.findMany({
where: { sessionId, archivedAt: null },
where,
orderBy: { ordering: 'desc' },
skip,
take: limit,
}),
this.prisma.sessionMessage.count({ where: { sessionId, archivedAt: null } }),
this.prisma.sessionMessage.count({ where }),
]);

return {
Expand Down
19 changes: 15 additions & 4 deletions packages/api/src/common/security.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ export function buildHelmetOptions(): FastifyHelmetOptions {
}

/**
* Build CORS options from environment.
* Reads CORS_ALLOWED_ORIGINS (comma-separated).
* Rejects wildcard '*' when credentials are enabled.
* Parse CORS_ALLOWED_ORIGINS (comma-separated) into a trimmed, non-empty list.
* Rejects wildcard '*' since credentials are enabled. Shared by the HTTP CORS
* layer and the WebSocket gateway's Origin check so both use one allowlist.
*/
export function buildCorsOptions() {
export function getAllowedOrigins(): string[] {
const raw = process.env['CORS_ALLOWED_ORIGINS'] ?? 'http://localhost:3000';
const origins = raw
.split(',')
Expand All @@ -61,6 +61,17 @@ export function buildCorsOptions() {
throw new Error("CORS_ALLOWED_ORIGINS must not contain '*' when credentials are enabled");
}

return origins;
}

/**
* Build CORS options from environment.
* Reads CORS_ALLOWED_ORIGINS (comma-separated).
* Rejects wildcard '*' when credentials are enabled.
*/
export function buildCorsOptions() {
const origins = getAllowedOrigins();

return {
origin: origins,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
Expand Down
Loading
Loading