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
45 changes: 41 additions & 4 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ import { buildRepoOutcomeCalibration } from "../services/outcome-calibration";
import { loadGatePrecisionReport } from "../services/gate-precision";
import { buildMaintainerQualityDashboard, isMaintainerQualityDataStale } from "../services/maintainer-quality-dashboard";
import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics";
import { compileFocusManifestPolicy } from "../signals/focus-manifest";
import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest";
import { loadPublicRepoFocusManifest, loadRepoFocusManifest, upsertRepoFocusManifest } from "../signals/focus-manifest-loader";
import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack";
import { generateContributorIssueDrafts } from "../services/contributor-issue-draft";
Expand Down Expand Up @@ -302,6 +302,7 @@ async function recordRouteProductUsage(
}).catch(() => undefined);
}

const LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES = 1024 * 1024;
const QUEUE_INTELLIGENCE_MAX_BODY_BYTES = 1024 * 1024;
const QUEUE_INTELLIGENCE_MAX_PULL_REQUESTS = 250;
const QUEUE_INTELLIGENCE_MAX_AUTHOR_LENGTH = 100;
Expand All @@ -316,6 +317,14 @@ function parsePositiveInt(value: string | null | undefined): number | null {
return parsed;
}

function isJsonByteLengthWithinLimit(value: unknown, maxBytes: number): boolean {
try {
return new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes;
} catch {
return false;
}
}

async function readRequestBodyWithLimit(request: Request, maxBytes: number): Promise<string | null> {
const stream = request.body;
if (!stream) return "";
Expand Down Expand Up @@ -473,6 +482,12 @@ const branchEligibilitySchema = z
.strict()
.transform((value) => ({ ...value, status: value.status === "eligible" ? ("unknown" as const) : value.status, source: "user_supplied" as const }));

const focusManifestInputSchema = z
.record(z.string(), z.unknown())
.refine((manifest) => isJsonByteLengthWithinLimit(manifest, MAX_FOCUS_MANIFEST_BYTES), {
message: `focusManifest must serialize to ${MAX_FOCUS_MANIFEST_BYTES} bytes or fewer`,
});

const localBranchAnalysisSchema = z
.object({
login: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS),
Expand Down Expand Up @@ -500,7 +515,7 @@ const localBranchAnalysisSchema = z
scenarioNotes: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(),
pendingCommitCount: z.number().int().min(0).optional(),
ciStatusHints: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(),
focusManifest: z.record(z.string(), z.unknown()).optional(),
focusManifest: focusManifestInputSchema.optional(),
branchEligibility: branchEligibilitySchema.optional(),
})
.strict();
Expand Down Expand Up @@ -2467,7 +2482,18 @@ export function createApp() {
});

app.post("/v1/local/branch-analysis", async (c) => {
const body = await c.req.json().catch(() => null);
const contentLength = parsePositiveInt(c.req.header("content-length"));
if (contentLength !== null && contentLength > LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES) {
return c.json({ error: "payload_too_large", maxBytes: LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES }, 413);
}
const rawBody = await readRequestBodyWithLimit(c.req.raw, LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES);
if (rawBody === null) return c.json({ error: "payload_too_large", maxBytes: LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES }, 413);
let body: unknown;
try {
body = JSON.parse(rawBody);
} catch {
body = null;
}
const parsed = localBranchAnalysisSchema.safeParse(body);
if (!parsed.success) return c.json({ error: "invalid_local_branch_analysis_request", issues: parsed.error.issues }, 400);
const unauthorized = await requireContributorAccess(c, parsed.data.login);
Expand Down Expand Up @@ -2542,7 +2568,18 @@ export function createApp() {
});

app.post("/v1/local/remediation-plan", async (c) => {
const body = await c.req.json().catch(() => null);
const contentLength = parsePositiveInt(c.req.header("content-length"));
if (contentLength !== null && contentLength > LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES) {
return c.json({ error: "payload_too_large", maxBytes: LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES }, 413);
}
const rawBody = await readRequestBodyWithLimit(c.req.raw, LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES);
if (rawBody === null) return c.json({ error: "payload_too_large", maxBytes: LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES }, 413);
let body: unknown;
try {
body = JSON.parse(rawBody);
} catch {
body = null;
}
const parsed = localBranchAnalysisSchema.safeParse(body);
if (!parsed.success) return c.json({ error: "invalid_local_branch_analysis_request", issues: parsed.error.issues }, 400);
const unauthorized = await requireContributorAccess(c, parsed.data.login);
Expand Down
17 changes: 16 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ import {
import { applyStepResult, buildPlanDag, nextReadySteps, planProgress, validatePlanDag, type PlanDag } from "../services/plan-dag";
import { isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution";
import { AGENT_ACTION_CLASSES, isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy";
import { MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest";
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { buildPredictedGateVerdict } from "../rules/predicted-gate";
import { buildIssueSlopAssessment, buildSlopAssessment, ISSUE_SLOP_RUBRIC_MARKDOWN, SLOP_RUBRIC_MARKDOWN } from "../signals/slop";
Expand Down Expand Up @@ -368,6 +369,20 @@ const automationStateOutputSchema = {
pendingActionCount: z.number().optional(),
};

const focusManifestInputSchema = z
.record(z.string(), z.unknown())
.refine((manifest) => isJsonByteLengthWithinLimit(manifest, MAX_FOCUS_MANIFEST_BYTES), {
message: `focusManifest must serialize to ${MAX_FOCUS_MANIFEST_BYTES} bytes or fewer`,
});

function isJsonByteLengthWithinLimit(value: unknown, maxBytes: number): boolean {
try {
return new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes;
} catch {
return false;
}
}

const localBranchAnalysisShape = {
login: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS),
repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS),
Expand All @@ -391,7 +406,7 @@ const localBranchAnalysisShape = {
expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(),
projectedCredibility: z.number().min(0).max(1).optional(),
scenarioNotes: z.array(z.string()).max(20).optional(),
focusManifest: z.record(z.string(), z.unknown()).optional(),
focusManifest: focusManifestInputSchema.optional(),
branchEligibility: callerBranchEligibilitySchema.optional(),
localScorer: z
.object({
Expand Down
91 changes: 91 additions & 0 deletions test/unit/routes-remediation-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ import { createSessionForGitHubUser } from "../../src/auth/security";
import { upsertInstallation, upsertRepositoryFromGitHub } from "../../src/db/repositories";
import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader";
import { createTestEnv } from "../helpers/d1";
import { MAX_FOCUS_MANIFEST_BYTES } from "../../src/signals/focus-manifest";

const PATH = "/v1/local/remediation-plan";
const BRANCH_ANALYSIS_PATH = "/v1/local/branch-analysis";

// Mirrors LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES in src/api/routes.ts (1 MiB).
const MAX_BODY_BYTES = 1024 * 1024;

function apiHeaders(env: Env): Record<string, string> {
return {
Expand Down Expand Up @@ -116,3 +121,89 @@ describe("remediation-plan route", () => {
});
});
});

describe("local branch routes byte-cap ingestion bound", () => {
for (const path of [PATH, BRANCH_ANALYSIS_PATH]) {
it(`rejects an oversized request body with 413 payload_too_large via Content-Length (${path})`, async () => {
const app = createApp();
const env = createTestEnv();
await seedRepo(env, "miner", "demo", 301);
const response = await app.request(
path,
{
method: "POST",
headers: { ...apiHeaders(env), "content-length": String(MAX_BODY_BYTES + 1) },
body: JSON.stringify(branchPayload("oktofeesh1", "miner/demo")),
},
env,
);
expect(response.status).toBe(413);
await expect(response.json()).resolves.toMatchObject({ error: "payload_too_large", maxBytes: MAX_BODY_BYTES });
});

it(`rejects an oversized streamed request body with 413 payload_too_large (${path})`, async () => {
const app = createApp();
const env = createTestEnv();
await seedRepo(env, "miner", "demo", 301);
// A body that exceeds the cap forces the streaming reader (readRequestBodyWithLimit) to bail
// out even when the Content-Length header is absent/under the limit.
const oversizedBody = JSON.stringify(
branchPayload("oktofeesh1", "miner/demo", { body: "x".repeat(MAX_BODY_BYTES + 16) }),
);
const response = await app.request(
path,
{
method: "POST",
headers: apiHeaders(env),
body: oversizedBody,
},
env,
);
expect(response.status).toBe(413);
await expect(response.json()).resolves.toMatchObject({ error: "payload_too_large", maxBytes: MAX_BODY_BYTES });
});

it(`rejects a focusManifest that serializes beyond the byte cap with 400 (${path})`, async () => {
const app = createApp();
const env = createTestEnv();
await seedRepo(env, "miner", "demo", 301);
// Stays under the 1 MiB request-body cap but blows past the focus-manifest serialized byte cap,
// so it must trip the focusManifestInputSchema refinement (not the body limit).
const oversizedManifest = { present: true, note: "a".repeat(MAX_FOCUS_MANIFEST_BYTES + 1) };
const requestBody = JSON.stringify(branchPayload("oktofeesh1", "miner/demo", { focusManifest: oversizedManifest }));
expect(requestBody.length).toBeLessThanOrEqual(MAX_BODY_BYTES);
const response = await app.request(
path,
{
method: "POST",
headers: apiHeaders(env),
body: requestBody,
},
env,
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({ error: "invalid_local_branch_analysis_request" });
});

it(`accepts a normal-size body and within-cap focusManifest with 200 (${path})`, async () => {
const app = createApp();
const env = createTestEnv();
await seedRepo(env, "miner", "demo", 301);
const response = await app.request(
path,
{
method: "POST",
headers: apiHeaders(env),
body: JSON.stringify(
branchPayload("oktofeesh1", "miner/demo", {
focusManifest: { present: true, wantedPaths: ["src/"], source: "caller" },
}),
),
},
env,
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ login: "oktofeesh1", repoFullName: "miner/demo" });
});
}
});
Loading