Skip to content

Commit 28ba12c

Browse files
mchwangclaude
andcommitted
Refuse malformed /api/runner requests with 400 before recording them
An unknown action, a non-integer expectedStateVersion, or a missing or malformed attempt ID now throws BadRequest before userAction, so the request answers 400 and nothing is saved under its action ID. Before, a bad attempt ID was refused inside userAction and recorded, so a corrected resend under the same key got ActionIdReused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0fcac82 commit 28ba12c

2 files changed

Lines changed: 20 additions & 8 deletions

File tree

‎test/runner-shutdown.test.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,20 @@ describe('/api/runner', () => {
205205
const first = await api(app, 'POST', '/api/runner', cancel);
206206
expect(first).toMatchObject({ status: 200, body: { result: { outcome: 'closed' }, runner: { task: { status: 'cancelled' } } } });
207207
expect((await api(app, 'POST', '/api/runner', cancel)).body.result).toEqual(first.body.result);
208-
expect((await api(app, 'POST', '/api/runner', { ...cancel, action: 'retry' })).status).toBe(409);
208+
expect((await api(app, 'POST', '/api/runner', { ...cancel, action: 'retry', attemptId: randomUUID() }))).toMatchObject({ status: 409, body: { error: /different request/ } });
209209
expect((await api(app, 'POST', '/api/runner', { ...cancel, actionId: 'not-a-uuid' })).status).toBe(400);
210210
});
211+
it('answers 400 for a malformed runner request and records nothing under its action ID', async () => {
212+
const { app } = await serve();
213+
const version = (await api(app, 'GET', '/api/runner')).body.stateVersion, actionId = randomUUID();
214+
expect(await api(app, 'POST', '/api/runner', { action: 'bogus', expectedStateVersion: version, actionId })).toMatchObject({ status: 400 });
215+
expect(await api(app, 'POST', '/api/runner', { action: 'retry', expectedStateVersion: 'x', actionId })).toMatchObject({ status: 400 });
216+
expect(await api(app, 'POST', '/api/runner', { action: 'cancel-attempt', attemptId: 'x', expectedStateVersion: version, actionId })).toMatchObject({ status: 400 });
217+
expect(await api(app, 'POST', '/api/runner', { action: 'retry', expectedStateVersion: version, actionId })).toMatchObject({ status: 400 });
218+
// Nothing was saved under the key, so the corrected request is evaluated, not answered with ActionIdReused.
219+
const fixed = await api(app, 'POST', '/api/runner', { action: 'cancel-task', expectedStateVersion: version, actionId });
220+
expect(fixed).toMatchObject({ status: 200, body: { result: { outcome: 'closed' } } });
221+
});
211222
it('cancels a running attempt, then retries it, and shutdown waits for the running retry', async () => {
212223
const { deps, settles } = fakeRunner();
213224
const { app, config, close } = await serve(deps);

‎web/server.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Questions, type QuestionAgent } from '../runner/questions.ts';
77
import { GhMergeGateway, type MergeGateway } from '../github/merge.ts';
88
import { MergeCoordinator, MergeNotApplied, MergeOutcomeUnknown } from '../runner/merge.ts';
99
import { RunnerCoordinator, type RunnerDeps } from '../runner/coordinator.ts';
10-
import { BadRequest, GuardRefusal, ShuttingDownError, isUuidV4, sameContext } from '../runner/lifecycle.ts';
10+
import { BadRequest, GuardRefusal, ShuttingDownError, assertUuidV4, isUuidV4, sameContext } from '../runner/lifecycle.ts';
1111
import { GhIssueGateway, type IssueGateway } from '../github/issues.ts';
1212
import { demoIssueGateway } from '../scripts/demo-issues.ts';
1313
import { IssueBoard } from './issues.ts';
@@ -47,19 +47,20 @@ export async function startServer(config: ReviewConfig, port = 4318, questionAge
4747
};
4848
const runnerAction = (input: Record<string, unknown>) => {
4949
const { action, attemptId, expectedStateVersion, actionId } = input;
50-
if (!['cancel-attempt', 'retry', 'cancel-task'].includes(action as string)) throw new GuardRefusal('Unsupported runner action.');
51-
if (!Number.isSafeInteger(expectedStateVersion)) throw new GuardRefusal('expectedStateVersion is required.');
50+
// Malformed requests are refused before userAction, so nothing is recorded under their action ID (HTTP 400).
51+
if (!['cancel-attempt', 'retry', 'cancel-task'].includes(action as string)) throw new BadRequest('Unsupported runner action.');
52+
if (!Number.isSafeInteger(expectedStateVersion)) throw new BadRequest('expectedStateVersion must be an integer.');
53+
if (action !== 'cancel-task') assertUuidV4(attemptId, 'Attempt ID');
5254
return service.store.userAction(identity, { actionId: actionId as string, kind: action as string, request: { attemptId, expectedStateVersion } }, () => {
5355
if (service.store.getTask(identity).stateVersion !== expectedStateVersion) throw new GuardRefusal('Stale task state. Reload before writing.');
5456
if (action === 'cancel-task') return { outcome: runner ? runner.cancelTask(identity, expectedStateVersion as number, actionId as string) : service.store.cancelTask(identity, expectedStateVersion as number, actionId as string) };
5557
if (!runner) throw new GuardRefusal('The runner is not available yet.');
56-
if (typeof attemptId !== 'string') throw new GuardRefusal('attemptId is required.');
5758
if (action === 'cancel-attempt') {
58-
if (!runner.stop(identity, attemptId, 'cancelled')) throw new GuardRefusal('That attempt is not running.');
59+
if (!runner.stop(identity, attemptId as string, 'cancelled')) throw new GuardRefusal('That attempt is not running.');
5960
return { outcome: 'stopping' };
6061
}
61-
const last = service.store.getAttempt(identity, attemptId);
62-
const retry = runner.retry(identity, attemptId, { expectedStateVersion: expectedStateVersion as number, kind: last.kind, item: last.item,
62+
const last = service.store.getAttempt(identity, attemptId as string);
63+
const retry = runner.retry(identity, attemptId as string, { expectedStateVersion: expectedStateVersion as number, kind: last.kind, item: last.item,
6364
expectedContext: service.store.currentContext(identity), deadline: Date.now() + 10 * 60_000 });
6465
return { outcome: 'started', attemptId: retry.id };
6566
}).response;

0 commit comments

Comments
 (0)