fix(dashboard): harden scheduled task writes (#36)

This commit is contained in:
Eyejoker
2026-04-27 03:15:47 +09:00
committed by GitHub
parent 92e4e22e4d
commit 96845110ee
4 changed files with 160 additions and 19 deletions

View File

@@ -1756,7 +1756,7 @@ function App() {
async function handleTaskCreate(input: CreateScheduledTaskInput) { async function handleTaskCreate(input: CreateScheduledTaskInput) {
setTaskActionKey('create'); setTaskActionKey('create');
try { try {
await createScheduledTask(input); await createScheduledTask({ ...input, requestId: makeClientRequestId() });
await refresh(false); await refresh(false);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));

View File

@@ -111,6 +111,7 @@ export interface CreateScheduledTaskInput {
scheduleType: DashboardTaskScheduleType; scheduleType: DashboardTaskScheduleType;
scheduleValue: string; scheduleValue: string;
contextMode: DashboardTaskContextMode; contextMode: DashboardTaskContextMode;
requestId?: string;
} }
export interface UpdateScheduledTaskInput { export interface UpdateScheduledTaskInput {

View File

@@ -397,6 +397,7 @@ describe('web dashboard server handler', () => {
body: JSON.stringify({ body: JSON.stringify({
roomJid: 'dc:ops', roomJid: 'dc:ops',
prompt: ' run hourly dashboard audit ', prompt: ' run hourly dashboard audit ',
requestId: 'task-create-1',
scheduleType: 'once', scheduleType: 'once',
scheduleValue: '2026-04-26T05:20:00.000Z', scheduleValue: '2026-04-26T05:20:00.000Z',
contextMode: 'group', contextMode: 'group',
@@ -422,6 +423,50 @@ describe('web dashboard server handler', () => {
status: 'active', 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( const update = await handler(
new Request(`http://localhost/api/tasks/${created.task.id}`, { new Request(`http://localhost/api/tasks/${created.task.id}`, {
method: 'PATCH', method: 'PATCH',
@@ -443,6 +488,27 @@ describe('web dashboard server handler', () => {
suspended_until: null, suspended_until: null,
}); });
expect(nudges).toEqual([]); 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 () => { it('rejects invalid scheduled task create and edit requests', async () => {
@@ -450,15 +516,25 @@ describe('web dashboard server handler', () => {
id: 'completed-task', id: 'completed-task',
status: 'completed', status: 'completed',
}); });
const active = makeTask({
id: 'active-task',
status: 'active',
});
const watch = makeTask({ const watch = makeTask({
id: 'watch-task', id: 'watch-task',
prompt: '[BACKGROUND CI WATCH]\nwatch', prompt: '[BACKGROUND CI WATCH]\nwatch',
}); });
const handler = createWebDashboardHandler({ const handler = createWebDashboardHandler({
readStatusSnapshots: () => [], readStatusSnapshots: () => [],
getTasks: () => [completed, watch], getTasks: () => [completed, active, watch],
getTaskById: (id) => 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: () => { createTask: () => {
throw new Error('createTask should not be called'); throw new Error('createTask should not be called');
}, },
@@ -539,6 +615,21 @@ describe('web dashboard server handler', () => {
}), }),
); );
expect(watcherEdit.status).toBe(409); 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 () => { it('queues paired inbox actions through the paired follow-up scheduler', async () => {

View File

@@ -38,6 +38,7 @@ import {
const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000; const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000;
const ROOM_MESSAGE_ID_CACHE_LIMIT = 500; const ROOM_MESSAGE_ID_CACHE_LIMIT = 500;
const WEB_TASK_PROMPT_MAX_LENGTH = 8000;
type PairedFollowUpTask = Pick< type PairedFollowUpTask = Pick<
PairedTask, PairedTask,
@@ -298,6 +299,7 @@ interface ScheduledTaskMutationBody {
scheduleValue?: unknown; scheduleValue?: unknown;
contextMode?: unknown; contextMode?: unknown;
agentType?: unknown; agentType?: unknown;
requestId?: unknown;
} }
async function readScheduledTaskMutationBody( async function readScheduledTaskMutationBody(
@@ -316,6 +318,14 @@ function normalizeNonEmptyString(value: unknown): string | null {
return trimmed ? trimmed : 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( function computeInitialNextRun(
scheduleType: ScheduledTask['schedule_type'], scheduleType: ScheduledTask['schedule_type'],
scheduleValue: string, scheduleValue: string,
@@ -371,6 +381,18 @@ function makeWebTaskId(): string {
return `web-task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; 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 { function sanitizeRoomMessageRequestId(value: unknown): string | null {
if (typeof value !== 'string') return null; if (typeof value !== 'string') return null;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -641,7 +663,7 @@ export function createWebDashboardHandler(
} }
const body = await readScheduledTaskMutationBody(request); const body = await readScheduledTaskMutationBody(request);
const prompt = normalizeNonEmptyString(body?.prompt); const prompt = normalizeTaskPrompt(body?.prompt);
const scheduleType = const scheduleType =
body && isScheduleType(body.scheduleType) ? body.scheduleType : null; body && isScheduleType(body.scheduleType) ? body.scheduleType : null;
const scheduleValue = normalizeNonEmptyString(body?.scheduleValue); const scheduleValue = normalizeNonEmptyString(body?.scheduleValue);
@@ -672,7 +694,17 @@ export function createWebDashboardHandler(
const agentType = isAgentType(body.agentType) const agentType = isAgentType(body.agentType)
? body.agentType ? body.agentType
: (room.group.agentType ?? 'claude-code'); : (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({ createScheduledTask({
id, id,
group_folder: room.group.folder, group_folder: room.group.folder,
@@ -722,11 +754,24 @@ export function createWebDashboardHandler(
if (!body) { if (!body) {
return jsonResponse({ error: 'Invalid task update' }, { status: 400 }); 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 = const prompt =
body.prompt === undefined body.prompt === undefined
? task.prompt ? task.prompt
: normalizeNonEmptyString(body.prompt); : normalizeTaskPrompt(body.prompt);
const scheduleChanged =
body.scheduleType !== undefined || body.scheduleValue !== undefined;
const scheduleType = const scheduleType =
body.scheduleType === undefined body.scheduleType === undefined
? task.schedule_type ? task.schedule_type
@@ -741,22 +786,26 @@ export function createWebDashboardHandler(
return jsonResponse({ error: 'Invalid task update' }, { status: 400 }); return jsonResponse({ error: 'Invalid task update' }, { status: 400 });
} }
const next = computeInitialNextRun( const updates: Parameters<typeof mutateTask>[1] = {
scheduleType,
scheduleValue,
opts.now?.() ?? new Date().toISOString(),
);
if (next.error) {
return jsonResponse({ error: next.error }, { status: 400 });
}
mutateTask(taskId, {
prompt, prompt,
schedule_type: scheduleType, schedule_type: scheduleType,
schedule_value: scheduleValue, 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); const updatedTask = loadTaskById(taskId);
return jsonResponse({ return jsonResponse({
ok: true, ok: true,