Skip to content

feat(deploy): direct-to-Cloudflare deploy — --backend cloudflare / --provision - #273

Merged
Brooooooklyn merged 128 commits into
mainfrom
feat/backend-cloudflare-deploy
Jul 22, 2026
Merged

Brooooooklyn merged 128 commits into
mainfrom
feat/backend-cloudflare-deploy

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Jul 17, 2026 •

Copy link
Copy Markdown
Member

void deploy --backend cloudflare + --provision — direct-to-Cloudflare deploy

Lets users deploy a Void app straight to their own Cloudflare account, bypassing the Void platform: vite build + a bare wrangler deploy, with --provision creating the inferred D1 / KV / R2 / Queues / Hyperdrive / ISR_CACHE resources and recording their real IDs in the root wrangler.jsonc. The managed void deploy path forks before any of this and is byte-for-byte unchanged.

void deploy --backend cloudflare --provision   # dev machine: provision inferred resources, then deploy
void deploy --backend cloudflare               # CI / every push: drift-check → build → deploy (narrow token)

Both paths bypass PlatformClient entirely (no project link, no void auth login, no platform API).

What's in this PR

Area File(s) What
Entry surface cli/args.ts, cli/cli.ts, cli/deploy.ts --backend cloudflare + --provision flags, validation (rejects --project/--dir/--spa/--skip-build on this path), early fork to runCloudflareDeploy
Orchestrator cli/deploy.ts runCloudflareDeploy app-class guard → desired-set inference (from source, pre-build) → auth/account/CI preflight → provision or drift-check → remote D1 migrate → build → deploy
Provisioner cli/provision.ts (new) shell-to-wrangler resource provisioner: read-first + CF existence pre-check + create-with-postcondition; one cross-process lock on the config path; pinned account/transport; dotenv-redirect fail-closed
Config writer cli/wrangler.ts comment-preserving root wrangler.jsonc writer (jsonc modify/applyEdits): queue producers/consumers, migrations_dir, account_id; fail-closed on parse/dup/dropped-comment
Env-schema probe cli/env-schema-probe.ts (new) isolated subprocess that evaluates env.ts + Vite config under a scrubbed env (no credentials in the code-evaluating process)
Secrets preflight cli/env-validation.ts self-host warn (plaintext vars) + gate (wrangler secret list → required-secret check), with account and worker-name reconciliation; platform path unchanged
Launcher cli/preset.ts resolveWranglerArgv / resolveWranglerCommand — resolves wrangler's real inner CLI, not the bin wrapper
Docs / agents docs/{guide/*,integrations/cloudflare,reference/cli}.md, AGENT_PROMPT.md, getting-started-prompt.txt, skills/void/SKILL.md, cli/help.ts the self-host flow: wrangler auth, account pinning, provision scope, migration/auth gates, .env* plaintext vs wrangler secret put, nested queue naming

New dependency: proper-lockfile (+ types) for the cross-process provision lock.

Security model — the emitted-config tamper guard

The wrangler deploy step is credentialed but the preceding vite build runs untrusted project code with the Cloudflare credentials scrubbed, so it can only edit files — including the dist/ssr/wrangler.json the deploy uploads. verifyGeneratedDeployConfig treats the emitted config as adversarial and rejects any capability the pre-build root config did not already grant. Every upload-config field a bare wrangler deploy acts on is pinned to a pre-build source of truth:

id-bearing bindings (d1/kv/r2/hyperdrive) · pass-through bindings (services/…) · unsafe · routes / workers_dev
· ai · durable objects (external-DO reject) · migrations (destructive-op reject) · container applications
· cron triggers · queues (producer+consumer)          — inject / drop / retarget all fail closed

Plus: the account is pinned, the wrangler uploader bundle is content-hashed before the first credentialed run and re-checked before every credentialed spawn, the secrets gate reconciles both account and worker name, and — on the no-provision path — the root config is checked against its pre-probe state so a pre-build probe can't launder the bindings. FP-safety of each pin is verified against real build artifacts.

Testing

  • Full vp test run packages/void green — 3662 passed / 8 skipped / 0 failed; vp run check + vp lint + vp fmt clean.
  • The guard alone has 325 unit tests (test/unit/cloudflare-deploy.test.ts); provisioner (provision.test.ts) and self-host secrets gate (env-validation-selfhost.test.ts) are separately covered.
  • Every change passed adversarial codex:adversarial-review; the guard was hardened across multiple rounds against injection, removal, ordering, and config-laundering vectors, each fix verified FP-safe against real emitted artifacts.

Known limitations / follow-ups

🤖 Generated with Claude Code

https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora


Note

Medium Risk
New deploy surface and dependency affect how users push workers and secrets to their own Cloudflare accounts; managed void deploy is forked early but provision locking and documented env/secret behavior need careful review before merge.

Overview
Introduces void deploy --backend cloudflare and --provision on the CLI: parseDeployArgs accepts the new flags, enforces --provision only with the Cloudflare backend, and rejects --project, --dir, and --spa on that path so users are not misled about where the worker lands. cli.ts forwards backend and provision into runDeploy.

Documentation is the bulk of the change: auth, deployment, env vars, queues, Cloudflare integration, and CLI reference now describe the self-host flow (wrangler auth, account pinning, provision scope, migration/auth gates, .env* plaintext vs wrangler secret put, nested queue naming). Agent prompts, getting-started text, and the Void skill routing table gain a self-host deploy entry.

Adds proper-lockfile (and types) as a dependency for cross-process provision locking referenced by the Cloudflare deploy path.

Reviewed by Cursor Bugbot for commit 8cf1c7c. Bugbot is set up for automated code reviews on this repo. Configure here.

Brooooooklyn and others added 30 commits July 15, 2026 20:29
…rk skeleton

Wire the CLI entry surface for a self-hosted Cloudflare deploy backend:
parse and validate --backend cloudflare and --provision in parseDeployArgs,
thread them through cli.ts into RunDeployOptions, and add an early fork in
runDeployInner to a stub runCloudflareDeploy that throws not-yet-implemented.

--backend accepts only 'cloudflare'; --provision requires --backend cloudflare.
The managed (default) deploy path is unchanged: the fork is the first statement
of runDeployInner and returns early. Real orchestration lands in a later task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sage

Two codex adversarial-review findings on the deploy --backend flag:

- backend validation only checked parseArgs' collapsed final value, so
  `--backend node --backend cloudflare` succeeded despite node being
  invalid. Add a token-aware dup guard (mirroring --project) that rejects
  a repeated --backend before value validation.
- bare `--backend` (no value) hit the generic missing-value catch branch
  hard-coded to the --project message. Extract the offending option name
  from the parseArgs error and emit a backend-specific message.

Adds three regression tests in deploy-args.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ation

Thin wrapper over resolveProjectCommand that resolves the project's local
wrangler binary (node_modules/.bin/wrangler when present, bare `wrangler`
otherwise) plus a subcommand, returning an execSync/spawnSync-ready command
string. Prerequisite for the --backend cloudflare deploy path (provisioner +
orchestrator). wrangler is not a special-cased bin like vite->vp, so
delegating reuses the exact local-bin resolution without duplication.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Project roots containing a space (or shell metacharacters) produced an
unquoted absolute bin path that execSync's shell would word-split, e.g.
`/tmp/my app/node_modules/.bin/wrangler deploy` tried to exec `/tmp/my`.

Quote only the executable segment, only when it contains shell-significant
characters, so space-free paths stay byte-identical. Platform-aware:
single quotes on POSIX, double quotes on cmd.exe. Fixes resolveWranglerCommand
and the pre-existing vite/framework build paths at the source.

Add a filesystem-backed regression test with a root containing a space and a
single quote, asserting the command is quoted and executes the right binary
through a shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ning

Add writeProvisionedWranglerConfig(root, patch) that fills the root-config
gaps `wrangler <res> create --update-config` won't write: top-level
account_id (always overwrite), name / compatibility_date (only-if-absent),
and migrations_dir on the D1 entry matched by binding (upsert).

Uses jsonc-parser modify()+applyEdits() so comments and unrelated content
survive (unlike the lossy JSON.stringify in syncWranglerBindings). All edits
are idempotent upserts; indentation is sniffed from the existing file.

Queues are intentionally omitted: mergeBindings (index.ts) already emits
scanner-derived queues.producers/consumers into dist/ssr/wrangler.json
unconditionally, and @cloudflare/vite-plugin merges the plugin config over
the root config with defu (which concatenates arrays), so a root-declared
queue entry would duplicate the scanner's in the deployed config. Queue
provisioning stays `wrangler queues create` in the provisioner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finding 2: writeProvisionedWranglerConfig treated any jsonc parse failure as
`{}`, so a UTF-8 BOM (kept by Node's utf-8 read) or malformed file clobbered
existing name/compatibility_date (only-if-absent saw them absent) and skipped
the matching D1 entry. Now strip + preserve a leading BOM (matching wrangler),
and fail closed on a genuine parse error (throw, no write) instead of writing
over the file.

Finding 1: framework self-host (Class A forces queues=[] at index.ts:738;
Class B/C return before mergeBindings) emits NO queue bindings from the build,
yet framework mode DOES support production queues via the deploy wrapper. Add a
comment-preserving, idempotent queues.producers/consumers upsert (producers keyed
by binding, consumers by queue) so the Task 6 orchestrator can bind queues for
framework builds only; native mode must not pass queues (build emits them, defu
would duplicate). Corrected the per-app-class justification in code + report.

5 new tests (BOM round-trip + only-if-absent + D1 match; malformed throws
untouched; queue upsert one-each; idempotent; append no-dup). 55 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The queue producer/consumer upsert in writeProvisionedWranglerConfig
replaced the whole matching array element via a single jsonc-parser
modify() at the element index whenever the existing entry differed from
the narrow inferred patch. That dropped inline comments and any sibling
fields the patch omits (producer delivery_delay; consumer
dead_letter_queue, max_concurrency, …), silently altering production
delivery/retry/concurrency/failure-routing and breaking the writer's
comment/unrelated-content preservation contract.

Upsert per-property instead: for an existing match, modify() only the
specific keys the patch supplies (path into the element's properties),
leaving unmentioned fields and comments intact; new entries still
append. Skips undefined values so no field is deleted, and stays
idempotent (a re-run finds each value equal and writes nothing).

Adds a regression: a producer with delivery_delay + comment and a
consumer with dead_letter_queue/max_concurrency + inline comment survive
an upsert of the same queue, and re-running is byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jsonc-parser parse() resolves a duplicate key to the LAST member (what
wrangler reads) while modify() edits the FIRST, so a property-level upsert
against a duplicated key silently patched a shadowed member and left wrangler
on the stale value — while a re-run looked byte-idempotent. Walk the AST and
fail closed (throw without writing, naming the key + line/column) on any
duplicate member, config-wide, matching the writer's malformed-config guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ding

writeProvisionedWranglerConfig matched producers by `binding` while consumers
matched by `queue`. Void derives a producer's binding deterministically from the
queue name (QUEUE_<NAME>; scan.ts + runtime/queues.ts), so the queue name is the
stable identity. Matching on binding missed a user-renamed binding on the same
queue and appended a duplicate producer, stranding sibling fields like
delivery_delay on the now-unused entry that the runtime never dispatches through.

- Match both producers and consumers by queue name (dropped the matchKey param).
- Property-level upsert repoints binding in place, preserving delivery_delay and
  inline comments; idempotent re-runs are byte-identical.
- Fail closed when the desired producer binding already binds a different queue.
- Regression + guard tests added; all 61 wrangler tests + `vp run check` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mments

Codex round 5 on writeProvisionedWranglerConfig:

Finding 1 — producer upsert keyed on queue name collapsed a legal
{jobs,CUSTOM}+{jobs,QUEUE_JOBS} pair into two identical QUEUE_JOBS
bindings (wrangler rejects duplicate binding names). Producers now key on
their UNIQUE binding: a missing binding is appended (never hijacking a
user's own same-queue producer), a binding already bound to a different
queue fails closed. Consumers stay keyed on queue (one consumer/queue).

Finding 2 — a formatting modify() reflowed the enclosing region and
re-emitted a sibling's trailing inline comment onto the newly inserted
member. New applyMemberEdit() strips the comment, runs the comment-free
edit, then splices the comment back after the sibling's comma (located via
parseTree, robust for arbitrary nesting). All insertions route through it;
replacements and comment-free appends are byte-identical to before.

Tests: producer-by-binding no-op + append regressions, and comment-trivia
regressions for migrations_dir insert + queue append. 64 wrangler tests
green; vp run check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng container

Round-6 comment-relocation edges in the wrangler writer:
- readTrailingComment now tolerates an optional separator/trailing comma
  between a sibling's value and its inline comment, so
  `"database_id": "x", // c` no longer reassociates the comment onto an
  inserted migrations_dir.
- applyMemberEdit resolves the actual receiving container (walks up to the
  deepest existing ancestor when the leaf container is created fresh), so
  adding a `producers` section next to a trailing-commented `consumers` keeps
  the comment on consumers instead of the new producers array.

Handles `//` and single-line `/* */` comments. A multi-line block comment used
as a trailing comment remains a documented, test-pinned limitation (text
preserved, association best-effort). No id-ownership / queue-identity /
idempotency / fail-closed behavior touched. 69 wrangler tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…in wrangler writer

Two high-severity edge cases in writeProvisionedWranglerConfig (Task 3):

1. CR-only (classic-Mac `\r`) line endings corrupted trailing-comment
   relocation: readTrailingComment's `//` scanner stopped only at `\n`, so on a
   CR-only file it consumed the rest of the line (including the closing brace),
   writing the new member outside the root object → JSONC parse error, and a
   second run threw instead of returning modified:false.
   - readTrailingComment now stops a line comment at BOTH `\r` and `\n`, and
     declines a `/* */` block containing either.
   - applyMemberEdit gains a validate-before-write backstop: the
     comment-relocated string is re-parsed and compared (jsonEqual) against the
     plain modify()+applyEdits() result; on parse failure or data mismatch it
     discards the relocation and returns the plain edit. Relocation is now safe
     by construction — it can only ever change comment placement, never JSONC
     validity or data.

2. Producer conflict detection was order-dependent: it used findIndex and
   inspected only the first binding match, so a config with one binding bound to
   two queues (already undeployable) would falsely report convergence or throw
   depending on array order. Now inspects every producer sharing the desired
   binding: 0 → append, 1 same queue → no-op, 1 different queue → throw,
   >1 (duplicate binding) → throw. Consumers still key on queue.

Adds regressions for CR-only object/array/missing-section appends and producer
order-independence (both array orders). 69 → 74 wrangler tests, all green;
vp run check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a final gate to writeProvisionedWranglerConfig that structurally
guarantees the writer's contract: every comment's TEXT survives a write.
Extract the comment-token multiset (createScanner, exact source slices) from
the original and final content; if any original token is missing from the
output, throw WITHOUT writing. Catches the composite-overwrite drop
(modify() replacing a scalar-expected field whose object/array value nests a
comment, e.g. account_id/migrations_dir/a queue option) and any future
pathological path. Relocation only moves a comment (multiset preserved), so
valid edits and idempotence are byte-unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ttern consistency in wrangler writer

Two fail-closed guards on writeProvisionedWranglerConfig so it never emits a
config wrangler rejects at deploy:

- Producer append now checks worker-wide binding-name uniqueness. Before
  appending a QUEUE_<NAME> producer (0-match branch only), collect every binding
  name used anywhere in the config (mirrors wrangler's
  validateBindingsHaveUniqueNames field set, minus queues) and throw on a
  cross-type collision (KV/D1/R2/DO/service/var/AI/hyperdrive/vectorize/...).
  Same-queue idempotent path is unaffected.

- Overwriting migrations_dir now validates a companion migrations_pattern first.
  If the existing (user-owned) pattern would not be under the desired dir
  (normalized, per wrangler's stripDirPrefix rule), throw without writing instead
  of silently invalidating it.

Adds 8 regression tests (87 total, all green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cer guard

The producer-append collision guard's collector missed three valid worker
binding-name sources, letting a QUEUE_<NAME> producer collide with an existing
binding yet still append (modified:true → wrangler rejects the deploy):

- secrets.required — wrangler folds these in as "Secret" bindings
- unsafe_hello_world — a [{ binding }] array absent from the mirrored map
- ratelimits[].name — a .name-keyed array wrangler's getBindingNames overlooks

Mirror wrangler's own validateBindingsHaveUniqueNames enumeration: add
unsafe_hello_world to FRIENDLY_BINDING_NAMES, fold config.secrets.required in as
"Secret", and generalize the send_email .name special-case into
NAME_KEYED_BINDING_ARRAYS = { send_email, ratelimits }. workflows keeps its
.binding path (its .name is the resource name, not a binding name) to avoid a
false positive; queues stays excluded (producer identity is handled by the
by-binding upsert). No id-ownership, queue identity, idempotency, comment gate,
or migrations logic touched.

Adds regressions for each newly-covered source plus a negative control proving
top-level name / d1 database_name / consumer queue = QUEUE_JOBS do NOT collide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add packages/void/src/cli/provision.ts: given a computed DesiredResourceSet,
ensure D1/KV/R2/Hyperdrive/Queues exist in the user's own Cloudflare account by
shelling out to wrangler through an injectable `runWrangler` seam (default impl
wraps spawnSync on resolveWranglerCommand with a CF-Access-scrubbed env).

Read-first + idempotent: a binding already pointing at a real id is left
untouched; a missing resource is created via `wrangler <res> create
--update-config` (which upserts the id by binding, comment-preservingly); an
already-existing one is adopted by capturing its real id (`d1 list/info --json`,
`kv namespace list`, `r2 bucket info --json`) and writing it through the Task 3
writer's new adopt fields. Queues create the resource only (no config entries).

Extend writeProvisionedWranglerConfig with additive optional adopt fields
(d1/kv/r2/hyperdrive) — each an idempotent, comment-preserving upsert-by-binding
that creates the entry if absent. All existing writer invariants + tests intact.

Verified against wrangler 4.107 source (uuid/7502, JSON-vs-cli-table3 output,
and that --update-config upserts by binding). R2/queues existence use `<res>
info` since their `list` is not JSON; hyperdrive account-adoption is deferred to
Task 6 (wrangler 4.107 has no machine-readable hyperdrive list).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ks, hyperdrive adoption

Address 7 verified codex findings on the Task 4 provisioner:

1. argv-injection: RunWrangler seam now takes a string[] argv; default impl
   spawns shell:false via new resolveWranglerArgv() launcher tokens, so a
   Postgres connection string / spaced path is one literal arg (no shell).
2. config discovery: readRootConfig is BOM-aware + fails closed on parse
   error/non-object; one exact configPath is threaded through read, wrangler
   -c, and the writer (new writeProvisionedWranglerConfig configPath option)
   so reads/writes never split-brain.
3. D1 convergence: migrations_dir ensured + verified on the reused path too;
   d1MigrationsDirSkipped is surfaced (throws) instead of silently succeeding.
4. KV pre-check: require status 0 + schema-valid JSON array before treating a
   title as absent; else fail closed (no orphan duplicate namespace).
5. R2 pre-check: create only on confirmed not-found (CF 10006 / not-found
   message); other nonzero info failures surface instead of creating.
6. Hyperdrive: add an injectable REST existence check + adopt-by-name before
   the connection-string gate; ambiguous existence + conn string fails closed.
7. writer adopt fields: uphold worker-wide binding uniqueness — fail closed on
   a duplicate same-binding entry and on a cross-type collision (reuses the
   existing collectWorkerBindingNames collector).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…visioner

F1 (critical): reconcile one effective Cloudflare account before any
read-first/create/REST — fail closed when config.account_id ≠ opts.accountId,
pin config.account_id ?? opts.accountId for the wrangler env AND Hyperdrive REST,
and pass `-c <configPath>` to EVERY wrangler call (list/info/create) so no path
targets a different account.

F2: paginate the Hyperdrive REST discovery (follow result_info), require
success:true + a valid id/name on every entry, fail closed on any malformed/
non-2xx payload, and require exactly 0/1 name match (>1 → fail closed).

F3: KV fails closed when a target-title namespace has no valid id (no silent
drop→absence); R2 only treats CF code 10006 / the exact "specified bucket does
not exist" message as absence, and positively identifies the bucket name before
adopting on exit 0.

F4: resolve wrangler via module resolution (createRequire/import.meta.resolve of
wrangler/package.json, relative to the void package) so strict-pnpm layouts
resolve, always launch as [execPath, jsBin], and throw clearly when unresolvable
(never a bare/`.cmd` shim).

F5: normalizeRunWranglerResult treats a non-numeric status, a non-null signal, or
a spawn error as failure — a SIGTERM'd child is no longer reported as success.

F6: rebuild the adopt collision guard from the CURRENT config text each adopt,
include queues.producers, validate matched (existing-target) entries too, and
require exactly one D1 entry for migrations_dir (no findIndex-first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…indings

Route every Cloudflare resource path through three shared decision points so the
same bug class cannot survive in an un-touched sibling path:
- assertDiscoverySucceeded — fail closed unless numeric status 0, before parse
  (D1 list, D1 info, KV list)
- validateResourceList — reject the whole list on any malformed member, never
  filter (KV, D1)
- isConfirmedAbsent — "absent -> create" only on the exact, name-aware not-found
  signal (R2 10006/message, queue `Queue "<name>" does not exist`)

Findings:
- F1: persist account_id BEFORE any remote op so a partial run cannot bind
  account-A ids under account B (a later different-account retry now fails closed)
- F2: prove Hyperdrive pagination completeness (distinct-id tracking + consistent
  total); fail closed on missing/empty/duplicate/inconsistent pages
- F3: KV list rejects a malformed member instead of filtering it to false "absent"
- F4: D1 list/info gated on success + strict-validated + identity-matched
- F5: queue not-found is exact + name-aware (no more "Account not found" -> create)
- F6: launcher prefers void's own wrangler; app-root only as fallback

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…visioner

Structural fixes (one shared mechanism per class, no per-instance patching):

- F1 (critical): scrub CLOUDFLARE_ENV in provisionEnv; resolve the effective
  account as config.account_id ?? opts.accountId ?? ambient CLOUDFLARE_ACCOUNT_ID;
  fail closed on the default (real-wrangler) path when none is resolvable; persist
  the resolved account via the Task 3 writer BEFORE the first remote side effect
  (retained round-2/3 reconciliation).
- F2: one resource-name validator (^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$) applied to
  every argv-positional name up front, so an option-like name (--help/-h) can
  never be parsed as a wrangler flag.
- F3: RunWranglerResult carries structured {signal,error} provenance; one rule
  (isHardFailure / isCleanSuccess / isCleanFailure) gates every discovery,
  absence, success and adopt path — a signal-killed/unspawnable result is never
  "absent" or "success".
- F4: postcondition-verify after every config-writing create (d1/kv/r2/hyperdrive)
  re-reads the config and confirms a real id landed, backstopping wrangler's
  bin.wrangler wrapper mapping a killed child to exit 0.
- F5: one strict D1 identity normalizer (empty-string aliases treated as absent,
  prefer database_id, reject conflicting ids) shared by d1 list + d1 info, with an
  exact requested-name match required before adopt.
- F6: the binding-name collector rejects (throws) a whole config when any
  binding-bearing array member is malformed, instead of dropping it and missing a
  real collision.

Adds 18 tests (217 pass across provision+wrangler+preset). deploy.ts + preset.ts
untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1 (critical): force the top-level wrangler config on EVERY invocation via an
explicit empty `--env=` flag. wrangler 4.107 reloads a project `.env`/`.env.local`
into process.env each command, so deleting CLOUDFLARE_ENV is not enough — a
project `.env` with CLOUDFLARE_ENV=staging would reselect the env.staging
block/account for discovery/create while --update-config patches top-level.
`args.env ?? getCloudflareEnv()` short-circuits on the explicit empty args.env,
wrangler's own documented top-level sentinel. One `wranglerConfigArgs` helper.

F2: move the account fence to the provisioner boundary — require a resolvable
effectiveAccountId whenever `desired` implies remote work, unconditionally (not
gated on the default seam), persisted before selecting/calling any seam.

F3: resolve rootWranglerPath to an ABSOLUTE path against `root` once, so Node
reads/writes and the default wrangler (cwd: root) can never target different files.

F4: verify queue creates with a follow-up `queues info` probe that POSITIVELY
identifies the queue (queues write no config, so a masked signal-killed create —
bin wrapper maps code===null to exit 0 — could otherwise record a false created).

F5: collapse the two opposite-precedence D1 helpers into ONE strict
normalizeD1(entry, requestedName) used by both list and info — exact requested-name
match, reject conflicting name aliases, require one unambiguous id, reject multiple
requested-name matches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…shape validator

The binding-name collision collector accepted malformed binding-bearing members in
several sibling shapes: a `{bindings:[null]}` (all members malformed) fell through
the `some(isRecord)` guard as a plain map; a singleton record with a non-string
`binding` (ai/browser/…) yielded an empty set; `secrets.required`, queue producers,
and adopt target arrays silently skipped malformed members. Each fail-open could let
the worker-wide uniqueness guard miss a real binding and commit a conflicting one.

Collapse to ONE field-aware strict reader: bindingNamesForField routes each field to
its known wrangler config shape — binding LISTS (durable_objects/unsafe/logfwdr),
namespace arrays (kv/d1/r2/…), SINGLETON objects (ai/browser/images/version_metadata/
assets), NAME-keyed arrays (send_email/ratelimits) — throwing on any malformed/empty
binding identity instead of dropping it. secrets.required and queues.producers are
validated strictly in the collector, and the adopt path now strictly validates its
TARGET field too (the owner map excludes it). Field-awareness avoids misreading a
key-map field (e.g. a `vars` entry keyed `bindings`/`binding`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F2: export `assertNoDuplicateMembers` so the provisioner can run the SAME
strict duplicate-member detection at its boundary (do not re-implement).

F5: remove the binding-collector's fail-open bypasses —
- route the producer-UPSERT path through the shared strict producer-list
  validator (`assertValidQueueProducers`, extracted from the adopt guard) so
  an existing `queues.producers:[null]` fails closed instead of committing a
  new producer beside a corrupt list;
- add the omitted singleton binding fields `websearch`, `stream`, `media`
  (audited against wrangler 4.107's `validateNamedSimpleBinding` set — the
  complete singleton set is now covered);
- encode each field's REQUIRED outer shape: a name-keyed field
  (`send_email`/`ratelimits`) that is present-but-not-an-array, and a
  binding-list field (`durable_objects`/`unsafe`/`logfwdr`) whose outer shape
  is wrong, now THROW rather than falling back to generic key collection
  (which could under-collect and hide a real binding collision).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1 (CRITICAL): thread a required transport account context into EVERY
wrangler + Hyperdrive invocation (default AND injected). `RunWrangler`'s call
context and the `ListHyperdriveConfigs` seam now carry the reconciled
`accountId`; a single runWrangler choke point injects it into every call, and
the lister is always invoked with `{ accountId }`, so an injected seam can
never query a DIFFERENT account than the config is pinned to.

F2: run the writer's strict duplicate-member detector at the provisioner
BOUNDARY (before account persistence, discovery, or any remote side effect),
so a config with duplicated members (e.g. two `kv_namespaces`) fails closed
instead of reaching a create (orphan) or being falsely reused.

F3: apply ONE strict Hyperdrive-list validator (`selectHyperdriveMatch`) at
the CALLER after either lister returns — rejecting non-object/empty-id/
empty-name/duplicate-id members and requiring exactly 0/1 exact-name match —
so an injected malformed list can no longer be adopted with an empty id.

F4: make `assertCreateWroteRealId` resource-specific + identity-checked — R2
`bucket_name` and D1 `database_name` must EXACTLY equal the requested name
(not merely be non-placeholder), so a create that wrote a different resource
fails closed.

F6: serialize the unguarded Hyperdrive list->create across processes with a
cross-process lockfile keyed on the resolved config path (O_EXCL create,
`{ pid, timestamp }`, stale-break on dead PID / bounded timeout, guaranteed
release in `finally`). The read-first check runs INSIDE the lock, so a second
concurrent provision reuses the first's id instead of minting a duplicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1 — hold ONE cross-process lock for the WHOLE provision (acquired before the
first config read, released in `finally`), so account reconciliation + persist +
all discovery/remote/writes run under it; a racing first-run serializes and the
second fails closed on the persisted-account mismatch. Body extracted into
`provisionCloudflareResourcesLocked`.

F2 — replace the hand-rolled O_EXCL lockfile (empty-file window, age-only expiry,
check-then-unlink stale race, ownerless release) with `proper-lockfile`: atomic
mkdir mutex on a dedicated adjacent marker file, mtime-refresh so a live owner
never expires, stale-compromise detection, ownership-scoped release, and an
`onCompromised` handler that fails closed (re-throws).

F3 — treat the Hyperdrive existence-check result as UNKNOWN until proven a real
array: `Array.isArray` gate BEFORE marking existence known, `selectHyperdriveMatch`
on the proven array only. A runtime null/undefined/non-array leaves existence
unknown and fails closed — no blind `hyperdrive create`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F4 — run the canonical worker-wide binding-name collision collector + owner check
for EVERY queue-producer upsert (matched/idempotent AND append), not only the
`index === -1` append path. A pre-existing cross-type collision (or a malformed
other binding field) now fails closed even on the idempotent path instead of being
silently accepted as `modified:false`. The collector still omits `queues`, so a
producer never self-collides.

F5 — replace content-GUESSING with EXPLICIT per-field shape metadata transcribed
from wrangler 4.107's config-schema.json (`BINDING_FIELD_SHAPES`): key-map fields
(vars/wasm_modules/text_blobs/data_blobs) collect KEYS (never misread a value that
looks like a binding list, never report a value as a key); required singletons
(all but assets) throw on missing/malformed/wrong-shape binding; durable_objects
and logfwdr require their `bindings` array (unsafe optional). `genericBindingNames`
removed — one strict code path, no fallback guessing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…he binding collector

F3 — a PRESENT-but-wrong-shape `queues`/`secrets` container silently bypassed the
worker-wide binding-name collector, hiding a real cross-type collision (an
ARRAY-shaped `queues` carrying a producer binding `DB` let a D1 adopt as `DB` slip
through). Add ONE shared `assertObjectContainer` validator that THROWS on a present
non-object (array/string/number/null) and is a no-op only when the container is
absent, and route the producer upsert, the adopt-guard owner collection, and the
secrets fold-in all through it — no bypass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1 — the default runWrangler ran wrangler via UNBOUNDED spawnSync, which blocks the
event loop; a call longer than the lock's stale window starved proper-lockfile's
heartbeat, letting another process steal the still-held mutex. Switch to async `spawn`
(awaited) so the loop stays free and the heartbeat always refreshes the mutex, plus a
hard per-command timeout (SIGKILL → HARD failure) so a hung wrangler cannot hang the
provision. The runWrangler seam is now async; sync test stubs still work via
Promise.resolve at the single choke point.

F2 — release swallowed unlock failures while proper-lockfile had already dropped its
ownership state, leaking the mutex while the provision reported success. release() now
caches one shared promise (idempotent double-release) and PROPAGATES a genuine
(non-ERELEASED) unlock failure; on body success a leaked lock now FAILS the provision.

F4 — replace the loose isRealValue on id-bearing checks with resource-SPECIFIC
validators (D1 hyphenated UUID; KV + Hyperdrive 32-char hex; R2 bucket_name grammar),
verified against wrangler 4.107 + live account resources. Applied to read-first reuse,
list/info adoption, and the create postcondition, so `id: " "`/`"xyz"` is no longer
mistaken for a real resource.

F5 — split the universal option-injection guard (reject empty/leading-`-`/control chars,
every positional) from per-resource service grammar, so a valid KV title with spaces
("My Own Namespace") is accepted while R2 keeps its lowercase DNS-like grammar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F2 — launch wrangler's REAL CLI (wrangler-dist/cli.js) instead of the
bin.wrangler wrapper, so the hard command timeout's SIGKILL hits the real
process (the wrapper only forwards SIGINT/SIGTERM and masks a signal-killed
child as exit 0). The default runner now spawns the launcher in its own
process group, SIGKILLs the whole group on timeout, and settles the promise
immediately — so a wrapper-plus-hung-grandchild scenario rejects within the
timeout with no lingering process.

F1 — pin ONE transport context. provisionEnv scrubs every transport-redirect
var (CLOUDFLARE_API_BASE_URL/CF_API_BASE_URL, WRANGLER_API_ENVIRONMENT,
CLOUDFLARE_COMPLIANCE_REGION, CLOUDFLARE_ENV) and re-pins CLOUDFLARE_API_BASE_URL
to the single resolved base (mirroring wrangler's own getCloudflareApiBaseUrl).
The Hyperdrive REST lister now honors that pinned base instead of hardcoding
prod, and provisioning fails closed when a project .env/.dev.vars would redirect
wrangler's transport out from under the pin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ck identity

The whole-provision mutex marker was derived from the LEXICAL config path, so a
real config and a symlink alias to it got DIFFERENT marker dirs and both locks
could be held at once (race). Canonicalize the config path with realpath BEFORE
deriving the lock identity and before all reads/writes/-c; a missing config
canonicalizes its parent dir so the identity stays alias-stable. The canonical
path is used everywhere downstream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 21, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_27ed389f-816d-47a1-8f99-ac69287b510d)

@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: 60a5140fbe

ℹ️ 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 packages/void/src/cli/deploy.ts Outdated
Comment thread packages/void/src/cli/deploy.ts
… wholesale, reject external durable objects, and exact-match passthrough entries

The post-build check on dist/ssr/wrangler.json guards against a compromised
`vite build` (its Cloudflare credentials are scrubbed, so it can only edit
files) rewriting the emitted config so the credentialed `wrangler deploy` grants
a capability the pre-build root never declared. Five gaps are closed:

- Pass-through entries are now compared for EXACT equality against the pinned
  pre-build root entry instead of a root-key subset. A real build adds nothing to
  a present entry (verified across services/vectorize/workflows/mtls_certificates/
  send_email/ratelimits/dispatch_namespaces — all byte-identical), so an added
  `entrypoint`/`environment` on a services binding — which wrangler uploads to
  retarget the service — is now caught.

- `unsafe` is pinned as a WHOLE object against the raw pre-build root. Its
  `.metadata`/`.capnp` were never entry-iterated, yet wrangler applies
  `unsafe.metadata` last at upload, overwriting the entire uploaded bindings
  array. An injected non-empty `unsafe` absent from root is now rejected.

- `tail_consumers` and `streaming_tail_consumers` are pinned the same way. They
  are not bindings, so the assert-set could not see them, yet wrangler uploads
  them and they forward every log/request to the named worker.

- An emitted `durable_objects` binding carrying `script_name` (or `environment`)
  targets another worker's Durable Objects. Void's own DOs are always local
  `{name, class_name}`, so such a binding must match a same-named root binding or
  it is rejected as build-injected.

- The emitted `ai` binding must be absent, Void's own `{binding:"AI"}`, or the
  pre-build root's — anything else is rejected.

A clean build (root declaring none of unsafe/tail_consumers/streaming_tail_
consumers) emits all three absent, so an emptiness check keeps the normalizer's
default-fill from false-positiving. The snapshot now carries the raw pre-build
root values for these fields; `unsafe` stays in the assert-set so the drift
invariant is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora
@cursor

cursor Bot commented Jul 21, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_31979220-3b27-4c8f-9db8-5c7e9a08a229)

@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: 79e2213ad1

ℹ️ 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 packages/void/src/cli/deploy.ts
Comment thread packages/void/src/cli/deploy.ts
…ed AI binding, and pin the wrangler uploader across the build

Three hardening changes to the self-host `--backend cloudflare` deploy guard, all
in `runCloudflareDeploy` / `verifyGeneratedDeployConfig`.

Make the whole-object pin symmetric. The `unsafe` / `tail_consumers` /
`streaming_tail_consumers` check skipped any emitted empty value without consulting
the root, so a build could empty a root-declared `tail_consumers` to `[]` — silently
disabling the source's audit/log forwarding — and still pass. An empty emitted value
now passes only when the root value is also empty; emptying a root-declared value is
a removal and is rejected like any other rewrite.

Authorize the emitted `ai` binding from the pre-build inference. The check accepted
any `{binding:'AI'}` unconditionally, letting a build inject a billable Workers AI
binding into an app that does not use AI. The emitted ai binding must now equal the
root's when the root declared one (so a custom ai binding cannot be rewritten to the
default), and is otherwise accepted only when the source's inference reports Workers
AI use — the same signal that gates the injection during the build.

Pin the wrangler uploader across the build. The build runs untrusted project code
with the parent's Cloudflare credentials scrubbed, but it can still edit files —
including the wrangler bundle the post-build remote migrate and deploy execute with
the token. Snapshot the uploader's content hash immediately before the build and
re-check it immediately after, before any credentialed wrangler run; a changed or
unverifiable binary fails closed with nothing left half-done. This closes the
"build swapped the uploader" vector only; a fully-compromised build dependency
remains a trust boundary, now noted in the Cloudflare integration docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora
@cursor

cursor Bot commented Jul 21, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_ab7f8893-9ed4-4d09-9da3-f689b21dcfd9)

@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: 9bb19a4e8e

ℹ️ 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 packages/void/src/cli/deploy.ts Outdated
…ructive DO migrations, and re-verify the uploader before every credentialed run

Four fixes to the self-host Cloudflare deploy guard
(verifyGeneratedDeployConfig / runCloudflareDeploy), all fail-closed and
false-positive-safe against a real build.

- Dropped pass-through binding: the entry pin only walked emitted entries, so
  a build that DROPPED a root-declared services / mtls_certificates / … binding
  went unnoticed and the deploy silently stripped a capability the source
  declared. Add a reverse-presence loop over the pinned entries: every pinned
  binding name must still appear in the emitted config (a field absent from the
  emitted config means all its pinned names are missing). Whole-object-pinned
  fields are skipped — their symmetric raw pin already rejects a removal.

- Removed root ai binding: the ai check was gated on the emitted binding being
  present, so a root-declared ai binding the build removed skipped the
  comparison and deployed without it. Read the root ai first — when the root
  declared one it must survive byte-identical, so a removed (not just rewritten)
  root ai fails closed.

- Traffic attachment: routes / route / workers_dev / preview_urls were never
  checked, so a build could inject a route to attach the worker to a hostname
  the account controls (traffic interception) or flip its public exposure. Void
  emits none of them; whole-object-pin all four (absent → both-empty → pass;
  declared → byte-identical → pass; injected/flipped → reject).

- Destructive Durable Object migration: the emitted migrations were never
  inspected, so a build could append an entry carrying deleted_classes /
  renamed_classes / transferred_classes / transferred_classes_from_script and
  wrangler deploy would run it against production DO data. Reject any emitted
  migration carrying a destructive key that is not byte-identical to a
  root-declared one. Void emits only creation-key migrations, so they are never
  checked; a user's own root-declared destructive migration is pinned and
  passes. Full migration-array pinning remains follow-up.

- Uploader re-verification: the single post-build hash re-check was too early —
  post-build project code (env.ts import, drizzle-kit) runs after it and
  interleaves with the credentialed wrangler runs, so it could overwrite the
  hashed bundle after the check. Re-hash and assert against the single pre-build
  baseline immediately before each credentialed spawn (secret list, D1
  migrate/list, deploy); the baseline is never re-taken post-build. Fails closed
  on any change or an unresolvable binary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora
@cursor

cursor Bot commented Jul 21, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_78660805-3a98-415c-a1ab-948c2b3f41d6)

@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: 4378b37928

ℹ️ 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 packages/void/src/cli/deploy.ts
Comment thread packages/void/src/cli/deploy.ts
Comment thread packages/void/src/cli/deploy.ts
…uploader before the pre-build credentialed runs

Three completions to the self-host Cloudflare deploy guard, all in the
`runCloudflareDeploy` / `verifyGeneratedDeployConfig` fork.

Reject a dropped id-bearing binding: the emitted-config check pinned each
provisioned D1/KV/R2/Hyperdrive identity two ways — every desired binding must
still be present, and every id-bearing binding the artifact declares must be one
that was pinned — but neither could see a non-desired, hand-declared root binding
(e.g. an EXTRA_DB) that a build silently DROPS: it is absent from the desired set,
and the completeness walk only iterates what the artifact still declares. Add a
reverse-presence loop over the pinned identity snapshot: every pinned
field+binding must still appear among the emitted id-bearing bindings, mirroring
the pass-through reverse-presence loop. A legitimate build spreads the root config
verbatim, so no pinned id-binding is ever dropped.

Reject a dropped queue: the queue check only enforced emitted-queues subset of
desired, rejecting an injected queue but waving through a dropped one — an
absent/empty queues field, or one retaining only the producer or only the
consumer, would ship a worker that loses message processing or leaves a QUEUE_*
binding undefined. The build emits every desired queue as both a producer and a
consumer (both mapped from the same scanned array), so additionally require each
desired queue name in both emitted groups, read per-group and defensively.

Baseline the wrangler uploader before the pre-build credentialed runs: the uploader
bundle hash was captured just before the build, but the pre-build env-schema probe
evaluates the user's Vite config and env.ts (project code) earlier, and whoami and
provisioning run before that capture — so a probe that overwrote the bundle would
have it executed with the Cloudflare token while a later baseline blessed it.
Capture the single baseline at the start of the deploy, before the env-schema
probe / whoami / provision (the desired-set inference above only AST-parses source,
so no project code runs before it), and assert it immediately before whoami and
before the provision call, in addition to the existing post-build per-spawn checks.
Fail closed on any change or an unresolvable binary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora
@cursor

cursor Bot commented Jul 21, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_c0c4b0dd-dbaa-40c3-8cf6-8000fb260a50)

@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: 5d7360e0f9

ℹ️ 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 packages/void/src/cli/deploy.ts
…and retargeting

The emitted dist/ssr/wrangler.json `containers` array was pinned by nothing, so an
untrusted build could inject a container application onto a no-sandbox app, drop the
sandbox container, or retarget an existing one's image. A bare `wrangler deploy`
provisions a container application from that array — billable, and it runs the image's
code — so any of these would ship code the source never asked for.

Reconstruct the expected containers set pre-build from the same inputs the build uses:
every root-declared container passes through verbatim, plus Void's own sandbox container
built from `resolveSandboxConfig` (the same pure function the build calls) when the source
needs the sandbox. The sandbox signal mirrors the build exactly —
`isSandboxEnabled(config)` OR the import walk detecting a `void/sandbox` import — surfaced
from `computeDesiredCloudflareResources` alongside `needsAI` (using `isSandboxEnabled`
alone would false-positive an import-only sandbox app such as kitchen-sink).
`verifyGeneratedDeployConfig` compares the emitted `containers` against this set keyed by
`class_name`, rejecting an injected, dropped, or retargeted container while a legitimate
build — whose emitted entry is byte-identical to the reconstruction — passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora
@cursor

cursor Bot commented Jul 21, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_72827df9-e0f4-4c28-bd62-6668014d35d3)

@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: fb720a8cd8

ℹ️ 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 packages/void/src/cli/deploy.ts
… and removal

The self-host Cloudflare deploy guard (verifyGeneratedDeployConfig) pinned
bindings, unsafe fields, routes, ai, Durable Objects, migrations, containers,
and queues, but ignored generated.triggers. Void emits triggers.crons from
scanJobsSync(root, paths.cronsDir).flatMap((job) => job.crons), and a bare
`wrangler deploy` reading dist/ssr/wrangler.json installs the worker's Cron
Triggers from that array. So an untrusted build could inject a schedule (running
attacker code on a billable schedule) or drop/retarget a legitimate schedule
(silently changing the worker's scheduled behavior), and the credentialed deploy
would accept it.

Reconstruct the expected schedules pre-build from the SAME scanJobsSync scan in
computeDesiredCloudflareResources (surfaced as expectedCrons), thread it into the
guard, and compare the emitted triggers.crons against it as an order-independent,
count-aware multiset. generated.triggers is read defensively — only an object
with an array crons yields emitted schedules, so a no-cron app (whose emitted
triggers is absent or the empty object the CF plugin materializes) stays
symmetric-empty. An emitted schedule not in the expected multiset is rejected as
injected, an expected schedule missing from the emitted array is rejected as
dropped, and a retarget is caught as both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora
@cursor

cursor Bot commented Jul 21, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_0ebe6510-c2cd-4bc3-b4d6-fcdcfa833d4a)

@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: f817988b26

ℹ️ 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 packages/void/src/cli/deploy.ts
Comment thread packages/void/src/cli/deploy.ts
…probe that rewrites the root bindings

The self-host Cloudflare deploy trusts the root wrangler config that later
credentialed steps read, but two config writes by untrusted project code were
not reconciled against the pinned values.

Secrets gate — pin the worker name. `dischargeSelfHostEnvContext` runs
`wrangler secret list -c <root>`, and wrangler resolves the worker NAME from the
config's top-level `name`. The gate reconciled `account_id` but not `name`, so
the post-build validate-emitted probe (project code) could rewrite `name`; the
gate would then list secrets for a different worker, and if that worker had the
required secrets the gate passed while the deploy shipped the pinned worker
without them. Thread the pinned `workerName` into the gate and reconcile the
config's `name` against it before the spawn, mirroring the account reconcile —
fail closed only when the config declares a name that differs from the pinned
one, so a legitimate deploy never trips.

Binding snapshot — reject a pre-build probe that laundered the bindings
(no-provision path). The step-6 binding/pass-through/container snapshots are
taken after the step-3c env-schema probe runs project code, so a probe that
rewrote the root bindings (swapping a binding id, adding an id-bearing or
pass-through binding, changing unsafe/tail/routes/containers) was captured as
the trusted baseline, emitted by the build, and accepted by the emitted-config
verifier. Rebuild the same snapshots from the config parsed before any project
code ran and, only in the no-provision path, assert the step-6 snapshots equal
those pre-probe ones (the Map-shaped snapshots need an explicit compare the
JSON-shaped deepEqual does not provide). The provision path legitimately writes
real ids into the config after the probe, so its config-laundering residual is
tracked in #283 and left out of scope here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora
@cursor

cursor Bot commented Jul 21, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_1d780d65-6f36-46b5-8516-047b4f586ce2)

@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: 8cf1c7c545

ℹ️ 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 packages/void/src/cli/deploy.ts
@Brooooooklyn Brooooooklyn changed the title feat(deploy): direct-to-Cloudflare deploy — --backend cloudflare / --provision (WIP) feat(deploy): direct-to-Cloudflare deploy — --backend cloudflare / --provision Jul 21, 2026
@Brooooooklyn
Brooooooklyn merged commit 02def5c into main Jul 22, 2026
7 checks passed
@Brooooooklyn
Brooooooklyn deleted the feat/backend-cloudflare-deploy branch July 22, 2026 01:05
Brooooooklyn added a commit that referenced this pull request Jul 23, 2026
…are backend (#284)

## Real-build integration + gated live e2e for `void deploy --backend
cloudflare`

Follow-up to #273. That PR shipped the direct-to-Cloudflare deploy and
its emitted-config tamper guard with **616 unit tests** — but those stub
the `vite build`, wrangler, and Cloudflare via DI seams and assert
against **hand-written** emitted-config fixtures. Nothing verified the
guard's core premise: that a **real** `@cloudflare/vite-plugin` +
wrangler build emits *exactly* the shape `verifyGeneratedDeployConfig`
reconstructs, adding nothing that would false-positive a legitimate
deploy. This PR closes that gap — and adds a live e2e that has been run
end to end against a real account.

### Part A — real-build integration test (CI-runnable, no Cloudflare
account)
`packages/void/test/integration/cloudflare-deploy-build.test.ts`

Runs a **real** `vite build` of the kitchen-sink playground (D1 + KV +
R2 + AI + sandbox/containers + cron + queue) with an overlaid root
`wrangler.jsonc` carrying real-shaped ids, reconstructs the guard's
`expected` with the **same exported builders** `runCloudflareDeploy`
uses (`computeDesiredCloudflareResources` + the `snapshot*` fns +
`reconstructExpectedContainers`), and asserts:

```
real emitted dist/ssr/wrangler.json → verifyGeneratedDeployConfig → { ok: true }     ← the FP-safety LOCK
reconstructed container set == emitted sandbox container (byte-identical)
6 on-disk tampers → { ok: false }:  inject container · swap D1 id · add EXTRA_DB · add cron · add services · add routes
```

**Why it matters:** if a future `@cloudflare/vite-plugin` / wrangler
upgrade adds a field to the emitted config, this test **fails in CI** —
instead of the drift shipping as a production false-positive that blocks
a real deploy. 11 tests, ~5.5s (one build in `beforeAll`, reused across
cases).

### Part B — gated live-account e2e (real deploy, verified end to end)
`packages/void/test/e2e/cloudflare-deploy-live.test.ts` (+
`test/e2e/README.md`)

Deploys the spa playground (D1 + KV + cron) to a real account, asserts a
live `200`, then tears everything down. Wrapped in
`describe.skipIf(...)` so it is **inert in CI** and skips cleanly with
no credentials; the live run is a manual operator step:

```
VOID_E2E_CF_TOKEN=<token> VOID_E2E_CF_ACCOUNT_ID=<id> vp test run packages/void/test/e2e
```

It does **not** drive `--provision`: that path fails closed in
non-interactive shells by design (a spawned CLI has no TTY, and CI can't
commit back the ids a create would mint), so it can't run from
`spawnSync`. Instead the test plays the provisioner's role — pre-creates
the id-bearing D1 + KV with `wrangler … create`, writes their real ids
(+ `migrations_dir` + `account_id`) into the app's root config exactly
as `--provision` would, then runs the CI-safe `void deploy --backend
cloudflare`. That exercises the real pipeline end to end:

```
ownership gate → pre-create D1+KV → vite build → tamper-guard →
remote D1 migration → wrangler deploy → GET /api/hello 200 → teardown (0 leaks)
```

Harness hardening:
- **Fail-closed ownership gate** — refuses to run if the fixed-name
worker/D1/KV already exist; every lookup it can't positively confirm as
free is treated as a collision (worker via CF API 200/404 with a bounded
retry against transient blips; D1/KV via list).
- **Existence-aware teardown** — deletes each resource only if it is
still present, fails loud on a present-but-undeletable resource, and
fails closed on an ambiguous lookup, so a real leak is never silently
swallowed.
- **Transport pinning** — `cloudEnv()` scrubs wrangler API-base
overrides so the spawned wrangler calls and the direct existence fetch
always resolve the same public api base.

Operator prerequisites (documented in the README): a token with Workers
Scripts / D1 / KV **Edit**, and an account with a **workers.dev
subdomain** already registered (else `wrangler deploy` uploads the
worker but can't publish a public URL for the assertion).

### playground/spa migration coherence
The spa app carried a hand-written `db/migrations/001_create_users.sql`
with no drizzle `meta/`, which trips the deploy's schema-drift gate.
This adds the coherent meta (journal + snapshot) under the existing
`001` tag so the app deploys cleanly, keeping `001_create_users.sql`
**byte-identical** (immutable history — renaming it would re-run `CREATE
TABLE` against an already-migrated D1 and wedge existing dev databases).

### Other non-test changes (both minimal)
- `export parseRootWranglerConfig` in `cli/deploy.ts` — so the test
parses the pre-build root config with the same JSONC parser the
orchestrator uses.
- `vite.config.ts` — wire `packages/void/test/e2e/**` into the
integration test project so the gated suite is **discovered and
skipped** in CI, not silently missing.

### Testing
- Part A: `vp test run
packages/void/test/integration/cloudflare-deploy-build.test.ts` → **11
passed** (real build).
- Part B (no creds): `vp test run packages/void/test/e2e` → **2
skipped** — verified inert.
- Part B (live account): `vp test run packages/void/test/e2e` with both
env vars → **2 passed** against a real account — build → guard → remote
migration → deploy → live 200 → clean teardown with **zero leaked
resources** (verified worker/D1/KV absent afterward).
- `vp run check` + `vp lint` + `vp fmt --check` clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Introduces a main-branch job that uses Cloudflare API tokens against a
real account, though isolation (environment branch policy, no creds on
PR/fork test jobs) is deliberate; production deploy logic only gains a
test export.
> 
> **Overview**
> Extends Cloudflare deploy coverage beyond stubbed unit tests with a
**real `vite build`** integration lock on `playground/kitchen-sink` and
a **gated live-account e2e** that runs `void deploy --backend
cloudflare` against `playground/spa`, then tears down uniquely named
workers/D1/KV.
> 
> The integration test rebuilds the same `expected` payload as
`runCloudflareDeploy`, asserts the real emitted `wrangler.json` passes
`verifyGeneratedDeployConfig`, and rejects minimal on-disk tampers
(injected bindings, swapped D1 id, extra cron/routes/services).
**`parseRootWranglerConfig` is exported** so tests parse root JSONC like
production.
> 
> The live suite pre-creates D1/KV (non-interactive substitute for
`--provision`), deploys, checks HTTP 200 plus D1/KV/cron and idempotent
re-deploy, with fail-closed ownership and teardown. **CI adds a separate
`live-e2e` job** on direct `main` pushes only, using the
`cloudflare-e2e` environment secrets, credential preflight, and
`VOID_E2E_RUN_ID`; the main `test` job stays uncredentialed and the
suite self-skips there. **`vite.config.ts`** forwards the e2e env vars
and includes `test/e2e/**` in the test project.
> 
> **playground/spa** gains a KV API route, Drizzle migration meta so
deploy drift checks pass, and README notes.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f17334d. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brooooooklyn added a commit that referenced this pull request Jul 24, 2026
… bugs it revealed (#286)

Windows CI has been red for months. This fixes the gate that hid the
problem, then fixes everything the gate was hiding.

## 1. Why Windows CI was red

`test (windows-latest)` failed `vp check` with ~1028 files reported as
mis-formatted — effectively the whole repo, on the last 5+ consecutive
`main` runs.

The repo had **no `.gitattributes`**. On Windows, git's default
`core.autocrlf=true` converts LF→CRLF on checkout, while oxfmt enforces
LF. Ubuntu keeps LF and passed, so the failure was platform-only.

Fix: a root `.gitattributes` pinning text files to LF everywhere (`*
text=auto eol=lf`) plus explicit `binary` markers. Checkout-only — every
committed blob is already LF, so `git add --renormalize .` rewrites
nothing.

## 2. What that revealed

With the formatting gate passing, the Windows job reached the test suite
**for the first time** and 42 tests failed. `provision.ts` /
`cloudflare-deploy.ts` landed in #273 while Windows CI was already dying
at formatting, so this code had *never executed on Windows*.

### Product bugs — `packages/void/src/cli/provision.ts`

All changes are `win32`-gated; POSIX behavior is byte-identical.

- **`canonicalizeConfigPath` returned two identities for one file.**
Every path in the module comes from `pathe` (POSIX-normalized), but
`realpathSync` is `node:fs` and returns a **native** path. An existing
config canonicalized to `D:\a\…`, a not-yet-created one to `D:/a/…` —
splitting the lock identity, the wrangler `-c` argv, and the paths
printed in errors, defeating the "ONE identity" contract the function
exists to provide.

- **The quarantine gate could fail OPEN.** Its probe target was `-pgid`
with no platform guard, but `process.kill(-n, 0)` on win32 resolves to
`OpenProcess((DWORD)-n)`, which fails and surfaces as `ESRCH` —
reporting any recorded owner "confirmed dead" and clearing a **live**
quarantine. The identical probe is already guarded in the retention
watchdog; this mirrors it.

- **An already-exited child could be quarantined.** Windows bypassed the
`killProcessGroup` seam for `child.kill()`, whose `false` conflates
`ESRCH` with `EPERM`, and `exitCode`/`signalCode` are set asynchronously
so a just-exited child still reads as running. Windows now routes
through the same seam, where `process.kill` throws `ESRCH` and is
classified "already gone".

- **Termination confirmation was assumed, not earned.** `probeGroupGone`
returned a constant `true` on Windows, degrading "never release the lock
until the child is confirmed dead" to a single fact. It now performs a
real `kill(pid, 0)` liveness probe; descendants stay covered by libuv's
global Job Object.

### Test-only fixes (product verified correct)

- **env-schema probe (10 failures).** The fixtures spliced an absolute
path in as an **ESM specifier**; on Windows `D:/…` parses as protocol
`d:` and Node rejects it with `ERR_UNSUPPORTED_ESM_URL_SCHEME`, so the
child never evaluated and every result came back `schema-error`. Now
built with `pathToFileURL`, as the product does. Real users are
unaffected — their `env.ts` uses bare specifiers and the product already
uses `pathToFileURL`.
- **Deploy pointer.** `@cloudflare/vite-plugin` writes it with
`node:path.relative` (native separators); the assertion pinned a POSIX
literal. Compare the normalized form.
- **pgid assertion.** `writeQuarantineSidecar` records `null` on win32
by design.
- **Signal-delivery cases skipped on Windows**, which cannot deliver a
signal to another process (libuv maps SIGTERM/SIGINT/SIGKILL to
`TerminateProcess`). This includes one case that "passed" there only
because the kernel Job Object reaped the child — a green that proved
nothing.

Two failures (`[33]`/`[34]`) are fixed by the **product** change, not by
touching them: their fixtures write a positive `pgid` against a live
process and demand the gate fail closed. The tests were right; the code
was wrong. They now guard the fix.

## Verification

- `packages/void` full suite on POSIX: **3673 pass**, 0 fail
- typecheck clean, `vp check` clean
- POSIX unchanged — every product change is `isWindows`-gated
- Windows verified by CI on this PR via the `test: windows` label

### Windows progression

| Run | Windows result |
|---|---|
| baseline (before this PR) | `vp check` died on ~1028 files — suite
never ran |
| after `.gitattributes` | gate passes; **42** test failures surfaced |
| after the provision/probe fixes | **2** failures (both teardown-only;
all 40 behavioural failures cleared) |
| after the teardown fix | pending |

The 40 cleared include all 21 path-separator, all 10 env-probe, the
build pointer, the pgid/quarantine set, and the signal-delivery cases.
Two of them (`[33]`/`[34]`) were fixed by the **product** change rather
than by touching the test: their fixtures write a positive `pgid`
against a live process and require the gate to fail closed. The tests
were right; the code was wrong. They now guard the fix.

## Known remaining

- **Teardown EPERM** in the kill-classify block: every child there runs
with `cwd` = the temp dir, and Windows will not remove a directory that
is a live process's cwd. Teardown now reaps any recorded pid before
removing and, if it still fails, reports which children were alive and
what entries remain rather than a bare errno.
- **Browser-test flakiness on Windows**: one metaframework browser case
has failed per run, but a *different* one each time (Solid
`net::ERR_NO_BUFFER_SPACE`, then SvelteKit `__spa_marker` undefined).
That pattern points at runner resource contention rather than a code
defect, so it is being tracked across runs instead of patched
speculatively.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes touch provision locking and quarantine gates on Windows
(fail-closed correctness); POSIX paths are explicitly preserved behind
platform checks.
> 
> **Overview**
> **Unblocks Windows CI** by adding root `.gitattributes` (`eol=lf`) so
checkout on `windows-latest` matches oxfmt’s LF expectation, then fixes
the Windows-only failures that surfaced once `vp check` passed.
> 
> **`provision.ts` (win32-gated):** Normalizes `realpathSync` results to
POSIX so lock/wrangler paths stay one identity; fixes quarantine probing
so negative PIDs are never used on Windows (fail-closed); routes child
termination through `killProcessGroup` / `kill(pid, 0)` instead of
assuming the child is gone.
> 
> **Tests & harness:** Env-schema fixtures use `pathToFileURL`; deploy
pointer assertions normalize separators; POSIX-only signal tests skip on
Windows; kill-classify temps are per-test with safer Windows teardown;
Vitest splits **shared-playground** integration into a serialized
project and forwards live-e2e env there.
> 
> **CI:** Optional Defender exclusions for workspace/temp on trusted
Windows runs; `.gitignore` for `**/.tmp-*`.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
1daa7f8. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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