diff --git a/src/components/panels/agent-detail-tabs.tsx b/src/components/panels/agent-detail-tabs.tsx index c09be4d..1714fc9 100644 --- a/src/components/panels/agent-detail-tabs.tsx +++ b/src/components/panels/agent-detail-tabs.tsx @@ -510,7 +510,12 @@ export function MemoryTab({ return (
-

Working Memory

+
+

Working Memory

+

+ Agent-level scratchpad only. Use the global Memory page to browse all workspace memory files. +

+
{!editing && ( <> diff --git a/src/components/panels/cron-management-panel.tsx b/src/components/panels/cron-management-panel.tsx index ec2bac1..c5de4e6 100644 --- a/src/components/panels/cron-management-panel.tsx +++ b/src/components/panels/cron-management-panel.tsx @@ -1,7 +1,8 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { useMissionControl, CronJob } from '@/store' +import { buildDayKey, getCronOccurrences } from '@/lib/cron-occurrences' interface NewJobForm { name: string @@ -56,6 +57,7 @@ export function CronManagementPanel() { const [availableModels, setAvailableModels] = useState([]) const [calendarView, setCalendarView] = useState('week') const [calendarDate, setCalendarDate] = useState(startOfDay(new Date())) + const [selectedCalendarDate, setSelectedCalendarDate] = useState(startOfDay(new Date())) const [searchQuery, setSearchQuery] = useState('') const [agentFilter, setAgentFilter] = useState('all') const [stateFilter, setStateFilter] = useState<'all' | 'enabled' | 'disabled'>('all') @@ -307,39 +309,69 @@ export function CronManagementPanel() { return matchesQuery && matchesAgent && matchesState }) - const agendaJobs = [...filteredJobs].sort((a, b) => { - const aRun = typeof a.nextRun === 'number' ? a.nextRun : Number.POSITIVE_INFINITY - const bRun = typeof b.nextRun === 'number' ? b.nextRun : Number.POSITIVE_INFINITY - return aRun - bRun - }) - const dayStart = startOfDay(calendarDate) const dayEnd = addDays(dayStart, 1) - const dayJobs = filteredJobs - .filter((job) => typeof job.nextRun === 'number' && job.nextRun >= dayStart.getTime() && job.nextRun < dayEnd.getTime()) - .sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0)) const weekStart = getWeekStart(calendarDate) const weekDays = Array.from({ length: 7 }, (_, idx) => addDays(weekStart, idx)) - const jobsByWeekDay = weekDays.map((date) => { - const start = startOfDay(date).getTime() - const end = addDays(date, 1).getTime() - const jobs = filteredJobs - .filter((job) => typeof job.nextRun === 'number' && job.nextRun >= start && job.nextRun < end) - .sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0)) - return { date, jobs } - }) const monthGridStart = getMonthStartGrid(calendarDate) const monthDays = Array.from({ length: 42 }, (_, idx) => addDays(monthGridStart, idx)) - const jobsByMonthDay = monthDays.map((date) => { - const start = startOfDay(date).getTime() - const end = addDays(date, 1).getTime() - const jobs = filteredJobs - .filter((job) => typeof job.nextRun === 'number' && job.nextRun >= start && job.nextRun < end) - .sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0)) - return { date, jobs } - }) + + const calendarBounds = useMemo(() => { + if (calendarView === 'day') { + return { startMs: dayStart.getTime(), endMs: dayEnd.getTime() } + } + if (calendarView === 'week') { + return { startMs: weekStart.getTime(), endMs: addDays(weekStart, 7).getTime() } + } + if (calendarView === 'month') { + return { startMs: monthGridStart.getTime(), endMs: addDays(monthGridStart, 42).getTime() } + } + const agendaStart = Date.now() + return { startMs: agendaStart, endMs: addDays(startOfDay(new Date()), 30).getTime() } + }, [calendarView, dayEnd, dayStart, monthGridStart, weekStart]) + + const calendarOccurrences = useMemo(() => { + const rows: Array<{ job: CronJob; atMs: number; dayKey: string }> = [] + for (const job of filteredJobs) { + const occurrences = getCronOccurrences(job.schedule, calendarBounds.startMs, calendarBounds.endMs, 1000) + for (const occurrence of occurrences) { + rows.push({ job, atMs: occurrence.atMs, dayKey: occurrence.dayKey }) + } + + if (occurrences.length === 0 && typeof job.nextRun === 'number' && job.nextRun >= calendarBounds.startMs && job.nextRun < calendarBounds.endMs) { + rows.push({ job, atMs: job.nextRun, dayKey: buildDayKey(new Date(job.nextRun)) }) + } + } + + rows.sort((a, b) => a.atMs - b.atMs) + return rows + }, [calendarBounds.endMs, calendarBounds.startMs, filteredJobs]) + + const occurrencesByDay = useMemo(() => { + const dayMap = new Map>() + for (const row of calendarOccurrences) { + const existing = dayMap.get(row.dayKey) || [] + existing.push({ job: row.job, atMs: row.atMs }) + dayMap.set(row.dayKey, existing) + } + return dayMap + }, [calendarOccurrences]) + + const dayJobs = occurrencesByDay.get(buildDayKey(dayStart)) || [] + + const jobsByWeekDay = weekDays.map((date) => ({ + date, + jobs: occurrencesByDay.get(buildDayKey(date)) || [], + })) + + const jobsByMonthDay = monthDays.map((date) => ({ + date, + jobs: occurrencesByDay.get(buildDayKey(date)) || [], + })) + + const selectedDayJobs = occurrencesByDay.get(buildDayKey(selectedCalendarDate)) || [] const moveCalendar = (direction: -1 | 1) => { setCalendarDate((prev) => { @@ -392,7 +424,7 @@ export function CronManagementPanel() {

Calendar View

-

Read-only schedule visibility across all cron jobs

+

Interactive schedule across all matching cron jobs

+ )) - )} + )}
)} @@ -493,13 +529,17 @@ export function CronManagementPanel() {
No scheduled jobs for this day.
) : (
- {dayJobs.map((job) => ( -
-
{job.name}
+ {dayJobs.map((row) => ( +
+ ))}
)} @@ -509,21 +549,25 @@ export function CronManagementPanel() { {calendarView === 'week' && (
{jobsByWeekDay.map(({ date, jobs }) => ( -
+ ))}
)} @@ -535,15 +579,16 @@ export function CronManagementPanel() { return (
setSelectedCalendarDate(startOfDay(date))} + className={`border border-border rounded-lg p-2 min-h-24 cursor-pointer ${inCurrentMonth ? 'bg-transparent' : 'bg-secondary/30'} ${isSameDay(date, selectedCalendarDate) ? 'border-primary/40 bg-primary/10' : 'hover:bg-secondary/50'}`} >
{date.getDate()}
- {jobs.slice(0, 2).map((job) => ( -
- {job.name} + {jobs.slice(0, 2).map((row) => ( +
+ {row.job.name}
))} {jobs.length > 2 &&
+{jobs.length - 2}
} @@ -553,6 +598,35 @@ export function CronManagementPanel() { })}
)} + + {calendarView !== 'agenda' && ( +
+
+

+ {selectedCalendarDate.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric', year: 'numeric' })} +

+ {selectedDayJobs.length} jobs +
+ {selectedDayJobs.length === 0 ? ( +
No jobs scheduled on this date.
+ ) : ( +
+ {selectedDayJobs.map((row) => ( + + ))} +
+ )} +
+ )}
diff --git a/src/components/panels/memory-browser-panel.tsx b/src/components/panels/memory-browser-panel.tsx index 7eeba6e..453526e 100644 --- a/src/components/panels/memory-browser-panel.tsx +++ b/src/components/panels/memory-browser-panel.tsx @@ -353,6 +353,9 @@ export function MemoryBrowserPanel() { ? 'Browse and manage local knowledge files and memory' : 'Explore knowledge files and memory structure'}

+

+ This page shows all workspace memory files. The agent profile Memory tab only edits that single agent's working memory. +

{/* Tab Navigation */}
diff --git a/src/components/panels/super-admin-panel.tsx b/src/components/panels/super-admin-panel.tsx index 0851a71..81ae747 100644 --- a/src/components/panels/super-admin-panel.tsx +++ b/src/components/panels/super-admin-panel.tsx @@ -461,6 +461,9 @@ export function SuperAdminPanel() { {createExpanded && (
+
+ Add a new workspace/client instance here. Fill the form below and click Create + Queue. +
{gatewayLoadError && (
Gateway list unavailable: {gatewayLoadError}. Using fallback owner value. diff --git a/src/lib/__tests__/auth.test.ts b/src/lib/__tests__/auth.test.ts index c25b97f..0a67b63 100644 --- a/src/lib/__tests__/auth.test.ts +++ b/src/lib/__tests__/auth.test.ts @@ -105,4 +105,23 @@ describe('requireRole', () => { ) expect(result.user).toBeDefined() }) + + it('accepts Authorization Bearer API key', () => { + const result = requireRole( + makeRequest({ authorization: 'Bearer test-api-key-secret' }), + 'admin', + ) + expect(result.user).toBeDefined() + expect(result.user!.username).toBe('api') + }) + + it('rejects API key auth when API_KEY is not configured', () => { + process.env = { ...originalEnv, API_KEY: '' } + const result = requireRole( + makeRequest({ 'x-api-key': 'test-api-key-secret' }), + 'viewer', + ) + expect(result.status).toBe(401) + expect(result.user).toBeUndefined() + }) }) diff --git a/src/lib/__tests__/cron-occurrences.test.ts b/src/lib/__tests__/cron-occurrences.test.ts new file mode 100644 index 0000000..08cf2cd --- /dev/null +++ b/src/lib/__tests__/cron-occurrences.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { buildDayKey, getCronOccurrences } from '@/lib/cron-occurrences' + +describe('buildDayKey', () => { + it('formats YYYY-MM-DD in local time', () => { + const date = new Date(2026, 2, 4, 9, 15, 0, 0) + expect(buildDayKey(date)).toBe('2026-03-04') + }) +}) + +describe('getCronOccurrences', () => { + it('expands daily schedule across range', () => { + const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime() + const end = new Date(2026, 2, 5, 0, 0, 0, 0).getTime() + const rows = getCronOccurrences('0 0 * * *', start, end) + expect(rows).toHaveLength(4) + expect(rows.map((r) => r.dayKey)).toEqual([ + '2026-03-01', + '2026-03-02', + '2026-03-03', + '2026-03-04', + ]) + }) + + it('supports step values', () => { + const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime() + const end = new Date(2026, 2, 1, 3, 0, 0, 0).getTime() + const rows = getCronOccurrences('*/30 * * * *', start, end) + expect(rows).toHaveLength(6) + }) + + it('ignores OpenClaw timezone suffix in display schedule', () => { + const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime() + const end = new Date(2026, 2, 2, 0, 0, 0, 0).getTime() + const rows = getCronOccurrences('0 6 * * * (UTC)', start, end) + expect(rows).toHaveLength(1) + }) + + it('returns empty list for invalid cron', () => { + const start = new Date(2026, 2, 1, 0, 0, 0, 0).getTime() + const end = new Date(2026, 2, 2, 0, 0, 0, 0).getTime() + expect(getCronOccurrences('invalid', start, end)).toEqual([]) + }) +}) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 04f853e..e1517e7 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -277,8 +277,9 @@ export function getUserFromRequest(request: Request): User | null { } // Check API key - return synthetic user - const apiKey = request.headers.get('x-api-key') - if (apiKey && safeCompare(apiKey, process.env.API_KEY || '')) { + const configuredApiKey = (process.env.API_KEY || '').trim() + const apiKey = extractApiKeyFromHeaders(request.headers) + if (configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey)) { return { id: 0, username: 'api', @@ -294,6 +295,24 @@ export function getUserFromRequest(request: Request): User | null { return null } +function extractApiKeyFromHeaders(headers: Headers): string | null { + const direct = (headers.get('x-api-key') || '').trim() + if (direct) return direct + + const authorization = (headers.get('authorization') || '').trim() + if (!authorization) return null + + const [scheme, ...rest] = authorization.split(/\s+/) + if (!scheme || rest.length === 0) return null + + const normalized = scheme.toLowerCase() + if (normalized === 'bearer' || normalized === 'apikey' || normalized === 'token') { + return rest.join(' ').trim() || null + } + + return null +} + /** * Role hierarchy levels for access control. * viewer < operator < admin diff --git a/src/lib/cron-occurrences.ts b/src/lib/cron-occurrences.ts new file mode 100644 index 0000000..88472fc --- /dev/null +++ b/src/lib/cron-occurrences.ts @@ -0,0 +1,139 @@ +export interface CronOccurrence { + atMs: number + dayKey: string +} + +interface ParsedField { + any: boolean + matches: (value: number) => boolean +} + +interface ParsedCron { + minute: ParsedField + hour: ParsedField + dayOfMonth: ParsedField + month: ParsedField + dayOfWeek: ParsedField +} + +function normalizeCronExpression(raw: string): string { + const trimmed = raw.trim() + const tzSuffixMatch = trimmed.match(/^(.*)\s+\([^)]+\)$/) + return (tzSuffixMatch?.[1] || trimmed).trim() +} + +function parseToken(token: string, min: number, max: number): { any: boolean; values: Set } { + const valueSet = new Set() + const trimmed = token.trim() + if (trimmed === '*') { + for (let i = min; i <= max; i += 1) valueSet.add(i) + return { any: true, values: valueSet } + } + + for (const part of trimmed.split(',')) { + const section = part.trim() + if (!section) continue + + const [rangePart, stepPart] = section.split('/') + const step = stepPart ? Number(stepPart) : 1 + if (!Number.isFinite(step) || step <= 0) continue + + if (rangePart === '*') { + for (let i = min; i <= max; i += step) valueSet.add(i) + continue + } + + if (rangePart.includes('-')) { + const [fromRaw, toRaw] = rangePart.split('-') + const from = Number(fromRaw) + const to = Number(toRaw) + if (!Number.isFinite(from) || !Number.isFinite(to)) continue + const start = Math.max(min, Math.min(max, from)) + const end = Math.max(min, Math.min(max, to)) + for (let i = start; i <= end; i += step) valueSet.add(i) + continue + } + + const single = Number(rangePart) + if (!Number.isFinite(single)) continue + if (single >= min && single <= max) valueSet.add(single) + } + + return { any: false, values: valueSet } +} + +function parseField(token: string, min: number, max: number): ParsedField { + const parsed = parseToken(token, min, max) + return { + any: parsed.any, + matches: (value: number) => parsed.values.has(value), + } +} + +function parseCron(raw: string): ParsedCron | null { + const normalized = normalizeCronExpression(raw) + const parts = normalized.split(/\s+/).filter(Boolean) + if (parts.length !== 5) return null + + return { + minute: parseField(parts[0], 0, 59), + hour: parseField(parts[1], 0, 23), + dayOfMonth: parseField(parts[2], 1, 31), + month: parseField(parts[3], 1, 12), + dayOfWeek: parseField(parts[4], 0, 6), + } +} + +function matchesDay(parsed: ParsedCron, date: Date): boolean { + const dayOfMonthMatches = parsed.dayOfMonth.matches(date.getDate()) + const dayOfWeekMatches = parsed.dayOfWeek.matches(date.getDay()) + + if (parsed.dayOfMonth.any && parsed.dayOfWeek.any) return true + if (parsed.dayOfMonth.any) return dayOfWeekMatches + if (parsed.dayOfWeek.any) return dayOfMonthMatches + return dayOfMonthMatches || dayOfWeekMatches +} + +export function buildDayKey(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function getCronOccurrences( + schedule: string, + rangeStartMs: number, + rangeEndMs: number, + max = 1000 +): CronOccurrence[] { + if (!schedule || !Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs)) return [] + if (rangeEndMs <= rangeStartMs || max <= 0) return [] + + const parsed = parseCron(schedule) + if (!parsed) return [] + + const occurrences: CronOccurrence[] = [] + const cursor = new Date(rangeStartMs) + cursor.setSeconds(0, 0) + if (cursor.getTime() < rangeStartMs) { + cursor.setMinutes(cursor.getMinutes() + 1, 0, 0) + } + + while (cursor.getTime() < rangeEndMs && occurrences.length < max) { + if ( + parsed.month.matches(cursor.getMonth() + 1) && + matchesDay(parsed, cursor) && + parsed.hour.matches(cursor.getHours()) && + parsed.minute.matches(cursor.getMinutes()) + ) { + occurrences.push({ + atMs: cursor.getTime(), + dayKey: buildDayKey(cursor), + }) + } + cursor.setMinutes(cursor.getMinutes() + 1, 0, 0) + } + + return occurrences +} diff --git a/src/proxy.ts b/src/proxy.ts index 44d14d9..dd051f3 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -52,6 +52,22 @@ function applySecurityHeaders(response: NextResponse): NextResponse { return response } +function extractApiKeyFromRequest(request: NextRequest): string { + const direct = (request.headers.get('x-api-key') || '').trim() + if (direct) return direct + + const authorization = (request.headers.get('authorization') || '').trim() + if (!authorization) return '' + + const [scheme, ...rest] = authorization.split(/\s+/) + if (!scheme || rest.length === 0) return '' + const normalized = scheme.toLowerCase() + if (normalized === 'bearer' || normalized === 'apikey' || normalized === 'token') { + return rest.join(' ').trim() + } + return '' +} + export function proxy(request: NextRequest) { // Network access control. // In production: default-deny unless explicitly allowed. @@ -63,7 +79,8 @@ export function proxy(request: NextRequest) { .map((s) => s.trim()) .filter(Boolean) - const isAllowedHost = allowAnyHost || allowedPatterns.some((p) => hostMatches(p, hostName)) + const enforceAllowlist = !allowAnyHost && allowedPatterns.length > 0 + const isAllowedHost = !enforceAllowlist || allowedPatterns.some((p) => hostMatches(p, hostName)) if (!isAllowedHost) { return new NextResponse('Forbidden', { status: 403 }) @@ -97,8 +114,10 @@ export function proxy(request: NextRequest) { // API routes: accept session cookie OR API key if (pathname.startsWith('/api/')) { - const apiKey = request.headers.get('x-api-key') - if (sessionToken || (apiKey && safeCompare(apiKey, process.env.API_KEY || ''))) { + const configuredApiKey = (process.env.API_KEY || '').trim() + const apiKey = extractApiKeyFromRequest(request) + const hasValidApiKey = Boolean(configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey)) + if (sessionToken || hasValidApiKey) { return applySecurityHeaders(NextResponse.next()) }