From cf83ce683b97f4c443acdd57db9f1edec2621104 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 25 Apr 2026 12:33:03 -0400 Subject: [PATCH 01/96] chore: remove legacy FABRO_JWT_* key generation script SESSION_SECRET is the sole auth root; the JWT keypair env vars are no longer part of the runtime auth model. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 3 --- bin/ops/generate-jwt-keys.sh | 15 --------------- 2 files changed, 18 deletions(-) delete mode 100755 bin/ops/generate-jwt-keys.sh diff --git a/.env.example b/.env.example index 59127f810..5645f195f 100644 --- a/.env.example +++ b/.env.example @@ -8,9 +8,6 @@ MINIMAX_API_KEY= OPENAI_API_KEY= ZAI_API_KEY= -FABRO_JWT_PRIVATE_KEY= -FABRO_JWT_PUBLIC_KEY= - SESSION_SECRET= GITHUB_APP_CLIENT_SECRET= GITHUB_APP_WEBHOOK_SECRET= diff --git a/bin/ops/generate-jwt-keys.sh b/bin/ops/generate-jwt-keys.sh deleted file mode 100755 index d31210f8c..000000000 --- a/bin/ops/generate-jwt-keys.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -openssl genpkey -algorithm Ed25519 -out fabro-jwt-private.pem -openssl pkey -in fabro-jwt-private.pem -pubout -out fabro-jwt-public.pem - -echo "" -echo "Generated:" -echo " fabro-jwt-private.pem (private key — for fabro-web / FABRO_JWT_PRIVATE_KEY)" -echo " fabro-jwt-public.pem (public key — for fabro-workflow / FABRO_JWT_PUBLIC_KEY)" -echo "" -echo "Set env vars with the PEM contents (including header/footer lines):" -echo "" -echo ' export FABRO_JWT_PRIVATE_KEY="$(cat fabro-jwt-private.pem)"' -echo ' export FABRO_JWT_PUBLIC_KEY="$(cat fabro-jwt-public.pem)"' From a4c04a296ed22aab3a84aa6146924ee263c5085e Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 25 Apr 2026 12:37:45 -0400 Subject: [PATCH 02/96] chore: move docker-context/ staging dir under tmp/ Keeps the repo root tidy. The staged Linux musl binaries used by the Dockerfile and the release pipeline now live at tmp/docker-context//fabro instead of docker-context//fabro. Co-Authored-By: Claude Opus 4.7 (1M context) --- .dockerignore | 2 +- .github/workflows/release.yml | 4 ++-- .gitignore | 1 - AGENTS.md | 4 ++-- Dockerfile | 6 +++--- lib/crates/fabro-dev/src/commands/docker_build.rs | 7 ++++--- lib/crates/fabro-dev/tests/it/docker_build.rs | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.dockerignore b/.dockerignore index 4bc5f6c79..08712f7bb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,4 @@ * !docker/entrypoint.sh !docker/settings.toml -!docker-context/** +!tmp/docker-context/** diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d3758d53..07cd3e155 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -188,9 +188,9 @@ jobs: x86_64-*) arch=amd64 ;; aarch64-*) arch=arm64 ;; esac - mkdir -p "docker-context/$arch" + mkdir -p "tmp/docker-context/$arch" tar -xzf "target/distrib/fabro-${target}.tar.gz" -C target/distrib - cp "target/distrib/fabro-${target}/fabro" "docker-context/$arch/fabro" + cp "target/distrib/fabro-${target}/fabro" "tmp/docker-context/$arch/fabro" done - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 diff --git a/.gitignore b/.gitignore index 12bf2b2ca..cb1ebbe2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ target -docker-context/ .env .entire node_modules diff --git a/AGENTS.md b/AGENTS.md index f46aaf2d7..12caab195 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24) - `cargo dev refresh-spa` — **run this before committing any TypeScript change in `apps/fabro-web/` or `lib/packages/fabro-api-client/`**. It runs the production build and then copies `dist/` into `lib/crates/fabro-spa/assets/` (which is tracked in git). CI's TypeScript `Build` job reruns this command and then `git diff --exit-code -- lib/crates/fabro-spa/assets` — if the committed bundle drifts from source (e.g. content-hashed filenames like `entry-.js` change), the check fails. `bun run build` on its own is not enough. ### Docker image -- `cargo dev docker-build` — builds the local Docker image from the current tree using the release pipeline's cargo-zigbuild approach. Honors `--arch amd64|arm64`, `--tag ` (default `fabro`), `--compile-only` (stages `docker-context//fabro` without `docker build`), and `--dry-run` (prints the Docker commands without running them). Prefer this over writing a throwaway Dockerfile; the release pipeline, `Dockerfile`, and this command share the same binary layout. +- `cargo dev docker-build` — builds the local Docker image from the current tree using the release pipeline's cargo-zigbuild approach. Honors `--arch amd64|arm64`, `--tag ` (default `fabro`), `--compile-only` (stages `tmp/docker-context//fabro` without `docker build`), and `--dry-run` (prints the Docker commands without running them). Prefer this over writing a throwaway Dockerfile; the release pipeline, `Dockerfile`, and this command share the same binary layout. - Refresh the embedded SPA before rebuilding the image after any `apps/fabro-web` change: `cargo dev refresh-spa` runs the bun build and copies `dist/` into `lib/crates/fabro-spa/assets/`. Skipping this step produces a Docker image whose Rust binary embeds a stale SPA bundle. ### Release automation @@ -102,7 +102,7 @@ When working on Rust crates, read the relevant strategy doc **before** making ch - **`docs-internal/logging-strategy.md`** — read when adding `tracing` calls (`info!`, `debug!`, `warn!`, `error!`), working on error handling paths, or adding new operations that should be observable - **`docs-internal/events-strategy.md`** — read when adding or modifying `Event` variants, touching `Emitter`/`emit()`, changing `progress.jsonl` output, or adding new workflow stage types -- **`files-internal/testing-strategy.md`** — read when adding or reorganizing tests, choosing between unit vs `tests/it`, deciding whether a test belongs in `cmd` vs `workflow` vs `scenario`, or deciding how to structure snapshots and fixtures +- **`docs-internal/testing-strategy.md`** — read when adding or reorganizing tests, choosing between unit vs `tests/it`, deciding whether a test belongs in `cmd` vs `workflow` vs `scenario`, or deciding how to structure snapshots and fixtures - **`docs-internal/server-secrets-strategy.md`** — read when adding or changing server-level secrets, startup validation, install-time secret persistence, or subprocess env inheritance/scrubbing ## Shell quoting in sandbox code diff --git a/Dockerfile b/Dockerfile index 29ff48dbc..0ced73368 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,8 @@ # Runtime image for the Fabro server. # # Binaries are supplied pre-built via the release workflow: -# docker-context/amd64/fabro (x86_64-unknown-linux-musl) -# docker-context/arm64/fabro (aarch64-unknown-linux-musl) +# tmp/docker-context/amd64/fabro (x86_64-unknown-linux-musl) +# tmp/docker-context/arm64/fabro (aarch64-unknown-linux-musl) # # The image serves the HTTP API (with embedded web UI) on $PORT (default # 32276), persists state to /storage, and runs as the unprivileged `fabro` @@ -26,7 +26,7 @@ RUN apk add --no-cache \ && adduser -S -u 1000 -G fabro -h /var/fabro -s /sbin/nologin fabro \ && install -d -o fabro -g fabro -m 0755 /var/fabro /storage -COPY --chmod=0755 docker-context/${TARGETARCH}/fabro /usr/local/bin/fabro +COPY --chmod=0755 tmp/docker-context/${TARGETARCH}/fabro /usr/local/bin/fabro COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/fabro-entrypoint diff --git a/lib/crates/fabro-dev/src/commands/docker_build.rs b/lib/crates/fabro-dev/src/commands/docker_build.rs index 60a2a1b91..c56217711 100644 --- a/lib/crates/fabro-dev/src/commands/docker_build.rs +++ b/lib/crates/fabro-dev/src/commands/docker_build.rs @@ -117,7 +117,7 @@ impl DockerBuildPlan { if self.compile_only { println!( - "Staged docker-context/{}/fabro (skipping docker build per --compile-only).", + "Staged tmp/docker-context/{}/fabro (skipping docker build per --compile-only).", self.arch ); return Ok(()); @@ -135,7 +135,7 @@ impl DockerBuildPlan { self.extract_command().to_shell_line(), ]; if self.compile_only { - lines.push(format!("staged docker-context/{}/fabro", self.arch)); + lines.push(format!("staged tmp/docker-context/{}/fabro", self.arch)); } else { lines.push(self.image_build_command().to_shell_line()); } @@ -202,12 +202,13 @@ impl DockerBuildPlan { fn context_dir(&self) -> PathBuf { self.workspace_root + .join("tmp") .join("docker-context") .join(self.arch.to_string()) } fn relative_context_dir(&self) -> String { - format!("docker-context/{}", self.arch) + format!("tmp/docker-context/{}", self.arch) } } diff --git a/lib/crates/fabro-dev/tests/it/docker_build.rs b/lib/crates/fabro-dev/tests/it/docker_build.rs index 461de29c9..2ea9d67db 100644 --- a/lib/crates/fabro-dev/tests/it/docker_build.rs +++ b/lib/crates/fabro-dev/tests/it/docker_build.rs @@ -83,7 +83,7 @@ fn dry_run_compile_only_skips_image_build() { let stdout = output_text(&output.stdout); assert!( - stdout.contains("docker-context/arm64/fabro"), + stdout.contains("tmp/docker-context/arm64/fabro"), "dry-run compile-only should print staged binary path:\n{stdout}" ); assert!( From bebf472ad26fc0b7a6d98e50e881fce56fe9f156 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 25 Apr 2026 12:38:41 -0400 Subject: [PATCH 03/96] chore: move testing-strategy doc into docs-internal The strategy doc lived alone under files-internal/ while every sibling strategy doc (logging, events, server-secrets) lived under docs-internal/. Move it next to the others and update plan/spec references. Co-Authored-By: Claude Opus 4.7 (1M context) --- {files-internal => docs-internal}/testing-strategy.md | 0 .../2026-04-19-001-feat-archived-run-status-plan.md | 4 ++-- ...20-001-fix-cli-server-same-host-assumptions-plan.md | 6 +++--- ...-20-002-refactor-extract-fabro-client-crate-plan.md | 2 +- ...-003-refactor-unify-run-vocabulary-metadata-plan.md | 10 +++++----- ...4-23-001-refactor-command-context-alignment-plan.md | 4 ++-- .../2026-04-24-001-refactor-adopt-uv-patterns-plan.md | 2 +- .../superpowers/specs/2026-04-18-web-install-design.md | 2 +- 8 files changed, 15 insertions(+), 15 deletions(-) rename {files-internal => docs-internal}/testing-strategy.md (100%) diff --git a/files-internal/testing-strategy.md b/docs-internal/testing-strategy.md similarity index 100% rename from files-internal/testing-strategy.md rename to docs-internal/testing-strategy.md diff --git a/docs/plans/2026-04-19-001-feat-archived-run-status-plan.md b/docs/plans/2026-04-19-001-feat-archived-run-status-plan.md index 91d522ce1..9c08eeae7 100644 --- a/docs/plans/2026-04-19-001-feat-archived-run-status-plan.md +++ b/docs/plans/2026-04-19-001-feat-archived-run-status-plan.md @@ -96,7 +96,7 @@ No billing / usage / reporting site reads `is_terminal()` — those roll up from No `docs/solutions/` entries exist in this repo; the institutional-knowledge base is empty. Substitute docs to follow: - `docs-internal/events-strategy.md` (the 7-step event checklist) -- `files-internal/testing-strategy.md` (layering: `cmd/*` for single-command CLI tests, `scenario/*` for cross-command lifecycle) +- `docs-internal/testing-strategy.md` (layering: `cmd/*` for single-command CLI tests, `scenario/*` for cross-command lifecycle) - `AGENTS.md` §"API workflow" (OpenAPI source-of-truth and regen sequence) and §"Rust import style" (types by name, functions via parent module) ### External References @@ -554,7 +554,7 @@ flowchart TB - **Origin document:** [docs/brainstorms/2026-04-19-run-archived-status-requirements.md](../brainstorms/2026-04-19-run-archived-status-requirements.md) - **Events strategy:** [docs-internal/events-strategy.md](../../docs-internal/events-strategy.md) -- **Testing strategy:** [files-internal/testing-strategy.md](../../files-internal/testing-strategy.md) +- **Testing strategy:** [docs-internal/testing-strategy.md](../../docs-internal/testing-strategy.md) - **API workflow convention:** `AGENTS.md` §"API workflow" - **Bulk-by-ID CLI template:** `lib/crates/fabro-cli/src/commands/runs/rm.rs` - **Actor-carrying event precedent:** `lib/crates/fabro-workflow/src/event.rs:93-96` (`RunCancelRequested`) diff --git a/docs/plans/2026-04-20-001-fix-cli-server-same-host-assumptions-plan.md b/docs/plans/2026-04-20-001-fix-cli-server-same-host-assumptions-plan.md index ebff1cccc..bbcfca88b 100644 --- a/docs/plans/2026-04-20-001-fix-cli-server-same-host-assumptions-plan.md +++ b/docs/plans/2026-04-20-001-fix-cli-server-same-host-assumptions-plan.md @@ -64,7 +64,7 @@ Those behaviors were survivable when the CLI and server were assumed to live on ### Institutional Learnings - No `docs/solutions/` directory exists in this repository, so there are no institutional learnings to carry forward from that source. -- `files-internal/testing-strategy.md` reinforces the right split for this work: +- `docs-internal/testing-strategy.md` reinforces the right split for this work: - connection/auth selection logic should get crate-level tests - single-command contract regressions should stay in `tests/it/cmd` - cross-command auth or exec narratives should stay in `tests/it/scenario` @@ -322,7 +322,7 @@ flowchart TB - Update docs/help copy anywhere it still implies that explicit `--server` may inherit local daemon identity or local dev-token convenience. **Patterns to follow:** -- `files-internal/testing-strategy.md` placement rules for crate-level vs `cmd/*` vs `scenario/*` +- `docs-internal/testing-strategy.md` placement rules for crate-level vs `cmd/*` vs `scenario/*` - existing real auth harness organization in `lib/crates/fabro-cli/tests/it/support/auth_harness.rs` **Test scenarios:** @@ -386,4 +386,4 @@ flowchart TB - `lib/crates/fabro-cli/tests/it/scenario/auth.rs` - `lib/crates/fabro-cli/tests/it/support/auth_harness.rs` - Repo guidance: - - `files-internal/testing-strategy.md` + - `docs-internal/testing-strategy.md` diff --git a/docs/plans/2026-04-20-002-refactor-extract-fabro-client-crate-plan.md b/docs/plans/2026-04-20-002-refactor-extract-fabro-client-crate-plan.md index 4ce7dfb11..224607c2a 100644 --- a/docs/plans/2026-04-20-002-refactor-extract-fabro-client-crate-plan.md +++ b/docs/plans/2026-04-20-002-refactor-extract-fabro-client-crate-plan.md @@ -96,7 +96,7 @@ These were all reasonable while the client had exactly one caller. They prevent - No `docs/solutions/` directory in this repo — no prior captured learnings apply. Historical plans in `docs/plans/` around CLI/server boundary (`2026-04-02-001-feat-server-daemon-management-plan.md`, `2026-04-05-cli-deglobalize-server-url-and-storage-dir-plan.md`, `2026-04-20-001-fix-cli-server-same-host-assumptions-plan.md`) have tightened the target-resolution contract progressively. This plan's `fabro-client` extraction is the next step in that direction: after target resolution became disciplined, pull the pure client *out* of the CLI and make its purity enforceable by the crate graph. - `CLAUDE.md` reminds: "We want the simplest change possible. We don't care about migration. Code readability matters most, and we're happy to make bigger changes to achieve it." We lean on this for the `ServerTarget` canonicalization change and the "delete the alias" step in the `Client` rename we just finished. -- `files-internal/testing-strategy.md` — most existing CLI tests exercise the client through CLI-level commands; those tests continue to live in `fabro-cli` and don't need to migrate. Tests of internal helpers (auth-store round-trips, `ServerTargetKey` canonicalization, loopback classification, SSE parsing, dev-token resolution) fall into three buckets: (a) auth-store/SSE/loopback/ServerTarget tests migrate to `fabro-client`; (b) dev-token resolution tests stay in `fabro-cli`; (c) server-client unit tests for refresh-token transport checks migrate. +- `docs-internal/testing-strategy.md` — most existing CLI tests exercise the client through CLI-level commands; those tests continue to live in `fabro-cli` and don't need to migrate. Tests of internal helpers (auth-store round-trips, `ServerTargetKey` canonicalization, loopback classification, SSE parsing, dev-token resolution) fall into three buckets: (a) auth-store/SSE/loopback/ServerTarget tests migrate to `fabro-client`; (b) dev-token resolution tests stay in `fabro-cli`; (c) server-client unit tests for refresh-token transport checks migrate. ### External References diff --git a/docs/plans/2026-04-20-003-refactor-unify-run-vocabulary-metadata-plan.md b/docs/plans/2026-04-20-003-refactor-unify-run-vocabulary-metadata-plan.md index fe8819d25..54dc04fa9 100644 --- a/docs/plans/2026-04-20-003-refactor-unify-run-vocabulary-metadata-plan.md +++ b/docs/plans/2026-04-20-003-refactor-unify-run-vocabulary-metadata-plan.md @@ -51,7 +51,7 @@ That mismatch leaks implementation history into the domain model and makes every - `lib/crates/fabro-workflow/src/lifecycle/git.rs` and `lib/crates/fabro-workflow/src/pipeline/finalize.rs` still use phase-specific `RunDump` constructors and a `checkpoint.json`-oriented metadata helper. - `lib/crates/fabro-workflow/src/operations/{fork.rs,rewind.rs,rebuild_meta.rs}` plus `lib/crates/fabro-cli/src/commands/run/rewind.rs` are the critical metadata readers/writers that must switch from standalone `checkpoint.json` and `start.json` reads to projection reads. - `lib/crates/fabro-types/src/stage_id.rs` already defines `Display` as `{node_id}@{visit}`, which should become the on-disk stage directory name. -- `files-internal/testing-strategy.md` says CLI integration tests should remain command-driven and black-box; layout-specific assertions belong in the right layer rather than by planting run internals by hand. +- `docs-internal/testing-strategy.md` says CLI integration tests should remain command-driven and black-box; layout-specific assertions belong in the right layer rather than by planting run internals by hand. ### Institutional Learnings @@ -95,7 +95,7 @@ That mismatch leaks implementation history into the domain model and makes every - Exact helper names for the new metadata commit writer (`write_snapshot`, `write_projection_commit`, etc.). The plan fixes the API shape and intent, but the final Rust name can be chosen during implementation. - Whether the shared export builder stays in `lib/crates/fabro-workflow/src/run_dump.rs` or moves to a nearby module. The key constraint is one authoritative layout builder, not a specific file name. -- Whether any low-value tests should move layers while being updated. Follow `files-internal/testing-strategy.md` if implementation reveals a better layer, but do not turn this refactor into a broad test reorganization. +- Whether any low-value tests should move layers while being updated. Follow `docs-internal/testing-strategy.md` if implementation reveals a better layer, but do not turn this refactor into a broad test reorganization. ## High-Level Technical Design @@ -299,11 +299,11 @@ Durable event store **Approach:** - Update retro agent instructions and sandbox uploads so the agent reads `run.json` projection data plus `graph.fabro` and stage files instead of `checkpoint.json` and `start.json`. - Rename or replace tests that currently assert `conclusion.json` or old `nodes/...` layouts so they assert conclusion presence inside `run.json` and stage files under `stages/`. -- Keep CLI integration tests black-box per `files-internal/testing-strategy.md`; layout assertions should come from public command behavior or crate-level tests, not hand-planted run internals. +- Keep CLI integration tests black-box per `docs-internal/testing-strategy.md`; layout assertions should come from public command behavior or crate-level tests, not hand-planted run internals. - Review snapshot diffs before accepting them because this refactor intentionally changes many file paths and exported filenames. **Patterns to follow:** -- Snapshot discipline in `files-internal/testing-strategy.md` +- Snapshot discipline in `docs-internal/testing-strategy.md` - Existing retro upload flow in `lib/crates/fabro-retro/src/retro_agent.rs` **Test scenarios:** @@ -351,4 +351,4 @@ Durable event store - `lib/crates/fabro-checkpoint/src/metadata.rs` - `lib/crates/fabro-workflow/src/operations/{fork.rs,rewind.rs,rebuild_meta.rs}` - `lib/crates/fabro-cli/src/commands/{store/dump.rs,store/run_export.rs,run/rewind.rs}` -- Related guidance: `files-internal/testing-strategy.md` +- Related guidance: `docs-internal/testing-strategy.md` diff --git a/docs/plans/2026-04-23-001-refactor-command-context-alignment-plan.md b/docs/plans/2026-04-23-001-refactor-command-context-alignment-plan.md index 348d5cfd6..6202200eb 100644 --- a/docs/plans/2026-04-23-001-refactor-command-context-alignment-plan.md +++ b/docs/plans/2026-04-23-001-refactor-command-context-alignment-plan.md @@ -132,7 +132,7 @@ behavior and avoids turning `CommandContext` into a new god object. `lib/crates/fabro-cli/src/commands/provider/mod.rs` — the clearest examples of raw `process_local_json` still being threaded despite the rest of the state already belonging to the invocation. -- `files-internal/testing-strategy.md` — CLI integration tests should +- `docs-internal/testing-strategy.md` — CLI integration tests should stay command-driven and black-box, with implementation-facing behavior covered by unit tests near the code. - `lib/crates/fabro-cli/src/commands/sandbox/mod.rs` — the real public @@ -828,7 +828,7 @@ command tree is consistently aligned on `CommandContext`. `lib/crates/fabro-cli/src/commands/auth/mod.rs` `lib/crates/fabro-cli/src/commands/provider/mod.rs` `lib/crates/fabro-cli/src/commands/system/mod.rs` -- Testing guidance: `files-internal/testing-strategy.md` +- Testing guidance: `docs-internal/testing-strategy.md` - Related history: `93b6577cd simplify: drop duplicate settings plumbing from cli/server refactor` `367fd9302 refactor(cli): centralize command settings and server access` diff --git a/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md b/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md index 443f268a2..8f53a72fe 100644 --- a/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md +++ b/docs/plans/2026-04-24-001-refactor-adopt-uv-patterns-plan.md @@ -987,7 +987,7 @@ impl fabro_options_metadata::OptionsMetadata for RunArgs { - **fabro strategy docs:** - `docs-internal/logging-strategy.md` (Phase 2 alignment) - `docs-internal/server-secrets-strategy.md` (Phase 1 constraint: no env mutation) - - `files-internal/testing-strategy.md` (Phase 3 guidance) + - `docs-internal/testing-strategy.md` (Phase 3 guidance) - **AGENTS.md:** `/Users/bhelmkamp/p/fabro-sh/fabro-3/AGENTS.md` (nightly clippy, strum, insta workflow, refresh-spa mandate). - **External docs:** - `miette`: https://docs.rs/miette/ diff --git a/docs/superpowers/specs/2026-04-18-web-install-design.md b/docs/superpowers/specs/2026-04-18-web-install-design.md index 05b4b7466..a3e4ea54c 100644 --- a/docs/superpowers/specs/2026-04-18-web-install-design.md +++ b/docs/superpowers/specs/2026-04-18-web-install-design.md @@ -346,7 +346,7 @@ Install-mode failures report to Sentry via `fabro-telemetry` with the existing a ## Testing strategy -Per `files-internal/testing-strategy.md` (re-read before implementing). +Per `docs-internal/testing-strategy.md` (re-read before implementing). ### Unit tests (`fabro-install` crate) From a1089b49fbb1172ffd58a8b4c2136ed764919d00 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 25 Apr 2026 12:38:56 -0400 Subject: [PATCH 04/96] chore: remove old plan --- ...ady-p1-resolve-std-fs-follow-up-markers.md | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 .context/compound-engineering/todos/001-ready-p1-resolve-std-fs-follow-up-markers.md diff --git a/.context/compound-engineering/todos/001-ready-p1-resolve-std-fs-follow-up-markers.md b/.context/compound-engineering/todos/001-ready-p1-resolve-std-fs-follow-up-markers.md deleted file mode 100644 index e70782b65..000000000 --- a/.context/compound-engineering/todos/001-ready-p1-resolve-std-fs-follow-up-markers.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -status: ready -priority: p1 -issue_id: "001" -tags: [rust, clippy, async-io, std-fs] -dependencies: [] ---- - -## Problem Statement - -Several Rust crates still contain `FOLLOW-UP:` markers related to blocking `std::fs` or sync I/O on async paths. The requested work is to execute the implementation plan in `~/.claude/plans/we-ll-feal-with-std-fs-jaunty-feigenbaum.md` and finish the refactors or tighten the remaining sync justifications. - -## Findings - -- The repo is currently on `main`, and the user explicitly approved proceeding there. -- `docs/solutions/` is not present, so there are no repo learnings to consult for this task. -- The current code matches the plan buckets across `fabro-agent`, `fabro-devcontainer`, `fabro-llm`, and `fabro-workflow`. - -## Proposed Solutions - -- Execute the plan in bucket order, using targeted failing checks before each production change where feasible. -- Prefer async propagation for truly async paths and `spawn_blocking` only at natural async boundaries. -- Remove or narrow `#[expect(clippy::disallowed_methods)]` annotations once the production sites are fixed. - -## Recommended Action - -Implement the plan directly, verify each bucket with crate-level tests or lint checks, then run the final formatting, clippy, workspace tests, and `FOLLOW-UP` sweep. - -## Acceptance Criteria - -- All `FOLLOW-UP:` markers under `lib/crates/` are removed. -- The planned async refactors and `spawn_blocking` boundary changes are implemented. -- Formatting and workspace clippy pass. -- Relevant crate tests pass during incremental verification. - -## Work Log - -- 2026-04-19: Created execution todo, confirmed branch choice with the user, and started inspecting the planned call sites. - From d2cc37c615894d56ef672d00004ce13ce3b328c2 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 25 Apr 2026 13:17:03 -0400 Subject: [PATCH 05/96] docs(changelog): refresh recent product changes --- .claude/skills/changelog/watermark | 2 +- docs/changelog/2026-04-24.mdx | 31 ++++++++++++++++++++++++++---- docs/changelog/2026-04-25.mdx | 28 +++++++++++++++++++++++++++ docs/docs.json | 1 + 4 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 docs/changelog/2026-04-25.mdx diff --git a/.claude/skills/changelog/watermark b/.claude/skills/changelog/watermark index 10eee6c9e..22cb0fa47 100644 --- a/.claude/skills/changelog/watermark +++ b/.claude/skills/changelog/watermark @@ -1 +1 @@ -6d97de0d9948f26d544e7a2bc35a99f53c55cbf8 +cb0c39ee915896c5a3e8873180092a8bc95bbf36 diff --git a/docs/changelog/2026-04-24.mdx b/docs/changelog/2026-04-24.mdx index e3017940e..0e8371481 100644 --- a/docs/changelog/2026-04-24.mdx +++ b/docs/changelog/2026-04-24.mdx @@ -1,5 +1,5 @@ --- -title: "Server-backed rewind and fork" +title: "Server-backed rewind, setup polish, and diff stats" date: "2026-04-24" --- @@ -28,27 +28,50 @@ The source run records which run superseded it, so archived run history keeps a `fabro fork`, `fabro rewind`, and their `--list` modes now use the server API. The server must be able to read the run's recorded working directory. Timeline listing no longer rebuilds a missing metadata branch; if the metadata branch is gone, the timeline is empty until metadata is restored. +## Smoother setup and local auth + +Self-hosted setup is easier to complete from the browser and the CLI. The install wizard now works from the server root, exposes the local object store root, treats the AWS access key ID as readable text, and the server start banner prints the install token on its own copyable line. + +Dev-token credentials are now stored in the same auth store as OAuth credentials, so CLI targets resolve credentials consistently whether the server is reached through TCP or a Unix socket. + +```bash +fabro auth login --dev-token +``` + ## More - New `POST /api/v1/runs/{id}/rewind` endpoint creates the replacement run and archives the source run - New `POST /api/v1/runs/{id}/fork` endpoint forks a run from the server-side run record - New `GET /api/v1/runs/{id}/timeline` endpoint returns checkpoint timeline entries from run metadata +- `GET /api/v1/runs/{id}/files` responses now include aggregate additions and deletions in `meta.stats` +- Install prefill and object-store responses now include local object store root details - Run summaries now include `superseded_by` data for rewound source runs - Removed the obsolete `fabro pr list` command - `fabro fork`, `fabro rewind`, and timeline listing now call the server API +- Fatal CLI errors now use styled diagnostics while preserving exit codes, telemetry, and auth help hints +- Server start output now prints the install token on a separate copyable line +- Added `fabro auth login --dev-token` for saving dev-token credentials in the auth store + + + +- Disabled git signing for sandbox checkpoint commits so local signing configuration does not block workflow bookkeeping +- Run Files now shows aggregate `+/-` diff stats beside the file count +- Run Files now uses a PR-style header with captured time, refresh, and Split/Stacked controls in one row +- Credentials embedded in URLs are redacted in logs and error output +- CLI and user-configuration reference docs are generated and checked for drift - Updated the quick start with supported platform details -- Fixed Mintlify MDX parsing in planning docs -- Simplified server-side pull request creation plumbing around run and GitHub context loading +- Fixed the install wizard not mounting at the root route +- Fixed the local object store root missing from install wizard configuration +- Changed the AWS access key ID field to a readable text input - Changed the install wizard token field to a single-line input -- Refreshed the embedded web assets for the rewind and install UI changes diff --git a/docs/changelog/2026-04-25.mdx b/docs/changelog/2026-04-25.mdx new file mode 100644 index 000000000..53493d09f --- /dev/null +++ b/docs/changelog/2026-04-25.mdx @@ -0,0 +1,28 @@ +--- +title: "Files Changed sidebar and settings panels" +date: "2026-04-25" +--- + +## Files Changed sidebar + +Large diffs are easier to navigate from the run detail page. The Files Changed tab now has a GitHub-style file tree sidebar that lists only changed files, shows each file's git status, supports search, and keeps selection tied to the existing `#file=` deep-link behavior. + +Clicking a file in the tree scrolls and focuses the matching diff, while mobile layouts keep the stacked diff view uncluttered. The tree lazy-loads on desktop and reserves its column while loading, so the diff layout no longer jumps as sidebar data arrives. + +## Settings panels + +The Settings page now presents server configuration as curated panels instead of a raw JSON dump. Server, access, integration, and artifact values are rendered with typed controls and readable summaries, so operators can scan what matters without parsing the full settings payload. + +## More + + +- Added the captured commit short SHA to the Files Changed freshness label +- Unboxed the Files Changed tree so it sits directly in the sidebar layout +- Styled web OAuth callback state-validation errors with the same browser shell as the CLI auth flow + + + +- Fixed aggregate diff stats counting sensitive, binary, symlink, and submodule entries that are not visible in the diff +- Fixed Files Changed tree selection so it only targets valid file paths after filtering or reloads +- Fixed sidebar loading and filtering behavior to keep the desktop layout stable + diff --git a/docs/docs.json b/docs/docs.json index f4d9ba011..67e18331a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -252,6 +252,7 @@ "group": "April 2026", "icon": "clock-rotate-left", "pages": [ + "changelog/2026-04-25", "changelog/2026-04-24", "changelog/2026-04-23", "changelog/2026-04-22", From e54597ec91147f9ae2ec8f1f11dde276964fed39 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 25 Apr 2026 18:57:32 -0400 Subject: [PATCH 06/96] docs: sync admin and checkpoint pages with recent changes Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/docs/watermark | 2 +- docs/administration/deploy-server.mdx | 12 +++++++++++- docs/execution/checkpoints.mdx | 2 ++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.claude/skills/docs/watermark b/.claude/skills/docs/watermark index 108d4b049..5d44dfced 100644 --- a/.claude/skills/docs/watermark +++ b/.claude/skills/docs/watermark @@ -1 +1 @@ -533785cd4c107cee673825847b1f8fe3d8d14dfe +d2cc37c615894d56ef672d00004ce13ce3b328c2 diff --git a/docs/administration/deploy-server.mdx b/docs/administration/deploy-server.mdx index 2b0db49eb..684ec2832 100644 --- a/docs/administration/deploy-server.mdx +++ b/docs/administration/deploy-server.mdx @@ -34,7 +34,9 @@ This starts the server on a Unix socket at `~/.fabro/fabro.sock` by default. Use ### First run: web install wizard -If `~/.fabro/settings.toml` does not yet exist, `fabro server start` enters **install mode**: it prints an install URL, attempts to open the URL in your default browser, and serves a web wizard that walks you through configuring your server URL, shared object store, LLM provider, and GitHub integration. +If `~/.fabro/settings.toml` does not yet exist, `fabro server start` enters **install mode**: it prints an install URL and a one-time install token, attempts to open the URL in your default browser, and serves a web wizard that walks you through configuring your server URL, shared object store, LLM provider, and GitHub integration. + +When Fabro can construct a direct install URL, the token is embedded in the URL and also printed on its own line for copying. If you open the server root through a reverse proxy or another machine, paste the printed install token when prompted. The `Object store` step offers two wizard-managed modes: @@ -87,6 +89,8 @@ The web UI connects to the API server and provides: - **Runs board** — Monitor all active runs organized by status - **Run detail** — Real-time stage progress, event stream, diffs, and usage stats +- **Files Changed** — Browse changed files with a searchable tree, per-file status, aggregate diff stats, and split or stacked diffs +- **Settings** — Inspect server configuration, enabled integrations, storage, auth, and capacity settings - **Start new run** — Submit workflows from the browser - **Human-in-the-loop** — Answer agent questions through the web interface - **Workflows** — Browse available workflows, view their graphs, and see run history @@ -145,6 +149,12 @@ Or use the `--server` flag: fabro model list --server https://fabro.example.com/api/v1 ``` +For dev-token servers, save the token in the CLI auth store instead of exporting it for every command: + +```bash +fabro auth login --server https://fabro.example.com/api/v1 --dev-token fabro_dev_... +``` + `fabro model list` and `fabro model test` honor `[cli.target]` by default unless you explicitly pass `--storage-dir`. `fabro exec` remains a local agent session and only uses the server when you pass `--server`. See [User Configuration](/reference/user-configuration#cli-target-section) for the full connection options, including client certificates for proxy-terminated HTTPS endpoints. diff --git a/docs/execution/checkpoints.mdx b/docs/execution/checkpoints.mdx index 3eddce7c8..b8c2fe891 100644 --- a/docs/execution/checkpoints.mdx +++ b/docs/execution/checkpoints.mdx @@ -39,6 +39,8 @@ The commit message follows a structured format: The `Fabro-Checkpoint` trailer links each run branch commit to its metadata branch commit, so you can navigate from file changes to the full execution state and back. +Fabro disables Git commit and tag signing for checkpoint commits created inside a sandbox. Your personal or repository-level signing settings can stay enabled, but sandbox bookkeeping does not need access to your signing key. + ### Metadata branch The metadata branch (`fabro/meta/{run_id}`) is an orphan branch that stores structured run data using Git's object storage directly (via `git2`). It is initialized at run start with: From c28b040c6f899816d1a6297c86ef5233b194dd61 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 25 Apr 2026 18:58:00 -0400 Subject: [PATCH 07/96] fix(install): reject wildcard public URLs Normalize bind-address wildcards before presenting install URLs, reject wildcard public origins at CLI and server install boundaries, and surface recovery guidance in the installer and doctor output. --- Cargo.lock | 1 + apps/fabro-web/app/install-app.test.tsx | 71 ++++++++ apps/fabro-web/app/install-app.tsx | 12 +- lib/crates/fabro-cli/src/commands/doctor.rs | 164 +++++++++++++++++- lib/crates/fabro-cli/src/commands/install.rs | 16 +- .../fabro-cli/src/commands/server/mod.rs | 47 ++++- .../fabro-cli/src/commands/server/start.rs | 3 +- lib/crates/fabro-cli/tests/it/cmd/install.rs | 23 +++ .../fabro-server/src/canonical_origin.rs | 10 +- lib/crates/fabro-server/src/install.rs | 57 +++--- lib/crates/fabro-server/src/server.rs | 7 +- .../fabro-server/tests/it/api/install.rs | 59 +++++++ .../{entry-zcpgp9fa.js => entry-xf46xn8z.js} | 146 ++++++++-------- lib/crates/fabro-spa/assets/index.html | 2 +- lib/crates/fabro-types/Cargo.toml | 1 + lib/crates/fabro-types/src/settings/mod.rs | 4 + .../fabro-types/src/settings/public_url.rs | 94 ++++++++++ 17 files changed, 586 insertions(+), 131 deletions(-) rename lib/crates/fabro-spa/assets/assets/{entry-zcpgp9fa.js => entry-xf46xn8z.js} (90%) create mode 100644 lib/crates/fabro-types/src/settings/public_url.rs diff --git a/Cargo.lock b/Cargo.lock index 4d9e11cc8..72c622440 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2310,6 +2310,7 @@ dependencies = [ "tempfile", "toml 0.8.23", "ulid", + "url", ] [[package]] diff --git a/apps/fabro-web/app/install-app.test.tsx b/apps/fabro-web/app/install-app.test.tsx index bff6a80c2..6d1d7d3c4 100644 --- a/apps/fabro-web/app/install-app.test.tsx +++ b/apps/fabro-web/app/install-app.test.tsx @@ -510,4 +510,75 @@ describe("InstallApp", () => { console.error = originalConsoleError; } }); + + test("shows the GitHub App callback URL on the review step", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const originalConsoleError = console.error; + console.error = ((...args: unknown[]) => { + if ( + typeof args[0] === "string" && + args[0].startsWith("react-test-renderer is deprecated") + ) { + return; + } + originalConsoleError(...args); + }) as typeof console.error; + try { + const fetchMock = mock((input: RequestInfo | URL) => { + expect(String(input)).toBe("/install/session"); + return Promise.resolve( + new Response( + JSON.stringify({ + completed_steps: ["server", "object_store", "llm", "github"], + llm: { + providers: [{ provider: "anthropic" }], + }, + server: { canonical_url: "https://fabro.example.com" }, + object_store: { provider: "local" }, + github: { + strategy: "app", + owner: { kind: "personal" }, + app_name: "octocat-fabro", + slug: "octocat-fabro", + allowed_username: "octocat", + }, + prefill: INSTALL_PREFILL, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + }); + globalThis.fetch = fetchMock as typeof fetch; + + const testWindow = createTestWindow("https://fabro.example.com/install/review"); + testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token"); + (globalThis as { window?: unknown }).window = testWindow; + + let renderer: TestRenderer.ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create( + + + } /> + + , + ); + }); + + await waitFor(() => { + const text = renderTreeText(renderer!.toJSON()); + expect(text).toContain("GitHub callback URL"); + expect(text).toContain("https://fabro.example.com/auth/callback/github"); + }); + + await act(async () => { + renderer?.unmount(); + }); + } finally { + console.error = originalConsoleError; + } + }); }); diff --git a/apps/fabro-web/app/install-app.tsx b/apps/fabro-web/app/install-app.tsx index c04b5988e..4053de577 100644 --- a/apps/fabro-web/app/install-app.tsx +++ b/apps/fabro-web/app/install-app.tsx @@ -1166,7 +1166,7 @@ function ReviewScreen({ /> {renderObjectStoreSummaryRows(session?.object_store)} - {renderGithubSummaryRows(session?.github)} + {renderGithubSummaryRows(session?.github, serverUrl)} {error ? : null}
@@ -1727,6 +1727,7 @@ function describeProvider(id: string): string { function renderGithubSummaryRows( github: InstallSessionResponse["github"], + serverUrl: string, ): ReactNode { if (!github) { return ; @@ -1741,6 +1742,11 @@ function renderGithubSummaryRows( value={github.allowed_username ? `@${github.allowed_username}` : "Not set"} mono={Boolean(github.allowed_username)} /> + ); } @@ -1756,6 +1762,10 @@ function renderGithubSummaryRows( ); } +function githubCallbackUrl(serverUrl: string): string { + return `${serverUrl.replace(/\/+$/, "")}/auth/callback/github`; +} + function renderObjectStoreSummaryRows( objectStore: InstallSessionResponse["object_store"], ): ReactNode { diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs index 2ba2b813d..a9da8e902 100644 --- a/lib/crates/fabro-cli/src/commands/doctor.rs +++ b/lib/crates/fabro-cli/src/commands/doctor.rs @@ -1,8 +1,9 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::Result; use fabro_api::types as api_types; use fabro_config::user::active_settings_path; +use fabro_types::settings::replace_wildcard_host; pub(crate) use fabro_util::check_report::{ CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, }; @@ -19,15 +20,30 @@ pub(crate) fn check_config(settings_path: Option) -> CheckResult { match settings_path { Some(path) => { let display = contract_tilde(&path); + let wildcard_urls = wildcard_public_url_details(&path); + let status = if wildcard_urls.is_empty() { + CheckStatus::Pass + } else { + CheckStatus::Warning + }; + let summary = if wildcard_urls.is_empty() { + display.display().to_string() + } else { + "wildcard public URL configured".to_string() + }; + let mut details = vec![CheckDetail::new(format!( + "Loaded from {}", + display.display() + ))]; + details.extend(wildcard_urls); CheckResult { name: "Configuration".to_string(), - status: CheckStatus::Pass, - summary: display.display().to_string(), - details: vec![CheckDetail::new(format!( - "Loaded from {}", - display.display() - ))], - remediation: None, + status, + summary, + details, + remediation: (status == CheckStatus::Warning).then(|| { + "Replace wildcard public URLs with loopback or proxy URLs, then update the GitHub App callback URL.".to_string() + }), } } None => CheckResult { @@ -42,6 +58,93 @@ pub(crate) fn check_config(settings_path: Option) -> CheckResult { } } +struct WildcardPublicUrl { + field: &'static str, + value: String, + suggestion: String, +} + +#[expect( + clippy::disallowed_methods, + reason = "Doctor synchronously reads one small local settings file while assembling a CLI report." +)] +fn wildcard_public_url_details(path: &Path) -> Vec { + let Ok(contents) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + let Ok(doc) = contents.parse::() else { + return Vec::new(); + }; + + let mut bad_urls = Vec::new(); + for (field, value) in [ + ( + "server.web.url", + toml_string_at(&doc, &["server", "web", "url"]), + ), + ( + "server.api.url", + toml_string_at(&doc, &["server", "api", "url"]), + ), + ( + "cli.target.url", + toml_string_at(&doc, &["cli", "target", "url"]), + ), + ] { + let Some(value) = value else { + continue; + }; + let Some(suggestion) = replace_wildcard_host(value, "127.0.0.1") else { + continue; + }; + bad_urls.push(WildcardPublicUrl { + field, + value: value.to_string(), + suggestion, + }); + } + + if bad_urls.is_empty() { + return Vec::new(); + } + + let mut details = bad_urls + .iter() + .map(|entry| CheckDetail { + text: format!( + "{} uses wildcard host {}; set it to {}", + entry.field, entry.value, entry.suggestion + ), + warn: true, + }) + .collect::>(); + + let callback_base = bad_urls + .iter() + .find(|entry| entry.field == "server.web.url") + .or_else(|| bad_urls.first()) + .map_or("http://127.0.0.1:32276", |entry| entry.suggestion.as_str()); + let github_settings_url = toml_string_at(&doc, &["server", "integrations", "github", "slug"]) + .map_or_else( + || "the GitHub App settings page".to_string(), + |slug| format!("https://github.com/settings/apps/{slug}"), + ); + details.push(CheckDetail { + text: format!( + "Update the GitHub App Callback URL at {github_settings_url} -> General -> Callback URL to {callback_base}/auth/callback/github" + ), + warn: true, + }); + + details +} + +fn toml_string_at<'a>(doc: &'a toml::Value, path: &[&str]) -> Option<&'a str> { + path.iter() + .try_fold(doc, |value, key| value.get(*key)) + .and_then(toml::Value::as_str) +} + fn check_version_parity(server_version: &str) -> CheckResult { let cli_version = FABRO_VERSION; if server_version == cli_version { @@ -333,6 +436,51 @@ mod tests { assert!(result.remediation.is_some()); } + #[test] + #[expect( + clippy::disallowed_methods, + reason = "unit test stages a temporary settings.toml fixture with sync std::fs" + )] + fn check_config_warns_about_wildcard_public_urls() { + let dir = tempfile::tempdir().unwrap(); + let settings_path = dir.path().join("settings.toml"); + std::fs::write( + &settings_path, + r#" +_version = 1 + +[server.web] +url = "http://0.0.0.0:32276" + +[server.api] +url = "http://0.0.0.0:32276" + +[server.integrations.github] +slug = "octocat-fabro" + +[cli.target] +type = "http" +url = "http://0.0.0.0:32276" +"#, + ) + .unwrap(); + + let result = check_config(Some(settings_path)); + assert_eq!(result.status, CheckStatus::Warning); + let details = result + .details + .iter() + .map(|detail| detail.text.as_str()) + .collect::>() + .join("\n"); + + assert!(details.contains("server.web.url")); + assert!(details.contains("server.api.url")); + assert!(details.contains("cli.target.url")); + assert!(details.contains("http://127.0.0.1:32276/auth/callback/github")); + assert!(details.contains("https://github.com/settings/apps/octocat-fabro")); + } + #[test] fn check_version_parity_warns_on_mismatch() { let result = check_version_parity("0.0.0-test"); diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 8e2d990a6..a5bba5c24 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -35,6 +35,7 @@ use fabro_server::serve; use fabro_store::ArtifactStore; use fabro_types::ServerSettings; use fabro_types::settings::server::ServerAuthMethod; +use fabro_types::settings::validate_public_url_with_label; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_util::version::FABRO_VERSION; @@ -122,6 +123,10 @@ fn merge_server_settings(doc: &mut toml::Value, web_url: &str) -> Result<()> { ) } +fn validate_web_url_arg(value: &str) -> Result { + validate_public_url_with_label(value, "--web-url").map_err(anyhow::Error::msg) +} + #[cfg(test)] fn format_config_toml() -> String { let mut doc = toml::Value::Table(toml::Table::default()); @@ -1453,6 +1458,7 @@ async fn run_install_github_inner( printer: Printer, ) -> Result<()> { let s = Styles::detect_stderr(); + let web_url = validate_web_url_arg(&args.web_url)?; let fabro_dir = fabro_util::Home::from_env().root().to_path_buf(); let config_path = fabro_dir.join(SETTINGS_CONFIG_FILENAME); if !config_path.exists() { @@ -1498,7 +1504,7 @@ async fn run_install_github_inner( ]); let registration = setup_github_app( &s, - &args.web_url, + &web_url, &owner, username.as_deref(), if args.non_interactive { @@ -1601,7 +1607,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( let _cli = &ctx.user_settings().cli; let printer = ctx.printer(); let json = ctx.json_output(); - let web_url = &args.web_url; + let web_url = validate_web_url_arg(&args.web_url)?; let s = Styles::detect_stderr(); let emoji = console::Emoji("⚒️ ", ""); let local_config = @@ -1686,7 +1692,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( )?; let registration = setup_github_app( &s, - web_url, + &web_url, &owner, username.as_deref(), if args.non_interactive { @@ -1740,7 +1746,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( ); } ServerConfigSelection::Write => { - merge_server_settings(&mut doc, web_url)?; + merge_server_settings(&mut doc, &web_url)?; } } @@ -1821,7 +1827,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( ) .await?; if let Some(token) = dev_token_for_auth_store { - let target = ServerTarget::http_url(&args.web_url)?; + let target = ServerTarget::http_url(&web_url)?; if let Err(err) = AuthStore::default().put( &target, AuthEntry::DevToken(DevTokenEntry { diff --git a/lib/crates/fabro-cli/src/commands/server/mod.rs b/lib/crates/fabro-cli/src/commands/server/mod.rs index 687010146..d844bcd0a 100644 --- a/lib/crates/fabro-cli/src/commands/server/mod.rs +++ b/lib/crates/fabro-cli/src/commands/server/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod start; pub(crate) mod status; pub(crate) mod stop; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; use std::time::Duration; @@ -303,10 +304,24 @@ fn install_url_hint(bind: &Bind, token: &str) -> Option { return Some(format!("https://{domain}/install?token={token}")); } - match bind { - Bind::Tcp(addr) => Some(format!("http://{addr}/install?token={token}")), - Bind::Unix(_) => None, - } + bind_to_browser_url(bind).map(|url| format!("{url}/install?token={token}")) +} + +pub(super) fn bind_to_browser_url(bind: &Bind) -> Option { + let Bind::Tcp(addr) = bind else { + return None; + }; + + let browser_addr = match addr.ip() { + IpAddr::V4(ip) if ip.is_unspecified() => { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), addr.port()) + } + IpAddr::V6(ip) if ip.is_unspecified() => { + SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), addr.port()) + } + _ => *addr, + }; + Some(format!("http://{browser_addr}")) } fn default_install_bind_request() -> BindRequest { @@ -345,7 +360,9 @@ fn generate_install_token() -> Result { #[cfg(test)] mod tests { - use super::install_mode_next_step_message; + use fabro_config::bind::Bind; + + use super::{bind_to_browser_url, install_mode_next_step_message}; #[test] fn install_mode_next_step_message_recommends_manual_restart_locally() { @@ -362,4 +379,24 @@ mod tests { " After install, the server should restart automatically." ); } + + #[test] + fn bind_to_browser_url_uses_loopback_for_ipv4_wildcard_bind() { + let bind = Bind::Tcp("0.0.0.0:32276".parse().unwrap()); + + assert_eq!( + bind_to_browser_url(&bind).as_deref(), + Some("http://127.0.0.1:32276") + ); + } + + #[test] + fn bind_to_browser_url_uses_loopback_for_ipv6_wildcard_bind() { + let bind = Bind::Tcp("[::]:32276".parse().unwrap()); + + assert_eq!( + bind_to_browser_url(&bind).as_deref(), + Some("http://[::1]:32276") + ); + } } diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/crates/fabro-cli/src/commands/server/start.rs index 47d5cdff9..85cf31c2e 100644 --- a/lib/crates/fabro-cli/src/commands/server/start.rs +++ b/lib/crates/fabro-cli/src/commands/server/start.rs @@ -363,8 +363,7 @@ async fn execute_daemon( pid, daemon.bind ); - if let Bind::Tcp(addr) = &daemon.bind { - let url = format!("http://{addr}"); + if let Some(url) = super::bind_to_browser_url(&daemon.bind) { let styled = match styles { Some(s) => format!("{}", s.cyan.apply_to(&url)), None => url, diff --git a/lib/crates/fabro-cli/tests/it/cmd/install.rs b/lib/crates/fabro-cli/tests/it/cmd/install.rs index e61be1632..a6f473e86 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/install.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/install.rs @@ -143,6 +143,29 @@ fn non_interactive_without_inputs_prints_scripted_usage_and_fails() { assert!(stderr.contains("--github-strategy")); } +#[test] +fn install_rejects_wildcard_web_url_before_collecting_inputs() { + let context = test_context!(); + let output = context + .command() + .args([ + "install", + "--web-url", + "http://0.0.0.0:32276", + "--non-interactive", + ]) + .output() + .expect("command should run"); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("--web-url must not use a wildcard host")); + assert!( + !stderr.contains("Non-interactive install requires additional flags"), + "wildcard web URL should be rejected before scripted input validation: {stderr}" + ); +} + #[test] fn hidden_non_interactive_args_require_non_interactive() { let context = test_context!(); diff --git a/lib/crates/fabro-server/src/canonical_origin.rs b/lib/crates/fabro-server/src/canonical_origin.rs index 41e32d854..2a9ab1170 100644 --- a/lib/crates/fabro-server/src/canonical_origin.rs +++ b/lib/crates/fabro-server/src/canonical_origin.rs @@ -3,8 +3,7 @@ reason = "Canonical origin validation handles the public server origin; it is not credential-bearing log output." )] -use fabro_types::settings::ServerNamespace; -use url::Url; +use fabro_types::settings::{ServerNamespace, validate_public_url}; use crate::server::EnvLookup; @@ -19,12 +18,7 @@ pub(crate) fn resolve_canonical_origin( .map_err(|_| canonical_origin_error(&resolved.web.url.as_source()))? .value; - let parsed = Url::parse(&value).map_err(|_| canonical_origin_error(&value))?; - if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { - return Err(canonical_origin_error(&value)); - } - - Ok(value) + validate_public_url(&value).map_err(|_| canonical_origin_error(&value)) } fn canonical_origin_error(value: &str) -> String { diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs index bab069159..cc939567f 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/fabro-server/src/install.rs @@ -27,6 +27,7 @@ use fabro_store::ArtifactStore; use fabro_types::ServerSettings; use fabro_types::settings::interp::InterpString; use fabro_types::settings::server::ObjectStoreSettings; +use fabro_types::settings::{is_wildcard_host, validate_public_url_with_label}; use fabro_util::version::FABRO_VERSION; use fabro_util::{Home, dev_token, session_secret}; use fabro_vault::SecretType as VaultSecretType; @@ -1620,7 +1621,34 @@ fn detect_canonical_url(headers: &HeaderMap) -> String { .filter(|value| !value.is_empty()) .unwrap_or("127.0.0.1:32276"); - format!("{scheme}://{host}") + format!("{scheme}://{}", sanitize_client_facing_host(host)) +} + +fn sanitize_client_facing_host(host: &str) -> String { + let host = host.trim(); + if let Some(end) = host + .strip_prefix('[') + .and_then(|rest| rest.find(']').map(|end| end + 1)) + { + let address = &host[1..end]; + let suffix = &host[end + 1..]; + if is_wildcard_host(address) { + return format!("localhost{suffix}"); + } + return host.to_string(); + } + + if let Some((address, port)) = host.rsplit_once(':') { + if !address.contains(':') && is_wildcard_host(address) { + return format!("localhost:{port}"); + } + } + + if is_wildcard_host(host) { + return "localhost".to_string(); + } + + host.to_string() } fn completed_steps(pending_install: &PendingInstall) -> Vec<&'static str> { @@ -1692,33 +1720,8 @@ fn install_error_response(status: StatusCode, message: impl Into) -> Res ApiError::new(status, message).into_response() } -#[expect( - clippy::disallowed_types, - reason = "Install canonical_url validation parses a public origin and rejects query/fragment credentials before storage." -)] fn validate_canonical_url(value: &str) -> Result<(), String> { - let trimmed = value.trim(); - let parsed = fabro_http::Url::parse(trimmed).map_err(|err| err.to_string())?; - match parsed.scheme() { - "http" | "https" => {} - other => return Err(format!("canonical_url must use http or https, got {other}")), - } - if parsed.host_str().is_none() { - return Err("canonical_url must include a host".to_string()); - } - if trimmed.ends_with('/') { - return Err("canonical_url must not end with a trailing slash".to_string()); - } - if parsed.path() != "/" { - return Err("canonical_url must not include a path".to_string()); - } - if parsed.query().is_some() { - return Err("canonical_url must not include a query string".to_string()); - } - if parsed.fragment().is_some() { - return Err("canonical_url must not include a fragment".to_string()); - } - Ok(()) + validate_public_url_with_label(value, "canonical_url").map(|_| ()) } fn generate_ephemeral_secret() -> String { diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 42b72a700..08df66210 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -8244,7 +8244,12 @@ url = "{url}" #[test] fn replace_settings_rejects_invalid_canonical_origin_and_keeps_previous_settings() { - for invalid in ["", "/relative/path", "ftp://fabro.example.com"] { + for invalid in [ + "", + "/relative/path", + "ftp://fabro.example.com", + "http://0.0.0.0:32276", + ] { let state = create_app_state_with_env_lookup( canonical_origin_settings("http://valid.example.com"), RunLayer::default(), diff --git a/lib/crates/fabro-server/tests/it/api/install.rs b/lib/crates/fabro-server/tests/it/api/install.rs index f4c0ec8a1..e372594b7 100644 --- a/lib/crates/fabro-server/tests/it/api/install.rs +++ b/lib/crates/fabro-server/tests/it/api/install.rs @@ -262,6 +262,27 @@ async fn install_session_requires_valid_install_token() { ); } +#[tokio::test] +async fn install_session_sanitizes_wildcard_host_prefill() { + let app = build_install_router(InstallAppState::for_test("test-install-token")).await; + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/install/session") + .header("authorization", "Bearer test-install-token") + .header("host", "0.0.0.0:32276") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = response_json(response, StatusCode::OK, "GET /install/session").await; + assert_eq!(body["prefill"]["canonical_url"], "http://localhost:32276"); +} + #[tokio::test] async fn install_endpoints_reject_missing_and_wrong_tokens() { let app = build_install_router(InstallAppState::for_test("test-install-token")).await; @@ -1719,6 +1740,44 @@ async fn install_server_rejects_trailing_slash_canonical_urls() { ); } +#[tokio::test] +async fn install_server_rejects_wildcard_canonical_urls() { + let app = build_install_router(InstallAppState::for_test("test-install-token")).await; + + for canonical_url in [ + "http://0.0.0.0:32276", + "http://[::]:32276", + "http://0:32276", + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/install/server") + .header("authorization", "Bearer test-install-token") + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"canonical_url":"{canonical_url}"}}"# + ))) + .unwrap(), + ) + .await + .unwrap(); + + let body = response_json( + response, + StatusCode::UNPROCESSABLE_ENTITY, + "PUT /install/server", + ) + .await; + assert_eq!( + body["errors"][0]["detail"], + "canonical_url must not use a wildcard host" + ); + } +} + #[tokio::test] async fn install_finish_failure_restores_settings_and_vault_but_leaves_env_keys() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/lib/crates/fabro-spa/assets/assets/entry-zcpgp9fa.js b/lib/crates/fabro-spa/assets/assets/entry-xf46xn8z.js similarity index 90% rename from lib/crates/fabro-spa/assets/assets/entry-zcpgp9fa.js rename to lib/crates/fabro-spa/assets/assets/entry-xf46xn8z.js index 69d5f29c9..a4b4127e9 100644 --- a/lib/crates/fabro-spa/assets/assets/entry-zcpgp9fa.js +++ b/lib/crates/fabro-spa/assets/assets/entry-xf46xn8z.js @@ -2,7 +2,7 @@ import{P as n,Q as T0,R as $8}from"./chunk-dep0g6mr.js";import{$ as k,aa as FZ,b 1. You might have mismatching versions of React and the renderer (such as React DOM) 2. You might be breaking the Rules of Hooks 3. You might have more than one copy of React in the same app -See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.`),N}typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(Error());var W={d:{f:Z,r:function(){throw Error("Invalid form element. requestFormReset must be passed a form that was rendered by React.")},D:Z,C:Z,L:Z,m:Z,X:Z,S:Z,M:Z},p:0,findDOMNode:null},U=Symbol.for("react.portal"),w=bF.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;typeof Map==="function"&&Map.prototype!=null&&typeof Map.prototype.forEach==="function"&&typeof Set==="function"&&Set.prototype!=null&&typeof Set.prototype.clear==="function"&&typeof Set.prototype.forEach==="function"||console.error("React depends on Map and Set built-in types. Make sure that you load a polyfill in older browsers. https://reactjs.org/link/react-polyfills"),Io.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=W,Io.createPortal=function(N,O){var _=2` tag.%s',_),typeof N==="string"&&typeof O==="object"&&O!==null&&typeof O.as==="string"){_=O.as;var A=q(_,O.crossOrigin);W.d.L(N,_,{crossOrigin:A,integrity:typeof O.integrity==="string"?O.integrity:void 0,nonce:typeof O.nonce==="string"?O.nonce:void 0,type:typeof O.type==="string"?O.type:void 0,fetchPriority:typeof O.fetchPriority==="string"?O.fetchPriority:void 0,referrerPolicy:typeof O.referrerPolicy==="string"?O.referrerPolicy:void 0,imageSrcSet:typeof O.imageSrcSet==="string"?O.imageSrcSet:void 0,imageSizes:typeof O.imageSizes==="string"?O.imageSizes:void 0,media:typeof O.media==="string"?O.media:void 0})}},Io.preloadModule=function(N,O){var _="";typeof N==="string"&&N||(_+=" The `href` argument encountered was "+K(N)+"."),O!==void 0&&typeof O!=="object"?_+=" The `options` argument encountered was "+K(O)+".":O&&("as"in O)&&typeof O.as!=="string"&&(_+=" The `as` option encountered was "+K(O.as)+"."),_&&console.error('ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, an `options` object with an `as` property valid for a `` tag.%s',_),typeof N==="string"&&(O?(_=q(O.as,O.crossOrigin),W.d.m(N,{as:typeof O.as==="string"&&O.as!=="script"?O.as:void 0,crossOrigin:_,integrity:typeof O.integrity==="string"?O.integrity:void 0})):W.d.m(N))},Io.requestFormReset=function(N){W.d.r(N)},Io.unstable_batchedUpdates=function(N,O){return N(O)},Io.useFormState=function(N,O,_){return $().useFormState(N,O,_)},Io.useFormStatus=function(){return $().useHostTransitionStatus()},Io.version="19.2.4",typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var g3=FZ((Pq0,JS)=>{var So=k(ZS());JS.exports=So});var YS=FZ((jo)=>{var p1=k(eI()),wX=k(n()),TF=k(g3());(function(){function Z(Q,X){for(Q=Q.memoizedState;Q!==null&&0=X.length)return G;var M=X[z],H=C2(Q)?Q.slice():P1({},Q);return H[M]=J(Q[M],X,z+1,G),H}function Y(Q,X,z){if(X.length!==z.length)console.warn("copyWithRename() expects paths of the same length");else{for(var G=0;GI8?console.error("Unexpected pop."):(X!==XA[I8]&&console.error("Unexpected Fiber popped."),Q.current=QA[I8],QA[I8]=null,XA[I8]=null,I8--)}function Q0(Q,X,z){I8++,QA[I8]=Q.current,XA[I8]=z,Q.current=X}function W0(Q){return Q===null&&console.error("Expected host context to exist. This error is likely caused by a bug in React. Please file an issue."),Q}function i(Q,X){Q0(a9,X,Q),Q0(Hz,Q,Q),Q0(r9,null,Q);var z=X.nodeType;switch(z){case 9:case 11:z=z===9?"#document":"#fragment",X=(X=X.documentElement)?(X=X.namespaceURI)?ky(X):r8:r8;break;default:if(z=X.tagName,X=X.namespaceURI)X=ky(X),X=fy(X,z);else switch(z){case"svg":X=GX;break;case"math":X=FU;break;default:X=r8}}z=z.toLowerCase(),z=Db(null,z),z={context:X,ancestorInfo:z},Y0(r9,Q),Q0(r9,z,Q)}function e(Q){Y0(r9,Q),Y0(Hz,Q),Y0(a9,Q)}function z0(){return W0(r9.current)}function o(Q){Q.memoizedState!==null&&Q0(vG,Q,Q);var X=W0(r9.current),z=Q.type,G=fy(X.context,z);z=Db(X.ancestorInfo,z),G={context:G,ancestorInfo:z},X!==G&&(Q0(Hz,Q,Q),Q0(r9,G,Q))}function q0(Q){Hz.current===Q&&(Y0(r9,Q),Y0(Hz,Q)),vG.current===Q&&(Y0(vG,Q),zB._currentValue=nJ)}function _0(){}function D0(){if(Oz===0){wD=console.log,MD=console.info,ND=console.warn,HD=console.error,OD=console.group,_D=console.groupCollapsed,AD=console.groupEnd;var Q={configurable:!0,enumerable:!0,value:_0,writable:!0};Object.defineProperties(console,{info:Q,log:Q,warn:Q,error:Q,group:Q,groupCollapsed:Q,groupEnd:Q})}Oz++}function A0(){if(Oz--,Oz===0){var Q={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:P1({},Q,{value:wD}),info:P1({},Q,{value:MD}),warn:P1({},Q,{value:ND}),error:P1({},Q,{value:HD}),group:P1({},Q,{value:OD}),groupCollapsed:P1({},Q,{value:_D}),groupEnd:P1({},Q,{value:AD})})}0>Oz&&console.error("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}function t(Q){var X=Error.prepareStackTrace;if(Error.prepareStackTrace=void 0,Q=Q.stack,Error.prepareStackTrace=X,Q.startsWith(`Error: react-stack-top-frame +See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.`),N}typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(Error());var W={d:{f:Z,r:function(){throw Error("Invalid form element. requestFormReset must be passed a form that was rendered by React.")},D:Z,C:Z,L:Z,m:Z,X:Z,S:Z,M:Z},p:0,findDOMNode:null},U=Symbol.for("react.portal"),w=bF.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;typeof Map==="function"&&Map.prototype!=null&&typeof Map.prototype.forEach==="function"&&typeof Set==="function"&&Set.prototype!=null&&typeof Set.prototype.clear==="function"&&typeof Set.prototype.forEach==="function"||console.error("React depends on Map and Set built-in types. Make sure that you load a polyfill in older browsers. https://reactjs.org/link/react-polyfills"),Io.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=W,Io.createPortal=function(N,O){var _=2` tag.%s',_),typeof N==="string"&&typeof O==="object"&&O!==null&&typeof O.as==="string"){_=O.as;var A=q(_,O.crossOrigin);W.d.L(N,_,{crossOrigin:A,integrity:typeof O.integrity==="string"?O.integrity:void 0,nonce:typeof O.nonce==="string"?O.nonce:void 0,type:typeof O.type==="string"?O.type:void 0,fetchPriority:typeof O.fetchPriority==="string"?O.fetchPriority:void 0,referrerPolicy:typeof O.referrerPolicy==="string"?O.referrerPolicy:void 0,imageSrcSet:typeof O.imageSrcSet==="string"?O.imageSrcSet:void 0,imageSizes:typeof O.imageSizes==="string"?O.imageSizes:void 0,media:typeof O.media==="string"?O.media:void 0})}},Io.preloadModule=function(N,O){var _="";typeof N==="string"&&N||(_+=" The `href` argument encountered was "+K(N)+"."),O!==void 0&&typeof O!=="object"?_+=" The `options` argument encountered was "+K(O)+".":O&&("as"in O)&&typeof O.as!=="string"&&(_+=" The `as` option encountered was "+K(O.as)+"."),_&&console.error('ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, an `options` object with an `as` property valid for a `` tag.%s',_),typeof N==="string"&&(O?(_=q(O.as,O.crossOrigin),W.d.m(N,{as:typeof O.as==="string"&&O.as!=="script"?O.as:void 0,crossOrigin:_,integrity:typeof O.integrity==="string"?O.integrity:void 0})):W.d.m(N))},Io.requestFormReset=function(N){W.d.r(N)},Io.unstable_batchedUpdates=function(N,O){return N(O)},Io.useFormState=function(N,O,_){return $().useFormState(N,O,_)},Io.useFormStatus=function(){return $().useHostTransitionStatus()},Io.version="19.2.4",typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var g3=FZ((Vq0,JS)=>{var So=k(ZS());JS.exports=So});var YS=FZ((jo)=>{var p1=k(eI()),wX=k(n()),TF=k(g3());(function(){function Z(Q,X){for(Q=Q.memoizedState;Q!==null&&0=X.length)return G;var M=X[z],H=y2(Q)?Q.slice():P1({},Q);return H[M]=J(Q[M],X,z+1,G),H}function Y(Q,X,z){if(X.length!==z.length)console.warn("copyWithRename() expects paths of the same length");else{for(var G=0;GI8?console.error("Unexpected pop."):(X!==XA[I8]&&console.error("Unexpected Fiber popped."),Q.current=QA[I8],QA[I8]=null,XA[I8]=null,I8--)}function Q0(Q,X,z){I8++,QA[I8]=Q.current,XA[I8]=z,Q.current=X}function W0(Q){return Q===null&&console.error("Expected host context to exist. This error is likely caused by a bug in React. Please file an issue."),Q}function i(Q,X){Q0(a9,X,Q),Q0(Hz,Q,Q),Q0(r9,null,Q);var z=X.nodeType;switch(z){case 9:case 11:z=z===9?"#document":"#fragment",X=(X=X.documentElement)?(X=X.namespaceURI)?ky(X):r8:r8;break;default:if(z=X.tagName,X=X.namespaceURI)X=ky(X),X=fy(X,z);else switch(z){case"svg":X=GX;break;case"math":X=FU;break;default:X=r8}}z=z.toLowerCase(),z=Db(null,z),z={context:X,ancestorInfo:z},Y0(r9,Q),Q0(r9,z,Q)}function e(Q){Y0(r9,Q),Y0(Hz,Q),Y0(a9,Q)}function z0(){return W0(r9.current)}function o(Q){Q.memoizedState!==null&&Q0(vG,Q,Q);var X=W0(r9.current),z=Q.type,G=fy(X.context,z);z=Db(X.ancestorInfo,z),G={context:G,ancestorInfo:z},X!==G&&(Q0(Hz,Q,Q),Q0(r9,G,Q))}function q0(Q){Hz.current===Q&&(Y0(r9,Q),Y0(Hz,Q)),vG.current===Q&&(Y0(vG,Q),zB._currentValue=nJ)}function _0(){}function D0(){if(Oz===0){wD=console.log,MD=console.info,ND=console.warn,HD=console.error,OD=console.group,_D=console.groupCollapsed,AD=console.groupEnd;var Q={configurable:!0,enumerable:!0,value:_0,writable:!0};Object.defineProperties(console,{info:Q,log:Q,warn:Q,error:Q,group:Q,groupCollapsed:Q,groupEnd:Q})}Oz++}function A0(){if(Oz--,Oz===0){var Q={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:P1({},Q,{value:wD}),info:P1({},Q,{value:MD}),warn:P1({},Q,{value:ND}),error:P1({},Q,{value:HD}),group:P1({},Q,{value:OD}),groupCollapsed:P1({},Q,{value:_D}),groupEnd:P1({},Q,{value:AD})})}0>Oz&&console.error("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}function t(Q){var X=Error.prepareStackTrace;if(Error.prepareStackTrace=void 0,Q=Q.stack,Error.prepareStackTrace=X,Q.startsWith(`Error: react-stack-top-frame `)&&(Q=Q.slice(29)),X=Q.indexOf(` `),X!==-1&&(Q=Q.slice(X+1)),X=Q.indexOf("react_stack_bottom_frame"),X!==-1&&(X=Q.lastIndexOf(` `,X)),X!==-1)Q=Q.slice(0,X);else return"";return Q}function B0(Q){if(qA===void 0)try{throw Error()}catch(z){var X=z.stack.trim().match(/\n( *(at )?)/);qA=X&&X[1]||"",FD=-1"u")return!1;var X=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(X.isDisabled)return!0;if(!X.supportsFiber)return console.error("The installed version of React DevTools is too old and will not work with the current version of React. Please update React DevTools. https://react.dev/link/react-devtools"),!0;try{SQ=X.inject(Q),D7=X}catch(z){console.error("React instrumentation encountered an error: %o.",z)}return X.checkDCE?!0:!1}function h0(Q){if(typeof sa==="function"&&oa(Q),D7&&typeof D7.setStrictMode==="function")try{D7.setStrictMode(SQ,Q)}catch(X){D3||(D3=!0,console.error("React instrumentation encountered an error: %o",X))}}function R2(Q){return Q>>>=0,Q===0?32:31-(ia(Q)/ta|0)|0}function O5(Q){var X=Q&42;if(X!==0)return X;switch(Q&-Q){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return Q&261888;case 262144:case 524288:case 1048576:case 2097152:return Q&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return Q&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return console.error("Should have found matching lanes. This is a bug in React."),Q}}function K5(Q,X,z){var G=Q.pendingLanes;if(G===0)return 0;var M=0,H=Q.suspendedLanes,F=Q.pingedLanes;Q=Q.warmLanes;var V=G&134217727;return V!==0?(G=V&~H,G!==0?M=O5(G):(F&=V,F!==0?M=O5(F):z||(z=V&~Q,z!==0&&(M=O5(z))))):(V=G&~H,V!==0?M=O5(V):F!==0?M=O5(F):z||(z=G&~Q,z!==0&&(M=O5(z)))),M===0?0:X!==0&&X!==M&&(X&H)===0&&(H=M&-M,z=X&-X,H>=z||H===32&&(z&4194048)!==0)?X:M}function W5(Q,X){return(Q.pendingLanes&~(Q.suspendedLanes&~Q.pingedLanes)&X)===0}function h7(Q,X){switch(Q){case 1:case 2:case 4:case 8:case 64:return X+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return X+5000;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return console.error("Should have found matching lanes. This is a bug in React."),-1}}function W4(){var Q=bG;return bG<<=1,(bG&62914560)===0&&(bG=4194304),Q}function $7(Q){for(var X=[],z=0;31>z;z++)X.push(Q);return X}function T5(Q,X){Q.pendingLanes|=X,X!==268435456&&(Q.suspendedLanes=0,Q.pingedLanes=0,Q.warmLanes=0)}function J5(Q,X,z,G,M,H){var F=Q.pendingLanes;Q.pendingLanes=z,Q.suspendedLanes=0,Q.pingedLanes=0,Q.warmLanes=0,Q.expiredLanes&=z,Q.entangledLanes&=z,Q.errorRecoveryDisabledLanes&=z,Q.shellSuspendCounter=0;var{entanglements:V,expirationTimes:C,hiddenUpdates:D}=Q;for(z=F&~z;0"u")return null;try{return Q.activeElement||Q.body}catch(X){return Q.body}}function y0(Q){return Q.replace(Ys,function(X){return"\\"+X.charCodeAt(0).toString(16)+" "})}function f0(Q,X){X.checked===void 0||X.defaultChecked===void 0||bD||(console.error("%s contains an input of type %s with both checked and defaultChecked props. Input elements must be either controlled or uncontrolled (specify either the checked prop, or the defaultChecked prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://react.dev/link/controlled-components",b1()||"A component",X.type),bD=!0),X.value===void 0||X.defaultValue===void 0||RD||(console.error("%s contains an input of type %s with both value and defaultValue props. Input elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://react.dev/link/controlled-components",b1()||"A component",X.type),RD=!0)}function m0(Q,X,z,G,M,H,F,V){if(Q.name="",F!=null&&typeof F!=="function"&&typeof F!=="symbol"&&typeof F!=="boolean"?(U1(F,"type"),Q.type=F):Q.removeAttribute("type"),X!=null)if(F==="number"){if(X===0&&Q.value===""||Q.value!=X)Q.value=""+s(X)}else Q.value!==""+s(X)&&(Q.value=""+s(X));else F!=="submit"&&F!=="reset"||Q.removeAttribute("value");X!=null?u0(Q,F,s(X)):z!=null?u0(Q,F,s(z)):G!=null&&Q.removeAttribute("value"),M==null&&H!=null&&(Q.defaultChecked=!!H),M!=null&&(Q.checked=M&&typeof M!=="function"&&typeof M!=="symbol"),V!=null&&typeof V!=="function"&&typeof V!=="symbol"&&typeof V!=="boolean"?(U1(V,"name"),Q.name=""+s(V)):Q.removeAttribute("name")}function i0(Q,X,z,G,M,H,F,V){if(H!=null&&typeof H!=="function"&&typeof H!=="symbol"&&typeof H!=="boolean"&&(U1(H,"type"),Q.type=H),X!=null||z!=null){if(!(H!=="submit"&&H!=="reset"||X!==void 0&&X!==null)){H0(Q);return}z=z!=null?""+s(z):"",X=X!=null?""+s(X):z,V||X===Q.value||(Q.value=X),Q.defaultValue=X}G=G!=null?G:M,G=typeof G!=="function"&&typeof G!=="symbol"&&!!G,Q.checked=V?Q.checked:!!G,Q.defaultChecked=!!G,F!=null&&typeof F!=="function"&&typeof F!=="symbol"&&typeof F!=="boolean"&&(U1(F,"name"),Q.name=F),H0(Q)}function u0(Q,X,z){X==="number"&&C0(Q.ownerDocument)===Q||Q.defaultValue===""+z||(Q.defaultValue=""+z)}function f1(Q,X){X.value==null&&(typeof X.children==="object"&&X.children!==null?wX.Children.forEach(X.children,function(z){z==null||typeof z==="string"||typeof z==="number"||typeof z==="bigint"||CD||(CD=!0,console.error("Cannot infer the option value of complex children. Pass a `value` prop or use a plain string as children to