Skip to content

fix(runtime): stop a project's own middleware blocking its release asset build - #3646

Merged
kojiwakayama merged 3 commits into
mainfrom
fix/control-plane-project-middleware
Aug 12, 2026
Merged

fix(runtime): stop a project's own middleware blocking its release asset build#3646
kojiwakayama merged 3 commits into
mainfrom
fix/control-plane-project-middleware

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

The failure

projectMiddlewareRuntime.execute wraps the entire handler chain
(src/server/runtime-handler/index.ts:607), so a project-root middleware.ts runs in front of the
control plane's signed task:release-asset-build dispatch to
POST /api/control-plane/runs/{runId}/execute. That dispatch is the only thing that calls
beginReleaseAssetManifestBuild.

A project that merely gates requests in middleware, which is the exact shape docs/guides/middleware.md
demonstrates, therefore answers its own deploy with a 401. Reproduced through the real chain before
the fix:

release assets: control-plane build dispatch ...
  builds a manifest when the project's own middleware gates every request ... FAILED

error: AssertionError: Values are not equal: release asset build never started;
runtime answered 401: Unauthorized
    [Diff] Actual / Expected
-   false
+   true

The user sees none of that. The manifest row is never created, the state stays missing, and 120
seconds later veryfront deploy says Release assets were not ready within 120s (last state: missing), naming neither middleware nor the dispatch. This is the second instance of the bug class
fixed in #3641; the first was security.csrf.

The decision, and why

Chosen: (a) a signed control-plane dispatch does not traverse project middleware.

Stated precisely, so it can be argued with:

A request that both (1) addresses one of the five anchored control-plane method/path shapes and
(2) carries the x-veryfront-control-plane-jws header does not run project middleware. Every
other request does, unchanged: an unsigned request to the very same path, a project route that
happens to sit inside /api/control-plane/, a look-alike prefix, and a registered surface
addressed with a method no handler owns.

The reasoning, in the order that decided it:

  1. It is not the project's traffic. No project route is addressed. The request targets a
    platform handler, and asks the runtime to perform internal work (build this release's asset
    manifest; start, resume, or cancel a run). "Users expect middleware to see their traffic" is
    true and is not in tension with this: a platform dispatch was never their traffic.

  2. Middleware cannot authorize it even in principle. createApplicationRequest withholds every
    x-veryfront-* header from project code (src/security/http/application-request.ts; asserted by
    the existing test "exposes application auth while withholding infrastructure headers"). The
    signature the receiving handler verifies is invisible to the middleware that would have to
    trust it. So middleware that gates on a credential has no branch that lets the dispatch through.
    This is the bug class exactly: a gate demanding a credential the caller structurally cannot hold,
    here because the platform itself strips it one layer earlier.

  3. The "middleware sees everything" contract was already not true. execute at
    project-middleware.ts:107 already bypassed middleware for three of the five control-plane
    surfaces (stream, resume, cancel) via isConfigOptionalControlPlaneRunRequest. The contract was
    inconsistent, not absolute; this makes it coherent and keyed on something stronger.

(b) traverse but cannot reject was rejected: running project code and discarding its answer is a
contract nobody can reason about. Middleware could still stall the dispatch, mutate the response of
a platform protocol, or run side effects on internal traffic, while its documented ability to gate
becomes a lie.

(c) documentation plus a legible error was rejected as the primary fix: it makes every project
with an auth middleware responsible for maintaining path exclusions against a platform-internal
route list that changes between framework versions. Worse, the advice would have to be "exclude
/api/control-plane/", and that namespace is reserved but not exclusively routed, so the advice
would also tell projects to unprotect their own routes under that prefix. The legible-error half of
(c) is kept, below, as defence in depth.

Not a weakening

The bypass reuses isSignedControlPlaneDispatch from #3641 unchanged: anchored method/path and
the signature header. It exempts nothing that is not authenticated more strongly downstream. The
only routes it can reach (ProjectRunExecuteHandler, AgentRunResumeHandler,
AgentRunCancelHandler, agents list) call verifyControlPlaneRequest and answer 401 without a
valid Ed25519 envelope bound to issuer, audience, project id, method, path and body hash. Skipping
project middleware therefore cannot deliver an unauthenticated request to project code; it can only
deliver it to a handler that rejects it. A prefix match would have shipped a bypass a project could
trigger by choosing a path; this cannot.

Making the silence stop

A manifest row appears the moment the runtime begins the build. Never observing one across the
whole polling window is therefore evidence, not a guess, that the dispatch never reached the
builder. waitForReleaseAssetManifest now distinguishes that case:

Release assets were not ready within 120s (last state: missing). No manifest was ever created for
this release, so the release asset build dispatch (POST
/api/control-plane/runs/{runId}/execute, target "task:release-asset-build") never reached the
builder on the deployed runtime. Check the runtime logs for this release, and any request gate in
front of it such as the project's middleware.ts or a security policy in veryfront.config.

A manifest that was seen and later vanished, or a poll that kept failing (lastTransientFailure),
keeps the original wording, because in those cases the build state is genuinely unknown and the
stronger claim would be false.

Tests

Added, all failing first for the right reason:

  • release-assets/build-dispatch-security.test.ts now drives the whole chain, project middleware
    then CsrfHandler then ProjectRunExecuteHandler, and asserts the release asset build executor
    is reached while a middleware that 401s everything is installed.
  • project-middleware.test.ts: all five signed control-plane surfaces reach the route without
    loading middleware.
  • deploy-project.test.ts: the timeout names the refused dispatch when no manifest row ever appears.

Adversarial, proving the gate still runs (these passed before and after; a fix that only proves the
happy path is how a bypass ships):

  • an unsigned request to POST /api/control-plane/runs/{runId}/execute, at both the unit and the
    full-chain level;
  • POST /api/control-plane/checkout, a project route inside the reserved namespace;
  • POST /api/control-plane-mirror/runs/run_1/execute, a look-alike prefix;
  • PUT /api/control-plane/runs/run_1/execute, a registered surface with an unowned method;
  • a dot-segment path that normalizes out of the surface.

No existing test was weakened, changed, or deleted. The pre-existing
"keeps project middleware enabled for control-plane run execution" test asserts an unsigned
execute request is still answered by middleware, so it keeps its assertions untouched and now serves
as the adversarial regression; only an explanatory comment was added above it.

Public contract change

docs/guides/middleware.md states the new rule. Reviewers who prefer (b) or (c) should say so on
this PR: the semantics are the decision here, the diff is small.

Summary by CodeRabbit

  • Bug Fixes

    • Improved release asset build timeout messages when dispatching fails before a manifest is created.
    • Error details now identify the failed dispatch, relevant request path, troubleshooting guidance, and polling attempts.
  • Documentation

    • Clarified middleware behavior for signed control-plane requests used during release asset generation and run lifecycle operations.
  • Reliability

    • Ensured authenticated control-plane operations proceed correctly while unsigned or unrelated requests continue receiving normal middleware checks.

…set build

`projectMiddlewareRuntime.execute` wraps the entire handler chain, so a root
`middleware.ts` sits in front of the control plane's signed
`task:release-asset-build` dispatch to
`POST /api/control-plane/runs/{runId}/execute`. A project that merely gates
requests in middleware, the shape the middleware guide itself demonstrates,
answers its own deploy with a 401 and the manifest is never built. The failure
surfaces 120 seconds later as `Release assets were not ready within 120s (last
state: missing)`, naming neither middleware nor the dispatch.

Middleware cannot authorize that dispatch even in principle.
`createApplicationRequest` withholds every `x-veryfront-*` header from project
code, so the signature the receiving handler verifies is invisible to the
middleware that would have to trust it. The gate demands a credential the caller
structurally cannot present, which is the same shape as the CSRF instance fixed
in #3641.

Chosen semantics: a signed control-plane dispatch does not traverse project
middleware. It is not the project's traffic. It addresses a platform handler in
a namespace the project does not serve, and asks the runtime to perform internal
work. Everything else still traverses middleware unchanged, including an
unsigned request to the same path and a project route that merely sits inside
`/api/control-plane/`.

The bypass reuses `isSignedControlPlaneDispatch` from #3641, which requires both
an anchored method/path pair a control-plane handler owns and the signature
header that handler verifies. It concedes nothing: the only routes it can reach
answer 401 without a valid envelope and never fall through to project code.

`veryfront deploy` also stops reporting a silent timeout. A manifest row appears
the moment the runtime begins the build, so never observing one means the
dispatch never reached the builder. That case now names the dispatch, the route,
and the gates that can refuse it, instead of only `last state: missing`. A
manifest that was seen and then vanished, or a poll that kept failing, keeps the
original wording.

Tests cover the signed dispatch reaching the release asset build executor
through the real chain (project middleware, then `CsrfHandler`, then
`ProjectRunExecuteHandler`), all five control-plane surfaces bypassing
middleware, and four adversarial cases that must keep running it: an unsigned
request to the execute path, a project route inside the reserved namespace, a
look-alike prefix, and a registered surface addressed with an unowned method.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kojiwakayama, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a4602bf8-eb41-4165-a74f-b3d917cf3d1d

📥 Commits

Reviewing files that changed from the base of the PR and between f81dc1a and 6229b79.

📒 Files selected for processing (4)
  • cli/shared/deployment/deploy-project.test.ts
  • cli/shared/deployment/deploy-project.ts
  • docs/guides/middleware.md
  • src/release-assets/build-dispatch-security.test.ts
📝 Walkthrough

Walkthrough

Changes

Control-plane dispatch flow

Layer / File(s) Summary
Signed dispatch middleware bypass
src/server/runtime-handler/project-middleware.ts, src/server/runtime-handler/project-middleware.test.ts, docs/guides/middleware.md
Signed control-plane dispatches bypass project middleware. Unsigned, unsupported, malformed, and unrelated requests continue through middleware.
Release asset dispatch integration
src/release-assets/build-dispatch-security.test.ts
Dispatch tests now run through ProjectMiddlewareRuntime and verify signed and unsigned request behavior.
Manifest polling timeout diagnostics
cli/shared/deployment/deploy-project.ts, cli/shared/deployment/deploy-project.test.ts
Polling tracks observed manifests and reports when the builder dispatch was not reached. The test verifies the diagnostic message and polling attempts.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DispatchTest
  participant ProjectMiddlewareRuntime
  participant ProjectMiddleware
  participant ReleaseAssetExecutor
  DispatchTest->>ProjectMiddlewareRuntime: Send signed control-plane dispatch
  ProjectMiddlewareRuntime->>ReleaseAssetExecutor: Forward authenticated dispatch
  ReleaseAssetExecutor-->>DispatchTest: Return 200 response
  DispatchTest->>ProjectMiddlewareRuntime: Send unsigned request
  ProjectMiddlewareRuntime->>ProjectMiddleware: Execute project middleware
  ProjectMiddleware-->>DispatchTest: Return 401 response
Loading

Possibly related PRs

Suggested labels: needs-human-input

Suggested reviewers: kwakayama

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: preventing project middleware from blocking release asset builds.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/control-plane-project-middleware

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f81dc1af78

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/guides/middleware.md Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cli/shared/deployment/deploy-project.ts`:
- Around line 643-645: In the deployment polling flow, add a monotonic
observedTransientFailure flag alongside lastTransientFailure, set it whenever a
retryable control-plane read fails, and use it in the neverStarted calculation.
Continue clearing lastTransientFailure after successful reads for last-failure
messaging, and add a regression covering one retryable error followed by missing
manifests that expects the generic timeout message.

In `@docs/guides/middleware.md`:
- Around line 209-210: The control-plane middleware explanation is too dense. In
the documentation section describing root middleware and control-plane dispatch,
split the routing behavior, signature limitation, narrow bypass condition, and
unsigned/other-path exceptions into short, direct, active present-tense
paragraphs while preserving all existing conditions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c3d40733-55f8-46ab-8a1e-a188e86dced5

📥 Commits

Reviewing files that changed from the base of the PR and between 870bcde and f81dc1a.

📒 Files selected for processing (6)
  • cli/shared/deployment/deploy-project.test.ts
  • cli/shared/deployment/deploy-project.ts
  • docs/guides/middleware.md
  • src/release-assets/build-dispatch-security.test.ts
  • src/server/runtime-handler/project-middleware.test.ts
  • src/server/runtime-handler/project-middleware.ts

Comment thread cli/shared/deployment/deploy-project.ts Outdated
Comment thread docs/guides/middleware.md Outdated
…ions bag

The auth gate and the project-middleware gate are being exempted on two
branches, and both widen `dispatchReleaseAssetBuild`. Grown as positional
parameters they merge into a signature that still compiles while every
existing call binds its argument to the wrong slot: a `{ projectMiddleware }`
argument lands in the `auth` parameter, no middleware is installed, and the
test that proves the middleware bypass works keeps passing without ever
exercising it.

Take one named options bag instead, and assemble all three gates the way the
runtime does — project middleware outermost, then the security handlers, then
the run executor — so a fix for one gate is exercised with the others
standing.
@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Pushed: this branch and #3645 would have merged into something broken

Both this PR and #3645 widen dispatchReleaseAssetBuild in src/release-assets/build-dispatch-security.test.ts, and both grew it as a positional parameter — auth? here, options there.

Merging them produces a two-line conflict in the parameter list and auto-merges everything else. That is the trap: the conflict looks trivial, so the natural resolution is to keep both parameters. It compiles. And then:

  builds a manifest when the project's own middleware gates every request ... ok
  keeps project middleware in front of an unsigned request to the same path ... FAILED

    [Diff] Actual / Expected
    -   200
    +   401

The ok on the first line is the real damage. dispatchReleaseAssetBuild(undefined, { projectMiddleware: [...] }) binds its argument to auth, so no middleware is installed at all — the test that proves the middleware bypass works passes without ever exercising it. The second test fails only because it also asserts the negative direction.

Fix

One named options bag, identical on both branches:

interface DispatchOptions {
  readonly auth?: SecurityConfig["auth"];
  readonly projectMiddleware?: MiddlewareFunction[];
  readonly unsigned?: boolean;
}

async function dispatchReleaseAssetBuild(
  csrf: CsrfSetting | undefined,
  options: DispatchOptions = {},
): Promise<DispatchOutcome>

A named field cannot mis-merge the way a positional slot can. The harness also now assembles all three gates in the order the runtime does — project middleware outermost, then the security handlers, then the run executor — so a fix for one gate is exercised with the others standing. Each branch keeps only its own tests.

Verified by merging, not by reasoning

… read

`neverStarted` read `lastTransientFailure`, which is cleared after any
successful read. One retryable control-plane failure followed by reads that
returned no manifest row therefore still produced the strong claim that the
`task:release-asset-build` dispatch never reached the builder, when the failed
read had in fact left the build state unknown for that window.

Track `observedTransientFailure` monotonically and key `neverStarted` on it.
`lastTransientFailure` keeps its existing role of naming the most recent
failure in the message, so no existing wording changes.

Also narrow the middleware guide's control-plane paragraph: unsigned
`POST .../stream`, `POST .../resume` and `DELETE .../runs/{runId}` bypass
middleware through `isConfigOptionalControlPlaneRunRequest` regardless of the
signature header, so the blanket claim that unsigned requests still run
middleware was false for those three. Split the section into short paragraphs.
@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 2058a38 Aug 12, 2026
33 checks passed
@kojiwakayama
kojiwakayama deleted the fix/control-plane-project-middleware branch August 12, 2026 19:05
@kojiwakayama kojiwakayama mentioned this pull request Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant