Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions app/api/attempt/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { NextResponse } from "next/server";
import { runAttempt } from "@/lib/arena";
import { runAttempt, validateAttemptInput } from "@/lib/arena";
import { getChallenge } from "@/lib/challenges/levels";
import { ensureSession } from "@/lib/http";
import { rateLimit } from "@/lib/ratelimit";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const MAX_INPUT = 4000;

export async function POST(req: Request) {
const session = ensureSession();

Expand All @@ -31,11 +29,12 @@ export async function POST(req: Request) {
{ status: 400 },
);
}
if (input.length === 0 || input.length > MAX_INPUT) {
return NextResponse.json(
{ error: `input must be 1..${MAX_INPUT} characters` },
{ status: 400 },
);
try {
validateAttemptInput(input);
} catch (err) {
const message =
err instanceof Error ? err.message : "invalid input";
return NextResponse.json({ error: message }, { status: 400 });
}
if (!getChallenge(challengeId)) {
return NextResponse.json({ error: "unknown challenge" }, { status: 404 });
Expand Down
13 changes: 13 additions & 0 deletions lib/arena.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ import type { Verdict } from "./types";
// Scoring rule: only the *first* crack of a level by a session earns points;
// subsequent cracks and all failures earn zero.

export const MAX_ATTEMPT_INPUT = 4000;

export function validateAttemptInput(input: string): void {
if (input.trim().length === 0) {
throw new Error("input must not be empty or whitespace-only");
}
if (input.length > MAX_ATTEMPT_INPUT) {
throw new Error(`input must be at most ${MAX_ATTEMPT_INPUT} characters`);
}
}

export interface RunAttemptArgs {
session: Session;
challengeId: string;
Expand All @@ -44,6 +55,8 @@ export async function runAttempt(args: RunAttemptArgs): Promise<AttemptOutcome>
throw new Error(`Unknown challenge: ${args.challengeId}`);
}

validateAttemptInput(args.input);

const priorAttempts = args.dryRun
? args.priorAttempts ?? 0
: await countAttempts(args.session.sessionId, challenge.id);
Expand Down
45 changes: 44 additions & 1 deletion tests/pipeline.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runAttempt } from "@/lib/arena";
import * as agentModule from "@/lib/agent";
import { MAX_ATTEMPT_INPUT, runAttempt } from "@/lib/arena";
import {
countAttempts,
getLeaderboard,
Expand All @@ -20,10 +21,52 @@ describe("end-to-end attempt pipeline with persistence", () => {
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});

it("rejects whitespace-only input before agent or persistence", async () => {
const respond = vi.fn();
vi.spyOn(agentModule, "getAgent").mockReturnValue({
name: "test",
isAvailable: () => true,
respond,
});

await expect(
runAttempt({
session: alice,
challengeId: "level-1-open-book",
input: " ",
}),
).rejects.toThrow(/input/i);

expect(respond).not.toHaveBeenCalled();
expect(await countAttempts("alice-1", "level-1-open-book")).toBe(0);
});

it("rejects oversized input before agent or persistence", async () => {
const respond = vi.fn();
vi.spyOn(agentModule, "getAgent").mockReturnValue({
name: "test",
isAvailable: () => true,
respond,
});

const oversized = "x".repeat(MAX_ATTEMPT_INPUT + 1);
await expect(
runAttempt({
session: alice,
challengeId: "level-1-open-book",
input: oversized,
}),
).rejects.toThrow(/input/i);

expect(respond).not.toHaveBeenCalled();
expect(await countAttempts("alice-1", "level-1-open-book")).toBe(0);
});

it("persists a failed attempt with zero points", async () => {
const out = await runAttempt({
session: alice,
Expand Down
Loading