Repository navigation
feat(deploy): direct-to-Cloudflare deploy — --backend cloudflare / --provision - #273
Conversation
…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>
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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".
… 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
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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".
…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
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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".
…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
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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".
…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
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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".
…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
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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".
… 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
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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".
…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
Bugbot couldn't run - usage limit reachedBugbot 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) |
There was a problem hiding this comment.
💡 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".
--backend cloudflare / --provision (WIP)--backend cloudflare / --provision
…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>
… 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>
void deploy --backend cloudflare+--provision— direct-to-Cloudflare deployLets users deploy a Void app straight to their own Cloudflare account, bypassing the Void platform:
vite build+ a barewrangler deploy, with--provisioncreating the inferred D1 / KV / R2 / Queues / Hyperdrive / ISR_CACHE resources and recording their real IDs in the rootwrangler.jsonc. The managedvoid deploypath forks before any of this and is byte-for-byte unchanged.Both paths bypass
PlatformCliententirely (no project link, novoid auth login, no platform API).What's in this PR
cli/args.ts,cli/cli.ts,cli/deploy.ts--backend cloudflare+--provisionflags, validation (rejects--project/--dir/--spa/--skip-buildon this path), early fork torunCloudflareDeploycli/deploy.tsrunCloudflareDeploycli/provision.ts(new)cli/wrangler.tswrangler.jsoncwriter (jsoncmodify/applyEdits): queue producers/consumers,migrations_dir,account_id; fail-closed on parse/dup/dropped-commentcli/env-schema-probe.ts(new)env.ts+ Vite config under a scrubbed env (no credentials in the code-evaluating process)cli/env-validation.tsvars) + gate (wrangler secret list→ required-secret check), with account and worker-name reconciliation; platform path unchangedcli/preset.tsresolveWranglerArgv/resolveWranglerCommand— resolves wrangler's real inner CLI, not the bin wrapperdocs/{guide/*,integrations/cloudflare,reference/cli}.md,AGENT_PROMPT.md,getting-started-prompt.txt,skills/void/SKILL.md,cli/help.ts.env*plaintext vswrangler secret put, nested queue namingNew dependency:
proper-lockfile(+ types) for the cross-process provision lock.Security model — the emitted-config tamper guard
The
wrangler deploystep is credentialed but the precedingvite buildruns untrusted project code with the Cloudflare credentials scrubbed, so it can only edit files — including thedist/ssr/wrangler.jsonthe deploy uploads.verifyGeneratedDeployConfigtreats the emitted config as adversarial and rejects any capability the pre-build root config did not already grant. Every upload-config field a barewrangler deployacts on is pinned to a pre-build source of truth: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
vp test run packages/voidgreen — 3662 passed / 8 skipped / 0 failed;vp run check+vp lint+vp fmtclean.test/unit/cloudflare-deploy.test.ts); provisioner (provision.test.ts) and self-host secrets gate (env-validation-selfhost.test.ts) are separately covered.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
--provisionis a single-operator dev-machine action — the config-path lock does not coordinate across machines (documented).--provisionfails closed in CI (it would create IDs CI can't commit back → duplicate infra).--provisionconfig-laundering delta-authorization, the verify→deploy TOCTOU, and full build isolation are tracked in Cloudflare deploy: harden the build→deploy trust boundary (TOCTOU + build isolation) #283. The no-provision (every-push/CI) path's config-laundering is already closed here.🤖 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 deployis forked early but provision locking and documented env/secret behavior need careful review before merge.Overview
Introduces
void deploy --backend cloudflareand--provisionon the CLI:parseDeployArgsaccepts the new flags, enforces--provisiononly with the Cloudflare backend, and rejects--project,--dir, and--spaon that path so users are not misled about where the worker lands.cli.tsforwardsbackendandprovisionintorunDeploy.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 vswrangler 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.