feat: provision --local + gitignored adapter manifests + hardened path safety - #287
feat: provision --local + gitignored adapter manifests + hardened path safety#287aram356 wants to merge 120 commits into
Conversation
Empty tracking commit for the implementation work tracked in: docs/superpowers/plans/2026-06-27-provision-local.md Issues: - Epic: <epic-issue-url> - Per-section sub-issues linked from the epic. This PR opens as a DRAFT and stays draft until Section 1 lands its first real commit. Each section opens as its own follow-up PR that lands here before the umbrella merges to main.
run_shared_checks iterates every declared adapter and dispatches validate_adapter_manifest, which for Spin does fs::read_to_string(manifest_root.join(rel)). With the containment guard sitting after run_shared_checks, a manifest declaring [adapters.spin.adapter].manifest = "../outside/spin.toml" could trigger a filesystem read outside the project root before the guard rejected it — a spec violation of §"Path containment (MUST)" which requires the helper run BEFORE any manifest-path use. Fix by relocating the check to fire immediately after load_push_context, and looping over every declared adapter (not just ctx.adapter) since run_shared_checks reads all of them. Also close Task 7's Minor about the duplicate adapter_entry call by removing the now-redundant per-adapter guard block. Regression test: config_push_local_rejects_parent_traversal_in_ sibling_spin_adapter declares a poisoned Spin adapter alongside the pushed axum adapter, and asserts the error names the containment violation (not Spin's "failed to read spin manifest" message that would surface under the old ordering). Also tighten copy_tree's else-branch to explicitly gate on is_regular_file() rather than "everything non-dir non-symlink", add a Unix symlink-skip test, and drop a stale #[expect(dead_code)] on ValidationContext::manifest_path that now has real callers.
…to feature/provision-local-impl
The bare-cwd variant of the accept-test (--manifest edgezero.toml)
previously wrote the manifest to a tempdir and set EDGEZERO_MANIFEST,
but run_provision reads args.manifest directly (no env fallback).
The test therefore failed on manifest load ("failed to load
edgezero.toml") and its negative !contains(path-safety-markers)
assertion vacuously passed — no actual coverage of the
`args.manifest.parent() == ""` fallback.
Fix by adding a CwdGuard RAII helper that chdirs into the tempdir
under the manifest_guard() serialisation lock and restores the
previous cwd on drop. Both accept-tests now also assert positively
that the error is the (true, true) dispatch stub ("local dry-run
staging lands in Task 10/11"), proving the manifest loaded AND
path-safety passed AND we reached the dispatch matrix. Drop the
now-unnecessary EnvOverride from both tests.
Reviewer: reviewer of Task 9 pushed this as a Low ahead of Task 10
because run_with_staging depends on manifest-root/cwd correctness.
prk-Jr
left a comment
There was a problem hiding this comment.
📝 ## PR Review
Summary
This is a thorough implementation with strong adapter-specific coverage, dry-run fidelity tests, and clear ownership boundaries. I found three blocking safety gaps: lexical containment can be bypassed through symlinks, run_serve mutates the process environment, and config validate remains outside the new path-safety perimeter.
😃 Praise
- 😃 The dry-run renderer redacts both sides before diffing and handles commented assignments plus value-only changes without leaking secret values.
Findings
Blocking
- 🔧 Apply path safety to
config validate: Bothrun_config_validateandrun_config_validate_typedcallrun_shared_checks, which reachesrun_adapter_shared_checksand invokesvalidate_adapter_manifestwithout first applyingassert_provision_paths_safe. Spin's validator then readsmanifest_root.join(adapter_manifest_path), so an absolute or traversing path rejected by provision/push/diff remains readable through validation (crates/edgezero-cli/src/config.rs:1421). Centralize the safe variant immediately before each adapter validator; local push can retain its stricter contained check.
Non-blocking
- 🤔 Document Axum's generated-manifest workflow:
docs/guide/adapters/axum.mdstill presentsaxum.tomlas an ordinary project file without explaining that it is now gitignored and absent after a fresh clone. Add the regeneration command (<app>-cli provision --adapter axum --local) near the manifest description (docs/guide/adapters/axum.md:201).
CI Status
- 📝 fmt: PASS
- 📝 clippy: PASS
- 📝 workspace tests: PASS
- 📝 feature compilation: PASS
- 📝 Spin wasm32-wasip2 check: PASS
- 📝 GitHub checks: PASS
| ("[adapters.<name>.adapter].crate", adapter_crate_path), | ||
| ] { | ||
| let Some(raw) = maybe_raw else { continue }; | ||
| let candidate = Path::new(raw); |
There was a problem hiding this comment.
🔧 Symlinks bypass the containment guarantee: these checks are purely lexical. For example, crate = "crates/worker" passes when crates/worker is a symlink to a directory outside the project; the subsequent adapter fs::write calls follow that symlink and mutate the external target. Dry-run makes this harder to notice because copy_dir_recursive deliberately skips symlinks, so its staged result differs from the real write path.
Please canonicalize and verify each existing path component (or reject symlink components) before writing. For complete TOCTOU protection, perform directory-relative opens/writes beneath a trusted root rather than checking a path and reopening it by name.
There was a problem hiding this comment.
Resolved in 44c9a2d (crates/edgezero-cli/src/path_safety.rs).
assert_provision_paths_impl now walks each component of the resolved path from project_root inward via new reject_symlink_components helper. Any existing intermediate whose symlink_metadata reports is_symlink() == true is rejected before dispatch. Broken/dangling symlinks are caught too (they still report is_symlink() == true). Applies to both the safe and strict-local variants — symlink following would let cloud dispatch read from outside the project as well.
Four regression tests (Unix, std::os::unix::fs::symlink): rejects_symlinked_adapter_crate_directory, rejects_symlink_in_manifest_intermediate_component, safe_variant_also_rejects_symlinked_paths, accepts_regular_directory_and_first_run_missing_paths (proves NotFound intermediates for first-run scaffold still pass).
Documented as best-effort against current filesystem state, not full TOCTOU protection — openat + O_NOFOLLOW threaded through every adapter is flagged as follow-up scope in the code comment.
| .strip_prefix('"') | ||
| .and_then(|stripped| stripped.strip_suffix('"')) | ||
| .unwrap_or(trimmed_val); | ||
| env::set_var(key, value); |
There was a problem hiding this comment.
🔧 Do not mutate the process environment from this library path: on Unix, setenv/getenv access is not thread-safe, and this crate cannot guarantee that no other thread is reading the environment. The repository's own shared_test_guards.rs explicitly documents this constraint, while run_serve can be called from a multithreaded downstream process.
Parse the file into an owned env overlay and apply it only to the spawned child with Command::envs. The existing adapter::run_shell path already builds a Command; registered-adapter execution should receive the same overlay through an execution context rather than process-global mutation. Validate keys and values while parsing so malformed NUL/= input returns an error instead of panicking.
There was a problem hiding this comment.
Resolved in c38cb54 → 7d6d886 → e0688aa (crates/edgezero-cli/src/env_file.rs, adapter.rs, lib.rs).
env_file::load_into_process_env (which called std::env::set_var) removed. Replaced by env_file::parse_env_overlay which returns an owned Vec<(String, String)> — no process-env mutation, no shared environ access, safe from any multithreaded caller. The parser also rejects lines whose key contains NUL or =, and values that contain NUL (both are Command::env contract violations that would otherwise panic or misroute downstream).
run_serve threads the overlay into the spawned child via new adapter::execute_with_env_overlay → Command::env per entry. Each Command::env call stores the entry in the Commands private map — no shared state, no setenv. Existing-env-wins preserved by checking std::env::var_os before each set (mirrors the manifest [environment.variables] block and the adapter bind hint).
Three parser tests + three integration tests:
env_file::tests::parses_key_value_lines_into_ordered_overlay(pure parser)env_file::tests::rejects_nul_byte_in_key/rejects_nul_byte_in_value(Command::env contract)env_file::tests::missing_file_yields_readable_errorrun_serve_does_not_mutate_parent_process_env_yet_child_sees_env_file_value(the load-bearing thread-safety assertion — child observes the marker via a file it writes; parent env asserted to beNoneafterrun_servereturns)run_serve_existing_parent_env_wins_over_env_file(opposite contract — parents KEY wins over the .env)
| // through as-is so structural drift is still visible. | ||
| let is_env_like = is_env_secret_carriage_path(proj_path); | ||
| let old_render = if is_env_like { | ||
| redact_env_body_for_diff(&old) |
There was a problem hiding this comment.
😃 Good secret-handling boundary: redacting both the old and staged bodies before constructing the diff prevents unchanged context lines from leaking operator values. The separate value-only-change marker also preserves useful signal without exposing keys or values.
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Summary
Requesting changes. The implementation has strong coverage and all current checks pass, but the inline findings identify three high-impact correctness/security regressions plus dry-run, nested-layout, and public-API compatibility gaps. I have not repeated the symlink-containment, process-global environment, validation-ordering, or Axum-documentation findings from the existing review.
…verlay via Command::env, axum docs All four findings from #287 review (@prk-Jr) addressed. ## Blocking #1 — config validate outside path-safety perimeter `run_config_validate` / `run_config_validate_typed` called `run_shared_checks` -> `run_adapter_shared_checks` which invoked `adapter.validate_adapter_manifest(manifest_root, path, ...)` without `assert_provision_paths_safe`. Spin's validator then reads `manifest_root.join(adapter_manifest_path)`, so a poisoned `manifest = "/etc/spin.toml"` would surface that file's contents as the validation error message. Fix: call `assert_provision_paths_safe` per-adapter INSIDE `run_adapter_shared_checks`, before each adapter's `validate_adapter_manifest`. This makes validate share the same minimum-bar guard push/diff/provision already run; local push/diff still layer their stricter `contained` variant on top. Regression tests: - `raw_rejects_absolute_adapter_manifest_path_before_adapter_validators_run` - `raw_rejects_parent_traversal_adapter_manifest_before_adapter_validators_run` ## Blocking #2 — Symlink bypass in lexical containment The lexical guard let `crate = "crates/worker"` pass when `crates/worker` is a symlink to a directory outside the project; subsequent adapter `fs::write` calls followed the symlink and mutated the external target. Dry-run `copy_dir_recursive` deliberately skips symlinks, so its staged result differed from the real write path — the operator wouldn't notice. Fix: new `reject_symlink_components(start, candidate, label)` helper walks each component of the resolved path and rejects any existing intermediate that `symlink_metadata` reports as `is_symlink()`. `NotFound` intermediates (first-run scaffold where the adapter crate isn't materialised yet) are fine — the adapter creates them from EdgeZero-owned code. Missing directories along the way are ok; a broken symlink still reports `is_symlink() == true`, so dangling links get caught too. Fires on BOTH the safe and strict-local variants; symlink following would let cloud dispatch read from outside the project too. Note: this closes the ambient-file surface but is not a full TOCTOU guard. A concurrent attacker who plants the symlink between check and adapter write can still race. Fully closing that requires directory-relative opens (`openat` + `O_NOFOLLOW`) threaded through every adapter — flagged as follow-up scope. Regression tests (Unix-only, use `std::os::unix::fs::symlink`): - `rejects_symlinked_adapter_crate_directory` - `rejects_symlink_in_manifest_intermediate_component` - `safe_variant_also_rejects_symlinked_paths` - `accepts_regular_directory_and_first_run_missing_paths` ## Blocking #3 — run_serve mutated process env `env_file::load_into_process_env` wrote every KEY=VALUE via `std::env::set_var`. On Unix, `setenv`/`getenv` operate on the shared `environ` array without synchronisation — the C standard flags concurrent access as UB. A multithreaded downstream process calling `edgezero_cli::run_serve` from one thread while another thread reads `std::env::var` observes a torn read. Fix: - `env_file::load_into_process_env` -> `parse_env_overlay`, which returns `Vec<(String, String)>`. No process-env mutation. - Parser rejects lines with NUL bytes in the key or value (`Command::env` contract violations) rather than panicking or silently misrouting downstream. - New `adapter::execute_with_env_overlay` accepts the owned overlay; `run_shell` applies it via `Command::env` (per-Command private map — no shared state, no setenv). Existing-env-wins preserved by checking `env::var_os` before each set, same rule as the manifest `[environment.variables]` block. - `run_serve` swaps the load path for `parse_env_overlay` + `execute_with_env_overlay`. Regression tests: - `env_file::tests::parses_key_value_lines_into_ordered_overlay` (pure parser: comments, quotes, indented lines, malformed lines skipped). - `env_file::tests::rejects_nul_byte_in_key` / `rejects_nul_byte_in_value` (Command::env contract). - `env_file::tests::missing_file_yields_readable_error`. - `run_serve_does_not_mutate_parent_process_env_yet_child_sees_env_file_value`: child observes the overlay via a file it writes; the PARENT's `env::var(marker)` is asserted to be None afterward. The load-bearing thread-safety assertion. - `run_serve_existing_parent_env_wins_over_env_file`: opposite contract — parent's KEY wins over the .env's KEY. - `run_serve_loads_env_file_into_process_env_before_spawning_child` (existing) updated: dropped the parent-env-observed assertion (which is now inverted from the new contract); kept the child-observed nested-manifest-parent assertion. The old `load_into_process_env_*` tests in lib.rs are deleted — parser behaviour is now covered in env_file.rs's own module tests, and the process-env observation was the exact pattern this fix removed. ## Non-blocking — Axum docs `docs/guide/adapters/axum.md` presented `axum.toml` as an ordinary project file. Added a warning block under the Configuration section documenting the 2026-07 amendment: it is provision-generated + gitignored, teammates regenerate via `<app>-cli provision --adapter axum --local`, operator edits survive re-run, and the Axum blueprint has no scaffold `.hbs` template so scaffold and clean-clone produce byte-identical output. All CI gates locally clean: fmt, clippy --all-features -D warnings, workspace tests (1521 pass — 4 new symlink + 4 new env-file parser + 2 new run_serve overlay + 2 new config-validate path-guard tests minus 2 obsolete load_into_process_env tests), Spin wasm32-wasip2 check, docs prettier.
…al-impl # Conflicts: # crates/edgezero-adapter-spin/src/cli.rs # crates/edgezero-cli/src/bin/check_no_nested_app_config.rs # crates/edgezero-cli/src/config.rs
…, dry-run diff, ConfigPushArgs source-compat All six findings from the second review pass (@ChristianPavilonis) addressed. ## P1a — Windows path shapes bypass containment `Path::is_absolute()` only catches drive-absolute (`C:\foo`) on Windows and Unix-absolute (`/foo`) on Unix. Two shapes slipped through on every host, regardless of authoring platform: - `\outside\spin.toml` — Windows rooted-without-drive; on Unix it looked like a relative component with embedded backslashes. - `D:outside\spin.toml` — Windows drive-relative (`D:`'s cwd); `Component::Prefix` only fires on Windows. Path safety now rejects any raw string that starts with `\` or `/` and any string matching a byte-level Windows drive prefix (`[A-Za-z]:`), regardless of host OS. Belt-and-braces: `Component::Prefix(_)` is also rejected. 5 new regression tests covering rooted-no-drive, forward-slash rooted, drive-relative, drive-absolute, and a legitimate mid-string-backslash accept case. ## P1b — Cloudflare retry loses existing namespace IDs If attempt 1 creates namespace A then fails on B, the outcome is discarded. On retry, A is skipped (already present in wrangler.toml) but was NOT recorded in `created_kv_ns`, so `ProvisionOutcome.deployed` only carries B. That permanently drops A from `[adapters.cloudflare.deployed].kv_namespaces` in edgezero.toml; teammates cloning fresh silently point at a missing namespace. Fix: when skipping an already-provisioned namespace, ALSO record its id in `created_kv_ns` so the outcome surfaces the full set (existing + newly-created). Regression test `skipped_existing_namespace_ids_still_surface_in_deployed_outcome` seeds a real id and asserts `outcome.deployed.sub_tables ["kv_namespaces"][logical]` matches. ## P1c — Nested Spin manifest emits wrong wasm relative path `synthesise_spin_toml` hard-coded `../../target/...` — correct for the 2-deep scaffold convention `crates/<crate>/spin.toml` but WRONG for nested layouts like `crates/spin-server/config/spin.toml` (needs `../../../target/...`; `../../target/` resolves to `crates/target/`, not the workspace's `target/`). New `workspace_relative_target_prefix(manifest_rel)` computes the prefix from the depth of `manifest_rel.parent()`. The synth signature grows a `manifest_rel: &Path` arg; the caller in `cli/mod.rs` passes `spin_rel` through. Existing tests updated to pass `Path::new("crates/x/spin.toml")` (or the seeded manifest path) to keep the same emitted prefix. Nested-manifest regression test tightened to assert the exact 3-deep `../../../target/...` prefix (previously only checked the basename, masking the bug). ## P2a — Cloud dry-run suppressed the edgezero.toml writeback diff `merge_deployed_into_manifest` returned silently in dry-run. Fastly's cloud dry-run can discover an existing `service_id` via a read-only `fastly service describe`, and the corresponding real run mutates tracked `edgezero.toml` without any preview. Dry-run now renders the original-vs-updated unified diff via `similar::TextDiff` when the doc changes, or logs a "no change" line otherwise. Existing dry-run test still passes (still no file mutation), just now with a log line the operator can read. ## P2b — Nested Axum synth emits crate_dir="." (loader breaks) For `crates/server/config/axum.toml`, the synthesiser found the crate name via the upward walk but still emitted `crate_dir = "."` — which points the axum loader at `crates/server/config/Cargo.toml` (manifest parent), not `crates/server/Cargo.toml` (crate root). `edgezero serve --adapter axum` then errored out with "expected Cargo.toml next to the manifest". New `cli_support::read_adapter_crate_root` returns the DIRECTORY holding the discovered Cargo.toml (sibling of the existing `read_adapter_crate_name`). `derive_axum_crate_dir` in `edgezero-adapter-axum/src/cli/mod.rs` computes the relative path from the manifest's parent to the crate root — `"."` for scaffold-convention, `".."` (or deeper) for nested. The synth gains a `crate_dir: &str` arg. Two regression tests: nested manifest emits `crate_dir = ".."`; scaffold-convention manifest still emits `crate_dir = "."`. ## P2c — ConfigPushArgs source break (`no_diff` / `no_env`) Prior commit hoisted `no_diff` / `no_env` under a nested `ConfigPushSuppressions` sub-struct to satisfy `clippy::struct_excessive_bools`, breaking every downstream CLI that constructs `ConfigPushArgs` via `Default::default()` + field assignment (`args.no_diff = true`) with E0609. `#[non_exhaustive]` does not insulate against moving or removing existing public fields. Fields restored as top-level on `ConfigPushArgs`. Total bool count is now 5, so the struct gets a per-item `#[expect(clippy::struct_excessive_bools)]` with a reason line pinning the source-compat rationale. `ConfigPushSuppressions` kept as an empty compat-stub for any out-of-tree code that reached for it. Call sites (config.rs, args.rs tests, app-demo test) updated back to `args.no_diff` / `args.no_env`. All CI gates locally clean: fmt, clippy `--workspace --all-targets --all-features -D warnings`, workspace tests (1622 pass), Spin wasm32-wasip2 check, examples/app-demo workspace tests.
Review responses — first-pass findingsThe two summary-body findings without inline threads: Blocking — Apply path safety to Non-blocking — Document Axum's generated-manifest workflow. Resolved in 7d6d886 ( Individual replies posted on each of the six second-review inline threads (P1a Windows paths, P1b CF retry, P1c Spin nested wasm, P2a dry-run diff, P2b Axum crate_dir, P2c ConfigPushArgs source break). All CI gates locally clean; branch at e0688aa on top of the main merge 3f340ef. |
…al-impl # Conflicts: # crates/edgezero-adapter-axum/src/cli/run.rs # crates/edgezero-adapter-cloudflare/src/cli.rs # crates/edgezero-adapter-fastly/src/cli.rs # crates/edgezero-adapter-spin/src/cli.rs # crates/edgezero-adapter-spin/src/cli/push_sqlite.rs # crates/edgezero-cli/Cargo.toml # crates/edgezero-cli/src/adapter.rs # crates/edgezero-cli/src/config.rs # crates/edgezero-cli/src/generator.rs # crates/edgezero-cli/src/provision.rs
`push_config_entries` built its starting map with a `_ => BTreeMap::new()` catch-all over `fs::read_to_string`, so every read failure -- not just NotFound -- silently produced an empty map. The `fs::write` that follows then replaced the file with only the current push's entries, dropping every sibling blob the operator had already pushed (e.g. losing `app_config` when pushing `app_config_staging`). Only two cases legitimately start empty: the file does not exist, or it exists and is blank. Invalid UTF-8, permission-denied and transient I/O errors now propagate with an error that names the file and says the push was refused rather than overwriting it. Regression test seeds an unreadable (invalid UTF-8) local-config file and asserts the push errors AND leaves the file byte-identical.
Blocking #1 -- symlink escapes in gitignored local state. `.edgezero/` and the env files are gitignored, so a symlink planted in either never surfaces in review, and both write paths followed it: - `env_file::append_lines_dedup_with_header` used `path.exists()` + `fs::write`, which follow a symlinked final component. A planted `.env -> ~/.ssh/authorized_keys` had provision write attacker-chosen `KEY=value` lines into the target, and `set_restrictive_mode` then chmod 0600'd the victim's file. A DANGLING link is worse: `fs::write` creates the target. Now rejected via `symlink_metadata`, which does not follow links. - `ProvisionLock::acquire` ran `create_dir_all` + `OpenOptions::create` on `.edgezero/provision.lock`, taking an flock on -- and holding a writable descriptor to -- a file outside the tree. - The dry-run staging copy vetted symlinked entries *inside* the tree it walked but not the root it was handed, so a symlinked `.edgezero` would have it copy e.g. `~/.aws` into the operator-visible staging dir. `path_safety::reject_symlink_components` already implemented the bounded component walk for manifest-declared paths; it is now pub(crate) with a field-agnostic message and reused by the two CLI sites. `env_file` lives in `edgezero-adapter` and has no project root to bound a walk against, so it guards its final component only -- the parent chain remains the resolving caller's job. Blocking #5 -- lossy derivations collapsing two values onto one target. Two independent axes, both silent and both serving the wrong value: - Fastly derives each secret's Viceroy env var as `key.to_ascii_uppercase()`, and `upsert_secret_store_entry` dedups on the exact key -- so `api_token` and `API_TOKEN` produced two separate `fastly.toml` rows that BOTH read `$API_TOKEN`. Now caught by `validate_typed_secrets`, which was a no-op stub. Cloudflare's stub is correct and stays: it never derives anything from the secret key. - Cloudflare AND Fastly both upper-case `store.logical` into `EDGEZERO__STORES__<KIND>__<LOGICAL>__NAME`. TOML keys are case-sensitive, so `[stores.kv.myStore]` and `[stores.kv.MYSTORE]` are two real stores emitting one variable, and env_file's dedup silently dropped the loser -- leaving that store pointed at the other's platform name. Guarded once in `ProvisionStores`, called by both. Kind is part of the variable name, so cross-kind id reuse still works. Tests cover each escape and each collision, asserting the victim file is byte-identical / the link target was never created, plus the negative cases (exact duplicate ids, same id across kinds, one key in two stores) so the guards don't over-reject.
`fastly.toml` is gitignored (per-machine); `edgezero.toml` is committed
(shared). The two provision paths move `service_id` in OPPOSITE
directions -- local pins the tracked id INTO fastly.toml, cloud captures
fastly.toml's id back OUT into the tracked block -- and nothing
arbitrated between them: the CLI hard-coded `deployed: None` on the
cloud arm, so the adapter could not see the tracked value, and
`merge_deployed_into_manifest` does a plain `insert`.
The read-back therefore always won. A developer whose gitignored
fastly.toml was left over from another service ran `edgezero provision`
and silently rewrote the id the whole team deploys against -- with no
diff to review, since the file that sourced it is not in git.
The read-back is not gratuitous, though: it is the intended bootstrap
(`fastly compute deploy` creates the service and writes its id into
fastly.toml, provision records it for the team), so "tracked always
wins" would break first-time capture. Thread the tracked state into the
cloud arm and arbitrate on it instead:
- tracked absent -> capture (the bootstrap case, unchanged)
- tracked == fastly.toml -> no writeback; returning the identical value
would report a no-op edit and render a
dry-run diff on every cloud run
- tracked != fastly.toml -> error naming both ids and both remedies
The conflict arm errors rather than picking a side because neither is
trustworthy automatically: the local file may be stale, or the service
may have been legitimately re-created. The operator decides.
The two existing read-back tests already passed `deployed: None`, so
they keep passing and now pin the bootstrap case precisely; new tests
cover the match and conflict arms.
The CLI runs `build`/`deploy`/`serve`/`auth` two ways: if the manifest sets `[adapters.<name>.commands].<action>` it spawns that shell command with the project root as cwd and the resolved child env (bind hints, `[environment.variables]`, the provision-written `.env` overlay); if the command is unset it falls back to the registered adapter's `execute`. The fallback received only the action and passthrough args, so everything the shell path applies was dropped: a `serve` there started the app with none of its `.env` secrets and resolved its manifest from the process cwd instead of the project root (blocking #3). It is reachable whenever a hand-written manifest omits `commands.serve` (scaffolded projects always set it). `Adapter::execute` now takes an `AdapterExecContext` carrying the manifest root + fully-resolved child env. Each adapter's `run::{build, deploy,serve}` seeds manifest discovery from `ctx.cwd()` (via the new `cli_support::discovery_base`) and applies `ctx.env()` to the spawned vendor CLI; the `auth` arms shell out to a globally-scoped login and ignore it. An empty context is inert, so the shell path and every existing call are unchanged. Env precedence + the required-secrets assertion were the shell path's private `apply_environment`; both moved into a shared `build_child_env` so the two paths cannot drift. That move also fixes two adjacent bugs: - #7: required `[environment.secrets]` were checked against the parent env BEFORE the `.env` overlay was applied, so a secret supplied only by the provision-written `.env` was falsely reported missing and `serve` refused to start. The check now sees the same resolved set the child will. - #9 (Spin half): the fallback `spin up` omitted `--runtime-config-file`, which the shell path passes, so local KV bindings provision wrote were absent. `run::serve` now passes it when `runtime-config.toml` exists. Trait doubles and call sites updated; tests cover the context apply, the inert default, overlay-as-secret-source, and the precedence arms.
Spec-alignment (findings #1, #6, #7): synthesised manifests now match the spec's normative baselines exactly, verified by per-adapter exact-content tests. - Cloudflare drops the `[build]` table; Fastly drops the empty `authors` array; Spin drops the `[component.<id>.build]` table. - Spin `allowed_outbound_hosts` becomes opt-in via `[adapters.spin.adapter].allowed_outbound_hosts`, defaulting to Spin's deny-all baseline. Both `edgezero new` and clean-clone read the same manifest, so the emitted file stays byte-identical. - Fastly cloud `provision` no longer auto-captures `service_id` from the gitignored, per-machine `fastly.toml`. Per the v1 contract the writeback is a documented one-time manual copy after `fastly compute deploy`; auto-capturing it let a stale local file overwrite the team's committed id. Correctness (findings #2, #3, #4, #8): - Cloudflare cloud `provision` reconciles namespace ids against the tracked `[adapters.cloudflare.deployed]` block: a local id that disagrees aborts with a conflict error instead of silently overwriting the committed one, and a fresh clone whose wrangler.toml lacks the id has it restored rather than creating a duplicate remote namespace. - Fastly typed-secret provisioning seeds the Viceroy `[[local_server.secret_stores.<name>]]` table under the resolved PLATFORM name (`TypedSecretEntry.platform`), the name the runtime opens, instead of the raw logical id -- an env override no longer points the runtime at a store the seed never created. - Symlink guards extended to the remaining provision/push write sites: `write_baseline_to_disk`, Axum's local-config JSON, and Spin's local KV SQLite db. `env_file::reject_symlinked_target` is now the shared final-component check. - Deployed-field ownership is enforced in core `Manifest` validation (canonical adapter map), so reduced-feature builds and non-CLI readers reject a field placed under an adapter that does not own it, not just the CLI's registry-based check. Also removes planning/review provenance markers (PR-round, Task, Stage, Phase, review-priority) from code comments, and corrects the CHANGELOG lock-path reference.
Closes the two remaining review mediums. Push→provision coverage (spec §"Push after provision leaves provision artifacts intact"): each adapter now has a concrete `push_after_provision_preserves_*` test that seeds an operator secret into the provision-written file, runs `config push --local`, and asserts that file is byte-for-byte intact: - Axum: `.edgezero/.env` (push writes local-config JSON). - Cloudflare: `.dev.vars` (push shells `wrangler kv bulk put` via the fake-wrangler shim -- no network). - Fastly: `[[local_server.secret_stores.<id>]]` in fastly.toml (push writes `[local_server.config_stores.*]`). - Spin: the Spin-side `.env` (push writes the local KV SQLite db). Migration docs: the generated README and getting-started/spin guides told operators to run the not-yet-installed project CLI on a fresh clone and to hand-edit the generated per-adapter binding. Both contradict the workflow -- the CLI is built from source (`cargo run -p <cli> --`) and provision owns the adapter binding. Corrected the README template, the getting-started and spin adapter guides, and the cli-walkthrough preamble, and fixed the remaining `.edgezero-provision.lock` -> `.edgezero/provision.lock` reference. CHANGELOG updated to note the coverage gap is closed.
The spec's rerun-to-refresh workflow says an operator who changes `[adapters.spin.adapter].component` out of phase with an already-synthesised `spin.toml` re-runs `provision --local` to refresh. But provision rejected it: `resolve_component_id` errored when the selector matched no existing component, and `validate_adapter_manifest` (which provision runs first) rejected it before provision could update anything -- so the manifest could never be refreshed. For a single-component `spin.toml`, provision now renames the sole `[component.<old>]` table to the selector and repoints `[[trigger.http]].component` at it (Spin's loader rejects a trigger that names a component with no matching block, so both edits happen together). Validation allows the transient mismatch for the single-component case so provision can proceed. A non-matching selector against MULTIPLE components stays a hard error -- provision can't infer which to rename. Tests: base `provision` and `provision_typed` both rename the sole component and repoint the trigger; validation allows the single- component refresh but still rejects the ambiguous multi-component case; the CLI `config validate` test is updated to match.
Round-11 review, blockers and mediums: - Spin `read_sqlite_entry` opened the db read-write and ran `CREATE TABLE`, so a nominally read-only `config diff --local` / push preflight dirtied the file. It now opens READ_ONLY and runs no DDL; a schema-less db reads as `MissingStore`. - Spin's local-KV path guarded only the final db component. A symlinked INTERMEDIATE (`.spin`) would let `create_dir_all` / open escape the project tree. New `env_file::reject_symlink_components` walks every component from the manifest root down; applied on both the write and the read resolution. - Cloudflare cloud provision no longer promotes an unverified namespace id read from the gitignored per-machine `wrangler.toml` into tracked deployed state. Durable ids come from `wrangler kv namespace create` output only; a local id that matches tracked is a no-op, one that conflicts errors, and one with no tracked entry is reported, never silently made the team source of truth. - `config validate` stays strict on a Spin component-selector mismatch (reports the inconsistency); the single-component refresh is now a provision-only transition via a new `allow_component_refresh` flag on `validate_adapter_manifest`, so provision can rename while the static check does not weaken. - Fastly cloud dry-run checks the `[setup.*]` skip BEFORE reporting, so a dry-run models the real run instead of claiming "would create" for a store that already exists. - Docs: Fastly/Cloudflare primitive snippets match the synthesised baselines; Fastly documents the one-time `service_id` copy; the migration guide gains the guarded `git rm --cached` untracking step.
…push tests
Round-11 review, remaining findings:
- The registry-fallback `execute` rediscovered each adapter's
per-platform manifest by scanning the workspace, which can pick the
wrong manifest in a nested / multi-app layout or follow a symlink off
the validated tree. `AdapterExecContext` now carries the declared,
root-resolved `[adapters.<name>.adapter].manifest`, and every
adapter's `run::{build,deploy,serve}` uses it verbatim (via
`cli_support::declared_or_discovered_manifest`) instead of scanning
whenever a manifest was loaded.
- Managed TOML value updates used `Table::insert`, which replaces the
whole item and drops a trailing inline comment on the line. Cloudflare
`id`/`preview_id`, Fastly root `service_id`, and the
`[adapters.<name>.deployed]` writeback now update the value in place
and clone the existing decor, honouring the byte-preserving merge
contract.
- The push-after-provision contract tests manually constructed the
secret file, so a provision-output-shape regression could slip past
them. All four (Axum/Cloudflare/Fastly/Spin) now run the real
`provision_typed` to write the file, let the operator fill in a value,
then push and assert it survives -- exercising the actual composition.
Blockers: - Apply the adapter declared-path safety guard to `build` and `deploy`, not just `serve`, via a shared `assert_adapter_declared_paths_safe`. - Anchor `wrangler kv namespace create` to the resolved wrangler.toml with `--config` so it can't act on an unrelated config. - Stop losing namespace ids when a later store fails mid-provision: `ProvisionOutcome` now carries `error`, per-store work moved into `provision_one_kv_store`, and the id is recorded before the writeback so a create-success/upsert-failure still checkpoints. The CLI persists `deployed` first, then surfaces the error. - Refresh the Spin component selector even when no KV/config stores are declared, so a secrets-only app doesn't leave a manifest that fails strict validate. - Reject distinct (store, key) pairs that collide on the same uppercased Fastly env var, including the same key across two stores. - Fix the migration untracking snippet: match adapter manifests and `.dev.vars` at the repo root as well as subdirectories (a `**/` pathspec skips root files), and keep the pipeline NUL-delimited. Also: - `config push --local` for Fastly now only upserts keys into a contents table provision already created, instead of fabricating the manifest structure it doesn't own. - Refuse Fastly chunk keys that exceed the 256-character Config Store key limit rather than emitting keys the platform rejects. - Treat a present-but-non-string `path`/`url`/`type` in Spin's runtime-config as malformed instead of silently falling back to the default backend, and preserve inline-comment decor when the component rename repoints a trigger. - Drop the legacy `.crate`/root fallback when resolving Spin's serve `.env`; it contradicts the required-`.manifest` rule and could read the wrong directory. - Assert the local dry-run actually succeeds for every adapter, and cover staged-tempdir path rewriting directly against `render_dry_run_report`. - Seed the Fastly and Cloudflare config stores in the config smoke before booting the emulator, and correct the adapter-manifest baselines in the docs (the synthesised `name` is the adapter crate's package name) plus the fresh-clone CLI invocations.
Blockers: - Refuse registry-fallback dispatch when a loaded manifest declares an adapter without `[adapters.<name>.adapter].manifest`. Previously the path guard was a no-op and the exec context carried no manifest, so every adapter fell back to a recursive, symlink-following workspace scan that could select an unrelated or out-of-tree project. - Thread the resolved `.crate` root through `AdapterExecContext`. The Cloudflare, Fastly, and Spin build paths assumed `Cargo.toml` sat beside the platform manifest, so a nested `crates/server/config/ spin.toml` resolved `config/Cargo.toml` and failed the build. - Refuse Fastly cloud provisioning when fastly.toml is absent. On a clean clone it creates the remote stores first, then materialises a `[setup.*]`-only manifest that `fastly compute build` rejects, leaving the stores orphaned. Cloud dispatch also now receives the tracked deployed state, so the resource-link remediation still fires when only `[adapters.fastly.deployed].service_id` knows the service is deployed. - Validate a chunk pointer's declared lengths before allocating. `envelope_len` is untrusted store data and reached `String::with_capacity` directly, so a corrupt pointer aborted the runtime instead of returning an integrity error. Also: - Preserve `ProvisionOutcome::error` across baseline prepending and the typed dry-run merge; both rebuilt the outcome from status lines and deployed state alone, reporting a failed local provision as success. - Enforce the 256-character Config Store key limit on the direct-value path, not just on chunk keys. - Make Fastly's dry-runs model the real operation: local push now probes the provision-owned contents table read-only, and cloud provisioning no longer claims it would create an `edgezero_runtime_env` store whose setup block already exists. - Build `axum.toml` through `toml_edit` instead of raw interpolation, and reject typed secret keys that cannot round-trip through a `<key>=` line (`partner=token` previously emitted `partner=token=`). - Point the config smoke at `/config/typed`, which reflects the single BlobEnvelope `config push` writes; the per-key assertions only passed against hand-seeded pre-cutover emulator state. - Save and restore `.dev.vars` in the secret and key-override smokes rather than truncating and deleting it: provision writes only empty placeholders, so it is not regenerable. - Drive clean-clone manifest regeneration through the typed provision entry point a generated CLI actually calls, and drop the unused `ConfigPushSuppressions` compatibility stub.
Blockers: - Split the typed dry-run helper so `run_local_dry_run_typed` clears the workspace `too_many_lines` clippy lint (the `-D warnings` CI gate was failing), and thread the staged provision's partial-failure error out so a dry-run over a broken provision exits non-zero. - Derive the axum nested `crate_dir` lexically instead of via `canonicalize()`: the old code required the manifest's parent dir to exist, so on a fresh clone it stayed relative while the crate root went absolute, `strip_prefix` failed, and it emitted "." where a nested manifest needs "..". - Anchor the adapters' `cargo build` at the crate root (`current_dir`) so dispatching through an absolute `EDGEZERO_MANIFEST` from outside the project no longer picks up the caller's `.cargo/config.toml` or resolves relative args against the wrong directory. - Make `resource_link_note` treat the tracked `[adapters.fastly.deployed].service_id` as the durable authority: prefer it over the gitignored fastly.toml and refuse on conflict, rather than recommending a link to whatever the local file names. Smoke fixes: - Config smoke: pass `--yes` on every `config push` (was prompting / failing without a TTY) and seed the mandatory `demo_api_token` secret per adapter so `/config/typed`'s secret walk resolves. - Secrets smoke: inject the `SMOKE_SECRET` declaration into the generated fastly.toml / spin.toml before boot (provision only declares typed secrets), so the Fastly and Spin arms resolve it on a clean clone. - Override smoke: stop appending a duplicate `demo_api_token` secret-store entry (warm-up already wrote it) and back up fastly.toml, which boot mutates in place. Also: - Refresh the app-demo Cargo.lock for axum's new `toml_edit` dep so `--locked` passes. - Require `[adapters.<name>.adapter].crate` alongside `.manifest` on the registry-fallback path, and propagate the untyped dry-run's error. - Emit the generated-CLI secret-placeholder follow-up after a bundled `provision --local`, and report Spin provisioning per touched file instead of one line naming only spin.toml. - Correct the spec (no `.hbs` templates back the adapter manifests) and the plan (keep the `.dev.vars` backup; only manifest backups are obsolete).
Blockers:
- Reject absolute / `..` SQLite `path`s and symlinked runtime-config.toml
in Spin local writes, so `--local` state can't escape the project tree.
- Refuse a Spin `--local` push whose label declares a non-SQLite backend
(redis / azure_cosmos / unknown) instead of seeding a database `spin up`
never reads.
- Edit inline-table deployed state via `TableLike`, so a valid
`deployed = { kv_namespaces = { … } }` manifest can be checkpointed
instead of failing writeback and stranding a created cloud resource.
- Reconcile the Fastly `service_id` (tracked vs local, with conflict
refusal) in PREFLIGHT, before any store is created or `[setup]` written,
and so dry-run surfaces the conflict too.
- Guard the axum serve `.env` chain with `reject_symlink_components`: a
symlinked `.edgezero` / `.env` can no longer inject env into the child.
- Enforce adapter-declaration consistency on the manifest
`commands.<action>` shell path too, so a half-declared adapter that
provision and the registry fallback reject can't slip through
build/deploy/serve.
Smoke:
- Back up fastly.toml before the oversized override section mutates it,
and run `restore_backups` even when `stop_server` returns non-zero
under `set -e` (it previously short-circuited the restore).
- Thread the authoritative `.crate` path into `synthesise_baseline_manifest` so a nested package can't be mis-selected: adapters now derive the crate name from the declared crate root, falling back to the ancestor Cargo.toml search only when it's undeclared. - Resolve a relative `CARGO_TARGET_DIR` against the crate dir cargo built in (the build runs with `current_dir(crate_dir)`), not the CLI's process cwd, so Cloudflare/Fastly artifact discovery finds the wasm. - Emit the commented `__KEY` hint for a CONFIG store added on Fastly's additive provision path, so incremental provisioning converges to the same shape a clean provision produces. - Dedup batched env-file entries against each other, not just the file, so duplicate typed-secret keys can't leave a trailing blank-valued placeholder that wins at load. - Repoint EVERY Spin trigger type on a component rename, not just `[[trigger.http]]`, so a redis/other trigger can't keep a stale ref. - Narrow the config-push lock: release it across the interactive consent prompt (re-acquired for the write, which rechecks remote state) so a human at the prompt can't block peers; and take the lock in `run_deploy` since `fastly compute deploy` writes `service_id` into fastly.toml. - Report provision writes by what actually landed: Axum and Spin no longer count deduped-away candidates as writes (`append_lines_dedup` now returns whether it wrote). - CI: add `--locked` to the app-demo test, and assert the adapter manifests + `.dev.vars` are actually gitignored (`git check-ignore`), not merely currently untracked.
Blockers:
- Edit inline `adapters = { … }` state via TableLike at the TOP level
too, so a fully-inline adapters tree can be checkpointed instead of
rejected after remote creation.
- Preflight a missing wrangler.toml in Cloudflare cloud provision, before
any `wrangler kv namespace create`, matching Fastly -- a clean clone no
longer orphans namespaces behind a manifest that never declared them.
- Thread the manifest deploy command into `config diff`'s push context
(as push already does) so a Fermyon Cloud Spin config reports the
Cloud-unsupported signal instead of reading local SQLite.
- Reject a Spin `--local` SQLite db that resolves OUTSIDE the crate even
when the `path` string is clean-relative: `--runtime-config` can point
the anchoring directory off-tree (e.g. `/tmp`).
- Guard the axum serve `.edgezero` chain UNCONDITIONALLY, not only when
`.env` exists -- the config store reads `local-config-*.json` there at
request time, so a symlinked `.edgezero` must be refused with no `.env`.
- Back up fastly.toml (and `.dev.vars`) BEFORE the config smoke's warm-up
and push mutate them, and install the cleanup trap first, so a run
never leaves a developer's tree changed.
Also:
- Resolve `CARGO_TARGET_DIR` for artifact lookup from the same ctx env
the build used (not just the process env), so a manifest-set custom
target dir is found.
- Make Spin's local read/diff reject Redis/Azure/unknown backends via the
same helper the write path uses, instead of diffing stale default
SQLite.
- Stop taking the non-reentrant provision lock in `run_deploy`: the
deploy command is arbitrary and may itself invoke provision/config
push in a child process, which would self-deadlock.
- Anchor axum `crate_dir` on the declared `.crate` (like the crate name),
so a nested package can't produce an internally inconsistent axum.toml.
- Align the spec gitignore example and the plan's axum step with the
current contract: axum.toml is a provision-generated, gitignored
manifest.
Provisioning and push: - Fastly cloud provision preflights the entire [setup] writeback shape before any `*-store create`, so a malformed-but-valid manifest aborts before orphaning a remote store (and dry-run models the real outcome). - Cloudflare cloud provision preflights every store's deterministic id-conflict and writeback-shape checks before the first `wrangler` call, so a later store's knowable conflict can't strand a namespace an earlier store already created. - Spin build artifact discovery reads CARGO_TARGET_DIR from the ctx env the build actually used, not just the process env. - A declared `[adapters.<name>.adapter].crate` with an unreadable Cargo.toml is now a hard error rather than a silent fallback to ancestor discovery. - Idempotent env-file provisioning repairs loose (0644) permissions to 0600 even when there is nothing to append; dry-run leaves mode alone. - A missing `fastly` CLI is surfaced as an error instead of being misreported as a missing config store. - Cloudflare config-push dry-run fails on a malformed wrangler.toml instead of masking it as an <unresolved> namespace; an absent or placeholder binding stays a lenient preview. Axum: - Fallback execution validates the resolved crate dir against the authoritative declared crate (or the workspace root), rejecting conflicts and symlink escapes. Smoke scripts: - Back up developer-local emulator state before warm-up provisioning mutates it, restore directories and files in reverse order, and distinguish an absent file from an existing empty one. Docs: - Reconcile the provision-local spec, scaffold README, and Spin guide with the hard-cutoff model: the synthesiser is the single writer for every adapter manifest, hand-edits are preserved on re-run, and `edgezero new` takes no --adapter flag.
Adds
edgezero provision --local, folds all five adapter manifests into the gitignored-generated model, and hardens the surrounding CLI (path safety, env redaction, adapter-scoped env-file load) acrossprovision,config push,config diff, andserve.Source artifacts
docs/superpowers/specs/2026-06-23-provision-local.mddocs/superpowers/plans/2026-06-27-provision-local.md— 43 tasks across 9 sectionsThe 9 plan sections all landed on this branch (rather than as separate sub-PRs merging in); each closes on merge below.
What ships
provision --local. NewProvisionMode::Localarm threads throughAdapter::provision. Local mode:toml_edit::DocumentMut(CLI-owned bootstrap runs before validation).EDGEZERO__STORES__<KIND>__<ID>__NAME=…) so the runtime resolves stores at startup..edgezero/.env,.dev.vars,<spin_crate>/.env).Dry-run stages a real
fs::copyinto atempfile::TempDirand diffs the result back. The tree stays byte-identical either way.Typed provision (
run_provision_typed::<C>). Generated<app>-clibinaries dispatch the typed variant, which runs the base flow then additionally emits per-secret placeholder lines derived fromAppConfig's#[secret]/#[secret(store_ref)]fields (Axum:<key>=; Cloudflare:<key>=""in.dev.vars; Fastly:[[local_server.secret_stores.<store_id>]]; Spin: lowercased[variables]+SPIN_VARIABLE_<NAME>=in.env). The bundlededgezerobinary intentionally does not emit placeholders — it has no downstreamAppConfigtype.All five adapter manifests are provision-generated and gitignored.
axum.tomljoinedwrangler.toml/fastly.toml/spin.toml/runtime-config.tomlin the 2026-07 amendment; the scaffold.hbstemplate foraxum.tomlwas removed so scaffold-time provision is the single writer. A CI grep gate enforces the whole set.Path safety. New
path_safety::{assert_provision_paths_safe, assert_provision_paths_contained}helpers guard every CLI entry point that joinsmanifest_rootwith an operator-declared adapter path. Cloud dispatch runs the "safe" variant (absolute-path rejection +..traversal rejection);--localruns the stricter "contained" variant that additionally requires BOTH[adapters.<name>.adapter].manifestAND[adapters.<name>.adapter].crateto be declared, with the manifest resolving inside the crate dir. Wired intorun_provision,run_provision_typed,run_config_push_typed,run_config_diff_typed, andrun_serve.Renamed / nested adapter crate support.
cli_support::read_adapter_crate_namewalks upward from the manifest's parent to the first Cargo.toml insidemanifest_rootand reads[package].name. All four bundled adapter synthesisers use this to name generated manifests correctly under operator renames ([adapters.axum.adapter].crate = "crates/server"→crate = "server"inaxum.toml; Cargo packagespin-server→ wasm pathspin_server.wasm). Nested manifests likecrates/server/config/spin.tomlresolve correctly too.Spin component / crate decoupling.
synthesise_spin_tomlnow takes two distinct identities —crate_name(from Cargo.toml, drives[application].nameand the wasm source basename) andcomponent(from[adapters.spin.adapter].component, drives[[trigger.http]].componentand the[component.<id>]table key). Fixes the pre-2026-07 conflation where setting a component selector would silently mispoint the wasm artifact.Env-value redaction in dry-run.
.envand.dev.varsbodies are rewritten asKEY=<redacted>before diffing so operator secrets never surface in dry-run output. CommentedKEY=valuelines (adapter provisioners emit these as# EDGEZERO__STORES__…__KEY=placeholders, and operators stash real values behind#for later use) get the same treatment; pure comments and blank lines pass through so structural drift stays readable.Adapter-scoped env-file load in
run_serve. Axum reads<manifest_root>/.edgezero/.env; Spin reads.envnext to the resolvedspin.toml(matches where provision writes it — a nestedmanifest = "crates/spin/config/spin.toml"correctly resolves tocrates/spin/config/.env). Both run through the path-safety guard before the read. Contract test spawns a real serve child, inspects its inherited environment via a file the child writes, and asserts the marker propagates.Section breakdown
Each section closes on merge:
ManifestAdapterDeployedschema + writeback (Tasks 14–16, 16b–16c)Post-plan additions (from external review iterations)
Multiple review passes drove refinements past the original plan:
.gitignore+ CI gate + spec-plan-guide docs updated.read_adapter_crate_nameupward walk added to support nested manifests (v5 review).[application].nameand wasm basename decoupled from the component selector (v5 review)..manifestAND.crate;run_config_diff_typedgets the guard (previously read-only justified skipping);run_servegets the guard on the Spin env-file load.# KEY=valuelines (v6/v7 reviews).run_manifest_shape_gates(addsvalidate_deployed_field_ownershipalongside capability + handler-path checks).run_servederives.envpath from[adapters.spin.adapter].manifest.parent()(was.crate— broke on nested manifests). Contract test observes the spawned child's env (v8 review).#[expect(...)]restructure sweep acrossedgezero-cli,edgezero-core/manifest.rs(Deserialize impls), and adaptercli/mod.rsfiles (removed via explicit trait method overrides,ConfigPushSuppressionssub-struct,streammodule, structural code fixes rather than workspace-levelallows).CI gates (all pass locally)
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --all-features -- -D warningscargo test --workspace --all-targetscargo check --workspace --all-targets --features "fastly cloudflare spin"cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spinexamples/app-demoworkspace testscargo test -p edgezero-cli --test generated_project_builds -- --ignored)Test plan
provision_local_*cases + Spin's env-label alignment quartet (Section 9)run_serve,run_config_diff_typed,run_config_push_typed,run_provisionMigration notes
<app>-cli provision --adapter <name> --localafter cloning to regenerate the five adapter manifests (.gitignorecovers all of them plus.dev.vars).[adapters.<name>.adapter].manifestand[adapters.<name>.adapter].crateare now both required forprovision --localandconfig push/diff --local. Every scaffolded project already sets both. Cloud dispatch remains permissive.run_provision_typed::<AppConfig>(not the untypedrun_provision) so#[secret]fields reach the adapters'provision_typedimpls.