From 96845110eef959242bcea7d34b5e4c647b4c8495 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 03:15:47 +0900 Subject: [PATCH] fix(dashboard): harden scheduled task writes (#36) --- apps/dashboard/src/App.tsx | 2 +- apps/dashboard/src/api.ts | 1 + src/web-dashboard-server.test.ts | 95 +++++++++++++++++++++++++++++++- src/web-dashboard-server.ts | 81 +++++++++++++++++++++------ 4 files changed, 160 insertions(+), 19 deletions(-) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 716e196..b0423d0 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -1756,7 +1756,7 @@ function App() { async function handleTaskCreate(input: CreateScheduledTaskInput) { setTaskActionKey('create'); try { - await createScheduledTask(input); + await createScheduledTask({ ...input, requestId: makeClientRequestId() }); await refresh(false); } catch (err) { setError(err instanceof Error ? err.message : String(err)); diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index b5f300b..bb603d4 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -111,6 +111,7 @@ export interface CreateScheduledTaskInput { scheduleType: DashboardTaskScheduleType; scheduleValue: string; contextMode: DashboardTaskContextMode; + requestId?: string; } export interface UpdateScheduledTaskInput { diff --git a/src/web-dashboard-server.test.ts b/src/web-dashboard-server.test.ts index c5574c3..6d913c1 100644 --- a/src/web-dashboard-server.test.ts +++ b/src/web-dashboard-server.test.ts @@ -397,6 +397,7 @@ describe('web dashboard server handler', () => { body: JSON.stringify({ roomJid: 'dc:ops', prompt: ' run hourly dashboard audit ', + requestId: 'task-create-1', scheduleType: 'once', scheduleValue: '2026-04-26T05:20:00.000Z', contextMode: 'group', @@ -422,6 +423,50 @@ describe('web dashboard server handler', () => { status: 'active', }); + const duplicateCreate = await handler( + new Request('http://localhost/api/tasks', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + roomJid: 'dc:ops', + prompt: 'run hourly dashboard audit', + requestId: 'task-create-1', + scheduleType: 'once', + scheduleValue: '2026-04-26T05:20:00.000Z', + contextMode: 'group', + }), + }), + ); + expect(duplicateCreate.status).toBe(200); + await expect(duplicateCreate.json()).resolves.toMatchObject({ + duplicate: true, + task: { id: created.task.id }, + }); + expect(tasks.size).toBe(1); + + tasks.set(created.task.id, { + ...tasks.get(created.task.id)!, + next_run: '2026-04-26T06:00:00.000Z', + suspended_until: '2026-04-26T05:45:00.000Z', + }); + const promptOnlyUpdate = await handler( + new Request(`http://localhost/api/tasks/${created.task.id}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + prompt: 'watch dashboard with same schedule', + }), + }), + ); + + expect(promptOnlyUpdate.status).toBe(200); + expect(tasks.get(created.task.id)).toMatchObject({ + next_run: '2026-04-26T06:00:00.000Z', + prompt: 'watch dashboard with same schedule', + schedule_type: 'once', + suspended_until: '2026-04-26T05:45:00.000Z', + }); + const update = await handler( new Request(`http://localhost/api/tasks/${created.task.id}`, { method: 'PATCH', @@ -443,6 +488,27 @@ describe('web dashboard server handler', () => { suspended_until: null, }); expect(nudges).toEqual([]); + + const longPrompt = await handler( + new Request('http://localhost/api/tasks', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + roomJid: 'dc:ops', + prompt: 'x'.repeat(8_100), + requestId: 'task-create-long', + scheduleType: 'once', + scheduleValue: '2026-04-26T05:20:00.000Z', + contextMode: 'isolated', + }), + }), + ); + expect(longPrompt.status).toBe(200); + const longCreated = (await longPrompt.json()) as { + task: { id: string; promptLength: number }; + }; + expect(longCreated.task.promptLength).toBe(8_000); + expect(tasks.get(longCreated.task.id)?.prompt).toHaveLength(8_000); }); it('rejects invalid scheduled task create and edit requests', async () => { @@ -450,15 +516,25 @@ describe('web dashboard server handler', () => { id: 'completed-task', status: 'completed', }); + const active = makeTask({ + id: 'active-task', + status: 'active', + }); const watch = makeTask({ id: 'watch-task', prompt: '[BACKGROUND CI WATCH]\nwatch', }); const handler = createWebDashboardHandler({ readStatusSnapshots: () => [], - getTasks: () => [completed, watch], + getTasks: () => [completed, active, watch], getTaskById: (id) => - id === completed.id ? completed : id === watch.id ? watch : undefined, + id === completed.id + ? completed + : id === active.id + ? active + : id === watch.id + ? watch + : undefined, createTask: () => { throw new Error('createTask should not be called'); }, @@ -539,6 +615,21 @@ describe('web dashboard server handler', () => { }), ); expect(watcherEdit.status).toBe(409); + + const unsupportedEdit = await handler( + new Request('http://localhost/api/tasks/active-task', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + agentType: 'codex', + contextMode: 'group', + prompt: 'run again', + scheduleType: 'interval', + scheduleValue: '60000', + }), + }), + ); + expect(unsupportedEdit.status).toBe(400); }); it('queues paired inbox actions through the paired follow-up scheduler', async () => { diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 182f820..0bd750e 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -38,6 +38,7 @@ import { const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000; const ROOM_MESSAGE_ID_CACHE_LIMIT = 500; +const WEB_TASK_PROMPT_MAX_LENGTH = 8000; type PairedFollowUpTask = Pick< PairedTask, @@ -298,6 +299,7 @@ interface ScheduledTaskMutationBody { scheduleValue?: unknown; contextMode?: unknown; agentType?: unknown; + requestId?: unknown; } async function readScheduledTaskMutationBody( @@ -316,6 +318,14 @@ function normalizeNonEmptyString(value: unknown): string | null { return trimmed ? trimmed : null; } +function normalizeTaskPrompt(value: unknown): string | null { + const prompt = normalizeNonEmptyString(value); + if (!prompt) return null; + return prompt.length > WEB_TASK_PROMPT_MAX_LENGTH + ? prompt.slice(0, WEB_TASK_PROMPT_MAX_LENGTH) + : prompt; +} + function computeInitialNextRun( scheduleType: ScheduledTask['schedule_type'], scheduleValue: string, @@ -371,6 +381,18 @@ function makeWebTaskId(): string { return `web-task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } +function sanitizeScheduledTaskRequestId(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120); + return safe || null; +} + +function makeWebTaskIdFromRequest(requestId: string | null): string { + return requestId ? `web-task-${requestId}` : makeWebTaskId(); +} + function sanitizeRoomMessageRequestId(value: unknown): string | null { if (typeof value !== 'string') return null; const trimmed = value.trim(); @@ -641,7 +663,7 @@ export function createWebDashboardHandler( } const body = await readScheduledTaskMutationBody(request); - const prompt = normalizeNonEmptyString(body?.prompt); + const prompt = normalizeTaskPrompt(body?.prompt); const scheduleType = body && isScheduleType(body.scheduleType) ? body.scheduleType : null; const scheduleValue = normalizeNonEmptyString(body?.scheduleValue); @@ -672,7 +694,17 @@ export function createWebDashboardHandler( const agentType = isAgentType(body.agentType) ? body.agentType : (room.group.agentType ?? 'claude-code'); - const id = makeWebTaskId(); + const requestId = sanitizeScheduledTaskRequestId(body.requestId); + const id = makeWebTaskIdFromRequest(requestId); + const existing = requestId ? loadTaskById(id) : undefined; + if (existing) { + return jsonResponse({ + ok: true, + duplicate: true, + task: sanitizeScheduledTask(existing), + }); + } + createScheduledTask({ id, group_folder: room.group.folder, @@ -722,11 +754,24 @@ export function createWebDashboardHandler( if (!body) { return jsonResponse({ error: 'Invalid task update' }, { status: 400 }); } + if ( + body.roomJid !== undefined || + body.groupFolder !== undefined || + body.contextMode !== undefined || + body.agentType !== undefined + ) { + return jsonResponse( + { error: 'Task room, context, and agent cannot be edited here' }, + { status: 400 }, + ); + } const prompt = body.prompt === undefined ? task.prompt - : normalizeNonEmptyString(body.prompt); + : normalizeTaskPrompt(body.prompt); + const scheduleChanged = + body.scheduleType !== undefined || body.scheduleValue !== undefined; const scheduleType = body.scheduleType === undefined ? task.schedule_type @@ -741,22 +786,26 @@ export function createWebDashboardHandler( return jsonResponse({ error: 'Invalid task update' }, { status: 400 }); } - const next = computeInitialNextRun( - scheduleType, - scheduleValue, - opts.now?.() ?? new Date().toISOString(), - ); - if (next.error) { - return jsonResponse({ error: next.error }, { status: 400 }); - } - - mutateTask(taskId, { + const updates: Parameters[1] = { prompt, schedule_type: scheduleType, schedule_value: scheduleValue, - next_run: next.nextRun, - suspended_until: null, - }); + }; + + if (scheduleChanged) { + const next = computeInitialNextRun( + scheduleType, + scheduleValue, + opts.now?.() ?? new Date().toISOString(), + ); + if (next.error) { + return jsonResponse({ error: next.error }, { status: 400 }); + } + updates.next_run = next.nextRun; + updates.suspended_until = null; + } + + mutateTask(taskId, updates); const updatedTask = loadTaskById(taskId); return jsonResponse({ ok: true,