From 79984702de1024535ce11415e068a818d132bf5c Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Thu, 5 Mar 2026 00:17:23 +0700 Subject: [PATCH 1/5] feat: provision full OpenClaw workspaces from agent creation --- src/app/api/agents/route.ts | 41 ++++++++++++++++++++- src/components/panels/agent-detail-tabs.tsx | 13 +++++++ src/lib/validation.ts | 3 ++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/app/api/agents/route.ts b/src/app/api/agents/route.ts index 3f835f0..1f52638 100644 --- a/src/app/api/agents/route.ts +++ b/src/app/api/agents/route.ts @@ -8,6 +8,10 @@ import { requireRole } from '@/lib/auth'; import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { validateBody, createAgentSchema } from '@/lib/validation'; +import { runOpenClaw } from '@/lib/command'; +import { config as appConfig } from '@/lib/config'; +import { resolveWithin } from '@/lib/paths'; +import path from 'node:path'; /** * GET /api/agents - List all agents with optional filtering @@ -123,6 +127,7 @@ export async function POST(request: NextRequest) { const { name, + openclaw_id, role, session_key, soul_content, @@ -130,9 +135,16 @@ export async function POST(request: NextRequest) { config = {}, template, gateway_config, - write_to_gateway + write_to_gateway, + provision_openclaw_workspace, + openclaw_workspace_path } = body; + const openclawId = (openclaw_id || name || 'agent') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + // Resolve template if specified let finalRole = role; let finalConfig: Record = { ...config }; @@ -158,6 +170,32 @@ export async function POST(request: NextRequest) { if (existingAgent) { return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 }); } + + if (provision_openclaw_workspace) { + if (!appConfig.openclawStateDir) { + return NextResponse.json( + { error: 'OPENCLAW_STATE_DIR is not configured; cannot provision OpenClaw workspace' }, + { status: 500 } + ); + } + + const workspacePath = openclaw_workspace_path + ? path.resolve(openclaw_workspace_path) + : resolveWithin(appConfig.openclawStateDir, path.join('workspaces', openclawId)); + + try { + await runOpenClaw( + ['agents', 'add', openclawId, '--name', name, '--workspace', workspacePath, '--non-interactive'], + { timeoutMs: 20000 } + ); + } catch (provisionError: any) { + logger.error({ err: provisionError, openclawId, workspacePath }, 'OpenClaw workspace provisioning failed'); + return NextResponse.json( + { error: provisionError?.message || 'Failed to provision OpenClaw agent workspace' }, + { status: 502 } + ); + } + } const now = Math.floor(Date.now() / 1000); @@ -215,7 +253,6 @@ export async function POST(request: NextRequest) { // Write to gateway config if requested if (write_to_gateway && finalConfig) { try { - const openclawId = (name || 'agent').toLowerCase().replace(/\s+/g, '-'); await writeAgentToConfig({ id: openclawId, name, diff --git a/src/components/panels/agent-detail-tabs.tsx b/src/components/panels/agent-detail-tabs.tsx index ee1973b..1e6bee1 100644 --- a/src/components/panels/agent-detail-tabs.tsx +++ b/src/components/panels/agent-detail-tabs.tsx @@ -852,6 +852,7 @@ export function CreateAgentModal({ dockerNetwork: 'none' as 'none' | 'bridge', session_key: '', write_to_gateway: true, + provision_openclaw_workspace: true, }) const [isCreating, setIsCreating] = useState(false) const [error, setError] = useState(null) @@ -916,10 +917,12 @@ export function CreateAgentModal({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: formData.name, + openclaw_id: formData.id || undefined, role: formData.role, session_key: formData.session_key || undefined, template: selectedTemplate || undefined, write_to_gateway: formData.write_to_gateway, + provision_openclaw_workspace: formData.provision_openclaw_workspace, gateway_config: { model: { primary: primaryModel }, identity: { name: formData.name, theme: formData.role, emoji: formData.emoji }, @@ -1199,6 +1202,16 @@ export function CreateAgentModal({ /> Add to gateway config (openclaw.json) + + )} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index f26e7a5..fb13b93 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -45,6 +45,7 @@ export const updateTaskSchema = createTaskSchema.partial() export const createAgentSchema = z.object({ name: z.string().min(1, 'Name is required').max(100), + openclaw_id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, 'openclaw_id must be kebab-case').max(100).optional(), role: z.string().min(1, 'Role is required').max(100).optional(), session_key: z.string().max(200).optional(), soul_content: z.string().max(50000).optional(), @@ -53,6 +54,8 @@ export const createAgentSchema = z.object({ template: z.string().max(100).optional(), gateway_config: z.record(z.string(), z.unknown()).optional(), write_to_gateway: z.boolean().optional(), + provision_openclaw_workspace: z.boolean().optional(), + openclaw_workspace_path: z.string().min(1).max(500).optional(), }) export const bulkUpdateTaskStatusSchema = z.object({ From d2edc718610376705a78856058ca0e4d83c804cb Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Thu, 5 Mar 2026 11:11:16 +0700 Subject: [PATCH 2/5] fix(auth): redirect unauthenticated panel requests to login --- src/app/[[...panel]]/page.tsx | 13 ++++++++++--- .../panels/agent-squad-panel-phase3.tsx | 19 ++++++++++++++++++- src/components/panels/settings-panel.tsx | 7 ++++++- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/app/[[...panel]]/page.tsx b/src/app/[[...panel]]/page.tsx index 0b0dcaa..e7d05dd 100644 --- a/src/app/[[...panel]]/page.tsx +++ b/src/app/[[...panel]]/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState } from 'react' -import { usePathname } from 'next/navigation' +import { usePathname, useRouter } from 'next/navigation' import { NavRail } from '@/components/layout/nav-rail' import { HeaderBar } from '@/components/layout/header-bar' import { LiveFeed } from '@/components/layout/live-feed' @@ -42,6 +42,7 @@ import { useServerEvents } from '@/lib/use-server-events' import { useMissionControl } from '@/store' export default function Home() { + const router = useRouter() const { connect } = useWebSocket() const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable, liveFeedOpen, toggleLiveFeed } = useMissionControl() @@ -62,7 +63,13 @@ export default function Home() { // Fetch current user fetch('/api/auth/me') - .then(res => res.ok ? res.json() : null) + .then(async (res) => { + if (res.ok) return res.json() + if (res.status === 401) { + router.replace(`/login?next=${encodeURIComponent(pathname)}`) + } + return null + }) .then(data => { if (data?.user) setCurrentUser(data.user) }) .catch(() => {}) @@ -120,7 +127,7 @@ export default function Home() { const wsUrl = explicitWsUrl || `${gatewayProto}://${gatewayHost}:${gatewayPort}` connect(wsUrl, wsToken) }) - }, [connect, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable]) + }, [connect, pathname, router, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable]) if (!isClient) { return ( diff --git a/src/components/panels/agent-squad-panel-phase3.tsx b/src/components/panels/agent-squad-panel-phase3.tsx index 8905d31..9cbe4b3 100644 --- a/src/components/panels/agent-squad-panel-phase3.tsx +++ b/src/components/panels/agent-squad-panel-phase3.tsx @@ -89,7 +89,14 @@ export function AgentSquadPanelPhase3() { setSyncToast(null) try { const response = await fetch('/api/agents/sync', { method: 'POST' }) + if (response.status === 401) { + window.location.assign('/login?next=%2Fagents') + return + } const data = await response.json() + if (response.status === 403) { + throw new Error('Admin access required for agent sync') + } if (!response.ok) throw new Error(data.error || 'Sync failed') setSyncToast(`Synced ${data.synced} agents (${data.created} new, ${data.updated} updated)`) fetchAgents() @@ -109,7 +116,17 @@ export function AgentSquadPanelPhase3() { if (agents.length === 0) setLoading(true) const response = await fetch('/api/agents') - if (!response.ok) throw new Error('Failed to fetch agents') + if (response.status === 401) { + window.location.assign('/login?next=%2Fagents') + return + } + if (response.status === 403) { + throw new Error('Access denied') + } + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error || 'Failed to fetch agents') + } const data = await response.json() setAgents(data.agents || []) diff --git a/src/components/panels/settings-panel.tsx b/src/components/panels/settings-panel.tsx index 08912c8..78f747d 100644 --- a/src/components/panels/settings-panel.tsx +++ b/src/components/panels/settings-panel.tsx @@ -43,12 +43,17 @@ export function SettingsPanel() { const fetchSettings = useCallback(async () => { try { const res = await fetch('/api/settings') + if (res.status === 401) { + window.location.assign('/login?next=%2Fsettings') + return + } if (res.status === 403) { setError('Admin access required') return } if (!res.ok) { - setError('Failed to load settings') + const data = await res.json().catch(() => ({})) + setError(data.error || 'Failed to load settings') return } const data = await res.json() From 17b51623beb9f40421089f850c092783b5421d86 Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Thu, 5 Mar 2026 11:16:52 +0700 Subject: [PATCH 3/5] fix(websocket): declare ping support ref for type safety --- src/lib/websocket.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/websocket.ts b/src/lib/websocket.ts index a1ab370..d860bcf 100644 --- a/src/lib/websocket.ts +++ b/src/lib/websocket.ts @@ -59,6 +59,8 @@ export function useWebSocket() { const pingCounterRef = useRef(0) const pingSentTimestamps = useRef>(new Map()) const missedPongsRef = useRef(0) + // Compat flag for gateway versions that may not implement ping RPC. + const gatewaySupportsPingRef = useRef(true) const { connection, @@ -116,6 +118,7 @@ export function useWebSocket() { pingIntervalRef.current = setInterval(() => { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !handshakeCompleteRef.current) return + if (!gatewaySupportsPingRef.current) return // Check missed pongs if (missedPongsRef.current >= MAX_MISSED_PONGS) { From dd759dcdb9eacb32a2fa916c63fb8349a65be396 Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Thu, 5 Mar 2026 11:28:47 +0700 Subject: [PATCH 4/5] fix(e2e): increase playwright webServer startup timeout --- playwright.config.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index f50bcea..e654bdd 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -21,6 +21,12 @@ export default defineConfig({ command: 'pnpm start', url: 'http://127.0.0.1:3005', reuseExistingServer: true, - timeout: 30_000, + timeout: 120_000, + env: { + ...process.env, + API_KEY: process.env.API_KEY || 'test-api-key-e2e-12345', + AUTH_USER: process.env.AUTH_USER || 'testadmin', + AUTH_PASS: process.env.AUTH_PASS || 'testpass1234!', + }, } }) From a34caa175233bef7211f47591c331d3ffac8b19b Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Thu, 5 Mar 2026 11:42:25 +0700 Subject: [PATCH 5/5] fix(e2e): run standalone server in Playwright CI startup --- playwright.config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index e654bdd..4a5c30d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,12 +18,15 @@ export default defineConfig({ { name: 'chromium', use: { ...devices['Desktop Chrome'] } } ], webServer: { - command: 'pnpm start', + command: 'node .next/standalone/server.js', url: 'http://127.0.0.1:3005', reuseExistingServer: true, timeout: 120_000, env: { ...process.env, + HOSTNAME: process.env.HOSTNAME || '127.0.0.1', + PORT: process.env.PORT || '3005', + MC_DISABLE_RATE_LIMIT: process.env.MC_DISABLE_RATE_LIMIT || '1', API_KEY: process.env.API_KEY || 'test-api-key-e2e-12345', AUTH_USER: process.env.AUTH_USER || 'testadmin', AUTH_PASS: process.env.AUTH_PASS || 'testpass1234!',