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 new file mode 100644 index 000000000..e70782b65 --- /dev/null +++ b/.context/compound-engineering/todos/001-ready-p1-resolve-std-fs-follow-up-markers.md @@ -0,0 +1,39 @@ +--- +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. + diff --git a/AGENTS.md b/AGENTS.md index 2df3d2b6e..adc9b5d79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,6 +100,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/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/Cargo.lock b/Cargo.lock index a6834259a..f3ce18892 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1505,7 +1505,7 @@ dependencies = [ [[package]] name = "fabro-agent" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -1561,7 +1561,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1582,7 +1582,7 @@ dependencies = [ [[package]] name = "fabro-checkpoint" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "chrono", "fabro-store", @@ -1597,7 +1597,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -1687,7 +1687,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -1705,6 +1705,7 @@ dependencies = [ "rand 0.9.4", "serde", "serde_json", + "static_assertions", "tempfile", "thiserror 2.0.18", "tokio", @@ -1714,7 +1715,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -1737,7 +1738,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "async-trait", "fabro-types", @@ -1752,7 +1753,7 @@ dependencies = [ [[package]] name = "fabro-devcontainer" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "fabro-http", "fabro-util", @@ -1768,7 +1769,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "base64", "chrono", @@ -1784,7 +1785,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -1798,7 +1799,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "async-trait", "fabro-agent", @@ -1821,7 +1822,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "http", "reqwest 0.13.2", @@ -1830,7 +1831,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -1844,7 +1845,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "async-trait", "dialoguer", @@ -1858,7 +1859,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1888,7 +1889,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "proc-macro2", "quote", @@ -1897,7 +1898,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "fabro-config", @@ -1913,7 +1914,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "insta", "serde", @@ -1923,7 +1924,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "axum", "base64", @@ -1942,7 +1943,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "cc", "libc", @@ -1951,7 +1952,7 @@ dependencies = [ [[package]] name = "fabro-retro" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -1969,7 +1970,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2001,7 +2002,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2079,7 +2080,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "fabro-http", "fabro-interview", @@ -2098,14 +2099,14 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "rust-embed", ] [[package]] name = "fabro-store" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "async-trait", "bytes", @@ -2131,7 +2132,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2156,7 +2157,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "fabro-util", @@ -2168,7 +2169,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "assert_cmd", "axum", @@ -2190,7 +2191,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "async-trait", "fabro-github", @@ -2203,7 +2204,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "chrono", "clap", @@ -2224,7 +2225,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "aho-corasick", "anyhow", @@ -2246,7 +2247,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "fabro-graphviz", "fabro-model", @@ -2256,7 +2257,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "chrono", "serde", @@ -2267,7 +2268,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -6912,7 +6913,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "axum", "base64", @@ -6931,7 +6932,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 49ad412a2..a4f711760 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.211.0-nightly.1" +version = "0.212.0-nightly.0" license = "MIT" [workspace.dependencies] diff --git a/bin/dev/check-boundary.sh b/bin/dev/check-boundary.sh index 974dc4442..d855e2384 100755 --- a/bin/dev/check-boundary.sh +++ b/bin/dev/check-boundary.sh @@ -5,6 +5,7 @@ cd "$(dirname "$0")/../.." server_symbol_allowlist=( "lib/crates/fabro-cli/src/local_server.rs" + "lib/crates/fabro-cli/src/commands/install.rs" "lib/crates/fabro-cli/src/commands/run/runner.rs" "lib/crates/fabro-cli/src/commands/pr/mod.rs" "lib/crates/fabro-cli/src/commands/pr/create.rs" diff --git a/clippy.toml b/clippy.toml index 0a19186f3..033174d94 100644 --- a/clippy.toml +++ b/clippy.toml @@ -20,6 +20,8 @@ disallowed-methods = [ { path = "std::fs::File::create", reason = "Blocking open; prefer tokio::fs::File::create on Tokio paths. Document intentional sync I/O with #[expect(clippy::disallowed_methods, reason = \"...\")]" }, { path = "std::fs::File::create_new", reason = "Blocking open; prefer tokio::fs::File::create_new on Tokio paths. Document intentional sync I/O with #[expect(clippy::disallowed_methods, reason = \"...\")]" }, { path = "std::fs::OpenOptions::open", reason = "Blocking open; prefer tokio::fs::OpenOptions::open on Tokio paths. OS file-lock semantics may require spawn_blocking instead. Document intentional sync I/O with #[expect(clippy::disallowed_methods, reason = \"...\")]" }, + { path = "std::env::set_var", reason = "Server/process env must be injected at construction or child-process spawn time, not mutated globally. See docs-internal/server-secrets-strategy.md" }, + { path = "std::env::remove_var", reason = "Server/process env must be injected at construction or child-process spawn time, not mutated globally. See docs-internal/server-secrets-strategy.md" }, { path = "reqwest::Client::new", reason = "Use fabro_http::http_client() or fabro_http::test_http_client()", allow-invalid = true }, { path = "reqwest::Client::builder", reason = "Use fabro_http::HttpClientBuilder::new()", allow-invalid = true }, { path = "reqwest::blocking::Client::new", reason = "Use fabro_http::blocking_http_client() or fabro_http::blocking_test_http_client()", allow-invalid = true }, diff --git a/docs-internal/cli-workflow-coupling-audit.md b/docs-internal/cli-workflow-coupling-audit.md index 00f9c295b..a58c2c828 100644 --- a/docs-internal/cli-workflow-coupling-audit.md +++ b/docs-internal/cli-workflow-coupling-audit.md @@ -34,7 +34,7 @@ | Path | Direct dependency | Why it still exists | Suggested handling | | --- | --- | --- | --- | -| `lib/crates/fabro-cli/src/commands/store/dump.rs` test module | `event::{Event, append_event}` | Unit tests synthesize workflow events directly. | Low priority; keep until a lighter-weight event fixture helper exists. | +| `lib/crates/fabro-cli/src/commands/dump.rs` test module | `event::{Event, append_event}` | Unit tests synthesize workflow events directly. | Low priority; keep until a lighter-weight event fixture helper exists. | | `lib/crates/fabro-cli/src/commands/run/wait.rs` test module | `outcome::StageStatus`, `records::Conclusion`, `run_status::RunStatusRecord` | Output tests construct workflow-owned records directly. | Replace with shared fixture builders once status/conclusion DTOs move out. | | `lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs` test module | `event::{Event, RunNoticeLevel, to_run_event, to_run_event_at}`, `outcome::billed_model_usage_from_llm` | Progress tests build engine events directly. | Replace with shared event fixture helpers after event DTO extraction. | | `lib/crates/fabro-cli/src/commands/run/run_progress/event.rs` test module | `event::{Event, to_run_event}` | Event rendering tests depend on engine event constructors. | Replace with shared event fixture helpers after event DTO extraction. | diff --git a/docs-internal/run-directory-keys.md b/docs-internal/run-directory-keys.md index 4d5adc8e7..990e57bfe 100644 --- a/docs-internal/run-directory-keys.md +++ b/docs-internal/run-directory-keys.md @@ -33,7 +33,7 @@ These paths are local runtime state, not canonical event projections. These names are still real, but they are no longer live scratch files by default: - Metadata branch files such as `run.json`, `start.json`, `checkpoint.json`, and `retro.json` -- `fabro store dump` exports such as `run.json`, `start.json`, `status.json`, `checkpoint.json`, `conclusion.json`, `retro.json`, `events.jsonl`, and per-node prompt/response/status/stdout/stderr files +- `fabro dump` exports such as `run.json`, `start.json`, `status.json`, `checkpoint.json`, `conclusion.json`, `retro.json`, `events.jsonl`, and per-node prompt/response/status/stdout/stderr files - Retro-agent temp uploads named `progress.jsonl`, `checkpoint.json`, `run.json`, and `start.json` inside the retro sandbox ## Notes diff --git a/docs-internal/server-secrets-strategy.md b/docs-internal/server-secrets-strategy.md new file mode 100644 index 000000000..1c092c3e5 --- /dev/null +++ b/docs-internal/server-secrets-strategy.md @@ -0,0 +1,69 @@ +# Server Secrets Strategy + +This document defines how Fabro handles server-level secrets. + +## Core Rules + +- `ServerSecrets` is the canonical server-secret reader. +- It reads from `process env` and `/server.env`. +- Resolution is snapshot-based: env and file are read once at construction, then treated as immutable for the life of the process. +- `process env` wins over `server.env` on conflicts. +- `fabro server start` never generates secrets. Missing required secrets are a startup error. +- `std::env::set_var` and `std::env::remove_var` are banned workspace-wide. Tests are not exempt. Enforced by clippy via `disallowed_methods` in `clippy.toml`; intentional exceptions must be annotated with a scoped `#[expect(clippy::disallowed_methods, reason = "...")]` at the call site. + +## Active Server Secrets + +These values belong to the server runtime and are read via `state.server_secret(...)`: + +| Secret | Used by | +|---|---| +| `SESSION_SECRET` | Cookie encryption and JWT signing derivation | +| `FABRO_DEV_TOKEN` | Dev-token user auth when `server.auth.methods` includes `dev-token` | +| `GITHUB_APP_PRIVATE_KEY` | GitHub App credentials | +| `GITHUB_APP_WEBHOOK_SECRET` | GitHub webhook verification | +| `GITHUB_APP_CLIENT_SECRET` | GitHub OAuth login | + +`FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY` are removed. `SESSION_SECRET` is the single auth root. + +## Startup + +- Foreground and daemon startup use the same validation path. +- Required-at-startup secrets are: + - `SESSION_SECRET` + - `FABRO_DEV_TOKEN` when dev-token auth is enabled + - `GITHUB_APP_CLIENT_SECRET` when GitHub auth is enabled +- Other server secrets remain lazy/feature-specific rather than universal boot blockers. + +## Provisioning + +Server secrets come from one of two sources: + +- Platform env for 12-factor deployments +- `server.env` written by install flows + +There is no compatibility layer for removed secrets and no startup-time secret generation. + +## Subprocess Boundaries + +- Worker and render-graph subprocesses start from `env_clear()` and re-add only explicit allowlisted variables. +- Authority-bearing values are re-injected intentionally. For worker subprocesses this is `FABRO_WORKER_TOKEN`, not user auth state such as `FABRO_DEV_TOKEN` or `auth.json`. +- The worker reads `FABRO_WORKER_TOKEN` from its env at startup (in `main()` before Tokio initializes) and immediately calls `std::env::remove_var` to scrub it. The token then flows through function arguments to `runner::execute`. Every descendant process (hooks, sandbox commands, devcontainer setup, MCP stdio, etc.) therefore inherits a worker env that no longer contains the bearer, so an unscrubbed spawn site cannot leak it. +- The daemon child inherits the parent env unchanged except for output-format hygiene (`FABRO_JSON` removal). + +## Tests + +- In-process tests must inject server secrets with construction-time stubs (`EnvSource`, `StubEnv`) or by writing `server.env`. +- Subprocess tests must set child env with `Command::env`. +- Tests must not mutate the process-wide environment. + +## Rotation + +- Secret rotation requires restart. +- Live rotation is intentionally unsupported. + +## Adding A New Server Secret + +1. Provision it through platform env or install-written `server.env`. +2. Read it through `state.server_secret(...)`. +3. Decide explicitly whether startup should fail when it is absent. +4. If a worker or render subprocess needs it, re-inject it explicitly rather than broadening inheritance casually. diff --git a/docs/administration/security.mdx b/docs/administration/security.mdx index 2a3feec24..437c2b5ac 100644 --- a/docs/administration/security.mdx +++ b/docs/administration/security.mdx @@ -30,7 +30,7 @@ Fabro is single-tenant software designed for small, trusted teams. The following - **Enable authentication.** Fabro supports `dev-token` and GitHub OAuth. Do not disable auth outside of local development or controlled demos. - **Configure a username allowlist for GitHub OAuth.** `[server.auth.github].allowed_usernames` should contain the exact GitHub users allowed to log in. An empty list rejects everyone. -- **Configure the session secret used by the web flow.** `SESSION_SECRET` should be provisioned with a strong value on long-lived deployments. If you also provision `FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY`, treat them as server runtime secrets, but they are not what currently gates browser auth. +- **Configure the session secret used by the web flow.** `SESSION_SECRET` should be provisioned with a strong value on long-lived deployments. - **Terminate HTTPS or mTLS upstream when needed.** Fabro's listener is plain HTTP/Unix only. If CI, scripts, or a browser must connect over HTTPS, terminate TLS at a reverse proxy or load balancer and keep the Fabro listener on a private network. ### Secrets diff --git a/docs/administration/server-configuration.mdx b/docs/administration/server-configuration.mdx index dce6b3ae7..1d96421d8 100644 --- a/docs/administration/server-configuration.mdx +++ b/docs/administration/server-configuration.mdx @@ -299,7 +299,6 @@ For the auth model above, the main server runtime secrets are: - `SESSION_SECRET` when the web UI is enabled - `FABRO_DEV_TOKEN` when `"dev-token"` auth is enabled - `GITHUB_APP_CLIENT_SECRET` when `"github"` auth is enabled -- `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY`, provisioned during install for future CLI login flows - `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` when the install wizard or a manual config uses static S3 object-store credentials @@ -332,8 +331,6 @@ Fabro resolves these from `process env -> server.env`. | Variable | Description | |---|---| -| `FABRO_JWT_PRIVATE_KEY` | Ed25519 private key (base64-encoded PEM) for JWT signing | -| `FABRO_JWT_PUBLIC_KEY` | Ed25519 public key (base64-encoded PEM) for JWT verification | | `SESSION_SECRET` | Session encryption secret (64-character hex string) | ### Object store runtime secrets (optional) diff --git a/docs/agents/outputs.mdx b/docs/agents/outputs.mdx index e375ff405..3e95af24d 100644 --- a/docs/agents/outputs.mdx +++ b/docs/agents/outputs.mdx @@ -7,7 +7,7 @@ When an agent or prompt node finishes, Fabro captures its response text and prod ## Response capture -After an agent or prompt node completes, Fabro captures the full response text and persists it to `stages/{node_id}@{visit}/response.md` in metadata snapshots and `fabro store dump` output. It also writes the final outcome (status, context updates, routing directives) to `stages/{node_id}@{visit}/status.json`. +After an agent or prompt node completes, Fabro captures the full response text and persists it to `stages/{node_id}@{visit}/response.md` in metadata snapshots and `fabro dump` output. It also writes the final outcome (status, context updates, routing directives) to `stages/{node_id}@{visit}/status.json`. ## Context updates @@ -92,7 +92,7 @@ review -> approve [label="Approve"] ## Output logging -Fabro writes several files per stage to `stages/{node_id}@{visit}/` in metadata snapshots and `fabro store dump` output: +Fabro writes several files per stage to `stages/{node_id}@{visit}/` in metadata snapshots and `fabro dump` output: | File | Contents | |---|---| diff --git a/docs/agents/prompts.mdx b/docs/agents/prompts.mdx index 69404aa79..61cc58dc1 100644 --- a/docs/agents/prompts.mdx +++ b/docs/agents/prompts.mdx @@ -295,4 +295,4 @@ Use prompt nodes for analysis, classification, and summarization tasks where too ## Prompt logging -Fabro persists the assembled prompt to `stages/{node_id}@{visit}/prompt.md` in metadata snapshots and `fabro store dump` output for every agent and prompt stage. This includes the preamble (if any) and the expanded prompt text. Use these files for debugging when an agent behaves unexpectedly. +Fabro persists the assembled prompt to `stages/{node_id}@{visit}/prompt.md` in metadata snapshots and `fabro dump` output for every agent and prompt stage. This includes the preamble (if any) and the expanded prompt text. Use these files for debugging when an agent behaves unexpectedly. diff --git a/docs/changelog/2026-03-03.mdx b/docs/changelog/2026-03-03.mdx index 2b9137aea..1fc0f648c 100644 --- a/docs/changelog/2026-03-03.mdx +++ b/docs/changelog/2026-03-03.mdx @@ -47,5 +47,4 @@ If something is misconfigured, `fabro doctor` tells you exactly what's wrong and - New indicatif-based progress display for `fabro run start` shows real-time stage progress, tool calls, model names, and timing - Mercury provider updated to `mercury-2`; estimated output speed (tok/s) added to `fabro model list` - Run defaults in `server.toml` are inherited by all workflow runs, so you don't have to repeat sandbox, model, or concurrency settings -- `FABRO_JWT_PUBLIC_KEY` and `FABRO_JWT_PRIVATE_KEY` accept base64-encoded PEM strings for containerized deployments diff --git a/docs/changelog/2026-03-29.mdx b/docs/changelog/2026-03-29.mdx index e6e82bdcf..f89bbadab 100644 --- a/docs/changelog/2026-03-29.mdx +++ b/docs/changelog/2026-03-29.mdx @@ -1,14 +1,14 @@ --- -title: "Store dump export command" +title: "Dump export command" date: "2026-03-29" --- -## `fabro store dump` +## `fabro dump` -A new `fabro store dump` command exports the contents of the run store to a human-readable format for debugging and inspection. This is useful for diagnosing issues with run state, verifying data integrity after migrations, or extracting run data for external analysis. +A new `fabro dump` command exports the contents of the run store to a human-readable format for debugging and inspection. This is useful for diagnosing issues with run state, verifying data integrity after migrations, or extracting run data for external analysis. ```bash -fabro store dump +fabro dump ``` ## More diff --git a/docs/execution/observability.mdx b/docs/execution/observability.mdx index 80eff8ed8..d10a5ec91 100644 --- a/docs/execution/observability.mdx +++ b/docs/execution/observability.mdx @@ -81,7 +81,7 @@ jq '{from: .properties.from_node, to: .properties.to_node, label: .properties.la <(fabro logs 01JKXYZ...) | head ``` -If you need files on disk for offline analysis, `fabro store dump` exports `events.jsonl` plus run-state projections. +If you need files on disk for offline analysis, `fabro dump` exports `events.jsonl` plus run-state projections. ## Event categories @@ -132,6 +132,6 @@ Post-run analysis surfaces include: |---|---| | `fabro logs ` | Full event envelope stream as NDJSON | | `fabro inspect ` | Current durable run state, including run/start/checkpoint/conclusion records | -| `fabro store dump --output ` | Exported `events.jsonl` plus reconstructed JSON and node files | +| `fabro dump --output ` | Exported `events.jsonl` plus reconstructed JSON and node files | See [retros](/execution/retros), [stages](/api-reference/run-internals/list-run-stages), and [turns](/api-reference/run-internals/list-stage-turns) for higher-level analysis views built on top of this event stream. diff --git a/docs/execution/retros.mdx b/docs/execution/retros.mdx index 879190803..e5a8437f4 100644 --- a/docs/execution/retros.mdx +++ b/docs/execution/retros.mdx @@ -143,4 +143,4 @@ Retros are also available via the REST API. See the [list retros](/api-reference ## Storage -Retros are stored in durable run state. If you need files on disk, `fabro store dump` materializes retro text under `stages/retro/` alongside `run.json`, stage files, and the rest of the exported run data. +Retros are stored in durable run state. If you need files on disk, `fabro dump` materializes retro text under `stages/retro/` alongside `run.json`, stage files, and the rest of the exported run data. diff --git a/docs/plans/2026-04-22-003-refactor-lock-down-server-secrets-plan.md b/docs/plans/2026-04-22-003-refactor-lock-down-server-secrets-plan.md new file mode 100644 index 000000000..cf02593b0 --- /dev/null +++ b/docs/plans/2026-04-22-003-refactor-lock-down-server-secrets-plan.md @@ -0,0 +1,483 @@ +--- +title: "refactor: Lock down server-level secrets handling" +type: refactor +status: active +date: 2026-04-22 +deepened: 2026-04-23 +--- + +# Lock down server-level secrets handling + +## Overview + +Server-level secrets (SESSION_SECRET, FABRO_DEV_TOKEN, GitHub App credentials, JWT keypair) flow through three independent paths today: + +- `ServerSecrets::get` reads process env *live* on every call, with on-disk envfile fallback. +- `load_or_create_local_session_secret` reads env first, then envfile, then auto-generates if missing. +- `execute_foreground` mutates parent process env via `std::env::set_var` so the in-process server's live env reads see the resolved value. + +Worker subprocess scrubs `FABRO_DEV_TOKEN`; daemon spawn does not — accidental inconsistency. + +This refactor: + +- **Makes `ServerSecrets` snapshot-based.** Reads env and envfile *once* at construction, exposes the merged result. Both env and file remain valid sources (env wins on conflict — 12-factor convention). The bug was *live* env reads coupled with parent-process mutation, not env reads themselves. +- **Eliminates parent-process env mutation.** With snapshot semantics, the foreground `set_var` becomes unnecessary; the daemon `cmd.env(SESSION_SECRET)` becomes redundant. +- **CLI install and web install share a common orchestration path for `server.env` writes.** Coordinated install flows may update server-level secrets while a server is running, but they must also own restart/handoff (web install already does this via `/install/finish`). +- **Worker and render-graph subprocesses use `env_clear` + strict fail-closed allowlists.** Worker is the trust boundary — it dispatches user-supplied workflow stages via `Sandbox`. Daemon child inherits parent env unchanged (12-factor pattern works). +- **Foreground and daemon startup share one validation path.** Daemon preflight builds the same `ServerSecrets` snapshot and runs the same server-side auth/startup validation foreground does — no separate parent-CLI validator with drift risk. +- **Legacy `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY` drift is removed.** SESSION_SECRET remains the sole auth master (HKDF source for cookie key + JWT signing key per `2026-04-19-003-feat-cli-auth-login-plan.md`); install stops generating those keys and actively removes them from `server.env` on subsequent runs. +- **`clippy::disallowed_methods` denies `std::env::set_var` / `remove_var`** workspace-wide *including tests*; narrow documented post-fork/pre-exec exceptions only. + +## Problem Frame + +- **Live env reads + parent-process mutation is unsound.** `std::env::set_var` in `execute_foreground` mutates global process state inside an async runtime. Rust 2024 marks this `unsafe` because it races concurrent readers. Workspace is on 2021; moving to 2024 forces the issue. The set_var is load-bearing because `ServerSecrets::get` reads env *live*, not at construction. +- **Worker subprocess env leakage.** Workers run user-supplied workflow stages (via `Sandbox`). Today they inherit the operator's full process env — any credential the operator exported leaks to user-controlled commands. +- **`SESSION_SECRET` autogeneration is the real startup bypass.** `load_or_create_local_session_secret` mints a value and writes the envfile if missing — a server can boot with a freshly-minted secret that bypassed the install flow's full setup. Same precedent already removed for `FABRO_DEV_TOKEN` (commit `7cb6c65d5`); SESSION_SECRET is the leftover. +- **`FABRO_JWT_*` is stale config drift.** The CLI auth migration (`2026-04-19-003-feat-cli-auth-login-plan.md`) consolidated cookie + JWT signing into a single HKDF chain rooted at SESSION_SECRET. The legacy `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY` envfile entries no longer participate in the runtime auth model but install still generates them and diagnostics still inspects them — they obscure the actual auth model and risk operator confusion. +- **CLI install and web install have drifted into separate persistence/restart behaviors.** They write to `server.env` via parallel code paths today; the bug-surface of "what does install actually persist" is twice as large as it should be. They should reconverge on a shared orchestration with consistent contracts. + +## Requirements Trace + +- R1. `ServerSecrets` reads env and envfile *once* at boot and exposes the merged snapshot. No live env reads from `ServerSecrets::get` or its callers on the resolution path. Env wins on conflict. +- R2. `fabro server start` does not generate any server-level secret. Missing → fail fast pointing at the install flows (CLI or web) or direct env-set. +- R3. `execute_foreground` does not mutate parent process env. +- R4. `execute_daemon` does not pass server-level secrets via `cmd.env(...)` (no longer needed; daemon child reads env+file at its own boot). +- R5. Worker and render-graph subprocesses use `env_clear` + strict fail-closed allowlists. New inherited env vars require demonstrated need plus tests. Authority-bearing values re-injected explicitly. +- R6. Tests use construction-time env stubs (via a small `EnvSource` trait) or explicit child-process `Command::env`. No test mutates process-wide env. +- R7. Foreground and daemon startup use one shared validation path; required secrets mirror current auth rules (SESSION_SECRET always; FABRO_DEV_TOKEN when dev-token auth enabled; GITHUB_APP_CLIENT_SECRET when GitHub auth enabled). +- R8. CLI install and web install share orchestration for coordinated `server.env` writes and restart/handoff. +- R9. `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY` are removed from install output, diagnostics, docs, and existing `server.env` files during install flows. +- R10. Behavior changes are documented in a strategy doc and enforced via workspace-wide `clippy::disallowed_methods` — including tests — with narrow documented exceptions only. + +## Scope Boundaries + +**In scope, active** — server-level secrets read via `state.server_secret(...)`, sourced from process env or `/server.env`: + +| Secret | Consumer | Sources | +|---|---|---| +| `SESSION_SECRET` | `AppState::session_key` (`server.rs:713`); HKDF source for cookie key + JWT signing key | env (12-factor) or `server.env` (install) | +| `FABRO_DEV_TOKEN` | `worker_command` (`server.rs:3825`) | env or `server.env` | +| `GITHUB_APP_PRIVATE_KEY` | `AppState::github_credentials` (`server.rs:726`) | env or `server.env` | +| `GITHUB_APP_WEBHOOK_SECRET` | `github_webhook_routes` (`server.rs:983`) | env or `server.env` | +| `GITHUB_APP_CLIENT_SECRET` | `web_auth.rs:536` | env or `server.env` | + +Both install flows (CLI `fabro install` and web install via `/install/finish`) write the envfile; neither mutates process env. Operators on 12-factor PaaS set secrets in platform env; operators on local/server installs use one of the install flows. + +**In scope, removal targets:** + +- `FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY` — legacy entries that no longer participate in the runtime auth model. Install stops generating them; diagnostics stops inspecting them; existing entries are actively removed from `server.env` on subsequent install flows. + +**Startup-critical subset:** SESSION_SECRET (always), FABRO_DEV_TOKEN (when dev-token auth is enabled), and GITHUB_APP_CLIENT_SECRET (when GitHub auth is enabled). The other in-scope active secrets (GITHUB_APP_PRIVATE_KEY, GITHUB_APP_WEBHOOK_SECRET) are not boot blockers — they continue to surface where they did before (conditional route mounts, per-request errors). The plan does not promote every server secret to a startup gate. + +**Out of scope:** + +- **Vault + `ProviderCredentials`** (`secrets.json`, REST-managed, runtime-mutable). Different lifecycle. +- **`vault_or_env` for run-level credentials** (`GITHUB_TOKEN`, `DAYTONA_API_KEY`). Same env-as-source pattern, different track. +- **`{{ env.FOO }}` config interpolation.** Operator-supplied templating, different feature. +- **Workflow-stage env (Sandbox).** Stages run inside `Sandbox` (local/Docker/Daytona); their env is configured by the workflow definition + Sandbox config. Anything a workflow stage needs to execute (e.g. `git push` requiring `GITHUB_TOKEN`) routes via Vault → Sandbox, not subprocess inheritance. +- **`validate_api_key` `set_var` in `provider_auth.rs`.** Vault-side smell, deferred. +- **Tailscale and `bun --watch-web` spawns.** Different surfaces. + +## Context & Research + +### Relevant code + +- `lib/crates/fabro-server/src/server_secrets.rs` — `ServerSecrets` definition; today's live `env_lookup` closure is the bug. +- `lib/crates/fabro-server/src/server.rs:697-699` — `state.server_secret(name)` wrapper. +- `lib/crates/fabro-server/src/server.rs:703,712-715` and `jwt_auth.rs:84-99` — `resolve_auth_mode_with_lookup`, currently routed through the live `env_lookup`. +- `lib/crates/fabro-server/src/server.rs:3776-3834` (`worker_command`) — worker spawn site. +- `lib/crates/fabro-server/src/server.rs:7232-7235` — `__render-graph` spawn site. +- `lib/crates/fabro-cli/src/commands/server/start.rs:229-274` — `load_or_create_local_session_secret` and `execute_foreground` `set_var` block. +- `lib/crates/fabro-cli/src/commands/server/start.rs:319-362` — `execute_daemon` spawn flow. +- `lib/crates/fabro-cli/src/commands/install.rs:1816,1856,1864` — install SESSION_SECRET generation and persistence. +- `lib/crates/fabro-config/src/envfile.rs:204-256` — `write_env_entries`, already atomic via tmp + fsync + rename. +- `Cargo.toml:111` and `clippy.toml` — existing `disallowed_methods` mechanism. + +### Institutional learnings + +- `docs/plans/2026-04-05-server-canonical-secrets-doctor-repo-plan.md` — architectural anchor: server is canonical, install provisions, request-time reads from store. +- `docs/plans/2026-04-19-003-feat-cli-auth-login-plan.md` — `SESSION_SECRET` is now HKDF master for JWT signing + cookie key. Higher stakes. +- `docs/plans/2026-04-18-001-feat-webhook-strategy-plan.md` — webhook secret already plumbed via `AppState` from the resolved store. +- Commit `7cb6c65d5` — "Remove dev-token minting from server start." SESSION_SECRET autogenerate is the leftover. + +## Key Technical Decisions + +- **`ServerSecrets` is a snapshot, not a live reader.** A small `EnvSource` trait exists *only at construction time* and immediately materializes into an owned `HashMap`. No trait object or callback survives into runtime lookup. `get(name)` returns from the merged (env ∪ file) snapshot; env wins on conflict (12-factor). Role: the *resolved-secrets API*; the source mix is the operator's choice. +- **Production uses a real process-env `EnvSource`; tests use a map-backed stub.** No live env reads anywhere on the secret resolution path. No `EnvLookup` closure surviving into runtime. +- **`resolve_auth_mode_with_lookup` migrates to read from `server_secrets`, not raw env.** Closure passed in delegates to `self.server_secrets.get(...)`. Without this, auth-mode resolution remains a live env reader. +- **Worker and render-graph allowlists are strict fail-closed.** Worker is the trust boundary (dispatches user stages via `Sandbox`); render-graph is hygiene. New inherited env vars require demonstrated need (failing test) and intentional addition. Proxy/cert env vars are not auto-inherited. + - Worker list: `PATH`, `HOME`, `TMPDIR`, `USER`, `RUST_LOG`, `RUST_BACKTRACE`, `FABRO_HOME`, `FABRO_STORAGE_ROOT`. Plus explicit `FABRO_DEV_TOKEN` re-injection when dev-token auth is enabled. + - Render-graph list: `PATH`, `HOME`, `TMPDIR`. Plus explicit `FABRO_TELEMETRY=off`. +- **Daemon child spawn inherits parent env unchanged** (modulo existing `cmd.env_remove("FABRO_JSON")` for output-format hygiene). The daemon is fabro's own server code, not a security boundary against itself. 12-factor env vars (SESSION_SECRET, AWS_*, RAILWAY_*) flow through naturally to the daemon child's snapshot. +- **Daemon preflight reuses the same shared auth/startup validation as foreground startup.** Both modes construct the same `ServerSecrets` snapshot and call the same server-side validation logic (today's `jwt_auth::resolve_auth_mode_with_lookup` already encodes "what's required for this auth mode" — that's the shared path). No separate parent-CLI validator with drift risk. Required secrets remain exactly the current startup-critical set: SESSION_SECRET (always), FABRO_DEV_TOKEN (when dev-token auth enabled), GITHUB_APP_CLIENT_SECRET (when GitHub auth enabled). GITHUB_APP_PRIVATE_KEY and GITHUB_APP_WEBHOOK_SECRET remain in-scope server secrets but are not new universal boot blockers. +- **Install coordination policy lives in shared install orchestration, not in low-level envfile helpers.** Coordinated install flows (CLI and web) may write to `server.env` while a server is running — but only when they also own restart/handoff. Web install already does this via `/install/finish`. CLI install joins the same orchestration. Manual edits to `server.env` outside the orchestration still require operator restart discipline. +- **`pub(crate) server_secrets` field tightened to `pub(super)`.** No production code legitimately bypasses the wrapper. +- **Workspace-wide clippy ban including tests.** Add `std::env::set_var`/`remove_var` to `clippy.toml` `disallowed-methods` — applies to production AND test code. Tests must use construction-time `EnvSource` stubs or explicit child-process `Command::env`. Narrow documented exceptions only (`fabro-telemetry/src/spawn.rs:73-76` post-fork pre-execvp). +- **Legacy `FABRO_JWT_*` is removed, not preserved.** Install stops generating; diagnostics stops reading; existing envfile entries are actively cleaned up on subsequent install flows. SESSION_SECRET is the sole auth master. + +## Open Questions + +### Resolved during planning + +- **`ServerSecrets` reads env AND file, snapshots, env wins.** Process env is a legitimate source (12-factor). The bug was *live* env reads + parent mutation, not env-as-source. +- **`EnvSource` exists only at construction time.** A small trait used to materialize an owned `HashMap` once; no callback or trait object survives into runtime lookup. Production uses a real process-env source; tests use a map-backed stub. +- **Daemon child inherits parent env; only worker/render-graph scrub.** Daemon is fabro's own server code and must see whatever the operator/platform set in env. Worker is the trust boundary (dispatches user stages via `Sandbox`). +- **No carve-out for AWS / cloud-platform credentials.** Daemon inherits parent env, so AWS env names reach the object-store layer naturally. +- **Active scope is 5 server secrets plus 2 legacy removal targets.** SESSION_SECRET, FABRO_DEV_TOKEN, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_WEBHOOK_SECRET, GITHUB_APP_CLIENT_SECRET are active. FABRO_JWT_PRIVATE_KEY and FABRO_JWT_PUBLIC_KEY are legacy — install stops generating them; diagnostics stops reading them; existing entries are actively removed. +- **Workflow stage env is the Sandbox's job.** This plan does not address workflow `bash`/`git`/etc. stage env — that's per-workflow Sandbox config. Workflows requiring credentials route them through Vault. +- **Web install keeps its existing restart-handoff contract.** `/install/finish` still returns the restart URL and the SPA still waits for the server to come back. No new `restart_required` flag, no manual-restart message proposal, no API surface change. +- **CLI and web install share orchestration for coordinated writes while running.** Blanket "refuse-while-running" is out. The orchestration is responsible for restart/handoff; both flows call into it. +- **Test migration shape:** full migration in Unit 1. `ServerSecrets::with_env_lookup` is deleted; tests use either `ServerSecrets::load(path, env_source)` with a map-backed `EnvSource` stub, or `provision_server_secrets(env_path, &[(name,value)])` helper (file-based injection). No `#[cfg(test)]` backdoor. +- **Visibility tightening:** `pub(crate) server_secrets` at `server.rs:579` → `pub(super)` as part of Unit 1. +- **Install lock window for interactive flows:** install orchestration's job; `gather inputs → persist + restart-handoff`. Serialization window is sub-second regardless of OAuth duration. +- **Dev-loop friction:** no `--quick` flag. Contributors run `fabro install` or set secrets directly via env (`SESSION_SECRET=$(openssl rand -hex 32) cargo run -- server start` works under the env-as-source model). +- **Compliance-driven rotation:** deferred. Captured as a known limitation in the strategy doc. + +## Implementation Units + +- [ ] **Unit 1: `ServerSecrets` becomes a snapshot built from `EnvSource` (construction-time only)** + +**Goal:** `ServerSecrets` reads env and envfile *once* at construction via a small `EnvSource` trait, materializes both into owned `HashMap`s, and exposes the merged snapshot. No trait object or closure survives into runtime lookup. Auth-mode resolution stops being a live env reader. + +**Requirements:** R1, R6 + +**Dependencies:** None. + +**Files:** +- Modify: `lib/crates/fabro-server/src/server_secrets.rs` — replace `env_lookup` closure field with `env_entries: HashMap` (owned). Introduce a small `EnvSource` trait used *only* at construction: + - `pub trait EnvSource { fn snapshot(&self) -> HashMap; }` + - `pub struct ProcessEnv;` impls `snapshot` via `std::env::vars().collect()`. + - `pub struct StubEnv(pub HashMap);` impls `snapshot` by cloning. (cfg-test or cfg(any(test, feature = "test-support")) — implementer picks based on existing patterns.) + - `pub fn load(path: PathBuf, env: &dyn EnvSource) -> Result` — calls `env.snapshot()` once, stores the result, never references `env` again. + - `get(name)` returns `self.env_entries.get(name).cloned().or_else(|| self.file_entries.get(name).cloned())`. +- Modify: `lib/crates/fabro-server/src/server.rs` — `build_app_state` accepts pre-built `ServerSecrets` and `AuthMode` from the caller (no longer constructs `ServerSecrets` itself). The single production caller is `serve_command` (via `resolve_startup` from Unit 4); test helpers construct both in the same way. Tighten `pub(crate) server_secrets` field at `server.rs:579` to `pub(super)`. Migrate the `resolve_auth_mode_with_lookup` call (`server.rs:703`) to pass a closure delegating to `self.server_secrets.get(...)`. +- Modify: `lib/crates/fabro-server/src/serve.rs:512` and `install.rs:910,965,2108` — minimal API migration: `ServerSecrets::load(path, &ProcessEnv)` instead of `with_env_lookup` at any *non-startup* construction sites (e.g. install-time inspection, install_object_store_lookup test helper). The startup construction sites at `serve.rs:594-607` are NOT modified by Unit 1 — Unit 4 collapses them into a single `resolve_startup(...)` call. To keep the workspace compiling between Unit 1 and Unit 4, Unit 1 may leave a temporary `ServerSecrets::load(path, &ProcessEnv)` call at `serve.rs:594` if needed; Unit 4 deletes it. Sequencing is documented in Unit 4's Dependencies. +- Modify: `lib/crates/fabro-server/src/server.rs:7864-7885` — replace `server_secrets_resolve_process_env_before_server_env` with a snapshot test using `StubEnv` (assert env-wins-on-conflict from the snapshot). +- Modify: `lib/crates/fabro-server/tests/it/api/cli_auth_token.rs:34`, `routing.rs:21,35-36`, `tcp.rs:28,68,173-174`, `lib/crates/fabro-cli/tests/it/support/auth_harness.rs:39-86` — migrate from `env_lookup` injection (for *secret* values) to constructing the test's `ServerSecrets` with `&StubEnv(...)`, or writing to a temp `server.env` via the `provision_server_secrets(env_path, &[(name, value)])` helper. Tests that currently pass `env_lookup` only to feed secret values into `ServerSecrets` switch to the new mechanism. +- Modify: test helpers like `create_app_state_with_env_lookup` (`server.rs:2488` and similar) — once `build_app_state` (`server.rs:2631`) requires precomputed `ServerSecrets` and `AuthMode`, the helpers must produce both internally and pass them through. The `env_lookup` parameter on these helpers is *retained* (still consumed downstream by `resolve_canonical_origin` at `server.rs:708` and slack `{{ env.FOO }}` interpolation at `server.rs:2676`, both out of scope). New shape: helper accepts an additional `&dyn EnvSource` parameter (or constructs a `StubEnv`/`ProcessEnv` based on the call site's intent), internally calls `resolve_startup(...)` (or directly constructs `ServerSecrets` + computes `AuthMode` if the helper bypasses settings — auditor's choice per call site), and passes the resulting `ServerSecrets` and `AuthMode` into `build_app_state`. Tests that set up specific auth modes (e.g. dev-token enabled) now thread settings + `EnvSource` through the helper rather than relying on a single closure to drive both secret resolution and auth-mode computation. +- Test: existing files plus new snapshot-semantics tests in `server_secrets.rs`. + +**Approach:** +- `EnvSource::snapshot()` is called exactly once during `ServerSecrets::load`. The trait reference is dropped immediately after; only the materialized `HashMap` is retained. No risk of live env reads via trait object dispatch. +- Object-store credential resolution at `serve.rs:340-401, 520-522, 540-542` uses `std::env::var(...)` directly (daemon child inherits parent env, so AWS names are present). + +**Test scenarios:** +- Happy path: `ServerSecrets::load(path, &StubEnv([("SESSION_SECRET", "from-env")].into()))` returns "from-env" even when file has a different value. +- Edge: env empty, file has the value → file wins (single fallback path). +- Edge: neither env nor file has the value → `get` returns `None`. +- Edge: env has it, file does too, different values → env wins (12-factor). +- Edge: missing file path → empty `file_entries`, env-only resolution. +- Snapshot semantics: after construction, mutating the source `EnvSource` (or process env, if production source) does not affect `get` returns. The snapshot is owned and immutable. +- Auth-mode happy path: `resolve_auth_mode_with_lookup` resolves correctly when secrets come from env, file, or both. +- Migration: `cli_auth_token`, `routing`, `tcp`, `auth_harness` tests pass after migration to `EnvSource` stubs. + +**Verification:** +- `cargo nextest run -p fabro-server -p fabro-cli` passes. +- `grep -rn 'std::env::var\|std::env::vars' lib/crates/fabro-server/src/ | grep -v 'serve.rs\|object_store\|spawn_env\|server_secrets.rs'` shows no live env reads on the server-secret resolution path (the only `std::env::vars()` is inside `ProcessEnv::snapshot`, called once at construction). + +--- + +- [ ] **Unit 2: Drop foreground `set_var`; drop daemon `cmd.env(SESSION_SECRET)`** + +**Goal:** Eliminate parent-process env mutation. Daemon parent stops passing SESSION_SECRET via `cmd.env`; daemon child reads env+file at its own boot. + +**Requirements:** R3, R4 + +**Dependencies:** Unit 1. + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/server/start.rs:265-274` — delete the `set_var`/scopeguard block in `execute_foreground`. +- Modify: `lib/crates/fabro-cli/src/commands/server/start.rs:350,352` — `execute_daemon` removes `cmd.env("SESSION_SECRET", ...)`. Keep existing `cmd.env_remove("FABRO_JSON")` (output-format hygiene). +- Modify: `lib/crates/fabro-test/src/lib.rs:931` — drop `cmd.env("SESSION_SECRET", ...)` for spawned test servers; tests provision via `server.env` or by setting env on the spawned `Command` explicitly when the test simulates a 12-factor PaaS scenario. +- Test: existing `cmd/server_start.rs` tests cover both modes. + +**Approach:** +- Unit 1's snapshot semantics make both `set_var` and `cmd.env(SESSION_SECRET)` unnecessary. The in-process foreground server's `ServerSecrets::load(path, &ProcessEnv)` snapshots whatever env the parent CLI had. The daemon child inherits parent env (today's behavior — unchanged) and snapshots it at its own boot. +- A `SESSION_SECRET` exported in the operator's parent shell does flow through to both modes — that's the 12-factor design intent. + +**Test scenarios:** + +Tests proving env behavior must use construction-time `EnvSource` stubs or explicit child-process `Command::env` — *not* `std::env::set_var`. Any old test using process-env mutation is migrated as part of this unit (or Unit 7's clippy enforcement will fail it). + +- Happy path (foreground, env source): in-process server constructed with `&StubEnv([("SESSION_SECRET", "from-env")].into())`; running server uses that value. +- Happy path (foreground, file source): empty `EnvSource`, value in `server.env`; running server uses the file value. +- Happy path (foreground, env-wins): env and file have different values; env wins. +- Happy path (daemon, env source): test passes `SESSION_SECRET` to spawned daemon via explicit `Command::env`; daemon child inherits and snapshots it. NOT via `std::env::set_var` in the test process. +- Happy path (daemon, file source): no env on the spawned `Command`, value in `server.env`; daemon child uses the file value. + +**Verification:** +- `grep -rn 'std::env::set_var\|remove_var' lib/crates/fabro-cli/src/` returns no hits in production code. +- `grep -rn 'cmd\.env."SESSION_SECRET"' lib/crates/fabro-cli/src/` returns no hits in production code (test code may still use `Command::env` for daemon spawn simulation; that's fine — it's setting child env, not parent env). + +--- + +- [ ] **Unit 3: Worker and render-graph spawns use `env_clear` + strict fail-closed allowlists** + +**Goal:** Worker and render-graph subprocesses inherit only an explicit allowlist. The lists are strict fail-closed — new env vars require demonstrated need (a failing test that proves a workflow execution path needs them) plus intentional addition. Authority-bearing values re-injected explicitly. Daemon child spawn is unchanged (inherits parent env per 12-factor). + +Proxy and TLS env vars (`HTTPS_PROXY`, `SSL_CERT_FILE`, etc.) are *not* inherited unless usage-proven by a failing test and intentionally added — they were never part of the worker's documented contract. + +**Requirements:** R5 + +**Dependencies:** None. + +**Files:** +- Create: `lib/crates/fabro-server/src/spawn_env.rs` — defines two helpers: + - `apply_worker_env(&mut tokio::process::Command)` — `env_clear` + worker list. + - `apply_render_graph_env(&mut tokio::process::Command)` — `env_clear` + render-graph list. +- Modify: `lib/crates/fabro-server/src/server.rs:3776-3834` — `worker_command` calls `apply_worker_env(&mut cmd)` first, then existing `cmd.env("FABRO_DEV_TOKEN", token)` re-injection. Remove existing `env_remove("FABRO_JSON")` and `env_remove("FABRO_DEV_TOKEN")` (covered by `env_clear`). +- Modify: `lib/crates/fabro-server/src/server.rs:7232-7235` — `__render-graph` spawn calls `apply_render_graph_env(&mut cmd)`. Keep explicit `cmd.env("FABRO_TELEMETRY", "off")`. +- Test: new tests in `spawn_env.rs`. + +**Approach:** + +```text +WORKER list (each entry has // reason: ... in the source): + PATH, HOME, TMPDIR, USER // process essentials + RUST_LOG, RUST_BACKTRACE // diagnostics + FABRO_HOME, FABRO_STORAGE_ROOT // worker reads its own state ++ explicit cmd.env("FABRO_DEV_TOKEN", token) when dev-token auth enabled + +RENDER_GRAPH list: + PATH, HOME, TMPDIR ++ explicit cmd.env("FABRO_TELEMETRY", "off") + +DAEMON spawn: no helper. Inherits parent env. Keeps existing cmd.env_remove("FABRO_JSON"). +``` + +The lists are constants in `spawn_env.rs`. Each entry is one named env var with a one-line `//` comment. A worker that needs a new env var must be amended in source — that's the entire mechanism. + +**Test scenarios:** +- Happy path (worker): with allowlisted names in parent env, all reach the worker. Random names (`MY_API_KEY`, `NEW_RELIC_LICENSE_KEY`, `DATABASE_URL`, `SESSION_SECRET=leak`) do not. +- Happy path (worker, dev-token enabled): `FABRO_DEV_TOKEN` is set to the install-provisioned value via explicit re-injection. +- Negative (worker): parent's `FABRO_DEV_TOKEN=garbage` does not reach the worker; explicit re-injection sets the correct value. +- Happy path (render-graph): `PATH` and `HOME` reach the child; arbitrary parent vars do not. +- Integration (render-graph): with `FABRO_TELEMETRY=on` in parent env, child sees `off` (explicit override after `env_clear`). +- Integration: real worker subprocess executes a workflow end-to-end with leak probes (`MY_API_TOKEN=leak`, `NEW_RELIC_LICENSE_KEY=leak`) exported in parent env. Workflow completes; leak probes do not appear in worker logs or in stage env (Sandbox-configured). + +**Verification:** +- `cargo nextest run -p fabro-server` passes. +- A test asserts the worker spawn sees *only* names in the allowlist plus the explicit `FABRO_DEV_TOKEN` — i.e. "no ambient inheritance except allowlisted names." Same for render-graph. The check operates by enumerating the child's actual env, not by greping `env_remove` calls. +- `grep -rn 'env_remove' lib/crates/fabro-server/src/` returns no hits for the worker (`server.rs:3776-3834`) or render-graph (`server.rs:7232-7235`) spawn sites — both routes are now via `env_clear` + the helpers. +- `grep -rn 'env_remove' lib/crates/fabro-cli/src/commands/server/start.rs` returns the single intentional `cmd.env_remove("FABRO_JSON")` on the daemon-spawn path (output-format hygiene; daemon child unchanged per Unit 2). No other `env_remove` calls in CLI server code. + +--- + +- [ ] **Unit 4: Shared startup validation via snapshot-backed `ServerSecrets`** + +**Goal:** Server start no longer auto-generates. Daemon preflight constructs the same `ServerSecrets` snapshot foreground startup uses and runs the *same* server-side auth/startup validation logic. No separate parent-CLI validator that could drift from the server-side rules. + +**Requirements:** R1, R2, R7 + +**Dependencies:** Units 1-3. + +**Files:** +- Create: `lib/crates/fabro-server/src/startup.rs` (or extend an existing module) — define the shared validation logic. Two entry points around it: a public preflight wrapper (CLI calls this; never sees `ServerSecrets`), and a crate-internal full-resolution function (`serve_command` calls this; consumes the snapshot directly). Both share a single internal implementation so there is no possibility of drift. + + ```text + // Crate-internal: returns full state for in-process consumption. + pub(crate) struct StartupResolution { + pub(crate) auth_mode: AuthMode, + pub(crate) server_secrets: ServerSecrets, + } + + pub(crate) fn resolve_startup( + env_path: &Path, + env: &dyn EnvSource, + settings: &ResolvedServerSettings, + ) -> Result + + // Public: thin wrapper for CLI preflight. Calls resolve_startup, drops the + // StartupResolution after validating, returns just the success/failure. + pub fn validate_startup( + env_path: &Path, + env: &dyn EnvSource, + settings: &ResolvedServerSettings, + ) -> Result<(), StartupValidationError> + ``` + + Internally `resolve_startup` constructs `ServerSecrets::load(env_path, env)`, then runs the existing `jwt_auth::resolve_auth_mode_with_lookup` against a closure delegating to that snapshot, then returns both. `validate_startup` is `resolve_startup(...).map(|_| ())` — the literal sharing of code makes drift impossible. + + Function takes `&ResolvedServerSettings` (not just `&ServerAuthSettings`) because validation already depends on `server.web.enabled` and `server.integrations.github.client_id` (`jwt_auth.rs:63`) — narrowing would silently weaken the validation surface. + + `StartupValidationError` *wraps or re-uses the existing error type* returned by `jwt_auth::resolve_auth_mode_with_lookup` plus the secret-loading errors from `ServerSecrets::load`. It must cover the full surface that path rejects today: missing required secret (`SESSION_SECRET`, `FABRO_DEV_TOKEN` when dev-token auth enabled, `GITHUB_APP_CLIENT_SECRET` when GitHub auth enabled), invalid secret value (e.g., malformed `FABRO_DEV_TOKEN`), empty auth methods, GitHub auth configured with `server.web.enabled = false`, missing `server.integrations.github.client_id`. Implementer audits `jwt_auth.rs:67` to enumerate the full variant set; the plan does not invent a narrow new one. + + CLI never sees `ServerSecrets`. `ServerSecrets` and `resolve_startup` stay `pub(crate)`. Only `validate_startup` + `EnvSource` + `ProcessEnv` + `StartupValidationError` cross the crate boundary. +- Modify: `lib/crates/fabro-server/src/lib.rs` — re-export `startup::{validate_startup, EnvSource, ProcessEnv, StartupValidationError}` from the crate root. **Not** `resolve_startup` or `StartupResolution`. +- Modify: `lib/crates/fabro-cli/src/commands/server/start.rs:229-250` — delete `load_or_create_local_session_secret`. Daemon preflight calls `fabro_server::validate_startup(runtime_directory.env_path(), &fabro_server::ProcessEnv, &resolved_settings)`. On `Err`: surface the error to stderr exactly as returned (text comes from the shared error type's `Display`). +- Modify: `lib/crates/fabro-cli/src/commands/server/start.rs:264` — `execute_foreground` does not run a separate validator. The in-process server's `serve_command` calls `resolve_startup` internally and threads the returned `(ServerSecrets, AuthMode)` into `build_app_state` (replacing today's separate `ServerSecrets::with_env_lookup` + `resolve_auth_mode_with_lookup` calls at `serve.rs:594-607`). Single source of truth: foreground's resolution and daemon's preflight share the same internal `resolve_startup`; only the calling surface differs. +- Modify: `lib/crates/fabro-server/src/serve.rs:594-607` — replace today's ad-hoc two-step construction with a single `resolve_startup(...)` call. The returned `ServerSecrets` and `AuthMode` are passed into `build_app_state` (per Unit 1's revised signature). +- Modify: `lib/crates/fabro-cli/src/commands/server/start.rs:350` — `execute_daemon` runs `validate_startup` before spawning the child. +- Test: `tests/it/cmd/server_start.rs` adds missing-secret tests for each required secret across both modes, plus tests demonstrating env-source success, file-source success, and each non-missing-key rejection (empty auth methods, web-disabled with GitHub auth, missing client_id, invalid dev token). A unit test in `fabro-server` asserts `validate_startup` and `resolve_startup` return identical accept/reject decisions for the same inputs. + +**Approach:** +- Required secrets remain *exactly* the current startup-critical set, encoded once in the shared validation: SESSION_SECRET (always), FABRO_DEV_TOKEN (when dev-token auth is enabled), GITHUB_APP_CLIENT_SECRET (when GitHub auth is enabled). GITHUB_APP_PRIVATE_KEY and GITHUB_APP_WEBHOOK_SECRET remain in-scope server secrets but are NOT new universal boot blockers — they continue to surface where they did before (conditional route mounts, per-request errors). +- Daemon preflight and foreground startup run the same validation function with the same `ServerSecrets` snapshot type; "drift" is impossible by construction. + +**Test scenarios:** +- Happy path: required secrets in env → boots (both modes). +- Happy path: required secrets in file → boots (both modes). +- Happy path: env wins when both present. +- Error path (each required secret): missing in both → fail fast naming the secret and both sources (both modes get identical error text because they call the same function). +- Negative regression: post-Unit-1 snapshot is built once; mutating env after boot doesn't change the resolved value. + +**Verification:** +- `fabro server start` against an empty env and uninstalled storage exits non-zero with the same error message in both `--foreground` and daemon modes. +- `fabro server start` with secrets in env (12-factor simulation) boots without any install flow having run. +- `grep -rn 'generate_session_secret' lib/crates/fabro-cli/src/commands/server/` returns no hits. +- `grep -rn 'ServerSecrets\|server_secrets::\|resolve_startup\|StartupResolution' lib/crates/fabro-cli/` returns no hits — CLI never sees `ServerSecrets` or the internal resolution. Only `validate_startup` is reachable from outside `fabro-server`. +- The public secret-related surface from `fabro-server`'s crate root is exactly: `validate_startup`, `EnvSource`, `ProcessEnv`, `StartupValidationError`. Nothing else. +- Foreground startup at `serve.rs:594-607` makes exactly one call to compute `(ServerSecrets, AuthMode)` — `resolve_startup(...)` — not a separate `ServerSecrets::with_env_lookup` followed by `resolve_auth_mode_with_lookup`. Both values are passed into `build_app_state`. +- `build_app_state` no longer constructs `ServerSecrets`; it accepts pre-built `ServerSecrets` and `AuthMode` from the caller. Verified by reading the signature. + +--- + +- [ ] **Unit 5: Shared install orchestration for coordinated `server.env` writes and restart handoff** + +**Goal:** CLI install and web install share a single orchestration layer for `server.env` persistence. Both flows take the same path through generation, persistence, removal of legacy entries, and restart handoff. Web install keeps its existing `/install/finish` restart-handoff contract; CLI install joins the same orchestration. No blanket refuse-while-running policy. + +**Requirements:** R8 + +**Dependencies:** Pairs naturally with Unit 6 (legacy `FABRO_JWT_*` removal hooks into the same orchestration). Either order works. + +**Files:** +- Modify: `lib/crates/fabro-install/src/lib.rs` — establish the shared orchestration entry points. Both CLI install and web install call into these. Orchestration owns: + - input gathering (no `server.env` write, no serialization) + - persistence (atomic `server.env` write via existing `envfile::merge_env_file` at `envfile.rs:204-256`) + - legacy entry removal (Unit 6 hook for `FABRO_JWT_*`) + - restart/handoff (web install via `/install/finish`'s existing restart URL contract; CLI install determines its own handoff — for the in-process case, exit cleanly; for daemon mode, respect today's `fabro server stop` + restart pattern) +- Modify: `lib/crates/fabro-cli/src/commands/install.rs:1864, 1213` — route `server.env` writes through the shared orchestration in `fabro-install`. CLI-specific UX (prompts, progress display) remains in `commands/install.rs`; persistence does not. +- Modify: `lib/crates/fabro-server/src/install.rs:1313, 1343-1353, 1380` — server-side `/install/finish` handlers route through the same orchestration. The existing `/install/finish` API contract (returns restart URL; SPA polls until server returns) is preserved exactly — no `restart_required` flag, no API surface change. +- Test: parity tests in `lib/crates/fabro-install/tests/` prove CLI install and web install take the same persistence/removal path with the same outputs given the same inputs. + +**Approach:** +- The shared orchestration in `fabro-install` is the single chokepoint for `server.env` writes from install flows. Manual edits to `server.env` outside the orchestration still require operator restart discipline (documented). +- Coordinated install flows MAY write while a server is running — they own restart/handoff. Web install already has the handoff via `/install/finish`; CLI install gets the same primitives. +- Two-phase shape: `gather inputs (no serialization)` → `persist + restart-handoff (atomic)`. Serialization window is sub-second regardless of OAuth duration. +- No PID-comparison logic, no refuse-while-running predicate, no new chokepoint helper in `fabro-config`. The earlier `write_server_env_serialized` proposal is dropped. + +**Test scenarios:** +- Parity: identical install inputs produce identical `server.env` contents and identical removed entries via CLI and web paths. +- Happy path (CLI): install on stopped server succeeds; `server.env` updated atomically. +- Happy path (web): install on running server completes via `/install/finish`; restart URL returned; SPA reconnects after restart. +- Legacy removal: install (CLI or web) on a `server.env` containing `FABRO_JWT_*` entries leaves the file without them (Unit 6 hook). +- Negative regression: install-then-start works in the common single-shell CLI sequence. + +**Verification:** +- `cargo nextest run -p fabro-install -p fabro-cli` passes including parity tests. +- `grep -rn 'envfile::write_env_entries\|envfile::merge_env_file' lib/crates/fabro-cli/src lib/crates/fabro-server/src` shows `server.env` writes routed through `fabro-install` orchestration; no direct calls outside it. +- `/install/finish` API contract unchanged (existing OpenAPI schema and SPA reconnect tests pass without modification). + +--- + +- [ ] **Unit 6: Remove legacy `FABRO_JWT_*` drift** + +**Goal:** Stop generating `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY`, stop reading them in diagnostics, remove operator/docs references that describe them as auth inputs, and actively remove existing entries from `server.env` during install flows. SESSION_SECRET is the sole auth master. + +**Requirements:** R9 + +**Dependencies:** None for code shape; the active-removal hook plugs into Unit 5's shared install orchestration. + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/install.rs:1853-1855` — delete `generate_jwt_keypair()` call and the corresponding `("FABRO_JWT_PRIVATE_KEY", ...)` / `("FABRO_JWT_PUBLIC_KEY", ...)` entries from `generated_server_env_pairs`. +- Modify: `lib/crates/fabro-server/src/install.rs` (whichever lines mirror the CLI side, surfaced in research) — same deletion on the web install side. +- Modify: `lib/crates/fabro-install/src/lib.rs` (Unit 5's shared orchestration) — add `FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY` to a `legacy_keys_to_remove` set; the persistence step writes these as removals on every install run. +- Modify: `lib/crates/fabro-server/src/diagnostics.rs:540, 552` — remove the `state.server_secret("FABRO_JWT_PUBLIC_KEY")` and `state.server_secret("FABRO_JWT_PRIVATE_KEY")` checks. Adjust diagnostics output and tests accordingly. +- Modify: **all** operator-facing references to `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY` across `docs/`, `apps/marketing/`, README, and any in-repo runbook. The criterion is "any mention," not "mention as auth input." Concrete known-stale targets: + - `docs/administration/server-configuration.mdx:297` — remove the "future CLI login flows" reference and any surrounding prose treating these as install/runtime secrets. + - `docs/administration/server-configuration.mdx:331` — remove the row(s) from the "Server authentication" table. + - Implementer must also `grep -rn 'FABRO_JWT_PRIVATE_KEY\|FABRO_JWT_PUBLIC_KEY' docs/ apps/ README*` and either delete or rewrite every hit. Surviving mentions should appear only in (a) the new strategy doc's "removed" section or (b) historical plans under `docs/plans/`. +- Modify: install snapshot tests, server.env fixture files, and any insta snapshots that assert on `FABRO_JWT_*` lines — regenerate. +- Test: install run against a `server.env` pre-seeded with `FABRO_JWT_*` entries — assert they are absent after install completes. + +**Approach:** +- Deletion is the entire change. No deprecation period; no compatibility shim. The keys haven't participated in runtime auth since the CLI auth login migration (`2026-04-19-003`). +- Operators upgrading run install once; legacy entries are silently cleaned up. The strategy doc records the cleanup for any operator who notices. + +**Test scenarios:** +- Happy path: install on a fresh storage dir produces a `server.env` with no `FABRO_JWT_*` entries. +- Happy path (cleanup): install on a `server.env` containing `FABRO_JWT_PRIVATE_KEY=...` and `FABRO_JWT_PUBLIC_KEY=...` produces an updated `server.env` without those keys, with other entries preserved. +- Diagnostics: `fabro doctor` (or whichever command surfaces diagnostics) does not mention `FABRO_JWT_*`. + +**Verification:** +- `grep -rn 'FABRO_JWT_PRIVATE_KEY\|FABRO_JWT_PUBLIC_KEY\|generate_jwt_keypair' lib/crates apps/ docs/ README*` returns hits *only* in (a) the new strategy doc's "removed" section, (b) historical plan files under `docs/plans/`. No hits in operator-facing docs (`docs/administration/`, `docs/quickstart/`, marketing) or production crate code. +- `cargo nextest run -p fabro-cli -p fabro-server` passes with regenerated snapshots. +- Mintlify docs build succeeds with the removed entries (no broken anchors/links from other pages that referenced the JWT-key sections). + +--- + +- [ ] **Unit 7: Strategy doc + workspace-wide clippy enforcement** + +**Goal:** Document the design and encode the rules as compile-time enforcement applied to *all* code including tests. + +**Requirements:** R10 + +**Dependencies:** Units 1-6. + +**Files:** +- Create: `docs-internal/server-secrets-strategy.md`. +- Modify: `clippy.toml` — add `std::env::set_var` and `std::env::remove_var` to `disallowed-methods` with reason text pointing at the strategy doc. The ban is workspace-wide and applies to tests as well as production code. +- Modify: `lib/crates/fabro-telemetry/src/spawn.rs:73-76` — add `#[expect(clippy::disallowed_methods, reason = "post-fork pre-execvp env mutation; safe because grandchild is single-threaded and about to be replaced via exec")]`. +- Modify: any other production caller surfaced during implementation (the count is small per Unit 1's grep) — same `#[expect]` pattern with documented reason. +- Modify: `CLAUDE.md` (and `AGENTS.md` if separate) — add "Strategy docs" entry pointing at the new doc. + +**Approach:** + +Strategy doc covers: +- **`ServerSecrets` is the resolved-secrets API.** Sources are process env (12-factor) and `/server.env` (install/local convenience). Snapshotted at boot via `EnvSource` trait that materializes immediately into owned `HashMap`. Env wins on conflict. +- **The five active server-level secrets** and their consumers (table from this plan). `FABRO_JWT_*` is removed (Unit 6); SESSION_SECRET is the sole auth master via HKDF. +- **Provisioning paths:** CLI install, web install (shared orchestration in `fabro-install`), or platform env. Server start does not auto-generate. +- **Tests must not mutate process env.** Enforced workspace-wide by clippy. Tests inject via construction-time `EnvSource` stubs (e.g. `StubEnv`) for in-process resolution, or via explicit child-process `Command::env` for subprocess simulation. +- **Worker and render-graph env:** `env_clear` + strict fail-closed allowlist. Daemon child inherits parent env (12-factor pattern). New worker env entries require demonstrated need + intentional addition. +- **Install while running:** allowed only through the shared install orchestration which owns restart/handoff. Manual `server.env` edits still require restart discipline. +- **Rotation:** restart required. Live rotation intentionally not supported. Compliance-driven N+1 rotation (overlap windows) is a known limitation tracked as follow-up. +- **Out of scope (with reasons):** Vault + `ProviderCredentials`, `vault_or_env` for run-level credentials (different track), `{{ env.FOO }}` config interpolation, workflow-stage env (Sandbox's job), `validate_api_key` `set_var` smell, Tailscale spawns, `bun --watch-web`. +- **Adding a new server-level secret:** (1) provision via the install orchestration or platform env; (2) consume via `state.server_secret(...)`; (3) do not touch env in any other layer; (4) decide if it joins the startup-critical set (most don't). +- **Adding a new worker env var:** add to the worker list in `spawn_env.rs` with a one-line reason and a failing-without test that proves need. + +**Verification:** +- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` passes. +- Adding a new `std::env::set_var(...)` in any production OR test file produces a clippy denial that names the strategy doc. + +## System-Wide Impact + +- **Interaction graph:** all active server-level secrets (the five remaining after Unit 6) converge through `state.server_secret(name)` after Unit 1, which returns from the env+file snapshot. `resolve_auth_mode_with_lookup` migrates in the same unit. Legacy `FABRO_JWT_*` is removed across install, diagnostics, and docs (Unit 6). +- **Error propagation:** daemon preflight (`validate_startup`) and foreground startup (`resolve_startup`, called from `serve_command`) both delegate to the same shared validation logic introduced in Unit 4 — the public preflight wrapper is literally `resolve_startup(...).map(|_| ())`, so error messages and accept/reject decisions are identical by construction. Unit 1 provides only the snapshot machinery (`ServerSecrets` + `EnvSource`) that Unit 4's validation consumes. Object-store credential failures still surface from the AWS SDK / object_store crate (unchanged — daemon inherits AWS env). +- **State lifecycle:** `ServerSecrets` snapshot built once at boot from env+file; both immutable for process lifetime. Rotating any in-scope secret requires restart. Install flows MAY update `server.env` while a server is running when they own restart/handoff via the shared install orchestration (Unit 5). +- **Subprocess env:** workers and render-graph processes inherit only an explicit fail-closed allowlist. Daemon child inherits parent env — that's how 12-factor SESSION_SECRET reaches the daemon. +- **API surface:** no public API change. `state.server_secret(...)` signature unchanged. `ServerSecrets::with_env_lookup` removed; replaced by `load(path, &dyn EnvSource)`. `/install/finish` API contract unchanged (no `restart_required` flag, no new fields). +- **Unchanged invariants:** `ProviderCredentials`, Vault REST API, `vault_or_env` for run-level credentials, `{{ env.FOO }}` interpolation, workflow stages (configured by `Sandbox`) all behave exactly as before. + +## Risks & Dependencies + +| Risk | Mitigation | +|---|---| +| Snapshot semantics surprise: operator changes env after boot, expects the running server to pick it up | Documented behavior. Live rotation is intentionally not supported; restart required. Strategy doc is explicit. | +| Worker list is missing something a workflow stage runner inside Sandbox needs | Worker process itself only does HTTP callbacks — Sandbox handles stage env. Real workflow integration test in Unit 3 verification catches false negatives. | +| Existing deployments without `server.env` AND without env-set secrets fail to start | Intended behavior; error message names both sources. Per repo policy, accept the breakage. | +| Test migration: replacing `std::env::set_var` calls with `EnvSource` stubs touches many files | `EnvSource` + `StubEnv` pattern is mechanical; one helper per pattern. Unit 7's clippy enforcement catches stragglers at compile time so nothing slips through. | +| Shared install orchestration regression breaks CLI/web parity | Parity tests in Unit 5 explicitly assert identical persistence behavior across CLI and web paths. Both flows route through the same `fabro-install` entry points. | +| Automatic `FABRO_JWT_*` removal surprises operators who believed those keys were authoritative | Strategy doc names the cleanup explicitly. Keys haven't participated in runtime auth since `2026-04-19-003`; cleanup is overdue, not novel. Operators upgrading run install once and the cleanup happens silently. | +| Rust 2024 edition migration is a separate effort | Removing `set_var` is a prerequisite; this work doesn't gate on the edition migration. Workspace-wide clippy enforcement (Unit 7) prevents reintroduction including in tests. | + +## Documentation / Operational Notes + +- **Operator-facing change:** `fabro server start` against an empty env AND uninstalled storage now fails fast naming both sources. Document in install/quickstart. +- **12-factor PaaS deployments (Railway, Heroku, Fly):** unchanged ergonomics — set secrets in platform env, run `fabro server start`. Works without `fabro install` having run on the platform. +- **Container/k8s deployments:** if secrets are mounted as files (e.g. via projected volume into `/server.env`) or set as env vars (k8s Secret → env), both work. +- **Cloud object-store (S3 / IRSA / ECS):** unchanged — daemon inherits AWS env from parent, object-store layer reads ambient credentials. +- **Install while server running:** install flows may update `server.env` on a running server only when they coordinate restart/handoff via the shared install orchestration (CLI install or web install via `/install/finish`). Manual edits to `server.env` outside the orchestration still require restart discipline. +- **`FABRO_JWT_*` removal:** `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY` are removed from the runtime auth model; SESSION_SECRET is the sole auth master for cookie and JWT derivation. Existing legacy entries are cleaned up automatically on subsequent install flows. +- **Rotation:** edit env (and restart) or edit `server.env` (and restart). Live rotation not supported. +- **Logging:** the fail-fast error appears in operator log aggregators. Pair human-readable message with a structured error code (e.g., `error_code=missing_session_secret`) so log searches match without depending on the exact string. + +## Sources & References + +- Related plans: + - `docs/plans/2026-04-05-server-canonical-secrets-doctor-repo-plan.md` — architectural anchor + - `docs/plans/2026-04-19-003-feat-cli-auth-login-plan.md` — SESSION_SECRET as HKDF master + - `docs/plans/2026-04-18-001-feat-webhook-strategy-plan.md` — webhook secret precedent + - `docs/plans/2026-04-02-001-feat-server-daemon-management-plan.md` — origin of daemon/foreground split +- Related commits: + - `7cb6c65d5` — "Remove dev-token minting from server start" (precedent for Unit 4) +- Strategy doc precedent: `docs-internal/logging-strategy.md`, `docs-internal/events-strategy.md` diff --git a/docs/plans/2026-04-22-004-refactor-worker-jwt-auth-plan.md b/docs/plans/2026-04-22-004-refactor-worker-jwt-auth-plan.md new file mode 100644 index 000000000..6d8535029 --- /dev/null +++ b/docs/plans/2026-04-22-004-refactor-worker-jwt-auth-plan.md @@ -0,0 +1,570 @@ +--- +title: "refactor: Server-issued per-run JWT for worker subprocess auth" +type: refactor +status: completed +date: 2026-04-22 +deepened: 2026-04-22 +--- + +# refactor: Server-issued per-run JWT for worker subprocess auth + +## Overview + +Server mints one per-run JWT (HS256, 72h, claims include `run_id`) at every worker subprocess spawn, injects it into the worker env as `FABRO_WORKER_TOKEN`, and worker-touched run-scoped routes accept it. Worker stops reading `~/.fabro/auth.json`. The artifact-upload-token mechanism is deleted entirely (greenfield — no external consumers, no shim required). End-user auth (dev-token / github) is now strictly orthogonal to worker auth. + +## Problem Frame + +Workers POST back to the server (events, state, blobs, stage artifacts). Today the worker only authenticates because it inherits the CLI user's OAuth session from `~/.fabro/auth.json` (`fabro-cli/src/server_client.rs:312` → `AuthStore::default()` at `fabro-client/src/auth_store.rs:107`). Side effects: + +- (a) worker authenticates *as the user* — events emitted by the worker get user identity, not "system"; +- (b) any deployment where the worker doesn't share a home dir with an authenticated CLI user (containerized server, `fabro` system user, remote worker, multi-tenant) silently fails; +- (c) worker auth is implicitly coupled to end-user auth strategy when conceptually independent. + +GitHub-only install (`auth.methods = ["github"]`) writes no `FABRO_DEV_TOKEN` and `worker_command` (`server.rs:3740-3750`) injects nothing — the worker has no documented credential at all. Only the home-dir steal makes it work. + +The artifact-upload-token mechanism (`server.rs:752`, `server.rs:846`) already proves the right pattern for one route. Generalize it to every worker-touched route. + +## Requirements Trace + +- R1. Worker subprocess authenticates to server with a credential the server explicitly issued, not one stolen from the user's home directory. +- R2. Credential is per-run (claim `run_id` must match path `run_id`); cross-run reuse rejected. +- R3. Credential survives server restart up to its natural 72h expiry (no operational events shortening the ceiling). +- R4. Both `start` and `resume` spawn paths re-mint a fresh 72h credential. +- R5. Worker auth works in any deployment topology, including GitHub-only installs with no `~/.fabro/auth.json` on the worker host. +- R6. Worker-emitted events stamped as a system principal (`system:worker`); originator user identity remains discoverable on the run record. +- R7. Worker-touched run-scoped routes still accept end-user JWTs for non-worker callers (CLI, web UI) — fall-through, not replacement. + +## Scope Boundaries + +- **Greenfield context:** no shipped deployments; no need to preserve in-flight workers across the change. Atomic swap. No backwards-compat shim. No deprecation cycle. +- Out: refresh tokens for workers (decided: hard 72h ceiling per spawn, fresh mint on resume). +- Out: in-memory or persisted revocation list. Per-run binding + 72h `exp` is the entire blast-radius bound. +- Out: multi-host / remote worker spawning (this plan makes it *possible*, doesn't deliver it). +- Out: changes to end-user auth methods (`ServerAuthMethod::DevToken | Github`). +- Out: any new `RunAuthMethod` variant — worker token bypasses `AuthenticatedSubject` entirely. +- Out: extending `RunSummary` with provenance — originator already on `RunSpec.provenance.subject` and that's enough. +- Out: SSE attach routes (`/runs/{id}/attach`, `/attach`). Worker is a producer, not a consumer; never calls them. Stay user-JWT-only. +- Out: lifecycle/admin/user-action routes (`/runs/{id}/cancel`, `/pause`, `/unpause`, `/archive`, `/unarchive`, `DELETE /runs/{id}`, `submit_answer`, `start_run`, `create_run`, list endpoints). Worker token explicitly rejected on these — they remain user-JWT-only. +- Out: any `Display` impl on `Credential::Worker` payload. Plan keeps redacted `Debug` only; do not add `Display`. +- Out: distinct exit codes for auth failure. Worker bails clearly at startup if env is missing; server 401/403 mid-run flows through normal client error handling. +- Out: blanket env scrubbing of trusted internal subprocesses (`gh auth token`, MCP servers, devcontainer setup, git). These may legitimately need credentials. Scrubbing scope: workflow/sandbox stage-execution chokepoint (`LocalSandbox::execute`) AND host-mode hooks (defense-in-depth; shell commands have no business reading the worker token). + +## Threat Model + +State the assumptions explicitly so reviewers and operators can challenge them. + +- **Trust boundary:** the server process and any worker subprocess running under the same OS user are mutually trusted. Same-UID attackers (or a workflow stage that compromises the worker process) can read `FABRO_WORKER_TOKEN` from `/proc//environ` on Linux. Multi-tenant deployments must isolate per-tenant via separate UIDs / containers / namespaces. Cross-run isolation between same-UID workers is NOT a property of this design. +- **`SESSION_SECRET` is the master key.** It signs both user JWTs and worker JWTs (via distinct HKDF context labels). Any leak vector — backup including `server.env`, env dump in logs, ECS task definition exposure, accidental commit, breadcrumb capture — gives the attacker the ability to mint tokens of either kind for any user / any run. Operational guidance: store `SESSION_SECRET` in a secrets manager, exclude from logs/Sentry, document a rotation procedure (rotation invalidates ALL outstanding worker tokens AND ALL user sessions — accept as the cost of compromise response). +- **Worker token compromise:** an attacker who exfiltrates a single worker token gains read/write on that one run's events/blobs/state for up to 72h. Run-id binding limits cross-run damage. There is no in-product revocation; rotating `SESSION_SECRET` is the only mechanism to invalidate outstanding worker tokens. + +## Context & Research + +### Relevant Code and Patterns + +- `lib/crates/fabro-server/src/server.rs:287-289, 752-812, 813-854` — artifact-upload-token: claims struct, key generation (`OsRng` per boot), mint, "service token first, else user JWT" check (`authorize_artifact_upload`). Generalize the shape to all worker-touched routes; replace this mechanism wholesale. +- `lib/crates/fabro-server/src/server.rs:3701-3755` — `worker_command`: single spawn site for both `start` and `resume`. Already passes `--artifact-upload-token` via argv (deleted in Unit 3) and calls `apply_worker_env` at `server.rs:3739`. Add `cmd.env("FABRO_WORKER_TOKEN", token)`. +- `lib/crates/fabro-server/src/server.rs:4666` — `execute_run_subprocess` calls `worker_command` once per spawn; `RunExecutionMode` flows from `start_run` (`server.rs:4181`) and `create_run` (`server.rs:4011`). Single mint site covers both modes. +- `lib/crates/fabro-server/src/spawn_env.rs:18` — existing `apply_worker_env` does `env_clear` + 8-name allowlist (PATH, HOME, TMPDIR, USER, RUST_LOG, RUST_BACKTRACE, FABRO_HOME, FABRO_STORAGE_ROOT). `SESSION_SECRET`, `FABRO_JWT_*`, `GITHUB_APP_*` are all already excluded. Existing `worker_allowlist_is_fail_closed` test (`spawn_env.rs:64-99`) asserts `SESSION_SECRET` is stripped. +- `lib/crates/fabro-server/src/auth/keys.rs:41` — existing HKDF helper `derive_jwt_key` for the user-JWT key. Mirror for worker JWT with distinct context label `b"fabro-worker-jwt-v1"` so worker keys survive server restarts (R3). +- `lib/crates/fabro-cli/src/commands/run/runner.rs:55-127` — `__run-worker` entry, `HttpRunStore`, `HttpArtifactUploader`. Only seven server endpoints touched (catalogued below). +- `lib/crates/fabro-cli/src/server_client.rs:51-58, 133, 312` — `connect_server_target_direct` → `connect_target_api_client_bundle` → `resolve_target_credential` → `AuthStore::default()`. Sole worker caller is `runner.rs:67`. Replaced for the worker only via a sibling constructor. +- `lib/crates/fabro-client/src/credential.rs:6-30` — `Credential` enum. Add `Worker(String)` variant; `bearer_token()` returns the string. +- `lib/crates/fabro-types/src/run_event/mod.rs:29-81` — `ActorRef`/`ActorKind { User | Agent | System }`. No new variant needed — stamp worker events with `ActorKind::System`. +- `lib/crates/fabro-types/src/run.rs:34-49` — `RunProvenance`/`RunSubjectProvenance` already on `RunSpec`. Originator preserved at run-creation time; no schema change. +- `lib/crates/fabro-sandbox/src/local.rs:43-66, 221` — `LocalSandbox::execute` does `env_clear` + `should_filter_env_var` heuristic for stage commands. The `_token` suffix filter incidentally catches `FABRO_WORKER_TOKEN`; make it explicit (denylist entry). + +### Worker → server endpoint surface + +These are the **only** routes that gain worker-token acceptance. Lifecycle/admin/list endpoints stay user-JWT-only (see Scope Boundaries). + +| Worker call | HTTP | Path | Server handler | Auth today | +|---|---|---|---|---| +| `client.get_run_state` | GET | `/runs/{id}/state` | `get_run_state` (`server.rs:5076`) | `AuthenticatedService` | +| `client.list_run_events` | GET | `/runs/{id}/events` | `list_run_events` (`server.rs:5146`) | `AuthenticatedService` | +| `client.append_run_event` | POST | `/runs/{id}/events` | `append_run_event` (`server.rs:5096`) | `AuthenticatedService` | +| `client.write_run_blob` | POST | `/runs/{id}/blobs` | `write_run_blob` (`server.rs:5352`) | `AuthenticatedService` | +| `client.read_run_blob` | GET | `/runs/{id}/blobs/{blobId}` | `read_run_blob` (`server.rs:5379`) | `AuthenticatedService` | +| `client.upload_stage_artifact_file` | POST | `/runs/{id}/stages/{stageId}/artifacts` (octet-stream) | `put_stage_artifact` (`server.rs:5838`) | `authorize_artifact_upload` | +| `client.upload_stage_artifact_batch` | POST | same path (multipart) | same handler | same | + +### Coordination with concurrent plans + +- `docs/plans/2026-04-22-003-refactor-lock-down-server-secrets-plan.md` partially landed: `apply_worker_env` exists at `lib/crates/fabro-server/src/spawn_env.rs:18` and is invoked from `worker_command` at `server.rs:3739`. The allowlist excludes server-only secrets, structurally preventing the worker from inheriting `SESSION_SECRET`. This plan adds the `FABRO_WORKER_TOKEN` re-injection alongside the existing `FABRO_DEV_TOKEN` re-injection (and ultimately replaces the latter). +- `docs/plans/2026-04-19-003-feat-cli-auth-login-plan.md` Unit 8 created `lib/crates/fabro-server/src/auth/jwt.rs` (`Claims`, `issue`, `verify`, `JwtError`) and `auth/keys.rs::derive_jwt_key`. Reuse the HKDF derivation pattern (distinct context label) and the `jsonwebtoken` primitives directly — do not route worker-token claims through user-`JwtSubject`. +- `docs/plans/2026-04-20-001-fix-cli-server-same-host-assumptions-plan.md` deliberately closed the "trust local files because same host" pattern. This plan preserves that closure — no new same-host exceptions; the worker uses an explicitly-passed token. + +### Institutional Learnings + +- No `docs/solutions/` directory exists. Prior decisions live in `docs/plans/`. +- Artifact-upload-token TTL precedent is 24h. New worker-token TTL is 72h — justified because worker tokens must survive long human-in-the-loop pauses with no in-process refresh. + +## Key Technical Decisions + +| Decision | Rationale | +|---|---| +| Replace artifact-upload-token entirely; one JWT per run covers all worker-touched run-scoped routes | Two parallel per-run JWTs is bookkeeping. Run-id binding gives the same blast-radius constraint without a separate scope. Greenfield → atomic delete, no shim. | +| Pass JWT to worker via env var `FABRO_WORKER_TOKEN`. **Env-only — never argv.** | Symmetric with existing `FABRO_DEV_TOKEN` re-injection model. Plays naturally with `env_clear` + explicit re-injection in `apply_worker_env`. Avoids token strings in `ps` output. | +| HS256, key derived from `SESSION_SECRET` via HKDF (context `b"fabro-worker-jwt-v1"`) | Workers must survive server restarts up to natural 72h expiry (R3). `OsRng`-per-boot defeats the long TTL. Distinct context label keeps it isolated from the user-JWT key. Operator rotation of `SESSION_SECRET` invalidates outstanding worker tokens — accepted (and is the only revocation mechanism). | +| 72h TTL, no refresh, no revocation list | Long-paused runs need a generous outer ceiling. Resume re-mints. Workers running > 72h continuously fail loudly — acceptable outer bound. Adding revocation requires persistent state and creates restart-window contradictions; not worth the complexity for the current threat model. | +| Per-run `run_id` claim, path-vs-claim check | Mirrors `maybe_authorize_artifact_upload_token`. Cross-run reuse → 403. | +| Add `Credential::Worker(String)` variant (not reuse `DevToken`) | Debug printing stays accurate. No `Display` impl — compile-time hardening prevents accidental `format!`-leaks. | +| New worker-only client constructor `connect_server_target_with_bearer(target, token)`, bypasses `AuthStore`/`OAuthSession` entirely | Worker should never read user OAuth. Surgical to fix at the worker callsite (one caller, `runner.rs:67`) rather than gating `resolve_target_credential` with a "are you a worker" flag. | +| Stamp `system:worker` actor in a worker-side sink wrapper inside `RunEventSink::fanout`, NOT in `to_run_event_at` | `to_run_event_at` and `stored_event_fields` are shared with the server (server flushes lifecycle events through `workflow_event::to_run_event` at `server.rs:6702`). Default-filling there mis-stamps server-emitted events. The wrapper is worker-local. | +| Route API is a set of typed `FromRequestParts` extractors (`AuthorizeRunScoped`, `AuthorizeRunBlob`, `AuthorizeStageArtifact`), NOT a bare helper function | Composes with existing `Json<...>` / `Bytes` body extractors (a `Parts`-taking helper would force full-`Request` extraction everywhere and break body handling). Each extractor returns the already-parsed run-id (and secondary path params) so handlers drop their own `Path` + `parse_*` dance. Replaces `_auth: AuthenticatedService` and the existing `authorize_artifact_upload` inline call. One fall-through behavior (worker token first, else user JWT) shared across all three. `authorize_worker_token` remains a `pub(crate)` internal helper used by the extractors. | +| Env scrubbing at two sites: `LocalSandbox::execute` (stage execution) and host-mode hooks | Stage commands run user-supplied code → MUST NOT see `FABRO_WORKER_TOKEN`. `LocalSandbox::execute` filters both inherited env AND the explicit `env_vars` extras path (today's code appends extras AFTER the filter — defense-in-depth gap this plan closes). Host-mode hooks get a targeted `env_remove("FABRO_WORKER_TOKEN")` (shell commands have no business reading the worker token, even when operator-configured). Trusted internal subprocesses (`gh auth token`, MCP server stdio, devcontainer setup, git) are NOT scrubbed — they may legitimately need credentials, and they aren't user-attack surfaces. | +| `authorize_worker_token` lives in `worker_token.rs` and takes `&WorkerTokenKeys` directly (NOT `&AppState`) | Sibling modules can't access private `AppState` fields. Mirroring `maybe_authorize_artifact_upload_token`'s signature (which already takes the typed keys) keeps the helper testable without a fixture `AppState`. The thin `authorize_run_scoped(parts, state, run_id)` adapter lives where it can see `AppState` and pulls `&state.worker_tokens` into the call. | +| Missing/invalid `FABRO_WORKER_TOKEN` → worker errors at startup with a clear message; mid-run 401/403 flow through normal client error handling | No special exit codes. Distinct operational telemetry isn't worth the machinery for the current scale. | + +### Worker-token vs artifact-upload-token (delta) + +| Property | Artifact-upload-token (today) | Worker-token (new) | +|---|---|---| +| Coverage | One route (`/runs/{id}/stages/{stageId}/artifacts`) | All 7 worker-touched run-scoped routes | +| TTL | 24h | 72h | +| Signing key | `OsRng` at server boot, in-memory only | HKDF from `SESSION_SECRET`, context `b"fabro-worker-jwt-v1"` | +| Survives server restart | No | Yes (up to natural expiry) | +| Issuer string | `"fabro-server-artifact-upload"` | `"fabro-server-worker"` | +| Scope claim | `"stage_artifacts:upload"` | `"run:worker"` | +| Passed to worker | `--artifact-upload-token` argv | `FABRO_WORKER_TOKEN` env var | +| Worker uses it as | Per-call method arg on the client | Client's bearer for every server call | + +## Open Questions + +### Resolved During Planning + +- TTL: 72h. Refresh: none in-process; fresh mint at every spawn (start AND resume). +- Key derivation: HKDF from `SESSION_SECRET` with context `b"fabro-worker-jwt-v1"`. +- Replace artifact-upload-token entirely vs. keep both: replace. +- Token transport: env var (`FABRO_WORKER_TOKEN`), not argv. +- Revocation: none. Per-run binding + 72h `exp` is the entire blast-radius bound. +- New `RunAuthMethod::Worker` variant: no — worker token bypasses `AuthenticatedSubject` entirely. +- Stamp worker events server-side vs. worker-side: worker-side, in a dedicated sink wrapper inside the worker's `RunEventSink::fanout` chain. +- Multi-token-per-run on rapid pause/resume: accept and document. Each prior token remains valid up to 72h `exp`. Bounded by run-id; out-of-scope to fix here. +- Env scrubbing scope: workflow stage-execution chokepoint at `LocalSandbox::execute` (inherited env + explicit `env_vars` extras) AND host-mode hooks at `fabro-hooks/src/executor.rs`. Trusted internal subprocesses (`gh auth token`, MCP stdio, devcontainer features, git) are not scrubbed. +- Auth-failure exit codes: no — generic error handling. + +### Deferred to Implementation + +- Exact module name for new server-side worker-token machinery — likely `fabro-server/src/worker_token.rs`. +- Mechanism for the compile-time "no `Display` for `Credential::Worker`" guard — `static_assertions::assert_not_impl_any!` is the natural fit; choose at implementation time. +- Whether to enforce "worker module never imports `AuthStore`" structurally (clippy `disallowed_types` on the `commands::run` module). Nice-to-have; defer. +- Core dump disable (`setrlimit(RLIMIT_CORE, 0)`) on the worker process. Same-UID attacker assumption holds today; defer. + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +```mermaid +sequenceDiagram + participant Op as Operator + participant Srv as fabro-server + participant W as worker subprocess + participant Child as sandbox stage child + + Op->>Srv: start (SESSION_SECRET in env) + Note over Srv: HKDF-derive WorkerTokenKeys
context "fabro-worker-jwt-v1" + Srv->>Srv: spawn scheduled (start or resume) + Note over Srv: issue_worker_token(run_id)
HS256 + claims{run_id, scope, 72h} + Srv->>W: spawn with apply_worker_env (env_clear + allowlist)
+ FABRO_WORKER_TOKEN injected + W->>W: read FABRO_WORKER_TOKEN from env
build Client with Credential::Worker(token)
(no AuthStore, no OAuthSession) + W->>Srv: POST /runs/{id}/events (Bearer ...) + Srv->>Srv: authorize_run_scoped:
1) try worker token (run_id match + exp check)
2) else fall through to user-JWT extractor + Srv-->>W: 200 OK + W->>Child: spawn stage command via LocalSandbox::execute
(env_clear + safelist; FABRO_WORKER_TOKEN excluded) + Child-->>W: result (no token in env) + Note over W,Srv: ... run completes ... + Note over Srv: server restart: HKDF re-derives same key,
outstanding tokens still verify (up to natural exp) +``` + +## Implementation Units + +- [x] **Unit 1: Worker JWT primitives (claims, keys, mint)** + +**Goal:** Server can mint a per-run worker JWT signed with a key derived from `SESSION_SECRET`. No callers yet. + +**Requirements:** R2, R3. + +**Dependencies:** None. + +**Files:** +- Create: `lib/crates/fabro-server/src/worker_token.rs` +- Modify: `lib/crates/fabro-server/src/auth/keys.rs` (add `derive_worker_jwt_key`) +- Modify: `lib/crates/fabro-server/src/lib.rs` (module declaration) +- Modify: `lib/crates/fabro-server/src/server.rs` (`AppState` field for `WorkerTokenKeys`, construction in `build_app_state`) +- Test: `lib/crates/fabro-server/src/worker_token.rs` (unit tests inline) + +**Approach:** +- Constants: `WORKER_TOKEN_ISSUER = "fabro-server-worker"`, `WORKER_TOKEN_SCOPE = "run:worker"`, `WORKER_TOKEN_TTL_SECS = 72 * 60 * 60`. +- `WorkerTokenClaims { iss, iat, exp, run_id, scope, jti }` — `jti` (random 128-bit hex) enables audit correlation in logs without exposing the token. +- `WorkerTokenKeys { encoding, decoding, validation }` — built from a 32-byte HKDF output keyed by `SESSION_SECRET`, context `b"fabro-worker-jwt-v1"`. +- `pub fn issue_worker_token(keys: &WorkerTokenKeys, run_id: &RunId) -> Result` — `jsonwebtoken::encode` with HS256. +- `pub(crate) fn derive_worker_jwt_key(secret: &[u8]) -> Result<[u8; 32], KeyDeriveError>` in `auth/keys.rs` — same HKDF construction as `derive_jwt_key`, distinct `info` parameter (`b"fabro-worker-jwt-v1"`). Mirrors the existing helper's error shape so the `KeyDeriveError` cases (empty / too-short secret) propagate identically. +- **App-state construction wires it explicitly**: `build_app_state` resolves `SESSION_SECRET` (already required for the user-JWT key today), calls `derive_worker_jwt_key`, and bails with a clear startup error if it fails. Failure modes: missing `SESSION_SECRET`, secret too short. Add `worker_tokens: WorkerTokenKeys` field on `AppState` next to `artifact_upload_tokens` (the artifact field is deleted in Unit 3). +- **Test app-state builders** (`worker_command_test_state` at `server.rs:7868` and any other test fixture that constructs `AppState`) must supply a fixture `SESSION_SECRET`. The existing test secret used by user-JWT tests can be reused. + +**Patterns to follow:** +- `server.rs:287-289, 319-326, 752-773, 798-812` (artifact-upload-token, end-to-end). +- `auth/keys.rs:41` (`derive_jwt_key` HKDF construction). + +**Test scenarios:** +- Happy path: `issue_worker_token` produces a token; `jsonwebtoken::decode` with the same `WorkerTokenKeys` returns the expected `WorkerTokenClaims` (iss, scope, run_id, jti). +- Edge case: token issued with `WorkerTokenKeys` derived from secret S verifies under a *fresh* `WorkerTokenKeys` derived from the same S — proves restart survival (R3). +- Edge case: token issued under secret S1 fails to verify under keys derived from secret S2 (rotation invalidation). +- Edge case: derivation context label `b"fabro-worker-jwt-v1"` produces a key materially different from `derive_jwt_key(secret)` (no accidental cross-acceptance with user JWTs). +- Error path: `derive_worker_jwt_key(b"")` returns `Err(KeyDeriveError::Empty)`. Mirrors existing `derive_jwt_key` error shape. +- Error path: `derive_worker_jwt_key(short_secret)` returns `Err(KeyDeriveError::TooShort { .. })` for secrets below the minimum length. +- Startup: `build_app_state` with no `SESSION_SECRET` in env returns a startup error matching the existing user-JWT-key startup-error wording. + +**Verification:** +- All worker-token unit tests pass. +- `cargo build -p fabro-server` succeeds. +- No production callers of new symbols yet — Unit 1 is purely additive infrastructure. + +--- + +- [x] **Unit 2: Server `AuthorizeRunScoped` extractor family + client `Credential::Worker` variant** + +**Goal:** Three typed `FromRequestParts` extractors (`AuthorizeRunScoped`, `AuthorizeRunBlob`, `AuthorizeStageArtifact`) accept worker token (run-id-bound) OR fall back to user JWT. Client crate gains a typed worker credential with no `Display` and redacted `Debug`. + +**Requirements:** R2, R7. + +**Dependencies:** Unit 1. + +**Files:** +- Modify: `lib/crates/fabro-server/src/worker_token.rs` (add `pub(crate) fn authorize_worker_token` internal helper; add the three public extractors `AuthorizeRunScoped`, `AuthorizeRunBlob`, `AuthorizeStageArtifact` with `FromRequestParts` impls) +- Modify: `lib/crates/fabro-server/src/lib.rs` (re-export the three extractors if needed by handler modules) +- Modify: `lib/crates/fabro-client/src/credential.rs` (add `Worker(String)` variant — no `Display`) +- Test: `lib/crates/fabro-server/src/worker_token.rs` (authorize helper tests inline) +- Test: `lib/crates/fabro-client/src/credential.rs` (Debug + bearer_token tests inline) + +**Approach:** +- **Module placement & visibility**: `worker_token.rs` is a sibling module to `server.rs`; sibling modules cannot read private `AppState` fields. Two options: (a) keep the helper inside `impl AppState` in `server.rs` like `issue_artifact_upload_token` does today, or (b) put the helper in `worker_token.rs` and pass `&WorkerTokenKeys` directly (NOT `&AppState`). **Choose (b)** — keeps `AppState` internals private, makes the helper trivially testable without an `AppState` fixture, mirrors how `maybe_authorize_artifact_upload_token` already takes `&ArtifactUploadTokenKeys` not `&AppState`. The thin glue in `authorize_run_scoped` then takes `&AppState` and pulls `&state.worker_tokens` into the call. +- `pub(crate) fn authorize_worker_token(parts: &Parts, run_id: &RunId, keys: &WorkerTokenKeys) -> Result` — mirror `maybe_authorize_artifact_upload_token` (`server.rs:813-844`). Verification-first rule (no unverified claim peeking — `jsonwebtoken::decode` only returns claims after signature + expiry validation): + - Bearer absent → `Ok(false)` silently. + - `jsonwebtoken::decode` with `WorkerTokenKeys` returns `Err(_)` (any reason — bad signature, expired, malformed, alg mismatch) → `Ok(false)` silently. Could be a user JWT in fall-through, an expired worker token, or anything else; we don't know without verifying, and we don't peek at unverified payload bytes. + - `decode` returns `Ok(claims)` AND `claims.scope != WORKER_TOKEN_SCOPE` → `Err(ApiError::forbidden())` + `tracing::warn!`. A token signed by us with a wrong scope is a misuse. + - `decode` returns `Ok(claims)` AND `claims.run_id != run_id` → `Err(ApiError::forbidden())` + `tracing::warn!`. Cross-run reuse. + - `decode` returns `Ok(claims)` AND scope + run_id match → `Ok(true)` + `tracing::info!`. +- **Extractor body (shared logic)**: each `FromRequestParts` impl runs its path-parse step, then calls a shared `pub(crate)` helper that mirrors `authorize_artifact_upload` (`server.rs:846-854`): try `authorize_worker_token(parts, run_id, &state.worker_tokens)?`; if `Ok(false)`, fall through to `authenticate_service_parts(parts)`. `worker_tokens` stays `pub(crate)` on `AppState` so the helper in the same crate can read it. +- `Credential::Worker(String)` — `bearer_token() -> &str` returns the string; `Debug` prints `Credential::Worker()`. **Do NOT implement `Display` on the `Credential` enum.** Compile-time hardening: assert `Credential: !Display` (variant-level assertions don't exist in Rust — the trait impl lives on the type). +- **Audit logging at authorize time** (driven entirely by the verified-only rule above — no log if `decode` itself fails): + - On successful worker-token auth: `tracing::info!(target = "worker_auth", run_id = %run_id, jti = %claims.jti, "worker token accepted")`. + - On `Ok(claims)` with scope or run_id mismatch: `tracing::warn!(target = "worker_auth", reason = ..., jti = %claims.jti, "worker token rejected")`. + - On `Err(_)` from `decode` (no claims available): silent. The bearer might be a user JWT in fall-through, an expired worker token, or garbage — we can't tell without verifying, and we don't try. + - Never log the token string itself — only `jti`. + +**Patterns to follow:** +- `server.rs:813-854` (artifact-upload-token authorize pattern). +- `fabro-client/src/credential.rs:6-40` (existing `DevToken`/`OAuth` variants). + +**Test scenarios:** +- Happy path (server, unit): valid worker token for path run_id → `AuthorizeRunScoped` extractor succeeds; handler receives the parsed `RunId`. +- Error path (server): worker token with `claims.run_id != path.run_id` → 403 + `worker_auth` warn with `jti`. +- Error path (server): worker token signed with worker key but wrong scope → 403 + `worker_auth` warn. +- Error path (server): expired worker token → `decode` returns `Err` → silent fall-through → user-JWT extractor rejects → 401. No `worker_auth` log. +- Error path (server): bad signature (e.g. token signed with different key) → `decode` returns `Err` → silent fall-through → 401. No `worker_auth` log. +- Error path (server): `alg=none` JWT → `decode` returns `Err` (validation requires HS256) → silent fall-through → 401. +- Integration (server): no `Authorization` header → falls through to user-JWT extractor → 401 (no implicit acceptance). +- Integration (server): valid user JWT, no worker token → user-JWT path accepts (R7). +- Audit (precision): successful worker-token auth emits `target = "worker_auth"` info span with `run_id` and `jti`. Decode-success-but-claims-mismatch emits `warn` with `reason` and `jti`. Decode failure (any reason) emits NO `worker_auth` log — verify by counting log events on a user-JWT request and on an expired worker token; both must produce zero `worker_auth` lines. +- Happy path (client): `Credential::Worker(s).bearer_token()` returns `s`. +- Edge case (client): `Debug` impl prints `Credential::Worker()` — token string never appears in debug output. +- Compile-time guard (client): assert `Credential: !Display` at the type level (e.g. via `static_assertions::assert_not_impl_any!(Credential: std::fmt::Display)`). Variants are not types; the trait impl lives on the enum. + +**Verification:** +- All extractor + helper tests pass with both branches exercised. +- `cargo nextest run -p fabro-server -p fabro-client` succeeds. + +--- + +- [x] **Unit 3: Wire worker-touched routes through the `AuthorizeRunScoped` extractor family; replace artifact-upload-token at the spawn** + +**Goal:** Each of the 7 worker-touched routes accepts the worker token. Server spawn injects `FABRO_WORKER_TOKEN` env var. Old artifact-upload-token machinery deleted atomically. Lifecycle/admin/SSE routes are NOT touched and continue to require user JWT. + +**Requirements:** R1, R2, R4, R7. + +**Dependencies:** Unit 1, Unit 2. + +**Files:** +- Modify: `lib/crates/fabro-server/src/server.rs` + - `get_run_state` (5076), `list_run_events` (5146), `append_run_event` (5096), `write_run_blob` (5352): replace `_auth: AuthenticatedService, Path(id): Path` with `AuthorizeRunScoped(id): AuthorizeRunScoped`. Body extractors stay unchanged. + - `read_run_blob` (5379): replace `_auth: AuthenticatedService, Path((id, blob_id)): Path<(String, String)>` with `AuthorizeRunBlob(id, blob_id): AuthorizeRunBlob`. + - `put_stage_artifact` (5838): replace `Path::<(String, String)>` + inline `authorize_artifact_upload(&parts, ...)` with `AuthorizeStageArtifact(id, stage_id): AuthorizeStageArtifact`. Keep `request: Request` for body extraction; continue to call `request.into_parts()` inside the handler for the header/body split. + - `worker_command` (3701-3755): replace `state.issue_artifact_upload_token` → `state.issue_worker_token`. Drop the `--artifact-upload-token ` arg entirely. Set `cmd.env("FABRO_WORKER_TOKEN", token)` unconditionally (always, regardless of auth method). Also `cmd.env_remove("FABRO_WORKER_TOKEN")` before re-injection (defense against parent-env leakage). Delete the conditional `cmd.env("FABRO_DEV_TOKEN", token)` block (3740-3750) — `FABRO_WORKER_TOKEN` replaces it as the only authority-bearing re-injection. + - **Lifecycle/admin/SSE routes left alone**: `cancel_run`, `pause_run`, `unpause_run`, `archive_run`, `unarchive_run`, `delete_run`, `submit_answer`, `start_run`, `create_run`, `attach_run_events` (`/runs/{id}/attach`), `attach_events` (`/attach`), and any list endpoint. Keep `_auth: AuthenticatedSubject` / `AuthenticatedService` directly. Add a one-line code comment at each of the 9 worker-rejecting handlers: `// Worker token intentionally not accepted; this is a user/admin action.` + - Delete: `ARTIFACT_UPLOAD_TOKEN_*` constants (287-289), `ArtifactUploadClaims` (319-326), `ArtifactUploadTokenKeys` (313-317), `artifact_upload_token_keys` (798-812), `AppState::issue_artifact_upload_token` (752-773), `AppState::artifact_upload_tokens` field (565), `maybe_authorize_artifact_upload_token` (813-844), `authorize_artifact_upload` (846-854). +- Test: existing tests in `lib/crates/fabro-server/src/server.rs` test module (rename / replace `worker_command_injects_dev_token_only_when_enabled` at 7836). + +**Approach:** +- The shape change is NOT trivially mechanical for JSON/body handlers like `append_run_event` (`Json<...>` body) or `write_run_blob` (`Bytes` body). Switching them to `Request::into_parts()` would require manually re-parsing the body. Instead, introduce a custom `FromRequestParts` extractor that composes naturally with body extractors. +- **Route API is the extractor, not the helper.** Route handlers should use the new extractor types as their route-facing auth contract. `authorize_worker_token` and the inner decode/fall-through logic remain `pub(crate)` helpers used only by the extractors. +- **Three extractor variants** (one per path shape the worker actually uses) in `worker_token.rs`: + - `AuthorizeRunScoped(pub RunId)` — for `/runs/{id}/...` with a single run-id path param. Used by: `get_run_state`, `list_run_events`, `append_run_event`, `write_run_blob`. + - `AuthorizeRunBlob(pub RunId, pub RunBlobId)` — for `/runs/{id}/blobs/{blobId}`. Used by: `read_run_blob`. + - `AuthorizeStageArtifact(pub RunId, pub StageId)` — for `/runs/{id}/stages/{stageId}/artifacts`. Used by: `put_stage_artifact` (and `list_stage_artifacts`, `get_stage_artifact` if they ever join the worker-touched set; not today). +- Each `impl FromRequestParts` internally: + - Extracts `Path::<(String, ...)>::from_request_parts` with the right tuple shape (1, 2, or 2 segments). + - Parses each segment via the existing `parse_run_id_path`, `parse_run_blob_id_path`, `parse_stage_id_path` helpers. + - Reads `AuthMode` from `parts.extensions` and `&AppState.worker_tokens` from axum state. + - Calls `authorize_worker_token(parts, &run_id, &keys)?`; on `Ok(false)` falls through to `authenticate_service_parts(parts)`. + - Returns the typed path params so handlers skip their own `Path` + `parse_*` dance. +- Handlers change: + - `get_run_state`, `list_run_events`, `append_run_event`, `write_run_blob`: `_auth: AuthenticatedService, Path(id): Path` → `AuthorizeRunScoped(id): AuthorizeRunScoped`. Body extractors (`Json<...>`, `Bytes`) stay intact. + - `read_run_blob`: `_auth: AuthenticatedService, Path((id, blob_id)): Path<(String, String)>` → `AuthorizeRunBlob(id, blob_id): AuthorizeRunBlob`. + - `put_stage_artifact`: currently takes `Request` + `Path::<(String, String)>`. Swap to `AuthorizeStageArtifact(id, stage_id): AuthorizeStageArtifact` + `request: Request` (body extraction stays manual via `request.into_parts()`). +- Atomic swap, no transition period — cargo + tests catch any missed callsite. +- **Pre-implementation audit:** grep all `client.*` and `api.*` callsites under `lib/crates/fabro-cli/src/commands/run/` (and any helpers it transitively uses) to confirm each resolves to one of the 7 endpoints in the table. If any newly-discovered handler exists, add a row and wire it through `AuthorizeRunScoped`. +- **Structural rule:** `AuthorizeRunScoped` is used ONLY in handlers whose path contains `{id}` (`RunId`). Verify by grep: every usage must correspond to a `{id}` path segment. + +**Patterns to follow:** +- `put_stage_artifact` handler (`server.rs:5838`) is the existing model: takes `parts: Parts`, calls `authorize_artifact_upload(&parts, &state, &id)?`. + +**Test scenarios:** +- Happy path: each of the 5 newly-wired routes accepts a worker token whose `claims.run_id` matches the path `id` → expected response. +- Error path: each route with worker token whose `claims.run_id` ≠ path `id` → 403. +- Integration: each route with valid user JWT and no worker token → still works (R7 fall-through). +- Replacement test for `worker_command_injects_dev_token_only_when_enabled`: rename to `worker_command_always_sets_worker_token_env`. Build `worker_command` for both `methods=["github"]` and `methods=["dev-token"]` settings; assert `FABRO_WORKER_TOKEN` env is set to a valid token in BOTH cases. Assert `FABRO_DEV_TOKEN` env is NOT set in either case. Assert no `--artifact-upload-token` or `--worker-token` arg appears in argv (env-only). +- **Negative path (user-only route table):** for every route in the table below, assert presenting a valid worker token (with claims matching the run_id where applicable) is rejected. Tests MUST run under `AuthMode::Enabled` with a valid user-JWT key configured — under `AuthMode::Disabled`, `AuthenticatedService` accepts everything before any validation (`jwt_auth.rs:279`-style), so a "reject" assertion proves nothing. Model after the existing `jwt_auth.rs` tests that use `AuthMode::Enabled` with test secrets. + + | Route | Handler | Expected status with worker token | + |---|---|---| + | `GET /runs` | `list_runs` | 401/403 | + | `POST /runs` | `create_run` | 401/403 | + | `GET /runs/resolve` | `resolve_run` | 401/403 | + | `POST /preflight` | `run_preflight` | 401/403 | + | `POST /graph/render` | `render_graph_from_manifest` | 401/403 | + | `GET /attach` | `attach_events` | 401/403 | + | `GET /boards/runs` | `list_board_runs` | 401/403 | + | `GET /runs/{id}` | `get_run_status` | 401/403 | + | `DELETE /runs/{id}` | `delete_run` | 401/403 | + | `GET /runs/{id}/questions` | `get_questions` | 401/403 | + | `POST /runs/{id}/questions/{qid}/answer` | `submit_answer` | 401/403 | + | `GET /runs/{id}/attach` | `attach_run_events` | 401/403 | + | `GET /runs/{id}/checkpoint` | `get_checkpoint` | 401/403 | + | `POST /runs/{id}/cancel` | `cancel_run` | 401/403 | + | `POST /runs/{id}/start` | `start_run` | 401/403 | + | `POST /runs/{id}/pause` | `pause_run` | 401/403 | + | `POST /runs/{id}/unpause` | `unpause_run` | 401/403 | + | `POST /runs/{id}/archive` | `archive_run` | 401/403 | + | `POST /runs/{id}/unarchive` | `unarchive_run` | 401/403 | + | `GET /runs/{id}/graph` | `get_graph` | 401/403 | + | `GET /runs/{id}/stages` | `list_run_stages` | 401/403 | + | `GET /runs/{id}/artifacts` | `list_run_artifacts` | 401/403 | + | `GET /runs/{id}/files` | `list_run_files` | 401/403 | + | `GET /runs/{id}/stages/{stageId}/artifacts` | `list_stage_artifacts` | 401/403 | + | `GET /runs/{id}/stages/{stageId}/artifacts/download` | `get_stage_artifact` | 401/403 | + | `GET /runs/{id}/billing` | `get_run_billing` | 401/403 | + | `GET /runs/{id}/settings` | `get_run_settings` | 401/403 | + | `POST /runs/{id}/preview` | `generate_preview_url` | 401/403 | + | `POST /runs/{id}/ssh` | `create_ssh_access` | 401/403 | + | `GET /runs/{id}/sandbox/files` | `list_sandbox_files` | 401/403 | + | `GET /runs/{id}/sandbox/file`, `PUT /runs/{id}/sandbox/file` | `get_sandbox_file`, `put_sandbox_file` | 401/403 | + + Every route in the real-routes router (`server.rs:1162-1230`) except the 7 worker-touched routes is user-only. The acceptance criterion is a single test helper that iterates this explicit table and asserts rejection for each under `AuthMode::Enabled`. Routes that return `not_implemented` (turns, workflows, insights) are not included — they'll stay user-only automatically if ever implemented; flag in a follow-up if needed. +- **Positive path for `put_stage_artifact`** (auth semantics changed — the only worker-touched route that previously had its own auth helper): explicit tests for the artifact upload route. + - Valid worker token with matching `run_id` → 200 (octet-stream variant; multipart variant if cheap to set up). + - Worker token with `claims.run_id != path.run_id` → 403. + - Valid user JWT (no worker token) → 200 (R7 fall-through preserved on this route). + - No bearer at all → 401. +- Regression (env scrubbing): extend the existing `worker_allowlist_is_fail_closed` test (`spawn_env.rs:64-99`) to assert `FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET` don't leak (alongside the existing `SESSION_SECRET` assertion). +- Edge case: assert deleted symbols (`ArtifactUploadClaims`, `authorize_artifact_upload`, etc.) no longer exist — covered implicitly by `cargo build`. + +**Verification:** +- `cargo build --workspace` succeeds (no references to deleted artifact-upload-token symbols). +- `cargo nextest run -p fabro-server` passes. + +--- + +- [x] **Unit 4: Worker (CLI) — use injected worker token from env, stop reading `AuthStore`** + +**Goal:** Worker subprocess reads its credential from `FABRO_WORKER_TOKEN` env at startup, uses it as its sole bearer for every server call, and never constructs `AuthStore::default()`. The artifact uploader holds the same token string in a per-call bearer field (the client's upload methods require a per-call bearer argument today). + +**Requirements:** R1, R5. + +**Dependencies:** Unit 2 (`Credential::Worker` variant), Unit 3 (server sets `FABRO_WORKER_TOKEN` env on the worker subprocess). + +**Files:** +- Modify: `lib/crates/fabro-cli/src/args.rs:808` — DELETE the `artifact_upload_token: Option` field on `RunWorkerArgs`. Do NOT add a replacement clap arg — the worker reads from env directly. +- Modify: `lib/crates/fabro-cli/src/commands/run/mod.rs:74-90` — drop the `artifact_upload_token` plumbing on the dispatch path. +- Modify: `lib/crates/fabro-cli/src/server_client.rs` — add `pub(crate) async fn connect_server_target_with_bearer(target: &ServerTarget, bearer: &str) -> Result`. Builds `Client` with `.credential(Credential::Worker(bearer.to_owned()))`, no `oauth_session`, no `resolve_target_credential` call, no `AuthStore` access. +- Modify: `lib/crates/fabro-cli/src/commands/run/runner.rs` + - At top of `execute`: read `FABRO_WORKER_TOKEN` from env via `std::env::var`. Validate non-empty; bail with a clear error mentioning `FABRO_WORKER_TOKEN` if missing or empty (this is a server-bug indicator, not a user error). Drop `artifact_upload_token` from `execute`'s signature. + - Line 67: replace `connect_server_target_direct(&server)` with `connect_server_target_with_bearer(&target, &worker_token)`. + - Delete `MissingArtifactUploadTokenUploader` (300-317) and the `match artifact_upload_token { Some/None }` fork (~244-251); always construct `HttpArtifactUploader`. + - `HttpArtifactUploader`: **keep** the per-call bearer field, rename `bearer_token: String` → `worker_token: String`. The client methods `upload_stage_artifact_file` and `upload_stage_artifact_batch` still require a per-call bearer parameter (no `Client::credential()` accessor exists today — see next bullet), so the uploader must hold the token and pass it per call. The token is the same string stored at client construction in `Credential::Worker`. +- Keep `lib/crates/fabro-client/src/client.rs:1043, 1081` — `upload_stage_artifact_*` continue to take a `bearer_token` parameter. There is no `Client::credential()` accessor today (`credential` at `client.rs:176` is a builder setter, not a getter), so threading the token per-call is the path of least resistance. The worker-side caller passes the same `FABRO_WORKER_TOKEN` string it built the client with. (If a `Client::credential()` accessor is added later as a separate concern, the per-call parameter can be dropped then.) + +**Stage-execution env scrubbing (narrow scope):** + +The spawn site that runs user-supplied workflow stage commands must NOT see `FABRO_WORKER_TOKEN`: `LocalSandbox::execute` (`lib/crates/fabro-sandbox/src/local.rs:221`). It already does `env_clear` + safelist (`local.rs:43-66`) with a `_token` suffix denylist that incidentally catches `FABRO_WORKER_TOKEN`. + +- Modify: `lib/crates/fabro-sandbox/src/local.rs:43-66` — add `"FABRO_WORKER_TOKEN"` (and `SESSION_SECRET`, `FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`) to an explicit denylist alongside the suffix heuristic. Comment why: future rename like `FABRO_WORKER_AUTH` would silently leak under the suffix heuristic alone. +- **Critical: filter both inherited env AND explicit `env_vars` extras.** `LocalSandbox::execute` (`local.rs:224`) appends caller-supplied `env_vars` after the inherited-env filter, so a stage config with `env_vars` containing `FABRO_WORKER_TOKEN` would still leak. Apply the same denylist to the `env_vars` extras path: drop any key in the denylist before calling `cmd.env(key, value)` on each extra. + +**Hooks (host mode) — surgical scrub:** + +`fabro-hooks/src/executor.rs:186` runs `sh -c ` for non-sandbox hooks and inherits the worker process env. Even though hooks are operator-configured (not random user input), they are still shell commands that have no business reading `FABRO_WORKER_TOKEN`. + +- Modify: `lib/crates/fabro-hooks/src/executor.rs:186` — `cmd.env_remove("FABRO_WORKER_TOKEN")` (and the same six server-secret names listed above) on host-mode hook spawns. Targeted, defense-in-depth. Hooks remain operator-trusted; this just keeps the worker token out of their env. + +**Out of scope for env scrubbing:** trusted internal subprocesses that run server-controlled code and may legitimately need credentials in their env: `gh auth token` (`fabro-github/src/lib.rs:129`), MCP server stdio (`fabro-mcp/src/client.rs:47`), devcontainer features (`fabro-devcontainer/src/features.rs:67-199`), git (`fabro-workflow/src/git.rs:35`). These are not user-attack surfaces. Do NOT scrub them. + +**Approach:** +- `connect_server_target_with_bearer` is the smallest possible surface: it skips the `AuthStore`/`OAuthSession` machinery entirely. The user-facing `connect_server_target` and `connect_server_with_settings` are unchanged. +- The new constructor is the *only* path the worker takes; verify by grep that `commands::run::runner` is the only module importing it. + +**Patterns to follow:** +- `server_client.rs:80-110` (`connect_managed_unix_socket_api_client_bundle`) for the client-builder shape; new constructor is a stripped-down version. + +**Test scenarios:** +- Happy path: `connect_server_target_with_bearer` builds a `Client` whose outgoing requests carry `Authorization: Bearer `. +- Edge case: `connect_server_target_with_bearer` does NOT call `AuthStore::default()` — verify by injecting a `FABRO_AUTH_FILE=/nonexistent` env override and confirming construction succeeds (the helper must not even attempt to read the file). +- Edge case: `connect_server_target_with_bearer` does NOT install an `OAuthSession` (no refresh attempts on 401). +- Integration: `runner::execute` with `FABRO_WORKER_TOKEN=` set in env POSTs an event using only the injected token; no fallback to `auth.json`. +- Error path: `runner::execute` invoked without `FABRO_WORKER_TOKEN` in env returns a clear error mentioning the variable name; does NOT silently fall back to user OAuth. +- Edge case: `RunWorkerArgs` no longer has `artifact_upload_token` field — `cargo build` confirms. +- Integration (env hygiene, sandbox path, **inherited**): worker env has `FABRO_WORKER_TOKEN=` set; a workflow stage Bash command `env | grep -E "FABRO_WORKER_TOKEN|SESSION_SECRET|FABRO_JWT_PRIVATE_KEY"` prints empty output. Covers `LocalSandbox::execute` denylist correctness for inherited env. +- Integration (env hygiene, sandbox path, **explicit env_vars**): a workflow stage configured with `env_vars: { FABRO_WORKER_TOKEN: "leaked", MY_VAR: "ok" }` produces a child env where `FABRO_WORKER_TOKEN` is absent but `MY_VAR=ok` is present. Covers the explicit-extras filter path. **Without this test, the explicit-env_vars bypass is undetected.** +- Integration (env hygiene, hooks): a host-mode hook (`fabro-hooks/src/executor.rs:186`) spawned with `FABRO_WORKER_TOKEN` in the worker's env produces a child where `env | grep FABRO_WORKER_TOKEN` is empty. + +**Verification:** +- `cargo build --workspace` succeeds. +- `cargo nextest run -p fabro-cli` passes. +- grep for `AuthStore` from `commands/run/` returns no hits. + +--- + +- [x] **Unit 5: Stamp `system:worker` actor on worker-emitted events (worker-side sink wrapper)** + +**Goal:** Events the worker emits without a typed actor get a `system:worker` stamp at the *worker-side sink layer*. User identity stays on the run record (`RunSpec.provenance.subject`), where it already lives. + +**Requirements:** R6. + +**Dependencies:** Should land WITH the auth changes (Units 1-4), not in isolation. Landing alone produces a wire-visible behavior change (worker events flip from `actor: None` to `actor: System(worker)`) without the auth context that justifies it. + +**Files:** +- Modify: `lib/crates/fabro-workflow/src/event.rs` — add `RunEventSink::Map { transform, inner }` variant that applies `transform` to each event before forwarding to `inner`. Wire it into the existing dispatch logic so all events reach the inner sink already transformed. +- Modify: `lib/crates/fabro-cli/src/commands/run/runner.rs` — at the `RunEventSink::fanout([...])` construction site (`runner.rs:100`), wrap the fanout in `RunEventSink::Map { transform: stamp_system_worker, inner: ... }` so the stamp applies to all downstream sinks (backend HTTP, local callback, future). +- Test: `lib/crates/fabro-workflow/src/event.rs` (test the `Map` variant in isolation). +- Test: `lib/crates/fabro-cli/src/commands/run/runner.rs` (test the worker-local stamp wrapper end-to-end). + +**Approach:** +- **Critical: do NOT modify `lib/crates/fabro-workflow/src/event.rs::to_run_event_at` or `stored_event_fields`.** Those helpers are shared with the server (e.g. `server.rs:6702` flushes lifecycle events through `workflow_event::to_run_event`). Default-filling there mis-stamps server-emitted events. +- Helper function (not `const` — `ActorRef::id`/`display` are `Option`, heap-allocated): `fn system_worker_actor() -> ActorRef { ActorRef { kind: ActorKind::System, id: Some("worker".to_string()), display: Some("system:worker".to_string()) } }` lives in `runner.rs` (worker-local). +- **Stamping must apply to the whole fanout, not just one sink variant.** `RunEventSink` is an enum (`Backend | Callback | Composite | …`) in `fabro-workflow/src/event.rs`. Wrapping only the `Backend` variant means the local callback (today: `update_worker_title_from_event`) and any future sink see unstamped events. +- **Design: add `RunEventSink::Map { transform: Arc RunEvent + Send + Sync>, inner: Box }`** variant. Worker constructs `RunEventSink::Map { transform: stamp_system_worker, inner: Box::new(RunEventSink::fanout([backend, callback])) }`; stamp applies before the fanout splits. +- **Dispatch: non-recursive, iterative, owned per branch.** The existing `write_run_event` at `event.rs:2698` uses an iterative stack with a single shared `&RunEvent`. Naively translating `Map => inner.write_run_event(&mapped).await` would introduce recursive async (doesn't compile cleanly without boxing each recursive call, and obscures the shape). + - Redesign the traversal to carry **`(sink, owned_event)` pairs** on the stack instead of `(&sink, &event)`. Each node owns the `RunEvent` value for its subtree. + - `Map { transform, inner }`: apply `transform` to the owned event → push `(*inner, transformed_event)` onto the stack. The branch downstream sees the new event; the original is dropped when this stack frame unwinds. + - `Composite { sinks }`: for each child sink, push `(child, event.clone())`. Each branch gets its own owned event. (Cloning `RunEvent` is cheap — it's a struct of owned data already serialized once; no deep-copy of large payloads.) + - `Backend { ... }` / `Callback { ... }`: terminal — invoke with the owned event, no recursion. + - Keep the existing async-loop shape; only the stack element type changes. +- The transform applies the value-based rule: **if `event.actor.is_none()`, fill with `system_worker_actor()`**. Agent events (`AssistantMessage`) keep `ActorKind::Agent` because `actor.is_some()`. Worker self-cancel events (`Event::RunCancelRequested { actor: None }`) correctly get the system actor. + +**Patterns to follow:** +- `RunEventSink::fanout` composition (`fabro-workflow/src/event.rs` sink layer). +- Existing actor-stamping for lifecycle events at the server endpoints (`actor_from_subject` at `server.rs:6175`) — server-side stamping for user actions; worker-side wrapper for worker events. + +**Test scenarios:** +- Happy path (`Map` variant): a `RunEventSink::Map { transform: |e| e.with_actor(ActorRef::user("alice")), inner: backend }` applied to an event with any actor → forwarded event has actor = `ActorRef::user("alice")` regardless. Confirms the variant works. +- Happy path (worker stamp): a stage-execution `RunEvent { actor: None, ... }` enters the wrapped fanout → BOTH the backend sink AND the local callback see `actor: Some(ActorRef { kind: System, id: Some("worker"), display: Some("system:worker") })`. +- Edge case: `RunEvent { actor: Some(user_actor), ... }` → both sinks retain the user actor (`actor.is_some()` → no-op). +- Edge case: agent message `RunEvent { actor: Some(ActorKind::Agent), ... }` → both sinks retain `ActorKind::Agent`. +- Edge case: worker self-cancel `RunEvent { actor: None, body: RunCancelRequested { ... } }` → both sinks get `system:worker`. +- Per-sink uniformity assertion: construct the worker's actual fanout (Backend + Callback), feed an `actor: None` event in, capture what each sink receives, assert both have `system:worker`. Without this test, only stamping the Backend variant could regress without detection. +- Regression: server-side event flush via `workflow_event::to_run_event` (`server.rs:6702`) is unchanged — `to_run_event_at` retains its passthrough semantics. +- Regression: `create_hydrates_provenance_into_store_state` (`fabro-workflow/src/operations/create.rs`) still passes — originator user identity still on `RunSpec.provenance.subject`. + +**Verification:** +- `cargo nextest run -p fabro-cli` passes. +- New tests for the default-fill and the override-protections both pass. + +--- + +- [x] **Unit 6: End-to-end regression — github-only worker run with no `~/.fabro/auth.json`** + +**Goal:** Lock in the bug fix: a github-only deployment can spawn a worker that completes a run, with no user OAuth artifact present on the worker host. Worker authenticates using only `FABRO_WORKER_TOKEN`. + +**Requirements:** R1, R5. + +**Dependencies:** Units 1-5. + +**Files:** +- Create: an integration test under `lib/crates/fabro-cli/tests/it/cmd/` (existing test scaffolding) — file name per local convention (e.g. `worker_auth.rs`). + +**Approach:** +- **Test fixture:** + - Server configured with `auth.methods = ["github"]` only; no `FABRO_DEV_TOKEN` in `server.env`. + - `FABRO_HOME` redirected to a fresh tempdir on the *worker* side (no `auth.json`, no `dev-token` file). + - Submitter path uses an authenticated test user JWT (minted directly via `auth/jwt.rs::issue` with the test `SESSION_SECRET`) to call `POST /runs` and start the run. **Do not** confuse this with the worker's auth — the user JWT is what authorizes run creation; the worker JWT (server-issued in response) is what the worker subprocess uses. +- Run a tiny workflow end-to-end via the daemon → worker spawn path. +- Assert the worker successfully POSTs at least one `RunEvent` and the run reaches a terminal status. +- Assert `~/.fabro/auth.json` is not touched (non-existence at the redirected `FABRO_HOME`). + +**Patterns to follow:** +- Existing integration tests in `lib/crates/fabro-cli/tests/it/cmd/` (per `support.rs` helpers like `daemon.bind.to_target()`). +- CLAUDE.md note: tests must use `.no_proxy()` HTTP clients. + +**Test scenarios:** +- Integration: github-only server + no `auth.json` on worker host + minimal workflow → run completes successfully; events visible via `GET /runs/{id}/events`. +- Integration: same setup but worker spawned with a deliberately-bogus `FABRO_WORKER_TOKEN` (e.g. valid HS256 but wrong `run_id` claim) → worker fails fast on first server call. + +**Verification:** +- `cargo nextest run -p fabro-cli --test it` passes the new test. +- Test fails on `main` (pre-Units 1-5) — confirms it covers the regression. + +## System-Wide Impact + +- **Interaction graph:** Worker subprocess no longer reads `~/.fabro/auth.json`. CLI user-facing commands (`fabro run`, `fabro ps`, `fabro auth`, etc.) unchanged — they still go through `connect_server_target` / `connect_server_with_settings`. SSE attach endpoints unchanged: worker is a producer, not a consumer; user-JWT-only auth on those routes preserved. +- **Error propagation:** Worker token expiry mid-run → next server call returns 401, worker exits with a generic error, server marks run failed via existing pump-worker exit handling at `server.rs:4786`. No new error class. +- **State lifecycle:** Server restart with HKDF-derived key: outstanding worker tokens remain valid up to natural expiry. Server restart with `SESSION_SECRET` rotated: outstanding workers fail at next call (acceptable; matches user-session invalidation). No revocation set. +- **Event sink uniformity:** the worker wraps its `RunEventSink::fanout([Store(http), Callback])` (`runner.rs:100`) in `RunEventSink::Map { transform: stamp_system_worker, inner: fanout }`, so the stamp applies once *before* the fanout splits — both the HTTP backend and the local callback observe identical actor metadata. The shared `to_run_event_at` converter remains pure (passthrough). A test asserts per-sink uniformity directly. +- **API surface parity:** `RunEvent.actor` shape unchanged (`ActorRef` already has `ActorKind::System`); worker-emitted events newly carry `system:worker` instead of `None`. Web UI audit (`apps/fabro-web/app/`) found ZERO references to `actor` or `author` today — nothing to break. +- **Worker-process trust degradation:** A workflow stage that compromises the worker process (malicious shell, code injection) gains read access to `FABRO_WORKER_TOKEN` for the worker's lifetime + up to 72h until natural expiry. Blast radius bounded by run-id claim: only the compromised run's blobs/events/state are accessible. Not cross-run. `SESSION_SECRET` is NOT in the worker's env (`apply_worker_env` allowlist) so the worker cannot mint cross-run tokens. +- **Integration coverage:** Unit 6 covers github-only-no-auth-store regression; existing CLI integration tests cover dev-token deployments. +- **Unchanged invariants:** End-user auth (dev-token / github) unchanged. `RunAuthMethod` enum unchanged. `RunSpec.provenance` shape unchanged. Webhook auth unchanged. Lifecycle/admin/SSE/list endpoints continue to require user auth — worker token explicitly rejected on all of them. `OpenAPI` / `fabro-api-client` (TypeScript) DTOs unchanged. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Worker process inherits `SESSION_SECRET` → can mint tokens for any run, defeating per-run binding | **Already structurally mitigated**: `apply_worker_env` at `spawn_env.rs:18` does `env_clear` + 8-name allowlist that excludes `SESSION_SECRET`. Existing `worker_allowlist_is_fail_closed` test asserts this. Unit 3 extends the test to also cover `FABRO_JWT_*` and `GITHUB_APP_*`. | +| Token leaks via `format!`/`Display`/`tracing` of `Credential::Worker` payload | `Credential::Worker` has redacted `Debug`, no `Display`. Compile-time guard test in Unit 2 asserts `!impl Display`. Audit logging in Unit 2 logs `jti` only, never the token. | +| Sentry / panic capture serializes the worker's env or backtrace locals | Sentry panic hook (`fabro-telemetry/src/panic.rs`) captures only panic message + stacktrace, not env or frame variables. Code review responsibility to keep token out of panic format strings. | +| 72h token compromised mid-run, attacker uses it from any host on network | Run-id binding limits blast radius to one run. No revocation; rotating `SESSION_SECRET` is the only invalidation mechanism (also invalidates user sessions). Acceptable for current threat model. | +| `FABRO_WORKER_TOKEN` readable via `/proc//environ` to same-UID processes on Linux | Documented in Threat Model: env-var transport does not protect against same-UID reads. Multi-tenant deployments must isolate per-tenant via separate UIDs / containers. NOT a property of this design. | +| Workflow stage child processes (sandbox-executed Bash) inherit `FABRO_WORKER_TOKEN` via env or via explicitly-supplied `env_vars` extras | `LocalSandbox::execute` filters BOTH the inherited env (existing safelist + denylist) AND the `env_vars` extras path (new in Unit 4). Two regression tests prove both paths. | +| Host-mode hook commands inherit `FABRO_WORKER_TOKEN` | `fabro-hooks/src/executor.rs` does targeted `cmd.env_remove("FABRO_WORKER_TOKEN")` (and the same six server-secret names) on host-mode hook spawns. Hooks remain operator-trusted; this is defense-in-depth — shell commands have no business reading the worker token. | +| Trusted internal subprocesses (`gh`, MCP, devcontainer features, git) inherit env including `FABRO_WORKER_TOKEN` | NOT scrubbed by design — these run server-controlled code, may legitimately need credentials, and are not user-attack surfaces. Documented in Unit 4. | +| `client.upload_stage_artifact_*` API requires a per-call bearer parameter | Per Unit 4: `HttpArtifactUploader` holds the token in a `worker_token: String` field (same string read from `FABRO_WORKER_TOKEN`) and threads it per call. No `Client::credential()` accessor today; per-call threading is the path of least churn. | +| Same-run concurrent worker spawn (scheduler race) → two valid tokens for one `run_id` racing on event/state appends | Scheduler's at-most-one-worker-per-run guarantee is assumed but not verified by this plan. If a race exists today, follow-up plan adds a server-side spawn lock or a per-spawn nonce. Out of scope here. | +| Rapid pause/resume cycles leave multiple valid tokens per run | Each prior worker token remains valid up to its 72h `exp`. Multiplicative compromise window bounded by run-id. Accepted; out of scope to fix here. | + +## Documentation / Operational Notes + +- `docs-internal/` — if any internal doc describes worker auth (search before landing), update to reflect: "worker → server auth uses a server-issued per-run JWT, independent of end-user auth method." +- No external user-facing doc impact (no public API change; CLI args on `__run-worker` are internal-only, hidden via `#[command(hide = true)]`). + +### Deploy story (greenfield, atomic swap) + +No shipped deployments to preserve. Atomic swap: +1. Deploy new binary; server restarts. +2. New runs spawn workers with `FABRO_WORKER_TOKEN` in env; worker uses it via `Credential::Worker`. +3. Old artifact-upload-token mechanism is gone from the codebase entirely. + +No drain, no shim, no checklist beyond verifying `SESSION_SECRET` is set (HKDF key derives from it). + +## Sources & References + +- Worker auth surface inventory: `lib/crates/fabro-cli/src/commands/run/runner.rs:55-127` +- Artifact-upload-token model to replace: `lib/crates/fabro-server/src/server.rs:287-289, 752-854` +- Worker spawn site: `lib/crates/fabro-server/src/server.rs:3701-3755` +- Existing HKDF key derivation: `lib/crates/fabro-server/src/auth/keys.rs:41` +- Existing worker env allowlist: `lib/crates/fabro-server/src/spawn_env.rs:18` +- `Credential` variants: `lib/crates/fabro-client/src/credential.rs:6-30` +- `ActorRef` / `ActorKind`: `lib/crates/fabro-types/src/run_event/mod.rs:29-81` +- `RunProvenance`: `lib/crates/fabro-types/src/run.rs:34-49` +- Stage-execution chokepoint: `lib/crates/fabro-sandbox/src/local.rs:221, 43-66` +- Coordinated plans: `docs/plans/2026-04-22-003-refactor-lock-down-server-secrets-plan.md`, `docs/plans/2026-04-19-003-feat-cli-auth-login-plan.md`, `docs/plans/2026-04-20-001-fix-cli-server-same-host-assumptions-plan.md` +- Origin of artifact-upload-token pattern: `docs/plans/2026-04-06-object-backed-artifact-uploads.md:42-45` +- Worker subprocess history: `docs/plans/2026-04-06-subprocess-run-workers-signal-control-plan.md`, `docs/plans/2026-04-07-worker-http-only-run-store-migration-plan.md` diff --git a/docs/plans/2026-04-23-001-refactor-collapse-settings-resolve-indirection-plan.md b/docs/plans/2026-04-23-001-refactor-collapse-settings-resolve-indirection-plan.md new file mode 100644 index 000000000..c1977a80b --- /dev/null +++ b/docs/plans/2026-04-23-001-refactor-collapse-settings-resolve-indirection-plan.md @@ -0,0 +1,306 @@ +--- +title: "refactor: collapse settings resolve indirection layer" +type: refactor +status: completed +date: 2026-04-23 +--- + +# refactor: collapse settings resolve indirection layer + +## Overview + +Delete `fabro_config::Resolver`, the per-`*Settings` `*_into(&mut errors)` methods, the private `ResolvedSettingsTree` in `fabro-workflow`, and the `resolve_settings_tree` free fn. Add a `WorkflowSettings` sibling to `ServerSettings`/`UserSettings` so workflow ops have one entrypoint. Standalone `resolve_*_from_file` helpers stay (single-namespace consumers depend on them) but get rewritten to not go through `Resolver`. + +`WorkflowSettings` is **workflow-only**: `{ project, workflow, run }`. It does NOT hold `server` or `features` — even though the per-request layer DOES carry `features` and a subset of `server` (storage, scheduler, artifacts, web, api) per `materialize_settings_layer`. Excluding them is an **ownership choice**: we want the live `ServerSettings` (resolved at server boot) to remain the single authority for deployment-level config, and we don't want a workflow-side parallel copy that can quietly drift. `ServerSettings` and `UserSettings` keep `features` because they're constructed from a complete owned settings layer at boot — they ARE the authority. + +Today's lifted `server_storage_root` field on `ResolvedSettingsTree` was an asymmetric workaround for "create_run needs one server-ops field but has no access to live `ServerSettings`." The fix: `create_run` gains a `storage_root: PathBuf` parameter so the caller (which owns the live `ServerSettings`) supplies it. No more lifted server fields anywhere. + +PR 2 (Combine trait + derive, uv pattern) is a separate, larger refactor — instructions in `docs/plans/2026-04-23-002-refactor-combine-trait-uv-pattern-plan.md`. + +## Problem Frame + +Today three layers sit between `SettingsLayer` and consumer code: + +1. `Resolver` (introduced in 52d24531d) — caches `apply_builtin_defaults` across multiple per-namespace resolves. +2. `*_into(&mut errors)` methods on `Resolver` — let `ServerSettings::from_layer` and `UserSettings::from_layer` accumulate errors across two namespaces. +3. `resolve_settings_tree` + `ResolvedSettingsTree` in `fabro-workflow/src/operations/create.rs` — bundles four namespaces for `create_run`. + +`Resolver` exists only because `create_run` was running 4× `apply_builtin_defaults(file.clone())` per request. It's a perf micro-fix dressed up as an API primitive. `ResolvedSettingsTree` is the missing `WorkflowSettings` sibling of `ServerSettings`/`UserSettings` — wrong crate, wrong name, asymmetric shape (lifts `storage_root: InterpString` instead of holding a full `ServerNamespace`). + +## Requirements Trace + +- R1. Single entrypoint per consumer (Server/User/Workflow), each with `from_layer(&SettingsLayer) -> Result<...>`. +- R2. Multi-namespace error accumulation preserved — `*Settings::from_layer` must surface errors from ALL its namespaces, not first-only. +- R3. Standalone `resolve_*_from_file` helpers continue to work for single-namespace consumers (dozens of callers across the workspace). +- R4. **`WorkflowSettings` holds workflow-owned namespaces only** (`project`, `workflow`, `run`). `server` and `features` are excluded as an ownership choice — the live `ServerSettings` is the single authority for deployment-level config, even though `materialize_settings_layer` does flow `features` and a subset of `server.*` (storage, scheduler, artifacts, web, api) through into the per-request layer. `ServerSettings` and `UserSettings` are constructed at boot from a complete owned settings layer and hold every namespace their consumer reads. Server-ops fields needed by `create_run` (today: just `storage.root`) come from the caller as parameters, not from the bundle. +- R5. No change to resolved-settings semantics or to persisted `Event::RunCreated.settings` shape. **`record.run_dir` is now derived from the new `storage_root` parameter, not from `request.settings.server.storage.root` as today.** For the production HTTP caller (which passes the same resolved `server.storage.root` it would have read from the live `ServerSettings`), `record.run_dir` matches today's value. For direct `create()` callers (test helpers, future API consumers) that pass a different path, `record.run_dir` will reflect the parameter — that is the new invariant. The other consumer-visible changes are: (a) `create_run`'s signature gains one parameter (R7); (b) the storage-root interpolation-failure error path moves from workflow-side to transport-side with different error text — see Risks. +- R6. **Workflow-settings resolution errors preserve `render_resolve_errors` formatting** (today: `; `-joined). `WorkflowSettings::from_layer` returns `Result>` (matching the `resolve_*_from_file` convention) so `create_run` keeps formatting via `render_resolve_errors`. R6 covers ONLY the `resolve_*` failure path inside `from_layer` — not the storage-root interpolation failure, which moves out of `create_run` per R5. +- R7. `create_run`'s signature gains a `storage_root: PathBuf` parameter. Callers supply it from the live deployment context: `fabro-server` reads it from its boot-time `ServerSettings`; tests pass a fixture path. + +## Scope Boundaries + +- Out of scope: the Combine refactor (PR 2). +- Out of scope: migrating every `resolve_*_from_file` caller to a bundle. Standalone helpers stay; only `create_run` gets the bundle treatment. +- Out of scope: removing `render_resolve_errors` (used by 4+ callers; orthogonal). +- Out of scope: changing the `*Layer` / `*Namespace` types in `fabro-types`. +- Out of scope: changing `apply_builtin_defaults`, `defaults.toml`, or any per-namespace `resolve_*(layer, &mut errors)` function body. + +## Context & Research + +### Relevant Code and Patterns + +- `lib/crates/fabro-config/src/context.rs` — current `ServerSettings` and `UserSettings` (use `Resolver`). +- `lib/crates/fabro-config/src/resolve/resolver.rs` — entire file deletes. +- `lib/crates/fabro-config/src/resolve/mod.rs` — `resolve_storage_root` + 6× `resolve_*_from_file` helpers (today: thin wrappers over `Resolver`; rewrite to inline `apply_builtin_defaults` + per-namespace resolve). +- `lib/crates/fabro-config/src/resolve/{cli,server,project,features,run,workflow}.rs` — per-namespace `resolve_*` fns; bodies unchanged. Visibility stays `pub` (callers in tests + helper crates). +- `lib/crates/fabro-config/src/defaults.rs` — `apply_builtin_defaults(layer: SettingsLayer) -> SettingsLayer`. One clone per call. +- `lib/crates/fabro-workflow/src/operations/create.rs:63-68` — `ResolvedSettingsTree`; `:116` use site; `:166` `combined_labels` call; `:287-297` `resolve_settings_tree`; `:299-304` `combined_labels` fn. +- `lib/crates/fabro-config/src/resolve/server.rs:68-75` — `resolve_storage` already substitutes `default_storage_dir()` when `server.storage.root` is missing. `Resolver::storage_root()` was duplicating this. With `server` excluded from `WorkflowSettings`, the duplication moves: the single authority for storage_root is now whatever `ServerSettings::server.storage.root` resolves to at server boot (used by the existing `state.server_storage_dir()` helper). +- `lib/crates/fabro-server/src/server.rs:577,643,659` — `AppState.settings` is a raw `Arc>`; `AppState.server_settings` is a separate `RwLock>` accessed via `state.server_settings()` (line 643). There IS a `state.server_storage_dir() -> PathBuf` helper (line 659), **but it panics on interpolation failure** (`.expect("server storage root should be resolved at startup")`). Today's `create()` returns `Error::Precondition` on the same failure (`create.rs:118-126`). Unit 3 must NOT use `server_storage_dir()` — the HTTP handler has to do its own `resolve_interp_string` and map errors to `ApiError`. See Unit 3 Approach. +- `lib/crates/fabro-config/src/effective_settings.rs:55-64` — documents that `server_settings.features` AND a small subset of `server_settings.server` (storage, scheduler, artifacts, web, api) ARE applied authoritatively into the per-request layer. Server-ops fields (auth, listen, ip_allowlist, slatedb, logging, integrations) are stripped. So the per-request layer DOES carry storage and features — `WorkflowSettings` excludes them by **ownership choice** (we want the live `ServerSettings` to be the single authority for deployment-level config), not by capability constraint. + +### Call Site Audit (verified) + +`resolve_*_from_file` standalones — KEEP (dozens of callers): +- `resolve_run_from_file`: 20+ callers across fabro-cli, fabro-server, fabro-workflow, tests +- `resolve_server_from_file`: 12+ callers across fabro-cli, fabro-server, fabro-install, tests +- `resolve_features_from_file`: `fabro-server/src/server.rs:1364` +- `resolve_project_from_file` / `resolve_workflow_from_file` / `resolve_cli_from_file`: callers in `fabro-config/src/project.rs`, fabro-cli, tests +- `resolve_storage_root`: 1 external caller (`fabro-cli/src/local_server.rs:15`) + tests + +`Resolver` — DELETE (1 caller): +- `lib/crates/fabro-workflow/src/operations/create.rs:288` (in `resolve_settings_tree`) + +`ResolvedSettingsTree` / `resolve_settings_tree` / `combined_labels` (free fn) — DELETE (private to `create.rs`, all uses local). + +### Institutional Learnings + +None directly applicable. The original `Resolver` commit (52d24531d) explicitly framed itself as a perf optimization, not an API improvement — its rationale evaporates once the per-consumer bundles each do their own apply-defaults. + +## Key Technical Decisions + +- **Principle: `WorkflowSettings` holds workflow-owned namespaces only.** `{ project, workflow, run }` — the namespaces whose authority belongs to the workflow consumer. `server` and `features` are excluded as an *ownership choice* (live `ServerSettings` is the single authority for deployment-level config), even though `materialize_settings_layer` makes them available in the per-request layer. `ServerSettings` and `UserSettings` are constructed from a complete owned layer at boot and hold every namespace their consumer reads. +- **`create_run` gains `storage_root: PathBuf` parameter.** Storage root is deployment-level config — it belongs to whoever booted the server. Lifting it through a per-request bundle (today's `ResolvedSettingsTree.server_storage_root`) was an asymmetric workaround. Pushing it to a parameter eliminates the asymmetry and makes the data-flow honest. The caller resolves env-var interpolation before passing and maps any resolution error to the appropriate transport-level response. +- **Persisted settings layer is the request-derived/materialized artifact; do NOT overwrite it.** What lands in `Event::RunCreated.settings` is not the raw submitted layer — `create()` runs `materialize_run(settings, ...)` first (`create.rs:407`) which normalizes and applies graph-time materialization. Treat the persisted field as "the materialized request artifact." `Event::RunCreated` carries two separate fields (`create.rs:234-265`): `settings: ` and `run_dir: String` (where the run actually went). They serve different purposes and should remain separately recorded. The `storage_root` parameter is the runtime authority for `create_run`; `record.run_dir` is the persisted authority for "where is this run?" downstream. `record.settings.server.storage.root` is whatever the materialized layer carries — possibly an env-template that resolved differently at runtime than what `record.run_dir` records. Future code that needs the path should read `run_dir`, not re-resolve the layer. Audit (one grep) confirmed no current `fabro-workflow` consumer reads `settings.server.storage.root` from a persisted record beyond the line being removed. +- **Keep standalone `resolve_*_from_file` helpers; rewrite without `Resolver`.** Each becomes `apply_builtin_defaults(file.clone())` + `resolve_(&layer..unwrap_or_default(), &mut errors)`. Rationale: dozens of single-namespace callers (e.g. "is this a dry run?" → `resolve_run_from_file(...)?.execution.mode`); forcing them through a bundle would pay full validation cost for one field. +- **`WorkflowSettings::from_layer` returns `Result>`.** Asymmetric with `ServerSettings::from_layer` and `UserSettings::from_layer` (which return `fabro_config::Result` wrapping `Error::Resolve`), but symmetric with `resolve_*_from_file`. Reason: preserves `create_run`'s existing user-visible error format (`; `-joined via `render_resolve_errors`); changing it would be an unowned scope expansion (R6). Existing `Server`/`UserSettings` callers undisturbed. +- **`combined_labels` becomes a method on `WorkflowSettings`.** Free fn was only callable in one place. +- **Each `*Settings::from_layer` calls `apply_builtin_defaults(layer.clone())` independently.** Acceptable — `create_run` is human-paced, not per-request. +- **Visibility of per-namespace `resolve_*` fns stays `pub`.** Called by `*_from_file` standalones and tests. + +## Open Questions + +### Resolved During Planning + +- **Should `WorkflowSettings` hold `ServerNamespace` and/or `FeaturesNamespace`?** Neither. Per R4, `WorkflowSettings` holds workflow-owned namespaces only. Server-ops and features ARE available in the per-request layer (via `materialize_settings_layer`), but excluded from the bundle as an ownership choice — live `ServerSettings` is the single authority for deployment-level config. +- **Where does `create_run` get `storage.root` if not from the bundle?** From a `storage_root: PathBuf` parameter. Caller (fabro-server, tests) supplies it. See R7. +- **Should we migrate every `resolve_*_from_file` caller to a bundle?** No. Standalones stay; bundles are for multi-namespace consumers only. +- **Does `combined_labels` belong as a method or free fn?** Method on `WorkflowSettings`. +- **What error type does `WorkflowSettings::from_layer` return?** `Result>` (matches `resolve_*_from_file`, preserves `create_run`'s existing error message format via `render_resolve_errors`). `Server`/`UserSettings` keep their existing `fabro_config::Result` shape. +- **Should `resolve_storage_root` standalone keep its separate identity?** Yes, keep it (rewrite inline). It's used by `fabro-cli/src/local_server.rs:15` to compute a runtime path without resolving full server settings; that use case remains. Don't migrate that caller in this PR. + +### Deferred to Implementation + +- **Test layout for `WorkflowSettings`.** Either add to `lib/crates/fabro-config/tests/resolve_root.rs` (existing multi-namespace tests live there) or new file `tests/workflow_settings.rs`. Pick during implementation based on what reads better. + +## Implementation Units + +- [x] **Unit 1: Add `WorkflowSettings` to `fabro-config::context`** + +**Goal:** New consumer bundle for workflow ops, sibling to `ServerSettings`/`UserSettings`. Workflow-only namespaces. + +**Requirements:** R1, R2, R6 + +**Dependencies:** None. + +**Files:** +- Modify: `lib/crates/fabro-config/src/context.rs` +- Modify: `lib/crates/fabro-config/src/lib.rs` (export `WorkflowSettings`) +- Test: `lib/crates/fabro-config/tests/resolve_root.rs` OR new `tests/workflow_settings.rs` + +**Approach:** +- Add a `pub struct WorkflowSettings` with all fields `pub`: + - `pub project: ProjectNamespace` + - `pub workflow: WorkflowNamespace` + - `pub run: RunNamespace` + Every field is `pub` so cross-crate field access from `fabro-workflow` works. +- Add `WorkflowSettings::from_layer(layer: &SettingsLayer) -> Result>`. Returns `Vec` (NOT wrapped in `fabro_config::Error::Resolve`) so callers can format via existing `render_resolve_errors`. Body: `apply_builtin_defaults(layer.clone())`, then call `resolve_project`, `resolve_workflow`, `resolve_run` into a shared `Vec`. Return `Ok(Self { ... })` if `errors.is_empty()`, else `Err(errors)`. +- Add `WorkflowSettings::combined_labels(&self) -> HashMap` — extend `project.metadata`, then `workflow.metadata`, then `run.metadata` (later wins on key conflict). Match today's `combined_labels` free fn ordering exactly. +- Do NOT yet delete `*_into` methods or `Resolver`. This unit is purely additive. + +**Patterns to follow:** +- `resolve_*_from_file` helpers in `lib/crates/fabro-config/src/resolve/mod.rs` for the `Result<_, Vec>` return shape and the per-namespace inline pattern. +- For the `combined_labels` ordering, today's free fn in `lib/crates/fabro-workflow/src/operations/create.rs:299-304`. + +**Test scenarios:** +- Happy path: `SettingsLayer::default()` resolves successfully. (Unlike `resolve_server` — which requires explicit `server.auth.methods` per `tests/resolve_root.rs:8` — the workflow-only namespaces have all required defaults in `defaults.toml`.) +- Happy path: layer with `project.metadata`, `workflow.metadata`, `run.metadata` produces `combined_labels` containing the union, with later sections winning on key conflict. +- Error path: layer with invalid `run.sandbox.provider` (e.g. `"not-a-provider"`) returns `Err(errors)` containing a `ResolveError` for `run.sandbox.provider`. (See existing fixture in `tests/resolve_root.rs:38`.) +- Error path: layer with multiple invalid fields *within* `run` (e.g. invalid `sandbox.provider` PLUS another resolve_run-emitting error — pick from existing run-resolve test fixtures during implementation) returns ALL of them in one `Err`. Proves R2 — the shared `errors` vec is being threaded through all `resolve_*` calls in `from_layer`. (Note: `resolve_project` and `resolve_workflow` cannot emit errors today — `lib/crates/fabro-config/src/resolve/{project,workflow}.rs` both ignore the `errors` vec — so a "two namespaces both error" test isn't constructible. The within-`run` test is the closest structural proof we can write.) + +**Verification:** +- New tests pass. +- `WorkflowSettings` is exported from `fabro_config` and constructable from any test fixture. +- All three fields publicly accessible from outside `fabro_config` (smoke-test by reading `wf.run.execution.mode` from a test file). + +--- + +- [x] **Unit 2: Delete `Resolver`; rewrite `*Settings::from_layer` and `resolve_*_from_file` to not depend on it** + +**Goal:** Remove the `Resolver` indirection. Each consumer-bundle constructor and each standalone helper inlines `apply_builtin_defaults` + the per-namespace resolve. + +**Requirements:** R2, R3, R5 + +**Dependencies:** Unit 1 (so `WorkflowSettings` exists; not strictly required for this unit, but Unit 3 depends on both and ordering is cleaner). + +**Files:** +- Delete: `lib/crates/fabro-config/src/resolve/resolver.rs` +- Modify: `lib/crates/fabro-config/src/resolve/mod.rs` (remove `mod resolver;` and `pub use resolver::Resolver;`; rewrite the 6 `resolve_*_from_file` helpers + `resolve_storage_root` to inline) +- Modify: `lib/crates/fabro-config/src/context.rs` (rewrite `ServerSettings::from_layer` and `UserSettings::from_layer` without `Resolver`) +- Modify: `lib/crates/fabro-config/src/lib.rs` (remove `Resolver` from re-exports) + +**Approach:** +- Each `resolve_*_from_file(file)` becomes: + ``` + let layer = apply_builtin_defaults(file.clone()); + let mut errors = Vec::new(); + let value = resolve_(&layer..clone().unwrap_or_default(), &mut errors); + if errors.is_empty() { Ok(value) } else { Err(errors) } + ``` +- `resolve_storage_root(file)` becomes a thin extraction from a defaulted layer (same logic as today's `Resolver::storage_root()`). +- `ServerSettings::from_layer` and `UserSettings::from_layer` build a defaulted layer once, accumulate errors from both their namespaces into one `Vec`. Preserves today's "errors from BOTH namespaces" behavior — verify with existing tests in `tests/resolve_server.rs` and `tests/resolve_cli.rs`. + +**Patterns to follow:** +- Today's `Resolver::server_into(&mut errors)` body shows the inline shape per namespace. +- Today's `ServerSettings::from_layer` shows the dual-namespace error accumulation pattern. + +**Test scenarios:** +- Happy path: existing `tests/resolve_server.rs`, `tests/resolve_cli.rs`, `tests/resolve_run.rs`, `tests/resolve_workflow.rs`, `tests/resolve_project.rs`, `tests/resolve_features.rs`, `tests/defaults.rs`, `tests/resolve_root.rs` all pass without modification (proves no behavior change for any consumer). +- Edge case: `ServerSettings::from_layer` with multiple errors *within* `server` (e.g. invalid `listen.address` AND invalid `ip_allowlist` CIDR — see existing fixture pattern in `tests/resolve_root.rs:22`) returns ALL errors in one `Err`. Proves the shared `errors` vec is threaded through. (`resolve_features` cannot emit errors per `lib/crates/fabro-config/src/resolve/features.rs:5`, so the "errors from both namespaces" pairing isn't constructible — within-namespace multi-error is the closest structural proof.) + +**Verification:** +- `cargo build -p fabro-config` clean. +- `cargo nextest run -p fabro-config` green with no test changes. +- `cargo nextest run -p fabro-config-tests` (if separate) or whatever test target covers `tests/` — green. +- `grep -rn "Resolver\b" lib/crates/fabro-config/src` returns no hits except in deleted file. + +--- + +- [x] **Unit 3: Migrate `create_run` to `WorkflowSettings`; add `storage_root` parameter; delete `ResolvedSettingsTree` + `resolve_settings_tree` + `combined_labels` free fn** + +**Goal:** `create_run` consumes `WorkflowSettings` for workflow-owned config and `storage_root` from the caller for deployment-owned config. `WorkflowSettings::from_layer` failures keep today's `; `-joined `render_resolve_errors` format (R6). Storage-root interpolation failures move out to the HTTP handler with new error attribution (R5). + +**Requirements:** R1, R5, R6, R7 + +**Dependencies:** Unit 1 (needs `WorkflowSettings`), Unit 2 (needs `Resolver` gone — though Unit 3 could technically land before Unit 2). + +**Files:** +- Modify: `lib/crates/fabro-workflow/src/operations/create.rs` + - Add `storage_root: PathBuf` parameter to `pub async fn create(...)`. + - Delete `struct ResolvedSettingsTree` (lines 63-68). + - Delete `fn resolve_settings_tree` (lines 287-297). + - Delete `fn combined_labels` (lines 299-304). + - Replace `let resolved_settings = resolve_settings_tree(&settings)?;` (line 116) with `WorkflowSettings::from_layer(&settings).map_err(|errors| Error::Precondition(render_resolve_errors(&errors)))?`. + - Replace lines 118-127 (today's storage_root resolution + Storage::new call): use the `storage_root` parameter directly. No more `InterpString::resolve(env)` here — caller did it. + - Pass `settings` (unmodified) into `PersistCreateOptions`. **Do NOT overwrite `settings.server.storage.root`** — see Key Technical Decisions: persisted layer is the request-derived/materialized artifact (post-`materialize_run`); `record.run_dir` is the path-of-truth. + - Replace `combined_labels(&resolved_settings)` (line 166) with `resolved_settings.combined_labels()`. + - Imports: remove `Resolver` if directly imported; remove `HashMap` import if no longer used; add `PathBuf` if not already imported. +- Modify: `lib/crates/fabro-server/src/server.rs:4019` — production caller. Resolve `storage_root` from the live `ServerSettings` and map any interpolation failure to a transport error. **Do NOT use `state.server_storage_dir()`** — that helper panics on resolution failure (`server.rs:659`), which would replace today's `Error::Precondition` graceful-failure path with a process abort. The handler must do its own `resolve_interp_string` (or equivalent) and map errors to an `ApiError` shape used elsewhere in the file. +- Modify: `lib/crates/fabro-workflow/src/operations/start.rs:1008,1190` — test helpers (`persisted_workflow` and the bundled-workflow test). Pass a temp dir or fixture path. +- Audit and modify: any other callers of `fabro_workflow::operations::create::create()` in workspace tests. Run `grep -rn "operations::create(\|workflow::operations::create" lib/crates/fabro-workflow/tests/ lib/crates/fabro-server/tests/` during implementation. + +**Approach:** +- Production caller pattern (sketched — verify error constructor and locking shape during implementation): + ``` + // Read the live ServerSettings (Arc) and resolve env-var template + let storage_root = match resolve_interp_string(&state.server_settings().server.storage.root) { + Ok(s) => PathBuf::from(s), + Err(e) => return ApiError::(format!("storage root: {e}")).into_response(), + }; + let created = match Box::pin(operations::create(state.store.as_ref(), create_input, storage_root)).await { ... }; + ``` + This preserves today's "interpolation failure becomes a graceful HTTP error" semantics. The exact `ApiError` constructor (e.g. `bad_request`, `internal`, or a new variant) is implementer's choice — pick whatever matches today's error shape for similar request-time validation failures in this file. `resolve_interp_string` already exists in the same module (used by `server_storage_dir`). +- Test helpers can use `tempfile::tempdir()` or a hardcoded path; pass `tmp.path().to_path_buf()` (or similar) as the third argument. +- `validate_sandbox_provider` at line 306 still uses `fabro_config::resolve_run_from_file(&resolved.settings)` — leave it. Different code path, same as today. + +**Patterns to follow:** +- Today's `to_error` closure in `create.rs:289-290` for the `render_resolve_errors` mapping shape. +- `Error::Precondition` is the established envelope for create-time settings failures. + +**Behavior change in scope (limited to R7 signature change):** +- `pub async fn create()` gains a `storage_root: PathBuf` parameter. Three production/test caller sites + workspace test audit. +- **No new resolve-time validation surfaces.** The bundle now resolves only `project`/`workflow`/`run`, exactly as today's `ResolvedSettingsTree` did. Server-ops fields (`auth`, `listen`, `ip_allowlist`, `integrations`) are NOT touched by this PR; they remain owned by the live `ServerSettings`. +- The InterpString env-var resolution that today happens inside `create()` (lines 119-126) moves to the caller. fabro-server today already has plenty of `InterpString::resolve` patterns to mirror. + +**Test scenarios:** +- Happy path: existing `create_run` tests (in `create.rs`'s `mod tests`, in `lib/crates/fabro-workflow/tests/`, in `lib/crates/fabro-server/tests/`) pass after caller updates. +- Happy path: `combined_labels()` method produces the same map as today's free fn for the same fixture (proves ordering preservation). +- Error path: `SettingsLayer` with malformed `run.sandbox.provider` causes `create_run` to fail with the expected `; `-joined message (proves R6 format preservation). +- Edge case: caller passes a non-existent `storage_root` — `create_run` either creates the dir (matches today's behavior via `Storage::new`) or fails with the same error today's `Storage::new` would produce. Verify behavior is identical to today. + +**Verification:** +- `cargo build -p fabro-workflow` clean. +- `cargo build -p fabro-server` clean. +- `cargo nextest run -p fabro-workflow` green. +- `cargo nextest run -p fabro-server` green (HTTP handler still works end-to-end). +- `grep -rn "ResolvedSettingsTree\|resolve_settings_tree" lib/` returns no hits. + +--- + +- [x] **Unit 4: Workspace build + lint sweep** + +**Goal:** Confirm no caller across the workspace still references deleted symbols. + +**Requirements:** R5 + +**Dependencies:** Units 1-3. + +**Files:** None modified directly; this unit is verification only. + +**Approach:** +- `cargo build --workspace` from repo root. +- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` (per CLAUDE.md). +- `cargo nextest run --workspace`. +- `grep -rn "fabro_config::Resolver\|ResolvedSettingsTree\|resolve_settings_tree" lib/ apps/` returns no hits. +- `cargo +nightly-2026-04-14 fmt --check --all`. + +**Test expectation:** none — pure verification unit. No new test scenarios; existing tests must pass unchanged. + +**Verification:** +- All commands green. +- No grep hits for deleted symbols. + +## System-Wide Impact + +- **Interaction graph:** `create_run`'s signature gains a `storage_root: PathBuf` parameter. ~3 production/test callers updated to pass it. No new resolve-time validation runs anywhere — the bundle resolves the same namespaces today's `ResolvedSettingsTree` did. +- **Error propagation:** `Server`/`UserSettings` error envelopes unchanged. `WorkflowSettings::from_layer` returns `Result>` (a new shape, asymmetric with the other two bundles); `create_run` formats it via `render_resolve_errors` to keep its user-visible error message format identical to today (`; `-joined). See R6. The InterpString-resolution error path that today lives inside `create_run` moves up to the caller — same error class, different attribution. +- **State lifecycle risks:** None — pure refactor, no persistent state touched. +- **API surface parity:** `fabro_config` public API loses `Resolver` (and the `*_into` methods accessible through it). Gains `pub struct WorkflowSettings`. All other re-exports unchanged. `render_resolve_errors` stays public (used by `create_run` and others). `fabro_workflow::operations::create::create()` signature changes (one new parameter). +- **Integration coverage:** Existing tests in `lib/crates/fabro-config/tests/` exercise both single-namespace (`resolve_*_from_file`) and multi-namespace (`*Settings::from_layer`) paths; they cover the integration shape. +- **Server-ops authority preserved:** No part of this PR re-resolves server-ops fields (`auth`, `listen`, `ip_allowlist`, `integrations`, `slatedb`, `logging`) per-request. The live `ServerSettings` (owned by `fabro-server` at boot) remains the single authority. `WorkflowSettings` doesn't pretend to hold them. +- **Unchanged invariants:** `*Layer` and `*Namespace` types in `fabro-types` — untouched. `apply_builtin_defaults` + `defaults.toml` — untouched. Per-namespace `resolve_*` function bodies — untouched. Consumer field-access patterns (`ctx.user_settings().cli.output.verbosity`) — untouched. `Server`/`UserSettings::from_layer` signatures — untouched. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| `create_run` signature change misses a workspace test caller; build break | Audit step listed in Unit 3 Files (`grep -rn "operations::create("`). Compiler will catch all production paths. | +| fabro-server's storage_root resolution call site fails with a new error class (env var missing, parse error) where today the failure happened inside `create_run` | Same error semantics; just attributed to the caller. Mention in PR description so reviewers don't read it as a regression. | +| One extra `apply_builtin_defaults(layer.clone())` per bundle on the create path vs. shared-via-Resolver | Acceptable — `create_run` is human-paced. Not in any hot path. | +| `combined_labels` method ordering subtly differs from free fn (both extend project → workflow → run) | Match today's free-fn ordering exactly. Existing tests will catch label-ordering regressions. | +| Asymmetric `from_layer` return type (`Result>` for `Workflow`; `fabro_config::Result` for `Server`/`User`) reads as inconsistency | Documented as a Key Technical Decision; reason is preserving `create_run`'s error-message format. Future PR could converge all three when the format change is intentional. | +| `record.settings.server.storage.root` (the request-derived/materialized artifact) and `record.run_dir` (where the run actually lives) can disagree — e.g. user submitted env-template `"{{ env.X }}"` that resolved at runtime to a different path than the parameter | Documented design (see Key Technical Decisions): persisted settings = request-derived/materialized artifact; `run_dir` = path-of-truth. Audit grep verifies no current consumer reads `settings.server.storage.root` from a persisted record beyond the line being removed. Future readers must use `run_dir` for paths. | +| HTTP handler's storage-root resolution failure path differs from today's `Error::Precondition` text | Documented behavior change — moves from a workflow-side `Error::Precondition` to a transport-side `ApiError`. Same failure class (graceful HTTP error), different attribution and error-message text. Mention in PR description. | + +## Documentation / Operational Notes + +- No user-facing docs to update (internal refactor). +- No migration scripts, feature flags, or rollout concerns. +- One signature change: `fabro_workflow::operations::create::create()` gains `storage_root: PathBuf`. Three caller sites + workspace test audit. No resolved-settings semantics change. +- Net code: ~150 lines deleted, ~50 added (smaller WorkflowSettings = smaller diff). + +## Sources & References + +- Origin: design conversation in this session. +- Related code: `lib/crates/fabro-config/src/resolve/resolver.rs` (introduced commit 52d24531d). +- Follow-up plan: `docs/plans/2026-04-23-002-refactor-combine-trait-uv-pattern-plan.md` (PR 2 — Combine trait + derive). 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 new file mode 100644 index 000000000..348d5cfd6 --- /dev/null +++ b/docs/plans/2026-04-23-001-refactor-command-context-alignment-plan.md @@ -0,0 +1,835 @@ +--- +title: "refactor: CommandContext alignment at CLI command boundaries" +type: refactor +status: completed +date: 2026-04-23 +deepened: 2026-04-23 +--- + +# CommandContext Alignment At CLI Command Boundaries + +## Overview + +Finish the partially landed `CommandContext` refactor in `fabro-cli` by +making `CommandContext` the shared command-boundary abstraction for +invocation plumbing in the CLI command families that already depend on +it or immediately reconstruct it. The goal is to stop threading +`&CliNamespace`, `&CliLayer`, `Printer`, and `process_local_json` +through command entrypoints when that data is already part of the same +invocation context. + +This is a follow-on plan to the earlier server-access refactor. The +current codebase already centralizes `cwd`, merged settings, and server +access in `CommandContext`, but command entrypoints still receive raw +plumbing and then rebuild a context locally. This plan aligns the +surface area around the existing struct instead of introducing a second +context or service wrapper. + +## Problem Frame + +`CommandContext` exists today in +`lib/crates/fabro-cli/src/command_context.rs`, and many commands +already depend on it for `cwd`, `machine_settings`, `user_settings`, and +`server()` access. The refactor is only half-finished: + +- the `printer` field and `printer()` accessor are both marked + `#[allow(dead_code, reason = "...still being wired through")]` +- `main.rs` still passes raw plumbing into most command families +- many command entrypoints still accept some combination of + `&CliNamespace`, `&CliLayer`, `Printer`, and `process_local_json` +- those same commands often construct `CommandContext` immediately + inside the handler + +That leaves the CLI with two overlapping models: + +- `CommandContext` as the intended abstraction for shared invocation + state +- raw parameter threading as the de facto command API + +The result is more churn on command signatures, more repeated output +format and JSON-gating branches, and a dead-code-marked `printer` field +that signals the abstraction boundary is unfinished. + +The refactor needs to finish in a way that preserves current CLI +behavior and avoids turning `CommandContext` into a new god object. + +## Requirements Trace + +- **R1.** In-scope CLI command entrypoints use `CommandContext` as the + shared invocation-plumbing abstraction instead of separately receiving + `&CliNamespace`, `&CliLayer`, `Printer`, and `process_local_json`. +- **R2.** `CommandContext` remains narrow: it carries invocation-scoped + plumbing and shared derived state, not command-specific args, styles, + or workflow-specific data. +- **R3.** JSON output, verbosity behavior, printer routing, and global + `--json` restrictions remain behaviorally unchanged for existing + commands, including `auth status` remaining explicit-global-`--json` + only rather than switching to persisted `cli.output.format = "json"`. +- **R4.** Target-based and storage-dir-based server resolution semantics + remain unchanged, including `CommandContext::server()` behavior and + `ServerSummaryLookup::from_client(...)` usage. +- **R5.** `main.rs` keeps its current pre-tracing bootstrap ordering for + logging and upgrade checks; `CommandContext` begins after that phase. +- **R6.** The current dead-code allowance on the `printer` field/accessor + is removed by making printer access a real part of the abstraction, or + by deleting redundant surface area if a narrower accessor is better. +- **R7.** Representative unit and integration coverage exists for the + context API, JSON/text output behavior, global `--json` gating, and + target/connection-based command families touched by the refactor. + +## Scope Boundaries + +- **In scope:** command families in `fabro-cli` that already use + `CommandContext` directly or immediately construct one from raw + plumbing: + `run`, `runs`, `artifact`, `store dump`, `preflight`, `validate`, + `graph`, `model`, `secret`, `pr`, `repo`, `auth`, `provider`, + `config`, `version`, `doctor`, `system`, and the public + `sandbox preview` / `sandbox ssh` command boundary that currently + forwards into `commands/run/*` leaf modules. +- **In scope:** top-level dispatch cleanup in + `lib/crates/fabro-cli/src/main.rs` and family dispatch modules where + the only reason raw CLI plumbing is still passed down is command + signature inertia. +- **Out of scope:** `exec`, `server`, `install`, `upgrade`, `workflow`, + `parse`, `render_graph`, hidden analytics/panic upload commands, + hidden `run worker`, and `sandbox cp`, except for thin compile-only + adapters if needed. +- **Out of scope:** changing CLI text, response payloads, exit-code + semantics, or server connection behavior beyond what is required to + move the plumbing boundary. +- **Out of scope:** moving `Styles` ownership into `CommandContext` or + making low-level pure rendering helpers context-aware when explicit + `Printer` or `Styles` parameters remain clearer. +- **Out of scope:** redesigning `server_client.rs` connection semantics. + This plan consumes the current `CommandContext::server()` model rather + than reopening the earlier server-access design. + +## Context & Research + +### Relevant Code and Patterns + +- `lib/crates/fabro-cli/src/command_context.rs` — current + `CommandContext`, constructors, `user_settings()` / `machine_settings()` + accessors, and dead-code-marked printer storage. +- `lib/crates/fabro-cli/src/main.rs` — top-level command dispatch still + passing raw plumbing into most families after bootstrap. +- `lib/crates/fabro-cli/src/server_client.rs` — current target and + storage-dir connection behavior that must stay unchanged. +- `lib/crates/fabro-cli/src/commands/run/create.rs` — existing example + of a helper that already takes `&CommandContext`. +- `lib/crates/fabro-cli/src/commands/run/mod.rs` — current mixed model: + some subcommands build a `CommandContext`, some still read raw + `cli.output.format`, and `process_local_json` is already vestigial at + this boundary. +- `lib/crates/fabro-cli/src/commands/secret/mod.rs` — good family-level + pattern where a single derived server/client can be shared across + subcommands. +- `lib/crates/fabro-cli/src/commands/pr/mod.rs` — representative mixed + local/server family using both `CommandContext::base(...)` and + `CommandContext::for_target(...)`. +- `lib/crates/fabro-cli/src/commands/auth/mod.rs` and + `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 + 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 + boundary for preview/ssh command dispatch; leaf implementation lives in + `commands/run/preview.rs` and `commands/run/ssh.rs`, but the command + family boundary is `sandbox`. +- `lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs` and + `lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs` — existing + command-owned coverage for those public entrypoints. + +### Current-State Inventory + +- `CommandContext::{base, for_target, for_connection}` currently has + **42** call sites across **37** command files under + `lib/crates/fabro-cli/src/commands`. +- The command tree contains **one** existing helper that already accepts + `&CommandContext` directly: + `lib/crates/fabro-cli/src/commands/run/create.rs`. +- The `printer()` accessor currently has no call sites, which is why the + dead-code allowance still exists. +- For in-scope command files, the dominant remaining use of raw + `&CliNamespace` is reading `cli.output.format` or + `cli.output.verbosity`; that is a signal that the data belongs on the + shared invocation context rather than each command signature. +- `process_local_json` is now concentrated in `auth`, `provider`, + `graph`, `sandbox preview`, and `sandbox ssh`. That makes it a good + candidate for a dedicated invocation-context field/helper instead of + continued parameter threading. + +### Related Context + +- `docs/plans/2026-04-08-cli-services-command-context-refactor-plan.md` + — earlier plan that introduced the current `CommandContext` and server + access model. This plan finishes the command-boundary alignment that + document did not fully land. +- `docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md` + — recent owner-first context plan that reinforces the repo preference + for dense, owner-scoped context objects instead of repeated free-form + plumbing. +- `git log -- lib/crates/fabro-cli/src/command_context.rs` shows recent + follow-on commits including `simplify: drop duplicate settings plumbing + from cli/server refactor`, which is consistent with the current goal of + collapsing overlapping command-boundary APIs. + +### Institutional Learnings + +- No relevant `docs/solutions/` entries currently cover this seam. + +### External References + +- No external research used. The repo already has sufficient local + context, existing partial implementation, and tests for this refactor. + +## Key Technical Decisions + +- **Use `CommandContext` as the command-boundary API for in-scope + commands.** + The abstraction already owns the hard parts: working directory, + merged settings, and server access. The remaining refactor should move + entrypoint APIs onto that abstraction instead of continuing to thread + raw plumbing beside it. + +- **Keep `CommandContext` narrow and invocation-scoped, not god-shaped.** + It should own: + `printer`, merged CLI-derived settings (`machine_settings`, + `user_settings`), `cwd`, config-path context, server-derivation state, + and the invocation-only global `--json` flag. + It should not own command args, `Styles`, render-only helpers, or + workflow/build-specific state. + +- **Do not store or expose the full `CliNamespace` publicly.** + Commands in scope only need a small subset of CLI plumbing: + output format, output verbosity, and global `--json` restrictions. + Output format and verbosity should come from + `ctx.user_settings().cli.output`, which already reflects CLI override + precedence through merged settings. The only truly extra invocation + field is the global `--json` switch, which is not part of persisted + settings and therefore belongs on the context explicitly. + +- **Preserve the current error-timing split.** + `CommandContext` construction should keep doing what it does today: + capture cwd, load local settings, and build merged invocation state. + Server-target resolution and server-connection failures should remain + deferred to `ctx.server().await?` or existing explicit + `resolve_server_target(...)` calls. The refactor should not make + malformed target/server resolution errors eager at context-construction + time. + +- **Keep `auth status` as an explicit-global-JSON command.** + Most in-scope commands should read output mode from + `ctx.user_settings().cli.output`, but `auth status` is a special case: + it currently emits JSON only for the explicit invocation-wide global + `--json` path, not merely because resolved CLI settings say + `output.format = json`. That distinction should remain intact, so the + context needs both resolved output settings and a separate helper for + the explicit global JSON flag. + +- **Create a base invocation context once, then derive target/connection + variants from it.** + `main.rs` should keep using raw settings during pre-tracing bootstrap. + After that, it should build a base `CommandContext` once for each + in-scope dispatch path. Command families then derive + target-based or connection-based contexts from that base without + re-supplying `Printer` and `CliLayer`. + +- **Allow private derivation state inside `CommandContext` if that is the + simplest way to avoid raw parameter threading.** + Storing a private `CliLayer` or equivalent internal builder state is + acceptable if it enables methods like `with_target(...)` or + `with_connection(...)` and keeps the raw plumbing hidden behind the + abstraction boundary. + +- **Keep render helpers explicit.** + Command entrypoints and family dispatchers should align on + `CommandContext`, but low-level helpers such as table rendering, + summary formatting, and browser-opening routines may keep explicit + `Printer` / `Styles` / boolean parameters where that stays simpler than + threading the full context downward. + +- **Keep workflow/manifest layer assembly command-local.** + Commands such as `run`, `preflight`, `validate`, and `graph` should + continue to build their workflow/project/manifests with the existing + command-owned helpers. `CommandContext` can provide `cwd`, merged user + settings for output behavior, and server access, but it should not + absorb manifest-building policy or workflow-layer composition. + +- **Migrate family-by-family with temporary compatibility wrappers if + needed.** + This is a cross-cutting refactor with wide signature churn. It is + better to allow short-lived constructor/adapter overlap during the + migration than to force a single giant all-or-nothing patch that is + harder to validate. + +## Open Questions + +### Resolved During Planning + +- **Should this refactor introduce a second wrapper type such as + `Services` or `InvocationContext`?** + No. The repo already has a partially landed `CommandContext`, and the + simplest aligned design is to finish that abstraction rather than + splitting responsibilities across two overlapping context types. + +- **Should `CommandContext` absorb the entire `CliNamespace`?** + No. Public command consumers should read output mode and verbosity from + `ctx.user_settings().cli.output`, while command-specific configuration + stays local to the commands that own it. + +- **Should `process_local_json` become part of `CommandContext`?** + Yes. It is invocation-scoped, currently leaks across multiple command + signatures, and is the one remaining piece of global command plumbing + that is not already represented by merged settings. + +- **Should preview/ssh be treated as `run` work or `sandbox` work in + this plan?** + Treat them as `sandbox` work at the public command boundary. The leaf + implementation modules remain under `commands/run/*`, but the raw + plumbing boundary in the current CLI is `commands/sandbox/mod.rs` and + the plan should align to that boundary and its tests. + +- **Should `auth status` remain “explicit global JSON only”?** + Yes. R3 for this plan is behavioral preservation, and the current + contract is that `auth status` emits JSON only when the explicit + invocation-global `--json` switch is active. + +- **Should `Styles` move into `CommandContext` in the same pass?** + No. That would enlarge the abstraction without addressing the actual + duplicated plumbing problem. + +- **Should out-of-scope local commands be forced onto `CommandContext` + just for uniformity?** + No. This pass should target the families where `CommandContext` already + provides real value or is already partially adopted. + +### Deferred to Implementation + +- **Exact API names for derived contexts.** + The implementation may settle on `with_target(...)`, + `for_target_from(...)`, or similar naming. The important contract is + that callers no longer pass raw `Printer` and `CliLayer` repeatedly. + +- **Whether static constructors remain temporarily during migration.** + If temporary wrappers reduce compile churn while family-by-family + patches land, they are acceptable. Final cleanup should remove the + now-redundant raw-plumbing entrypoints from in-scope call sites. + +- **Whether `CommandContext` should be cheaply cloneable or should build + derived variants from private state on demand.** + Either is acceptable if it preserves the abstraction boundary and does + not change runtime behavior. + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +| Boundary | Current shape | Target shape | +|---|---|---| +| `main.rs` -> command family | `dispatch(args, &cli_settings, &cli_layer, process_local_json, printer)` | `dispatch(args, &base_ctx)` | +| family dispatch -> leaf command | raw CLI plumbing plus `CommandContext::for_target(...)` inside the leaf | derive `target_ctx` / `connection_ctx` once from `base_ctx`, then pass `&CommandContext` or already-resolved client/output helpers | +| output mode lookup | `cli.output.format` / `cli.output.verbosity` | `ctx.user_settings().cli.output.*` | +| global `--json` guard | separate `process_local_json` parameter | `ctx` accessor/helper | +| printing | raw `printer` parameter | `ctx.printer()` or a narrower printer extracted from `ctx` | + +Directional flow: + +```text +bootstrap raw globals/settings for tracing + upgrade check + -> build base CommandContext once for the in-scope command dispatch + -> family dispatch receives &base_ctx + -> family derives: + target_ctx from target args + connection_ctx from storage-dir-aware args + base/local ctx for settings-only commands + -> leaf command reads: + ctx.cwd() + ctx.machine_settings() + ctx.user_settings().cli.output.* + ctx.require_no_json_override() or equivalent + ctx.printer() + ctx.server().await? + -> render-only helpers stay explicit over Printer / Styles where simpler +``` + +## Implementation Units + +- [x] **Unit 1: Reframe `CommandContext` as the invocation-boundary object** + +**Goal:** Make `CommandContext` capable of carrying the invocation +plumbing that is still leaking through command signatures, while keeping +the type narrowly scoped. + +**Requirements:** R1, R2, R3, R4, R6 + +**Dependencies:** None + +**Files:** +- Modify: `lib/crates/fabro-cli/src/command_context.rs` +- Modify: `lib/crates/fabro-cli/src/main.rs` +- Test: `lib/crates/fabro-cli/src/command_context.rs` + +**Approach:** +- Add the remaining invocation-only plumbing that does not already exist + on the context, specifically the global `--json` switch used today via + `process_local_json`. +- Make printer access part of the live API so the dead-code allowance on + the `printer` field/accessor can be removed. +- Add context-derivation methods that let callers obtain target-based or + connection-based variants from a base invocation context without + re-supplying raw `Printer` and `CliLayer`. +- Keep output format and verbosity sourced from + `ctx.user_settings().cli.output` rather than storing a second public + output-format copy on the side, while still exposing the explicit + invocation-global JSON flag separately for commands like `auth status` + whose contract is not identical to resolved output format. + +**Patterns to follow:** +- `lib/crates/fabro-cli/src/command_context.rs` +- `docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md` + for owner-first context boundaries + +**Test scenarios:** +- Happy path: a base context exposes the same output format and verbosity + that commands currently read from merged CLI settings. +- Happy path: a base context preserves both resolved output settings and + the explicit invocation-global JSON flag so commands can distinguish + between “resolved output format is JSON” and “user passed global + `--json`”. +- Happy path: deriving a target-based context preserves printer/global + JSON state and resolves server access through the existing + `server_client::connect_server_with_settings(...)` path. +- Edge case: deriving a connection-based context with a storage-dir + override changes only the storage-backed settings path and preserves + other invocation-scoped data. +- Error path: settings-load failures tied to context construction still + surface during context construction, while malformed target resolution + remains deferred until `ctx.server().await?` or explicit + `resolve_server_target(...)` calls. + +**Verification:** +- The `printer` field/accessor is no longer dead code. +- There is one clear way to obtain a base context and derive + target/connection variants without raw plumbing at the call site. +- The context API makes the distinction between resolved output format + and explicit global JSON invocation state unambiguous. + +- [x] **Unit 2: Move `main.rs` and the run/preflight/graph/sandbox boundary to context-first dispatch** + +**Goal:** Eliminate raw plumbing from the top-level dispatch path and the +run-oriented command family plus the public `sandbox preview` / +`sandbox ssh` boundary that already rely heavily on `CommandContext`. + +**Requirements:** R1, R3, R4, R5, R6, R7 + +**Dependencies:** Unit 1 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/main.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/command.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/create.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/preview.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/ssh.rs` +- Modify: `lib/crates/fabro-cli/src/commands/sandbox/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/resume.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/rewind.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/fork.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/wait.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/diff.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/logs.rs` +- Modify: `lib/crates/fabro-cli/src/commands/run/cp.rs` +- Modify: `lib/crates/fabro-cli/src/commands/preflight.rs` +- Modify: `lib/crates/fabro-cli/src/commands/validate.rs` +- Modify: `lib/crates/fabro-cli/src/commands/graph.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/run.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/create.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/start.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/attach.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/diff.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/logs.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/preflight.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/validate.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/graph.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/json_global.rs` +- Test: `lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs` +- Test: `lib/crates/fabro-cli/tests/it/scenario/recovery.rs` + +**Approach:** +- Build a base `CommandContext` in `main.rs` only after the existing + bootstrap phase completes. +- Change the run-family and run-adjacent entrypoints to accept context + instead of raw `cli` / `cli_layer` / `printer` bundles. +- Replace direct reads of `cli.output.format` and + `cli.output.verbosity` with `ctx.user_settings().cli.output.*`. +- Replace direct `process_local_json` threading with a context helper + for the small number of commands that still need it (`graph`, + `commands/sandbox/mod.rs` for the public preview/ssh boundary). +- Move the public `sandbox preview` / `sandbox ssh` dispatch boundary to + the same context-first shape as the run-family boundary, while leaving + the leaf implementation modules in `commands/run/*` if that remains + the cleanest internal organization. +- Keep `Styles` local to the leaf commands and keep `attach` / `start` + client helpers narrow if broadening them adds no value. + +**Execution note:** Start by preserving or expanding characterization +coverage for JSON/text output and global `--json` gating before removing +the old raw-plumbing signatures from these entrypoints. + +**Patterns to follow:** +- `lib/crates/fabro-cli/src/commands/run/create.rs` +- `lib/crates/fabro-cli/src/commands/preflight.rs` +- `lib/crates/fabro-cli/src/commands/graph.rs` +- `lib/crates/fabro-cli/src/commands/sandbox/mod.rs` + +**Test scenarios:** +- Happy path: `fabro run --detach` still prints a bare run ID in text + mode and the same JSON payload in JSON mode after output format moves + behind `CommandContext`. +- Happy path: `fabro run` / `resume` / `attach` still inherit verbose + rendering from CLI output verbosity and preserve idle-sleep behavior. +- Edge case: `fabro graph --json` without an explicit output file still + rejects the global JSON override exactly as it does today. +- Edge case: `fabro sandbox preview --open` still opens the browser only + when global JSON mode is not active. +- Edge case: `fabro sandbox ssh` still allows `--print` under global + JSON mode but continues to reject the unsupported interactive + combination. +- Error path: `preflight` and `validate` continue to fail on validation + errors with the same text-vs-JSON contract and exit behavior. +- Integration: create -> start -> attach and resume/recovery flows still + resolve targets, stream output, and summarize results through the same + black-box CLI contracts. + +**Verification:** +- `main.rs` no longer passes the raw plumbing bundle into the run / + preflight / validate / graph / sandbox preview-ssh boundary. +- Those command boundaries obtain shared invocation data exclusively via + `CommandContext`. + +- [x] **Unit 3: Migrate runs/artifact/store families to context-first family dispatch** + +**Goal:** Remove repeated target-context reconstruction from the command +families that already build a target-based `CommandContext` immediately +and mostly use raw CLI state only for output mode. + +**Requirements:** R1, R3, R4, R6, R7 + +**Dependencies:** Units 1-2 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/runs/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/runs/list.rs` +- Modify: `lib/crates/fabro-cli/src/commands/runs/archive.rs` +- Modify: `lib/crates/fabro-cli/src/commands/runs/rm.rs` +- Modify: `lib/crates/fabro-cli/src/commands/runs/inspect.rs` +- Modify: `lib/crates/fabro-cli/src/commands/artifact/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/artifact/list.rs` +- Modify: `lib/crates/fabro-cli/src/commands/artifact/cp.rs` +- Modify: `lib/crates/fabro-cli/src/commands/store/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/store/dump.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/archive.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/inspect.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/rm.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/store_dump.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/store.rs` + +**Approach:** +- Have each family receive a context-first boundary and derive its + target-based command context once, close to the namespace/selector + boundary. +- Switch output-mode branches to + `ctx.user_settings().cli.output.format`. +- Preserve existing client-sharing patterns such as + `ServerSummaryLookup::from_client(ctx.server().await?)`. +- Keep leaf helpers narrow where they already only need a resolved + client, resolved run ID, or printer. + +**Patterns to follow:** +- `lib/crates/fabro-cli/src/commands/secret/mod.rs` +- `lib/crates/fabro-cli/src/commands/artifact/mod.rs` + +**Test scenarios:** +- Happy path: `runs list`, `archive`, `unarchive`, and `rm` still render + the same JSON and text shapes after output-mode decisions move behind + the context. +- Happy path: `runs inspect` remains a JSON-only projection of server + state and still resolves the selected run before fetching the state. +- Edge case: “no runs found” and “no artifacts found” text-mode messages + remain unchanged. +- Error path: ambiguous or missing run selectors still fail through the + existing server/client resolution path. +- Integration: artifact listing/copy and store dump continue to hit the + same server-backed data path and respect output-format selection. + +**Verification:** +- These family dispatchers no longer need separate `cli_layer` and + `printer` arguments merely to reconstruct a target context. + +- [x] **Unit 4: Migrate PR, secret, and system command families** + +**Goal:** Align the families that mix local settings, target-based +server access, and storage-dir-aware server access onto the same +context-first boundary. + +**Requirements:** R1, R3, R4, R6, R7 + +**Dependencies:** Units 1-3 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/pr/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/pr/list.rs` +- Modify: `lib/crates/fabro-cli/src/commands/pr/create.rs` +- Modify: `lib/crates/fabro-cli/src/commands/pr/view.rs` +- Modify: `lib/crates/fabro-cli/src/commands/pr/merge.rs` +- Modify: `lib/crates/fabro-cli/src/commands/pr/close.rs` +- Modify: `lib/crates/fabro-cli/src/commands/secret/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/secret/list.rs` +- Modify: `lib/crates/fabro-cli/src/commands/secret/set.rs` +- Modify: `lib/crates/fabro-cli/src/commands/secret/rm.rs` +- Modify: `lib/crates/fabro-cli/src/commands/system/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/system/info.rs` +- Modify: `lib/crates/fabro-cli/src/commands/system/df.rs` +- Modify: `lib/crates/fabro-cli/src/commands/system/events.rs` +- Modify: `lib/crates/fabro-cli/src/commands/system/prune.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/pr.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_list.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_create.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_view.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_merge.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/pr_close.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/secret.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/secret_list.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/secret_set.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/secret_rm.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/system.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/system_info.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/system_df.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/system_events.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/system_prune.rs` + +**Approach:** +- Keep `pr`’s split between base-settings work and target-based run + lookup, but move both sides onto a common context-first boundary so + raw `cli_layer` / `printer` threading disappears from the family API. +- Preserve `secret`’s existing pattern of resolving the server once at + the family boundary and passing the client into subcommands. +- For `system`, derive connection-aware contexts from the base + invocation context so storage-dir override behavior remains explicit + and unchanged. + +**Patterns to follow:** +- `lib/crates/fabro-cli/src/commands/pr/mod.rs` +- `lib/crates/fabro-cli/src/commands/secret/mod.rs` +- `lib/crates/fabro-cli/src/commands/system/mod.rs` + +**Test scenarios:** +- Happy path: PR list/view/create/merge/close continue to render the same + JSON/text contracts and still resolve GitHub credentials from merged + local settings. +- Happy path: secret list/set/rm continue to resolve one server client at + the namespace boundary and honor JSON mode. +- Happy path: system info/df/events/prune continue to use + storage-dir-aware connection mode where appropriate. +- Edge case: missing secret / no matching runs-to-prune / empty PR list + still produce the same user-visible text-mode outcomes. +- Error path: invalid storage-dir or connection resolution still fails + before the command attempts remote work. +- Integration: the `system` family continues to respect explicit + `--storage-dir` overrides and local daemon resolution semantics. + +**Verification:** +- These family boundaries no longer accept raw plumbing bundles when the + only reason was to build a `CommandContext` or inspect output mode. + +- [x] **Unit 5: Finish remaining base/target context users** + +**Goal:** Complete the context-first migration for the remaining in-scope +command surfaces that already use `CommandContext` but still expose raw +plumbing at their entrypoints. + +**Requirements:** R1, R2, R3, R5, R6, R7 + +**Dependencies:** Units 1-4 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/commands/model.rs` +- Modify: `lib/crates/fabro-cli/src/commands/repo/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/repo/init.rs` +- Modify: `lib/crates/fabro-cli/src/commands/repo/deinit.rs` +- Modify: `lib/crates/fabro-cli/src/commands/auth/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/auth/login.rs` +- Modify: `lib/crates/fabro-cli/src/commands/auth/logout.rs` +- Modify: `lib/crates/fabro-cli/src/commands/auth/status.rs` +- Modify: `lib/crates/fabro-cli/src/commands/provider/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/provider/login.rs` +- Modify: `lib/crates/fabro-cli/src/commands/config/mod.rs` +- Modify: `lib/crates/fabro-cli/src/commands/version.rs` +- Modify: `lib/crates/fabro-cli/src/commands/doctor.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/model.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/model_list.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/model_test.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/repo.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/repo_init.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/repo_deinit.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/auth.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/provider.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/provider_login.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/config.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/version.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/doctor.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/json_global.rs` + +**Approach:** +- Move base-context-only command families (`auth`, `provider`, parts of + `repo`) onto `CommandContext` so JSON gating and printer usage come + from the shared invocation object. +- Use `ctx.machine_settings()` / `ctx.user_settings()` consistently for + local settings lookups rather than parallel raw-CLI arguments. +- Preserve the special-case JSON contract for `auth status`: explicit + invocation-global `--json` remains the only JSON trigger, even if + resolved CLI settings say `output.format = json`. +- Keep `repo deinit` and similar pure-local leaf helpers narrow if a + derived `json_output` boolean or `Printer` extracted from the context + keeps the internal helper clearer than passing the full context. + +**Patterns to follow:** +- `lib/crates/fabro-cli/src/commands/auth/login.rs` +- `lib/crates/fabro-cli/src/commands/auth/status.rs` +- `lib/crates/fabro-cli/src/commands/config/mod.rs` +- `lib/crates/fabro-cli/src/commands/version.rs` + +**Test scenarios:** +- Happy path: `auth status` and `provider login` preserve global `--json` + restrictions and still resolve the intended server target from merged + settings. +- Happy path: `config`, `version`, `model`, and `doctor` continue to + honor text-vs-JSON output without a direct `CliNamespace` parameter. +- Edge case: persisted `cli.output.format = "json"` without explicit + global `--json` still leaves `auth status` on its current text-mode + path. +- Edge case: explicit global `--json` still forces `auth status` onto + its JSON output path even though the implementation no longer receives + a raw `process_local_json` parameter. +- Edge case: `repo init` non-JSON progress output and JSON result + payloads stay unchanged. +- Edge case: `repo deinit` still stays local and does not accidentally + require server access just because the family boundary now uses + `CommandContext`. +- Error path: missing auth sessions, invalid server targets, and missing + GitHub access continue to fail with the same user-facing contract. + +**Verification:** +- Remaining in-scope command entrypoints no longer expose the raw + plumbing bundle as part of their public internal API. + +- [x] **Unit 6: Remove migration scaffolding and prove the boundary is clean** + +**Goal:** Delete transitional surface area and confirm the in-scope +command tree is consistently aligned on `CommandContext`. + +**Requirements:** R1, R2, R6, R7 + +**Dependencies:** Units 1-5 + +**Files:** +- Modify: `lib/crates/fabro-cli/src/command_context.rs` +- Modify: `lib/crates/fabro-cli/src/main.rs` +- Modify: in-scope command modules touched by transitional wrappers +- Test: `lib/crates/fabro-cli/tests/it/cmd/top_level.rs` +- Test: `lib/crates/fabro-cli/tests/it/cmd/json_global.rs` + +**Approach:** +- Remove temporary wrappers or compatibility constructors that were only + present to get through the migration. +- Delete stale comments and “still being wired through” dead-code + annotations once the boundary is real. +- Do a final sweep to ensure in-scope command boundaries are not still + taking `&CliNamespace`, `&CliLayer`, `Printer`, and + `process_local_json` together out of habit. + +**Patterns to follow:** +- Keep cleanup limited to true scaffolding removal; do not reopen command + semantics or formatting behavior in the cleanup pass. + +**Test scenarios:** +- Test expectation: none -- cleanup-only unit. Behavioral coverage should + already exist from Units 1-5. + +**Verification:** +- The command-boundary API is visibly simpler and consistent across the + in-scope families. +- No in-scope command still looks half-migrated. + +## System-Wide Impact + +- **Interaction graph:** `main.rs` bootstrap -> base `CommandContext` -> + family dispatchers -> derived target/connection contexts -> + `server_client.rs` / `user_config.rs`. This touches nearly every + user-facing CLI family that already talks to settings or server + resolution. +- **Error propagation:** context-construction failures remain early and + synchronous at the command boundary; server access errors still flow + through `ctx.server().await?` and should not move deeper into render + helpers. +- **State lifecycle risks:** the biggest correctness risk is accidental + reuse of the wrong derived context, especially storage-dir-aware + contexts in the `system` family and target-based contexts in run/PR + flows. +- **API surface parity:** although this is an internal refactor, it + touches external CLI contracts indirectly through JSON/text output, + verbosity, global `--json` restrictions, and server-target resolution. +- **Integration coverage:** black-box command tests and scenario tests + are the main safety net. Unit tests should only cover context + construction/derivation and not replace CLI integration coverage. +- **Unchanged invariants:** tracing and upgrade bootstrap ordering stays + in `main.rs`; `exec` keeps its distinct direct-provider path; server + connection semantics stay in `server_client.rs`; `Styles` remain + command-local. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Output-mode drift when replacing `cli.output.format` reads with `ctx.user_settings().cli.output.format` | Keep characterization coverage in existing `cmd/*` tests for both text and JSON paths before deleting the raw parameters | +| Global `--json` behavior changes while moving `process_local_json` into the context | Add targeted coverage in `json_global.rs`, `auth.rs`, `provider_login.rs`, `graph.rs`, and sandbox preview/ssh command tests | +| Storage-dir-aware system commands accidentally derive a target-mode context instead of a connection-mode context | Keep connection-specific derivation explicit and cover `system_info`, `system_df`, `system_events`, and `system_prune` with CLI integration tests | +| `CommandContext` grows into a second god object | Keep explicit scope rules: invocation plumbing only, no command args, no `Styles`, no feature-specific render state | +| The refactor becomes a giant compile-fix patch with poor reviewability | Land the work family-by-family with temporary compatibility wrappers where needed, and verify each family with its existing tests before cleanup | + +## Documentation / Operational Notes + +- No user-facing documentation changes are expected. +- Internal comments in `command_context.rs` and nearby command modules + should be updated to describe the final boundary, not the transitional + “still being wired through” state. +- The earlier April 8 plan should remain as historical context; this plan + supersedes it for the command-boundary alignment work. + +## Sources & References + +- Prior plan: `docs/plans/2026-04-08-cli-services-command-context-refactor-plan.md` +- Related plan: `docs/plans/2026-04-22-001-refactor-settings-api-entrypoints-plan.md` +- Related code: + `lib/crates/fabro-cli/src/command_context.rs` + `lib/crates/fabro-cli/src/main.rs` + `lib/crates/fabro-cli/src/server_client.rs` + `lib/crates/fabro-cli/src/commands/run/mod.rs` + `lib/crates/fabro-cli/src/commands/pr/mod.rs` + `lib/crates/fabro-cli/src/commands/secret/mod.rs` + `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` +- Related history: + `93b6577cd simplify: drop duplicate settings plumbing from cli/server refactor` + `367fd9302 refactor(cli): centralize command settings and server access` + `4b30a5f16 refactor(cli): route command output through Printer` diff --git a/docs/plans/2026-04-23-002-refactor-combine-trait-uv-pattern-plan.md b/docs/plans/2026-04-23-002-refactor-combine-trait-uv-pattern-plan.md new file mode 100644 index 000000000..98e2d3ad4 --- /dev/null +++ b/docs/plans/2026-04-23-002-refactor-combine-trait-uv-pattern-plan.md @@ -0,0 +1,339 @@ +--- +title: "refactor: replace merge.rs with Combine trait + derive (uv pattern)" +type: refactor +status: completed +date: 2026-04-23 +sequence_after: 2026-04-23-001-refactor-collapse-settings-resolve-indirection-plan.md +--- + +# refactor: replace merge.rs with Combine trait + derive (uv pattern) + +## For the engineer picking this up + +This is the second of two related settings-architecture refactors. PR 1 (`docs/plans/2026-04-23-001-...`) collapses the resolve-indirection layer (`Resolver`, `ResolvedSettingsTree`, `*_into` methods). PR 2 — this one — replaces the 750-line hand-rolled `merge.rs` with a small `Combine` trait, a dumb derive macro, and a handful of newtypes. Land PR 1 first; the two are orthogonal but PR 1 is smaller and lower-risk. + +The pattern is lifted directly from astral-sh/uv. The user's reference is `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-settings/src/combine.rs` and `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-macros/src/lib.rs` — read both before starting. They're short. + +## Overview + +`lib/crates/fabro-config/src/merge.rs` contains 42 hand-rolled `combine_*` functions implementing the v2 settings-merge matrix. Most are mechanical "field: higher.x.or(lower.x)" boilerplate. Replace them with: + +1. A `Combine` trait in `fabro-types` (single `fn combine(self, other: Self) -> Self` method). +2. A dumb `#[derive(Combine)]` proc-macro in the existing `fabro-macros` crate that emits `Self { f: self.f.combine(other.f), ... }` field-by-field. ~25 lines. +3. Concrete `impl Combine` blocks for `Option` types (`Option`, `Option`, option-wrapped settings enums, etc.). Most are one-liners (`self.or(other)`). +4. A generic `impl Combine for Option` for recursive optional subtables only. +5. Newtypes for strategy-bearing collection fields (`ReplaceMap`, `StickyMap`, `MergeMap`, plus exact Vec impls or list newtypes where needed). +6. Bespoke `impl Combine` on irregular cases (hooks, splices, whole-replace structs). + +Every merge-participating settings type gets a `Combine` impl. Field-merge structs can derive it. Whole-replace structs and special collection cases must implement it manually. `merge.rs` deletes entirely after its tests are moved. + +## Why this matters (don't skip — it's load-bearing) + +I (the previous engineer / Claude) initially recommended AGAINST a derive macro because attribute-driven derives hide the merge matrix behind macro expansion. **uv's pattern sidesteps this by encoding the rule in the type, not in attributes.** The derive is dead-stupid — it dispatches to `field.combine(other.field)` and that's it. The intelligence lives one level down in `impl Combine` for each field type. The merge matrix is auditable in one file (`combine.rs`) with one stanza per field strategy, and the type system enforces it: you can't accidentally use the wrong merge strategy for a map because `ReplaceMap`, `StickyMap`, and `MergeMap` are different types. + +If you're tempted to add `#[combine(strategy = "...")]` attributes — DON'T. That defeats the entire point. If a field needs a custom rule, it gets a newtype. + +## Reference: how uv does it + +Read these in order: + +1. `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-macros/src/lib.rs` lines 17-52 — the entire derive macro. +2. `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-settings/src/combine.rs` — the trait, the `impl_combine_or!` macro for one-line `self.or(other)` impls, and the bespoke struct impls. Read uv's collection impls for context, but do not copy them blindly; Fabro's collection fields need the explicit strategy rules below. +3. `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-settings/src/settings.rs` — search for `#[derive(...Combine...)]` to see how it's applied to the `*Options` structs. + +The whole pattern is small — under 400 lines including the derive crate. + +## Fabro-specific wrinkle: collection strategy lives in field types + +uv's pattern works cleanly because every field type is structurally distinct. Fabro currently has multiple bare collection fields whose merge strategy depends on the field, not the Rust type. Do not add a blanket `Combine` impl for `HashMap` or `Vec`; make strategy visible in the field type instead. + +| Field | Type today | Today's `merge.rs` rule | New field type | +|-------|------------|-------------------------|----------------| +| `project.metadata`, `workflow.metadata`, `run.metadata` | `HashMap` | replace whole map if higher/self is non-empty (`merge_string_map_replace`) | `ReplaceMap` | +| `run.sandbox.env` | `HashMap` | sticky-by-key (`merge_string_map_sticky`) | `StickyMap` | +| `daytona.labels` | `HashMap` | sticky-by-key (`merge_string_map_sticky`) | `StickyMap` | +| `run.agent.mcps`, `cli.exec.agent.mcps` | `HashMap` | sticky-by-key; higher/self replaces the whole entry for the same key | `StickyMap` | +| `server.integrations.github.permissions` | `HashMap` | sticky-by-key | `StickyMap` | +| `run.notifications` | `HashMap` | per-key recursive combine | `MergeMap` | + +To use uv's "rule lives in the type" pattern, introduce these newtypes in `lib/crates/fabro-types/src/settings/`: + +``` +#[serde(transparent)] +pub struct ReplaceMap(pub HashMap); + +#[serde(transparent)] +pub struct StickyMap(pub HashMap); + +#[serde(transparent)] +pub struct MergeMap(pub HashMap); +``` + +And impl `Combine` for each. Field declarations in the layer types now document the rule: + +``` +pub struct ProjectLayer { + pub metadata: ReplaceMap, // visibly "replace whole" + // ... +} +pub struct RunSandboxLayer { + pub env: StickyMap, // visibly "sticky-by-key" + // ... +} +pub struct RunLayer { + pub notifications: MergeMap, // visibly "per-key recursive" + // ... +} +``` + +This is a strict ergonomic improvement: today you have to grep `merge.rs` to find out how `metadata` merges; after, the field type tells you. + +**Migration consideration:** these newtypes need careful serde handling. Use bare newtypes, not `Option<...>`, for fields that are bare `HashMap` today. The existing schema treats "absent" and "present but empty" as the same default empty map, and `merge.rs` falls back when the higher map is empty. Preserve that behavior with `#[serde(default, skip_serializing_if = "ReplaceMap::is_empty")]` / equivalent helpers. Verify with the existing `tests/parse_*` tests in `fabro-config` and add new round-trip tests. + +## Irregular cases (hand-written `impl Combine`) + +These don't fit the derive pattern. Each gets a bespoke `impl Combine` on its own type. Look at `merge.rs` for current behavior: + +| Type | Current fn | New shape | +|------|-----------|-----------| +| `Vec` | `combine_hooks` | exact `impl Combine for Vec` or `HookList` newtype — ordered merge with optional `id` replacement | +| `Vec` | `splice_model_fallbacks` | exact `impl Combine for Vec` or `ModelFallbackList` newtype — splice with `Splice` sentinel | +| `Vec` (events) | `splice_events` | exact `impl Combine for Vec` or `EventList` newtype — splice variant | +| `RunPrepareLayer` | `combine_run_prepare` (returns `higher` whole) | `impl Combine for RunPrepareLayer { fn combine(self, _other) = self }` | +| `CliTargetLayer` | `combine_cli_target` (returns `higher` whole) | same shape as above | +| `ServerListenLayer` | `combine_listen` (returns `higher` whole) | same shape as above | +| `FeaturesLayer` | top-level `replace_if_some` | `impl Combine for FeaturesLayer { fn combine(self, _other) = self }` | +| `RunArtifactsLayer` | `replace_if_some` | whole-replace `impl Combine` returning `self` | +| `RunCheckpointLayer.exclude_globs` | guards "if higher empty, take lower" | hand-written `impl Combine for RunCheckpointLayer` that returns `self` when `self.exclude_globs` is non-empty, else `other` | +| `Option<...>` fields currently merged with `higher.x.or(lower.x)` | one-line whole replacement | either concrete `impl Combine for Option` or an inner `impl Combine for Leaf` returning `self` | +| `MergeMap` | `combine_notifications` (per-key recursive combine) | `impl Combine for MergeMap` | +| `StickyMap` | `merge_string_map_sticky` | per-key sticky replacement, not recursive field merge | + +Do not add a blanket `impl Combine for Vec`. Exact Vec impls are allowed only for the concrete special cases above. If an exact impl starts to collide with an option/list strategy, use a newtype instead. + +## Convention to pick: argument order / which side wins + +uv uses `self.combine(other)` where **`self` wins** (self is the higher-precedence layer). Fabro's current `merge.rs` uses `combine_files(lower, higher)` where **higher (the second arg) wins**. Pick one and stick with it. + +Recommendation: adopt uv's "self wins" convention. It reads naturally — `user_settings.combine(defaults)` = "user settings, with defaults as fallback." For multi-layer stacking: `workspace.combine(user).combine(defaults)` gives `workspace > user > defaults`. + +This means `apply_builtin_defaults` becomes: + +``` +pub fn apply_builtin_defaults(layer: SettingsLayer) -> SettingsLayer { + layer.combine(defaults_layer().clone()) +} +``` + +And the existing `combine_files(lower, higher)` callers need their argument order swapped. + +## Core coherence rules + +These rules are mandatory. They avoid the compile-time and behavior traps that a naive port from uv would introduce in Fabro. + +1. `Combine` lives in `fabro-types`, because the settings layer structs live in `fabro-types` and `fabro-config` already depends on `fabro-types`. Putting the trait in `fabro-config` would create a dependency cycle. +2. The derive macro lives in the existing `fabro-macros` proc-macro crate. `fabro-types` already depends on `fabro-macros`, so do not create a new `fabro-config-macros` crate. +3. Do not implement `Combine` for scalar inner types like `String`, `bool`, `InterpString`, or settings enums. Implement `Combine` for `Option`, `Option`, `Option`, `Option`, etc. This lets `impl Combine for Option` coexist with concrete scalar option impls. +4. `impl Combine for Option` is for recursive optional subtables only. It preserves whole-replace behavior only when the inner type's `Combine` impl returns `self`. +5. Do not derive `Combine` for a type just because it is a `*Layer`. Derive only when the current `merge.rs` behavior is field-by-field merge. If current behavior is `higher.or(lower)` or `replace_if_some`, the inner type needs a whole-replace `Combine` impl returning `self`. +6. Do not implement blanket `Combine` for `Vec` or `HashMap`. Use exact impls or strategy newtypes. + +## Implementation Units + +- [x] **Unit 1: Add `Combine` trait + option leaf impls in `fabro-types`** + +**Goal:** Lay down the trait infrastructure with no derive yet. + +**Files:** +- Create: `lib/crates/fabro-types/src/settings/combine.rs` +- Modify: `lib/crates/fabro-types/src/settings/mod.rs` (add `mod combine; pub use combine::Combine;`) +- Modify: `lib/crates/fabro-types/src/lib.rs` if desired to re-export `settings::Combine` at the crate root. + +**Approach:** +- Copy uv's trait shape verbatim. Convention: `self` wins. +- Add `impl Combine for Option` for recursive optional subtables: + ``` + match (self, other) { + (Some(this), Some(fallback)) => Some(this.combine(fallback)), + (this, fallback) => this.or(fallback), + } + ``` +- Add an `impl_combine_or_option!` macro for one-line whole-replace option leaves. List every leaf scalar/enum used in `*Layer` types as `Option` impls: `Option`, `Option`, numeric options, `Option`, `Option`, `Option`, `Option`, `Option`, `Option` where applicable, etc. Grep `lib/crates/fabro-types/src/settings/` for the full list. +- Add concrete whole-replace option impls for composite leaves that should not recursively merge, such as `Option>` for `run.inputs` and any `Option>` fields that remain bare vectors after Unit 3. +- Do **not** implement `Combine` for scalar inner types (`String`, `bool`, `InterpString`, enums). If `String: Combine` exists, it conflicts with `impl Combine for Option`. +- Do **not** implement blanket `Combine` for `Vec` or `HashMap`. + +**Test scenarios:** +- Happy path: `Some("a").combine(Some("b")) == Some("a")`. +- Happy path: `None.combine(Some("a")) == Some("a")`. +- Edge case: `Some("a").combine(None) == Some("a")`. +- Recursive option: `Some(FieldMergeLayer { a: Some(1), b: None }).combine(Some(FieldMergeLayer { a: Some(2), b: Some(3) }))` preserves `a = 1` and inherits `b = 3`. +- Whole-replace option: `Some(WholeReplaceLayer { a: Some(1), b: None }).combine(Some(WholeReplaceLayer { a: Some(2), b: Some(3) }))` returns the `self` layer without inheriting `b`. +- Each option leaf type gets at least one assertion proving `self.or(other)` semantics. + +**Verification:** `cargo build -p fabro-types` clean. New unit tests pass. + +--- + +- [x] **Unit 2: Add `#[derive(Combine)]` to the existing `fabro-macros` crate** + +**Goal:** The dumb derive macro. + +**Files:** +- Modify: `lib/crates/fabro-macros/src/lib.rs` +- Modify: `lib/crates/fabro-types/src/lib.rs` or `lib/crates/fabro-types/src/settings/mod.rs` to re-export the derive if that keeps call sites simple. + +**Approach:** +- Copy uv's `derive_combine` verbatim from `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-macros/src/lib.rs:17-52`. +- The derive emits `Self { f1: self.f1.combine(other.f1), ... }` for named fields. Unnamed fields / enums: `unimplemented!()` (uv does the same; we don't need them). +- The derive may reference `crate::settings::Combine` or `crate::Combine`; if using `crate::Combine`, re-export the trait at the `fabro-types` crate root first. This works because the derive output expands inside `fabro-types`, where the layer structs live. +- Do not derive `Combine` in `fabro-config`; `fabro-config` is the consumer of the combined settings tree, not the owner of the layer types. + +**Test scenarios:** +- Happy path: a small struct `#[derive(Combine)] struct Foo { a: Option, b: Option }` produces a working `Combine` impl. Proven by a unit test that asserts `Foo { a: Some(1), b: None }.combine(Foo { a: Some(2), b: Some("x".into()) }) == Foo { a: Some(1), b: Some("x".into()) }`. +- Integration: nested struct with an `Option` field combines recursively. +- Whole-replace integration: nested struct with an `Option` field does not inherit fields when both sides are `Some`. + +**Verification:** `cargo build -p fabro-macros` and `cargo build -p fabro-types` clean. Derive test passes. + +--- + +- [x] **Unit 3: Introduce collection strategy newtypes and migrate strategy-bearing maps** + +**Goal:** Move map merge rules into the type system. + +**Files:** +- Create: `lib/crates/fabro-types/src/settings/maps.rs` (or extend existing module) +- Modify: `lib/crates/fabro-types/src/settings/mod.rs` to export the newtypes +- Modify: `lib/crates/fabro-types/src/settings/combine.rs` or `maps.rs` to add `impl Combine for ReplaceMap`, `StickyMap`, and `MergeMap`. +- Modify field declarations in `lib/crates/fabro-types/src/settings/{project,workflow,run,cli,server}.rs`: + - `project.metadata`, `workflow.metadata`, `run.metadata` -> `ReplaceMap` + - `run.sandbox.env` -> `StickyMap` + - `daytona.labels` -> `StickyMap` + - `run.agent.mcps`, `cli.exec.agent.mcps` -> `StickyMap` + - `server.integrations.github.permissions` -> `StickyMap` + - `run.notifications` -> `MergeMap` +- Audit every consumer of these fields. They currently access `HashMap` directly; with newtypes they'll need `.0`, `Deref`, iterator helpers, or explicit conversion at resolve boundaries. + +**Approach:** +- `#[serde(transparent)]` on the newtype. +- Implement `Deref>`, `DerefMut`, `From>`, and `IntoIterator` as needed so existing read-side consumers keep working without churn. +- Implement `Default`, `Debug`, `Clone`, `PartialEq`, `Serialize`, `Deserialize`. +- `impl Combine for ReplaceMap`: if `self.0` is non-empty take self, else take other (matches today's `merge_string_map_replace` with self/other swapped per the convention chosen in Unit 1). +- `impl Combine for StickyMap`: per-key insert-or-keep where `self` wins on conflict (matches `merge_string_map_sticky` after swapping argument order). +- `impl Combine for MergeMap`: per-key recursive combine where `self` wins on conflict and missing keys are inherited from `other`. +- Keep `run.inputs: Option>` as a concrete whole-replace option leaf unless there is a stronger reason to newtype it. + +**Test scenarios:** +- Happy path: `ReplaceMap` round-trips through TOML deserialize/serialize unchanged. +- Happy path: `ReplaceMap::combine` with both non-empty takes `self`'s map whole. +- Happy path: `StickyMap::combine` merges keys, `self` wins on conflict. +- Happy path: `MergeMap` recursively combines an existing route by key. +- Edge case: `ReplaceMap` empty + `StickyMap` empty + `MergeMap` empty — combine produces empty values and preserves fallback behavior. +- Integration: existing `tests/parse_*` tests still parse the same TOML fixtures without modification (proves transparent serde works). + +**Verification:** All existing parse tests pass. New newtype tests pass. + +--- + +- [x] **Unit 4: Add selective `Combine` impls to settings layer types** + +**Goal:** Cover every settings type. After this unit, `SettingsLayer.combine(other)` works. + +**Files:** +- Modify: `*Layer` definitions in `lib/crates/fabro-types/src/settings/{cli,project,workflow,run,server,features,layer}.rs` to add `#[derive(Combine)]` only where current behavior is field-by-field merge. +- Modify: whole-replace and irregular types to add bespoke `impl Combine`. Place each impl next to its type definition, or group them in `settings/combine.rs` if local style reads cleaner. +- Move the tests currently embedded in `lib/crates/fabro-config/src/merge.rs` into a surviving test module before deleting `merge.rs`. + +**Approach:** +- Walk through `merge.rs` function-by-function. For each `combine_(lower, higher) -> NameLayer`: + - If the body is field-by-field `higher.x.or(lower.x)` for every field → add `#[derive(Combine)]` to the struct, delete the function. + - If one or two fields are special (`merge_string_map_replace` etc.) → if those fields have been migrated to newtypes (Unit 3), the derive handles it. If not, hand-write `impl Combine` on the struct. + - If the body returns `higher` whole → hand-written `impl Combine` returning `self`. +- If a field currently uses `higher.x.or(lower.x)` and `x` is an `Option`, treat it as whole-replace unless the current `merge.rs` calls a recursive `combine_*` helper for that type elsewhere. +- For Vec types with splice semantics (model fallbacks, events) → exact bespoke `impl Combine for Vec<...>` or a list newtype. The current `splice_*` helpers can be inlined into the impls. Do not add a blanket Vec impl. +- For hook lists → exact bespoke `impl Combine for Vec` or a `HookList` newtype preserving the current id-aware ordering. +- For `RunCheckpointLayer` → hand-write the special "self non-empty wins, otherwise fallback" behavior. +- For `FeaturesLayer`, `RunPrepareLayer`, `RunArtifactsLayer`, `CliTargetLayer`, and `ServerListenLayer` → whole-replace impl returning `self`. + +**Test scenarios:** +- Happy path: for each layer struct, an existing `merge.rs` test (search for `#[test]` in `lib/crates/fabro-config/src/merge.rs` or `tests/`) continues to pass with the new infrastructure substituted. (Recommended: write the new infra to coexist with `merge.rs` initially, swap call sites in Unit 5, then delete `merge.rs` in Unit 6.) +- Edge case: hooks ordered merge with id replacement — assert the exact ordering produced today is preserved. +- Edge case: model fallbacks splice — verify `Splice` sentinel handling. +- Edge case: a whole-replace optional subtable with missing fields does not inherit fallback fields. + +**Verification:** `cargo build --workspace` clean. Existing merge tests still pass when run against the new trait dispatch. + +--- + +- [x] **Unit 5: Swap `combine_files` call sites to use `Combine` trait; rewrite `apply_builtin_defaults`** + +**Goal:** All merging goes through `.combine()`. + +**Files:** +- Modify: `lib/crates/fabro-config/src/defaults.rs` — `apply_builtin_defaults` becomes `layer.combine(defaults_layer().clone())`. +- Modify: every caller of `combine_files` — search `grep -rn "combine_files" lib/`. Audit and convert each. +- Modify callers to import `fabro_types::settings::Combine` (or `fabro_types::Combine` if re-exported at crate root). +- Argument-order check: today's `combine_files(lower, higher)` becomes `higher.combine(lower)` under the "self wins" convention. + +**Verification:** `cargo build --workspace` clean. `cargo nextest run --workspace` green. + +--- + +- [x] **Unit 6: Delete `merge.rs`** + +**Goal:** Final cleanup. + +**Files:** +- Delete: `lib/crates/fabro-config/src/merge.rs` +- Modify: `lib/crates/fabro-config/src/lib.rs` remove `mod merge;` and any re-exports of merge helpers (`combine_files`, etc.) +- Confirm the characterization tests formerly inside `merge.rs` now live in a surviving test module. + +**Verification:** +- `cargo build --workspace` clean. +- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` clean. +- `cargo nextest run --workspace` green. +- `grep -rn "combine_files\|merge_option\|merge_string_map" lib/` returns no hits. + +## System-Wide Impact + +- **API surface:** `fabro_config::combine_files` removed. `fabro_types::settings::Combine` becomes the trait API for layer combination. Likely no external callers depend on `combine_files`, but verify with grep before deleting. +- **Consumer code:** Field reads on `metadata`/`env`/`labels`/`notifications`/`mcps`/`permissions` may need `.0`, iterator helpers, or conversion if `Deref` is not sufficient. Audit during Unit 3. +- **Serde compatibility:** `#[serde(transparent)]` on newtypes must round-trip identically to today's bare `HashMap` fields. Existing `tests/parse_*` are the canary. +- **Test coverage:** every `merge.rs` test must keep passing, just running through the new infrastructure. Move those tests before deleting `merge.rs`; do not lose behavior parity coverage. +- **Net code change:** ~750 lines of `merge.rs` removed; ~25 (derive macro) + ~160 (`combine.rs` trait + option leaf impls + strategy impls) + ~80 (newtypes + consumer adjustments) + bespoke impls. Net reduction is still expected, but exact line count is less important than preserving the merge matrix. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| `Deref` on newtypes doesn't cover every access pattern (e.g., methods that take `HashMap` by value or by `&mut`) | Audit consumers during Unit 3. Add `From`/`Into` impls or change consumer code to take the newtype. | +| Serde `#[serde(transparent)]` doesn't behave identically to bare HashMap for some TOML edge cases | The existing `tests/parse_*` fixtures are the contract. Run them after Unit 3; any failure is the migration bug. | +| Convention swap (lower/higher → self/other) introduces subtle bugs at `combine_files` call sites | Do Unit 5 carefully. Each call-site swap is a 2-line diff but easy to invert. Lean on existing merge tests. | +| Generic `Option` accidentally field-merges a subtable that currently replaces whole | Unit 4 must audit every `higher.x.or(lower.x)` site. Any inner type with whole-replace semantics gets `impl Combine for T { fn combine(self, _other) -> Self { self } }` or a concrete `impl Combine for Option` leaf impl. Add a regression test where the fallback has a field missing from self and confirm it is not inherited. | +| Exact `Vec<...>` impls collide with a future broad list strategy | Do not add blanket `Vec` impls. If exact Vec impls become awkward, migrate those fields to explicit newtypes (`HookList`, `ModelFallbackList`, `EventList`) instead. | +| Macro path breaks because derive output expands inside `fabro-types` modules | Re-export `Combine` at a stable path before deriving. Prefer `crate::settings::Combine` or `crate::Combine` and verify with `cargo build -p fabro-types` in Unit 2. | + +## Open Questions + +### Resolved (from design conversation) + +- **Trait + dumb derive vs. attribute-driven derive?** Dumb derive (uv pattern). Attributes hide the merge matrix. +- **Where does `Combine` live?** `fabro-types`, because the layer types live there and `fabro-config` depends on `fabro-types`. +- **Where does the derive live?** Existing `fabro-macros`, not a new `fabro-config-macros` crate. +- **How does generic `Option` avoid changing whole-replace behavior?** Whole-replace inner types implement `Combine` by returning `self`; scalar leaves get concrete `Option` impls and do not implement `Combine` on `T`. +- **Newtypes for map variants?** Yes. Rule lives in the type. Include `ReplaceMap`, `StickyMap`, and `MergeMap`, not just the original three `HashMap` cases. +- **Blanket collection impls?** No blanket `Vec` or `HashMap` impls. +- **Convention?** "Self wins" (uv). Swap argument order at call sites in Unit 5. + +### Deferred to Implementation + +- **Should `combine_files` survive as a thin wrapper or be deleted entirely?** Probably delete; `layer.combine(other)` reads fine. +- **Exact Vec impls or list newtypes for hooks/fallbacks/events?** Start with exact impls if they stay conflict-free. Move to `HookList`, `ModelFallbackList`, or `EventList` if type coherence or readability gets worse. + +## Sources & References + +- **Reference implementation:** `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-settings/src/combine.rs` and `/Users/bhelmkamp/p/astral-sh/uv/crates/uv-macros/src/lib.rs` (read both fully before starting). +- **Sequencing:** Land `2026-04-23-001-refactor-collapse-settings-resolve-indirection-plan.md` first. +- **Design conversation:** preserved in chat transcript with Bryan dated 2026-04-23. Key decisions: rule-lives-in-type beats attribute-driven derive; collection merge strategies need newtypes; `WorkflowSettings` includes `ServerNamespace` (PR 1 decision); `Combine` is the right shape but `Resolve` is not (resolve is per-field, not per-type). +- **Origin of current `merge.rs`:** "v2 merge matrix implementation" header note; rules trace to settings TOML redesign requirements (`docs/plans/2026-04-08-settings-toml-redesign-implementation-plan.md` and the follow-on handoffs). diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 8a1f33e6e..bac80bc2e 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -947,13 +947,13 @@ fabro secret rm ANTHROPIC_API_KEY --- -## `fabro store dump` +## `fabro dump` -Export the contents of a run's store-backed state to a directory for debugging and inspection. +Export the contents of a run's durable state to a directory for debugging and inspection. ```bash -fabro store dump -fabro store dump abc123 -o ./debug-output +fabro dump +fabro dump abc123 -o ./debug-output ``` | Argument / Flag | Description | diff --git a/docs/reference/run-directory.mdx b/docs/reference/run-directory.mdx index d707afa35..f463697b3 100644 --- a/docs/reference/run-directory.mdx +++ b/docs/reference/run-directory.mdx @@ -30,18 +30,18 @@ These paths are local runtime state and caches, not the canonical run state. - **`runtime/`** — Local runtime files. Today this is mainly materialized blob payloads under `runtime/blobs/`. - **`nodes/{manager_node}_{visit}/child/`** — Nested scratch directories for manager-loop child workflows. -Large durable values, event streams, checkpoints, diffs, conclusions, and retros are no longer projected into live scratch by default. Use `fabro logs`, `fabro inspect`, the API, or `fabro store dump` for those surfaces. +Large durable values, event streams, checkpoints, diffs, conclusions, and retros are no longer projected into live scratch by default. Use `fabro logs`, `fabro inspect`, the API, or `fabro dump` for those surfaces. ## Reconstructed and export-only layouts -Reconstructed metadata branches and `fabro store dump` exports now use the same core layout: +Reconstructed metadata branches and `fabro dump` exports now use the same core layout: - `run.json` for the current projection snapshot, including the current checkpoint - `graph.fabro` for workflow source - `stages/retro/*.md` for retro prompt/response text - `stages/{node_id}@{visit}/...` for per-stage prompt, response, status, diff, stdout, and stderr files -`fabro store dump` adds export-only history surfaces on top of that shared layout: +`fabro dump` adds export-only history surfaces on top of that shared layout: - `events.jsonl` for the durable event stream - `checkpoints/*.json` for checkpoint history snapshots 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 ce5f96ae0..05b4b7466 100644 --- a/docs/superpowers/specs/2026-04-18-web-install-design.md +++ b/docs/superpowers/specs/2026-04-18-web-install-design.md @@ -260,7 +260,7 @@ The web wizard produces **the same on-disk state** as the CLI install. The TOML- Files written: - `~/.fabro/settings.toml` — server config, auth methods, GitHub integration strategy. -- `/server.env` — `FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY`, `SESSION_SECRET`, `FABRO_DEV_TOKEN`, plus GitHub App env pairs (`GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`) if the App strategy was chosen. +- `/server.env` — `SESSION_SECRET`, `FABRO_DEV_TOKEN`, plus GitHub App env pairs (`GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`) if the App strategy was chosen. - `/vaults/default/secrets.json` — vault entries for LLM API key credentials and (if Token strategy) `GITHUB_TOKEN`. Path matches `Storage::secrets_path()` at `lib/crates/fabro-config/src/storage.rs:38`. - `/server.dev-token` — the per-storage dev token, written via `Storage::server_state().dev_token_path()` at `storage.rs:103`. The CLI install also writes a home-level mirror at `Home::from_env().dev_token_path()` (`install.rs:1994-1999`); the web flow does the same to keep parity, since the home-level file is what tooling outside the storage dir expects to find. - Artifact store metadata stamped with `FABRO_VERSION` via `write_artifact_store_metadata` (`install.rs:1458`). diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 44e4160e3..798518579 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -514,7 +514,7 @@ pub(crate) struct InspectArgs { } #[derive(Args)] -pub(crate) struct StoreDumpArgs { +pub(crate) struct DumpArgs { #[command(flatten)] pub(crate) server: ServerTargetArgs, @@ -803,10 +803,6 @@ pub(crate) struct RunWorkerArgs { #[arg(long, hide = true)] pub(crate) storage_dir: Option, - /// Short-lived bearer token for artifact uploads - #[arg(long, hide = true)] - pub(crate) artifact_upload_token: Option, - /// Run scratch directory #[arg(long)] pub(crate) run_dir: PathBuf, @@ -1001,8 +997,8 @@ pub(crate) enum Commands { Parse(ParseArgs), /// Inspect and copy run artifacts (screenshots, reports, traces) Artifact(ArtifactNamespace), - /// Export store-backed run state for debugging - Store(StoreNamespace), + /// Export a run's durable state to a directory + Dump(DumpArgs), #[command(flatten)] RunsCmd(RunsCommands), /// List and test LLM models @@ -1085,9 +1081,7 @@ impl Commands { ArtifactCommand::List(_) => "artifact list", ArtifactCommand::Cp(_) => "artifact cp", }, - Self::Store(ns) => match &ns.command { - StoreCommand::Dump(_) => "store dump", - }, + Self::Dump(_) => "dump", Self::Exec(_) => "exec", Self::RunCmd(cmd) => cmd.name(), Self::Preflight(_) => "preflight", @@ -1199,18 +1193,6 @@ pub(crate) enum ArtifactCommand { Cp(ArtifactCpArgs), } -#[derive(Args)] -pub(crate) struct StoreNamespace { - #[command(subcommand)] - pub(crate) command: StoreCommand, -} - -#[derive(Subcommand)] -pub(crate) enum StoreCommand { - /// Export a run's durable state to a directory - Dump(StoreDumpArgs), -} - #[derive(Args)] pub(crate) struct SecretNamespace { #[command(flatten)] diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index b5351d640..ee215b554 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -3,13 +3,14 @@ use std::sync::Arc; use anyhow::{Context as _, Result, bail}; use fabro_config::UserSettings; -use fabro_config::merge::combine_files; -use fabro_types::settings::SettingsLayer; -use fabro_types::settings::cli::CliLayer; +use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; +use fabro_types::settings::{Combine, SettingsLayer}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; -use crate::args::{ServerConnectionArgs, ServerTargetArgs}; +use crate::args::{ + ServerConnectionArgs, ServerTargetArgs, printer_from_verbosity, require_no_json_override, +}; use crate::server_client::Client; use crate::{server_client, user_config}; @@ -26,88 +27,62 @@ pub(crate) enum ServerMode { } pub(crate) struct CommandContext { - #[allow( - dead_code, - reason = "This item is kept for command plumbing that is still being wired through." - )] - printer: Printer, - cwd: PathBuf, - base_config_path: PathBuf, - machine_settings: SettingsLayer, - user_settings: UserSettings, - server_mode: ServerMode, - server: OnceCell>, + printer: Printer, + process_local_json: bool, + cwd: PathBuf, + base_config_path: PathBuf, + cli_layer: CliLayer, + machine_settings: SettingsLayer, + user_settings: UserSettings, + server_mode: ServerMode, + server: OnceCell>, } impl CommandContext { - pub(crate) fn base(printer: Printer, cli_layer: &CliLayer) -> Result { - Self::new(printer, ServerMode::None, cli_layer) - } - - pub(crate) fn for_target( - args: &ServerTargetArgs, - printer: Printer, - cli_layer: &CliLayer, - ) -> Result { - Self::new( - printer, - ServerMode::ByTarget { - target_override: args.server.clone(), - }, - cli_layer, - ) - } - - pub(crate) fn for_connection( - args: &ServerConnectionArgs, - printer: Printer, - cli_layer: &CliLayer, - ) -> Result { - Self::new( - printer, - ServerMode::ByStorageDir { - target_override: args.target.server.clone(), - storage_dir_override: args.storage_dir.clone_path(), - }, - cli_layer, - ) - } - - fn new(printer: Printer, server_mode: ServerMode, cli_layer: &CliLayer) -> Result { + pub(crate) fn from_disk(cli_layer: &CliLayer, process_local_json: bool) -> Result { + let (machine_settings, user_settings) = load_merged_settings(cli_layer, &ServerMode::None)?; + let printer = printer_from_verbosity(user_settings.cli.output.verbosity); let cwd = std::env::current_dir().context("Failed to get current directory")?; let base_config_path = user_config::active_settings_path(None); - let disk_settings = match &server_mode { - ServerMode::None | ServerMode::ByTarget { .. } => user_config::load_settings()?, - ServerMode::ByStorageDir { - storage_dir_override, - .. - } => user_config::load_settings_with_storage_dir(storage_dir_override.as_deref())?, - }; - let machine_settings = combine_files(disk_settings, SettingsLayer { - cli: Some(cli_layer.clone()), - ..SettingsLayer::default() - }); - let user_settings = fabro_config::UserSettings::from_layer(&machine_settings)?; Ok(Self { printer, + process_local_json, cwd, base_config_path, + cli_layer: cli_layer.clone(), machine_settings, user_settings, - server_mode, + server_mode: ServerMode::None, server: OnceCell::new(), }) } - #[allow( - dead_code, - reason = "This item is kept for command plumbing that is still being wired through." - )] + pub(crate) fn with_target(&self, args: &ServerTargetArgs) -> Result { + self.with_server_mode(ServerMode::ByTarget { + target_override: args.server.clone(), + }) + } + + pub(crate) fn with_connection(&self, args: &ServerConnectionArgs) -> Result { + self.with_server_mode(ServerMode::ByStorageDir { + target_override: args.target.server.clone(), + storage_dir_override: args.storage_dir.clone_path(), + }) + } + pub(crate) fn printer(&self) -> Printer { self.printer } + pub(crate) fn explicit_json_requested(&self) -> bool { + self.process_local_json + } + + pub(crate) fn require_no_json_override(&self) -> Result<()> { + require_no_json_override(self.process_local_json) + } + pub(crate) fn cwd(&self) -> &Path { &self.cwd } @@ -120,6 +95,14 @@ impl CommandContext { &self.user_settings } + pub(crate) fn json_output(&self) -> bool { + self.user_settings.cli.output.format == OutputFormat::Json + } + + pub(crate) fn verbose(&self) -> bool { + self.user_settings.cli.output.verbosity == OutputVerbosity::Verbose + } + pub(crate) async fn server(&self) -> Result> { let server_mode = self.server_mode.clone(); let base_config_path = self.base_config_path.clone(); @@ -149,4 +132,161 @@ impl CommandContext { Ok(Arc::clone(client)) } + + fn with_server_mode(&self, server_mode: ServerMode) -> Result { + // Always reload settings for the requested derivation mode so the result + // depends only on the requested mode, not on whichever derived context + // happened to call into this helper. + let (machine_settings, user_settings) = + load_merged_settings(&self.cli_layer, &server_mode)?; + + Ok(Self { + printer: self.printer, + process_local_json: self.process_local_json, + cwd: self.cwd.clone(), + base_config_path: self.base_config_path.clone(), + cli_layer: self.cli_layer.clone(), + machine_settings, + user_settings, + server_mode, + server: OnceCell::new(), + }) + } +} + +fn load_merged_settings( + cli_layer: &CliLayer, + server_mode: &ServerMode, +) -> Result<(SettingsLayer, UserSettings)> { + let disk_settings = match server_mode { + ServerMode::None | ServerMode::ByTarget { .. } => user_config::load_settings()?, + ServerMode::ByStorageDir { + storage_dir_override, + .. + } => user_config::load_settings_with_storage_dir(storage_dir_override.as_deref())?, + }; + merge_settings_layer(disk_settings, cli_layer) +} + +fn merge_settings_layer( + disk_settings: SettingsLayer, + cli_layer: &CliLayer, +) -> Result<(SettingsLayer, UserSettings)> { + let machine_settings = SettingsLayer { + cli: Some(cli_layer.clone()), + ..SettingsLayer::default() + } + .combine(disk_settings); + let user_settings = UserSettings::from_layer(&machine_settings)?; + Ok((machine_settings, user_settings)) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use fabro_config::parse_settings_layer; + use fabro_config::user::apply_storage_dir_override; + use fabro_types::settings::InterpString; + use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputFormat, OutputVerbosity}; + use fabro_util::printer::Printer; + use tokio::sync::OnceCell; + + use super::{CommandContext, ServerMode, merge_settings_layer}; + + fn cli_layer_with_json_and_verbose() -> CliLayer { + CliLayer { + output: Some(CliOutputLayer { + format: Some(OutputFormat::Json), + verbosity: Some(OutputVerbosity::Verbose), + }), + ..CliLayer::default() + } + } + + fn synthetic_context(process_local_json: bool, printer: Printer) -> CommandContext { + let cli_layer = cli_layer_with_json_and_verbose(); + let (machine_settings, user_settings) = + merge_settings_layer(parse_settings_layer("_version = 1\n").unwrap(), &cli_layer) + .expect("settings should merge"); + CommandContext { + printer, + process_local_json, + cwd: PathBuf::from("/tmp/workspace"), + base_config_path: PathBuf::from("/tmp/settings.toml"), + cli_layer, + machine_settings, + user_settings, + server_mode: ServerMode::None, + server: OnceCell::new(), + } + } + + #[test] + fn context_exposes_resolved_output_and_explicit_json_state() { + let ctx = synthetic_context(true, Printer::Default); + + assert_eq!(ctx.user_settings().cli.output.format, OutputFormat::Json); + assert_eq!( + ctx.user_settings().cli.output.verbosity, + OutputVerbosity::Verbose + ); + assert!(ctx.explicit_json_requested()); + assert_eq!(ctx.printer(), Printer::Default); + } + + #[test] + fn storage_dir_override_only_changes_storage_root_in_merged_settings() { + let cli_layer = cli_layer_with_json_and_verbose(); + let base_disk_settings = parse_settings_layer( + r#" +_version = 1 + +[server.storage] +root = "/srv/fabro/default" +"#, + ) + .expect("settings fixture should parse"); + let override_disk_settings = apply_storage_dir_override( + base_disk_settings.clone(), + Some(std::path::Path::new("/srv/fabro/override")), + ); + + let (base_settings, base_user_settings) = + merge_settings_layer(base_disk_settings, &cli_layer) + .expect("base settings should merge"); + let (connection_settings, connection_user_settings) = + merge_settings_layer(override_disk_settings, &cli_layer) + .expect("connection settings should merge"); + + assert_eq!(base_user_settings, connection_user_settings); + assert_eq!(base_user_settings.cli.output.format, OutputFormat::Json); + assert_eq!( + base_settings + .server + .as_ref() + .and_then(|server| server.storage.as_ref()) + .and_then(|storage| storage.root.as_ref()) + .map(InterpString::as_source), + Some("/srv/fabro/default".to_string()) + ); + assert_eq!( + connection_settings + .server + .as_ref() + .and_then(|server| server.storage.as_ref()) + .and_then(|storage| storage.root.as_ref()) + .map(InterpString::as_source), + Some("/srv/fabro/override".to_string()) + ); + } + + #[test] + fn explicit_json_guard_uses_invocation_flag_not_resolved_output_format() { + let json_ctx = synthetic_context(true, Printer::Default); + let text_ctx = synthetic_context(false, Printer::Default); + + assert!(json_ctx.require_no_json_override().is_err()); + assert!(text_ctx.require_no_json_override().is_ok()); + } } diff --git a/lib/crates/fabro-cli/src/commands/artifact/cp.rs b/lib/crates/fabro-cli/src/commands/artifact/cp.rs index 2756912c1..bf989aac2 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/cp.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/cp.rs @@ -6,28 +6,21 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use crate::args::ArtifactCpArgs; +use crate::command_context::CommandContext; use crate::server_client::Client; use crate::shared::{print_json_pretty, split_run_path}; -pub(super) async fn cp_command( - args: &ArtifactCpArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(super) async fn cp_command(args: &ArtifactCpArgs, base_ctx: &CommandContext) -> Result<()> { + let printer = base_ctx.printer(); let (run_id_selector, asset_path) = parse_source(&args.source); let (run_id, client, entries) = super::resolve_artifacts( + base_ctx, &args.server, run_id_selector, args.node.as_deref(), args.retry, - cli_layer, - printer, ) .await?; @@ -64,7 +57,7 @@ pub(super) async fn cp_command( .unwrap_or_else(|| std::ffi::OsStr::new(&entry.relative_path)), ); write_artifact_file(&client, &run_id, entry, &dest_file).await?; - if cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { print_json_pretty(&serde_json::json!({ "copied": [{ "relative_path": entry.relative_path, @@ -125,7 +118,7 @@ pub(super) async fn cp_command( } } - if cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { print_json_pretty(&serde_json::json!({ "copied": copied }))?; } else { fabro_util::printout!( diff --git a/lib/crates/fabro-cli/src/commands/artifact/list.rs b/lib/crates/fabro-cli/src/commands/artifact/list.rs index e7de1d4a4..782e323c9 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/list.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/list.rs @@ -1,30 +1,23 @@ use anyhow::Result; use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use crate::args::ArtifactListArgs; +use crate::command_context::CommandContext; -pub(super) async fn list_command( - args: &ArtifactListArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(super) async fn list_command(args: &ArtifactListArgs, base_ctx: &CommandContext) -> Result<()> { + let printer = base_ctx.printer(); let (_run_id, _client, entries) = super::resolve_artifacts( + base_ctx, &args.server, &args.run_id, args.node.as_deref(), args.retry, - cli_layer, - printer, ) .await?; - if cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { fabro_util::printout!(printer, "{}", serde_json::to_string_pretty(&entries)?); return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/artifact/mod.rs b/lib/crates/fabro-cli/src/commands/artifact/mod.rs index 605d1beec..b9a5ffcf7 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/mod.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/mod.rs @@ -2,10 +2,7 @@ mod cp; mod list; use anyhow::{Context, Result}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::CliLayer; use fabro_types::{RunId, StageId}; -use fabro_util::printer::Printer; use crate::args::{ArtifactCommand, ArtifactNamespace, ServerTargetArgs}; use crate::command_context::CommandContext; @@ -22,14 +19,13 @@ pub(super) struct ArtifactEntry { } pub(super) async fn resolve_artifacts( + base_ctx: &CommandContext, server: &ServerTargetArgs, run_selector: &str, node: Option<&str>, retry: Option, - cli_layer: &CliLayer, - printer: Printer, ) -> Result<(RunId, Client, Vec)> { - let ctx = CommandContext::for_target(server, printer, cli_layer)?; + let ctx = base_ctx.with_target(server)?; let client = ctx.server().await?; let run_id = client.resolve_run(run_selector).await?.run_id; let mut entries = Vec::new(); @@ -62,14 +58,9 @@ pub(super) async fn resolve_artifacts( Ok((run_id, client.clone_for_reuse(), entries)) } -pub(crate) async fn dispatch( - ns: ArtifactNamespace, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(ns: ArtifactNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { - ArtifactCommand::List(args) => list::list_command(&args, cli, cli_layer, printer).await, - ArtifactCommand::Cp(args) => cp::cp_command(&args, cli, cli_layer, printer).await, + ArtifactCommand::List(args) => list::list_command(&args, base_ctx).await, + ArtifactCommand::Cp(args) => cp::cp_command(&args, base_ctx).await, } } diff --git a/lib/crates/fabro-cli/src/commands/auth/login.rs b/lib/crates/fabro-cli/src/commands/auth/login.rs index 7968fb78e..926dd23a9 100644 --- a/lib/crates/fabro-cli/src/commands/auth/login.rs +++ b/lib/crates/fabro-cli/src/commands/auth/login.rs @@ -4,13 +4,12 @@ use anyhow::{Context as _, Result, bail}; use chrono::{DateTime, Utc}; use fabro_client::{AuthEntry, AuthStore, StoredSubject}; use fabro_http::header::CONTENT_TYPE; -use fabro_types::settings::cli::CliLayer; use fabro_util::browser; use fabro_util::printer::Printer; use serde::Deserialize; use tokio::time::timeout; -use crate::args::{AuthLoginArgs, require_no_json_override}; +use crate::args::AuthLoginArgs; use crate::command_context::CommandContext; use crate::user_config; use crate::user_config::ServerTarget; @@ -33,17 +32,13 @@ struct CliTokenSubject { email: String, } -pub(super) async fn login_command( - args: AuthLoginArgs, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { - require_no_json_override(process_local_json)?; +pub(super) async fn login_command(args: AuthLoginArgs, base_ctx: &CommandContext) -> Result<()> { + base_ctx.require_no_json_override()?; + let printer = base_ctx.printer(); #[cfg(not(unix))] { - let _ = (args, cli_layer, printer); + let _ = (args, printer); bail!( "CLI OAuth login is not supported on Windows in this release. Use WSL, or use a dev-token server." ); @@ -51,8 +46,7 @@ pub(super) async fn login_command( #[cfg(unix)] { - let ctx = CommandContext::base(printer, cli_layer)?; - let target = user_config::resolve_server_target(&args.server, ctx.machine_settings())?; + let target = user_config::resolve_server_target(&args.server, base_ctx.machine_settings())?; let web_url = browser_origin(&target)?; let pkce = fabro_oauth::generate_pkce(); let state = fabro_oauth::generate_state(); diff --git a/lib/crates/fabro-cli/src/commands/auth/logout.rs b/lib/crates/fabro-cli/src/commands/auth/logout.rs index 6acf20275..f1ad4b806 100644 --- a/lib/crates/fabro-cli/src/commands/auth/logout.rs +++ b/lib/crates/fabro-cli/src/commands/auth/logout.rs @@ -1,23 +1,16 @@ use anyhow::{Result, bail}; use fabro_client::{AuthEntry, AuthStore}; use fabro_http::header::AUTHORIZATION; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; -use crate::args::{AuthLogoutArgs, require_no_json_override}; +use crate::args::AuthLogoutArgs; use crate::command_context::CommandContext; use crate::user_config; use crate::user_config::ServerTarget; -pub(super) async fn logout_command( - args: AuthLogoutArgs, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { - require_no_json_override(process_local_json)?; +pub(super) async fn logout_command(args: AuthLogoutArgs, base_ctx: &CommandContext) -> Result<()> { + base_ctx.require_no_json_override()?; + let printer = base_ctx.printer(); - let ctx = CommandContext::base(printer, cli_layer)?; let store = AuthStore::default(); if args.all { let entries = store.list()?; @@ -41,7 +34,7 @@ pub(super) async fn logout_command( return Ok(()); } - let target = user_config::resolve_server_target(&args.server, ctx.machine_settings())?; + let target = user_config::resolve_server_target(&args.server, base_ctx.machine_settings())?; let Some(entry) = store.get(&target)? else { fabro_util::printerr!(printer, "Not logged in to {}.", target); return Ok(()); diff --git a/lib/crates/fabro-cli/src/commands/auth/mod.rs b/lib/crates/fabro-cli/src/commands/auth/mod.rs index dd4ca8a83..0802ac73d 100644 --- a/lib/crates/fabro-cli/src/commands/auth/mod.rs +++ b/lib/crates/fabro-cli/src/commands/auth/mod.rs @@ -3,26 +3,14 @@ mod logout; mod status; use anyhow::Result; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; use crate::args::{AuthCommand, AuthNamespace}; +use crate::command_context::CommandContext; -pub(crate) async fn dispatch( - ns: AuthNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(ns: AuthNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { - AuthCommand::Login(args) => { - login::login_command(args, cli_layer, process_local_json, printer).await - } - AuthCommand::Logout(args) => { - logout::logout_command(args, cli_layer, process_local_json, printer).await - } - AuthCommand::Status(args) => { - status::status_command(&args, cli_layer, process_local_json, printer) - } + AuthCommand::Login(args) => login::login_command(args, base_ctx).await, + AuthCommand::Logout(args) => logout::logout_command(args, base_ctx).await, + AuthCommand::Status(args) => status::status_command(&args, base_ctx), } } diff --git a/lib/crates/fabro-cli/src/commands/auth/status.rs b/lib/crates/fabro-cli/src/commands/auth/status.rs index aa82b6f7e..808745e7f 100644 --- a/lib/crates/fabro-cli/src/commands/auth/status.rs +++ b/lib/crates/fabro-cli/src/commands/auth/status.rs @@ -1,9 +1,7 @@ use anyhow::Result; use chrono::{DateTime, Utc}; use fabro_client::{AuthEntry, AuthStore}; -use fabro_types::settings::cli::CliLayer; use fabro_util::dev_token::{read_dev_token_file, validate_dev_token_format}; -use fabro_util::printer::Printer; use serde::Serialize; use crate::args::AuthStatusArgs; @@ -40,13 +38,8 @@ struct StatusOutput { dev_token: &'static str, } -pub(super) fn status_command( - args: &AuthStatusArgs, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::base(printer, cli_layer)?; +pub(super) fn status_command(args: &AuthStatusArgs, ctx: &CommandContext) -> Result<()> { + let printer = ctx.printer(); let store = AuthStore::default(); let now = Utc::now(); let rows = if args.server.as_deref().is_some() { @@ -61,7 +54,7 @@ pub(super) fn status_command( "not_set" }; - if process_local_json { + if ctx.explicit_json_requested() { print_json_pretty(&StatusOutput { servers: rows, dev_token, diff --git a/lib/crates/fabro-cli/src/commands/config/mod.rs b/lib/crates/fabro-cli/src/commands/config/mod.rs index 1535bfa0e..7dfe3c383 100644 --- a/lib/crates/fabro-cli/src/commands/config/mod.rs +++ b/lib/crates/fabro-cli/src/commands/config/mod.rs @@ -11,9 +11,6 @@ use std::io::Write; use fabro_api::types::ServerSettings; use fabro_config::UserSettings; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use serde::Serialize; use crate::args::SettingsArgs; @@ -26,29 +23,17 @@ struct RenderedConfig { server: ServerSettings, } -async fn rendered_config( - args: &SettingsArgs, - cli_layer: &CliLayer, - printer: Printer, -) -> anyhow::Result { - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; +pub(crate) async fn execute(args: &SettingsArgs, base_ctx: &CommandContext) -> anyhow::Result<()> { + let ctx = base_ctx.with_target(&args.target)?; let user = fabro_config::UserSettings::resolve()?; let server = ctx .server() .await? .retrieve_resolved_server_settings() .await?; - serde_json::to_value(RenderedConfig { user, server }).map_err(Into::into) -} + let config = serde_json::to_value(RenderedConfig { user, server })?; -pub(crate) async fn execute( - args: &SettingsArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> anyhow::Result<()> { - let config = Box::pin(rendered_config(args, cli_layer, printer)).await?; - if cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { print_json_pretty(&config)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/doctor.rs b/lib/crates/fabro-cli/src/commands/doctor.rs index 85c8517c6..2ba2b813d 100644 --- a/lib/crates/fabro-cli/src/commands/doctor.rs +++ b/lib/crates/fabro-cli/src/commands/doctor.rs @@ -3,8 +3,6 @@ use std::path::PathBuf; use anyhow::Result; use fabro_api::types as api_types; use fabro_config::user::active_settings_path; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; pub(crate) use fabro_util::check_report::{ CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus, }; @@ -142,13 +140,12 @@ fn render_report(report: &CheckReport, styles: &Styles, verbose: bool, printer: pub(crate) async fn run_doctor( args: &DoctorArgs, - verbose: bool, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result { + let verbose = args.verbose || base_ctx.verbose(); + let printer = base_ctx.printer(); let styles = Styles::detect_stdout(); - let json = cli.output.format == OutputFormat::Json; + let json = base_ctx.json_output(); let spinner = if json { None } else { @@ -179,7 +176,7 @@ pub(crate) async fn run_doctor( }], }; - let ctx = match CommandContext::for_target(&args.target, printer, cli_layer) { + let ctx = match base_ctx.with_target(&args.target) { Ok(ctx) => ctx, Err(err) => { report.sections.push(CheckSection { diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/dump.rs similarity index 97% rename from lib/crates/fabro-cli/src/commands/store/dump.rs rename to lib/crates/fabro-cli/src/commands/dump.rs index df1cf1f6a..0748ae671 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/dump.rs @@ -1,6 +1,6 @@ #![expect( clippy::disallowed_methods, - reason = "CLI `store dump` command: sync file I/O for dump outputs" + reason = "CLI `dump` command: sync file I/O for dump outputs" )] use std::io::ErrorKind; @@ -11,34 +11,27 @@ use bytes::Bytes; #[cfg(test)] use fabro_store::{ArtifactStore, RunDatabase}; use fabro_store::{EventEnvelope, RunProjection, StageId}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_types::{RunBlobId, RunId}; -use fabro_util::printer::Printer; +use fabro_workflow::run_dump::RunDump; use futures::future::BoxFuture; #[cfg(test)] use serde::de::DeserializeOwned; use tokio::task::spawn_blocking; -use super::run_export::StoreRunExport; -use crate::args::StoreDumpArgs; +use crate::args::DumpArgs; use crate::command_context::CommandContext; use crate::server_client::Client; use crate::shared::{absolute_or_current, print_json_pretty}; -pub(crate) async fn dump_command( - args: &StoreDumpArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; +pub(crate) async fn run(args: &DumpArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; let state = client.get_run_state(&run_id).await?; let source = ServerDumpSource::new(client.as_ref(), &run_id); let file_count = export_run_from_source(&source, &state, &args.output).await?; - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "run_id": run_id, "output_dir": absolute_or_current(&args.output), @@ -222,7 +215,7 @@ async fn export_run_from_source( .with_context(|| format!("failed to create {}", staging_parent.display()))?; let staging_dir = tempfile::Builder::new() - .prefix(".fabro-store-dump-") + .prefix(".fabro-dump-") .tempdir_in(staging_parent) .with_context(|| { format!( @@ -248,7 +241,7 @@ async fn write_run_dump( output_dir: &Path, ) -> Result { let events = source.list_events().await?; - let mut dump = StoreRunExport::from_store_state_and_events(state, &events)?; + let mut dump = RunDump::from_store_state_and_events(state, &events)?; dump.hydrate_referenced_blobs_with_reader(|blob_id| source.read_blob(blob_id)) .await?; diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index 4048915e5..c8b218daf 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -13,15 +13,15 @@ use fabro_llm::types::{ FinishReason, Message, Request, Response as LlmResponse, StreamEvent, TokenCounts, }; use fabro_mcp::config::{McpServerSettings, McpTransport}; +use fabro_types::settings::InterpString; use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat; use fabro_types::settings::run::McpEntryLayer; -use fabro_types::settings::{CliNamespace, InterpString}; use fabro_util::exit::{ErrorExt, ExitClass}; -use fabro_util::printer::Printer; use futures::stream; use serde::Deserialize; use crate::args::ExecArgs; +use crate::command_context::CommandContext; use crate::{server_client, user_config}; fn runtime_mcp_server(name: &str, entry: &McpEntryLayer) -> McpServerSettings { @@ -356,14 +356,11 @@ impl ProviderAdapter for AuthenticatedFabroServerAdapter { } } -pub(crate) async fn execute( - mut args: ExecArgs, - cli: &CliNamespace, - _printer: Printer, -) -> AnyResult<()> { +pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResult<()> { use fabro_agent::cli::PermissionLevel as AgentPermissionLevel; use fabro_types::settings::run::AgentPermissions; + let cli = &ctx.user_settings().cli; let raw_settings = user_config::load_settings()?; #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = crate::sleep_inhibitor::guard(cli.exec.prevent_idle_sleep); diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index 71ffa9f68..b8416a6c3 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -13,13 +13,11 @@ use anyhow::{Context, bail}; use fabro_api::types; use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_types::settings::{CliNamespace, SettingsLayer}; -use fabro_util::printer::Printer; +use fabro_types::settings::SettingsLayer; use fabro_util::terminal::Styles; use tracing::debug; -use crate::args::{GraphArgs, GraphDirection, require_no_json_override}; +use crate::args::{GraphArgs, GraphDirection}; use crate::command_context::CommandContext; use crate::commands::run::output::api_diagnostics_to_local; use crate::manifest_builder::{ManifestBuildInput, build_run_manifest}; @@ -28,16 +26,14 @@ use crate::shared::{absolute_or_current, print_diagnostics, print_json_pretty, r pub(crate) async fn run( args: &GraphArgs, styles: &Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, + base_ctx: &CommandContext, ) -> anyhow::Result<()> { - if process_local_json && args.output.is_none() { - require_no_json_override(process_local_json)?; + if args.output.is_none() { + base_ctx.require_no_json_override()?; } - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.target)?; let built = build_run_manifest(ManifestBuildInput { workflow: args.workflow.clone(), cwd: ctx.cwd().to_path_buf(), @@ -73,7 +69,7 @@ pub(crate) async fn run( if let Some(ref output_path) = args.output { std::fs::write(output_path, &rendered) .with_context(|| format!("writing rendered graph to {}", output_path.display()))?; - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "path": absolute_or_current(output_path), "format": args.format.to_string(), diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 3e0753af4..60c4b016b 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -26,15 +26,14 @@ use fabro_config::daemon::ServerDaemon; use fabro_config::user::{SETTINGS_CONFIG_FILENAME, default_storage_dir}; use fabro_config::{Storage, envfile}; use fabro_install::{ - InstallListenConfig, generate_jwt_keypair, merge_server_settings as merge_server_settings_impl, - write_github_app_settings, write_token_settings, + InstallListenConfig, PendingSettingsWrite, merge_server_settings as merge_server_settings_impl, + persist_install_outputs_direct, write_github_app_settings, write_token_settings, }; use fabro_model::Provider; use fabro_server::serve; use fabro_store::ArtifactStore; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; +use fabro_types::settings::SettingsLayer; use fabro_types::settings::server::ServerAuthMethod; -use fabro_types::settings::{CliNamespace, SettingsLayer}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_util::version::FABRO_VERSION; @@ -52,6 +51,7 @@ use crate::args::{ DoctorArgs, InstallArgs, InstallCommand, InstallGitHubStrategyArg, InstallGithubArgs, InstallNonInteractiveArgs, ServerTargetArgs, }; +use crate::command_context::CommandContext; use crate::commands::server::{start, stop}; use crate::gh::GhCli; use crate::shared::provider_auth::{ @@ -883,13 +883,6 @@ enum PendingGitHubSettings { }, } -#[derive(Clone, Copy)] -struct PendingSettingsWrite<'a> { - path: &'a Path, - contents: &'a str, - previous_contents: Option<&'a str>, -} - async fn setup_github_app( s: &Styles, web_url: &str, @@ -1148,15 +1141,24 @@ fn credential_secret_request(credential: &AuthCredential) -> Result Result<()> { - if secrets.is_empty() { - return Ok(()); - } +fn server_env_updates(secrets: &[(String, String)]) -> Vec { + secrets + .iter() + .map(|(key, value)| envfile::EnvFileUpdate { + key: key.clone(), + value: value.clone(), + comment: None, + }) + .collect() +} - let env_path = Storage::new(storage_dir).runtime_directory().env_path(); - envfile::merge_env_file(&env_path, secrets.iter().cloned()) - .with_context(|| format!("merging server env secrets into {}", env_path.display()))?; - Ok(()) +fn server_env_removals(keys: &[&'static str]) -> Vec { + keys.iter() + .map(|key| envfile::EnvFileRemoval { + key: (*key).to_string(), + comment: None, + }) + .collect() } async fn persist_install_outputs( @@ -1221,20 +1223,15 @@ fn persist_github_install_changes( let previous_vault = std::fs::read_to_string(&vault_path).ok(); let result = (|| -> Result<()> { - let mut server_env = envfile::read_env_file(&server_env_path) - .with_context(|| format!("reading env file {}", server_env_path.display()))?; - for key in &writes.server_env_remove { - server_env.remove(*key); - } - for (key, value) in &writes.server_env_set { - server_env.insert(key.clone(), value.clone()); - } - if server_env.is_empty() { - restore_optional_file(&server_env_path, None)?; - } else { - envfile::write_env_file(&server_env_path, &server_env) - .with_context(|| format!("writing env file {}", server_env_path.display()))?; - } + let server_env_writes = server_env_updates(&writes.server_env_set); + let server_env_removals = server_env_removals(&writes.server_env_remove); + persist_install_outputs_direct( + storage_dir, + &server_env_writes, + &server_env_removals, + &[], + Some(&writes.settings_write), + )?; let mut vault = Vault::load(vault_path.clone()).map_err(anyhow::Error::from)?; for key in &writes.vault_remove { @@ -1249,14 +1246,6 @@ fn persist_github_install_changes( .map_err(anyhow::Error::from)?; } - std::fs::write(writes.settings_write.path, writes.settings_write.contents).with_context( - || { - format!( - "writing settings file {}", - writes.settings_write.path.display() - ) - }, - )?; Ok(()) })(); @@ -1293,12 +1282,16 @@ async fn persist_install_outputs_with_settings( connect_server: impl for<'a> Fn(&'a Path) -> BoxFuture<'a, Result>, stop_server: impl for<'a> Fn(&'a Path, Duration) -> BoxFuture<'a, bool>, ) -> Result<()> { - persist_server_env_secrets(storage_dir, server_env_secrets)?; - - if let Some(write) = settings_write { - std::fs::write(write.path, write.contents) - .with_context(|| format!("writing settings file {}", write.path.display()))?; - } + let server_env_path = Storage::new(storage_dir).runtime_directory().env_path(); + let previous_server_env = std::fs::read_to_string(&server_env_path).ok(); + let settings_write_ref = settings_write.as_ref(); + persist_install_outputs_direct( + storage_dir, + &server_env_updates(server_env_secrets), + &[], + &[], + settings_write_ref, + )?; let persist_result = persist_vault_secrets_with( storage_dir, @@ -1310,6 +1303,7 @@ async fn persist_install_outputs_with_settings( .await; if let Err(err) = persist_result { + restore_optional_file(&server_env_path, previous_server_env.as_deref())?; if let Some(write) = settings_write { match write.previous_contents { Some(previous) => std::fs::write(write.path, previous) @@ -1415,15 +1409,12 @@ where pub(crate) async fn execute( args: &InstallArgs, command: Option, - cli: &CliNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, + ctx: &CommandContext, ) -> Result<()> { match command { - None => run_install(args, cli, cli_layer, process_local_json, printer).await, + None => run_install(args, ctx).await, Some(InstallCommand::Github(github_args)) => { - run_install_github_command(args, &github_args, cli, process_local_json, printer).await + run_install_github_command(args, &github_args, ctx).await } } } @@ -1431,16 +1422,20 @@ pub(crate) async fn execute( async fn run_install_github_command( args: &InstallArgs, github_args: &InstallGithubArgs, - cli: &CliNamespace, - process_local_json: bool, - printer: Printer, + ctx: &CommandContext, ) -> Result<()> { - let json = cli.output.format == OutputFormat::Json; - if process_local_json && !args.non_interactive { + let json = ctx.json_output(); + if ctx.explicit_json_requested() && !args.non_interactive { bail!("--json is only supported for install with --non-interactive"); } - let result = Box::pin(run_install_github_inner(args, github_args, json, printer)).await; + let result = Box::pin(run_install_github_inner( + args, + github_args, + json, + ctx.printer(), + )) + .await; if json { let emit_result = match &result { Ok(()) => emit_install_json_event(&install_complete_event()), @@ -1592,19 +1587,13 @@ async fn run_install_github_inner( Ok(()) } -pub(crate) async fn run_install( - args: &InstallArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { - let json = cli.output.format == OutputFormat::Json; - if process_local_json && !args.non_interactive { +pub(crate) async fn run_install(args: &InstallArgs, ctx: &CommandContext) -> Result<()> { + let json = ctx.json_output(); + if ctx.explicit_json_requested() && !args.non_interactive { bail!("--json is only supported for install with --non-interactive"); } - let result = Box::pin(run_install_inner(args, cli, cli_layer, printer)).await; + let result = Box::pin(run_install_inner(args, ctx)).await; if json { let emit_result = match &result { Ok(()) => emit_install_json_event(&install_complete_event()), @@ -1618,13 +1607,10 @@ pub(crate) async fn run_install( result } -async fn run_install_inner( - args: &InstallArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let json = cli.output.format == OutputFormat::Json; +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 s = Styles::detect_stderr(); let emoji = console::Emoji("⚒️ ", ""); @@ -1807,13 +1793,6 @@ async fn run_install_inner( s.green.apply_to("✔") ); - let (jwt_private_pem, jwt_public_pem) = generate_jwt_keypair()?; - fabro_util::printerr!( - printer, - " {} Ed25519 JWT keypair generated", - s.green.apply_to("✔") - ); - let dev_token = if fabro_config::dev_token_auth_enabled(&install_settings) { let token = dev_token::read_or_mint_dev_token_for_install( &fabro_util::Home::from_env().dev_token_path(), @@ -1834,14 +1813,7 @@ async fn run_install_inner( None }; - let jwt_private_b64 = BASE64_STANDARD.encode(jwt_private_pem.as_bytes()); - let jwt_public_b64 = BASE64_STANDARD.encode(jwt_public_pem.as_bytes()); - - let mut generated_server_env_pairs = vec![ - ("FABRO_JWT_PRIVATE_KEY".to_string(), jwt_private_b64), - ("FABRO_JWT_PUBLIC_KEY".to_string(), jwt_public_b64), - ("SESSION_SECRET".to_string(), session_secret), - ]; + let mut generated_server_env_pairs = vec![("SESSION_SECRET".to_string(), session_secret)]; if let Some(token) = dev_token { generated_server_env_pairs.push(("FABRO_DEV_TOKEN".to_string(), token)); } @@ -1941,7 +1913,7 @@ async fn run_install_inner( target: ServerTargetArgs::default(), verbose: false, }; - doctor::run_doctor(&doctor_args, false, cli, cli_layer, printer).await + doctor::run_doctor(&doctor_args, ctx).await }, ) .await? @@ -2026,39 +1998,6 @@ mod tests { assert!(secret.chars().all(|c| !c.is_ascii_uppercase())); } - // -- JWT keypair -- - - #[tokio::test] - async fn jwt_keypair_private_pem_header() { - let (private, _) = generate_jwt_keypair().unwrap(); - assert!( - private.starts_with("-----BEGIN PRIVATE KEY-----"), - "private PEM: {private}" - ); - } - - #[tokio::test] - async fn jwt_keypair_public_pem_header() { - let (_, public) = generate_jwt_keypair().unwrap(); - assert!( - public.starts_with("-----BEGIN PUBLIC KEY-----"), - "public PEM: {public}" - ); - } - - #[tokio::test] - async fn jwt_keypair_public_parses() { - let (_, public) = generate_jwt_keypair().unwrap(); - jsonwebtoken::DecodingKey::from_ed_pem(public.as_bytes()).expect("public key should parse"); - } - - #[tokio::test] - async fn jwt_keypair_private_parses() { - let (private, _) = generate_jwt_keypair().unwrap(); - jsonwebtoken::EncodingKey::from_ed_pem(private.as_bytes()) - .expect("private key should parse"); - } - // -- Config TOML generation -- #[test] @@ -2511,11 +2450,8 @@ client_id = "client-id" #[tokio::test] async fn persist_install_outputs_persists_vault_secrets_via_server_when_autostarting() { let dir = tempfile::tempdir().unwrap(); - let server_env_pairs = vec![ - ("SESSION_SECRET".to_string(), "session".to_string()), - ("FABRO_JWT_PUBLIC_KEY".to_string(), "public-key".to_string()), - ]; - let vault_secrets = vec![ + let server_env_pairs = [("SESSION_SECRET".to_string(), "session".to_string())]; + let vault_secrets = [ CreateSecretRequest { name: "GITHUB_TOKEN".to_string(), value: "gh-token".to_string(), @@ -2549,7 +2485,8 @@ client_id = "client-id" .await; let stop_called = Arc::new(AtomicBool::new(false)); - persist_server_env_secrets(dir.path(), &server_env_pairs).unwrap(); + let env_path = Storage::new(dir.path()).runtime_directory().env_path(); + envfile::merge_env_file(&env_path, server_env_pairs.iter().cloned()).unwrap(); persist_vault_secrets_with( dir.path(), &vault_secrets, @@ -2576,7 +2513,6 @@ client_id = "client-id" std::fs::read_to_string(Storage::new(dir.path()).runtime_directory().env_path()) .unwrap(); assert!(server_env.contains("SESSION_SECRET=session")); - assert!(server_env.contains("FABRO_JWT_PUBLIC_KEY=public-key")); assert_eq!(created.calls_async().await, 2); assert!(stop_called.load(Ordering::SeqCst)); assert!(!Storage::new(dir.path()).secrets_path().exists()); @@ -2585,7 +2521,7 @@ client_id = "client-id" #[tokio::test] async fn persist_vault_secrets_with_leaves_running_server_up() { let dir = tempfile::tempdir().unwrap(); - let vault_secrets = vec![CreateSecretRequest { + let vault_secrets = [CreateSecretRequest { name: "GITHUB_TOKEN".to_string(), value: "gh-token".to_string(), type_: ApiSecretType::Environment, @@ -2793,10 +2729,10 @@ client_id = "client-id" } #[tokio::test] - async fn persist_install_outputs_with_settings_does_not_write_settings_on_secret_failure() { + async fn persist_install_outputs_with_settings_rolls_back_new_files_on_secret_failure() { let dir = tempfile::tempdir().unwrap(); - let server_env_pairs = vec![("SESSION_SECRET".to_string(), "session".to_string())]; - let vault_secrets = vec![CreateSecretRequest { + let server_env_pairs = [("SESSION_SECRET".to_string(), "session".to_string())]; + let vault_secrets = [CreateSecretRequest { name: "GITHUB_CLI_TOKEN".to_string(), value: "gh-token".to_string(), type_: ApiSecretType::Environment, @@ -2831,7 +2767,7 @@ client_id = "client-id" assert!(result.is_err()); assert!( - Storage::new(dir.path()) + !Storage::new(dir.path()) .runtime_directory() .env_path() .exists() @@ -2843,8 +2779,8 @@ client_id = "client-id" #[tokio::test] async fn persist_install_outputs_with_settings_restores_previous_contents_on_secret_failure() { let dir = tempfile::tempdir().unwrap(); - let server_env_pairs = vec![("SESSION_SECRET".to_string(), "session".to_string())]; - let vault_secrets = vec![CreateSecretRequest { + let server_env_pairs = [("SESSION_SECRET".to_string(), "session".to_string())]; + let vault_secrets = [CreateSecretRequest { name: "GITHUB_CLI_TOKEN".to_string(), value: "gh-token".to_string(), type_: ApiSecretType::Environment, diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs index 68eece40f..f9408d15b 100644 --- a/lib/crates/fabro-cli/src/commands/mod.rs +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod artifact; pub(crate) mod auth; pub(crate) mod config; pub(crate) mod doctor; +pub(crate) mod dump; pub(crate) mod exec; pub(crate) mod graph; pub(crate) mod install; @@ -10,6 +11,7 @@ pub(crate) mod parse; pub(crate) mod pr; pub(crate) mod preflight; pub(crate) mod provider; +pub(crate) mod rebuild; pub(crate) mod render_graph; pub(crate) mod repo; pub(crate) mod run; @@ -17,7 +19,6 @@ pub(crate) mod runs; pub(crate) mod sandbox; pub(crate) mod secret; pub(crate) mod server; -pub(crate) mod store; pub(crate) mod system; pub(crate) mod uninstall; pub(crate) mod upgrade; diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index d7c26c2a5..2c33be0b8 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -3,9 +3,6 @@ use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_api::types as api_types; use fabro_model::{Catalog, Model, Provider}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use serde::Serialize; @@ -42,19 +39,17 @@ struct ModelTestOutput { pub(crate) async fn execute( command: Option, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result<()> { let command = command.unwrap_or_default(); let target_args = match &command { ModelsCommand::List(args) => &args.target, ModelsCommand::Test(args) => &args.target, }; - let ctx = CommandContext::for_target(target_args, printer, cli_layer)?; + let ctx = base_ctx.with_target(target_args)?; let server = ctx.server().await?; - run_models(command, &server, cli.output.format == OutputFormat::Json).await + run_models(command, &server, ctx.json_output()).await } fn format_context_window(tokens: i64) -> String { diff --git a/lib/crates/fabro-cli/src/commands/parse.rs b/lib/crates/fabro-cli/src/commands/parse.rs index e14d970ce..12088ca04 100644 --- a/lib/crates/fabro-cli/src/commands/parse.rs +++ b/lib/crates/fabro-cli/src/commands/parse.rs @@ -11,13 +11,11 @@ use std::io::Write; use fabro_config::project::resolve_workflow; use fabro_graphviz::parser::parse_ast; -use fabro_types::settings::CliNamespace; -use fabro_util::printer::Printer; use crate::args::ParseArgs; use crate::shared::read_workflow_file; -pub(crate) fn run(args: &ParseArgs, _cli: &CliNamespace, _printer: Printer) -> anyhow::Result<()> { +pub(crate) fn run(args: &ParseArgs) -> anyhow::Result<()> { let stdout = std::io::stdout(); run_to(args, stdout.lock()) } diff --git a/lib/crates/fabro-cli/src/commands/pr/close.rs b/lib/crates/fabro-cli/src/commands/pr/close.rs index c48132ba0..034467526 100644 --- a/lib/crates/fabro-cli/src/commands/pr/close.rs +++ b/lib/crates/fabro-cli/src/commands/pr/close.rs @@ -1,22 +1,15 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use tracing::info; use crate::args::PrCloseArgs; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(super) async fn close_command( - args: PrCloseArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let (record, _run_id) = - super::load_pr_record(&args.server, &args.run_id, cli_layer, printer).await?; +pub(super) async fn close_command(args: PrCloseArgs, base_ctx: &CommandContext) -> Result<()> { + let (ctx, record, _run_id) = + super::load_pr_record(&args.server, &args.run_id, base_ctx).await?; - let creds = super::load_github_credentials_required(cli_layer, printer)?; + let creds = super::load_github_credentials_required(&ctx)?; fabro_github::close_pull_request( &creds, @@ -29,13 +22,18 @@ pub(super) async fn close_command( .map_err(|err| anyhow::anyhow!("{err}"))?; info!(number = record.number, owner = %record.owner, repo = %record.repo, "Closed pull request"); - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "number": record.number, "html_url": record.html_url, }))?; } else { - fabro_util::printout!(printer, "Closed #{} ({})", record.number, record.html_url); + fabro_util::printout!( + ctx.printer(), + "Closed #{} ({})", + record.number, + record.html_url + ); } Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/pr/create.rs b/lib/crates/fabro-cli/src/commands/pr/create.rs index 589e1294d..829a93aad 100644 --- a/lib/crates/fabro-cli/src/commands/pr/create.rs +++ b/lib/crates/fabro-cli/src/commands/pr/create.rs @@ -5,9 +5,6 @@ use fabro_auth::configured_providers_from_process_env; use fabro_config::Storage; use fabro_model::Catalog; use fabro_sandbox::daytona::detect_repo_info; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use fabro_vault::Vault; use fabro_workflow::outcome::StageStatus; use fabro_workflow::pull_request::maybe_open_pull_request; @@ -16,7 +13,7 @@ use tracing::info; use crate::args::PrCreateArgs; use crate::command_context::CommandContext; -use crate::commands::store::rebuild::rebuild_run_store; +use crate::commands::rebuild::rebuild_run_store; use crate::shared::print_json_pretty; use crate::shared::repo::ensure_matching_repo_origin; use crate::user_config; @@ -25,13 +22,9 @@ use crate::user_config; deprecated, reason = "boundary-exempt(pr-api): remove with follow-up #1 when PR ops move server-side" )] -pub(super) async fn create_command( - args: PrCreateArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; +pub(super) async fn create_command(args: PrCreateArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); let client = ctx.server().await?; let run_id = client.resolve_run(&args.run_id).await?.run_id; let events = client.list_run_events(&run_id, None, None).await?; @@ -86,7 +79,7 @@ pub(super) async fn create_command( let (owner, repo) = fabro_github::parse_github_owner_repo(&https_url) .map_err(|err| anyhow::anyhow!("{err}"))?; - let creds = super::load_github_credentials_required(cli_layer, printer)?; + let creds = super::load_github_credentials_required(base_ctx)?; let branch_found = fabro_github::branch_exists( &creds, @@ -137,14 +130,14 @@ pub(super) async fn create_command( match pull_request { Some(record) => { info!(pr_url = %record.html_url, "Pull request created"); - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&record)?; } else { fabro_util::printout!(printer, "{}", record.html_url); } } None => { - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::Value::Null)?; } else { fabro_util::printout!(printer, "No pull request created (empty diff)."); diff --git a/lib/crates/fabro-cli/src/commands/pr/list.rs b/lib/crates/fabro-cli/src/commands/pr/list.rs index bf86031eb..f1ce2f230 100644 --- a/lib/crates/fabro-cli/src/commands/pr/list.rs +++ b/lib/crates/fabro-cli/src/commands/pr/list.rs @@ -1,9 +1,6 @@ use anyhow::Result; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use futures::future::join_all; use serde::Serialize; @@ -23,13 +20,9 @@ struct PrRow { url: String, } -pub(super) async fn list_command( - args: PrListArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; +pub(super) async fn list_command(args: PrListArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; let mut entries = Vec::new(); @@ -42,7 +35,7 @@ pub(super) async fn list_command( } if entries.is_empty() { - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&Vec::::new())?; return Ok(()); } @@ -50,7 +43,7 @@ pub(super) async fn list_command( return Ok(()); } - let creds = super::load_github_credentials_required(cli_layer, printer)?; + let creds = super::load_github_credentials_required(base_ctx)?; let futures: Vec<_> = entries .iter() @@ -104,7 +97,7 @@ pub(super) async fn list_command( .collect() }; - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&rows)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/pr/merge.rs b/lib/crates/fabro-cli/src/commands/pr/merge.rs index 3ee3f3338..c34d5b4fc 100644 --- a/lib/crates/fabro-cli/src/commands/pr/merge.rs +++ b/lib/crates/fabro-cli/src/commands/pr/merge.rs @@ -1,22 +1,15 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use tracing::info; use crate::args::PrMergeArgs; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(super) async fn merge_command( - args: PrMergeArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let (record, _run_id) = - super::load_pr_record(&args.server, &args.run_id, cli_layer, printer).await?; +pub(super) async fn merge_command(args: PrMergeArgs, base_ctx: &CommandContext) -> Result<()> { + let (ctx, record, _run_id) = + super::load_pr_record(&args.server, &args.run_id, base_ctx).await?; - let creds = super::load_github_credentials_required(cli_layer, printer)?; + let creds = super::load_github_credentials_required(&ctx)?; fabro_github::merge_pull_request( &creds, @@ -30,14 +23,19 @@ pub(super) async fn merge_command( .map_err(|err| anyhow::anyhow!("{err}"))?; info!(number = record.number, owner = %record.owner, repo = %record.repo, method = %args.method, "Merged pull request"); - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "number": record.number, "html_url": record.html_url, "method": args.method, }))?; } else { - fabro_util::printout!(printer, "Merged #{} ({})", record.number, record.html_url); + fabro_util::printout!( + ctx.printer(), + "Merged #{} ({})", + record.number, + record.html_url + ); } Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index dd70c83f7..d5fd6c7e7 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -8,9 +8,7 @@ use anyhow::{Context, Result, anyhow}; use fabro_config::Storage; use fabro_github::GitHubCredentials; use fabro_types::PullRequestRecord; -use fabro_types::settings::cli::CliLayer; -use fabro_types::settings::{CliNamespace, InterpString}; -use fabro_util::printer::Printer; +use fabro_types::settings::InterpString; use crate::args::{PrCommand, PrNamespace, ServerTargetArgs}; use crate::command_context::CommandContext; @@ -20,20 +18,13 @@ use crate::user_config; const GITHUB_CREDENTIALS_REQUIRED: &str = "GitHub credentials required — run `fabro install` or set GITHUB_TOKEN"; -pub(crate) async fn dispatch( - ns: PrNamespace, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(ns: PrNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { - PrCommand::Create(args) => { - Box::pin(create::create_command(args, cli, cli_layer, printer)).await - } - PrCommand::List(args) => list::list_command(args, cli, cli_layer, printer).await, - PrCommand::View(args) => view::view_command(args, cli, cli_layer, printer).await, - PrCommand::Merge(args) => merge::merge_command(args, cli, cli_layer, printer).await, - PrCommand::Close(args) => close::close_command(args, cli, cli_layer, printer).await, + PrCommand::Create(args) => Box::pin(create::create_command(args, base_ctx)).await, + PrCommand::List(args) => list::list_command(args, base_ctx).await, + PrCommand::View(args) => view::view_command(args, base_ctx).await, + PrCommand::Merge(args) => merge::merge_command(args, base_ctx).await, + PrCommand::Close(args) => close::close_command(args, base_ctx).await, } } @@ -41,14 +32,10 @@ pub(crate) async fn dispatch( deprecated, reason = "boundary-exempt(pr-api): remove with follow-up #1 when PR ops move server-side" )] -fn load_github_credentials_required( - cli_layer: &CliLayer, - printer: Printer, -) -> Result { - let ctx = CommandContext::base(printer, cli_layer)?; - let server_settings = fabro_config::ServerSettings::from_layer(ctx.machine_settings()) +fn load_github_credentials_required(base_ctx: &CommandContext) -> Result { + let server_settings = fabro_config::ServerSettings::from_layer(base_ctx.machine_settings()) .map_err(anyhow::Error::from)?; - let vault = user_config::storage_dir(ctx.machine_settings()) + let vault = user_config::storage_dir(base_ctx.machine_settings()) .ok() .and_then(|dir| fabro_vault::Vault::load(Storage::new(&dir).secrets_path()).ok()); let creds = build_github_credentials( @@ -70,15 +57,14 @@ fn load_github_credentials_required( pub(crate) async fn load_pr_record( server: &ServerTargetArgs, run_id: &str, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<(PullRequestRecord, fabro_types::RunId)> { - let ctx = CommandContext::for_target(server, printer, cli_layer)?; + base_ctx: &CommandContext, +) -> Result<(CommandContext, PullRequestRecord, fabro_types::RunId)> { + let ctx = base_ctx.with_target(server)?; let client = ctx.server().await?; let run_id = client.resolve_run(run_id).await?.run_id; let state = client.get_run_state(&run_id).await?; let record = state.pull_request.with_context(|| { format!("No pull request found in store. Create one first with: fabro pr create {run_id}") })?; - Ok((record, run_id)) + Ok((ctx, record, run_id)) } diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs index d766e7dd4..0fcf343a6 100644 --- a/lib/crates/fabro-cli/src/commands/pr/view.rs +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -1,22 +1,15 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use tracing::info; use crate::args::PrViewArgs; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(super) async fn view_command( - args: PrViewArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let (record, _run_id) = - super::load_pr_record(&args.server, &args.run_id, cli_layer, printer).await?; +pub(super) async fn view_command(args: PrViewArgs, base_ctx: &CommandContext) -> Result<()> { + let (ctx, record, _run_id) = + super::load_pr_record(&args.server, &args.run_id, base_ctx).await?; - let creds = super::load_github_credentials_required(cli_layer, printer)?; + let creds = super::load_github_credentials_required(&ctx)?; let detail = fabro_github::get_pull_request( &creds, @@ -30,11 +23,12 @@ pub(super) async fn view_command( info!(number = detail.number, owner = %record.owner, repo = %record.repo, "Viewing pull request"); - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&detail)?; return Ok(()); } + let printer = ctx.printer(); fabro_util::printout!(printer, "#{} {}", detail.number, detail.title); let state_display = if detail.draft { "draft" } else { &detail.state }; fabro_util::printout!(printer, "State: {state_display}"); diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index 1a49e8cb5..5f98e0179 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -1,9 +1,6 @@ use anyhow::bail; use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use crate::args::PreflightArgs; @@ -17,13 +14,12 @@ use crate::shared::print_json_pretty; pub(crate) async fn execute( mut args: PreflightArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> anyhow::Result<()> { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; - args.verbose = args.verbose || cli.output.verbosity == OutputVerbosity::Verbose; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.target)?; + args.verbose = args.verbose || ctx.verbose(); let manifest = build_run_manifest(ManifestBuildInput { workflow: args.workflow.clone(), @@ -38,7 +34,7 @@ pub(crate) async fn execute( let response = client.run_preflight(manifest.manifest).await?; let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics); - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&response)?; } else { print_preflight_workflow_summary( diff --git a/lib/crates/fabro-cli/src/commands/provider/login.rs b/lib/crates/fabro-cli/src/commands/provider/login.rs index 86a393a09..32babd47d 100644 --- a/lib/crates/fabro-cli/src/commands/provider/login.rs +++ b/lib/crates/fabro-cli/src/commands/provider/login.rs @@ -1,23 +1,20 @@ use anyhow::Result; use fabro_api::types; use fabro_auth::credential_id_for; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; -use crate::args::{ProviderLoginArgs, require_no_json_override}; +use crate::args::ProviderLoginArgs; use crate::command_context::CommandContext; use crate::shared::provider_auth; pub(super) async fn login_command( args: ProviderLoginArgs, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, + base_ctx: &CommandContext, ) -> Result<()> { - require_no_json_override(process_local_json)?; + base_ctx.require_no_json_override()?; + let printer = base_ctx.printer(); let s = Styles::detect_stderr(); - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; + let ctx = base_ctx.with_target(&args.target)?; let server = ctx.server().await?; let credential = if args.api_key_stdin { provider_auth::authenticate_provider_with_api_key_source( diff --git a/lib/crates/fabro-cli/src/commands/provider/mod.rs b/lib/crates/fabro-cli/src/commands/provider/mod.rs index 07e3d9adf..8dd8f0080 100644 --- a/lib/crates/fabro-cli/src/commands/provider/mod.rs +++ b/lib/crates/fabro-cli/src/commands/provider/mod.rs @@ -1,20 +1,12 @@ mod login; use anyhow::Result; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; use crate::args::{ProviderCommand, ProviderNamespace}; +use crate::command_context::CommandContext; -pub(crate) async fn dispatch( - ns: ProviderNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(ns: ProviderNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { - ProviderCommand::Login(args) => { - login::login_command(args, cli_layer, process_local_json, printer).await - } + ProviderCommand::Login(args) => login::login_command(args, base_ctx).await, } } diff --git a/lib/crates/fabro-cli/src/commands/store/rebuild.rs b/lib/crates/fabro-cli/src/commands/rebuild.rs similarity index 100% rename from lib/crates/fabro-cli/src/commands/store/rebuild.rs rename to lib/crates/fabro-cli/src/commands/rebuild.rs diff --git a/lib/crates/fabro-cli/src/commands/repo/deinit.rs b/lib/crates/fabro-cli/src/commands/repo/deinit.rs index 8ed8ae707..4376c06a4 100644 --- a/lib/crates/fabro-cli/src/commands/repo/deinit.rs +++ b/lib/crates/fabro-cli/src/commands/repo/deinit.rs @@ -1,9 +1,9 @@ use anyhow::{Context, Result, bail}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::OutputFormat; -use fabro_util::printer::Printer; -pub(crate) fn run_deinit(cli: &CliNamespace, printer: Printer) -> Result> { +use crate::command_context::CommandContext; + +pub(crate) fn run_deinit(base_ctx: &CommandContext) -> Result> { + let printer = base_ctx.printer(); let repo_root = super::init::git_repo_root()?; let mut removed = Vec::new(); @@ -20,7 +20,7 @@ pub(crate) fn run_deinit(cli: &CliNamespace, printer: Printer) -> Result Result Result { pub(crate) async fn run_init( args: &RepoInitArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result> { + let printer = base_ctx.printer(); let repo_root = spawn_blocking(git_repo_root) .await .context("git repo root task panicked")??; @@ -79,7 +75,7 @@ draft = true let green = console::Style::new().green(); let bold = console::Style::new().bold(); let dim = console::Style::new().dim(); - if cli.output.format != OutputFormat::Json { + if !base_ctx.json_output() { fabro_util::printerr!( printer, " {} {}", @@ -112,7 +108,7 @@ draft = true ) .with_context(|| format!("failed to write {}", dot_path.display()))?; created.push(".fabro/workflows/hello/workflow.fabro".to_string()); - if cli.output.format != OutputFormat::Json { + if !base_ctx.json_output() { fabro_util::printerr!( printer, " {} {}", @@ -129,7 +125,7 @@ draft = true ) .with_context(|| format!("failed to write {}", toml_path.display()))?; created.push(".fabro/workflows/hello/workflow.toml".to_string()); - if cli.output.format != OutputFormat::Json { + if !base_ctx.json_output() { fabro_util::printerr!( printer, " {} {}", @@ -138,7 +134,7 @@ draft = true ); } - if cli.output.format != OutputFormat::Json { + if !base_ctx.json_output() { fabro_util::printerr!( printer, "\n{} Run a workflow with:\n\n {}", @@ -150,18 +146,15 @@ draft = true ); } - if cli.output.format != OutputFormat::Json { - check_github_app_installation(&args.target, cli_layer, printer).await; + if !base_ctx.json_output() { + check_github_app_installation(&args.target, base_ctx).await; } Ok(created) } -async fn check_github_app_installation( - target: &ServerTargetArgs, - cli_layer: &CliLayer, - printer: Printer, -) { +async fn check_github_app_installation(target: &ServerTargetArgs, base_ctx: &CommandContext) { + let printer = base_ctx.printer(); // Get the git remote origin URL let output = match TokioCommand::new("git") .args(["remote", "get-url", "origin"]) @@ -199,7 +192,7 @@ async fn check_github_app_installation( return; // Not a GitHub repo — skip silently }; - let ctx = match CommandContext::for_target(target, printer, cli_layer) { + let ctx = match base_ctx.with_target(target) { Ok(ctx) => ctx, Err(err) => { fabro_util::printerr!( diff --git a/lib/crates/fabro-cli/src/commands/repo/mod.rs b/lib/crates/fabro-cli/src/commands/repo/mod.rs index 5ddeaa95b..8ed44d046 100644 --- a/lib/crates/fabro-cli/src/commands/repo/mod.rs +++ b/lib/crates/fabro-cli/src/commands/repo/mod.rs @@ -2,30 +2,23 @@ pub(crate) mod deinit; pub(crate) mod init; use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use crate::args::{RepoCommand, RepoNamespace}; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(crate) async fn dispatch( - ns: RepoNamespace, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(ns: RepoNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { RepoCommand::Init(args) => { - let created = init::run_init(&args, cli, cli_layer, printer).await?; - if cli.output.format == OutputFormat::Json { + let created = init::run_init(&args, base_ctx).await?; + if base_ctx.json_output() { print_json_pretty(&serde_json::json!({ "created": created }))?; } Ok(()) } RepoCommand::Deinit => { - let removed = deinit::run_deinit(cli, printer)?; - if cli.output.format == OutputFormat::Json { + let removed = deinit::run_deinit(base_ctx)?; + if base_ctx.json_output() { print_json_pretty(&serde_json::json!({ "removed": removed }))?; } Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/run/command.rs b/lib/crates/fabro-cli/src/commands/run/command.rs index 20572cf2b..f824e4408 100644 --- a/lib/crates/fabro-cli/src/commands/run/command.rs +++ b/lib/crates/fabro-cli/src/commands/run/command.rs @@ -1,36 +1,19 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use crate::args::RunArgs; use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -use crate::user_config::load_settings_with_storage_dir; -pub(crate) async fn execute( - mut args: RunArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn execute(mut args: RunArgs, base_ctx: &CommandContext) -> Result<()> { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; - let cli_defaults = load_settings_with_storage_dir(None)?; - args.verbose = args.verbose || cli.output.verbosity == OutputVerbosity::Verbose; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.target)?; + args.verbose = args.verbose || ctx.verbose(); let quiet = args.detach; let prevent_idle_sleep = ctx.user_settings().cli.exec.prevent_idle_sleep; - let created_run = Box::pin(super::create::create_run( - &ctx, - &args, - cli_defaults, - styles, - quiet, - printer, - )) - .await?; + let created_run = Box::pin(super::create::create_run(&ctx, &args, styles, quiet)).await?; if !quiet { fabro_util::printerr!( @@ -50,7 +33,7 @@ pub(crate) async fn execute( let client = ctx.server().await?; super::start::start_run_with_client(&client, &created_run.run_id, false).await?; - let json = cli.output.format == OutputFormat::Json; + let json = ctx.json_output(); if args.detach { if json { print_json_pretty(&serde_json::json!({ "run_id": created_run.run_id }))?; @@ -64,7 +47,7 @@ pub(crate) async fn execute( true, styles, json, - ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose, + ctx.verbose(), printer, )) .await?; diff --git a/lib/crates/fabro-cli/src/commands/run/cp.rs b/lib/crates/fabro-cli/src/commands/run/cp.rs index 84c05258e..159354a8c 100644 --- a/lib/crates/fabro-cli/src/commands/run/cp.rs +++ b/lib/crates/fabro-cli/src/commands/run/cp.rs @@ -1,9 +1,6 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use tokio::fs; use tracing::{debug, info}; @@ -26,12 +23,7 @@ enum CopyDirection { }, } -pub(crate) async fn cp_command( - args: CpArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn cp_command(args: CpArgs, base_ctx: &CommandContext) -> Result<()> { let direction = parse_direction(&args.src, &args.dst)?; match direction { @@ -41,7 +33,7 @@ pub(crate) async fn cp_command( local_path, } => { let (client, run_id) = - resolve_client_and_run_id(&args.server, &run_prefix, cli_layer, printer).await?; + resolve_client_and_run_id(base_ctx, &args.server, &run_prefix).await?; let file_count = if args.recursive { Some(download_recursive(&client, &run_id, &remote_path, &local_path).await?) @@ -51,7 +43,7 @@ pub(crate) async fn cp_command( None }; - if cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { let mut value = serde_json::json!({ "direction": "download", "recursive": args.recursive, @@ -72,7 +64,7 @@ pub(crate) async fn cp_command( remote_path, } => { let (client, run_id) = - resolve_client_and_run_id(&args.server, &run_prefix, cli_layer, printer).await?; + resolve_client_and_run_id(base_ctx, &args.server, &run_prefix).await?; let file_count = if args.recursive { Some(upload_recursive(&client, &run_id, &local_path, &remote_path).await?) @@ -82,7 +74,7 @@ pub(crate) async fn cp_command( None }; - if cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { let mut value = serde_json::json!({ "direction": "upload", "recursive": args.recursive, @@ -124,12 +116,11 @@ fn parse_direction(src: &str, dst: &str) -> Result { } async fn resolve_client_and_run_id( + base_ctx: &CommandContext, server: &ServerTargetArgs, run_prefix: &str, - cli_layer: &CliLayer, - printer: Printer, ) -> Result<(Client, fabro_types::RunId)> { - let ctx = CommandContext::for_target(server, printer, cli_layer)?; + let ctx = base_ctx.with_target(server)?; let client = ctx.server().await?; let run_id = client.resolve_run(run_prefix).await?.run_id; Ok((client.clone_for_reuse(), run_id)) diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index 4c34ef861..9f1e5a105 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -1,8 +1,6 @@ use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; use fabro_types::RunId; -use fabro_types::settings::SettingsLayer; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use super::output::{api_diagnostics_to_local, print_preflight_workflow_summary}; @@ -22,10 +20,8 @@ pub(crate) struct CreatedRun { pub(crate) async fn create_run( ctx: &CommandContext, args: &RunArgs, - _cli_defaults: SettingsLayer, styles: &Styles, quiet: bool, - printer: Printer, ) -> anyhow::Result { let workflow_path = args .workflow @@ -51,6 +47,7 @@ pub(crate) async fn create_run( })?; let client = ctx.server().await?; if !quiet { + let printer = ctx.printer(); let preflight = client.run_preflight(built.manifest.clone()).await?; let diagnostics = api_diagnostics_to_local(&preflight.workflow.diagnostics); if !diagnostics diff --git a/lib/crates/fabro-cli/src/commands/run/diff.rs b/lib/crates/fabro-cli/src/commands/run/diff.rs index 51da824cc..e4ee319fb 100644 --- a/lib/crates/fabro-cli/src/commands/run/diff.rs +++ b/lib/crates/fabro-cli/src/commands/run/diff.rs @@ -10,9 +10,6 @@ use std::io::{self, IsTerminal, Write}; use anyhow::{Context, Result, bail}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use tracing::{debug, info}; use crate::args::DiffArgs; @@ -20,21 +17,16 @@ use crate::command_context::CommandContext; use crate::server_client::RunProjection; use crate::shared::print_json_pretty; -pub(crate) async fn run( - args: DiffArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn run(args: DiffArgs, base_ctx: &CommandContext) -> Result<()> { info!(run_id = %args.run, "Showing diff"); - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; let state = client.get_run_state(&run_id).await?; let patch = resolve_diff(&state, &args)?; - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { let value = serde_json::json!({ "run_id": run_id, "node": args.node, diff --git a/lib/crates/fabro-cli/src/commands/run/fork.rs b/lib/crates/fabro-cli/src/commands/run/fork.rs index 70d5ebba6..cf5dc7296 100644 --- a/lib/crates/fabro-cli/src/commands/run/fork.rs +++ b/lib/crates/fabro-cli/src/commands/run/fork.rs @@ -1,27 +1,19 @@ use anyhow::{Context, Result}; use fabro_checkpoint::git::Store; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_workflow::operations::{ForkRunInput, RewindTarget, build_timeline_or_rebuild, fork}; use git2::Repository; use crate::args::ForkArgs; use crate::command_context::CommandContext; -use crate::commands::store::rebuild::rebuild_run_store; +use crate::commands::rebuild::rebuild_run_store; use crate::shared::print_json_pretty; use crate::shared::repo::ensure_matching_repo_origin; -pub(crate) async fn run( - args: &ForkArgs, - styles: &Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn run(args: &ForkArgs, styles: &Styles, base_ctx: &CommandContext) -> Result<()> { let repo = Repository::discover(".").context("not in a git repository")?; - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run_id).await?.run_id; let state = client.get_run_state(&run_id).await?; @@ -34,7 +26,7 @@ pub(crate) async fn run( let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?; if args.list { - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&super::rewind::timeline_entries_json(&timeline))?; return Ok(()); } @@ -56,7 +48,7 @@ pub(crate) async fn run( let run_id_string = run_id.to_string(); let new_run_id_string = new_run_id.to_string(); - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { let target = args.target.clone().unwrap_or_else(|| "latest".to_string()); print_json_pretty(&serde_json::json!({ "source_run_id": run_id_string, diff --git a/lib/crates/fabro-cli/src/commands/run/logs.rs b/lib/crates/fabro-cli/src/commands/run/logs.rs index 879d3f68b..11def61ff 100644 --- a/lib/crates/fabro-cli/src/commands/run/logs.rs +++ b/lib/crates/fabro-cli/src/commands/run/logs.rs @@ -13,10 +13,7 @@ use std::time::Duration; use anyhow::{Context, Result, bail}; use chrono::{DateTime, Utc}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::json::normalize_json_value; -use fabro_util::printer::Printer; use fabro_util::redact::redact_jsonl_line; use fabro_util::terminal::Styles; use tokio::time; @@ -29,14 +26,8 @@ use crate::shared::format_usd_micros; const FOLLOW_TERMINAL_GRACE: Duration = Duration::from_millis(500); -pub(crate) async fn run( - args: &LogsArgs, - styles: &Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; +pub(crate) async fn run(args: &LogsArgs, styles: &Styles, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; info!(run_id = %run_id, "Showing logs"); @@ -60,7 +51,7 @@ pub(crate) async fn run( let stdout = io::stdout(); let is_tty = stdout.is_terminal(); let mut out = stdout.lock(); - let pretty = args.pretty && cli.output.format != OutputFormat::Json; + let pretty = args.pretty && !ctx.json_output(); for line in &filtered { if pretty { diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index 7acd35e3e..b8451b004 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -1,13 +1,9 @@ -use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; -use fabro_util::printer::Printer; +use anyhow::{Result, anyhow}; use fabro_util::terminal::Styles; use crate::args::{AttachArgs, RunCommands, RunWorkerArgs, StartArgs}; use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -use crate::user_config::load_settings_with_storage_dir; pub(crate) mod attach; pub(crate) mod command; @@ -29,27 +25,18 @@ pub(crate) mod wait; pub(crate) async fn dispatch( cmd: RunCommands, - cli: &CliNamespace, - cli_layer: &CliLayer, - _process_local_json: bool, - printer: Printer, + base_ctx: &CommandContext, + worker_token: Option, ) -> Result<()> { + let printer = base_ctx.printer(); + match cmd { - RunCommands::Run(args) => Box::pin(command::execute(args, cli, cli_layer, printer)).await, + RunCommands::Run(args) => Box::pin(command::execute(args, base_ctx)).await, RunCommands::Create(args) => { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let cli_defaults = load_settings_with_storage_dir(None)?; - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; - let created_run = Box::pin(create::create_run( - &ctx, - &args, - cli_defaults, - styles, - true, - printer, - )) - .await?; - if cli.output.format == OutputFormat::Json { + let ctx = base_ctx.with_target(&args.target)?; + let created_run = Box::pin(create::create_run(&ctx, &args, styles, true)).await?; + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "run_id": created_run.run_id }))?; } else { fabro_util::printout!(printer, "{}", created_run.run_id); @@ -57,27 +44,28 @@ pub(crate) async fn dispatch( Ok(()) } RunCommands::Start(StartArgs { server, run }) => { - let ctx = CommandContext::for_target(&server, printer, cli_layer)?; + let ctx = base_ctx.with_target(&server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&run).await?.run_id; start::start_run_with_client(client.as_ref(), &run_id, false).await?; - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "run_id": run_id }))?; } Ok(()) } RunCommands::Attach(AttachArgs { server, run }) => { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let ctx = CommandContext::for_target(&server, printer, cli_layer)?; + let ctx = base_ctx.with_target(&server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&run).await?.run_id; + let json = ctx.json_output(); let exit_code = Box::pin(attach::attach_run_with_client( client.as_ref(), &run_id, false, styles, - cli.output.format == OutputFormat::Json, - ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose, + json, + ctx.verbose(), printer, )) .await?; @@ -89,49 +77,50 @@ pub(crate) async fn dispatch( RunCommands::RunWorker(RunWorkerArgs { server, storage_dir, - artifact_upload_token, run_dir, run_id, mode, }) => { - runner::execute( + let worker_token = worker_token + .filter(|token| !token.trim().is_empty()) + .ok_or_else(|| { + anyhow!("FABRO_WORKER_TOKEN is required for worker subprocess auth") + })?; + Box::pin(runner::execute( run_id, server, storage_dir, - artifact_upload_token, run_dir, mode, - ) + &worker_token, + )) .await } - RunCommands::Diff(args) => diff::run(args, cli, cli_layer, printer).await, + RunCommands::Diff(args) => diff::run(args, base_ctx).await, RunCommands::Logs(args) => { let styles = Styles::detect_stdout(); - logs::run(&args, &styles, cli, cli_layer, printer).await + logs::run(&args, &styles, base_ctx).await } RunCommands::Resume(args) => { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let ctx = base_ctx.with_target(&args.server)?; crate::sleep_inhibitor::guard(ctx.user_settings().cli.exec.prevent_idle_sleep) }; - Box::pin(resume::resume_command( - args, styles, cli, cli_layer, printer, - )) - .await + Box::pin(resume::resume_command(args, styles, base_ctx)).await } RunCommands::Rewind(args) => { let styles = Styles::detect_stderr(); - Box::pin(rewind::run(&args, &styles, cli, cli_layer, printer)).await + Box::pin(rewind::run(&args, &styles, base_ctx)).await } RunCommands::Fork(args) => { let styles = Styles::detect_stderr(); - Box::pin(fork::run(&args, &styles, cli, cli_layer, printer)).await + Box::pin(fork::run(&args, &styles, base_ctx)).await } RunCommands::Wait(args) => { let styles = Styles::detect_stderr(); - wait::run(&args, &styles, cli, cli_layer, printer).await + wait::run(&args, &styles, base_ctx).await } } } diff --git a/lib/crates/fabro-cli/src/commands/run/overrides.rs b/lib/crates/fabro-cli/src/commands/run/overrides.rs index 9a8a813b4..7238c05e0 100644 --- a/lib/crates/fabro-cli/src/commands/run/overrides.rs +++ b/lib/crates/fabro-cli/src/commands/run/overrides.rs @@ -3,13 +3,13 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use fabro_sandbox::SandboxProvider; -use fabro_types::settings::SettingsLayer; use fabro_types::settings::cli::{CliLayer, CliOutputLayer, OutputVerbosity}; use fabro_types::settings::interp::InterpString; use fabro_types::settings::run::{ ApprovalMode, RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer, RunSandboxLayer, }; +use fabro_types::settings::{ReplaceMap, SettingsLayer}; use crate::args::{PreflightArgs, RunArgs}; @@ -133,7 +133,7 @@ pub(crate) fn run_args_layer(args: &RunArgs) -> Result { let run = RunLayer { goal, - metadata: parse_labels(&args.label), + metadata: ReplaceMap::from(parse_labels(&args.label)), model, sandbox, execution, diff --git a/lib/crates/fabro-cli/src/commands/run/preview.rs b/lib/crates/fabro-cli/src/commands/run/preview.rs index f344ffd76..c4c79753e 100644 --- a/lib/crates/fabro-cli/src/commands/run/preview.rs +++ b/lib/crates/fabro-cli/src/commands/run/preview.rs @@ -1,21 +1,13 @@ use anyhow::{Context, Result}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use tracing::info; use crate::args::PreviewArgs; use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(crate) async fn run( - args: PreviewArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; +pub(crate) async fn run(args: PreviewArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; let expires_in_secs = @@ -31,7 +23,8 @@ pub(crate) async fn run( info!(run_id = %args.run, port = args.port, "Generating preview URL"); - if cli.output.format == OutputFormat::Json { + let json = ctx.json_output(); + if json { match response.token { Some(token) => { print_json_pretty(&serde_json::json!({ "url": response.url, "token": token }))?; @@ -56,7 +49,7 @@ pub(crate) async fn run( } } - if args.open && !process_local_json { + if should_open_browser(args.open, json) { #[expect( clippy::disallowed_methods, reason = "Preview URL opening is a fire-and-forget OS integration, not a Tokio-managed child process." @@ -83,3 +76,23 @@ fn format_standard_output(url: &str, token: &str) -> String { fn format_signed_output(url: &str) -> String { format!("{url}\n") } + +fn should_open_browser(open_requested: bool, json: bool) -> bool { + open_requested && !json +} + +#[cfg(test)] +mod tests { + use super::should_open_browser; + + #[test] + fn json_output_suppresses_browser_opening() { + assert!(!should_open_browser(true, true)); + } + + #[test] + fn text_output_honors_browser_opening() { + assert!(should_open_browser(true, false)); + assert!(!should_open_browser(false, false)); + } +} diff --git a/lib/crates/fabro-cli/src/commands/run/resume.rs b/lib/crates/fabro-cli/src/commands/run/resume.rs index 9c892ea14..22db1bc5d 100644 --- a/lib/crates/fabro-cli/src/commands/run/resume.rs +++ b/lib/crates/fabro-cli/src/commands/run/resume.rs @@ -1,6 +1,3 @@ -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat, OutputVerbosity}; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use crate::args::ResumeArgs; @@ -15,17 +12,16 @@ use crate::shared::print_json_pretty; pub(crate) async fn resume_command( args: ResumeArgs, styles: &'static Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> anyhow::Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; super::start::start_run_with_client(client.as_ref(), &run_id, true).await?; - let json = cli.output.format == OutputFormat::Json; + let json = ctx.json_output(); if args.detach { if json { print_json_pretty(&serde_json::json!({ "run_id": run_id }))?; @@ -39,7 +35,7 @@ pub(crate) async fn resume_command( true, styles, json, - ctx.user_settings().cli.output.verbosity == OutputVerbosity::Verbose, + ctx.verbose(), printer, )) .await?; diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/crates/fabro-cli/src/commands/run/rewind.rs index a57e0f9d0..1a4556026 100644 --- a/lib/crates/fabro-cli/src/commands/run/rewind.rs +++ b/lib/crates/fabro-cli/src/commands/run/rewind.rs @@ -3,8 +3,6 @@ use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_checkpoint::git::Store; use fabro_types::run_event::{CheckpointCompletedProps, RunRewoundProps, RunSubmittedProps}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_types::{EventBody, RunEvent}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -17,7 +15,7 @@ use serde::Serialize; use crate::args::RewindArgs; use crate::command_context::CommandContext; -use crate::commands::store::rebuild::rebuild_run_store; +use crate::commands::rebuild::rebuild_run_store; use crate::server_client::Client; use crate::shared::repo::ensure_matching_repo_origin; use crate::shared::{color_if, print_json_pretty}; @@ -33,12 +31,11 @@ pub(crate) struct TimelineEntryJson { pub(crate) async fn run( args: &RewindArgs, styles: &Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result<()> { let repo = Repository::discover(".").context("not in a git repository")?; - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run_id).await?.run_id; let state = client.get_run_state(&run_id).await?; @@ -54,7 +51,7 @@ pub(crate) async fn run( let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?; if args.list || args.target.is_none() { - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&timeline_entries_json(&timeline))?; return Ok(()); } @@ -79,7 +76,7 @@ pub(crate) async fn run( let run_id_string = run_id.to_string(); - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "run_id": run_id_string, "target": target_arg, diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index b587fb3a1..9c3c75d9d 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -18,7 +18,7 @@ use fabro_interview::{ControlInterviewer, WorkerControlEnvelope, WorkerControlMe use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer}; use fabro_types::settings::run::RunMode; use fabro_types::settings::{InterpString, SettingsLayer}; -use fabro_types::{ArtifactUpload, EventBody, FailureReason, RunBlobId, RunEvent, RunId}; +use fabro_types::{ActorRef, ArtifactUpload, EventBody, FailureReason, RunBlobId, RunEvent, RunId}; use fabro_vault::Vault; use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader}; use fabro_workflow::event::{Emitter, RunEventSink}; @@ -57,14 +57,15 @@ pub(crate) async fn execute( run_id: RunId, server: String, storage_dir: Option, - artifact_upload_token: Option, run_dir: PathBuf, mode: RunWorkerMode, + worker_token: &str, ) -> Result<()> { let _ = fabro_proc::title_init(); set_worker_title(&run_id, initial_worker_title_phase(mode)); - let client = server_client::connect_server_target_direct(&server).await?; + let target = server.parse::()?; + let client = server_client::connect_server_target_with_bearer(&target, worker_token).await?; let run_store = HttpRunStore::connect(run_id, client.clone_for_reuse()).await?; let run_state = run_store .state() @@ -77,7 +78,7 @@ pub(crate) async fn execute( let artifact_sink = Some(ArtifactSink::Uploader(build_artifact_uploader( run_id, client.clone_for_reuse(), - artifact_upload_token, + worker_token.to_owned(), ))); let interviewer = Arc::new(ControlInterviewer::new()); let cancel_token = Arc::new(AtomicBool::new(false)); @@ -98,13 +99,16 @@ pub(crate) async fn execute( emitter: Arc::new(Emitter::new(run_id)), interviewer, run_store: run_store.clone(), - event_sink: RunEventSink::fanout(vec![ - RunEventSink::backend(run_store), - RunEventSink::callback(move |event| { - update_worker_title_from_event(&event); - async move { Ok(()) } - }), - ]), + event_sink: RunEventSink::map( + stamp_system_worker, + RunEventSink::fanout(vec![ + RunEventSink::backend(run_store), + RunEventSink::callback(move |event| { + update_worker_title_from_event(&event); + async move { Ok(()) } + }), + ]), + ), artifact_sink, run_control: Some(run_control), github_app, @@ -241,22 +245,19 @@ async fn apply_worker_control_line( fn build_artifact_uploader( run_id: RunId, client: server_client::Client, - artifact_upload_token: Option, + worker_token: String, ) -> Arc { - match artifact_upload_token { - Some(token) => Arc::new(HttpArtifactUploader { - run_id, - client, - bearer_token: token, - }), - None => Arc::new(MissingArtifactUploadTokenUploader { run_id }), - } + Arc::new(HttpArtifactUploader { + run_id, + client, + worker_token, + }) } struct HttpArtifactUploader { run_id: RunId, client: server_client::Client, - bearer_token: String, + worker_token: String, } #[async_trait] @@ -280,7 +281,7 @@ impl StageArtifactUploader for HttpArtifactUploader { stage_id, &artifact.path, &artifact_capture_dir.join(&artifact.path), - &self.bearer_token, + &self.worker_token, ) .await; } @@ -291,31 +292,12 @@ impl StageArtifactUploader for HttpArtifactUploader { stage_id, artifact_capture_dir, artifacts, - &self.bearer_token, + &self.worker_token, ) .await } } -struct MissingArtifactUploadTokenUploader { - run_id: RunId, -} - -#[async_trait] -impl StageArtifactUploader for MissingArtifactUploadTokenUploader { - async fn upload_stage_artifacts( - &self, - _stage_id: &fabro_types::StageId, - _artifact_capture_dir: &Path, - _artifacts: &[ArtifactUpload], - ) -> Result<()> { - Err(anyhow!( - "run {} could not upload artifacts because the worker did not receive an artifact upload token", - self.run_id - )) - } -} - #[derive(Clone)] struct HttpRunStore { run_id: RunId, @@ -502,6 +484,13 @@ fn update_worker_title_from_event(event: &RunEvent) { } } +fn stamp_system_worker(mut event: RunEvent) -> RunEvent { + if event.actor.is_none() { + event.actor = Some(ActorRef::system_worker()); + } + event +} + fn maybe_build_github_credentials( settings: &SettingsLayer, vault: Option<&fabro_vault::Vault>, @@ -587,6 +576,7 @@ mod tests { use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; + use chrono::Utc; use fabro_auth::{AuthCredential, AuthDetails}; use fabro_config::Storage; use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question, QuestionType}; @@ -595,18 +585,36 @@ mod tests { InterviewCompletedProps, InterviewStartedProps, RunCompletedProps, RunControlEffectProps, RunFailedProps, RunStatusTransitionProps, }; - use fabro_types::{EventBody, FailureReason, SuccessReason, fixtures}; + use fabro_types::{ActorRef, EventBody, FailureReason, SuccessReason, fixtures}; use fabro_vault::{SecretType, Vault}; - use fabro_workflow::artifact_upload::StageArtifactUploader; + use fabro_workflow::event::RunEventSink; use super::{ - MissingArtifactUploadTokenUploader, WorkerControlStreamEvent, WorkerTitlePhase, - apply_worker_control_line, handle_worker_control_stream_events, initial_worker_title_phase, - load_worker_vault, read_worker_control_stream_blocking, worker_title, + WorkerControlStreamEvent, WorkerTitlePhase, apply_worker_control_line, + handle_worker_control_stream_events, initial_worker_title_phase, load_worker_vault, + read_worker_control_stream_blocking, stamp_system_worker, worker_title, worker_title_phase_for_event, }; use crate::args::RunWorkerMode; + fn running_event(actor: Option) -> fabro_types::RunEvent { + fabro_types::RunEvent { + id: "evt_1".to_string(), + ts: Utc::now(), + run_id: fixtures::RUN_1, + node_id: None, + node_label: None, + stage_id: None, + parallel_group_id: None, + parallel_branch_id: None, + session_id: None, + parent_session_id: None, + tool_call_id: None, + actor, + body: EventBody::RunRunning(RunStatusTransitionProps::default()), + } + } + #[test] fn worker_title_uses_short_run_id_and_phase() { let short_id: String = fixtures::RUN_1.to_string().chars().take(12).collect(); @@ -699,24 +707,50 @@ mod tests { ); } + #[test] + fn stamp_system_worker_fills_missing_actor_only() { + let stamped = stamp_system_worker(running_event(None)); + + assert_eq!(stamped.actor, Some(ActorRef::system_worker())); + + let existing_actor = ActorRef::user("octocat".to_string()); + let stamped = stamp_system_worker(running_event(Some(existing_actor.clone()))); + assert_eq!(stamped.actor, Some(existing_actor)); + } + #[tokio::test] - async fn missing_artifact_upload_token_error_does_not_mention_removed_storage_mode() { - let uploader = MissingArtifactUploadTokenUploader { - run_id: fixtures::RUN_1, - }; - let temp = tempfile::tempdir().unwrap(); - - let error = uploader - .upload_stage_artifacts(&fabro_types::StageId::new("code", 2), temp.path(), &[]) - .await - .unwrap_err(); - - assert!( - error - .to_string() - .contains("worker did not receive an artifact upload token") + async fn worker_event_stamp_applies_to_all_fanout_sinks() { + let first = Arc::new(tokio::sync::Mutex::new(Vec::new())); + let second = Arc::new(tokio::sync::Mutex::new(Vec::new())); + let first_events = Arc::clone(&first); + let second_events = Arc::clone(&second); + let sink = RunEventSink::map( + stamp_system_worker, + RunEventSink::fanout(vec![ + RunEventSink::callback(move |event| { + let first_events = Arc::clone(&first_events); + async move { + first_events.lock().await.push(event); + Ok(()) + } + }), + RunEventSink::callback(move |event| { + let second_events = Arc::clone(&second_events); + async move { + second_events.lock().await.push(event); + Ok(()) + } + }), + ]), ); - assert!(!error.to_string().contains("object-backed artifacts")); + let event = running_event(None); + + sink.write_run_event(&event).await.unwrap(); + + let first = first.lock().await; + let second = second.lock().await; + assert_eq!(first[0].actor, Some(ActorRef::system_worker())); + assert_eq!(second[0].actor, Some(ActorRef::system_worker())); } #[tokio::test] diff --git a/lib/crates/fabro-cli/src/commands/run/ssh.rs b/lib/crates/fabro-cli/src/commands/run/ssh.rs index d3ce4f9f8..37ceabc0f 100644 --- a/lib/crates/fabro-cli/src/commands/run/ssh.rs +++ b/lib/crates/fabro-cli/src/commands/run/ssh.rs @@ -1,25 +1,17 @@ use anyhow::{Result, bail}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use tracing::info; -use crate::args::{SshArgs, require_no_json_override}; +use crate::args::SshArgs; use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(crate) async fn run( - args: SshArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { - if process_local_json && !args.print { - require_no_json_override(process_local_json)?; +pub(crate) async fn run(args: SshArgs, base_ctx: &CommandContext) -> Result<()> { + if !args.print { + base_ctx.require_no_json_override()?; } - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; let ssh = client.create_run_ssh_access(&run_id, args.ttl).await?; @@ -27,7 +19,7 @@ pub(crate) async fn run( info!(run_id = %args.run, ttl_minutes = args.ttl, "Creating SSH access"); if args.print { - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "command": ssh.command }))?; } else { { diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/crates/fabro-cli/src/commands/run/wait.rs index 26bf2d181..fbd2a1e96 100644 --- a/lib/crates/fabro-cli/src/commands/run/wait.rs +++ b/lib/crates/fabro-cli/src/commands/run/wait.rs @@ -11,8 +11,6 @@ use std::io::Write; use anyhow::{Result, bail}; use fabro_types::RunId; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_workflow::records::Conclusion; @@ -24,14 +22,9 @@ use crate::args::WaitArgs; use crate::command_context::CommandContext; use crate::shared::{format_duration_ms, format_usd_micros, run_status_kind}; -pub(crate) async fn run( - args: &WaitArgs, - styles: &Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; +pub(crate) async fn run(args: &WaitArgs, styles: &Styles, base_ctx: &CommandContext) -> Result<()> { + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.server)?; let client = ctx.server().await?; let run_id = client.resolve_run(&args.run).await?.run_id; info!(run_id = %run_id, "Waiting for run to complete"); @@ -63,7 +56,7 @@ pub(crate) async fn run( let conclusion = client.get_run_state(&run_id).await?.conclusion; - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { let json_value = build_json_output(final_status, &run_id, conclusion.as_ref()); let mut out = std::io::stdout().lock(); serde_json::to_writer_pretty(&mut out, &json_value)?; diff --git a/lib/crates/fabro-cli/src/commands/runs/archive.rs b/lib/crates/fabro-cli/src/commands/runs/archive.rs index dcc996fc9..bf9543392 100644 --- a/lib/crates/fabro-cli/src/commands/runs/archive.rs +++ b/lib/crates/fabro-cli/src/commands/runs/archive.rs @@ -1,46 +1,24 @@ use anyhow::{Result, bail}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use super::short_run_id; use crate::args::{RunsArchiveArgs, RunsUnarchiveArgs}; use crate::command_context::CommandContext; -use crate::server_client; use crate::shared::print_json_pretty; pub(crate) async fn archive_command( args: &RunsArchiveArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; - run_bulk( - Action::Archive, - &args.runs, - ctx.server().await?.as_ref(), - cli, - printer, - ) - .await + let ctx = base_ctx.with_target(&args.server)?; + run_bulk(Action::Archive, &args.runs, &ctx).await } pub(crate) async fn unarchive_command( args: &RunsUnarchiveArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; - run_bulk( - Action::Unarchive, - &args.runs, - ctx.server().await?.as_ref(), - cli, - printer, - ) - .await + let ctx = base_ctx.with_target(&args.server)?; + run_bulk(Action::Unarchive, &args.runs, &ctx).await } #[derive(Clone, Copy)] @@ -62,14 +40,11 @@ impl Action { } } -async fn run_bulk( - action: Action, - identifiers: &[String], - client: &server_client::Client, - cli: &CliNamespace, - printer: Printer, -) -> Result<()> { - let json = cli.output.format == OutputFormat::Json; +async fn run_bulk(action: Action, identifiers: &[String], ctx: &CommandContext) -> Result<()> { + let client = ctx.server().await?; + let client = client.as_ref(); + let json = ctx.json_output(); + let printer = ctx.printer(); let mut had_errors = false; let mut changed = Vec::new(); let mut errors = Vec::new(); diff --git a/lib/crates/fabro-cli/src/commands/runs/inspect.rs b/lib/crates/fabro-cli/src/commands/runs/inspect.rs index dfde883ae..80f7b2015 100644 --- a/lib/crates/fabro-cli/src/commands/runs/inspect.rs +++ b/lib/crates/fabro-cli/src/commands/runs/inspect.rs @@ -1,6 +1,4 @@ use anyhow::Result; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; use fabro_workflow::run_status::RunStatus; use serde::Serialize; @@ -20,8 +18,9 @@ pub(crate) struct InspectOutput { pub sandbox: Option, } -pub(crate) async fn run(args: &InspectArgs, cli_layer: &CliLayer, printer: Printer) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; +pub(crate) async fn run(args: &InspectArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); let client = ctx.server().await?; let run = ServerRunSummaryInfo::from_summary(client.resolve_run(&args.run).await?); let run_id = run.run_id(); diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index c26f4c73e..1eec81398 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -4,9 +4,6 @@ use anyhow::Result; use chrono::Utc; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_util::text::strip_goal_decoration; use fabro_workflow::run_status::RunStatus; @@ -20,11 +17,10 @@ use crate::shared::{color_if, format_duration_ms, run_status_kind, tilde_path}; pub(crate) async fn list_command( args: &RunsListArgs, styles: &Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; + let ctx = base_ctx.with_target(&args.server)?; + let printer = ctx.printer(); let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; let label_filters = parse_label_filters(&args.filter.label); let filtered = filter_server_runs( @@ -35,7 +31,7 @@ pub(crate) async fn list_command( !args.all, ); - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { let json_rows: Vec<_> = filtered .iter() .map(|run| { diff --git a/lib/crates/fabro-cli/src/commands/runs/mod.rs b/lib/crates/fabro-cli/src/commands/runs/mod.rs index 3804d4052..49ea0561c 100644 --- a/lib/crates/fabro-cli/src/commands/runs/mod.rs +++ b/lib/crates/fabro-cli/src/commands/runs/mod.rs @@ -1,35 +1,24 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use crate::args::RunsCommands; +use crate::command_context::CommandContext; pub(crate) mod archive; pub(crate) mod inspect; pub(crate) mod list; pub(crate) mod rm; -pub(crate) async fn dispatch( - cmd: RunsCommands, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(cmd: RunsCommands, base_ctx: &CommandContext) -> Result<()> { match cmd { RunsCommands::Ps(args) => { let styles = Styles::detect_stdout(); - list::list_command(&args, &styles, cli, cli_layer, printer).await - } - RunsCommands::Rm(args) => rm::remove_command(&args, cli, cli_layer, printer).await, - RunsCommands::Inspect(args) => inspect::run(&args, cli_layer, printer).await, - RunsCommands::Archive(args) => { - archive::archive_command(&args, cli, cli_layer, printer).await - } - RunsCommands::Unarchive(args) => { - archive::unarchive_command(&args, cli, cli_layer, printer).await + list::list_command(&args, &styles, base_ctx).await } + RunsCommands::Rm(args) => rm::remove_command(&args, base_ctx).await, + RunsCommands::Inspect(args) => inspect::run(&args, base_ctx).await, + RunsCommands::Archive(args) => archive::archive_command(&args, base_ctx).await, + RunsCommands::Unarchive(args) => archive::unarchive_command(&args, base_ctx).await, } } diff --git a/lib/crates/fabro-cli/src/commands/runs/rm.rs b/lib/crates/fabro-cli/src/commands/runs/rm.rs index 8990d4efc..31cacfd99 100644 --- a/lib/crates/fabro-cli/src/commands/runs/rm.rs +++ b/lib/crates/fabro-cli/src/commands/runs/rm.rs @@ -1,7 +1,4 @@ use anyhow::{Result, bail}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use super::short_run_id; use crate::args::RunsRemoveArgs; @@ -9,23 +6,16 @@ use crate::command_context::CommandContext; use crate::server_client; use crate::shared::print_json_pretty; -pub(crate) async fn remove_command( - args: &RunsRemoveArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&args.server, printer, cli_layer)?; - remove_from(args, ctx.server().await?.as_ref(), cli, printer).await +pub(crate) async fn remove_command(args: &RunsRemoveArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + remove_from(args, &ctx).await } -async fn remove_from( - args: &RunsRemoveArgs, - client: &server_client::Client, - cli: &CliNamespace, - printer: Printer, -) -> Result<()> { - let json = cli.output.format == OutputFormat::Json; +async fn remove_from(args: &RunsRemoveArgs, ctx: &CommandContext) -> Result<()> { + let client = ctx.server().await?; + let client = client.as_ref(); + let json = ctx.json_output(); + let printer = ctx.printer(); let mut had_errors = false; let mut removed = Vec::new(); let mut errors = Vec::new(); diff --git a/lib/crates/fabro-cli/src/commands/sandbox/mod.rs b/lib/crates/fabro-cli/src/commands/sandbox/mod.rs index 3adc7bd13..96c41bb50 100644 --- a/lib/crates/fabro-cli/src/commands/sandbox/mod.rs +++ b/lib/crates/fabro-cli/src/commands/sandbox/mod.rs @@ -1,24 +1,12 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; use crate::args::SandboxCommand; +use crate::command_context::CommandContext; -pub(crate) async fn dispatch( - command: SandboxCommand, - cli: &CliNamespace, - cli_layer: &CliLayer, - process_local_json: bool, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(command: SandboxCommand, base_ctx: &CommandContext) -> Result<()> { match command { - SandboxCommand::Cp(args) => super::run::cp::cp_command(args, cli, cli_layer, printer).await, - SandboxCommand::Preview(args) => { - super::run::preview::run(args, cli, cli_layer, process_local_json, printer).await - } - SandboxCommand::Ssh(args) => { - super::run::ssh::run(args, cli, cli_layer, process_local_json, printer).await - } + SandboxCommand::Cp(args) => super::run::cp::cp_command(args, base_ctx).await, + SandboxCommand::Preview(args) => super::run::preview::run(args, base_ctx).await, + SandboxCommand::Ssh(args) => super::run::ssh::run(args, base_ctx).await, } } diff --git a/lib/crates/fabro-cli/src/commands/secret/list.rs b/lib/crates/fabro-cli/src/commands/secret/list.rs index 25d7f7680..0ceb23871 100644 --- a/lib/crates/fabro-cli/src/commands/secret/list.rs +++ b/lib/crates/fabro-cli/src/commands/secret/list.rs @@ -2,13 +2,10 @@ use anyhow::Result; use chrono::{DateTime, Utc}; use cli_table::format::{Border, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::OutputFormat; -use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use crate::args::SecretListArgs; -use crate::server_client::Client; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; fn format_age(dt: DateTime, now: DateTime) -> String { @@ -22,14 +19,11 @@ fn format_age(dt: DateTime, now: DateTime) -> String { } } -pub(super) async fn list_command( - client: &Client, - _args: &SecretListArgs, - cli: &CliNamespace, - printer: Printer, -) -> Result<()> { +pub(super) async fn list_command(_args: &SecretListArgs, ctx: &CommandContext) -> Result<()> { + let client = ctx.server().await?; + let printer = ctx.printer(); let secrets = client.list_secrets().await?; - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&secrets)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/secret/mod.rs b/lib/crates/fabro-cli/src/commands/secret/mod.rs index 19fdeef73..6b748f445 100644 --- a/lib/crates/fabro-cli/src/commands/secret/mod.rs +++ b/lib/crates/fabro-cli/src/commands/secret/mod.rs @@ -3,24 +3,15 @@ mod rm; mod set; use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; use crate::args::{SecretCommand, SecretNamespace}; use crate::command_context::CommandContext; -pub(crate) async fn dispatch( - ns: SecretNamespace, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_target(&ns.target, printer, cli_layer)?; - let server = ctx.server().await?; +pub(crate) async fn dispatch(ns: SecretNamespace, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&ns.target)?; match ns.command { - SecretCommand::List(args) => list::list_command(&server, &args, cli, printer).await, - SecretCommand::Rm(args) => rm::rm_command(&server, &args, cli, printer).await, - SecretCommand::Set(args) => set::set_command(&server, &args, cli, printer).await, + SecretCommand::List(args) => list::list_command(&args, &ctx).await, + SecretCommand::Rm(args) => rm::rm_command(&args, &ctx).await, + SecretCommand::Set(args) => set::set_command(&args, &ctx).await, } } diff --git a/lib/crates/fabro-cli/src/commands/secret/rm.rs b/lib/crates/fabro-cli/src/commands/secret/rm.rs index 253ca09d0..1821f2823 100644 --- a/lib/crates/fabro-cli/src/commands/secret/rm.rs +++ b/lib/crates/fabro-cli/src/commands/secret/rm.rs @@ -1,23 +1,16 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::OutputFormat; -use fabro_util::printer::Printer; use crate::args::SecretRmArgs; -use crate::server_client::Client; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(super) async fn rm_command( - client: &Client, - args: &SecretRmArgs, - cli: &CliNamespace, - printer: Printer, -) -> Result<()> { +pub(super) async fn rm_command(args: &SecretRmArgs, ctx: &CommandContext) -> Result<()> { + let client = ctx.server().await?; client.delete_secret_by_name(&args.key).await?; - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "key": args.key }))?; } else { - fabro_util::printerr!(printer, "Removed {}", args.key); + fabro_util::printerr!(ctx.printer(), "Removed {}", args.key); } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/secret/set.rs b/lib/crates/fabro-cli/src/commands/secret/set.rs index 1ec66418e..2a361b875 100644 --- a/lib/crates/fabro-cli/src/commands/secret/set.rs +++ b/lib/crates/fabro-cli/src/commands/secret/set.rs @@ -11,13 +11,10 @@ use std::io::{IsTerminal, Read as _}; use anyhow::{Context as _, Result, bail}; use fabro_api::types; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::OutputFormat; -use fabro_util::printer::Printer; use tokio::task::spawn_blocking; use crate::args::{SecretSetArgs, SecretTypeArg}; -use crate::server_client::Client; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; use crate::shared::provider_auth::prompt_password; @@ -57,13 +54,9 @@ async fn resolve_value(args: &SecretSetArgs) -> Result { bail!("secret value required: pass , use --value-stdin, or run interactively") } -pub(super) async fn set_command( - client: &Client, - args: &SecretSetArgs, - cli: &CliNamespace, - printer: Printer, -) -> Result<()> { +pub(super) async fn set_command(args: &SecretSetArgs, ctx: &CommandContext) -> Result<()> { let value = resolve_value(args).await?; + let client = ctx.server().await?; let meta = client .create_secret(types::CreateSecretRequest { name: args.key.clone(), @@ -72,10 +65,10 @@ pub(super) async fn set_command( description: args.description.clone(), }) .await?; - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&meta)?; } else { - fabro_util::printerr!(printer, "Set {}", meta.name); + fabro_util::printerr!(ctx.printer(), "Set {}", meta.name); } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/crates/fabro-cli/src/commands/server/start.rs index 9e87dcc50..6380cf1a9 100644 --- a/lib/crates/fabro-cli/src/commands/server/start.rs +++ b/lib/crates/fabro-cli/src/commands/server/start.rs @@ -7,15 +7,15 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; +use fabro_config::RuntimeDirectory; use fabro_config::bind::{Bind, BindRequest}; use fabro_config::daemon::ServerDaemon; use fabro_config::user::{FABRO_CONFIG_ENV, default_settings_path, load_settings_config}; -use fabro_config::{RuntimeDirectory, envfile}; use fabro_server::jwt_auth::auth_method_name; -use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs}; +use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs, resolve_runtime_server_settings_for_start}; +use fabro_server::{process_env_snapshot, validate_startup}; use fabro_types::settings::ServerAuthMethod; use fabro_util::printer::Printer; -use fabro_util::session_secret; use fabro_util::terminal::Styles; use tokio::net::{TcpStream, UnixStream}; use tokio::process::Command as TokioCommand; @@ -222,33 +222,6 @@ fn configured_auth_methods(config_path: Option<&Path>) -> Vec .unwrap_or_default() } -fn valid_session_secret(secret: &str) -> bool { - session_secret::validate_session_secret(secret).is_ok() -} - -fn load_or_create_local_session_secret(runtime_directory: &RuntimeDirectory) -> Result { - if let Some(secret) = std::env::var("SESSION_SECRET") - .ok() - .filter(|secret| valid_session_secret(secret)) - { - return Ok(secret); - } - - let server_env_path = runtime_directory.env_path(); - if let Some(secret) = envfile::read_env_file(&server_env_path) - .ok() - .and_then(|entries| entries.get("SESSION_SECRET").cloned()) - .filter(|secret| valid_session_secret(secret)) - { - return Ok(secret); - } - - let secret = session_secret::generate_session_secret(); - envfile::merge_env_file(&server_env_path, [("SESSION_SECRET", secret.as_str())]) - .with_context(|| format!("merging session secret into {}", server_env_path.display()))?; - Ok(secret) -} - // --------------------------------------------------------------------------- // Foreground mode // --------------------------------------------------------------------------- @@ -261,18 +234,6 @@ async fn execute_foreground( styles: &'static Styles, _printer: Printer, ) -> Result<()> { - let session_secret = load_or_create_local_session_secret(&RuntimeDirectory::new(&storage_dir))?; - let prior_session_secret = std::env::var_os("SESSION_SECRET"); - std::env::set_var("SESSION_SECRET", &session_secret); - let _env_guard = - scopeguard::guard( - prior_session_secret, - |prior_session_secret| match prior_session_secret { - Some(value) => std::env::set_var("SESSION_SECRET", value), - None => std::env::remove_var("SESSION_SECRET"), - }, - ); - super::foreground::serve_with_daemon_record(serve_args, bind, storage_dir, styles).await } @@ -303,6 +264,13 @@ async fn execute_daemon( return Ok(()); } + let resolved_settings = resolve_runtime_server_settings_for_start(serve_args, storage_dir)?; + validate_startup( + runtime_directory.env_path().as_path(), + process_env_snapshot(), + &resolved_settings, + )?; + let log_path = runtime_directory.log_path(); if let Some(parent) = log_path.parent() { std::fs::create_dir_all(parent) @@ -347,9 +315,7 @@ async fn execute_daemon( cmd.arg("--watch-web"); } - let session_secret = load_or_create_local_session_secret(&runtime_directory)?; cmd.arg("--storage-dir").arg(storage_dir); - cmd.env("SESSION_SECRET", &session_secret); cmd.env_remove("FABRO_JSON"); cmd.stdout(stdout_log) diff --git a/lib/crates/fabro-cli/src/commands/store/mod.rs b/lib/crates/fabro-cli/src/commands/store/mod.rs deleted file mode 100644 index 2c885231b..000000000 --- a/lib/crates/fabro-cli/src/commands/store/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -pub(crate) mod dump; -pub(crate) mod rebuild; -mod run_export; - -use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; - -use crate::args::{StoreCommand, StoreNamespace}; - -pub(crate) async fn dispatch( - ns: StoreNamespace, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - match ns.command { - StoreCommand::Dump(args) => dump::dump_command(&args, cli, cli_layer, printer).await, - } -} diff --git a/lib/crates/fabro-cli/src/commands/store/run_export.rs b/lib/crates/fabro-cli/src/commands/store/run_export.rs deleted file mode 100644 index 42738384d..000000000 --- a/lib/crates/fabro-cli/src/commands/store/run_export.rs +++ /dev/null @@ -1 +0,0 @@ -pub(super) use fabro_workflow::run_dump::RunDump as StoreRunExport; diff --git a/lib/crates/fabro-cli/src/commands/system/df.rs b/lib/crates/fabro-cli/src/commands/system/df.rs index 67a099a2e..fd4e43eab 100644 --- a/lib/crates/fabro-cli/src/commands/system/df.rs +++ b/lib/crates/fabro-cli/src/commands/system/df.rs @@ -3,23 +3,15 @@ use chrono::{DateTime, Utc}; use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; use fabro_api::types; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use crate::args::DfArgs; use crate::command_context::CommandContext; use crate::shared::{format_size, print_json_pretty}; -pub(super) async fn df_command( - args: &DfArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?; +pub(super) async fn df_command(args: &DfArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_connection(&args.connection)?; let server = ctx.server().await?; - let json = cli.output.format == OutputFormat::Json; + let json = ctx.json_output(); let (output, storage_dir) = if json { (server.get_system_disk_usage(args.verbose).await?, None) diff --git a/lib/crates/fabro-cli/src/commands/system/events.rs b/lib/crates/fabro-cli/src/commands/system/events.rs index 1e8d69346..8e063ca7d 100644 --- a/lib/crates/fabro-cli/src/commands/system/events.rs +++ b/lib/crates/fabro-cli/src/commands/system/events.rs @@ -1,8 +1,5 @@ use anyhow::Result; use fabro_client::sse; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use futures::StreamExt; use crate::args::SystemEventsArgs; @@ -10,16 +7,14 @@ use crate::command_context::CommandContext; pub(super) async fn events_command( args: &SystemEventsArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> Result<()> { - let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?; + let ctx = base_ctx.with_connection(&args.connection)?; let server = ctx.server().await?; let mut stream = server.attach_events(&args.run_ids).await?; let mut pending = Vec::new(); - let json = cli.output.format == OutputFormat::Json; + let json = ctx.json_output(); while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|err| anyhow::anyhow!("{err}"))?; pending.extend_from_slice(&chunk); diff --git a/lib/crates/fabro-cli/src/commands/system/info.rs b/lib/crates/fabro-cli/src/commands/system/info.rs index 314b3f347..c81024215 100644 --- a/lib/crates/fabro-cli/src/commands/system/info.rs +++ b/lib/crates/fabro-cli/src/commands/system/info.rs @@ -1,23 +1,15 @@ use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_util::printer::Printer; use crate::args::SystemInfoArgs; use crate::command_context::CommandContext; use crate::shared::print_json_pretty; -pub(super) async fn info_command( - args: &SystemInfoArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?; +pub(super) async fn info_command(args: &SystemInfoArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_connection(&args.connection)?; let server = ctx.server().await?; let response = server.get_system_info().await?; - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&response)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/system/mod.rs b/lib/crates/fabro-cli/src/commands/system/mod.rs index 45f35dee6..d70cae9c6 100644 --- a/lib/crates/fabro-cli/src/commands/system/mod.rs +++ b/lib/crates/fabro-cli/src/commands/system/mod.rs @@ -4,23 +4,16 @@ mod info; mod prune; use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::CliLayer; -use fabro_util::printer::Printer; pub(crate) use prune::parse_duration; use crate::args::{SystemCommand, SystemNamespace}; +use crate::command_context::CommandContext; -pub(crate) async fn dispatch( - ns: SystemNamespace, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn dispatch(ns: SystemNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { - SystemCommand::Info(args) => info::info_command(&args, cli, cli_layer, printer).await, - SystemCommand::Prune(args) => prune::prune_command(&args, cli, cli_layer, printer).await, - SystemCommand::Df(args) => df::df_command(&args, cli, cli_layer, printer).await, - SystemCommand::Events(args) => events::events_command(&args, cli, cli_layer, printer).await, + SystemCommand::Info(args) => info::info_command(&args, base_ctx).await, + SystemCommand::Prune(args) => prune::prune_command(&args, base_ctx).await, + SystemCommand::Df(args) => df::df_command(&args, base_ctx).await, + SystemCommand::Events(args) => events::events_command(&args, base_ctx).await, } } diff --git a/lib/crates/fabro-cli/src/commands/system/prune.rs b/lib/crates/fabro-cli/src/commands/system/prune.rs index 376ae2003..3fef61b9c 100644 --- a/lib/crates/fabro-cli/src/commands/system/prune.rs +++ b/lib/crates/fabro-cli/src/commands/system/prune.rs @@ -2,8 +2,6 @@ use std::collections::HashMap; use anyhow::{Context, Result, bail}; use fabro_api::types; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use tracing::{debug, info}; @@ -11,13 +9,9 @@ use crate::args::RunsPruneArgs; use crate::command_context::CommandContext; use crate::shared::{format_size, print_json_pretty}; -pub(super) async fn prune_command( - args: &RunsPruneArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { - let ctx = CommandContext::for_connection(&args.connection, printer, cli_layer)?; +pub(super) async fn prune_command(args: &RunsPruneArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_connection(&args.connection)?; + let printer = ctx.printer(); let server = ctx.server().await?; let response = server .prune_runs(types::PruneRunsRequest { @@ -29,7 +23,7 @@ pub(super) async fn prune_command( workflow: args.filter.workflow.clone(), }) .await?; - prune_from(&response, cli.output.format == OutputFormat::Json, printer) + prune_from(&response, ctx.json_output(), printer) } pub(crate) fn parse_duration(s: &str) -> Result { diff --git a/lib/crates/fabro-cli/src/commands/uninstall.rs b/lib/crates/fabro-cli/src/commands/uninstall.rs index 5d19aa459..63b5fd67d 100644 --- a/lib/crates/fabro-cli/src/commands/uninstall.rs +++ b/lib/crates/fabro-cli/src/commands/uninstall.rs @@ -15,14 +15,13 @@ use std::time::Duration; use anyhow::{Context, Result}; use fabro_config::Storage; use fabro_config::daemon::ServerDaemon; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::OutputFormat; use fabro_util::Home; use fabro_util::printer::Printer; use serde::Serialize; use tracing::warn; use crate::args::UninstallArgs; +use crate::command_context::CommandContext; use crate::commands::server::stop; use crate::shared::{format_size, print_json_pretty, tilde_path}; use crate::{local_server, user_config}; @@ -43,12 +42,9 @@ struct Inventory { clippy::unused_async, reason = "The shared command dispatch path expects an async handler." )] -pub(crate) async fn run_uninstall( - args: &UninstallArgs, - cli: &CliNamespace, - printer: Printer, -) -> Result<()> { - let json = cli.output.format == OutputFormat::Json; +pub(crate) async fn run_uninstall(args: &UninstallArgs, ctx: &CommandContext) -> Result<()> { + let json = ctx.json_output(); + let printer = ctx.printer(); let home = Home::from_env(); let home_root = home.root().to_path_buf(); diff --git a/lib/crates/fabro-cli/src/commands/upgrade.rs b/lib/crates/fabro-cli/src/commands/upgrade.rs index 53390cfd5..694ac7838 100644 --- a/lib/crates/fabro-cli/src/commands/upgrade.rs +++ b/lib/crates/fabro-cli/src/commands/upgrade.rs @@ -23,6 +23,7 @@ use tokio::task::JoinHandle; use tracing::debug; use crate::args::UpgradeArgs; +use crate::command_context::CommandContext; use crate::shared::print_json_pretty; // ── Download backend abstraction ─────────────────────────────────────────── @@ -434,11 +435,9 @@ impl UpgradeCheckState { // ── Main upgrade command ─────────────────────────────────────────────────── -pub(crate) async fn run_upgrade( - args: UpgradeArgs, - cli: &CliNamespace, - printer: Printer, -) -> Result<()> { +pub(crate) async fn run_upgrade(args: UpgradeArgs, ctx: &CommandContext) -> Result<()> { + let cli = &ctx.user_settings().cli; + let printer = ctx.printer(); let current_exe = std::env::current_exe() .context("resolving current fabro executable path")? .canonicalize() diff --git a/lib/crates/fabro-cli/src/commands/validate.rs b/lib/crates/fabro-cli/src/commands/validate.rs index 74bba2d11..162a980c7 100644 --- a/lib/crates/fabro-cli/src/commands/validate.rs +++ b/lib/crates/fabro-cli/src/commands/validate.rs @@ -1,9 +1,7 @@ use anyhow::bail; use fabro_config::load::load_settings_user; use fabro_config::user::active_settings_path; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; -use fabro_types::settings::{CliNamespace, SettingsLayer}; -use fabro_util::printer::Printer; +use fabro_types::settings::SettingsLayer; use fabro_util::terminal::Styles; use crate::args::ValidateArgs; @@ -15,11 +13,10 @@ use crate::shared::{print_diagnostics, print_json_pretty, relative_path}; pub(crate) async fn run( args: &ValidateArgs, styles: &Styles, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, + base_ctx: &CommandContext, ) -> anyhow::Result<()> { - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.target)?; let built = build_run_manifest(ManifestBuildInput { workflow: args.workflow.clone(), cwd: ctx.cwd().to_path_buf(), @@ -33,7 +30,7 @@ pub(crate) async fn run( let response = client.run_preflight(built.manifest).await?; let diagnostics = api_diagnostics_to_local(&response.workflow.diagnostics); - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&serde_json::json!({ "workflow_name": response.workflow.name, "nodes": response.workflow.nodes, diff --git a/lib/crates/fabro-cli/src/commands/version.rs b/lib/crates/fabro-cli/src/commands/version.rs index 993557834..8b4304b23 100644 --- a/lib/crates/fabro-cli/src/commands/version.rs +++ b/lib/crates/fabro-cli/src/commands/version.rs @@ -6,8 +6,6 @@ use std::io::IsTerminal; use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::{CliLayer, OutputFormat}; use fabro_util::printer::Printer; use serde_json::{Map, Value, json}; @@ -16,14 +14,10 @@ use crate::command_context::CommandContext; use crate::shared::print_json_pretty; use crate::user_config::{self, ServerTarget}; -pub(crate) async fn version_command( - args: &VersionArgs, - cli: &CliNamespace, - cli_layer: &CliLayer, - printer: Printer, -) -> Result<()> { +pub(crate) async fn version_command(args: &VersionArgs, base_ctx: &CommandContext) -> Result<()> { let client = client_info(); - let ctx = CommandContext::for_target(&args.target, printer, cli_layer)?; + let printer = base_ctx.printer(); + let ctx = base_ctx.with_target(&args.target)?; let server_target = user_config::resolve_server_target(&args.target, ctx.machine_settings())?; let server_address = format_server_target(&server_target); let server_info = match ctx.server().await { @@ -49,7 +43,7 @@ pub(crate) async fn version_command( }, }; - if cli.output.format == OutputFormat::Json { + if ctx.json_output() { print_json_pretty(&json_output(&client, &server_info))?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/workflow/create.rs b/lib/crates/fabro-cli/src/commands/workflow/create.rs index 6a4f7b5f8..742c3cad5 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/create.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/create.rs @@ -7,18 +7,13 @@ use std::path::Path; use anyhow::{Context, Result, bail}; use fabro_config::project::{discover_project_config, resolve_fabro_root}; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::OutputFormat; -use fabro_util::printer::Printer; use crate::args::WorkflowCreateArgs; +use crate::command_context::CommandContext; use crate::shared::{print_json_pretty, relative_path}; -pub(super) fn create_command( - args: &WorkflowCreateArgs, - cli: &CliNamespace, - printer: Printer, -) -> Result<()> { +pub(super) fn create_command(args: &WorkflowCreateArgs, base_ctx: &CommandContext) -> Result<()> { + let printer = base_ctx.printer(); let cwd = std::env::current_dir()?; let Some((config_path, config)) = discover_project_config(&cwd)? else { @@ -31,7 +26,7 @@ pub(super) fn create_command( let fabro_root = resolve_fabro_root(&config_path, &config); let created = write_workflow_scaffold(args, &fabro_root)?; - if cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { let created: Vec<_> = created.iter().map(|path| relative_path(path)).collect(); print_json_pretty(&serde_json::json!({ "name": args.name, diff --git a/lib/crates/fabro-cli/src/commands/workflow/list.rs b/lib/crates/fabro-cli/src/commands/workflow/list.rs index 40da91203..96553af73 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/list.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/list.rs @@ -5,21 +5,17 @@ use fabro_config::project::{ WorkflowInfo, WorkflowSource, discover_project_config, list_workflows_detailed, resolve_fabro_root, }; -use fabro_types::settings::CliNamespace; -use fabro_types::settings::cli::OutputFormat; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use crate::args::WorkflowListArgs; +use crate::command_context::CommandContext; use crate::shared::{color_if, print_json_pretty, relative_path}; const GOAL_MAX_LEN: usize = 60; -pub(super) fn list_command( - _args: &WorkflowListArgs, - cli: &CliNamespace, - printer: Printer, -) -> Result<()> { +pub(super) fn list_command(_args: &WorkflowListArgs, base_ctx: &CommandContext) -> Result<()> { + let printer = base_ctx.printer(); let styles = Styles::detect_stderr(); let cwd = std::env::current_dir()?; @@ -36,7 +32,7 @@ pub(super) fn list_command( let workflows = list_workflows_detailed(Some(&project_wf_dir), user_wf_dir.as_deref()); - if cli.output.format == OutputFormat::Json { + if base_ctx.json_output() { print_json_pretty(&workflows)?; return Ok(()); } diff --git a/lib/crates/fabro-cli/src/commands/workflow/mod.rs b/lib/crates/fabro-cli/src/commands/workflow/mod.rs index 44de6cf4e..528dc8a35 100644 --- a/lib/crates/fabro-cli/src/commands/workflow/mod.rs +++ b/lib/crates/fabro-cli/src/commands/workflow/mod.rs @@ -2,14 +2,13 @@ mod create; mod list; use anyhow::Result; -use fabro_types::settings::CliNamespace; -use fabro_util::printer::Printer; use crate::args::{WorkflowCommand, WorkflowNamespace}; +use crate::command_context::CommandContext; -pub(crate) fn dispatch(ns: WorkflowNamespace, cli: &CliNamespace, printer: Printer) -> Result<()> { +pub(crate) fn dispatch(ns: WorkflowNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { - WorkflowCommand::List(args) => list::list_command(&args, cli, printer), - WorkflowCommand::Create(args) => create::create_command(&args, cli, printer), + WorkflowCommand::List(args) => list::list_command(&args, base_ctx), + WorkflowCommand::Create(args) => create::create_command(&args, base_ctx), } } diff --git a/lib/crates/fabro-cli/src/local_server.rs b/lib/crates/fabro-cli/src/local_server.rs index cdba3b556..72bcfc576 100644 --- a/lib/crates/fabro-cli/src/local_server.rs +++ b/lib/crates/fabro-cli/src/local_server.rs @@ -12,9 +12,16 @@ use fabro_server::serve::resolve_bind_request_from_settings; use fabro_types::settings::{ServerAuthMethod, SettingsLayer}; pub(crate) fn storage_dir(settings: &SettingsLayer) -> Result { + storage_dir_with_lookup(settings, &|name| std::env::var(name).ok()) +} + +pub(crate) fn storage_dir_with_lookup( + settings: &SettingsLayer, + lookup: &dyn Fn(&str) -> Option, +) -> Result { let storage_root = fabro_config::resolve_storage_root(settings); let resolved_root = storage_root - .resolve(|name| std::env::var(name).ok()) + .resolve(lookup) .map_err(|err| anyhow::anyhow!("failed to resolve {}: {err}", storage_root.as_source()))?; Ok(PathBuf::from(resolved_root.value)) } diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 6e54808ff..d42f59a58 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -24,13 +24,10 @@ use std::ffi::OsString; use anyhow::Result; use args::{ Commands, GlobalArgs, LONG_VERSION, RunCommands, ServerCommand, ServerNamespace, - global_args_cli_layer, printer_from_verbosity, require_no_json_override, + global_args_cli_layer, require_no_json_override, }; use clap::{CommandFactory, Parser}; -use fabro_config::merge::combine_files; use fabro_telemetry::{git, panic as tel_panic, sanitize, sender}; -use fabro_types::settings::SettingsLayer; -use fabro_types::settings::cli::OutputVerbosity; use fabro_util::exit::ExitClass; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; @@ -38,6 +35,8 @@ use fabro_util::{browser, exit}; use rustls::crypto::ring::default_provider; use tracing::debug; +use crate::command_context::CommandContext; + #[derive(Parser)] #[command(name = "fabro", version, long_version = LONG_VERSION)] struct Cli { @@ -73,12 +72,33 @@ async fn main() { std::process::exit(commands::render_graph::execute()); } + // Capture the worker bearer token immediately and scrub it from the process + // env before any subprocess can be spawned. Every descendant of the worker + // (hooks, sandbox commands, devcontainer setup, MCP stdio, etc.) therefore + // inherits a process env that no longer contains FABRO_WORKER_TOKEN, so an + // unscrubbed spawn site cannot leak it. The token flows to `runner::execute` + // through an explicit function argument instead of the environment. + let worker_token = if subcommand == Some("__run-worker") { + let token = std::env::var("FABRO_WORKER_TOKEN").ok(); + #[expect( + clippy::disallowed_methods, + reason = "Scrub the worker bearer from this process's env before any \ + child process is spawned, so no descendant can inherit it." + )] + { + std::env::remove_var("FABRO_WORKER_TOKEN"); + } + token + } else { + None + }; + tel_panic::install_panic_hook(); fabro_telemetry::init_cli(); let start = std::time::Instant::now(); - let (command_name, result) = Box::pin(main_inner()).await; + let (command_name, result) = Box::pin(main_inner(worker_token)).await; let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); let exit_code = result.as_ref().err().map_or(0, exit::exit_code_for); @@ -146,7 +166,7 @@ async fn main() { } } -async fn main_inner() -> (String, Result<()>) { +async fn main_inner(worker_token: Option) -> (String, Result<()>) { let _ = default_provider().install_default(); let cli = Cli::parse(); @@ -165,22 +185,14 @@ async fn main_inner() -> (String, Result<()>) { Err(err) => return (command_name, Err(err)), }; - let user_settings = match user_config::load_settings() { - Ok(settings) => settings, + let base_ctx = match CommandContext::from_disk(&cli_layer, process_local_json) { + Ok(ctx) => ctx, Err(err) => return (command_name, Err(err)), }; - let combined_settings = combine_files(user_settings, SettingsLayer { - cli: Some(cli_layer.clone()), - ..SettingsLayer::default() - }); - let cli_settings = match fabro_config::UserSettings::from_layer(&combined_settings) { - Ok(settings) => settings.cli, - Err(err) => return (command_name, Err(err.into())), - }; - let printer = printer_from_verbosity(cli_settings.output.verbosity); + let printer = base_ctx.printer(); let config_log_level = match &pre_tracing_bootstrap.sink { - logging::InternalLogSink::Cli => cli_settings.logging.level.clone(), + logging::InternalLogSink::Cli => base_ctx.user_settings().cli.logging.level.clone(), logging::InternalLogSink::Server { .. } => pre_tracing_bootstrap.config_log_level.clone(), }; if let Err(err) = logging::init_tracing( @@ -204,57 +216,44 @@ async fn main_inner() -> (String, Result<()>) { | Commands::Repo(_) | Commands::Install { .. } ) { - commands::upgrade::spawn_upgrade_check(cli_settings.updates.check, printer) + commands::upgrade::spawn_upgrade_check(base_ctx.user_settings().cli.updates.check, printer) } else { None }; let result = Box::pin(async move { match *command { - Commands::Exec(args) => commands::exec::execute(args, &cli_settings, printer).await?, + Commands::Exec(args) => { + commands::exec::execute(args, &base_ctx).await?; + } Commands::RunCmd(cmd) => { - Box::pin(commands::run::dispatch( - cmd, - &cli_settings, - &cli_layer, - process_local_json, - printer, - )) - .await?; + Box::pin(commands::run::dispatch(cmd, &base_ctx, worker_token)).await?; } Commands::Preflight(args) => { - commands::preflight::execute(args, &cli_settings, &cli_layer, printer).await?; + commands::preflight::execute(args, &base_ctx).await?; } Commands::Validate(args) => { let styles = Styles::detect_stderr(); - commands::validate::run(&args, &styles, &cli_settings, &cli_layer, printer).await?; + commands::validate::run(&args, &styles, &base_ctx).await?; } Commands::Graph(args) => { let styles = Styles::detect_stderr(); - commands::graph::run( - &args, - &styles, - &cli_settings, - &cli_layer, - process_local_json, - printer, - ) - .await?; + commands::graph::run(&args, &styles, &base_ctx).await?; } Commands::Parse(args) => { - commands::parse::run(&args, &cli_settings, printer)?; + commands::parse::run(&args)?; } Commands::Artifact(ns) => { - commands::artifact::dispatch(ns, &cli_settings, &cli_layer, printer).await?; + commands::artifact::dispatch(ns, &base_ctx).await?; } - Commands::Store(ns) => { - commands::store::dispatch(ns, &cli_settings, &cli_layer, printer).await?; + Commands::Dump(args) => { + commands::dump::run(&args, &base_ctx).await?; } Commands::RunsCmd(cmd) => { - commands::runs::dispatch(cmd, &cli_settings, &cli_layer, printer).await?; + commands::runs::dispatch(cmd, &base_ctx).await?; } Commands::Model { command } => { - commands::model::execute(command, &cli_settings, &cli_layer, printer).await?; + commands::model::execute(command, &base_ctx).await?; } Commands::Server(ns) => { Box::pin(commands::server::dispatch( @@ -266,21 +265,11 @@ async fn main_inner() -> (String, Result<()>) { .await?; } Commands::Doctor(args) => { - let verbose = - args.verbose || cli_settings.output.verbosity == OutputVerbosity::Verbose; - let exit_code = Box::pin(commands::doctor::run_doctor( - &args, - verbose, - &cli_settings, - &cli_layer, - printer, - )) - .await?; + let exit_code = Box::pin(commands::doctor::run_doctor(&args, &base_ctx)).await?; std::process::exit(exit_code); } Commands::Version(args) => { - commands::version::version_command(&args, &cli_settings, &cli_layer, printer) - .await?; + commands::version::version_command(&args, &base_ctx).await?; } Commands::Discord => { if process_local_json { @@ -301,65 +290,40 @@ async fn main_inner() -> (String, Result<()>) { } } Commands::Repo(ns) => { - commands::repo::dispatch(ns, &cli_settings, &cli_layer, printer).await?; + commands::repo::dispatch(ns, &base_ctx).await?; } Commands::Install { args, command } => { - Box::pin(commands::install::execute( - &args, - command, - &cli_settings, - &cli_layer, - process_local_json, - printer, - )) - .await?; + Box::pin(commands::install::execute(&args, command, &base_ctx)).await?; } Commands::Uninstall(args) => { - commands::uninstall::run_uninstall(&args, &cli_settings, printer).await?; + commands::uninstall::run_uninstall(&args, &base_ctx).await?; } Commands::Auth(ns) => { - commands::auth::dispatch(ns, &cli_layer, process_local_json, printer).await?; + commands::auth::dispatch(ns, &base_ctx).await?; } Commands::Pr(ns) => { - Box::pin(commands::pr::dispatch( - ns, - &cli_settings, - &cli_layer, - printer, - )) - .await?; + Box::pin(commands::pr::dispatch(ns, &base_ctx)).await?; } Commands::Secret(ns) => { - commands::secret::dispatch(ns, &cli_settings, &cli_layer, printer).await?; + commands::secret::dispatch(ns, &base_ctx).await?; } Commands::Settings(args) => { - Box::pin(commands::config::execute( - &args, - &cli_settings, - &cli_layer, - printer, - )) - .await?; + Box::pin(commands::config::execute(&args, &base_ctx)).await?; + } + Commands::Workflow(ns) => { + commands::workflow::dispatch(ns, &base_ctx)?; } - Commands::Workflow(ns) => commands::workflow::dispatch(ns, &cli_settings, printer)?, Commands::Upgrade(args) => { - commands::upgrade::run_upgrade(args, &cli_settings, printer).await?; + commands::upgrade::run_upgrade(args, &base_ctx).await?; } Commands::Provider(ns) => { - commands::provider::dispatch(ns, &cli_layer, process_local_json, printer).await?; + commands::provider::dispatch(ns, &base_ctx).await?; } Commands::Sandbox { command } => { - commands::sandbox::dispatch( - command, - &cli_settings, - &cli_layer, - process_local_json, - printer, - ) - .await?; + commands::sandbox::dispatch(command, &base_ctx).await?; } Commands::System(ns) => { - commands::system::dispatch(ns, &cli_settings, &cli_layer, printer).await?; + commands::system::dispatch(ns, &base_ctx).await?; } Commands::Completion(args) => { require_no_json_override(process_local_json)?; @@ -505,7 +469,7 @@ async fn prepare_server_bootstrap( mod tests { use args::{ AuthCommand, AuthNamespace, Commands, InstallGitHubStrategyArg, ModelsCommand, - ProviderCommand, ProviderNamespace, StoreCommand, StoreNamespace, + ProviderCommand, ProviderNamespace, }; use tokio::runtime::Runtime; @@ -940,13 +904,11 @@ level = "warn" } #[test] - fn parse_store_dump_command() { - let cli = Cli::try_parse_from(["fabro", "store", "dump", "ABC123", "-o", "./out"]) - .expect("should parse"); + fn parse_dump_command() { + let cli = + Cli::try_parse_from(["fabro", "dump", "ABC123", "-o", "./out"]).expect("should parse"); match *cli.command.unwrap() { - Commands::Store(StoreNamespace { - command: StoreCommand::Dump(args), - }) => { + Commands::Dump(args) => { assert_eq!(args.run, "ABC123"); assert_eq!(args.output, std::path::PathBuf::from("./out")); } @@ -999,8 +961,6 @@ level = "warn" "__run-worker", "--server", "/tmp/fabro.sock", - "--artifact-upload-token", - "token-123", "--run-dir", "/tmp/run", "--run-id", @@ -1012,7 +972,6 @@ level = "warn" match *cli.command.unwrap() { Commands::RunCmd(RunCommands::RunWorker(args)) => { assert_eq!(args.server, "/tmp/fabro.sock"); - assert_eq!(args.artifact_upload_token.as_deref(), Some("token-123")); assert_eq!(args.run_dir, std::path::PathBuf::from("/tmp/run")); assert_eq!(args.run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap()); assert!(matches!(args.mode, args::RunWorkerMode::Start)); @@ -1039,7 +998,6 @@ level = "warn" match *cli.command.unwrap() { Commands::RunCmd(RunCommands::RunWorker(args)) => { assert_eq!(args.server, "http://127.0.0.1:3000"); - assert!(args.artifact_upload_token.is_none()); assert_eq!(args.run_dir, std::path::PathBuf::from("/tmp/run")); assert_eq!(args.run_id, "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap()); assert!(matches!(args.mode, args::RunWorkerMode::Resume)); diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 946ec697a..9ffeb71de 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -9,15 +9,14 @@ use std::path::{Component, Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use fabro_api::types; use fabro_config::load::load_settings_for_workflow; -use fabro_config::merge::combine_files; use fabro_config::project::{self, discover_project_config, resolve_workflow_path}; use fabro_config::run::{parse_run_config, resolve_run_goal}; use fabro_graphviz::graph::AttrValue; use fabro_graphviz::parser; use fabro_sandbox::daytona::detect_repo_info; use fabro_types::RunId; -use fabro_types::settings::SettingsLayer; use fabro_types::settings::run::{DaytonaDockerfileLayer, ResolvedGoalSource, ResolvedRunGoal}; +use fabro_types::settings::{Combine, SettingsLayer}; use fabro_workflow::git::{GitSyncStatus, head_sha, sync_status}; use crate::args::{PreflightArgs, RunArgs}; @@ -58,10 +57,11 @@ struct WorkflowScanInput { pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result { let workflow_layer = load_settings_for_workflow(&input.workflow, &input.cwd)?; - let merged_settings = combine_files( - combine_files(input.user_layer, workflow_layer), - input.args_layer.clone(), - ); + let merged_settings = input + .args_layer + .clone() + .combine(workflow_layer) + .combine(input.user_layer); let root_resolution = resolve_workflow_path(&input.workflow, &input.cwd)?; let target_path = root_resolution.dot_path.clone(); diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index 60ef4c21e..fc2731121 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -52,9 +52,17 @@ pub(crate) async fn connect_server_target(target: &ServerTarget) -> Result Result { - let target = target.parse::()?; - connect_server_target(&target).await +pub(crate) async fn connect_server_target_with_bearer( + target: &ServerTarget, + bearer: &str, +) -> Result { + build_client( + target.clone(), + Some(Credential::Worker(bearer.to_owned())), + None, + None, + ) + .await } pub(crate) async fn connect_server_with_settings( @@ -380,6 +388,8 @@ async fn wait_for_server_ready(http_client: &fabro_http::HttpClient) -> Result<( mod tests { use chrono::{Duration as ChronoDuration, Utc}; use fabro_client::{AuthEntry, StoredSubject}; + use httpmock::Method::{GET, POST}; + use serde_json::json; use super::*; @@ -500,21 +510,20 @@ mod tests { #[test] fn resolve_local_tcp_credential_does_not_fallback_to_home_dev_token() { let temp_home = tempfile::tempdir().unwrap(); - std::fs::write( - temp_home.path().join("dev-token"), - "fabro_dev_abababababababababababababababababababababababababababababababab", - ) - .unwrap(); - let original_home = std::env::var_os("FABRO_HOME"); - std::env::set_var("FABRO_HOME", temp_home.path()); - let _guard = scopeguard::guard(original_home, |original_home| match original_home { - Some(value) => std::env::set_var("FABRO_HOME", value), - None => std::env::remove_var("FABRO_HOME"), - }); - + let token = "fabro_dev_abababababababababababababababababababababababababababababababab"; + std::fs::write(temp_home.path().join("dev-token"), token).unwrap(); let target = ServerTarget::http_url("http://127.0.0.1:32276").unwrap(); + let store = AuthStore::new(temp_home.path().join("auth.json")); + assert_eq!( + load_cli_dev_token_from_sources(None, &Home::new(temp_home.path())).as_deref(), + Some(token) + ); - assert!(resolve_local_tcp_credential(&target).unwrap().is_none()); + assert!( + resolve_local_tcp_credential_with_store(&target, None, &store, Utc::now()) + .unwrap() + .is_none() + ); } #[test] @@ -546,6 +555,87 @@ mod tests { assert!(local_dev_token_fallback(&target)); } + #[tokio::test] + async fn connect_server_target_with_bearer_sends_worker_bearer_token() { + let server = httpmock::MockServer::start(); + let info_mock = server.mock(|when, then| { + when.method(GET) + .path("/api/v1/system/info") + .header("authorization", "Bearer worker-token"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "version": "1.2.3", + "git_sha": "abcdef0", + "build_date": "2026-04-20", + "profile": "release", + "os": "darwin", + "arch": "arm64", + "storage_dir": "/tmp/fabro-worker-auth", + "storage_engine": "slatedb", + "runs": { "total": 0, "active": 0 }, + "uptime_secs": 42 + })); + }); + + let target = ServerTarget::http_url(server.base_url()).unwrap(); + let client = connect_server_target_with_bearer(&target, "worker-token") + .await + .unwrap(); + let info = client.get_system_info().await.unwrap(); + + assert_eq!(info.version.as_deref(), Some("1.2.3")); + info_mock.assert(); + } + + #[tokio::test] + async fn connect_server_target_with_bearer_does_not_attempt_oauth_refresh() { + let server = httpmock::MockServer::start(); + let info_mock = server.mock(|when, then| { + when.method(GET) + .path("/api/v1/system/info") + .header("authorization", "Bearer worker-token"); + then.status(401) + .header("Content-Type", "application/json") + .json_body(json!({ + "errors": [{ + "status": "401", + "title": "Unauthorized", + "detail": "Access token expired.", + "code": "access_token_expired" + }] + })); + }); + let refresh_mock = server.mock(|when, then| { + when.method(POST).path("/auth/cli/refresh"); + then.status(200) + .header("Content-Type", "application/json") + .json_body(json!({ + "access_token": "unused", + "access_token_expires_at": (Utc::now() + ChronoDuration::minutes(10)).to_rfc3339(), + "refresh_token": "unused", + "refresh_token_expires_at": (Utc::now() + ChronoDuration::days(30)).to_rfc3339(), + "subject": { + "idp_issuer": "https://github.com", + "idp_subject": "12345", + "login": "octocat", + "name": "Octo Cat", + "email": "octocat@example.com" + } + })); + }); + + let target = ServerTarget::http_url(server.base_url()).unwrap(); + let client = connect_server_target_with_bearer(&target, "worker-token") + .await + .unwrap(); + let err = client.get_system_info().await.unwrap_err(); + + assert!(err.to_string().contains("Access token expired")); + info_mock.assert(); + assert_eq!(refresh_mock.calls(), 0); + } + fn oauth_entry( access_token_expires_at: chrono::DateTime, refresh_token_expires_at: chrono::DateTime, diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 774d3c8a5..6131c2870 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -249,13 +249,13 @@ root = "{{ env.FABRO_STORAGE_ROOT }}" "#, ); let temp = tempfile::tempdir().unwrap(); - let original = std::env::var_os("FABRO_STORAGE_ROOT"); - std::env::set_var("FABRO_STORAGE_ROOT", temp.path()); - let _guard = scopeguard::guard(original, |original| match original { - Some(value) => std::env::set_var("FABRO_STORAGE_ROOT", value), - None => std::env::remove_var("FABRO_STORAGE_ROOT"), - }); - assert_eq!(storage_dir(&settings).unwrap(), temp.path()); + assert_eq!( + local_server::storage_dir_with_lookup(&settings, &|name| { + (name == "FABRO_STORAGE_ROOT").then(|| temp.path().display().to_string()) + }) + .unwrap(), + temp.path() + ); } } diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 68af2b229..88a6233ed 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -643,6 +643,11 @@ fn attach_json_errors_without_prompting_for_human_input() { "ts": "[TIMESTAMP]" }, { + "actor": { + "display": "system:worker", + "id": "worker", + "kind": "system" + }, "event": "run.starting", "id": "[EVENT_ID]", "properties": {}, @@ -650,6 +655,11 @@ fn attach_json_errors_without_prompting_for_human_input() { "ts": "[TIMESTAMP]" }, { + "actor": { + "display": "system:worker", + "id": "worker", + "kind": "system" + }, "event": "sandbox.initializing", "id": "[EVENT_ID]", "properties": { @@ -659,6 +669,11 @@ fn attach_json_errors_without_prompting_for_human_input() { "ts": "[TIMESTAMP]" }, { + "actor": { + "display": "system:worker", + "id": "worker", + "kind": "system" + }, "event": "sandbox.ready", "id": "[EVENT_ID]", "properties": { @@ -669,6 +684,11 @@ fn attach_json_errors_without_prompting_for_human_input() { "ts": "[TIMESTAMP]" }, { + "actor": { + "display": "system:worker", + "id": "worker", + "kind": "system" + }, "event": "sandbox.initialized", "id": "[EVENT_ID]", "properties": { @@ -679,6 +699,11 @@ fn attach_json_errors_without_prompting_for_human_input() { "ts": "[TIMESTAMP]" }, { + "actor": { + "display": "system:worker", + "id": "worker", + "kind": "system" + }, "event": "run.started", "id": "[EVENT_ID]", "properties": { @@ -689,6 +714,11 @@ fn attach_json_errors_without_prompting_for_human_input() { "ts": "[TIMESTAMP]" }, { + "actor": { + "display": "system:worker", + "id": "worker", + "kind": "system" + }, "event": "run.running", "id": "[EVENT_ID]", "properties": {}, @@ -696,6 +726,11 @@ fn attach_json_errors_without_prompting_for_human_input() { "ts": "[TIMESTAMP]" }, { + "actor": { + "display": "system:worker", + "id": "worker", + "kind": "system" + }, "event": "stage.started", "id": "[EVENT_ID]", "node_id": "start", @@ -711,6 +746,11 @@ fn attach_json_errors_without_prompting_for_human_input() { "ts": "[TIMESTAMP]" }, { + "actor": { + "display": "system:worker", + "id": "worker", + "kind": "system" + }, "event": "stage.completed", "id": "[EVENT_ID]", "node_id": "start", @@ -739,6 +779,11 @@ fn attach_json_errors_without_prompting_for_human_input() { "ts": "[TIMESTAMP]" }, { + "actor": { + "display": "system:worker", + "id": "worker", + "kind": "system" + }, "event": "edge.selected", "id": "[EVENT_ID]", "properties": { @@ -752,6 +797,11 @@ fn attach_json_errors_without_prompting_for_human_input() { "ts": "[TIMESTAMP]" }, { + "actor": { + "display": "system:worker", + "id": "worker", + "kind": "system" + }, "event": "checkpoint.completed", "id": "[EVENT_ID]", "node_id": "start", @@ -790,6 +840,11 @@ fn attach_json_errors_without_prompting_for_human_input() { "ts": "[TIMESTAMP]" }, { + "actor": { + "display": "system:worker", + "id": "worker", + "kind": "system" + }, "event": "stage.started", "id": "[EVENT_ID]", "node_id": "approve", @@ -805,6 +860,11 @@ fn attach_json_errors_without_prompting_for_human_input() { "ts": "[TIMESTAMP]" }, { + "actor": { + "display": "system:worker", + "id": "worker", + "kind": "system" + }, "event": "interview.started", "id": "[EVENT_ID]", "node_id": "approve", @@ -831,6 +891,11 @@ fn attach_json_errors_without_prompting_for_human_input() { "ts": "[TIMESTAMP]" }, { + "actor": { + "display": "system:worker", + "id": "worker", + "kind": "system" + }, "event": "run.blocked", "id": "[EVENT_ID]", "properties": { diff --git a/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs b/lib/crates/fabro-cli/tests/it/cmd/dump.rs similarity index 89% rename from lib/crates/fabro-cli/tests/it/cmd/store_dump.rs rename to lib/crates/fabro-cli/tests/it/cmd/dump.rs index 1fedcf9e0..355dc7f0c 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/dump.rs @@ -16,14 +16,14 @@ use crate::support::{LightweightCli, unique_run_id}; fn help() { let context = test_context!(); let mut cmd = context.command(); - cmd.args(["store", "dump", "--help"]); + cmd.args(["dump", "--help"]); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- Export a run's durable state to a directory - Usage: fabro store dump [OPTIONS] --output + Usage: fabro dump [OPTIONS] --output Arguments: Run ID prefix or workflow name @@ -42,7 +42,7 @@ fn help() { } #[test] -fn store_dump_accepts_server_target_from_separate_home() { +fn dump_accepts_server_target_from_separate_home() { let context = test_context!(); let run = setup_completed_dry_run(&context); let cli = LightweightCli::new(); @@ -51,7 +51,6 @@ fn store_dump_accepts_server_target_from_separate_home() { let mut cmd = cli.command(); cmd.args([ - "store", "dump", "--server", &server, @@ -63,10 +62,10 @@ fn store_dump_accepts_server_target_from_separate_home() { cmd.env("FABRO_DEV_TOKEN", dev_token); } - let output = cmd.output().expect("store dump should execute"); + let output = cmd.output().expect("dump should execute"); assert!( output.status.success(), - "store dump via remote server target failed\nstdout:\n{}\nstderr:\n{}", + "dump via remote server target failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); @@ -74,7 +73,7 @@ fn store_dump_accepts_server_target_from_separate_home() { } #[test] -fn store_dump_exports_large_command_output_backed_by_blob_refs() { +fn dump_exports_large_command_output_backed_by_blob_refs() { let context = test_context!(); let workflow = context.temp_dir.join("large-output.fabro"); fs::write( @@ -130,17 +129,11 @@ fn store_dump_exports_large_command_output_backed_by_blob_refs() { let output_dir = context.temp_dir.join("export"); let mut dump_cmd = context.command(); - dump_cmd.args([ - "store", - "dump", - "--output", - output_dir.to_str().unwrap(), - &run_id, - ]); - let dump_output = dump_cmd.output().expect("store dump should execute"); + dump_cmd.args(["dump", "--output", output_dir.to_str().unwrap(), &run_id]); + let dump_output = dump_cmd.output().expect("dump should execute"); assert!( dump_output.status.success(), - "store dump failed\nstdout:\n{}\nstderr:\n{}", + "dump failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&dump_output.stdout), String::from_utf8_lossy(&dump_output.stderr) ); @@ -153,7 +146,7 @@ fn store_dump_exports_large_command_output_backed_by_blob_refs() { } #[test] -fn store_dump_exports_blob_refs_and_artifacts_together() { +fn dump_exports_blob_refs_and_artifacts_together() { let context = test_context!(); let workspace_dir = context.temp_dir.join("mixed-export"); fs::create_dir_all(&workspace_dir).unwrap(); @@ -233,17 +226,11 @@ include = ["assets/**"] let output_dir = context.temp_dir.join("export-mixed"); let mut dump_cmd = context.command(); - dump_cmd.args([ - "store", - "dump", - "--output", - output_dir.to_str().unwrap(), - &run_id, - ]); - let dump_output = dump_cmd.output().expect("store dump should execute"); + dump_cmd.args(["dump", "--output", output_dir.to_str().unwrap(), &run_id]); + let dump_output = dump_cmd.output().expect("dump should execute"); assert!( dump_output.status.success(), - "store dump failed\nstdout:\n{}\nstderr:\n{}", + "dump failed\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&dump_output.stdout), String::from_utf8_lossy(&dump_output.stderr) ); @@ -260,14 +247,13 @@ include = ["assets/**"] } #[test] -fn store_dump_exports_completed_run_snapshot() { +fn dump_exports_completed_run_snapshot() { let context = test_context!(); let run = setup_completed_dry_run(&context); let output_dir = context.temp_dir.join("export"); let mut cmd = context.command(); cmd.args([ - "store", "dump", "--output", output_dir.to_str().unwrap(), @@ -298,7 +284,7 @@ fn store_dump_exports_completed_run_snapshot() { } #[test] -fn store_dump_rejects_non_empty_output_dir() { +fn dump_rejects_non_empty_output_dir() { let context = test_context!(); let run = setup_completed_dry_run(&context); let output_dir = context.temp_dir.join("nonempty"); @@ -307,7 +293,6 @@ fn store_dump_rejects_non_empty_output_dir() { let mut cmd = context.command(); cmd.args([ - "store", "dump", "--output", output_dir.to_str().unwrap(), diff --git a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs index ee823c0f5..a8fbc255d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs @@ -25,7 +25,7 @@ fn help() { validate Validate a workflow graph Render a workflow graph as SVG artifact Inspect and copy run artifacts (screenshots, reports, traces) - store Export store-backed run state for debugging + dump Export a run's durable state to a directory rm Remove one or more workflow runs inspect Show detailed information about a workflow run archive Mark terminal runs as archived (reviewed, no further action needed). Archived runs are hidden from default listings diff --git a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs index ac22067bd..32e7d4dd7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs @@ -48,6 +48,30 @@ fn settings_uses_json_output_format_from_home_config() { assert!(value.is_object()); } +#[test] +fn auth_status_ignores_json_output_format_from_home_config() { + let context = test_context!(); + context.write_home( + ".fabro/settings.toml", + "_version = 1\n\n[cli.output]\nformat = \"json\"\n", + ); + + let output = context + .command() + .args(["auth", "status"]) + .output() + .expect("command should run"); + + assert!(output.status.success()); + assert!( + output.stdout.is_empty(), + "stdout should stay empty in text mode" + ); + let stderr = output_stderr(&output); + assert!(stderr.contains("Not logged in to any servers.")); + assert!(stderr.contains("Dev token:")); +} + #[test] fn secret_list_uses_json_output_format_from_home_config() { let context = test_context!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/logs.rs b/lib/crates/fabro-cli/tests/it/cmd/logs.rs index 0ec7bfe45..75fff236a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/logs.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/logs.rs @@ -131,8 +131,8 @@ fn logs_completed_run_reads_store_without_progress_jsonl() { success: true exit_code: 0 ----- stdout ----- - {"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} ----- stderr ----- "#); } @@ -165,8 +165,8 @@ fn logs_tail_limits_output() { success: true exit_code: 0 ----- stdout ----- - {"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} - {"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.started","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} + {"actor":{"display":"system:worker","id":"worker","kind":"system"},"event":"sandbox.cleanup.completed","id":"[EVENT_ID]","properties":{"duration_ms": [DURATION_MS],"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"} ----- stderr ----- "#); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs index 8cae23120..f9b1e96c9 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs @@ -9,6 +9,7 @@ mod diff; mod discord; mod docs; mod doctor; +mod dump; mod exec; mod fabro; mod fork; @@ -53,8 +54,6 @@ mod server_start; mod server_status; mod server_stop; mod start; -mod store; -mod store_dump; pub(crate) mod support; mod system; mod system_df; @@ -70,6 +69,7 @@ mod upgrade; mod validate; mod version; mod wait; +mod worker_auth; mod workflow; mod workflow_create; mod workflow_list; diff --git a/lib/crates/fabro-cli/tests/it/cmd/ps.rs b/lib/crates/fabro-cli/tests/it/cmd/ps.rs index a6d021a3b..4642010d1 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ps.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/ps.rs @@ -9,12 +9,17 @@ use crate::support::{fatal_error_line, unique_run_id}; const TEST_DEV_TOKEN: &str = "fabro_dev_abababababababababababababababababababababababababababababababab"; +const TEST_SESSION_SECRET: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; fn provision_local_server_auth(context: &fabro_test::TestContext, storage_dir: &std::path::Path) { context.ensure_home_server_auth_methods(); let server_env_path = Storage::new(storage_dir).runtime_directory().env_path(); - envfile::merge_env_file(&server_env_path, [("FABRO_DEV_TOKEN", TEST_DEV_TOKEN)]) - .expect("merging FABRO_DEV_TOKEN into server.env"); + envfile::merge_env_file(&server_env_path, [ + ("FABRO_DEV_TOKEN", TEST_DEV_TOKEN), + ("SESSION_SECRET", TEST_SESSION_SECRET), + ]) + .expect("merging server auth into server.env"); dev_token::write_dev_token( &context.home_dir.join(".fabro").join("dev-token"), TEST_DEV_TOKEN, diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index c29798113..d65f354b1 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -832,6 +832,11 @@ fn dry_run_persists_event_history_in_store() { .expect("tail logs should include the latest event"); fabro_json_snapshot!(context, &live_content, @r#" { + "actor": { + "display": "system:worker", + "id": "worker", + "kind": "system" + }, "event": "sandbox.cleanup.completed", "id": "[EVENT_ID]", "properties": { diff --git a/lib/crates/fabro-cli/tests/it/cmd/runner.rs b/lib/crates/fabro-cli/tests/it/cmd/runner.rs index 6cf0ad28c..32799e105 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/runner.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/runner.rs @@ -8,21 +8,31 @@ )] use std::io::Read; +use std::path::Path; use std::process::{Child, ExitStatus, Output, Stdio}; use std::time::{Duration, Instant}; +use fabro_config::{Storage, envfile}; use fabro_store::EventEnvelope; use fabro_test::{assert_reqwest_status, expect_reqwest_json, fabro_snapshot, test_context}; -use fabro_types::{EventBody, FailureReason, RunEvent}; +use fabro_types::{EventBody, FailureReason, RunEvent, StageId}; +use hkdf::Hkdf; use httpmock::MockServer; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use sha2::Sha256; use super::support::{ - local_dev_token, output_stderr, run_events, run_state, server_endpoint, server_target, - wait_for_event_names, wait_for_status, write_gated_workflow, + find_run_dir, local_dev_token, output_stderr, run_events, run_state, server_endpoint, + server_target, wait_for_event_names, wait_for_status, write_gated_workflow, }; use crate::support::{fabro_json_snapshot, unique_run_id}; const SHARED_DAEMON_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const LEAKED_WORKER_PARENT_TOKEN: &str = "leak-worker-parent-token"; +const LEAKED_NEW_RELIC_LICENSE: &str = "leak-new-relic-license"; +const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker"; +const WORKER_TOKEN_SCOPE: &str = "run:worker"; +const WORKER_TOKEN_TTL_SECS: u64 = 72 * 60 * 60; fn auth_context() -> fabro_test::TestContext { let context = test_context!(); @@ -50,6 +60,48 @@ fn assert_worker_succeeded(run_dir: &std::path::Path, stdout: &[u8]) { ))); } +#[derive(serde::Serialize)] +struct WorkerTokenClaims { + iss: String, + iat: u64, + exp: u64, + run_id: String, + scope: String, + jti: String, +} + +fn worker_token_for_run(storage_dir: &Path, run_id: &str) -> String { + let runtime_directory = Storage::new(storage_dir).runtime_directory(); + let session_secret = envfile::read_env_file(&runtime_directory.env_path()) + .expect("server env should load") + .get("SESSION_SECRET") + .cloned() + .expect("server env should include SESSION_SECRET"); + let hkdf = Hkdf::::new(None, session_secret.as_bytes()); + let mut key = [0_u8; 32]; + hkdf.expand(b"fabro-worker-jwt-v1", &mut key) + .expect("worker jwt hkdf output should fit"); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let claims = WorkerTokenClaims { + iss: WORKER_TOKEN_ISSUER.to_string(), + iat: now, + exp: now + WORKER_TOKEN_TTL_SECS, + run_id: run_id.to_string(), + scope: WORKER_TOKEN_SCOPE.to_string(), + jti: format!("{:032x}", rand::random::()), + }; + + jsonwebtoken::encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(&key), + ) + .expect("worker token should encode") +} + fn spawn_worker_process( context: &fabro_test::TestContext, server: &str, @@ -60,9 +112,10 @@ fn spawn_worker_process( let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fabro")); fabro_test::apply_test_isolation(&mut cmd, &context.home_dir); cmd.current_dir(&context.temp_dir); - if let Some(token) = local_dev_token(&context.storage_dir) { - cmd.env("FABRO_DEV_TOKEN", token); - } + cmd.env( + "FABRO_WORKER_TOKEN", + worker_token_for_run(&context.storage_dir, run_id), + ); cmd.args([ "__run-worker", "--server", @@ -118,14 +171,30 @@ fn child_output(mut child: Child, status: ExitStatus) -> Output { } } -fn worker_command(context: &fabro_test::TestContext) -> assert_cmd::Command { +fn worker_command(context: &fabro_test::TestContext, run_id: &str) -> assert_cmd::Command { let mut cmd = context.command(); - if let Some(token) = local_dev_token(&context.storage_dir) { - cmd.env("FABRO_DEV_TOKEN", token); - } + cmd.env( + "FABRO_WORKER_TOKEN", + worker_token_for_run(&context.storage_dir, run_id), + ); cmd } +fn assert_no_worker_env_leak(scope: &str, content: &str) { + for needle in [ + "MY_API_TOKEN=", + "NEW_RELIC_LICENSE_KEY=", + "FABRO_WORKER_TOKEN=", + LEAKED_WORKER_PARENT_TOKEN, + LEAKED_NEW_RELIC_LICENSE, + ] { + assert!( + !content.contains(needle), + "{scope} leaked {needle:?}:\n{content}" + ); + } +} + async fn wait_for_server_question( client: &fabro_http::HttpClient, base_url: &str, @@ -174,16 +243,46 @@ fn help() { --server Fabro server target: http(s) URL or absolute Unix socket path --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] --run-dir Run scratch directory + --quiet Suppress non-essential output [env: FABRO_QUIET=] --run-id Run ID - --verbose Enable verbose output [env: FABRO_VERBOSE=] --mode Worker mode [possible values: start, resume] + --verbose Enable verbose output [env: FABRO_VERBOSE=] -h, --help Print help ----- stderr ----- "); } +#[test] +fn worker_requires_fabro_worker_token_env() { + let context = auth_context(); + let run_dir = tempfile::tempdir().unwrap(); + let run_id = unique_run_id(); + let output = context + .command() + .args([ + "__run-worker", + "--server", + "http://127.0.0.1:32276", + "--run-dir", + run_dir.path().to_str().unwrap(), + "--run-id", + &run_id, + "--mode", + "start", + ]) + .timeout(SHARED_DAEMON_TIMEOUT) + .output() + .expect("worker should execute"); + + assert!(!output.status.success()); + assert!( + output_stderr(&output).contains("FABRO_WORKER_TOKEN"), + "{}", + output_stderr(&output) + ); +} + #[test] fn runner_uses_cached_graph_after_source_deleted() { let context = auth_context(); @@ -218,7 +317,7 @@ digraph CachedGraph { let server = server_target(&context.storage_dir); std::fs::remove_file(&workflow_path).unwrap(); - let output = worker_command(&context) + let output = worker_command(&context, run_id.as_str()) .args([ "__run-worker", "--server", @@ -301,7 +400,7 @@ digraph GitHubApp { context.write_home(".fabro/settings.toml", "_version = 1\n"); let server = server_target(&context.storage_dir); - let mut cmd = worker_command(&context); + let mut cmd = worker_command(&context, run_id.as_str()); cmd.env("GITHUB_APP_PRIVATE_KEY", "%%%not-base64%%%"); cmd.args([ "__run-worker", @@ -351,7 +450,7 @@ digraph DetachedStoreOnly { let run_dir = context.find_run_dir(&run_id); let server = server_target(&context.storage_dir); - let output = worker_command(&context) + let output = worker_command(&context, run_id.as_str()) .args([ "__run-worker", "--server", @@ -373,6 +472,120 @@ digraph DetachedStoreOnly { assert_worker_succeeded(&run_dir, &output); } +#[test] +fn server_dispatched_worker_does_not_inherit_parent_secret_env() { + let mut context = test_context!(); + let server_root = tempfile::tempdir_in("/tmp").unwrap(); + let storage_dir = server_root.path().join("storage"); + let socket_path = server_root.path().join("fabro.sock"); + let config_path = server_root.path().join("settings.toml"); + context.manage_storage_dir(&storage_dir); + std::fs::write( + &config_path, + format!( + r#"_version = 1 + +[server.storage] +root = "{}" + +[server.auth] +methods = ["dev-token"] +"#, + storage_dir.display() + ), + ) + .expect("writing leak-probe server settings"); + + let start_output = context + .command() + .env("MY_API_TOKEN", LEAKED_WORKER_PARENT_TOKEN) + .env("NEW_RELIC_LICENSE_KEY", LEAKED_NEW_RELIC_LICENSE) + .args(["server", "start"]) + .arg("--storage-dir") + .arg(&storage_dir) + .arg("--bind") + .arg(&socket_path) + .arg("--config") + .arg(&config_path) + .output() + .expect("server start should execute"); + assert!( + start_output.status.success(), + "server start failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&start_output.stdout), + String::from_utf8_lossy(&start_output.stderr) + ); + + let workflow_path = context.temp_dir.join("worker-leak-probe.fabro"); + std::fs::write( + &workflow_path, + r#"digraph WorkerLeakProbe { + graph [goal="Verify worker subprocess env isolation", default_max_retries=0] + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + probe [shape=parallelogram, label="Probe", script="echo probe-ran; for key in $(printf 'MY%s NEW%s FABRO%s' '_API_TOKEN' '_RELIC_LICENSE_KEY' '_WORKER_TOKEN'); do value=$(printenv \"$key\" || true); if [ -n \"$value\" ]; then echo \"$key=$value\"; fi; done"] + start -> probe -> exit +} +"#, + ) + .expect("writing leak-probe workflow"); + + let run_id = unique_run_id(); + let dev_token = local_dev_token(&storage_dir).expect("managed server should have a dev token"); + let run_output = context + .run_cmd() + .env("FABRO_DEV_TOKEN", dev_token) + .args([ + "--server", + socket_path.to_str().expect("socket path should be UTF-8"), + "--run-id", + run_id.as_str(), + "--detach", + "--auto-approve", + "--no-retro", + "--sandbox", + "local", + workflow_path + .to_str() + .expect("workflow path should be UTF-8"), + ]) + .output() + .expect("detached leak-probe run should execute"); + assert!( + run_output.status.success(), + "detached run failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run_output.stdout), + String::from_utf8_lossy(&run_output.stderr) + ); + + let run_dir = find_run_dir(&storage_dir, &run_id).expect("leak-probe run dir should exist"); + wait_for_status(&run_dir, &["succeeded"]); + + let state = run_state(&run_dir); + let _probe = state + .node(&StageId::new("probe", 1)) + .expect("probe node state should exist"); + let stdout = state + .checkpoint + .as_ref() + .and_then(|checkpoint| checkpoint.context_values.get("command.output")) + .and_then(serde_json::Value::as_str) + .expect("probe command output should exist"); + assert!( + stdout.contains("probe-ran"), + "probe stage should have executed, got stdout:\n{stdout}" + ); + assert_no_worker_env_leak("probe stdout", stdout); + assert_no_worker_env_leak( + "run state", + &serde_json::to_string(&state).expect("run state should serialize"), + ); + + let server_log = + std::fs::read_to_string(storage_dir.join("logs/server.log")).unwrap_or_default(); + assert_no_worker_env_leak("server log", &server_log); +} + #[test] fn runner_resume_rejects_completed_run_without_mutating_it() { let context = auth_context(); @@ -435,7 +648,7 @@ digraph Test { } "#); - let mut cmd = worker_command(&context); + let mut cmd = worker_command(&context, &run_id); cmd.args([ "__run-worker", "--server", @@ -513,8 +726,7 @@ fn runner_reports_missing_run_spec_without_prefetching_events() { .body(r#"{"data":[],"meta":{"has_more":false}}"#); }); - let output = context - .command() + let output = worker_command(&context, &run_id) .args([ "__run-worker", "--server", diff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs index 6b86ca10f..dc6d6a0c5 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs @@ -1,6 +1,31 @@ use fabro_test::{fabro_snapshot, test_context}; +use httpmock::MockServer; +use serde_json::{Value, json}; use super::support::setup_local_sandbox_run; +use crate::support::unique_run_id; + +fn remote_run_summary(run_id: &str) -> serde_json::Value { + json!({ + "run_id": run_id, + "workflow_name": "Preview Test", + "workflow_slug": "preview-test", + "goal": "Preview test", + "title": "Preview test", + "labels": {}, + "host_repo_path": "/srv/repo", + "repository": { "name": "repo" }, + "start_time": "2026-04-19T12:00:00Z", + "created_at": "2026-04-19T12:00:00Z", + "status": { + "kind": "running" + }, + "pending_control": null, + "duration_ms": null, + "elapsed_secs": null, + "total_usd_micros": null + }) +} #[test] fn help() { @@ -49,3 +74,55 @@ fn sandbox_preview_rejects_non_daytona_run() { error: Sandbox provider does not support this capability. "); } + +#[test] +fn sandbox_preview_open_is_suppressed_by_json_output_format_from_home_config() { + let context = test_context!(); + context.write_home( + ".fabro/settings.toml", + "_version = 1\n\n[cli.output]\nformat = \"json\"\n", + ); + let server = MockServer::start(); + let run_id = unique_run_id(); + let resolve_run = server.mock(|when, then| { + when.method("GET") + .path("/api/v1/runs/resolve") + .query_param("selector", run_id.as_str()); + then.status(200) + .header("content-type", "application/json") + .body(remote_run_summary(&run_id).to_string()); + }); + let preview = server.mock(|when, then| { + when.method("POST") + .path(format!("/api/v1/runs/{run_id}/preview")) + .json_body(json!({ + "port": 3000, + "expires_in_secs": 3600, + "signed": true, + })); + then.status(201) + .header("content-type", "application/json") + .body(json!({ "url": "https://preview.example.test/app" }).to_string()); + }); + + let mut cmd = context.preview(); + cmd.args([ + "--server", + &format!("{}/api/v1", server.base_url()), + "--open", + run_id.as_str(), + "3000", + ]); + let output = cmd.output().expect("command should run"); + + assert!(output.status.success(), "sandbox preview should succeed"); + let value: Value = serde_json::from_slice(&output.stdout).expect("preview JSON should parse"); + assert_eq!( + value, + json!({ + "url": "https://preview.example.test/app" + }) + ); + resolve_run.assert(); + preview.assert(); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs index 13996832b..b5c54475b 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs @@ -14,13 +14,15 @@ use std::time::{Duration, Instant}; use fabro_config::{Storage, envfile}; use fabro_test::{ - apply_test_isolation, fabro_snapshot, isolated_storage_dir, server_log_files, test_context, - wait_for_log_line, wait_for_path, + TestContext, apply_test_isolation, fabro_snapshot, isolated_storage_dir, server_log_files, + test_context, wait_for_log_line, wait_for_path, }; use fabro_util::dev_token; const TEST_DEV_TOKEN: &str = "fabro_dev_abababababababababababababababababababababababababababababababab"; +const TEST_SESSION_SECRET: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; fn write_dev_token_server_settings(config_path: &std::path::Path, rest: &str) { std::fs::write( @@ -32,12 +34,115 @@ fn write_dev_token_server_settings(config_path: &std::path::Path, rest: &str) { fn provision_dev_token_auth(home_dir: &std::path::Path, storage_dir: &std::path::Path) { let server_env_path = Storage::new(storage_dir).runtime_directory().env_path(); - envfile::merge_env_file(&server_env_path, [("FABRO_DEV_TOKEN", TEST_DEV_TOKEN)]) - .expect("merging FABRO_DEV_TOKEN into server.env"); + envfile::merge_env_file(&server_env_path, [ + ("FABRO_DEV_TOKEN", TEST_DEV_TOKEN), + ("SESSION_SECRET", TEST_SESSION_SECRET), + ]) + .expect("merging server auth into server.env"); dev_token::write_dev_token(&home_dir.join(".fabro").join("dev-token"), TEST_DEV_TOKEN) .expect("writing home dev-token"); } +#[derive(Clone, Copy, Debug)] +enum ServerStartMode { + Foreground, + Daemon, +} + +impl ServerStartMode { + const ALL: [Self; 2] = [Self::Foreground, Self::Daemon]; + + fn name(self) -> &'static str { + match self { + Self::Foreground => "foreground", + Self::Daemon => "daemon", + } + } + + fn add_args(self, cmd: &mut assert_cmd::Command) { + if matches!(self, Self::Foreground) { + cmd.arg("--foreground"); + } + } +} + +struct StartupFailureCase { + name: &'static str, + settings: &'static str, + server_env: &'static [(&'static str, &'static str)], + expected_error: &'static str, +} + +fn run_startup_failure(context: &TestContext, mode: ServerStartMode, case: &StartupFailureCase) { + let storage_root = isolated_storage_dir(); + let storage_dir = storage_root + .path() + .join(format!("{}-{}", case.name, mode.name())); + let socket_path = storage_root + .path() + .join(format!("{}-{}.sock", case.name, mode.name())); + let config_dir = tempfile::tempdir_in("/tmp").expect("creating startup failure config dir"); + let config_path = config_dir.path().join("settings.toml"); + std::fs::write(&config_path, case.settings).expect("writing startup failure settings"); + if !case.server_env.is_empty() { + envfile::merge_env_file( + &Storage::new(&storage_dir).runtime_directory().env_path(), + case.server_env.iter().copied(), + ) + .expect("writing startup failure server.env"); + } + + let mut cmd = context.command(); + cmd.args(["server", "start"]); + mode.add_args(&mut cmd); + cmd.arg("--storage-dir") + .arg(&storage_dir) + .arg("--bind") + .arg(&socket_path) + .arg("--config") + .arg(&config_path); + let output = cmd + .output() + .expect("server start failure command should run"); + + assert!( + !output.status.success(), + "server start should reject {} in {} mode\nstdout:\n{}\nstderr:\n{}", + case.name, + mode.name(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.stdout.is_empty(), + "server start rejection should not write stdout for {} in {} mode:\n{}", + case.name, + mode.name(), + String::from_utf8_lossy(&output.stdout) + ); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + format!("error: {}\n", case.expected_error), + "unexpected stderr for {} in {} mode", + case.name, + mode.name() + ); + + let log_path = storage_dir.join("logs/server.log"); + match mode { + ServerStartMode::Foreground => assert!( + log_path.exists(), + "foreground validation intentionally runs after log bootstrap for {}", + case.name + ), + ServerStartMode::Daemon => assert!( + !log_path.exists(), + "daemon validation should fail before creating server.log for {}", + case.name + ), + } +} + #[test] fn help() { let context = test_context!(); @@ -96,6 +201,120 @@ fn help() { "); } +#[test] +fn start_rejects_invalid_startup_configuration_in_foreground_and_daemon() { + const DEV_TOKEN_SETTINGS: &str = r#"_version = 1 + +[server.auth] +methods = ["dev-token"] +"#; + const GITHUB_SETTINGS: &str = r#"_version = 1 + +[server.web] +enabled = true + +[server.auth] +methods = ["github"] + +[server.auth.github] +allowed_usernames = ["octocat"] + +[server.integrations.github] +client_id = "Iv1.testclient" +"#; + const GITHUB_WITHOUT_CLIENT_ID_SETTINGS: &str = r#"_version = 1 + +[server.web] +enabled = true + +[server.auth] +methods = ["github"] + +[server.auth.github] +allowed_usernames = ["octocat"] +"#; + const GITHUB_WEB_DISABLED_SETTINGS: &str = r#"_version = 1 + +[server.web] +enabled = false + +[server.auth] +methods = ["github"] + +[server.auth.github] +allowed_usernames = ["octocat"] + +[server.integrations.github] +client_id = "Iv1.testclient" +"#; + const EMPTY_AUTH_METHODS_SETTINGS: &str = r"_version = 1 + +[server.auth] +methods = [] +"; + + let context = test_context!(); + let cases = [ + StartupFailureCase { + name: "missing-session-secret", + settings: DEV_TOKEN_SETTINGS, + server_env: &[("FABRO_DEV_TOKEN", TEST_DEV_TOKEN)], + expected_error: "Fabro server refuses to start: auth is configured but SESSION_SECRET is not set.", + }, + StartupFailureCase { + name: "missing-dev-token", + settings: DEV_TOKEN_SETTINGS, + server_env: &[("SESSION_SECRET", TEST_SESSION_SECRET)], + expected_error: "Fabro server refuses to start: dev-token auth is enabled but FABRO_DEV_TOKEN is not set.", + }, + StartupFailureCase { + name: "missing-github-client-secret", + settings: GITHUB_SETTINGS, + server_env: &[("SESSION_SECRET", TEST_SESSION_SECRET)], + expected_error: "Fabro server refuses to start: github auth is enabled but GITHUB_APP_CLIENT_SECRET is not set.", + }, + StartupFailureCase { + name: "empty-auth-methods", + settings: EMPTY_AUTH_METHODS_SETTINGS, + server_env: &[], + expected_error: "failed to resolve server settings:\n server.auth.methods: invalid value - must not be empty", + }, + StartupFailureCase { + name: "github-web-disabled", + settings: GITHUB_WEB_DISABLED_SETTINGS, + server_env: &[ + ("SESSION_SECRET", TEST_SESSION_SECRET), + ("GITHUB_APP_CLIENT_SECRET", "github-client-secret"), + ], + expected_error: "Fabro server refuses to start: github auth is enabled but server.web.enabled is false.", + }, + StartupFailureCase { + name: "github-missing-client-id", + settings: GITHUB_WITHOUT_CLIENT_ID_SETTINGS, + server_env: &[ + ("SESSION_SECRET", TEST_SESSION_SECRET), + ("GITHUB_APP_CLIENT_SECRET", "github-client-secret"), + ], + expected_error: "Fabro server refuses to start: github auth is enabled but server.integrations.github.client_id is not configured.", + }, + StartupFailureCase { + name: "invalid-dev-token", + settings: DEV_TOKEN_SETTINGS, + server_env: &[ + ("SESSION_SECRET", TEST_SESSION_SECRET), + ("FABRO_DEV_TOKEN", "not-a-valid-dev-token"), + ], + expected_error: "Fabro server refuses to start: FABRO_DEV_TOKEN has invalid format.", + }, + ]; + + for case in &cases { + for mode in ServerStartMode::ALL { + run_startup_failure(&context, mode, case); + } + } +} + #[test] fn start_already_running_exits_with_error() { let context = test_context!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/store.rs b/lib/crates/fabro-cli/tests/it/cmd/store.rs deleted file mode 100644 index 082ce6ba3..000000000 --- a/lib/crates/fabro-cli/tests/it/cmd/store.rs +++ /dev/null @@ -1,29 +0,0 @@ -use fabro_test::{fabro_snapshot, test_context}; - -#[test] -fn help() { - let context = test_context!(); - let mut cmd = context.command(); - cmd.args(["store", "--help"]); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - Export store-backed run state for debugging - - Usage: fabro store [OPTIONS] - - Commands: - dump Export a run's durable state to a directory - help Print this message or the help of the given subcommand(s) - - Options: - --json Output as JSON [env: FABRO_JSON=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help - ----- stderr ----- - "); -} diff --git a/lib/crates/fabro-cli/tests/it/cmd/system_events.rs b/lib/crates/fabro-cli/tests/it/cmd/system_events.rs index 26c793e96..2950aeaae 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/system_events.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/system_events.rs @@ -1,4 +1,5 @@ use fabro_test::{fabro_snapshot, test_context}; +use httpmock::MockServer; #[test] fn help() { @@ -26,3 +27,87 @@ fn help() { ----- stderr ----- "); } + +#[test] +fn system_events_renders_text_lines_from_sse_payloads() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = crate::support::unique_run_id(); + let payload = serde_json::json!({ + "payload": { + "ts": "2026-04-05T12:00:00Z", + "run_id": run_id, + "event": "run.completed", + } + }); + let attach_mock = server.mock(|when, then| { + when.method("GET") + .path("/api/v1/attach") + .query_param("run_id", run_id.as_str()); + then.status(200) + .header("Content-Type", "text/event-stream") + .body(format!("data: {payload}\n\n")); + }); + + let output = context + .command() + .args([ + "system", + "events", + "--server", + &format!("{}/api/v1", server.base_url()), + "--run-id", + &run_id, + ]) + .output() + .expect("command should run"); + + assert!(output.status.success(), "system events failed"); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert_eq!( + stdout.trim(), + format!("2026-04-05T12:00:00Z {} run.completed", &run_id[..12]) + ); + attach_mock.assert(); +} + +#[test] +fn system_events_json_emits_raw_sse_payloads() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = crate::support::unique_run_id(); + let payload = serde_json::json!({ + "payload": { + "ts": "2026-04-05T12:00:00Z", + "run_id": run_id, + "event": "run.completed", + } + }); + let attach_mock = server.mock(|when, then| { + when.method("GET") + .path("/api/v1/attach") + .query_param("run_id", run_id.as_str()); + then.status(200) + .header("Content-Type", "text/event-stream") + .body(format!("data: {payload}\n\n")); + }); + + let output = context + .command() + .args([ + "--json", + "system", + "events", + "--server", + &format!("{}/api/v1", server.base_url()), + "--run-id", + &run_id, + ]) + .output() + .expect("command should run"); + + assert!(output.status.success(), "system events failed"); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert_eq!(stdout.trim(), payload.to_string()); + attach_mock.assert(); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/system_info.rs b/lib/crates/fabro-cli/tests/it/cmd/system_info.rs index b7cd24027..ce218247f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/system_info.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/system_info.rs @@ -48,3 +48,41 @@ fn system_info_json_reports_runtime_fields() { assert!(value["uptime_secs"].is_number()); assert!(value["runs"]["total"].is_number()); } + +#[test] +fn system_info_uses_explicit_storage_dir_override() { + let mut context = test_context!(); + let storage_dir = context.temp_dir.join("alternate-storage"); + std::fs::create_dir_all(&storage_dir).unwrap(); + context.write_home( + ".fabro/settings.toml", + format!( + "_version = 1\n\n[server.storage]\nroot = {:?}\n", + context.storage_dir.display().to_string() + ), + ); + context.ensure_home_server_auth_methods(); + context.manage_storage_dir(&storage_dir); + + let output = context + .command() + .args([ + "--json", + "system", + "info", + "--storage-dir", + storage_dir.to_str().unwrap(), + ]) + .output() + .expect("command should run"); + + assert!( + output.status.success(), + "system info failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let value: Value = + serde_json::from_slice(&output.stdout).expect("system info JSON should parse"); + assert_eq!(value["storage_dir"], storage_dir.display().to_string()); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs b/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs new file mode 100644 index 000000000..3e46e80ec --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs @@ -0,0 +1,470 @@ +#![expect( + clippy::disallowed_methods, + reason = "These worker-auth regressions start a real server subprocess, write isolated auth fixtures, and spawn the compiled fabro binary." +)] +#![expect( + clippy::disallowed_types, + reason = "These regressions intentionally own Child processes to exercise the real server-dispatched worker path." +)] +#![expect( + clippy::unwrap_used, + reason = "Integration-test setup for real-subprocess auth harness; panic-on-failure is the desired behavior." +)] + +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use chrono::{Duration as ChronoDuration, Utc}; +use fabro_client::{AuthEntry, AuthStore, ServerTarget, StoredSubject}; +use fabro_config::{Storage, envfile}; +use fabro_store::EventEnvelope; +use fabro_test::{apply_test_isolation, expect_reqwest_json, isolated_storage_dir, test_context}; +use hkdf::Hkdf; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use sha2::Sha256; + +use super::support::{find_run_dir, output_stderr, output_stdout}; +use crate::support::{ + TEST_SESSION_SECRET, issue_test_github_jwt, parse_event_envelopes, unique_run_id, +}; + +const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); +const TEST_GITHUB_CLIENT_SECRET: &str = "github-client-secret"; +const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker"; +const WORKER_TOKEN_SCOPE: &str = "run:worker"; +const WORKER_TOKEN_TTL_SECS: u64 = 72 * 60 * 60; + +struct RunningGithubOnlyServer { + child: Option, + home_root: tempfile::TempDir, + worker_home: PathBuf, + _storage_root: tempfile::TempDir, + storage_dir: PathBuf, + api_base_url: String, +} + +impl RunningGithubOnlyServer { + async fn start() -> Self { + let home_root = tempfile::tempdir_in("/tmp").unwrap(); + let worker_home = home_root.path().join("worker-home"); + std::fs::create_dir_all(&worker_home).unwrap(); + + let storage_root = isolated_storage_dir(); + let storage_dir = storage_root.path().join("storage"); + let port = reserve_port(); + let api_base_url = format!("http://127.0.0.1:{port}"); + let config_path = home_root.path().join("settings.toml"); + std::fs::write( + &config_path, + format!( + r#"_version = 1 + +[server.web] +enabled = true +url = "{api_base_url}" + +[server.auth] +methods = ["github"] + +[server.auth.github] +allowed_usernames = ["octocat"] + +[server.integrations.github] +client_id = "github-client-id" +"# + ), + ) + .unwrap(); + envfile::merge_env_file( + &Storage::new(&storage_dir).runtime_directory().env_path(), + [ + ("SESSION_SECRET", TEST_SESSION_SECRET), + ("GITHUB_APP_CLIENT_SECRET", TEST_GITHUB_CLIENT_SECRET), + ], + ) + .unwrap(); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_fabro")); + apply_test_isolation(&mut cmd, home_root.path()); + cmd.env("FABRO_HOME", &worker_home); + cmd.args(["server", "start", "--foreground"]) + .arg("--storage-dir") + .arg(&storage_dir) + .arg("--bind") + .arg(format!("127.0.0.1:{port}")) + .arg("--config") + .arg(&config_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + let mut child = cmd.spawn().expect("github-only server should spawn"); + wait_for_http_ready(&api_base_url, &mut child).await; + + Self { + child: Some(child), + home_root, + worker_home, + _storage_root: storage_root, + storage_dir, + api_base_url, + } + } + + fn target(&self) -> String { + format!("{}/api/v1", self.api_base_url) + } + + fn shutdown(mut self) { + let mut stop = Command::new(env!("CARGO_BIN_EXE_fabro")); + apply_test_isolation(&mut stop, self.home_root.path()); + stop.env("FABRO_HOME", &self.worker_home); + stop.args(["server", "stop"]) + .arg("--storage-dir") + .arg(&self.storage_dir); + let output = stop.output().expect("server stop should run"); + assert!( + output.status.success(), + "server stop failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let output = self + .child + .take() + .expect("server child should still be present") + .wait_with_output() + .expect("server output should be readable"); + assert!( + output.status.success(), + "github-only server exited unsuccessfully\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + } +} + +impl Drop for RunningGithubOnlyServer { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + if child.try_wait().ok().flatten().is_none() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } +} + +#[derive(serde::Serialize)] +struct WorkerTokenClaims { + iss: String, + iat: u64, + exp: u64, + run_id: String, + scope: String, + jti: String, +} + +fn reserve_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +fn write_submitter_auth(home_dir: &Path, target: &str, access_token: &str) { + let auth_store = AuthStore::new(home_dir.join(".fabro").join("auth.json")); + let target = ServerTarget::http_url(target).unwrap(); + let now = Utc::now(); + auth_store + .put(&target, AuthEntry { + access_token: access_token.to_string(), + access_token_expires_at: now + ChronoDuration::minutes(10), + refresh_token: "refresh-unused".to_string(), + refresh_token_expires_at: now + ChronoDuration::days(30), + subject: StoredSubject { + idp_issuer: "https://github.com".to_string(), + idp_subject: "12345".to_string(), + login: "octocat".to_string(), + name: "The Octocat".to_string(), + email: "octocat@example.com".to_string(), + }, + logged_in_at: now, + }) + .unwrap(); +} + +fn write_probe_workflow(path: &Path) { + std::fs::write( + path, + r#"digraph WorkerAuthProbe { + graph [goal="Verify github-only worker auth", default_max_retries=0] + start [shape=Mdiamond] + exit [shape=Msquare] + probe [shape=parallelogram, script="printf worker-auth-ok"] + start -> probe -> exit +} +"#, + ) + .unwrap(); +} + +fn issue_worker_token_for_run(storage_dir: &Path, run_id: &str) -> String { + let runtime_directory = Storage::new(storage_dir).runtime_directory(); + let session_secret = envfile::read_env_file(&runtime_directory.env_path()) + .expect("server env should load") + .get("SESSION_SECRET") + .cloned() + .expect("server env should include SESSION_SECRET"); + let hkdf = Hkdf::::new(None, session_secret.as_bytes()); + let mut key = [0_u8; 32]; + hkdf.expand(b"fabro-worker-jwt-v1", &mut key) + .expect("worker jwt hkdf output should fit"); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let claims = WorkerTokenClaims { + iss: WORKER_TOKEN_ISSUER.to_string(), + iat: now, + exp: now + WORKER_TOKEN_TTL_SECS, + run_id: run_id.to_string(), + scope: WORKER_TOKEN_SCOPE.to_string(), + jti: format!("{:032x}", rand::random::()), + }; + + jsonwebtoken::encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(&key), + ) + .expect("worker token should encode") +} + +fn wait_for_run_dir(storage_dir: &Path, run_id: &str) -> PathBuf { + let deadline = Instant::now() + COMMAND_TIMEOUT; + loop { + if let Some(run_dir) = find_run_dir(storage_dir, run_id) { + return run_dir; + } + assert!( + Instant::now() < deadline, + "timed out waiting for run dir for {run_id}" + ); + std::thread::sleep(Duration::from_millis(50)); + } +} + +async fn wait_for_http_ready(base_url: &str, child: &mut Child) { + let client = fabro_test::test_http_client(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match client.get(format!("{base_url}/health")).send().await { + Ok(response) if response.status().is_success() => return, + Ok(_) | Err(_) if Instant::now() < deadline => { + if let Some(status) = child.try_wait().expect("server process should poll") { + let mut stderr = Vec::new(); + if let Some(stderr_pipe) = child.stderr.as_mut() { + stderr_pipe + .read_to_end(&mut stderr) + .expect("server stderr should be readable"); + } + panic!( + "github-only server exited before becoming ready with status {status}\nstderr:\n{}", + String::from_utf8_lossy(&stderr) + ); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + Ok(response) => panic!("server at {base_url} was not ready: {}", response.status()), + Err(err) => panic!("server at {base_url} was not ready: {err}"), + } + } +} + +async fn run_events(api_base_url: &str, run_id: &str, access_token: &str) -> Vec { + let response = fabro_test::test_http_client() + .get(format!("{api_base_url}/api/v1/runs/{run_id}/events")) + .bearer_auth(access_token) + .send() + .await + .expect("event request should succeed"); + let body: serde_json::Value = expect_reqwest_json( + response, + fabro_http::StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/events"), + ) + .await; + parse_event_envelopes(&body) +} + +async fn wait_for_completed_events( + api_base_url: &str, + run_id: &str, + access_token: &str, +) -> Vec { + let deadline = Instant::now() + COMMAND_TIMEOUT; + loop { + let events = run_events(api_base_url, run_id, access_token).await; + if events + .iter() + .any(|event| event.event.event_name() == "run.completed") + { + return events; + } + assert!( + Instant::now() < deadline, + "timed out waiting for run.completed for {run_id}" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn github_only_server_dispatched_worker_succeeds_without_worker_auth_store() { + let context = test_context!(); + let server = RunningGithubOnlyServer::start().await; + let target = server.target(); + let access_token = issue_test_github_jwt(&server.api_base_url); + write_submitter_auth(&context.home_dir, &target, &access_token); + assert!(!server.worker_home.join("auth.json").exists()); + assert!(!server.worker_home.join("auth.lock").exists()); + + let workflow = context.temp_dir.join("worker-auth.fabro"); + write_probe_workflow(&workflow); + let run_id = unique_run_id(); + let output = context + .run_cmd() + .args([ + "--server", + &target, + "--run-id", + &run_id, + "--detach", + "--dry-run", + "--auto-approve", + "--no-retro", + "--sandbox", + "local", + workflow.to_str().unwrap(), + ]) + .output() + .expect("detached run should execute"); + + assert!( + output.status.success(), + "github-only detached run failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(output_stdout(&output).trim(), run_id); + + let _run_dir = wait_for_run_dir(&server.storage_dir, &run_id); + let events = wait_for_completed_events(&server.api_base_url, &run_id, &access_token).await; + + assert!(events.iter().any(|event| { + event + .event + .actor + .as_ref() + .and_then(|actor| actor.display.as_deref()) + == Some("system:worker") + })); + assert!(!server.worker_home.join("auth.json").exists()); + assert!(!server.worker_home.join("auth.lock").exists()); + + server.shutdown(); +} + +#[test] +fn runner_rejects_bogus_worker_token_against_github_only_server() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let context = test_context!(); + let server = RunningGithubOnlyServer::start().await; + let target = server.target(); + let access_token = issue_test_github_jwt(&server.api_base_url); + write_submitter_auth(&context.home_dir, &target, &access_token); + + let workflow = context.temp_dir.join("worker-auth-negative.fabro"); + write_probe_workflow(&workflow); + let run_id = unique_run_id(); + let create_output = context + .create_cmd() + .args([ + "--server", + &target, + "--run-id", + &run_id, + "--dry-run", + "--auto-approve", + "--no-retro", + "--sandbox", + "local", + workflow.to_str().unwrap(), + ]) + .output() + .expect("remote create should execute"); + + assert!( + create_output.status.success(), + "github-only create failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&create_output.stdout), + String::from_utf8_lossy(&create_output.stderr) + ); + assert_eq!(output_stdout(&create_output).trim(), run_id); + + let run_dir = wait_for_run_dir(&server.storage_dir, &run_id); + let worker_root = tempfile::tempdir_in("/tmp").unwrap(); + let worker_home = worker_root.path().join("fabro-home"); + std::fs::create_dir_all(&worker_home).unwrap(); + let auth_file = worker_root.path().join("missing").join("auth.json"); + let bogus_token = issue_worker_token_for_run(&server.storage_dir, &unique_run_id()); + + let mut cmd = Command::new(env!("CARGO_BIN_EXE_fabro")); + apply_test_isolation(&mut cmd, worker_root.path()); + cmd.env("FABRO_HOME", &worker_home); + cmd.env("FABRO_AUTH_FILE", &auth_file); + cmd.env("FABRO_WORKER_TOKEN", bogus_token); + cmd.args([ + "__run-worker", + "--server", + &target, + "--run-dir", + run_dir.to_str().unwrap(), + "--run-id", + &run_id, + "--mode", + "start", + ]); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + let output = cmd.output().expect("worker should execute"); + + assert!( + !output.status.success(), + "worker should fail with a bogus token\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stderr = output_stderr(&output); + assert!( + stderr.contains("403") + || stderr.contains("Forbidden") + || stderr.contains("Authentication required") + || stderr.contains("Access denied"), + "{stderr}" + ); + assert!(!auth_file.exists()); + assert!(!auth_file.with_extension("lock").exists()); + + server.shutdown(); + }); +} diff --git a/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs b/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs index 5abc94115..5906cc6da 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs @@ -13,10 +13,16 @@ fn start_status_stop_lifecycle() { let server_env_path = fabro_config::Storage::new(&storage_dir) .runtime_directory() .env_path(); - fabro_config::envfile::merge_env_file(&server_env_path, [( - "FABRO_DEV_TOKEN", - "fabro_dev_abababababababababababababababababababababababababababababababab", - )]) + fabro_config::envfile::merge_env_file(&server_env_path, [ + ( + "FABRO_DEV_TOKEN", + "fabro_dev_abababababababababababababababababababababababababababababababab", + ), + ( + "SESSION_SECRET", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ), + ]) .unwrap(); fabro_util::dev_token::write_dev_token( &context.home_dir.join(".fabro").join("dev-token"), diff --git a/lib/crates/fabro-cli/tests/it/support/auth_harness.rs b/lib/crates/fabro-cli/tests/it/support/auth_harness.rs index a8a81b40b..8a1fce43d 100644 --- a/lib/crates/fabro-cli/tests/it/support/auth_harness.rs +++ b/lib/crates/fabro-cli/tests/it/support/auth_harness.rs @@ -22,22 +22,20 @@ use fabro_server::auth::GithubEndpoints; use fabro_server::ip_allowlist::IpAllowlistConfig; use fabro_server::jwt_auth::resolve_auth_mode_with_lookup; use fabro_server::server::{ - RouterOptions, build_router_with_options, create_app_state_with_env_lookup, + RouterOptions, build_router_with_options, + create_app_state_with_env_lookup_and_server_secret_env, }; use fabro_test::{GitHubAppState, TestContext, apply_test_isolation}; -use fabro_types::RunAuthMethod; -use hkdf::Hkdf; -use jsonwebtoken::{Algorithm, EncodingKey, Header}; use serde_json::Value; -use sha2::Sha256; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio::task::JoinHandle; -use ulid::Ulid; + +use super::auth_tokens::{ + TEST_SESSION_SECRET, TestGithubJwtSubject, issue_expired_test_github_jwt, +}; const LOGIN_TIMEOUT: Duration = Duration::from_secs(10); -const TEST_SESSION_SECRET: &str = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; pub(crate) const TEST_DEV_TOKEN: &str = "fabro_dev_abababababababababababababababababababababababababababababababab"; @@ -79,12 +77,21 @@ impl RealAuthHarness { _ => None, }) .expect("auth mode should resolve"); - let state = create_app_state_with_env_lookup(settings, 5, move |name| match name { - "SESSION_SECRET" => Some(TEST_SESSION_SECRET.to_string()), - "GITHUB_APP_CLIENT_SECRET" => Some(github_client_secret.clone()), - "FABRO_DEV_TOKEN" => dev_token.clone(), - _ => None, - }); + let mut secrets = std::collections::HashMap::from([ + ( + "SESSION_SECRET".to_string(), + TEST_SESSION_SECRET.to_string(), + ), + ( + "GITHUB_APP_CLIENT_SECRET".to_string(), + github_client_secret.clone(), + ), + ]); + if let Some(token) = dev_token.clone() { + secrets.insert("FABRO_DEV_TOKEN".to_string(), token); + } + let state = + create_app_state_with_env_lookup_and_server_secret_env(settings, 5, |_| None, &secrets); let github_base = github_base_url(&twin.base_url); let router = build_router_with_options( state, @@ -315,22 +322,6 @@ async fn record_request( next.run(req).await } -#[derive(serde::Serialize)] -struct TestJwtClaims { - iss: String, - aud: String, - sub: String, - exp: u64, - iat: u64, - jti: String, - idp_issuer: String, - idp_subject: String, - login: String, - name: String, - email: String, - auth_method: RunAuthMethod, -} - async fn bind_listener() -> (TcpListener, String) { let listener = TcpListener::bind("127.0.0.1:0") .await @@ -469,43 +460,15 @@ fn auth_store_path(context: &TestContext) -> std::path::PathBuf { } fn expired_access_token(issuer: &str, subject: &serde_json::Map) -> String { - let key = derived_jwt_key(); - let now = Utc::now(); - let claims = TestJwtClaims { - iss: issuer.to_string(), - aud: "fabro-cli".to_string(), - sub: subject_value(subject, "idp_subject"), - exp: (now - ChronoDuration::minutes(10)) - .timestamp() - .try_into() - .expect("expired timestamp should be positive"), - iat: (now - ChronoDuration::minutes(20)) - .timestamp() - .try_into() - .expect("issued-at timestamp should be positive"), - jti: Ulid::new().to_string(), + issue_expired_test_github_jwt(issuer, TestGithubJwtSubject { idp_issuer: subject_value(subject, "idp_issuer"), idp_subject: subject_value(subject, "idp_subject"), login: subject_value(subject, "login"), name: subject_value(subject, "name"), email: subject_value(subject, "email"), - auth_method: RunAuthMethod::Github, - }; - - jsonwebtoken::encode( - &Header::new(Algorithm::HS256), - &claims, - &EncodingKey::from_secret(&key), - ) - .expect("expired JWT should encode") -} - -fn derived_jwt_key() -> [u8; 32] { - let hkdf = Hkdf::::new(None, TEST_SESSION_SECRET.as_bytes()); - let mut key = [0_u8; 32]; - hkdf.expand(b"fabro-jwt-hs256-v1", &mut key) - .expect("HKDF should derive the fixed-size JWT key"); - key + avatar_url: String::new(), + user_url: String::new(), + }) } fn read_stderr_and_capture_url( diff --git a/lib/crates/fabro-cli/tests/it/support/auth_tokens.rs b/lib/crates/fabro-cli/tests/it/support/auth_tokens.rs new file mode 100644 index 000000000..6638c3c20 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/support/auth_tokens.rs @@ -0,0 +1,122 @@ +use chrono::{Duration as ChronoDuration, Utc}; +use fabro_types::RunAuthMethod; +use hkdf::Hkdf; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use sha2::Sha256; +use ulid::Ulid; + +pub(crate) const TEST_SESSION_SECRET: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const JWT_AUDIENCE: &str = "fabro-cli"; + +#[derive(Clone)] +pub(crate) struct TestGithubJwtSubject { + pub(crate) idp_issuer: String, + pub(crate) idp_subject: String, + pub(crate) login: String, + pub(crate) name: String, + pub(crate) email: String, + pub(crate) avatar_url: String, + pub(crate) user_url: String, +} + +impl TestGithubJwtSubject { + pub(crate) fn octocat() -> Self { + Self { + idp_issuer: "https://github.com".to_string(), + idp_subject: "12345".to_string(), + login: "octocat".to_string(), + name: "The Octocat".to_string(), + email: "octocat@example.com".to_string(), + avatar_url: "https://example.com/octocat.png".to_string(), + user_url: "https://github.com/octocat".to_string(), + } + } +} + +#[derive(serde::Serialize)] +struct TestJwtClaims { + iss: String, + aud: String, + sub: String, + exp: u64, + iat: u64, + jti: String, + idp_issuer: String, + idp_subject: String, + login: String, + name: String, + email: String, + avatar_url: String, + user_url: String, + auth_method: RunAuthMethod, +} + +pub(crate) fn issue_test_github_jwt(issuer: &str) -> String { + let now = Utc::now(); + issue_github_jwt( + issuer, + TestGithubJwtSubject::octocat(), + now, + now + ChronoDuration::minutes(10), + format!("{:032x}", rand::random::()), + ) +} + +pub(crate) fn issue_expired_test_github_jwt(issuer: &str, subject: TestGithubJwtSubject) -> String { + let now = Utc::now(); + issue_github_jwt( + issuer, + subject, + now - ChronoDuration::minutes(20), + now - ChronoDuration::minutes(10), + Ulid::new().to_string(), + ) +} + +fn issue_github_jwt( + issuer: &str, + subject: TestGithubJwtSubject, + issued_at: chrono::DateTime, + expires_at: chrono::DateTime, + jti: String, +) -> String { + let key = derived_jwt_key(); + let claims = TestJwtClaims { + iss: issuer.to_string(), + aud: JWT_AUDIENCE.to_string(), + sub: subject.idp_subject.clone(), + exp: expires_at + .timestamp() + .try_into() + .expect("expiration time should be positive"), + iat: issued_at + .timestamp() + .try_into() + .expect("issued-at time should be positive"), + jti, + idp_issuer: subject.idp_issuer, + idp_subject: subject.idp_subject, + login: subject.login, + name: subject.name, + email: subject.email, + avatar_url: subject.avatar_url, + user_url: subject.user_url, + auth_method: RunAuthMethod::Github, + }; + jsonwebtoken::encode( + &Header::new(Algorithm::HS256), + &claims, + &EncodingKey::from_secret(&key), + ) + .expect("test GitHub JWT should encode") +} + +fn derived_jwt_key() -> [u8; 32] { + let hkdf = Hkdf::::new(None, TEST_SESSION_SECRET.as_bytes()); + let mut key = [0_u8; 32]; + hkdf.expand(b"fabro-jwt-hs256-v1", &mut key) + .expect("HKDF should derive the fixed-size JWT key"); + key +} diff --git a/lib/crates/fabro-cli/tests/it/support/mod.rs b/lib/crates/fabro-cli/tests/it/support/mod.rs index d2300c7c8..d48eb2bce 100644 --- a/lib/crates/fabro-cli/tests/it/support/mod.rs +++ b/lib/crates/fabro-cli/tests/it/support/mod.rs @@ -1,4 +1,5 @@ mod auth_harness; +mod auth_tokens; use assert_cmd::Command; use fabro_store::EventEnvelope; @@ -50,6 +51,7 @@ pub(crate) use auth_harness::{ RealAuthHarness, TEST_DEV_TOKEN, complete_login_via_browser, expire_saved_access_token, no_redirect_browser_client, run_detached, saved_auth_entry, }; +pub(crate) use auth_tokens::{TEST_SESSION_SECRET, issue_test_github_jwt}; pub(crate) use fabro_json_snapshot; pub(crate) fn run_output_filters(context: &TestContext) -> Vec<(String, String)> { diff --git a/lib/crates/fabro-cli/tests/it/workflow/command_agent_mixed.rs b/lib/crates/fabro-cli/tests/it/workflow/command_agent_mixed.rs index ee9ba5be8..d0fbabe4f 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/command_agent_mixed.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/command_agent_mixed.rs @@ -6,8 +6,8 @@ use fabro_test::test_context; use super::{ - completed_nodes, find_run_dir, fixture, read_conclusion, run_id_for, sandbox_tests, - store_dump_export, timeout_for, + completed_nodes, dump_export, find_run_dir, fixture, read_conclusion, run_id_for, + sandbox_tests, timeout_for, }; sandbox_tests!(command_agent_mixed, keys = ["ANTHROPIC_API_KEY"]); @@ -48,7 +48,7 @@ fn scenario_command_agent_mixed(sandbox: &str) { "verify should be completed" ); - let export_dir = store_dump_export(&context, &run_id_for(&run_dir)); + let export_dir = dump_export(&context, &run_id_for(&run_dir)); let stdout = std::fs::read_to_string(export_dir.join("stages/verify@1/stdout.log")) .expect("verify stdout.log should exist"); assert!( diff --git a/lib/crates/fabro-cli/tests/it/workflow/command_pipeline.rs b/lib/crates/fabro-cli/tests/it/workflow/command_pipeline.rs index 5a46d92e4..7c1d7eae7 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/command_pipeline.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/command_pipeline.rs @@ -6,8 +6,8 @@ use fabro_test::test_context; use super::{ - completed_nodes, find_run_dir, fixture, read_conclusion, run_id_for, sandbox_tests, - store_dump_export, timeout_for, + completed_nodes, dump_export, find_run_dir, fixture, read_conclusion, run_id_for, + sandbox_tests, timeout_for, }; sandbox_tests!(command_pipeline); @@ -47,7 +47,7 @@ fn scenario_command_pipeline(sandbox: &str) { "step2 should be completed" ); - let export_dir = store_dump_export(&context, &run_id_for(&run_dir)); + let export_dir = dump_export(&context, &run_id_for(&run_dir)); let stdout1 = std::fs::read_to_string(export_dir.join("stages/step1@1/stdout.log")) .expect("step1 stdout.log should exist"); assert!( diff --git a/lib/crates/fabro-cli/tests/it/workflow/full_stack.rs b/lib/crates/fabro-cli/tests/it/workflow/full_stack.rs index 4b03f1155..a2a1feff4 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/full_stack.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/full_stack.rs @@ -6,8 +6,8 @@ use fabro_test::test_context; use super::{ - completed_nodes, find_run_dir, fixture, has_event, read_conclusion, read_run_spec, run_id_for, - sandbox_tests, store_dump_export, timeout_for, + completed_nodes, dump_export, find_run_dir, fixture, has_event, read_conclusion, read_run_spec, + run_id_for, sandbox_tests, timeout_for, }; sandbox_tests!(full_stack, keys = ["ANTHROPIC_API_KEY"]); @@ -73,7 +73,7 @@ fn scenario_full_stack(sandbox: &str) { } // Verify node stdout should contain PASS - let export_dir = store_dump_export(&context, &run_id_for(&run_dir)); + let export_dir = dump_export(&context, &run_id_for(&run_dir)); let stdout = std::fs::read_to_string(export_dir.join("stages/verify@1/stdout.log")) .expect("verify stdout.log should exist"); assert!( diff --git a/lib/crates/fabro-cli/tests/it/workflow/mod.rs b/lib/crates/fabro-cli/tests/it/workflow/mod.rs index 5cf1005a4..b7911837a 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/mod.rs @@ -59,17 +59,16 @@ pub(super) fn has_event(run_dir: &Path, event_name: &str) -> bool { .any(|event| event.event.event_name() == event_name) } -pub(super) fn store_dump_export(context: &TestContext, run_id: &str) -> PathBuf { - let output_dir = context.temp_dir.join(format!("store-dump-{run_id}")); +pub(super) fn dump_export(context: &TestContext, run_id: &str) -> PathBuf { + let output_dir = context.temp_dir.join(format!("dump-{run_id}")); context .command() .args([ - "store", "dump", "--output", output_dir .to_str() - .expect("store dump output path should be valid UTF-8"), + .expect("dump output path should be valid UTF-8"), run_id, ]) .assert() diff --git a/lib/crates/fabro-client/Cargo.toml b/lib/crates/fabro-client/Cargo.toml index 219a6b4ee..e0c426a1c 100644 --- a/lib/crates/fabro-client/Cargo.toml +++ b/lib/crates/fabro-client/Cargo.toml @@ -35,4 +35,5 @@ tracing.workspace = true [dev-dependencies] httpmock = "0.8" +static_assertions = "1" tempfile = "3" diff --git a/lib/crates/fabro-client/src/credential.rs b/lib/crates/fabro-client/src/credential.rs index c0a194069..6f14804c3 100644 --- a/lib/crates/fabro-client/src/credential.rs +++ b/lib/crates/fabro-client/src/credential.rs @@ -5,6 +5,7 @@ use crate::AuthEntry; #[derive(Clone)] pub enum Credential { DevToken(String), + Worker(String), OAuth(AuthEntry), } @@ -24,7 +25,7 @@ where impl Credential { pub fn bearer_token(&self) -> &str { match self { - Self::DevToken(token) => token, + Self::DevToken(token) | Self::Worker(token) => token, Self::OAuth(entry) => &entry.access_token, } } @@ -34,7 +35,29 @@ impl fmt::Debug for Credential { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::DevToken(_) => f.write_str("Credential::DevToken()"), + Self::Worker(_) => f.write_str("Credential::Worker()"), Self::OAuth(_) => f.write_str("Credential::OAuth()"), } } } + +#[cfg(test)] +mod tests { + use static_assertions::assert_not_impl_any; + + use super::Credential; + + assert_not_impl_any!(Credential: std::fmt::Display); + + #[test] + fn worker_bearer_token_returns_inner_token() { + let credential = Credential::Worker("worker-token".to_string()); + assert_eq!(credential.bearer_token(), "worker-token"); + } + + #[test] + fn worker_debug_redacts_token() { + let credential = Credential::Worker("worker-token".to_string()); + assert_eq!(format!("{credential:?}"), "Credential::Worker()"); + } +} diff --git a/lib/crates/fabro-config/src/context.rs b/lib/crates/fabro-config/src/context.rs index 5cddd9e6a..42d77793f 100644 --- a/lib/crates/fabro-config/src/context.rs +++ b/lib/crates/fabro-config/src/context.rs @@ -1,9 +1,16 @@ -use fabro_types::settings::{CliNamespace, FeaturesNamespace, ServerNamespace, SettingsLayer}; +use std::collections::HashMap; + +use fabro_types::settings::{ + CliNamespace, FeaturesNamespace, ProjectNamespace, RunNamespace, ServerNamespace, + SettingsLayer, WorkflowNamespace, +}; use serde::{Deserialize, Serialize}; -use crate::resolve::Resolver; use crate::user::load_settings_config; -use crate::{Error, Result}; +use crate::{ + Error, ResolveError, Result, apply_builtin_defaults, resolve_cli, resolve_features, + resolve_project, resolve_run, resolve_server, resolve_workflow, +}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ServerSettings { @@ -13,10 +20,10 @@ pub struct ServerSettings { impl ServerSettings { pub fn from_layer(layer: &SettingsLayer) -> Result { - let resolver = Resolver::from_layer(layer); + let layer = apply_builtin_defaults(layer.clone()); let mut errors = Vec::new(); - let server = resolver.server_into(&mut errors); - let features = resolver.features_into(&mut errors); + let server = resolve_server(&layer.server.clone().unwrap_or_default(), &mut errors); + let features = resolve_features(&layer.features.clone().unwrap_or_default(), &mut errors); if errors.is_empty() { Ok(Self { server, features }) } else { @@ -38,10 +45,10 @@ pub struct UserSettings { impl UserSettings { pub fn from_layer(layer: &SettingsLayer) -> Result { - let resolver = Resolver::from_layer(layer); + let layer = apply_builtin_defaults(layer.clone()); let mut errors = Vec::new(); - let cli = resolver.cli_into(&mut errors); - let features = resolver.features_into(&mut errors); + let cli = resolve_cli(&layer.cli.clone().unwrap_or_default(), &mut errors); + let features = resolve_features(&layer.features.clone().unwrap_or_default(), &mut errors); if errors.is_empty() { Ok(Self { cli, features }) } else { @@ -54,3 +61,36 @@ impl UserSettings { Self::from_layer(&layer) } } + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct WorkflowSettings { + pub project: ProjectNamespace, + pub workflow: WorkflowNamespace, + pub run: RunNamespace, +} + +impl WorkflowSettings { + pub fn from_layer(layer: &SettingsLayer) -> std::result::Result> { + let layer = apply_builtin_defaults(layer.clone()); + let mut errors = Vec::new(); + let project = resolve_project(&layer.project.clone().unwrap_or_default(), &mut errors); + let workflow = resolve_workflow(&layer.workflow.clone().unwrap_or_default(), &mut errors); + let run = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors); + if errors.is_empty() { + Ok(Self { + project, + workflow, + run, + }) + } else { + Err(errors) + } + } + + pub fn combined_labels(&self) -> HashMap { + let mut labels = self.project.metadata.clone(); + labels.extend(self.workflow.metadata.clone()); + labels.extend(self.run.metadata.clone()); + labels + } +} diff --git a/lib/crates/fabro-config/src/defaults.rs b/lib/crates/fabro-config/src/defaults.rs index dada39238..0d19c0c22 100644 --- a/lib/crates/fabro-config/src/defaults.rs +++ b/lib/crates/fabro-config/src/defaults.rs @@ -1,8 +1,7 @@ use std::sync::LazyLock; -use fabro_types::settings::SettingsLayer; +use fabro_types::settings::{Combine, SettingsLayer}; -use crate::merge::combine_files; use crate::parse_settings_layer; static DEFAULTS_LAYER: LazyLock = LazyLock::new(|| { @@ -17,5 +16,5 @@ pub fn defaults_layer() -> &'static SettingsLayer { #[must_use] pub fn apply_builtin_defaults(layer: SettingsLayer) -> SettingsLayer { - combine_files(defaults_layer().clone(), layer) + layer.combine(defaults_layer().clone()) } diff --git a/lib/crates/fabro-config/src/effective_settings.rs b/lib/crates/fabro-config/src/effective_settings.rs index 9a42750b8..b1cebe1c0 100644 --- a/lib/crates/fabro-config/src/effective_settings.rs +++ b/lib/crates/fabro-config/src/effective_settings.rs @@ -9,11 +9,10 @@ //! stanzas in `.fabro/project.toml` and `workflow.toml` remain schema-valid but //! inert. -use fabro_types::settings::SettingsLayer; use fabro_types::settings::run::{RunExecutionLayer, RunLayer}; use fabro_types::settings::server::ServerLayer; +use fabro_types::settings::{Combine, SettingsLayer}; -use crate::merge::combine_files; use crate::{Error, Result, apply_builtin_defaults}; #[derive(Clone, Debug, Default)] @@ -86,13 +85,11 @@ pub fn materialize_settings_layer( server_defaults.cli = None; server_defaults.server = None; - let combined = combine_files( - combine_files( - combine_files(combine_files(server_defaults, user), project), - workflow, - ), - args, - ); + let combined = args + .combine(workflow) + .combine(project) + .combine(user) + .combine(server_defaults); let settings = enforce_server_authority(combined, server_settings); Ok(apply_builtin_defaults(settings)) diff --git a/lib/crates/fabro-config/src/envfile.rs b/lib/crates/fabro-config/src/envfile.rs index 403cf5595..576877827 100644 --- a/lib/crates/fabro-config/src/envfile.rs +++ b/lib/crates/fabro-config/src/envfile.rs @@ -358,7 +358,7 @@ mod tests { let entries = merge_env_file(&path, [ ("SESSION_SECRET", "secret"), - ("FABRO_JWT_PUBLIC_KEY", "jwt"), + ("FABRO_DEV_TOKEN", "token"), ]) .unwrap(); @@ -368,8 +368,8 @@ mod tests { Some("secret") ); assert_eq!( - entries.get("FABRO_JWT_PUBLIC_KEY").map(String::as_str), - Some("jwt") + entries.get("FABRO_DEV_TOKEN").map(String::as_str), + Some("token") ); } diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index ed5772707..bff64f72f 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -2,8 +2,9 @@ clippy::disallowed_methods, reason = "sync config loading utilities used at startup; not on a Tokio path" )] -//! Resolved settings entrypoints: [`ServerSettings`] for the running server and -//! [`UserSettings`] for the CLI/user perspective. +//! Resolved settings entrypoints: [`ServerSettings`] for the running server, +//! [`UserSettings`] for the CLI/user perspective, and [`WorkflowSettings`] for +//! workflow execution. extern crate self as fabro_config; @@ -17,7 +18,6 @@ pub mod envfile; pub mod error; pub mod home; pub mod load; -pub mod merge; pub mod parse; pub mod project; pub mod resolve; @@ -27,7 +27,7 @@ pub mod user; use std::path::Path; -pub use context::{ServerSettings, UserSettings}; +pub use context::{ServerSettings, UserSettings, WorkflowSettings}; pub use defaults::{apply_builtin_defaults, defaults_layer}; pub use error::{Error, Result}; pub use fabro_util::path::expand_tilde; @@ -37,7 +37,7 @@ pub use load::{ }; pub use parse::{ParseError, parse_settings_layer}; pub use resolve::{ - ResolveError, Resolver, dev_token_auth_enabled, render_resolve_errors, resolve_cli, + ResolveError, dev_token_auth_enabled, render_resolve_errors, resolve_cli, resolve_cli_from_file, resolve_features, resolve_features_from_file, resolve_project, resolve_project_from_file, resolve_run, resolve_run_from_file, resolve_server, resolve_server_from_file, resolve_storage_root, resolve_workflow, resolve_workflow_from_file, diff --git a/lib/crates/fabro-config/src/load.rs b/lib/crates/fabro-config/src/load.rs index 0943dcfb6..9e7f17c4b 100644 --- a/lib/crates/fabro-config/src/load.rs +++ b/lib/crates/fabro-config/src/load.rs @@ -6,9 +6,8 @@ use std::path::{Path, PathBuf}; use fabro_types::settings::run::RunGoalLayer; -use fabro_types::settings::{InterpString, SettingsLayer}; +use fabro_types::settings::{Combine, InterpString, SettingsLayer}; -use crate::merge::combine_files; use crate::parse::parse_settings_layer; use crate::{Error, Result, project, user}; @@ -39,7 +38,7 @@ pub fn load_settings_for_workflow(path: &Path, cwd: &Path) -> Result Result { diff --git a/lib/crates/fabro-config/src/merge.rs b/lib/crates/fabro-config/src/merge.rs deleted file mode 100644 index 85e463b79..000000000 --- a/lib/crates/fabro-config/src/merge.rs +++ /dev/null @@ -1,834 +0,0 @@ -//! v2 merge matrix implementation. -//! -//! Encodes the normative merge behavior from the requirements doc: replace -//! scalars, field-merge structured tables, replace freeform maps by default, -//! sticky merge-by-key where the requirements call for it, splice-capable -//! string arrays, whole-list replacement for ordered prepare steps, and -//! ordered hook merging with optional `id` replacement. -#![allow( - clippy::needless_pass_by_value, - reason = "This merge layer consumes owned settings trees by design." -)] - -use std::collections::HashMap; - -use fabro_types::settings::cli::{ - CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliOutputLayer, CliTargetLayer, - CliUpdatesLayer, -}; -use fabro_types::settings::layer::SettingsLayer; -use fabro_types::settings::project::ProjectLayer; -use fabro_types::settings::run::{ - DaytonaSandboxLayer, GitAuthorLayer, HookEntry, InterviewsLayer, ModelRefOrSplice, - NotificationRouteLayer, RunAgentLayer, RunCheckpointLayer, RunExecutionLayer, RunGitLayer, - RunLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer, RunScmLayer, - StringOrSplice, -}; -use fabro_types::settings::server::{ - DiscordIntegrationLayer, GithubIntegrationLayer, IntegrationWebhooksLayer, - ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer, - ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerSchedulerLayer, - ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SlackIntegrationLayer, - TeamsIntegrationLayer, -}; -use fabro_types::settings::workflow::WorkflowLayer; - -/// Combine two settings files: `higher` takes precedence over `lower` wherever -/// the merge matrix does not dictate otherwise. -#[must_use] -pub fn combine_files(lower: SettingsLayer, higher: SettingsLayer) -> SettingsLayer { - SettingsLayer { - version: higher.version.or(lower.version), - project: merge_option(lower.project, higher.project, combine_project), - workflow: merge_option(lower.workflow, higher.workflow, combine_workflow), - run: merge_option(lower.run, higher.run, combine_run), - cli: merge_option(lower.cli, higher.cli, combine_cli), - server: merge_option(lower.server, higher.server, combine_server), - features: replace_if_some(lower.features, higher.features), - } -} - -fn merge_option(lower: Option, higher: Option, f: fn(T, T) -> T) -> Option { - match (lower, higher) { - (Some(l), Some(h)) => Some(f(l, h)), - (Some(l), None) => Some(l), - (None, Some(h)) => Some(h), - (None, None) => None, - } -} - -fn replace_if_some(lower: Option, higher: Option) -> Option { - higher.or(lower) -} - -fn merge_string_map_replace( - lower: HashMap, - higher: HashMap, -) -> HashMap { - if higher.is_empty() { lower } else { higher } -} - -fn merge_string_map_sticky( - mut lower: HashMap, - higher: HashMap, -) -> HashMap { - for (k, v) in higher { - lower.insert(k, v); - } - lower -} - -// ------------------- project ------------------- - -fn combine_project(lower: ProjectLayer, higher: ProjectLayer) -> ProjectLayer { - ProjectLayer { - name: higher.name.or(lower.name), - description: higher.description.or(lower.description), - directory: higher.directory.or(lower.directory), - metadata: merge_string_map_replace(lower.metadata, higher.metadata), - } -} - -// ------------------- workflow ------------------- - -fn combine_workflow(lower: WorkflowLayer, higher: WorkflowLayer) -> WorkflowLayer { - WorkflowLayer { - name: higher.name.or(lower.name), - description: higher.description.or(lower.description), - graph: higher.graph.or(lower.graph), - metadata: merge_string_map_replace(lower.metadata, higher.metadata), - } -} - -// ------------------- run ------------------- - -fn combine_run(lower: RunLayer, higher: RunLayer) -> RunLayer { - RunLayer { - goal: higher.goal.or(lower.goal), - working_dir: higher.working_dir.or(lower.working_dir), - metadata: merge_string_map_replace(lower.metadata, higher.metadata), - inputs: higher.inputs.or(lower.inputs), - model: merge_option(lower.model, higher.model, combine_run_model), - git: merge_option(lower.git, higher.git, combine_run_git), - prepare: merge_option(lower.prepare, higher.prepare, combine_run_prepare), - execution: merge_option(lower.execution, higher.execution, combine_run_execution), - checkpoint: merge_option(lower.checkpoint, higher.checkpoint, combine_run_checkpoint), - sandbox: merge_option(lower.sandbox, higher.sandbox, combine_run_sandbox), - notifications: combine_notifications(lower.notifications, higher.notifications), - interviews: merge_option(lower.interviews, higher.interviews, combine_interviews), - agent: merge_option(lower.agent, higher.agent, combine_run_agent), - hooks: combine_hooks(lower.hooks, higher.hooks), - scm: merge_option(lower.scm, higher.scm, combine_run_scm), - pull_request: merge_option(lower.pull_request, higher.pull_request, combine_run_pr), - artifacts: replace_if_some(lower.artifacts, higher.artifacts), - } -} - -fn combine_run_model(lower: RunModelLayer, higher: RunModelLayer) -> RunModelLayer { - RunModelLayer { - provider: higher.provider.or(lower.provider), - name: higher.name.or(lower.name), - fallbacks: splice_model_fallbacks(lower.fallbacks, higher.fallbacks), - } -} - -fn splice_model_fallbacks( - lower: Vec, - higher: Vec, -) -> Vec { - if higher.is_empty() { - return lower; - } - let splice_pos = higher - .iter() - .position(|e| matches!(e, ModelRefOrSplice::Splice)); - let Some(pos) = splice_pos else { - return higher; - }; - let mut out = Vec::new(); - for (i, entry) in higher.into_iter().enumerate() { - if i == pos { - out.extend( - lower - .iter() - .filter(|e| !matches!(e, ModelRefOrSplice::Splice)) - .cloned(), - ); - } else if !matches!(entry, ModelRefOrSplice::Splice) { - out.push(entry); - } - } - out -} - -fn combine_run_git(lower: RunGitLayer, higher: RunGitLayer) -> RunGitLayer { - RunGitLayer { - author: merge_option(lower.author, higher.author, combine_git_author), - } -} - -fn combine_git_author(lower: GitAuthorLayer, higher: GitAuthorLayer) -> GitAuthorLayer { - GitAuthorLayer { - name: higher.name.or(lower.name), - email: higher.email.or(lower.email), - } -} - -fn combine_run_prepare(_lower: RunPrepareLayer, higher: RunPrepareLayer) -> RunPrepareLayer { - // Whole-list replacement for prepare.steps per the merge matrix. - higher -} - -fn combine_run_execution(lower: RunExecutionLayer, higher: RunExecutionLayer) -> RunExecutionLayer { - RunExecutionLayer { - mode: higher.mode.or(lower.mode), - approval: higher.approval.or(lower.approval), - retros: higher.retros.or(lower.retros), - } -} - -fn combine_run_checkpoint( - lower: RunCheckpointLayer, - higher: RunCheckpointLayer, -) -> RunCheckpointLayer { - // Exclude globs are a security/policy list: replace by default. - if higher.exclude_globs.is_empty() { - lower - } else { - higher - } -} - -fn combine_run_sandbox(lower: RunSandboxLayer, higher: RunSandboxLayer) -> RunSandboxLayer { - RunSandboxLayer { - provider: higher.provider.or(lower.provider), - preserve: higher.preserve.or(lower.preserve), - devcontainer: higher.devcontainer.or(lower.devcontainer), - // Sticky merge-by-key for run.sandbox.env per R71. - env: merge_string_map_sticky(lower.env, higher.env), - local: higher.local.or(lower.local), - daytona: merge_option(lower.daytona, higher.daytona, combine_daytona), - } -} - -fn combine_daytona(lower: DaytonaSandboxLayer, higher: DaytonaSandboxLayer) -> DaytonaSandboxLayer { - DaytonaSandboxLayer { - auto_stop_interval: higher.auto_stop_interval.or(lower.auto_stop_interval), - // Sticky merge-by-key for provider-native labels per R71. - labels: merge_string_map_sticky(lower.labels, higher.labels), - snapshot: higher.snapshot.or(lower.snapshot), - network: higher.network.or(lower.network), - skip_clone: higher.skip_clone.or(lower.skip_clone), - } -} - -fn combine_notifications( - mut lower: HashMap, - higher: HashMap, -) -> HashMap { - for (k, h) in higher { - match lower.remove(&k) { - Some(l) => { - lower.insert(k, combine_notification_route(l, h)); - } - None => { - lower.insert(k, h); - } - } - } - lower -} - -fn combine_notification_route( - lower: NotificationRouteLayer, - higher: NotificationRouteLayer, -) -> NotificationRouteLayer { - NotificationRouteLayer { - enabled: higher.enabled.or(lower.enabled), - provider: higher.provider.or(lower.provider), - events: splice_events(lower.events, higher.events), - slack: higher.slack.or(lower.slack), - discord: higher.discord.or(lower.discord), - teams: higher.teams.or(lower.teams), - } -} - -fn splice_events(lower: Vec, higher: Vec) -> Vec { - if higher.is_empty() { - return lower; - } - let splice_pos = higher - .iter() - .position(|e| matches!(e, StringOrSplice::Splice)); - let Some(pos) = splice_pos else { - return higher; - }; - let mut out = Vec::new(); - for (i, entry) in higher.into_iter().enumerate() { - if i == pos { - out.extend( - lower - .iter() - .filter(|e| !matches!(e, StringOrSplice::Splice)) - .cloned(), - ); - } else if !matches!(entry, StringOrSplice::Splice) { - out.push(entry); - } - } - out -} - -fn combine_interviews(lower: InterviewsLayer, higher: InterviewsLayer) -> InterviewsLayer { - InterviewsLayer { - provider: higher.provider.or(lower.provider), - slack: higher.slack.or(lower.slack), - discord: higher.discord.or(lower.discord), - teams: higher.teams.or(lower.teams), - } -} - -fn combine_run_agent(lower: RunAgentLayer, higher: RunAgentLayer) -> RunAgentLayer { - RunAgentLayer { - permissions: higher.permissions.or(lower.permissions), - // MCP entries: field-merge per key. Higher replaces lower for same keys. - mcps: merge_string_map_sticky(lower.mcps, higher.mcps), - } -} - -/// Merge two ordered hook lists using the id-aware replacement rule. -fn combine_hooks(lower: Vec, higher: Vec) -> Vec { - let mut out: Vec = Vec::with_capacity(lower.len() + higher.len()); - let mut appended_ids: Vec = Vec::new(); - - for lower_entry in &lower { - if let Some(id) = &lower_entry.id { - if let Some(replacement) = higher.iter().find(|h| h.id.as_deref() == Some(id.as_str())) - { - out.push(replacement.clone()); - appended_ids.push(id.clone()); - continue; - } - } - out.push(lower_entry.clone()); - } - - for higher_entry in higher { - if let Some(id) = &higher_entry.id { - if appended_ids.contains(id) { - continue; - } - } - out.push(higher_entry); - } - - out -} - -fn combine_run_scm(lower: RunScmLayer, higher: RunScmLayer) -> RunScmLayer { - RunScmLayer { - provider: higher.provider.or(lower.provider), - owner: higher.owner.or(lower.owner), - repository: higher.repository.or(lower.repository), - github: higher.github.or(lower.github), - } -} - -fn combine_run_pr(lower: RunPullRequestLayer, higher: RunPullRequestLayer) -> RunPullRequestLayer { - RunPullRequestLayer { - enabled: higher.enabled.or(lower.enabled), - draft: higher.draft.or(lower.draft), - auto_merge: higher.auto_merge.or(lower.auto_merge), - merge_strategy: higher.merge_strategy.or(lower.merge_strategy), - } -} - -// ------------------- cli ------------------- - -fn combine_cli(lower: CliLayer, higher: CliLayer) -> CliLayer { - CliLayer { - target: merge_option(lower.target, higher.target, combine_cli_target), - auth: higher.auth.or(lower.auth), - exec: merge_option(lower.exec, higher.exec, combine_cli_exec), - output: merge_option(lower.output, higher.output, combine_cli_output), - updates: merge_option(lower.updates, higher.updates, combine_cli_updates), - logging: higher.logging.or(lower.logging), - } -} - -fn combine_cli_target(_lower: CliTargetLayer, higher: CliTargetLayer) -> CliTargetLayer { - // The transport type is a scalar discriminant: the higher layer's choice wins. - higher -} - -fn combine_cli_exec(lower: CliExecLayer, higher: CliExecLayer) -> CliExecLayer { - CliExecLayer { - prevent_idle_sleep: higher.prevent_idle_sleep.or(lower.prevent_idle_sleep), - model: merge_option(lower.model, higher.model, combine_cli_exec_model), - agent: merge_option(lower.agent, higher.agent, combine_cli_exec_agent), - } -} - -fn combine_cli_exec_model( - lower: CliExecModelLayer, - higher: CliExecModelLayer, -) -> CliExecModelLayer { - CliExecModelLayer { - provider: higher.provider.or(lower.provider), - name: higher.name.or(lower.name), - } -} - -fn combine_cli_exec_agent( - lower: CliExecAgentLayer, - higher: CliExecAgentLayer, -) -> CliExecAgentLayer { - CliExecAgentLayer { - permissions: higher.permissions.or(lower.permissions), - mcps: merge_string_map_sticky(lower.mcps, higher.mcps), - } -} - -fn combine_cli_output(lower: CliOutputLayer, higher: CliOutputLayer) -> CliOutputLayer { - CliOutputLayer { - format: higher.format.or(lower.format), - verbosity: higher.verbosity.or(lower.verbosity), - } -} - -fn combine_cli_updates(lower: CliUpdatesLayer, higher: CliUpdatesLayer) -> CliUpdatesLayer { - CliUpdatesLayer { - check: higher.check.or(lower.check), - } -} - -// ------------------- server ------------------- - -fn combine_server(lower: ServerLayer, higher: ServerLayer) -> ServerLayer { - ServerLayer { - listen: merge_option(lower.listen, higher.listen, combine_listen), - api: higher.api.or(lower.api), - web: merge_option(lower.web, higher.web, combine_server_web), - auth: merge_option(lower.auth, higher.auth, combine_server_auth), - ip_allowlist: merge_option( - lower.ip_allowlist, - higher.ip_allowlist, - combine_server_ip_allowlist, - ), - storage: merge_option(lower.storage, higher.storage, combine_server_storage), - artifacts: merge_option(lower.artifacts, higher.artifacts, combine_server_artifacts), - slatedb: merge_option(lower.slatedb, higher.slatedb, combine_server_slatedb), - scheduler: merge_option(lower.scheduler, higher.scheduler, combine_server_scheduler), - logging: higher.logging.or(lower.logging), - integrations: merge_option( - lower.integrations, - higher.integrations, - combine_server_integrations, - ), - } -} - -fn combine_listen(_lower: ServerListenLayer, higher: ServerListenLayer) -> ServerListenLayer { - // Transport type is a scalar discriminant: replace whole. - higher -} - -fn combine_server_web(lower: ServerWebLayer, higher: ServerWebLayer) -> ServerWebLayer { - ServerWebLayer { - enabled: higher.enabled.or(lower.enabled), - url: higher.url.or(lower.url), - } -} - -fn combine_server_auth(lower: ServerAuthLayer, higher: ServerAuthLayer) -> ServerAuthLayer { - ServerAuthLayer { - methods: higher.methods.or(lower.methods), - github: higher.github.or(lower.github), - } -} - -fn combine_server_ip_allowlist( - lower: ServerIpAllowlistLayer, - higher: ServerIpAllowlistLayer, -) -> ServerIpAllowlistLayer { - ServerIpAllowlistLayer { - entries: higher.entries.or(lower.entries), - trusted_proxy_count: higher.trusted_proxy_count.or(lower.trusted_proxy_count), - } -} - -fn combine_server_storage( - lower: ServerStorageLayer, - higher: ServerStorageLayer, -) -> ServerStorageLayer { - ServerStorageLayer { - root: higher.root.or(lower.root), - } -} - -fn combine_server_artifacts( - lower: ServerArtifactsLayer, - higher: ServerArtifactsLayer, -) -> ServerArtifactsLayer { - ServerArtifactsLayer { - provider: higher.provider.or(lower.provider), - prefix: higher.prefix.or(lower.prefix), - local: higher.local.or(lower.local), - s3: higher.s3.or(lower.s3), - } -} - -fn combine_server_slatedb( - lower: ServerSlateDbLayer, - higher: ServerSlateDbLayer, -) -> ServerSlateDbLayer { - ServerSlateDbLayer { - provider: higher.provider.or(lower.provider), - prefix: higher.prefix.or(lower.prefix), - flush_interval: higher.flush_interval.or(lower.flush_interval), - local: higher.local.or(lower.local), - s3: higher.s3.or(lower.s3), - disk_cache: higher.disk_cache.or(lower.disk_cache), - } -} - -fn combine_server_scheduler( - lower: ServerSchedulerLayer, - higher: ServerSchedulerLayer, -) -> ServerSchedulerLayer { - ServerSchedulerLayer { - max_concurrent_runs: higher.max_concurrent_runs.or(lower.max_concurrent_runs), - } -} - -fn combine_server_integrations( - lower: ServerIntegrationsLayer, - higher: ServerIntegrationsLayer, -) -> ServerIntegrationsLayer { - ServerIntegrationsLayer { - github: merge_option(lower.github, higher.github, combine_github_integration), - slack: merge_option(lower.slack, higher.slack, combine_slack_integration), - discord: merge_option(lower.discord, higher.discord, combine_discord_integration), - teams: merge_option(lower.teams, higher.teams, combine_teams_integration), - } -} - -fn combine_github_integration( - lower: GithubIntegrationLayer, - higher: GithubIntegrationLayer, -) -> GithubIntegrationLayer { - GithubIntegrationLayer { - enabled: higher.enabled.or(lower.enabled), - strategy: higher.strategy.or(lower.strategy), - app_id: higher.app_id.or(lower.app_id), - client_id: higher.client_id.or(lower.client_id), - slug: higher.slug.or(lower.slug), - permissions: merge_string_map_sticky(lower.permissions, higher.permissions), - webhooks: merge_option( - lower.webhooks, - higher.webhooks, - combine_integration_webhooks, - ), - } -} - -fn combine_integration_webhooks( - lower: IntegrationWebhooksLayer, - higher: IntegrationWebhooksLayer, -) -> IntegrationWebhooksLayer { - IntegrationWebhooksLayer { - strategy: higher.strategy.or(lower.strategy), - ip_allowlist: merge_option( - lower.ip_allowlist, - higher.ip_allowlist, - combine_server_ip_allowlist_override, - ), - } -} - -fn combine_server_ip_allowlist_override( - lower: ServerIpAllowlistOverrideLayer, - higher: ServerIpAllowlistOverrideLayer, -) -> ServerIpAllowlistOverrideLayer { - ServerIpAllowlistOverrideLayer { - entries: higher.entries.or(lower.entries), - trusted_proxy_count: higher.trusted_proxy_count.or(lower.trusted_proxy_count), - } -} - -fn combine_slack_integration( - lower: SlackIntegrationLayer, - higher: SlackIntegrationLayer, -) -> SlackIntegrationLayer { - SlackIntegrationLayer { - enabled: higher.enabled.or(lower.enabled), - default_channel: higher.default_channel.or(lower.default_channel), - } -} - -fn combine_discord_integration( - lower: DiscordIntegrationLayer, - higher: DiscordIntegrationLayer, -) -> DiscordIntegrationLayer { - DiscordIntegrationLayer { - enabled: higher.enabled.or(lower.enabled), - } -} - -fn combine_teams_integration( - lower: TeamsIntegrationLayer, - higher: TeamsIntegrationLayer, -) -> TeamsIntegrationLayer { - TeamsIntegrationLayer { - enabled: higher.enabled.or(lower.enabled), - } -} - -#[cfg(test)] -mod tests { - use fabro_types::settings::InterpString; - use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; - - use super::*; - use crate::parse::parse_settings_layer; - - fn parse(input: &str) -> SettingsLayer { - parse_settings_layer(input).expect("fixture should parse") - } - - #[test] - fn run_inputs_replace_wholesale() { - let lower = parse( - r#" -[run.inputs] -a = "lower" -b = "lower" -"#, - ); - let higher = parse( - r#" -[run.inputs] -a = "higher" -"#, - ); - let merged = combine_files(lower, higher); - let inputs = merged.run.unwrap().inputs.unwrap(); - assert_eq!(inputs.len(), 1); - assert_eq!(inputs.get("a"), Some(&toml::Value::String("higher".into()))); - assert!(!inputs.contains_key("b"), "lower key should be gone"); - } - - #[test] - fn run_sandbox_env_merges_sticky() { - let lower = parse( - r#" -[run.sandbox.env] -A = "lower-a" -B = "lower-b" -"#, - ); - let higher = parse( - r#" -[run.sandbox.env] -A = "higher-a" -C = "higher-c" -"#, - ); - let merged = combine_files(lower, higher); - let sandbox = merged.run.unwrap().sandbox.unwrap(); - assert_eq!(sandbox.env.len(), 3); - } - - #[test] - fn run_prepare_steps_replaces_whole_list() { - let lower = parse( - r#" -[[run.prepare.steps]] -script = "lower-1" - -[[run.prepare.steps]] -script = "lower-2" -"#, - ); - let higher = parse( - r#" -[[run.prepare.steps]] -script = "higher-1" -"#, - ); - let merged = combine_files(lower, higher); - let steps = merged.run.unwrap().prepare.unwrap().steps; - assert_eq!(steps.len(), 1); - } - - #[test] - fn run_model_fallbacks_splice_inserts_inherited() { - let lower = parse( - r#" -[run.model] -fallbacks = ["openai", "gpt-5.4"] -"#, - ); - let higher = parse( - r#" -[run.model] -fallbacks = ["anthropic", "..."] -"#, - ); - let merged = combine_files(lower, higher); - let fallbacks = merged.run.unwrap().model.unwrap().fallbacks; - // ["anthropic", "openai", "gpt-5.4"] - assert_eq!(fallbacks.len(), 3); - } - - #[test] - fn hooks_replace_by_id_in_place() { - let lower = parse( - r#" -[[run.hooks]] -id = "shared" -event = "run_start" -script = "lower-script" -"#, - ); - let higher = parse( - r#" -[[run.hooks]] -id = "shared" -event = "run_start" -script = "higher-script" -"#, - ); - let merged = combine_files(lower, higher); - let hooks = merged.run.unwrap().hooks; - assert_eq!(hooks.len(), 1); - assert_eq!( - hooks[0] - .script - .as_ref() - .map(InterpString::as_source) - .as_deref(), - Some("higher-script") - ); - } - - #[test] - fn anonymous_hooks_append_after_merged_inherited() { - let lower = parse( - r#" -[[run.hooks]] -event = "run_start" -script = "lower-anon" -"#, - ); - let higher = parse( - r#" -[[run.hooks]] -event = "run_complete" -script = "higher-anon" -"#, - ); - let merged = combine_files(lower, higher); - let hooks = merged.run.unwrap().hooks; - assert_eq!(hooks.len(), 2); - assert_eq!( - hooks[0] - .script - .as_ref() - .map(InterpString::as_source) - .as_deref(), - Some("lower-anon") - ); - assert_eq!( - hooks[1] - .script - .as_ref() - .map(InterpString::as_source) - .as_deref(), - Some("higher-anon") - ); - } - - #[test] - fn notification_route_events_splice() { - let lower = parse( - r#" -[run.notifications.ops] -events = ["run.failed"] -"#, - ); - let higher = parse( - r#" -[run.notifications.ops] -events = ["...", "run.completed"] -"#, - ); - let merged = combine_files(lower, higher); - let run = merged.run.unwrap(); - let events = &run.notifications["ops"].events; - assert_eq!(events.len(), 2); - } - - #[test] - fn project_metadata_replaces_wholesale() { - let lower = parse( - r#" -[project.metadata] -a = "1" -b = "2" -"#, - ); - let higher = parse( - r#" -[project.metadata] -a = "replaced" -"#, - ); - let merged = combine_files(lower, higher); - let meta = merged.project.unwrap().metadata; - assert_eq!(meta.len(), 1); - assert_eq!(meta.get("a"), Some(&"replaced".to_string())); - } - - #[test] - fn cli_output_merges_by_field() { - let lower = parse( - r#" -[cli.output] -format = "text" -verbosity = "normal" -"#, - ); - let higher = parse( - r#" -[cli.output] -verbosity = "verbose" -"#, - ); - - let merged = combine_files(lower, higher); - let output = merged.cli.unwrap().output.unwrap(); - assert_eq!(output.format, Some(OutputFormat::Text)); - assert_eq!(output.verbosity, Some(OutputVerbosity::Verbose)); - } - - #[test] - fn cli_updates_merges_by_field() { - let lower = parse( - r" -[cli.updates] -check = true -", - ); - let higher = parse( - r#" -[cli.logging] -level = "debug" -"#, - ); - - let merged = combine_files(lower, higher); - let updates = merged.cli.unwrap().updates.unwrap(); - assert_eq!(updates.check, Some(true)); - } -} diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index f35282ed7..754cba237 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -2,7 +2,6 @@ mod cli; mod error; mod features; mod project; -mod resolver; mod run; mod server; mod workflow; @@ -15,45 +14,71 @@ use fabro_types::settings::{ }; pub use features::resolve_features; pub use project::resolve_project; -pub use resolver::Resolver; pub use run::resolve_run; pub use server::{dev_token_auth_enabled, resolve_server}; pub use workflow::resolve_workflow; +use crate::apply_builtin_defaults; +use crate::user::default_storage_dir; + pub fn resolve_storage_root(file: &SettingsLayer) -> InterpString { - Resolver::from_layer(file).storage_root() + let layer = apply_builtin_defaults(file.clone()); + layer + .server + .as_ref() + .and_then(|server| server.storage.as_ref()) + .and_then(|storage| storage.root.clone()) + .unwrap_or_else(|| default_interp(default_storage_dir())) } pub fn resolve_cli_from_file(file: &SettingsLayer) -> Result> { - Resolver::from_layer(file).cli() + let layer = apply_builtin_defaults(file.clone()); + let mut errors = Vec::new(); + let value = resolve_cli(&layer.cli.clone().unwrap_or_default(), &mut errors); + finish(value, errors) } pub fn resolve_server_from_file( file: &SettingsLayer, ) -> Result> { - Resolver::from_layer(file).server() + let layer = apply_builtin_defaults(file.clone()); + let mut errors = Vec::new(); + let value = resolve_server(&layer.server.clone().unwrap_or_default(), &mut errors); + finish(value, errors) } pub fn resolve_project_from_file( file: &SettingsLayer, ) -> Result> { - Resolver::from_layer(file).project() + let layer = apply_builtin_defaults(file.clone()); + let mut errors = Vec::new(); + let value = resolve_project(&layer.project.clone().unwrap_or_default(), &mut errors); + finish(value, errors) } pub fn resolve_features_from_file( file: &SettingsLayer, ) -> Result> { - Resolver::from_layer(file).features() + let layer = apply_builtin_defaults(file.clone()); + let mut errors = Vec::new(); + let value = resolve_features(&layer.features.clone().unwrap_or_default(), &mut errors); + finish(value, errors) } pub fn resolve_run_from_file(file: &SettingsLayer) -> Result> { - Resolver::from_layer(file).run() + let layer = apply_builtin_defaults(file.clone()); + let mut errors = Vec::new(); + let value = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors); + finish(value, errors) } pub fn resolve_workflow_from_file( file: &SettingsLayer, ) -> Result> { - Resolver::from_layer(file).workflow() + let layer = apply_builtin_defaults(file.clone()); + let mut errors = Vec::new(); + let value = resolve_workflow(&layer.workflow.clone().unwrap_or_default(), &mut errors); + finish(value, errors) } /// Render a list of [`ResolveError`]s as a single semicolon-separated message @@ -101,6 +126,14 @@ pub(crate) fn default_interp(path: impl AsRef) -> InterpString InterpString::parse(&path.as_ref().to_string_lossy()) } +fn finish(value: T, errors: Vec) -> Result> { + if errors.is_empty() { + Ok(value) + } else { + Err(errors) + } +} + #[cfg(test)] mod tests { use std::collections::HashMap; diff --git a/lib/crates/fabro-config/src/resolve/project.rs b/lib/crates/fabro-config/src/resolve/project.rs index 1bd5ee0e7..3fb6d0a6e 100644 --- a/lib/crates/fabro-config/src/resolve/project.rs +++ b/lib/crates/fabro-config/src/resolve/project.rs @@ -10,6 +10,6 @@ pub fn resolve_project(layer: &ProjectLayer, _errors: &mut Vec) -> .directory .clone() .expect("defaults.toml should provide project.directory"), - metadata: layer.metadata.clone(), + metadata: layer.metadata.clone().into_inner(), } } diff --git a/lib/crates/fabro-config/src/resolve/resolver.rs b/lib/crates/fabro-config/src/resolve/resolver.rs deleted file mode 100644 index 893aa41ac..000000000 --- a/lib/crates/fabro-config/src/resolve/resolver.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Cache builtin defaults across multiple per-namespace resolutions. -//! -//! [`resolve_storage_root`] and the per-namespace `resolve_*_from_file` -//! helpers each call [`apply_builtin_defaults`], which clones both the input -//! layer and the embedded defaults layer before merging them. Callers that -//! need more than one namespace would otherwise pay that cost N times. -//! -//! [`Resolver`] applies defaults once on construction, then exposes per- -//! namespace methods that work against the materialized layer. It is the -//! shared backend for the standalone `resolve_*_from_file` helpers and the -//! preferred entrypoint when more than one namespace is needed. - -use fabro_types::settings::{ - CliNamespace, FeaturesNamespace, InterpString, ProjectNamespace, RunNamespace, ServerNamespace, - SettingsLayer, WorkflowNamespace, -}; - -use super::{ - ResolveError, default_interp, resolve_cli, resolve_features, resolve_project, resolve_run, - resolve_server, resolve_workflow, -}; -use crate::apply_builtin_defaults; -use crate::user::default_storage_dir; - -pub struct Resolver { - layer: SettingsLayer, -} - -impl Resolver { - #[must_use] - pub fn from_layer(layer: &SettingsLayer) -> Self { - Self { - layer: apply_builtin_defaults(layer.clone()), - } - } - - pub fn cli(&self) -> Result> { - let mut errors = Vec::new(); - let value = self.cli_into(&mut errors); - finish(value, errors) - } - - pub fn server(&self) -> Result> { - let mut errors = Vec::new(); - let value = self.server_into(&mut errors); - finish(value, errors) - } - - pub fn project(&self) -> Result> { - let mut errors = Vec::new(); - let value = self.project_into(&mut errors); - finish(value, errors) - } - - pub fn features(&self) -> Result> { - let mut errors = Vec::new(); - let value = self.features_into(&mut errors); - finish(value, errors) - } - - pub fn run(&self) -> Result> { - let mut errors = Vec::new(); - let value = self.run_into(&mut errors); - finish(value, errors) - } - - pub fn workflow(&self) -> Result> { - let mut errors = Vec::new(); - let value = self.workflow_into(&mut errors); - finish(value, errors) - } - - /// Resolved storage root, defaulting to [`default_storage_dir`] when the - /// input layer doesn't pin one. - #[must_use] - pub fn storage_root(&self) -> InterpString { - self.layer - .server - .as_ref() - .and_then(|server| server.storage.as_ref()) - .and_then(|storage| storage.root.clone()) - .unwrap_or_else(|| default_interp(default_storage_dir())) - } - - pub fn cli_into(&self, errors: &mut Vec) -> CliNamespace { - let layer = self.layer.cli.clone().unwrap_or_default(); - resolve_cli(&layer, errors) - } - - pub fn server_into(&self, errors: &mut Vec) -> ServerNamespace { - let layer = self.layer.server.clone().unwrap_or_default(); - resolve_server(&layer, errors) - } - - pub fn project_into(&self, errors: &mut Vec) -> ProjectNamespace { - let layer = self.layer.project.clone().unwrap_or_default(); - resolve_project(&layer, errors) - } - - pub fn features_into(&self, errors: &mut Vec) -> FeaturesNamespace { - let layer = self.layer.features.clone().unwrap_or_default(); - resolve_features(&layer, errors) - } - - pub fn run_into(&self, errors: &mut Vec) -> RunNamespace { - let layer = self.layer.run.clone().unwrap_or_default(); - resolve_run(&layer, errors) - } - - pub fn workflow_into(&self, errors: &mut Vec) -> WorkflowNamespace { - let layer = self.layer.workflow.clone().unwrap_or_default(); - resolve_workflow(&layer, errors) - } -} - -fn finish(value: T, errors: Vec) -> Result> { - if errors.is_empty() { - Ok(value) - } else { - Err(errors) - } -} diff --git a/lib/crates/fabro-config/src/resolve/run.rs b/lib/crates/fabro-config/src/resolve/run.rs index f2505fd23..8c918685f 100644 --- a/lib/crates/fabro-config/src/resolve/run.rs +++ b/lib/crates/fabro-config/src/resolve/run.rs @@ -19,7 +19,7 @@ pub fn resolve_run(layer: &RunLayer, errors: &mut Vec) -> RunNames RunNamespace { goal: resolve_goal(layer.goal.as_ref()), working_dir: layer.working_dir.clone(), - metadata: layer.metadata.clone(), + metadata: layer.metadata.clone().into_inner(), inputs: layer.inputs.clone().unwrap_or_default(), model: resolve_model(layer.model.as_ref()), git: resolve_git(layer.git.as_ref()), @@ -164,7 +164,7 @@ fn resolve_sandbox( devcontainer: sandbox .devcontainer .expect("defaults.toml should provide run.sandbox.devcontainer"), - env: sandbox.env.clone(), + env: sandbox.env.clone().into_inner(), local: resolve_local_sandbox(sandbox), daytona: sandbox.daytona.as_ref().map(resolve_daytona), } @@ -186,7 +186,7 @@ fn resolve_local_sandbox(sandbox: &RunSandboxLayer) -> LocalSandboxSettings { fn resolve_daytona(daytona: &DaytonaSandboxLayer) -> DaytonaSettings { DaytonaSettings { auto_stop_interval: daytona.auto_stop_interval, - labels: daytona.labels.clone(), + labels: daytona.labels.clone().into_inner(), snapshot: daytona.snapshot.as_ref().and_then(|snapshot| { snapshot.name.as_ref().map(|name| DaytonaSnapshotSettings { name: name.clone(), diff --git a/lib/crates/fabro-config/src/resolve/server.rs b/lib/crates/fabro-config/src/resolve/server.rs index dc73c8da3..54cbf8ed9 100644 --- a/lib/crates/fabro-config/src/resolve/server.rs +++ b/lib/crates/fabro-config/src/resolve/server.rs @@ -29,7 +29,7 @@ pub fn dev_token_auth_enabled(layer: &SettingsLayer) -> bool { pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> ServerNamespace { let storage = resolve_storage(layer.storage.as_ref()); let listen = resolve_listen(layer.listen.as_ref(), errors); - let web = resolve_web(layer.api.as_ref(), layer.web.as_ref()); + let web = resolve_web(layer.web.as_ref()); let auth = resolve_auth(layer.auth.as_ref(), errors); let ip_allowlist = resolve_ip_allowlist(layer.ip_allowlist.as_ref(), errors); let integrations = resolve_integrations(layer.integrations.as_ref(), errors); @@ -97,7 +97,7 @@ fn resolve_listen( } } -fn resolve_web(_api: Option<&ServerApiLayer>, layer: Option<&ServerWebLayer>) -> ServerWebSettings { +fn resolve_web(layer: Option<&ServerWebLayer>) -> ServerWebSettings { let layer = layer.expect("defaults.toml should provide server.web defaults"); ServerWebSettings { @@ -463,7 +463,7 @@ fn resolve_integrations( app_id: github.app_id.clone(), client_id: github.client_id.clone(), slug: github.slug.clone(), - permissions: github.permissions.clone(), + permissions: github.permissions.clone().into_inner(), webhooks: github.webhooks.as_ref().map(|webhooks| { resolve_github_webhooks(webhooks, "server.integrations.github.webhooks", errors) }), diff --git a/lib/crates/fabro-config/src/resolve/workflow.rs b/lib/crates/fabro-config/src/resolve/workflow.rs index 5bb5bc139..272c0437f 100644 --- a/lib/crates/fabro-config/src/resolve/workflow.rs +++ b/lib/crates/fabro-config/src/resolve/workflow.rs @@ -13,6 +13,6 @@ pub fn resolve_workflow( .graph .clone() .expect("defaults.toml should provide workflow.graph"), - metadata: layer.metadata.clone(), + metadata: layer.metadata.clone().into_inner(), } } diff --git a/lib/crates/fabro-config/tests/combine.rs b/lib/crates/fabro-config/tests/combine.rs new file mode 100644 index 000000000..55aff3b1c --- /dev/null +++ b/lib/crates/fabro-config/tests/combine.rs @@ -0,0 +1,282 @@ +use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; +use fabro_types::settings::run::StringOrSplice; +use fabro_types::settings::{Combine, InterpString, SettingsLayer}; + +fn parse(input: &str) -> SettingsLayer { + fabro_config::parse_settings_layer(input).expect("fixture should parse") +} + +#[test] +fn run_inputs_replace_wholesale() { + let lower = parse( + r#" +[run.inputs] +a = "lower" +b = "lower" +"#, + ); + let higher = parse( + r#" +[run.inputs] +a = "higher" +"#, + ); + let merged = higher.combine(lower); + let inputs = merged.run.unwrap().inputs.unwrap(); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs.get("a"), Some(&toml::Value::String("higher".into()))); + assert!(!inputs.contains_key("b"), "lower key should be gone"); +} + +#[test] +fn run_sandbox_env_merges_sticky() { + let lower = parse( + r#" +[run.sandbox.env] +A = "lower-a" +B = "lower-b" +"#, + ); + let higher = parse( + r#" +[run.sandbox.env] +A = "higher-a" +C = "higher-c" +"#, + ); + let merged = higher.combine(lower); + let sandbox = merged.run.unwrap().sandbox.unwrap(); + assert_eq!(sandbox.env.len(), 3); + assert_eq!( + sandbox.env.get("A").map(InterpString::as_source).as_deref(), + Some("higher-a") + ); + assert_eq!( + sandbox.env.get("B").map(InterpString::as_source).as_deref(), + Some("lower-b") + ); +} + +#[test] +fn run_prepare_steps_replaces_whole_list() { + let lower = parse( + r#" +[[run.prepare.steps]] +script = "lower-1" + +[[run.prepare.steps]] +script = "lower-2" +"#, + ); + let higher = parse( + r#" +[[run.prepare.steps]] +script = "higher-1" +"#, + ); + let merged = higher.combine(lower); + let steps = merged.run.unwrap().prepare.unwrap().steps; + assert_eq!(steps.len(), 1); +} + +#[test] +fn run_model_fallbacks_splice_inserts_inherited() { + let lower = parse( + r#" +[run.model] +fallbacks = ["openai", "gpt-5.4"] +"#, + ); + let higher = parse( + r#" +[run.model] +fallbacks = ["anthropic", "..."] +"#, + ); + let merged = higher.combine(lower); + let fallbacks = merged.run.unwrap().model.unwrap().fallbacks; + assert_eq!(fallbacks.len(), 3); +} + +#[test] +fn hooks_replace_by_id_in_place() { + let lower = parse( + r#" +[[run.hooks]] +id = "shared" +event = "run_start" +script = "lower-script" +"#, + ); + let higher = parse( + r#" +[[run.hooks]] +id = "shared" +event = "run_start" +script = "higher-script" +"#, + ); + let merged = higher.combine(lower); + let hooks = merged.run.unwrap().hooks; + assert_eq!(hooks.len(), 1); + assert_eq!( + hooks[0] + .script + .as_ref() + .map(InterpString::as_source) + .as_deref(), + Some("higher-script") + ); +} + +#[test] +fn anonymous_hooks_append_after_merged_inherited() { + let lower = parse( + r#" +[[run.hooks]] +event = "run_start" +script = "lower-anon" +"#, + ); + let higher = parse( + r#" +[[run.hooks]] +event = "run_complete" +script = "higher-anon" +"#, + ); + let merged = higher.combine(lower); + let hooks = merged.run.unwrap().hooks; + assert_eq!(hooks.len(), 2); + assert_eq!( + hooks[0] + .script + .as_ref() + .map(InterpString::as_source) + .as_deref(), + Some("lower-anon") + ); + assert_eq!( + hooks[1] + .script + .as_ref() + .map(InterpString::as_source) + .as_deref(), + Some("higher-anon") + ); +} + +#[test] +fn notification_route_events_splice() { + let lower = parse( + r#" +[run.notifications.ops] +enabled = true +provider = "slack" +events = ["run.failed"] +"#, + ); + let higher = parse( + r#" +[run.notifications.ops] +events = ["...", "run.completed"] +"#, + ); + let merged = higher.combine(lower); + let run = merged.run.unwrap(); + let route = &run.notifications["ops"]; + assert_eq!(route.enabled, Some(true)); + assert_eq!(route.provider.as_deref(), Some("slack")); + assert_eq!(route.events, vec![ + StringOrSplice::Value("run.failed".to_string()), + StringOrSplice::Value("run.completed".to_string()), + ]); +} + +#[test] +fn project_metadata_replaces_wholesale() { + let lower = parse( + r#" +[project.metadata] +a = "1" +b = "2" +"#, + ); + let higher = parse( + r#" +[project.metadata] +a = "replaced" +"#, + ); + let merged = higher.combine(lower); + let meta = merged.project.unwrap().metadata; + assert_eq!(meta.len(), 1); + assert_eq!(meta.get("a"), Some(&"replaced".to_string())); +} + +#[test] +fn cli_output_merges_by_field() { + let lower = parse( + r#" +[cli.output] +format = "text" +verbosity = "normal" +"#, + ); + let higher = parse( + r#" +[cli.output] +verbosity = "verbose" +"#, + ); + + let merged = higher.combine(lower); + let output = merged.cli.unwrap().output.unwrap(); + assert_eq!(output.format, Some(OutputFormat::Text)); + assert_eq!(output.verbosity, Some(OutputVerbosity::Verbose)); +} + +#[test] +fn cli_updates_merges_by_field() { + let lower = parse( + r" +[cli.updates] +check = true +", + ); + let higher = parse( + r#" +[cli.logging] +level = "debug" +"#, + ); + + let merged = higher.combine(lower); + let updates = merged.cli.unwrap().updates.unwrap(); + assert_eq!(updates.check, Some(true)); +} + +#[test] +fn whole_replace_option_subtable_does_not_inherit_fallback_fields() { + let lower = parse( + r#" +[server.artifacts.s3] +bucket = "lower-bucket" +region = "us-east-1" +"#, + ); + let higher = parse( + r#" +[server.artifacts.s3] +bucket = "higher-bucket" +"#, + ); + + let merged = higher.combine(lower); + let s3 = merged.server.unwrap().artifacts.unwrap().s3.unwrap(); + assert_eq!( + s3.bucket.map(|bucket| bucket.as_source()), + Some("higher-bucket".to_string()) + ); + assert_eq!(s3.region, None); +} diff --git a/lib/crates/fabro-config/tests/resolve_root.rs b/lib/crates/fabro-config/tests/resolve_root.rs index d4620053e..f0f79c275 100644 --- a/lib/crates/fabro-config/tests/resolve_root.rs +++ b/lib/crates/fabro-config/tests/resolve_root.rs @@ -1,4 +1,5 @@ use fabro_config::parse_settings_layer; +use fabro_types::settings::run::RunMode; use fabro_types::settings::{InterpString, SettingsLayer}; fn parse(source: &str) -> SettingsLayer { @@ -102,3 +103,92 @@ name = "gpt-5" Some("gpt-5".to_string()) ); } + +#[test] +fn workflow_settings_resolve_defaults_and_expose_fields() { + let settings = SettingsLayer::default(); + let resolved = + fabro_config::WorkflowSettings::from_layer(&settings).expect("defaults should resolve"); + + assert_eq!(resolved.project.directory, "."); + assert_eq!(resolved.workflow.graph, "workflow.fabro"); + assert_eq!(resolved.run.execution.mode, RunMode::Normal); +} + +#[test] +fn workflow_settings_combine_labels_with_later_namespaces_winning() { + let settings = parse( + r#" +_version = 1 + +[project.metadata] +project = "yes" +shared = "project" + +[workflow.metadata] +workflow = "yes" +shared = "workflow" + +[run.metadata] +run = "yes" +shared = "run" +"#, + ); + + let labels = fabro_config::WorkflowSettings::from_layer(&settings) + .expect("workflow settings should resolve") + .combined_labels(); + + assert_eq!(labels.get("project").map(String::as_str), Some("yes")); + assert_eq!(labels.get("workflow").map(String::as_str), Some("yes")); + assert_eq!(labels.get("run").map(String::as_str), Some("yes")); + assert_eq!(labels.get("shared").map(String::as_str), Some("run")); +} + +#[test] +fn workflow_settings_report_invalid_run_sandbox_provider() { + let settings = parse( + r#" +_version = 1 + +[run.sandbox] +provider = "not-a-provider" +"#, + ); + + let errors = fabro_config::WorkflowSettings::from_layer(&settings) + .expect_err("invalid workflow settings should fail"); + + assert!(errors.iter().any(|error| { + matches!( + error, + fabro_config::ResolveError::Invalid { path, .. } if path == "run.sandbox.provider" + ) + })); +} + +#[test] +fn workflow_settings_accumulate_multiple_run_errors() { + let settings = parse( + r#" +_version = 1 + +[run.sandbox] +provider = "not-a-provider" + +[[run.prepare.steps]] +script = "echo hi" +command = ["echo", "hi"] +"#, + ); + + let rendered = fabro_config::WorkflowSettings::from_layer(&settings) + .expect_err("invalid workflow settings should fail") + .into_iter() + .map(|error| error.to_string()) + .collect::>() + .join("\n"); + + assert!(rendered.contains("run.sandbox.provider")); + assert!(rendered.contains("run.prepare.steps[0]")); +} diff --git a/lib/crates/fabro-install/src/lib.rs b/lib/crates/fabro-install/src/lib.rs index 7cf5c222f..29d03911a 100644 --- a/lib/crates/fabro-install/src/lib.rs +++ b/lib/crates/fabro-install/src/lib.rs @@ -6,17 +6,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use fabro_config::{Storage, envfile}; use fabro_vault::{SecretType as VaultSecretType, Vault}; -use ring::rand::SystemRandom; -use ring::signature::{Ed25519KeyPair, KeyPair as _}; - -const ED25519_SPKI_PREFIX: [u8; 12] = [ - 0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65, 0x70, 0x03, 0x21, 0x00, -]; -const ED25519_PUBLIC_KEY_LEN: usize = 32; pub struct PendingSettingsWrite<'a> { pub path: &'a Path, @@ -95,47 +86,6 @@ impl std::error::Error for PersistInstallOutputsError { } } -fn pem_encode(label: &str, bytes: &[u8]) -> String { - let body = BASE64_STANDARD.encode(bytes); - let mut pem = String::new(); - pem.push_str("-----BEGIN "); - pem.push_str(label); - pem.push_str("-----\n"); - for chunk in body.as_bytes().chunks(64) { - pem.push_str(std::str::from_utf8(chunk).expect("base64 output should be valid UTF-8")); - pem.push('\n'); - } - pem.push_str("-----END "); - pem.push_str(label); - pem.push_str("-----\n"); - pem -} - -fn ed25519_public_key_spki(public_key: &[u8]) -> Result> { - anyhow::ensure!( - public_key.len() == ED25519_PUBLIC_KEY_LEN, - "generated Ed25519 public key had unexpected length" - ); - - let mut spki = Vec::with_capacity(ED25519_SPKI_PREFIX.len() + public_key.len()); - spki.extend_from_slice(&ED25519_SPKI_PREFIX); - spki.extend_from_slice(public_key); - Ok(spki) -} - -pub fn generate_jwt_keypair() -> Result<(String, String)> { - let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()) - .map_err(|_| anyhow::anyhow!("failed to generate Ed25519 keypair"))?; - let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()) - .map_err(|_| anyhow::anyhow!("failed to parse generated Ed25519 keypair"))?; - let public_der = ed25519_public_key_spki(keypair.public_key().as_ref())?; - - Ok(( - pem_encode("PRIVATE KEY", pkcs8.as_ref()), - pem_encode("PUBLIC KEY", &public_der), - )) -} - pub fn default_web_url() -> String { "http://127.0.0.1:32276".to_string() } diff --git a/lib/crates/fabro-macros/src/lib.rs b/lib/crates/fabro-macros/src/lib.rs index 2d1344ab6..94c9b38a1 100644 --- a/lib/crates/fabro-macros/src/lib.rs +++ b/lib/crates/fabro-macros/src/lib.rs @@ -2,7 +2,7 @@ use proc_macro::TokenStream; use quote::quote; use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; -use syn::{Ident, ItemFn, LitStr, Token, parenthesized, parse_macro_input}; +use syn::{DeriveInput, Ident, ItemFn, LitStr, Token, parenthesized, parse_macro_input}; enum E2eRequirement { Twin, @@ -146,3 +146,46 @@ pub fn e2e_test(attr: TokenStream, item: TokenStream) -> TokenStream { } .into() } + +#[proc_macro_derive(Combine)] +pub fn derive_combine(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + impl_combine(&input) +} + +fn impl_combine(ast: &DeriveInput) -> TokenStream { + let name = &ast.ident; + let fields = match ast.data { + syn::Data::Struct(syn::DataStruct { + fields: syn::Fields::Named(ref fields), + .. + }) => &fields.named, + _ => { + return syn::Error::new_spanned( + ast, + "Combine can only be derived for structs with named fields", + ) + .to_compile_error() + .into(); + } + }; + let (impl_generics, ty_generics, where_clause) = ast.generics.split_for_impl(); + + let combines = fields.iter().map(|field| { + let name = &field.ident; + quote! { + #name: crate::settings::Combine::combine(self.#name, other.#name) + } + }); + + quote! { + impl #impl_generics crate::settings::Combine for #name #ty_generics #where_clause { + fn combine(self, other: Self) -> Self { + Self { + #(#combines),* + } + } + } + } + .into() +} diff --git a/lib/crates/fabro-sandbox/src/local.rs b/lib/crates/fabro-sandbox/src/local.rs index e26635859..af6d54ddd 100644 --- a/lib/crates/fabro-sandbox/src/local.rs +++ b/lib/crates/fabro-sandbox/src/local.rs @@ -227,7 +227,9 @@ impl Sandbox for LocalSandbox { if let Some(extra) = env_vars { for (k, v) in extra { - filtered_env.push((k.clone(), v.clone())); + if !Self::should_filter_env_var(k) { + filtered_env.push((k.clone(), v.clone())); + } } } @@ -547,6 +549,7 @@ async fn sigterm_then_kill(child: &mut Child) { reason = "sandbox tests stage fixtures with sync std::fs writes/reads" )] mod tests { + use std::collections::HashMap; use std::path::PathBuf; use super::*; @@ -705,6 +708,24 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + #[tokio::test] + async fn exec_command_filters_sensitive_explicit_env_vars() { + let dir = temp_dir(); + let env = LocalSandbox::new(dir.clone()); + let extra = HashMap::from([ + ("FABRO_WORKER_TOKEN".to_string(), "leaked".to_string()), + ("MY_VAR".to_string(), "ok".to_string()), + ]); + let result = env + .exec_command("env", 5000, None, Some(&extra), None) + .await + .unwrap(); + + assert!(!result.stdout.contains("FABRO_WORKER_TOKEN=leaked")); + assert!(result.stdout.contains("MY_VAR=ok")); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn env_var_filtering() { assert!(LocalSandbox::should_filter_env_var("OPENAI_API_KEY")); @@ -713,6 +734,8 @@ mod tests { assert!(LocalSandbox::should_filter_env_var("AWS_SECRET")); assert!(LocalSandbox::should_filter_env_var("AUTH_TOKEN")); assert!(LocalSandbox::should_filter_env_var("MY_CREDENTIAL")); + assert!(LocalSandbox::should_filter_env_var("FABRO_WORKER_TOKEN")); + assert!(LocalSandbox::should_filter_env_var("SESSION_SECRET")); // Case insensitive assert!(LocalSandbox::should_filter_env_var("my_api_key")); assert!(LocalSandbox::should_filter_env_var("Some_Secret")); diff --git a/lib/crates/fabro-server/src/auth/keys.rs b/lib/crates/fabro-server/src/auth/keys.rs index f871821ab..ece88cd70 100644 --- a/lib/crates/fabro-server/src/auth/keys.rs +++ b/lib/crates/fabro-server/src/auth/keys.rs @@ -5,6 +5,7 @@ use sha2::Sha256; const COOKIE_KEY_INFO: &[u8] = b"fabro-cookie-v1"; const JWT_KEY_INFO: &[u8] = b"fabro-jwt-hs256-v1"; +const WORKER_JWT_KEY_INFO: &[u8] = b"fabro-worker-jwt-v1"; const MIN_MASTER_BYTES: usize = 32; #[derive(Clone, Debug, PartialEq, Eq)] @@ -42,6 +43,10 @@ pub(crate) fn derive_jwt_key(master: &[u8]) -> Result(master, JWT_KEY_INFO)?)) } +pub(crate) fn derive_worker_jwt_key(master: &[u8]) -> Result<[u8; 32], KeyDeriveError> { + derive_bytes::<32>(master, WORKER_JWT_KEY_INFO) +} + fn derive_bytes(master: &[u8], info: &[u8]) -> Result<[u8; N], KeyDeriveError> { validate_master(master)?; @@ -69,7 +74,7 @@ fn validate_master(master: &[u8]) -> Result<(), KeyDeriveError> { mod tests { use cookie::{Cookie, CookieJar}; - use super::{KeyDeriveError, derive_cookie_key, derive_jwt_key}; + use super::{KeyDeriveError, derive_cookie_key, derive_jwt_key, derive_worker_jwt_key}; #[test] fn derives_same_cookie_key_for_same_master() { @@ -93,6 +98,18 @@ mod tests { assert_eq!(jwt_key.as_bytes().len(), 32); } + #[test] + fn derives_different_worker_and_user_jwt_subkeys() { + let master = [0x61; 32]; + + let user_key = derive_jwt_key(&master).expect("jwt derivation should succeed"); + let worker_key = + derive_worker_jwt_key(&master).expect("worker jwt derivation should succeed"); + + assert_ne!(user_key.as_bytes(), worker_key); + assert_eq!(worker_key.len(), 32); + } + #[test] fn rejects_empty_master_secret() { let err = derive_cookie_key(&[]).expect_err("empty secret should fail"); @@ -108,6 +125,21 @@ mod tests { }); } + #[test] + fn worker_derivation_rejects_empty_master_secret() { + let err = derive_worker_jwt_key(&[]).expect_err("empty secret should fail"); + assert_eq!(err, KeyDeriveError::Empty); + } + + #[test] + fn worker_derivation_rejects_short_master_secret() { + let err = derive_worker_jwt_key(&[0x61; 31]).expect_err("short secret should fail"); + assert_eq!(err, KeyDeriveError::TooShort { + got_bytes: 31, + min_bytes: 32, + }); + } + #[test] fn derived_cookie_key_round_trips_private_cookie() { let key = derive_cookie_key(&[0x61; 32]).expect("derivation should succeed"); diff --git a/lib/crates/fabro-server/src/auth/mod.rs b/lib/crates/fabro-server/src/auth/mod.rs index 14efe5d1e..709efe7b8 100644 --- a/lib/crates/fabro-server/src/auth/mod.rs +++ b/lib/crates/fabro-server/src/auth/mod.rs @@ -8,5 +8,7 @@ pub(crate) use cli_flow::web_routes; pub(crate) use fabro_store::{AuthCode, ConsumeOutcome, RefreshToken}; pub use github_endpoints::GithubEndpoints; pub(crate) use jwt::{JwtError, JwtSubject, issue, verify}; -pub(crate) use keys::{JwtSigningKey, KeyDeriveError, derive_cookie_key, derive_jwt_key}; +pub(crate) use keys::{ + JwtSigningKey, KeyDeriveError, derive_cookie_key, derive_jwt_key, derive_worker_jwt_key, +}; pub(crate) use translate::{auth_translation_middleware, demo_routing_middleware}; diff --git a/lib/crates/fabro-server/src/diagnostics.rs b/lib/crates/fabro-server/src/diagnostics.rs index 926f4c9b3..7ea025eee 100644 --- a/lib/crates/fabro-server/src/diagnostics.rs +++ b/lib/crates/fabro-server/src/diagnostics.rs @@ -540,30 +540,6 @@ fn check_crypto(state: &AppState) -> CheckResult { } } - if let Some(raw) = state.server_secret("FABRO_JWT_PUBLIC_KEY") { - if let Err(err) = decode_pem_value("FABRO_JWT_PUBLIC_KEY", &raw).and_then(|pem| { - jsonwebtoken::DecodingKey::from_ed_pem(pem.as_bytes()) - .map(|_| ()) - .map_err(|e| format!("invalid JWT public key: {e}")) - }) { - errors.push(err); - } else { - details.push(CheckDetail::new("FABRO_JWT_PUBLIC_KEY valid".to_string())); - } - } - - if let Some(raw) = state.server_secret("FABRO_JWT_PRIVATE_KEY") { - if let Err(err) = decode_pem_value("FABRO_JWT_PRIVATE_KEY", &raw).and_then(|pem| { - jsonwebtoken::EncodingKey::from_ed_pem(pem.as_bytes()) - .map(|_| ()) - .map_err(|e| format!("invalid JWT private key: {e}")) - }) { - errors.push(err); - } else { - details.push(CheckDetail::new("FABRO_JWT_PRIVATE_KEY valid".to_string())); - } - } - if errors.is_empty() { CheckResult { name: "Crypto".to_string(), diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs index e1dbe6969..301d7a943 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/fabro-server/src/install.rs @@ -18,9 +18,8 @@ use fabro_config::bind::{Bind, BindRequest}; use fabro_config::envfile::EnvFileUpdate; use fabro_install::{ InstallListenConfig, OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV, - PendingSettingsWrite, VaultSecretWrite, generate_jwt_keypair, merge_server_settings, - persist_install_outputs_direct, write_github_app_settings, write_object_store_settings, - write_token_settings, + PendingSettingsWrite, VaultSecretWrite, merge_server_settings, persist_install_outputs_direct, + write_github_app_settings, write_object_store_settings, write_token_settings, }; use fabro_model::Provider; use fabro_store::ArtifactStore; @@ -43,7 +42,7 @@ use zeroize::Zeroizing; use crate::error::ApiError; use crate::serve::{self, DEFAULT_TCP_PORT}; -use crate::server_secrets::ServerSecrets; +use crate::server_secrets::{ServerSecrets, process_env_snapshot}; use crate::{security_headers, static_files}; #[derive(Clone)] @@ -113,6 +112,11 @@ impl InstallAppState { reason = "test-only: set FABRO_TEST_IN_MEMORY_STORE to a constant so install tests \ don't hang on real S3; parallel tests race on the same value" )] + #[expect( + clippy::disallowed_methods, + reason = "test-only: forces the in-memory object store for install tests so they \ + don't contact real S3" + )] pub fn for_test_with_paths(token: &str, storage_dir: &Path, config_path: &Path) -> Self { // Install-flow tests verify persistence and redaction, not S3 // reachability. Force the in-memory object store shortcut so @@ -973,7 +977,8 @@ async fn validate_install_object_store_selection( let server_env_path = Storage::new(state.storage_dir.as_ref()) .runtime_directory() .env_path(); - let server_secrets = ServerSecrets::load(server_env_path).map_err(|err| err.to_string())?; + let server_secrets = ServerSecrets::load(server_env_path, process_env_snapshot()) + .map_err(|err| err.to_string())?; let build_options = serve::ObjectStoreBuildOptions { client_options, retry_config: RetryConfig { @@ -1373,23 +1378,7 @@ async fn post_install_finish( }; let session_secret = session_secret::generate_session_secret(); - let (jwt_private_pem, jwt_public_pem) = match generate_jwt_keypair() { - Ok(value) => value, - Err(err) => { - return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); - } - }; - server_env_writes.extend([ - make_env_write( - "FABRO_JWT_PRIVATE_KEY", - BASE64_STANDARD.encode(jwt_private_pem.as_bytes()), - ), - make_env_write( - "FABRO_JWT_PUBLIC_KEY", - BASE64_STANDARD.encode(jwt_public_pem.as_bytes()), - ), - make_env_write("SESSION_SECRET", session_secret), - ]); + server_env_writes.push(make_env_write("SESSION_SECRET", session_secret)); if let Some(token) = dev_token.as_ref() { server_env_writes.push(make_env_write("FABRO_DEV_TOKEN", token.clone())); } @@ -1968,6 +1957,7 @@ async fn wait_for_shutdown(mut shutdown_rx: watch::Receiver) { #[cfg(test)] mod tests { + use std::collections::HashMap; use std::io; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; @@ -2108,7 +2098,7 @@ AWS_SESSION_TOKEN=ambient-session\n\ AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/fabro-web-identity-token\n", ) .unwrap(); - let server_secrets = ServerSecrets::with_env_lookup(env_path.clone(), |_| None).unwrap(); + let server_secrets = ServerSecrets::load(env_path.clone(), HashMap::new()).unwrap(); let manual_credentials = InstallAwsCredentialPair::new("submitted-access", "submitted-secret"); diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index b332cb797..5108282cd 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -144,7 +144,7 @@ where .unwrap_or_else(|| "fabro-server".to_string()) } -fn session_secret_key_error(err: &KeyDeriveError) -> anyhow::Error { +pub(crate) fn session_secret_key_error(err: &KeyDeriveError) -> anyhow::Error { match err { KeyDeriveError::Empty => { anyhow!( @@ -182,7 +182,7 @@ fn config_allows_run_auth_method(config: &ConfiguredAuth, method: RunAuthMethod) } } -fn bearer_token(parts: &Parts) -> Option> { +pub(crate) fn bearer_token(parts: &Parts) -> Option> { let value = parts.headers.get(header::AUTHORIZATION)?; let Ok(value) = value.to_str() else { return Some(Err(ApiError::unauthorized())); diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index 55b6e3db0..e43bab386 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -31,7 +31,12 @@ pub mod security_headers; pub mod serve; pub mod server; mod server_secrets; +mod spawn_env; +mod startup; pub mod static_files; pub mod web_auth; +mod worker_token; pub use error::{ApiError, Error, Result}; +pub use server_secrets::process_env_snapshot; +pub use startup::validate_startup; diff --git a/lib/crates/fabro-server/src/run_files.rs b/lib/crates/fabro-server/src/run_files.rs index 9bd1e7655..90400d42a 100644 --- a/lib/crates/fabro-server/src/run_files.rs +++ b/lib/crates/fabro-server/src/run_files.rs @@ -43,7 +43,7 @@ use tokio::sync::{Mutex, watch}; use crate::error::ApiError; use crate::jwt_auth::AuthenticatedService; use crate::run_files_security::{RunFilesMetrics, is_sensitive}; -use crate::server::{AppState, parse_run_id_path_pub}; +use crate::server::{AppState, parse_run_id_path}; /// Per-file cap: 256 KiB OR 20k lines (whichever comes first). pub(crate) const PER_FILE_BYTES_CAP: u64 = 256 * 1024; @@ -190,7 +190,7 @@ pub async fn list_run_files( Query(params): Query, ) -> Response { // 1. Parse run_id. - let id = match parse_run_id_path_pub(&id) { + let id = match parse_run_id_path(&id) { Ok(id) => id, Err(resp) => return resp, }; diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index cae7fd3e3..4186baf1a 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -5,7 +5,6 @@ use std::sync::Arc; use anyhow::{Result, anyhow, bail}; use fabro_api::types; use fabro_config::effective_settings::EffectiveSettingsLayers; -use fabro_config::merge::combine_files; use fabro_config::project::resolve_working_directory; use fabro_config::run::parse_run_config; use fabro_config::{effective_settings, parse_settings_layer}; @@ -26,7 +25,7 @@ use fabro_types::settings::run::{ RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer, RunNamespace, RunSandboxLayer, }; -use fabro_types::settings::{ServerNamespace, SettingsLayer}; +use fabro_types::settings::{Combine, ReplaceMap, ServerNamespace, SettingsLayer}; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_validate::Severity; use fabro_workflow::Error as WorkflowError; @@ -75,14 +74,14 @@ pub(crate) fn prepare_manifest( .iter() .filter(|config| config.type_ == types::ManifestConfigType::Project) .try_fold(SettingsLayer::default(), |layer, config| { - Ok::<_, anyhow::Error>(combine_files(layer, parse_manifest_config(config)?)) + Ok::<_, anyhow::Error>(parse_manifest_config(config)?.combine(layer)) })?; let user_layer = manifest .configs .iter() .filter(|config| config.type_ == types::ManifestConfigType::User) .try_fold(SettingsLayer::default(), |layer, config| { - Ok::<_, anyhow::Error>(combine_files(layer, parse_manifest_config(config)?)) + Ok::<_, anyhow::Error>(parse_manifest_config(config)?.combine(layer)) })?; let mut settings = effective_settings::materialize_settings_layer( EffectiveSettingsLayers::new(args_layer, workflow_layer, project_layer, user_layer), @@ -250,7 +249,7 @@ fn manifest_args_layer(args: Option<&types::ManifestArgs>) -> SettingsLayer { model, sandbox, execution, - metadata: parse_labels(&args.label), + metadata: ReplaceMap::from(parse_labels(&args.label)), ..RunLayer::default() }); diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 3705d5437..101982df8 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -6,7 +6,6 @@ use std::time::Duration; use anyhow::Context; use clap::Args; use fabro_config::bind::{self, Bind, BindRequest}; -use fabro_config::merge::combine_files; use fabro_config::user::{apply_storage_dir_override, load_settings_config}; use fabro_config::{ServerSettings, Storage}; use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV}; @@ -15,7 +14,7 @@ use fabro_types::settings::server::{ GithubIntegrationStrategy, ServerLayer, ServerListenLayer, WebhookStrategy, }; use fabro_types::settings::{ - GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerListenSettings, + Combine, GithubIntegrationSettings, InterpString, ObjectStoreSettings, ServerListenSettings, ServerNamespace, SettingsLayer, }; use fabro_util::terminal::Styles; @@ -32,12 +31,12 @@ use tracing::{error, info, warn}; use crate::canonical_origin::resolve_canonical_origin; use crate::github_webhooks::{TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV}; use crate::ip_allowlist::{GitHubMetaResolver, IpAllowlistConfig, resolve_ip_allowlist_config}; -use crate::jwt_auth::resolve_auth_mode_with_lookup; use crate::server::{ AppState, AppStateConfig, RouterOptions, build_app_state, build_router_with_options, reconcile_incomplete_runs_on_startup, shutdown_active_workers, spawn_scheduler, }; -use crate::server_secrets::ServerSecrets; +use crate::server_secrets::{ServerSecrets, process_env_snapshot}; +use crate::startup::resolve_startup; const TEST_IN_MEMORY_STORE_ENV: &str = "FABRO_TEST_IN_MEMORY_STORE"; const AWS_SESSION_TOKEN_ENV: &str = "AWS_SESSION_TOKEN"; @@ -475,13 +474,22 @@ fn resolve_server_settings(file: &SettingsLayer) -> anyhow::Result anyhow::Result { + let disk_settings = load_settings_config(args.config.as_deref())?; + let effective_settings = apply_runtime_settings(&disk_settings, args, data_dir); + resolve_server_settings(&effective_settings) +} + pub fn resolve_bind_request_from_settings( settings: &SettingsLayer, explicit_bind: Option<&str>, ) -> anyhow::Result { let effective_settings = match explicit_bind.map(bind::parse_bind).transpose()? { Some(BindRequest::TcpHost(host)) => return Ok(BindRequest::TcpHost(host)), - Some(bind) => combine_files(settings.clone(), bind_override_layer(bind)), + Some(bind) => bind_override_layer(bind).combine(settings.clone()), None => settings.clone(), }; let resolved = resolve_server_settings(&effective_settings)?; @@ -533,7 +541,7 @@ fn resolve_interp_path(value: &InterpString) -> anyhow::Result { fn load_server_secrets_for_settings(settings: &ServerNamespace) -> anyhow::Result { let storage_root = resolve_interp_path(&settings.storage.root)?; let server_env_path = Storage::new(&storage_root).runtime_directory().env_path(); - ServerSecrets::load(server_env_path).map_err(anyhow::Error::from) + ServerSecrets::load(server_env_path, process_env_snapshot()).map_err(anyhow::Error::from) } pub(crate) fn build_artifact_object_store_with_server_secrets( @@ -615,24 +623,21 @@ where let storage = Storage::new(&data_dir); let vault_path = storage.secrets_path(); let server_env_path = storage.runtime_directory().env_path(); - let server_secrets = ServerSecrets::load(server_env_path.clone())?; - let webhook_secret_present = server_secrets.get(WEBHOOK_SECRET_ENV).is_some(); - // Shared config for live reloading let effective_settings = apply_runtime_settings(&disk_settings, &args, &data_dir); let resolved_server_settings = resolve_server_settings(&effective_settings)?; + let (auth_mode, server_secrets) = resolve_startup( + &server_env_path, + process_env_snapshot(), + &resolved_server_settings, + )?; + let webhook_secret_present = server_secrets.get(WEBHOOK_SECRET_ENV).is_some(); let bind_request = resolve_bind_request_from_settings(&effective_settings, args.bind.as_deref())?; let shared_settings = Arc::new(RwLock::new(effective_settings)); std::fs::create_dir_all(&data_dir) .with_context(|| format!("creating data directory {}", data_dir.display()))?; - let (auth_mode, max_concurrent_runs) = { - let auth_mode = resolve_auth_mode_with_lookup(&resolved_server_settings, |name| { - server_secrets.get(name) - })?; - let max_concurrent_runs = resolved_server_settings.scheduler.max_concurrent_runs; - (auth_mode, max_concurrent_runs) - }; + let max_concurrent_runs = resolved_server_settings.scheduler.max_concurrent_runs; let web_enabled = resolved_server_settings.web.enabled; let github_meta_resolver = GitHubMetaResolver::from_cache_dir(&storage.cache_dir())?; @@ -665,7 +670,7 @@ where store, artifact_store, vault_path, - server_env_path, + server_secrets, env_lookup, http_client: None, })?; diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index fc3ee36f3..e245fdf6a 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -11,7 +11,6 @@ use axum::body::Body; #[cfg(test)] use axum::body::to_bytes; use axum::extract::{self as axum_extract, DefaultBodyLimit, Path, Query, State}; -use axum::http::request::Parts; use axum::http::{HeaderMap, Method, StatusCode, header}; use axum::middleware::{self}; use axum::response::sse::{Event, KeepAlive, Sse}; @@ -40,7 +39,7 @@ pub use fabro_api::types::{ }; use fabro_auth::parse_credential_secret; use fabro_config::daemon::ServerDaemon; -use fabro_config::{ServerSettings, Storage}; +use fabro_config::{ServerSettings, Storage, envfile}; use fabro_interview::{ Answer, ControlInterviewer, Interviewer, Question, QuestionType, WorkerControlEnvelope, }; @@ -90,10 +89,7 @@ use fabro_workflow::run_lookup::{ RunInfo, StatusFilter, filter_runs, scan_runs_with_summaries, scratch_base, }; use fabro_workflow::run_status::{FailureReason, RunStatus, SuccessReason}; -use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; use object_store::memory::InMemory as MemoryObjectStore; -use rand::TryRngCore; -use rand::rngs::OsRng; use sha2::{Digest, Sha256}; use tempfile::NamedTempFile; use tokio::fs; @@ -117,14 +113,17 @@ use crate::github_webhooks::{ WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV, parse_event_metadata, verify_signature, }; use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware}; -use crate::jwt_auth::{ - AuthMode, AuthenticatedService, AuthenticatedSubject, authenticate_service_parts, -}; +use crate::jwt_auth::{self, AuthMode, AuthenticatedService, AuthenticatedSubject}; use crate::run_files::{FilesInFlight, list_run_files, new_files_in_flight}; use crate::run_selector::{ResolveRunError, resolve_run_by_selector}; use crate::server_secrets::{ LlmClientResult, ProviderCredentials, ServerSecrets, auth_issue_message, }; +use crate::spawn_env::{apply_render_graph_env, apply_worker_env}; +use crate::worker_token::{ + AuthorizeRunBlob, AuthorizeRunScoped, AuthorizeStageArtifact, WorkerTokenKeys, + issue_worker_token, +}; use crate::{demo, diagnostics, run_manifest, security_headers, static_files, web_auth}; pub(crate) type EnvLookup = Arc Option + Send + Sync>; @@ -283,9 +282,6 @@ const WORKER_CANCEL_GRACE: Duration = Duration::from_secs(5); const TERMINAL_DELETE_WORKER_GRACE: Duration = Duration::from_millis(50); const WORKER_CONTROL_QUEUE_CAPACITY: usize = 8; const WORKER_CONTROL_ENQUEUE_TIMEOUT: Duration = Duration::from_secs(1); -const ARTIFACT_UPLOAD_TOKEN_ISSUER: &str = "fabro-server-artifact-upload"; -const ARTIFACT_UPLOAD_TOKEN_SCOPE: &str = "stage_artifacts:upload"; -const ARTIFACT_UPLOAD_TOKEN_TTL_SECS: u64 = 24 * 60 * 60; const MAX_SINGLE_ARTIFACT_BYTES: u64 = 10 * 1024 * 1024; const MAX_MULTIPART_ARTIFACTS: usize = 100; const RENDER_ERROR_PREFIX: &[u8] = b"RENDER_ERROR:"; @@ -308,22 +304,6 @@ enum RenderSubprocessError { const MAX_MULTIPART_REQUEST_BYTES: u64 = 50 * 1024 * 1024; const MAX_MULTIPART_MANIFEST_BYTES: usize = 256 * 1024; -#[derive(Clone)] -struct ArtifactUploadTokenKeys { - encoding: Arc, - decoding: Arc, - validation: Arc, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -struct ArtifactUploadClaims { - iss: String, - iat: u64, - exp: u64, - run_id: String, - scope: String, -} - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] struct ArtifactBatchUploadManifest { entries: Vec, @@ -561,7 +541,7 @@ pub struct AppState { aggregate_billing: Mutex, store: Arc, artifact_store: ArtifactStore, - artifact_upload_tokens: ArtifactUploadTokenKeys, + worker_tokens: WorkerTokenKeys, started_at: Instant, max_concurrent_runs: usize, scheduler_notify: Notify, @@ -572,7 +552,7 @@ pub struct AppState { pub(crate) files_in_flight: FilesInFlight, pub(crate) vault: Arc>, - pub(crate) server_secrets: ServerSecrets, + pub(super) server_secrets: ServerSecrets, pub(crate) provider_credentials: ProviderCredentials, pub(crate) settings: Arc>, pub(crate) server_settings: RwLock>, @@ -591,7 +571,7 @@ pub(crate) struct AppStateConfig { pub(crate) store: Arc, pub(crate) artifact_store: ArtifactStore, pub(crate) vault_path: PathBuf, - pub(crate) server_env_path: PathBuf, + pub(crate) server_secrets: ServerSecrets, pub(crate) env_lookup: EnvLookup, pub(crate) http_client: Option, } @@ -692,6 +672,10 @@ impl AppState { self.server_secrets.get(name) } + pub(crate) fn worker_token_keys(&self) -> &WorkerTokenKeys { + &self.worker_tokens + } + pub(crate) fn resolve_interp(&self, value: &InterpString) -> anyhow::Result { value .resolve(|name| (self.env_lookup)(name)) @@ -748,30 +732,6 @@ impl AppState { } } - fn issue_artifact_upload_token(&self, run_id: &RunId) -> Result { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs()); - let claims = ArtifactUploadClaims { - iss: ARTIFACT_UPLOAD_TOKEN_ISSUER.to_string(), - iat: now, - exp: now + ARTIFACT_UPLOAD_TOKEN_TTL_SECS, - run_id: run_id.to_string(), - scope: ARTIFACT_UPLOAD_TOKEN_SCOPE.to_string(), - }; - jsonwebtoken::encode( - &Header::new(Algorithm::HS256), - &claims, - &self.artifact_upload_tokens.encoding, - ) - .map_err(|err| { - ApiError::new( - StatusCode::INTERNAL_SERVER_ERROR, - format!("failed to sign artifact upload token: {err}"), - ) - }) - } - fn begin_shutdown(&self) { self.shutting_down.store(true, Ordering::Relaxed); self.scheduler_notify.notify_waiters(); @@ -794,65 +754,6 @@ impl AppState { } } -fn artifact_upload_token_keys() -> ArtifactUploadTokenKeys { - let mut secret = [0_u8; 32]; - OsRng.try_fill_bytes(&mut secret).expect("OS RNG"); - - let mut validation = Validation::new(Algorithm::HS256); - validation.set_required_spec_claims(&["iss", "iat", "exp"]); - validation.set_issuer(&[ARTIFACT_UPLOAD_TOKEN_ISSUER]); - - ArtifactUploadTokenKeys { - encoding: Arc::new(EncodingKey::from_secret(&secret)), - decoding: Arc::new(DecodingKey::from_secret(&secret)), - validation: Arc::new(validation), - } -} - -fn maybe_authorize_artifact_upload_token( - parts: &Parts, - run_id: &RunId, - keys: &ArtifactUploadTokenKeys, -) -> Result { - let Some(header) = parts - .headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - else { - return Ok(false); - }; - let Some(token) = header.strip_prefix("Bearer ") else { - return Ok(false); - }; - - let claims = - match jsonwebtoken::decode::(token, &keys.decoding, &keys.validation) - { - Ok(token_data) => token_data.claims, - Err(_) => return Ok(false), - }; - - if claims.scope != ARTIFACT_UPLOAD_TOKEN_SCOPE { - return Err(ApiError::forbidden()); - } - if claims.run_id != run_id.to_string() { - return Err(ApiError::forbidden()); - } - - Ok(true) -} - -fn authorize_artifact_upload( - parts: &Parts, - state: &AppState, - run_id: &RunId, -) -> Result<(), ApiError> { - if maybe_authorize_artifact_upload_token(parts, run_id, &state.artifact_upload_tokens)? { - return Ok(()); - } - authenticate_service_parts(parts) -} - fn decode_secret_pem(name: &str, raw: &str) -> Result { if raw.starts_with("-----") { return Ok(raw.to_string()); @@ -2407,6 +2308,21 @@ pub fn create_app_state_with_env_lookup( settings: SettingsLayer, max_concurrent_runs: usize, env_lookup: impl Fn(&str) -> Option + Send + Sync + 'static, +) -> Arc { + create_app_state_with_env_lookup_and_server_secret_env( + settings, + max_concurrent_runs, + env_lookup, + &HashMap::new(), + ) +} + +#[doc(hidden)] +pub fn create_app_state_with_env_lookup_and_server_secret_env( + settings: SettingsLayer, + max_concurrent_runs: usize, + env_lookup: impl Fn(&str) -> Option + Send + Sync + 'static, + server_secret_env: &HashMap, ) -> Arc { let (store, artifact_store) = test_store_bundle(); let env_lookup: EnvLookup = Arc::new(env_lookup); @@ -2415,6 +2331,8 @@ pub fn create_app_state_with_env_lookup( let mut config = default_test_app_state_config(settings, max_concurrent_runs, env_lookup); config.store = store; config.artifact_store = artifact_store; + let server_env_path = config.vault_path.with_file_name("server.env"); + config.server_secrets = load_test_server_secrets(server_env_path, server_secret_env.clone()); build_app_state(config).expect("test app state should build") } @@ -2450,7 +2368,7 @@ pub(crate) fn create_test_app_state_with_session_key( store, artifact_store, vault_path, - server_env_path, + server_secrets: load_test_server_secrets(server_env_path, HashMap::new()), env_lookup, http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), }) @@ -2485,7 +2403,7 @@ fn default_test_app_state_config( store, artifact_store, vault_path, - server_env_path, + server_secrets: load_test_server_secrets(server_env_path, HashMap::new()), env_lookup, http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")), } @@ -2542,6 +2460,30 @@ fn default_env_lookup() -> EnvLookup { Arc::new(|name| std::env::var(name).ok()) } +fn load_test_server_secrets(path: PathBuf, env: HashMap) -> ServerSecrets { + let mut env = env; + let file_has_session_secret = envfile::read_env_file(&path) + .ok() + .is_some_and(|entries| entries.contains_key("SESSION_SECRET")); + if !env.contains_key("SESSION_SECRET") && !file_has_session_secret { + env.insert( + "SESSION_SECRET".to_string(), + "server-test-session-key-0123456789".to_string(), + ); + } + ServerSecrets::load(path, env).expect("test server secrets should load") +} + +fn worker_token_keys_from_server_secrets( + server_secrets: &ServerSecrets, +) -> anyhow::Result { + let session_secret = server_secrets + .get("SESSION_SECRET") + .ok_or_else(|| jwt_auth::session_secret_key_error(&auth::KeyDeriveError::Empty))?; + WorkerTokenKeys::from_master_secret(session_secret.as_bytes()) + .map_err(|err| jwt_auth::session_secret_key_error(&err)) +} + pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result> { let AppStateConfig { settings, @@ -2550,16 +2492,12 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result anyhow::Result) -> HashMap Result { +pub(crate) fn parse_run_id_path(id: &str) -> Result { id.parse::() .map_err(|_| ApiError::bad_request("Invalid run ID.").into_response()) } -/// Public re-export so sibling modules (e.g. `run_files`) can share the same -/// 400-on-invalid-ULID parse behavior without duplicating the helper. -#[allow( - clippy::result_large_err, - reason = "This shared run ID parser returns HTTP 400 responses directly." -)] -pub(crate) fn parse_run_id_path_pub(id: &str) -> Result { - parse_run_id_path(id) -} - #[allow( clippy::result_large_err, reason = "Stage ID parsing returns HTTP 400 responses directly." )] -fn parse_stage_id_path(stage_id: &str) -> Result { +pub(crate) fn parse_stage_id_path(stage_id: &str) -> Result { StageId::from_str(stage_id) .map_err(|_| ApiError::bad_request("Invalid stage ID.").into_response()) } @@ -3127,7 +3056,7 @@ fn parse_stage_id_path(stage_id: &str) -> Result { clippy::result_large_err, reason = "Blob ID parsing returns HTTP 400 responses directly." )] -fn parse_blob_id_path(blob_id: &str) -> Result { +pub(crate) fn parse_blob_id_path(blob_id: &str) -> Result { RunBlobId::from_str(blob_id) .map_err(|_| ApiError::bad_request("Invalid blob ID.").into_response()) } @@ -3697,17 +3626,14 @@ fn worker_command( ) })?; let server_target = daemon.bind.to_target(); - let artifact_upload_token = state - .issue_artifact_upload_token(&run_id) - .map_err(|_| anyhow::anyhow!("failed to sign artifact upload token"))?; + let worker_token = issue_worker_token(state.worker_token_keys(), &run_id) + .map_err(|_| anyhow::anyhow!("failed to sign worker token"))?; let mut cmd = Command::new(exe); cmd.arg("__run-worker") .arg("--server") .arg(server_target) .arg("--storage-dir") .arg(&storage_dir) - .arg("--artifact-upload-token") - .arg(artifact_upload_token) .arg("--run-dir") .arg(run_dir) .arg("--run-id") @@ -3718,19 +3644,9 @@ fn worker_command( .stdout(Stdio::null()) .stderr(Stdio::piped()); - cmd.env_remove("FABRO_JSON"); - cmd.env_remove("FABRO_DEV_TOKEN"); - if state - .server_settings() - .server - .auth - .methods - .contains(&ServerAuthMethod::DevToken) - { - if let Some(token) = state.server_secret("FABRO_DEV_TOKEN") { - cmd.env("FABRO_DEV_TOKEN", token); - } - } + apply_worker_env(&mut cmd); + cmd.env_remove("FABRO_WORKER_TOKEN"); + cmd.env("FABRO_WORKER_TOKEN", worker_token); #[cfg(unix)] fabro_proc::pre_exec_setpgid(cmd.as_std_mut()); @@ -4016,7 +3932,23 @@ async fn create_run( create_input.provenance = Some(run_provenance(&headers, &subject)); create_input.submitted_manifest_bytes = Some(body.to_vec()); - let created = match Box::pin(operations::create(state.store.as_ref(), create_input)).await { + let storage_root = match resolve_interp_string(&state.server_settings().server.storage.root) { + Ok(path) => PathBuf::from(path), + Err(err) => { + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to resolve server storage root: {err}"), + ) + .into_response(); + } + }; + let created = match Box::pin(operations::create( + state.store.as_ref(), + create_input, + storage_root, + )) + .await + { Ok(created) => created, Err(WorkflowError::ValidationFailed { .. } | WorkflowError::Parse(_)) => { return ApiError::bad_request("Validation failed").into_response(); @@ -4287,7 +4219,7 @@ async fn execute_run(state: Arc, run_id: RunId) { } if state.registry_factory_override.is_some() { - execute_run_in_process(state, run_id).await; + Box::pin(execute_run_in_process(state, run_id)).await; return; } @@ -5041,14 +4973,9 @@ async fn submit_answer( } async fn get_run_state( - _auth: AuthenticatedService, + AuthorizeRunScoped(id): AuthorizeRunScoped, State(state): State>, - Path(id): Path, ) -> Response { - let id = match parse_run_id_path(&id) { - Ok(id) => id, - Err(response) => return response, - }; match state.store.open_run_reader(&id).await { Ok(run_store) => match run_store.state().await { Ok(run_state) => Json(run_state).into_response(), @@ -5061,15 +4988,10 @@ async fn get_run_state( } async fn append_run_event( - _auth: AuthenticatedService, + AuthorizeRunScoped(id): AuthorizeRunScoped, State(state): State>, - Path(id): Path, Json(value): Json, ) -> Response { - let id = match parse_run_id_path(&id) { - Ok(id) => id, - Err(response) => return response, - }; if let Some(response) = reject_if_archived(state.as_ref(), &id).await { return response; } @@ -5111,15 +5033,10 @@ async fn append_run_event( } async fn list_run_events( - _auth: AuthenticatedService, + AuthorizeRunScoped(id): AuthorizeRunScoped, State(state): State>, - Path(id): Path, Query(params): Query, ) -> Response { - let id = match parse_run_id_path(&id) { - Ok(id) => id, - Err(response) => return response, - }; let since_seq = params.since_seq(); let limit = params.limit(); match state.store.open_run_reader(&id).await { @@ -5317,15 +5234,10 @@ async fn get_checkpoint( } async fn write_run_blob( - _auth: AuthenticatedService, + AuthorizeRunScoped(id): AuthorizeRunScoped, State(state): State>, - Path(id): Path, body: Bytes, ) -> Response { - let id = match parse_run_id_path(&id) { - Ok(id) => id, - Err(response) => return response, - }; if let Some(response) = reject_if_archived(state.as_ref(), &id).await { return response; } @@ -5344,18 +5256,9 @@ async fn write_run_blob( } async fn read_run_blob( - _auth: AuthenticatedService, + AuthorizeRunBlob(id, blob_id): AuthorizeRunBlob, State(state): State>, - Path((id, blob_id)): Path<(String, String)>, ) -> Response { - let id = match parse_run_id_path(&id) { - Ok(id) => id, - Err(response) => return response, - }; - let blob_id = match parse_blob_id_path(&blob_id) { - Ok(blob_id) => blob_id, - Err(response) => return response, - }; match state.store.open_run_reader(&id).await { Ok(run_store) => match run_store.read_blob(&blob_id).await { Ok(Some(bytes)) => octet_stream_response(bytes), @@ -5804,23 +5707,11 @@ async fn upload_stage_artifact_multipart( async fn put_stage_artifact( State(state): State>, - Path((id, stage_id)): Path<(String, String)>, + AuthorizeStageArtifact(id, stage_id): AuthorizeStageArtifact, Query(params): Query, request: axum_extract::Request, ) -> Response { - let id = match parse_run_id_path(&id) { - Ok(id) => id, - Err(response) => return response, - }; - let stage_id = match parse_stage_id_path(&stage_id) { - Ok(stage_id) => stage_id, - Err(response) => return response, - }; let (parts, body) = request.into_parts(); - - if let Err(err) = authorize_artifact_upload(&parts, state.as_ref(), &id) { - return err.into_response(); - } if let Some(response) = reject_if_archived(state.as_ref(), &id).await { return response; } @@ -7111,9 +7002,9 @@ async fn render_dot_subprocess( .map_err(|err| RenderSubprocessError::SpawnFailed(err.to_string()))?; let exe = render_graph_subprocess_exe(exe_override)?; let mut cmd = Command::new(exe); + apply_render_graph_env(&mut cmd); cmd.arg("__render-graph") .env("FABRO_TELEMETRY", "off") - .env_remove("FABRO_JSON") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -7264,13 +7155,15 @@ mod tests { use std::process::Stdio; use axum::body::Body; - use axum::http::{Request, header}; - use chrono::Utc; + use axum::http::{Method, Request, header}; + use chrono::{Duration as ChronoDuration, Utc}; use fabro_config::bind::Bind; use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question, QuestionType}; use fabro_model::Provider; use fabro_types::settings::ServerAuthMethod; - use fabro_types::{InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunId, fixtures}; + use fabro_types::{ + InterviewQuestionRecord, InterviewQuestionType, RunAuthMethod, RunBlobId, RunId, fixtures, + }; use serde_json::json; use tokio_stream::StreamExt as _; use tower::ServiceExt; @@ -7288,6 +7181,8 @@ mod tests { const TEST_WEBHOOK_SECRET: &str = "webhook-secret"; const TEST_DEV_TOKEN: &str = "fabro_dev_abababababababababababababababababababababababababababababababab"; + const TEST_SESSION_SECRET: &str = "server-test-session-key-0123456789"; + const TEST_JWT_ISSUER: &str = "https://fabro.example"; const WRONG_DEV_TOKEN: &str = "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"; @@ -7340,9 +7235,12 @@ mod tests { )] fn webhook_test_app(auth_mode: AuthMode) -> Router { let secret = TEST_WEBHOOK_SECRET.to_string(); - let state = create_app_state_with_env_lookup(SettingsLayer::default(), 5, move |name| { - (name == WEBHOOK_SECRET_ENV).then(|| secret.clone()) - }); + let state = create_app_state_with_env_lookup_and_server_secret_env( + SettingsLayer::default(), + 5, + |_| None, + &HashMap::from([(WEBHOOK_SECRET_ENV.to_string(), secret)]), + ); build_router_with_options( state, &auth_mode, @@ -7381,6 +7279,84 @@ mod tests { }) } + fn jwt_auth_mode() -> AuthMode { + AuthMode::Enabled(ConfiguredAuth { + methods: vec![ServerAuthMethod::Github], + dev_token: None, + jwt_key: Some( + auth::derive_jwt_key(TEST_SESSION_SECRET.as_bytes()) + .expect("test JWT key should derive"), + ), + jwt_issuer: Some(TEST_JWT_ISSUER.to_string()), + }) + } + + fn jwt_auth_state() -> Arc { + create_test_app_state_with_session_key(SettingsLayer::default(), Some(TEST_SESSION_SECRET)) + } + + fn jwt_auth_app() -> (Arc, Router) { + let state = jwt_auth_state(); + let app = build_router(Arc::clone(&state), jwt_auth_mode()); + (state, app) + } + + fn test_user_subject() -> auth::JwtSubject { + auth::JwtSubject { + identity: fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(), + login: "octocat".to_string(), + name: "The Octocat".to_string(), + email: "octocat@example.com".to_string(), + avatar_url: "https://example.com/octocat.png".to_string(), + user_url: "https://github.com/octocat".to_string(), + auth_method: RunAuthMethod::Github, + } + } + + fn issue_test_user_jwt() -> String { + let key = auth::derive_jwt_key(TEST_SESSION_SECRET.as_bytes()) + .expect("test JWT key should derive"); + auth::issue( + &key, + TEST_JWT_ISSUER, + &test_user_subject(), + ChronoDuration::minutes(10), + ) + } + + fn issue_test_worker_token(run_id: &RunId) -> String { + let keys = WorkerTokenKeys::from_master_secret(TEST_SESSION_SECRET.as_bytes()) + .expect("worker keys should derive"); + issue_worker_token(&keys, run_id).expect("worker token should issue") + } + + async fn create_run_with_bearer(app: &Router, bearer: &str) -> RunId { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(api("/runs")) + .header(header::AUTHORIZATION, format!("Bearer {bearer}")) + .header(header::CONTENT_TYPE, "application/json") + .body(manifest_body(MINIMAL_DOT)) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json!(response, StatusCode::CREATED).await; + body["id"].as_str().unwrap().parse().unwrap() + } + + fn bearer_request(method: Method, path: &str, bearer: &str, body: Body) -> Request { + Request::builder() + .method(method) + .uri(api(path)) + .header(header::AUTHORIZATION, format!("Bearer {bearer}")) + .body(body) + .unwrap() + } + fn canonical_origin_settings(url: &str) -> SettingsLayer { fabro_config::parse_settings_layer(&format!( r#" @@ -7782,12 +7758,11 @@ root = "/srv/new" ) .unwrap(); - let secrets = - ServerSecrets::with_env_lookup(dir.path().join("server.env"), |name| match name { - "SESSION_SECRET" => Some("env-value".to_string()), - _ => None, - }) - .unwrap(); + let secrets = ServerSecrets::load( + dir.path().join("server.env"), + HashMap::from([("SESSION_SECRET".to_string(), "env-value".to_string())]), + ) + .unwrap(); assert_eq!(secrets.get("SESSION_SECRET").as_deref(), Some("env-value")); assert_eq!( @@ -7798,36 +7773,112 @@ root = "/srv/new" #[cfg(unix)] #[test] - fn worker_command_injects_dev_token_only_when_enabled() { + fn worker_command_always_sets_worker_token_env() { let github_only = tempfile::tempdir().unwrap(); let github_state = worker_command_test_state(github_only.path(), &["github"], Some(TEST_DEV_TOKEN)); + let github_run_id = RunId::new(); let github_cmd = worker_command( github_state.as_ref(), - RunId::new(), + github_run_id, RunExecutionMode::Start, github_only.path(), ) .unwrap(); + assert!(matches!( + command_env_value(&github_cmd, "FABRO_WORKER_TOKEN"), + EnvOverride::Set(_) + )); assert_eq!( command_env_value(&github_cmd, "FABRO_DEV_TOKEN"), - EnvOverride::Removed + EnvOverride::Unchanged ); + let github_args = github_cmd + .as_std() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + assert!( + !github_args + .iter() + .any(|arg| arg == "--artifact-upload-token") + ); + assert!(!github_args.iter().any(|arg| arg == "--worker-token")); + let EnvOverride::Set(github_token) = command_env_value(&github_cmd, "FABRO_WORKER_TOKEN") + else { + panic!("worker token should be set"); + }; + let github_keys = WorkerTokenKeys::from_master_secret(TEST_SESSION_SECRET.as_bytes()) + .expect("worker keys should derive"); + let github_claims = jsonwebtoken::decode::( + &github_token, + github_keys.decoding_key(), + github_keys.validation(), + ) + .expect("github worker token should decode") + .claims; + assert_eq!(github_claims.run_id, github_run_id.to_string()); let dev_token = tempfile::tempdir().unwrap(); let dev_token_state = worker_command_test_state(dev_token.path(), &["dev-token"], Some(TEST_DEV_TOKEN)); + let dev_token_run_id = RunId::new(); let dev_token_cmd = worker_command( dev_token_state.as_ref(), - RunId::new(), + dev_token_run_id, RunExecutionMode::Start, dev_token.path(), ) .unwrap(); + assert!(matches!( + command_env_value(&dev_token_cmd, "FABRO_WORKER_TOKEN"), + EnvOverride::Set(_) + )); assert_eq!( command_env_value(&dev_token_cmd, "FABRO_DEV_TOKEN"), - EnvOverride::Set(TEST_DEV_TOKEN.to_string()) + EnvOverride::Unchanged ); + let EnvOverride::Set(dev_worker_token) = + command_env_value(&dev_token_cmd, "FABRO_WORKER_TOKEN") + else { + panic!("worker token should be set"); + }; + let dev_claims = jsonwebtoken::decode::( + &dev_worker_token, + github_keys.decoding_key(), + github_keys.validation(), + ) + .expect("dev-token worker token should decode") + .claims; + assert_eq!(dev_claims.run_id, dev_token_run_id.to_string()); + } + + #[test] + fn build_app_state_requires_session_secret_for_worker_tokens() { + let settings = Arc::new(RwLock::new(SettingsLayer::default())); + ensure_test_auth_methods(&settings); + let (store, artifact_store) = test_store_bundle(); + let vault_path = test_secret_store_path(); + let server_env_path = vault_path.with_file_name("server.env"); + let Err(err) = build_app_state(AppStateConfig { + settings, + registry_factory_override: None, + max_concurrent_runs: 5, + store, + artifact_store, + vault_path, + server_secrets: ServerSecrets::load(server_env_path, HashMap::new()).unwrap(), + env_lookup: default_env_lookup(), + http_client: Some( + fabro_http::test_http_client().expect("test HTTP client should build"), + ), + }) else { + panic!("build_app_state should require SESSION_SECRET") + }; + + assert!(err.to_string().contains( + "Fabro server refuses to start: auth is configured but SESSION_SECRET is not set." + )); } fn worker_command_test_state( @@ -7867,10 +7918,15 @@ allowed_usernames = ["octocat"] .write(&runtime_directory) .unwrap(); - create_app_state_with_env_lookup(settings, 5, move |name| match name { - "FABRO_DEV_TOKEN" => dev_token.clone(), - _ => None, - }) + let server_secret_env = dev_token + .map(|token| HashMap::from([("FABRO_DEV_TOKEN".to_string(), token)])) + .unwrap_or_default(); + create_app_state_with_env_lookup_and_server_secret_env( + settings, + 5, + |_| None, + &server_secret_env, + ) } #[cfg(unix)] @@ -9124,6 +9180,284 @@ slug = "fabro" assert_status!(response, StatusCode::BAD_REQUEST).await; } + #[tokio::test] + async fn worker_token_accepts_run_scoped_routes_and_falls_back_to_user_jwt() { + let (state, app) = jwt_auth_app(); + let user_jwt = issue_test_user_jwt(); + let run_id = create_run_with_bearer(&app, &user_jwt).await; + let worker_token = issue_test_worker_token(&run_id); + let other_run_id = create_run_with_bearer(&app, &user_jwt).await; + let other_worker_token = issue_test_worker_token(&other_run_id); + let blob_id = state + .store + .open_run(&run_id) + .await + .unwrap() + .write_blob(b"preloaded blob") + .await + .unwrap(); + + let response = app + .clone() + .oneshot(bearer_request( + Method::GET, + &format!("/runs/{run_id}/state"), + &worker_token, + Body::empty(), + )) + .await + .unwrap(); + assert_status!(response, StatusCode::OK).await; + + let append_body = serde_json::to_vec(&serde_json::json!({ + "id": "evt-run-notice", + "ts": "2026-04-23T12:00:00Z", + "event": "run.notice", + "run_id": run_id.to_string(), + "properties": { + "level": "info", + "code": "worker", + "message": "hello" + } + })) + .unwrap(); + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(api(&format!("/runs/{run_id}/events"))) + .header(header::AUTHORIZATION, format!("Bearer {worker_token}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(append_body)) + .unwrap(), + ) + .await + .unwrap(); + assert_status!(response, StatusCode::OK).await; + + let response = app + .clone() + .oneshot(bearer_request( + Method::GET, + &format!("/runs/{run_id}/events"), + &worker_token, + Body::empty(), + )) + .await + .unwrap(); + assert_status!(response, StatusCode::OK).await; + + let response = app + .clone() + .oneshot(bearer_request( + Method::POST, + &format!("/runs/{run_id}/blobs"), + &worker_token, + Body::from("worker blob"), + )) + .await + .unwrap(); + assert_status!(response, StatusCode::OK).await; + + let response = app + .clone() + .oneshot(bearer_request( + Method::GET, + &format!("/runs/{run_id}/blobs/{blob_id}"), + &worker_token, + Body::empty(), + )) + .await + .unwrap(); + assert_status!(response, StatusCode::OK).await; + + let response = app + .clone() + .oneshot(bearer_request( + Method::GET, + &format!("/runs/{run_id}/state"), + &user_jwt, + Body::empty(), + )) + .await + .unwrap(); + assert_status!(response, StatusCode::OK).await; + + let response = app + .clone() + .oneshot(bearer_request( + Method::GET, + &format!("/runs/{run_id}/state"), + &other_worker_token, + Body::empty(), + )) + .await + .unwrap(); + assert_status!(response, StatusCode::FORBIDDEN).await; + } + + #[tokio::test] + async fn worker_token_controls_stage_artifact_route() { + let (_state, app) = jwt_auth_app(); + let user_jwt = issue_test_user_jwt(); + let run_id = create_run_with_bearer(&app, &user_jwt).await; + let worker_token = issue_test_worker_token(&run_id); + let other_run_id = create_run_with_bearer(&app, &user_jwt).await; + let mismatched_worker_token = issue_test_worker_token(&other_run_id); + + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(api(&format!( + "/runs/{run_id}/stages/code@2/artifacts?filename=artifact.txt" + ))) + .header(header::AUTHORIZATION, format!("Bearer {worker_token}")) + .header(header::CONTENT_TYPE, "application/octet-stream") + .body(Body::from("artifact")) + .unwrap(), + ) + .await + .unwrap(); + assert_status!(response, StatusCode::NO_CONTENT).await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(api(&format!( + "/runs/{run_id}/stages/code@2/artifacts?filename=artifact.txt" + ))) + .header(header::AUTHORIZATION, format!("Bearer {user_jwt}")) + .header(header::CONTENT_TYPE, "application/octet-stream") + .body(Body::from("artifact")) + .unwrap(), + ) + .await + .unwrap(); + assert_status!(response, StatusCode::NO_CONTENT).await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(api(&format!( + "/runs/{run_id}/stages/code@2/artifacts?filename=artifact.txt" + ))) + .header( + header::AUTHORIZATION, + format!("Bearer {mismatched_worker_token}"), + ) + .header(header::CONTENT_TYPE, "application/octet-stream") + .body(Body::from("artifact")) + .unwrap(), + ) + .await + .unwrap(); + assert_status!(response, StatusCode::FORBIDDEN).await; + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri(api(&format!( + "/runs/{run_id}/stages/code@2/artifacts?filename=artifact.txt" + ))) + .header(header::CONTENT_TYPE, "application/octet-stream") + .body(Body::from("artifact")) + .unwrap(), + ) + .await + .unwrap(); + assert_status!(response, StatusCode::UNAUTHORIZED).await; + } + + #[tokio::test] + async fn worker_token_is_rejected_on_user_only_routes() { + let (_state, app) = jwt_auth_app(); + let user_jwt = issue_test_user_jwt(); + let run_id = create_run_with_bearer(&app, &user_jwt).await; + let worker_token = issue_test_worker_token(&run_id); + let blob_id = RunBlobId::new(b"blob"); + let user_only_routes = vec![ + (Method::GET, "/runs".to_string()), + (Method::POST, "/runs".to_string()), + (Method::GET, "/runs/resolve".to_string()), + (Method::POST, "/preflight".to_string()), + (Method::POST, "/graph/render".to_string()), + (Method::GET, "/attach".to_string()), + (Method::GET, "/boards/runs".to_string()), + (Method::GET, format!("/runs/{run_id}")), + (Method::DELETE, format!("/runs/{run_id}")), + (Method::GET, format!("/runs/{run_id}/questions")), + (Method::POST, format!("/runs/{run_id}/questions/q-1/answer")), + (Method::GET, format!("/runs/{run_id}/attach")), + (Method::GET, format!("/runs/{run_id}/checkpoint")), + (Method::POST, format!("/runs/{run_id}/cancel")), + (Method::POST, format!("/runs/{run_id}/start")), + (Method::POST, format!("/runs/{run_id}/pause")), + (Method::POST, format!("/runs/{run_id}/unpause")), + (Method::POST, format!("/runs/{run_id}/archive")), + (Method::POST, format!("/runs/{run_id}/unarchive")), + (Method::GET, format!("/runs/{run_id}/graph")), + (Method::GET, format!("/runs/{run_id}/stages")), + (Method::GET, format!("/runs/{run_id}/artifacts")), + (Method::GET, format!("/runs/{run_id}/files")), + ( + Method::GET, + format!("/runs/{run_id}/stages/code@2/artifacts"), + ), + ( + Method::GET, + format!("/runs/{run_id}/stages/code@2/artifacts/download"), + ), + (Method::GET, format!("/runs/{run_id}/billing")), + (Method::GET, format!("/runs/{run_id}/settings")), + (Method::POST, format!("/runs/{run_id}/preview")), + (Method::POST, format!("/runs/{run_id}/ssh")), + (Method::GET, format!("/runs/{run_id}/sandbox/files")), + (Method::GET, format!("/runs/{run_id}/sandbox/file")), + (Method::PUT, format!("/runs/{run_id}/sandbox/file")), + ]; + + for (method, path) in user_only_routes { + let response = app + .clone() + .oneshot(bearer_request( + method.clone(), + &path, + &worker_token, + Body::empty(), + )) + .await + .unwrap(); + assert!( + matches!( + response.status(), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + ), + "{method} {path} unexpectedly accepted worker token with status {}", + response.status() + ); + } + + let response = app + .clone() + .oneshot(bearer_request( + Method::GET, + &format!("/runs/{run_id}/blobs/{blob_id}"), + &worker_token, + Body::empty(), + )) + .await + .unwrap(); + assert_ne!(response.status(), StatusCode::UNAUTHORIZED); + } + #[tokio::test] async fn stage_artifacts_multipart_round_trip() { let state = create_app_state(); diff --git a/lib/crates/fabro-server/src/server_secrets.rs b/lib/crates/fabro-server/src/server_secrets.rs index d296f0f0b..b09ed710b 100644 --- a/lib/crates/fabro-server/src/server_secrets.rs +++ b/lib/crates/fabro-server/src/server_secrets.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::path::PathBuf; +use std::path::Path; use std::sync::Arc; use fabro_auth::{CredentialResolver, CredentialUsage, ResolveError, ResolvedCredential}; @@ -11,6 +11,10 @@ use tokio::sync::RwLock as AsyncRwLock; type EnvLookup = Arc Option + Send + Sync>; +pub fn process_env_snapshot() -> HashMap { + std::env::vars().collect() +} + #[derive(Debug, thiserror::Error)] pub(crate) enum Error { #[error(transparent)] @@ -18,36 +22,33 @@ pub(crate) enum Error { } pub(crate) struct ServerSecrets { - path: PathBuf, + env_entries: HashMap, file_entries: HashMap, - env_lookup: EnvLookup, } impl ServerSecrets { - pub(crate) fn load(path: PathBuf) -> Result { - Self::with_env_lookup(path, |name| std::env::var(name).ok()) - } - - pub(crate) fn with_env_lookup(path: PathBuf, env_lookup: F) -> Result - where - F: Fn(&str) -> Option + Send + Sync + 'static, - { + pub(crate) fn load( + path: impl AsRef, + env_entries: HashMap, + ) -> Result { Ok(Self { - file_entries: envfile::read_env_file(&path)?, - path, - env_lookup: Arc::new(env_lookup), + env_entries, + file_entries: envfile::read_env_file(path.as_ref())?, }) } pub(crate) fn get(&self, name: &str) -> Option { - (self.env_lookup)(name).or_else(|| self.file_entries.get(name).cloned()) + self.env_entries + .get(name) + .cloned() + .or_else(|| self.file_entries.get(name).cloned()) } } impl std::fmt::Debug for ServerSecrets { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ServerSecrets") - .field("path", &self.path) + .field("env_entries", &self.env_entries.keys().collect::>()) .field( "file_entries", &self.file_entries.keys().collect::>(), @@ -149,13 +150,15 @@ impl std::fmt::Debug for ProviderCredentials { #[cfg(test)] mod tests { + use std::collections::HashMap; use std::sync::Arc; use fabro_auth::{AuthCredential, AuthDetails}; + use fabro_config::envfile; use fabro_vault::{SecretType, Vault}; use tokio::sync::RwLock as AsyncRwLock; - use super::ProviderCredentials; + use super::{ProviderCredentials, ServerSecrets}; use crate::server_secrets::Provider; #[tokio::test] @@ -198,4 +201,33 @@ mod tests { Provider::Anthropic ]); } + + #[test] + fn server_secrets_snapshot_prefers_env_over_file() { + let dir = tempfile::tempdir().unwrap(); + let env_path = dir.path().join("server.env"); + envfile::write_env_file( + &env_path, + &HashMap::from([ + ("SESSION_SECRET".to_string(), "file-value".to_string()), + ( + "GITHUB_APP_CLIENT_SECRET".to_string(), + "file-client".to_string(), + ), + ]), + ) + .unwrap(); + + let secrets = ServerSecrets::load( + env_path, + HashMap::from([("SESSION_SECRET".to_string(), "env-value".to_string())]), + ) + .unwrap(); + + assert_eq!(secrets.get("SESSION_SECRET").as_deref(), Some("env-value")); + assert_eq!( + secrets.get("GITHUB_APP_CLIENT_SECRET").as_deref(), + Some("file-client") + ); + } } diff --git a/lib/crates/fabro-server/src/spawn_env.rs b/lib/crates/fabro-server/src/spawn_env.rs new file mode 100644 index 000000000..2aba371dc --- /dev/null +++ b/lib/crates/fabro-server/src/spawn_env.rs @@ -0,0 +1,135 @@ +use std::ffi::OsString; + +use tokio::process::Command; + +const WORKER_ENV_ALLOWLIST: &[&str] = &[ + "PATH", + "HOME", + "TMPDIR", + "USER", + "RUST_LOG", + "RUST_BACKTRACE", + "FABRO_HOME", + "FABRO_STORAGE_ROOT", +]; + +const RENDER_GRAPH_ENV_ALLOWLIST: &[&str] = &["PATH", "HOME", "TMPDIR"]; + +pub(crate) fn apply_worker_env(cmd: &mut Command) { + apply_allowlist(cmd, WORKER_ENV_ALLOWLIST, &|name| std::env::var_os(name)); +} + +pub(crate) fn apply_render_graph_env(cmd: &mut Command) { + apply_allowlist(cmd, RENDER_GRAPH_ENV_ALLOWLIST, &|name| { + std::env::var_os(name) + }); +} + +fn apply_allowlist(cmd: &mut Command, keys: &[&str], lookup: &dyn Fn(&str) -> Option) { + cmd.env_clear(); + for key in keys { + if let Some(value) = lookup(key) { + cmd.env(key, value); + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use std::collections::HashMap; + use std::ffi::OsString; + use std::path::Path; + + use super::{RENDER_GRAPH_ENV_ALLOWLIST, WORKER_ENV_ALLOWLIST, apply_allowlist}; + + fn env_command() -> tokio::process::Command { + assert!(Path::new("/usr/bin/env").exists()); + tokio::process::Command::new("/usr/bin/env") + } + + async fn env_output(mut cmd: tokio::process::Command) -> HashMap { + let output = cmd.output().await.expect("running env subprocess"); + assert!(output.status.success()); + String::from_utf8(output.stdout) + .expect("parsing env subprocess output as UTF-8") + .lines() + .filter_map(|line| { + let (key, value) = line.split_once('=')?; + Some((key.to_string(), value.to_string())) + }) + .collect() + } + + #[tokio::test] + async fn worker_allowlist_is_fail_closed() { + let env = HashMap::from([ + ("PATH".to_string(), "/bin".to_string()), + ("HOME".to_string(), "/tmp/home".to_string()), + ("TMPDIR".to_string(), "/tmp".to_string()), + ("USER".to_string(), "alice".to_string()), + ("RUST_LOG".to_string(), "debug".to_string()), + ("FABRO_HOME".to_string(), "/tmp/fabro-home".to_string()), + ( + "FABRO_STORAGE_ROOT".to_string(), + "/tmp/fabro-storage".to_string(), + ), + ("SESSION_SECRET".to_string(), "leak".to_string()), + ("FABRO_JWT_PRIVATE_KEY".to_string(), "leak".to_string()), + ("FABRO_JWT_PUBLIC_KEY".to_string(), "leak".to_string()), + ("GITHUB_APP_PRIVATE_KEY".to_string(), "leak".to_string()), + ("GITHUB_APP_CLIENT_SECRET".to_string(), "leak".to_string()), + ("GITHUB_APP_WEBHOOK_SECRET".to_string(), "leak".to_string()), + ("FABRO_DEV_TOKEN".to_string(), "garbage".to_string()), + ("MY_API_KEY".to_string(), "blocked".to_string()), + ]); + let mut cmd = env_command(); + apply_allowlist(&mut cmd, WORKER_ENV_ALLOWLIST, &|name| { + env.get(name).map(OsString::from) + }); + cmd.env( + "FABRO_DEV_TOKEN", + "fabro_dev_abababababababababababababababababababababababababababababababab", + ); + + let actual = env_output(cmd).await; + + assert_eq!(actual.get("PATH").map(String::as_str), Some("/bin")); + assert_eq!(actual.get("HOME").map(String::as_str), Some("/tmp/home")); + assert_eq!( + actual.get("FABRO_DEV_TOKEN").map(String::as_str), + Some("fabro_dev_abababababababababababababababababababababababababababababababab") + ); + assert!(!actual.contains_key("SESSION_SECRET")); + assert!(!actual.contains_key("FABRO_JWT_PRIVATE_KEY")); + assert!(!actual.contains_key("FABRO_JWT_PUBLIC_KEY")); + assert!(!actual.contains_key("GITHUB_APP_PRIVATE_KEY")); + assert!(!actual.contains_key("GITHUB_APP_CLIENT_SECRET")); + assert!(!actual.contains_key("GITHUB_APP_WEBHOOK_SECRET")); + assert!(!actual.contains_key("MY_API_KEY")); + } + + #[tokio::test] + async fn render_graph_allowlist_is_fail_closed() { + let env = HashMap::from([ + ("PATH".to_string(), "/bin".to_string()), + ("HOME".to_string(), "/tmp/home".to_string()), + ("TMPDIR".to_string(), "/tmp".to_string()), + ("FABRO_TELEMETRY".to_string(), "on".to_string()), + ("SESSION_SECRET".to_string(), "leak".to_string()), + ]); + let mut cmd = env_command(); + apply_allowlist(&mut cmd, RENDER_GRAPH_ENV_ALLOWLIST, &|name| { + env.get(name).map(OsString::from) + }); + cmd.env("FABRO_TELEMETRY", "off"); + + let actual = env_output(cmd).await; + + assert_eq!(actual.get("PATH").map(String::as_str), Some("/bin")); + assert_eq!( + actual.get("FABRO_TELEMETRY").map(String::as_str), + Some("off") + ); + assert!(!actual.contains_key("SESSION_SECRET")); + } +} diff --git a/lib/crates/fabro-server/src/startup.rs b/lib/crates/fabro-server/src/startup.rs new file mode 100644 index 000000000..6bd2c957f --- /dev/null +++ b/lib/crates/fabro-server/src/startup.rs @@ -0,0 +1,87 @@ +use std::collections::HashMap; +use std::path::Path; + +use fabro_types::settings::ServerNamespace; + +use crate::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup}; +use crate::server_secrets::ServerSecrets; + +pub(crate) fn resolve_startup( + env_path: &Path, + env_entries: HashMap, + settings: &ServerNamespace, +) -> anyhow::Result<(AuthMode, ServerSecrets)> { + let server_secrets = ServerSecrets::load(env_path, env_entries)?; + let auth_mode = resolve_auth_mode_with_lookup(settings, |name| server_secrets.get(name))?; + Ok((auth_mode, server_secrets)) +} + +pub fn validate_startup( + env_path: &Path, + env_entries: HashMap, + settings: &ServerNamespace, +) -> anyhow::Result<()> { + resolve_startup(env_path, env_entries, settings).map(|_| ()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use fabro_config::parse_settings_layer; + use fabro_types::settings::ServerNamespace; + + use super::validate_startup; + + fn resolved_settings(auth_methods: &[&str]) -> ServerNamespace { + let settings = parse_settings_layer(&format!( + r" +_version = 1 + +[server.auth] +methods = [{}] +", + auth_methods + .iter() + .map(|method| format!("\"{method}\"")) + .collect::>() + .join(", ") + )) + .unwrap(); + fabro_config::resolve_server_from_file(&settings).unwrap() + } + + #[test] + fn validate_startup_accepts_configured_secrets() { + let dir = tempfile::tempdir().unwrap(); + let env = HashMap::from([ + ( + "SESSION_SECRET".to_string(), + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(), + ), + ( + "FABRO_DEV_TOKEN".to_string(), + "fabro_dev_abababababababababababababababababababababababababababababababab" + .to_string(), + ), + ]); + let settings = resolved_settings(&["dev-token"]); + + assert!(validate_startup(dir.path().join("server.env").as_path(), env, &settings).is_ok()); + } + + #[test] + fn validate_startup_rejects_missing_secrets() { + let dir = tempfile::tempdir().unwrap(); + let settings = resolved_settings(&["dev-token"]); + + assert!( + validate_startup( + dir.path().join("server.env").as_path(), + HashMap::new(), + &settings, + ) + .is_err() + ); + } +} diff --git a/lib/crates/fabro-server/src/worker_token.rs b/lib/crates/fabro-server/src/worker_token.rs new file mode 100644 index 000000000..7db7382e7 --- /dev/null +++ b/lib/crates/fabro-server/src/worker_token.rs @@ -0,0 +1,599 @@ +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::extract::{FromRequestParts, Path}; +use axum::http::StatusCode; +use axum::http::request::Parts; +use axum::response::{IntoResponse, Response}; +use fabro_types::{RunBlobId, RunId, StageId}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use tracing::{info, warn}; +use uuid::Uuid; + +use crate::ApiError; +use crate::auth::{self, KeyDeriveError}; +use crate::jwt_auth::{authenticate_service_parts, bearer_token}; +use crate::server::{AppState, parse_blob_id_path, parse_run_id_path, parse_stage_id_path}; + +pub(crate) const WORKER_TOKEN_ISSUER: &str = "fabro-server-worker"; +pub(crate) const WORKER_TOKEN_SCOPE: &str = "run:worker"; +pub(crate) const WORKER_TOKEN_TTL_SECS: u64 = 72 * 60 * 60; + +#[derive(Clone)] +pub(crate) struct WorkerTokenKeys { + encoding: Arc, + decoding: Arc, + validation: Arc, +} + +impl WorkerTokenKeys { + pub(crate) fn from_master_secret(secret: &[u8]) -> Result { + let key = auth::derive_worker_jwt_key(secret)?; + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_nbf = false; + validation.set_required_spec_claims(&["iss", "iat", "exp"]); + validation.set_issuer(&[WORKER_TOKEN_ISSUER]); + + Ok(Self { + encoding: Arc::new(EncodingKey::from_secret(&key)), + decoding: Arc::new(DecodingKey::from_secret(&key)), + validation: Arc::new(validation), + }) + } + + #[cfg(test)] + pub(crate) fn decoding_key(&self) -> &DecodingKey { + &self.decoding + } + + #[cfg(test)] + pub(crate) fn validation(&self) -> &Validation { + &self.validation + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub(crate) struct WorkerTokenClaims { + pub(crate) iss: String, + pub(crate) iat: u64, + pub(crate) exp: u64, + pub(crate) run_id: String, + pub(crate) scope: String, + pub(crate) jti: String, +} + +pub(crate) fn issue_worker_token( + keys: &WorkerTokenKeys, + run_id: &RunId, +) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()); + let claims = WorkerTokenClaims { + iss: WORKER_TOKEN_ISSUER.to_string(), + iat: now, + exp: now + WORKER_TOKEN_TTL_SECS, + run_id: run_id.to_string(), + scope: WORKER_TOKEN_SCOPE.to_string(), + jti: Uuid::new_v4().simple().to_string(), + }; + jsonwebtoken::encode(&Header::new(Algorithm::HS256), &claims, &keys.encoding).map_err(|err| { + ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + format!("failed to sign worker token: {err}"), + ) + }) +} + +pub(crate) fn authorize_worker_token( + parts: &Parts, + run_id: &RunId, + keys: &WorkerTokenKeys, +) -> Result { + let Some(Ok(token)) = bearer_token(parts) else { + return Ok(false); + }; + + let claims = + match jsonwebtoken::decode::(token, &keys.decoding, &keys.validation) { + Ok(token_data) => token_data.claims, + Err(_) => return Ok(false), + }; + + if claims.scope != WORKER_TOKEN_SCOPE { + warn!( + target: "worker_auth", + run_id = %run_id, + jti = %claims.jti, + reason = "wrong_scope", + "worker token rejected" + ); + return Err(ApiError::forbidden()); + } + if claims.run_id != run_id.to_string() { + warn!( + target: "worker_auth", + run_id = %run_id, + token_run_id = %claims.run_id, + jti = %claims.jti, + reason = "run_id_mismatch", + "worker token rejected" + ); + return Err(ApiError::forbidden()); + } + + info!( + target: "worker_auth", + run_id = %run_id, + jti = %claims.jti, + "worker token accepted" + ); + Ok(true) +} + +fn authorize_run_scoped(parts: &Parts, state: &AppState, run_id: &RunId) -> Result<(), ApiError> { + if authorize_worker_token(parts, run_id, state.worker_token_keys())? { + return Ok(()); + } + authenticate_service_parts(parts) +} + +pub(crate) struct AuthorizeRunScoped(pub(crate) RunId); + +impl FromRequestParts> for AuthorizeRunScoped { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let Path(id): Path = Path::from_request_parts(parts, state) + .await + .map_err(IntoResponse::into_response)?; + let run_id = parse_run_id_path(&id)?; + authorize_run_scoped(parts, state.as_ref(), &run_id) + .map_err(IntoResponse::into_response)?; + Ok(Self(run_id)) + } +} + +pub(crate) struct AuthorizeRunBlob(pub(crate) RunId, pub(crate) RunBlobId); + +impl FromRequestParts> for AuthorizeRunBlob { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let Path((id, blob_id)): Path<(String, String)> = Path::from_request_parts(parts, state) + .await + .map_err(IntoResponse::into_response)?; + let run_id = parse_run_id_path(&id)?; + let blob_id = parse_blob_id_path(&blob_id)?; + authorize_run_scoped(parts, state.as_ref(), &run_id) + .map_err(IntoResponse::into_response)?; + Ok(Self(run_id, blob_id)) + } +} + +pub(crate) struct AuthorizeStageArtifact(pub(crate) RunId, pub(crate) StageId); + +impl FromRequestParts> for AuthorizeStageArtifact { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let Path((id, stage_id)): Path<(String, String)> = Path::from_request_parts(parts, state) + .await + .map_err(IntoResponse::into_response)?; + let run_id = parse_run_id_path(&id)?; + let stage_id = parse_stage_id_path(&stage_id)?; + authorize_run_scoped(parts, state.as_ref(), &run_id) + .map_err(IntoResponse::into_response)?; + Ok(Self(run_id, stage_id)) + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex as StdMutex}; + + use axum::http::header; + use axum::http::request::Parts; + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use chrono::Duration as ChronoDuration; + use jsonwebtoken::{Algorithm, Header, decode}; + use serde_json::json; + use tracing::field::{Field, Visit}; + use tracing::{Event, Subscriber, subscriber}; + use tracing_subscriber::layer::{Context, SubscriberExt}; + use tracing_subscriber::{Layer, Registry}; + use uuid::Uuid; + + use super::{ + WORKER_TOKEN_ISSUER, WORKER_TOKEN_SCOPE, WorkerTokenClaims, WorkerTokenKeys, + authorize_worker_token, issue_worker_token, + }; + use crate::auth; + + const TEST_SECRET: &[u8] = b"0123456789abcdef0123456789abcdef"; + const OTHER_SECRET: &[u8] = b"fedcba9876543210fedcba9876543210"; + + fn keys(secret: &[u8]) -> WorkerTokenKeys { + WorkerTokenKeys::from_master_secret(secret).expect("worker keys should derive") + } + + fn run_id() -> fabro_types::RunId { + "01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap() + } + + fn other_run_id() -> fabro_types::RunId { + "01ARZ3NDEKTSV4RRFFQ69G5FAW".parse().unwrap() + } + + fn request_parts(authorization: Option<&str>) -> Parts { + let mut builder = axum::http::Request::builder(); + if let Some(authorization) = authorization { + builder = builder.header(header::AUTHORIZATION, authorization); + } + let (parts, ()) = builder.body(()).unwrap().into_parts(); + parts + } + + fn bearer_parts(token: &str) -> Parts { + request_parts(Some(&format!("Bearer {token}"))) + } + + fn wrong_scope_token(keys: &WorkerTokenKeys, run_id: &fabro_types::RunId) -> String { + let claims = WorkerTokenClaims { + iss: WORKER_TOKEN_ISSUER.to_string(), + iat: 1, + exp: u64::MAX / 2, + run_id: run_id.to_string(), + scope: "wrong:scope".to_string(), + jti: Uuid::new_v4().simple().to_string(), + }; + jsonwebtoken::encode(&Header::new(Algorithm::HS256), &claims, &keys.encoding) + .expect("test token should encode") + } + + fn expired_worker_token(keys: &WorkerTokenKeys, run_id: &fabro_types::RunId) -> String { + let claims = WorkerTokenClaims { + iss: WORKER_TOKEN_ISSUER.to_string(), + iat: 1, + exp: 2, + run_id: run_id.to_string(), + scope: WORKER_TOKEN_SCOPE.to_string(), + jti: Uuid::new_v4().simple().to_string(), + }; + jsonwebtoken::encode(&Header::new(Algorithm::HS256), &claims, &keys.encoding) + .expect("expired test token should encode") + } + + fn alg_none_token(run_id: &fabro_types::RunId) -> String { + let header = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "alg": "none", + "typ": "JWT", + })) + .expect("jwt header should serialize"), + ); + let payload = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "iss": WORKER_TOKEN_ISSUER, + "iat": 1_u64, + "exp": u64::MAX / 2, + "run_id": run_id.to_string(), + "scope": WORKER_TOKEN_SCOPE, + "jti": Uuid::new_v4().simple().to_string(), + })) + .expect("jwt payload should serialize"), + ); + format!("{header}.{payload}.") + } + + fn issue_user_jwt() -> String { + let subject = auth::JwtSubject { + identity: fabro_types::IdpIdentity::new("https://github.com", "12345").unwrap(), + login: "octocat".to_string(), + name: "The Octocat".to_string(), + email: "octocat@example.com".to_string(), + avatar_url: "https://example.com/octocat.png".to_string(), + user_url: "https://github.com/octocat".to_string(), + auth_method: fabro_types::RunAuthMethod::Github, + }; + let key = auth::derive_jwt_key(TEST_SECRET).expect("user jwt key should derive"); + auth::issue( + &key, + "https://fabro.example", + &subject, + ChronoDuration::minutes(10), + ) + } + + #[derive(Debug)] + struct LogCapture { + target: String, + fields: Vec<(String, String)>, + } + + #[derive(Default)] + struct LogCaptureVisitor { + fields: Vec<(String, String)>, + } + + impl Visit for LogCaptureVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.fields + .push((field.name().to_string(), format!("{value:?}"))); + } + } + + struct LogCaptureLayer { + events: Arc>>, + } + + impl Layer for LogCaptureLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + if event.metadata().target() != "worker_auth" { + return; + } + + let mut visitor = LogCaptureVisitor::default(); + event.record(&mut visitor); + self.events.lock().unwrap().push(LogCapture { + target: event.metadata().target().to_string(), + fields: visitor.fields, + }); + } + } + + fn capture_logs(f: impl FnOnce() -> T) -> (T, Arc>>) { + let events = Arc::new(StdMutex::new(Vec::::new())); + let layer = LogCaptureLayer { + events: Arc::clone(&events), + }; + let subscriber = Registry::default().with(layer); + let result = subscriber::with_default(subscriber, f); + (result, events) + } + + #[test] + fn issue_worker_token_round_trips_claims() { + let run_id = run_id(); + let keys = keys(TEST_SECRET); + + let token = issue_worker_token(&keys, &run_id).expect("worker token should issue"); + let decoded = decode::(&token, &keys.decoding, &keys.validation) + .expect("worker token should decode"); + + assert_eq!(decoded.claims, WorkerTokenClaims { + iss: WORKER_TOKEN_ISSUER.to_string(), + iat: decoded.claims.iat, + exp: decoded.claims.exp, + run_id: run_id.to_string(), + scope: WORKER_TOKEN_SCOPE.to_string(), + jti: decoded.claims.jti.clone(), + }); + assert_eq!(decoded.header.alg, Algorithm::HS256); + assert_eq!(decoded.claims.jti.len(), 32); + } + + #[test] + fn worker_token_survives_key_rederivation() { + let run_id = run_id(); + let first = keys(TEST_SECRET); + let second = keys(TEST_SECRET); + + let token = issue_worker_token(&first, &run_id).expect("worker token should issue"); + let decoded = decode::(&token, &second.decoding, &second.validation) + .expect("worker token should decode after re-derivation"); + + assert_eq!(decoded.claims.run_id, run_id.to_string()); + } + + #[test] + fn worker_token_fails_under_rotated_secret() { + let run_id = run_id(); + let first = keys(TEST_SECRET); + let second = keys(OTHER_SECRET); + + let token = issue_worker_token(&first, &run_id).expect("worker token should issue"); + let err = decode::(&token, &second.decoding, &second.validation) + .expect_err("rotated secret should reject the token"); + assert!(matches!( + err.kind(), + jsonwebtoken::errors::ErrorKind::InvalidSignature + )); + } + + #[test] + fn worker_key_is_distinct_from_user_jwt_key() { + let user_key = auth::derive_jwt_key(TEST_SECRET).expect("user key should derive"); + let worker_key = + auth::derive_worker_jwt_key(TEST_SECRET).expect("worker key should derive"); + + assert_ne!(user_key.as_bytes(), worker_key); + } + + #[test] + fn authorize_worker_token_accepts_matching_run_id() { + let run_id = run_id(); + let keys = keys(TEST_SECRET); + let token = issue_worker_token(&keys, &run_id).expect("worker token should issue"); + let parts = bearer_parts(&token); + + assert!(authorize_worker_token(&parts, &run_id, &keys).unwrap()); + } + + #[test] + fn authorize_worker_token_rejects_cross_run_reuse() { + let run_id = run_id(); + let other_run_id = other_run_id(); + let keys = keys(TEST_SECRET); + let token = issue_worker_token(&keys, &other_run_id).expect("worker token should issue"); + let parts = bearer_parts(&token); + + let err = authorize_worker_token(&parts, &run_id, &keys) + .expect_err("mismatched run should reject"); + assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN); + } + + #[test] + fn authorize_worker_token_rejects_wrong_scope() { + let run_id = run_id(); + let keys = keys(TEST_SECRET); + let token = wrong_scope_token(&keys, &run_id); + let parts = bearer_parts(&token); + + let err = + authorize_worker_token(&parts, &run_id, &keys).expect_err("wrong scope should reject"); + assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN); + } + + #[test] + fn authorize_worker_token_falls_through_without_header() { + let run_id = run_id(); + let keys = keys(TEST_SECRET); + let parts = request_parts(None); + + let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys)); + + assert!(!result.unwrap()); + assert!(captured.lock().unwrap().is_empty()); + } + + #[test] + fn authorize_worker_token_falls_through_for_user_jwt_without_worker_logs() { + let run_id = run_id(); + let keys = keys(TEST_SECRET); + let token = issue_user_jwt(); + let parts = bearer_parts(&token); + + let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys)); + + assert!(!result.unwrap()); + assert!(captured.lock().unwrap().is_empty()); + } + + #[test] + fn authorize_worker_token_falls_through_for_expired_token_without_worker_logs() { + let run_id = run_id(); + let keys = keys(TEST_SECRET); + let token = expired_worker_token(&keys, &run_id); + let parts = bearer_parts(&token); + + let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys)); + + assert!(!result.unwrap()); + assert!(captured.lock().unwrap().is_empty()); + } + + #[test] + fn authorize_worker_token_falls_through_for_bad_signature_without_worker_logs() { + let run_id = run_id(); + let signer = keys(OTHER_SECRET); + let verifier = keys(TEST_SECRET); + let token = issue_worker_token(&signer, &run_id).expect("worker token should issue"); + let parts = bearer_parts(&token); + + let (result, captured) = + capture_logs(|| authorize_worker_token(&parts, &run_id, &verifier)); + + assert!(!result.unwrap()); + assert!(captured.lock().unwrap().is_empty()); + } + + #[test] + fn authorize_worker_token_falls_through_for_alg_none_without_worker_logs() { + let run_id = run_id(); + let keys = keys(TEST_SECRET); + let token = alg_none_token(&run_id); + let parts = bearer_parts(&token); + + let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys)); + + assert!(!result.unwrap()); + assert!(captured.lock().unwrap().is_empty()); + } + + #[test] + fn authorize_worker_token_logs_acceptance() { + let run_id = run_id(); + let keys = keys(TEST_SECRET); + let token = issue_worker_token(&keys, &run_id).expect("worker token should issue"); + let parts = bearer_parts(&token); + + let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys)); + + assert!(result.unwrap()); + let events = captured.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].target, "worker_auth"); + assert!(events[0] + .fields + .iter() + .any(|(field, value)| field == "message" && value.contains("worker token accepted"))); + assert!( + events[0] + .fields + .iter() + .any(|(field, value)| field == "run_id" && value.contains(&run_id.to_string())) + ); + assert!( + events[0] + .fields + .iter() + .any(|(field, value)| field == "jti" && !value.is_empty()) + ); + } + + #[test] + fn authorize_worker_token_logs_run_id_mismatch() { + let run_id = run_id(); + let other_run_id = other_run_id(); + let keys = keys(TEST_SECRET); + let token = issue_worker_token(&keys, &other_run_id).expect("worker token should issue"); + let parts = bearer_parts(&token); + + let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys)); + + let err = result.expect_err("mismatched run should reject"); + assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN); + let events = captured.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].target, "worker_auth"); + assert!( + events[0] + .fields + .iter() + .any(|(field, value)| field == "reason" && value.contains("run_id_mismatch")) + ); + } + + #[test] + fn authorize_worker_token_logs_wrong_scope() { + let run_id = run_id(); + let keys = keys(TEST_SECRET); + let token = wrong_scope_token(&keys, &run_id); + let parts = bearer_parts(&token); + + let (result, captured) = capture_logs(|| authorize_worker_token(&parts, &run_id, &keys)); + + let err = result.expect_err("wrong scope should reject"); + assert_eq!(err.status(), axum::http::StatusCode::FORBIDDEN); + let events = captured.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].target, "worker_auth"); + assert!( + events[0] + .fields + .iter() + .any(|(field, value)| field == "reason" && value.contains("wrong_scope")) + ); + } +} diff --git a/lib/crates/fabro-server/tests/it/api/docs.rs b/lib/crates/fabro-server/tests/it/api/docs.rs index e20617f90..006fe67db 100644 --- a/lib/crates/fabro-server/tests/it/api/docs.rs +++ b/lib/crates/fabro-server/tests/it/api/docs.rs @@ -23,8 +23,12 @@ fn security_doc_does_not_require_jwt_keys_for_the_current_web_flow() { "security doc should still mention the session secret" ); assert!( - !security.contains("`FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY`, and `SESSION_SECRET`"), - "security doc should not describe JWT keys as required for the current web flow" + !security.contains("FABRO_JWT_PRIVATE_KEY"), + "security doc should not mention removed JWT key settings" + ); + assert!( + !security.contains("FABRO_JWT_PUBLIC_KEY"), + "security doc should not mention removed JWT key settings" ); } diff --git a/lib/crates/fabro-server/tests/it/api/install.rs b/lib/crates/fabro-server/tests/it/api/install.rs index c5bc618d0..95bc8d4d6 100644 --- a/lib/crates/fabro-server/tests/it/api/install.rs +++ b/lib/crates/fabro-server/tests/it/api/install.rs @@ -764,8 +764,6 @@ async fn token_install_finish_persists_settings_env_and_vault() { .env_path(), ) .unwrap(); - assert!(server_env.contains("FABRO_JWT_PRIVATE_KEY=")); - assert!(server_env.contains("FABRO_JWT_PUBLIC_KEY=")); assert!(server_env.contains("SESSION_SECRET=")); assert!(server_env.contains("FABRO_DEV_TOKEN=")); assert!(!server_env.contains("AWS_ACCESS_KEY_ID=")); diff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs index 898d6fc2a..a12b067bf 100644 --- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs +++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs @@ -12,7 +12,7 @@ use axum::body::Body; use axum::http::{Method, Request, StatusCode}; use fabro_server::install::{InstallAppState, build_install_router}; use fabro_server::jwt_auth::AuthMode; -use fabro_server::server::{build_router, create_app_state_with_env_lookup}; +use fabro_server::server::{build_router, create_app_state_with_env_lookup_and_server_secret_env}; use serde_yaml::Value; use tower::ServiceExt; @@ -146,9 +146,12 @@ fn github_webhook_spec_and_sdk_describe_a_json_body() { async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present() { let secret = "test-webhook-secret".to_string(); let app = build_router( - create_app_state_with_env_lookup(test_settings(), 5, move |name| { - (name == "GITHUB_APP_WEBHOOK_SECRET").then(|| secret.clone()) - }), + create_app_state_with_env_lookup_and_server_secret_env( + test_settings(), + 5, + |_| None, + &std::collections::HashMap::from([("GITHUB_APP_WEBHOOK_SECRET".to_string(), secret)]), + ), AuthMode::Disabled, ); diff --git a/lib/crates/fabro-telemetry/src/spawn.rs b/lib/crates/fabro-telemetry/src/spawn.rs index 741ea8748..08e4abe05 100644 --- a/lib/crates/fabro-telemetry/src/spawn.rs +++ b/lib/crates/fabro-telemetry/src/spawn.rs @@ -37,7 +37,7 @@ pub fn spawn_detached(args: &[&str], env: &[(&str, &str)], env_remove: &[&str]) #[expect( clippy::disallowed_types, clippy::disallowed_methods, - reason = "Detaching must flush stdio synchronously before the double-fork." + reason = "Detaching must flush stdio synchronously before the double-fork; post-fork pre-exec env mutation is the one allowed exception to the workspace env-mutation ban." )] fn spawn_detached_unix(args: &[&str], env: &[(&str, &str)], env_remove: &[&str]) { // Flush stdout/stderr before forking so the child process doesn't inherit diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs index 05b5cca44..4c9f690bf 100644 --- a/lib/crates/fabro-test/src/lib.rs +++ b/lib/crates/fabro-test/src/lib.rs @@ -631,8 +631,11 @@ fn write_settings_file(path: &Path, storage_dir: &Path, rest: &str) { fn write_test_server_dev_token(storage_dir: &Path) { let server_env_path = Storage::new(storage_dir).runtime_directory().env_path(); - envfile::merge_env_file(&server_env_path, [("FABRO_DEV_TOKEN", TEST_DEV_TOKEN)]) - .unwrap_or_else(|err| panic!("failed to write {}: {err}", server_env_path.display())); + envfile::merge_env_file(&server_env_path, [ + ("FABRO_DEV_TOKEN", TEST_DEV_TOKEN), + ("SESSION_SECRET", TEST_SESSION_SECRET), + ]) + .unwrap_or_else(|err| panic!("failed to write {}: {err}", server_env_path.display())); } fn write_test_home_dev_token(settings_path: &Path) { diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index ed11b0a4f..c3d7cdc5f 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -60,6 +60,7 @@ pub use run_id::{RunId, fixtures}; pub use run_projection::{NodeState, PendingInterviewRecord, RunProjection}; pub use run_summary::RunSummary; pub use sandbox_record::SandboxRecord; +pub use settings::Combine; pub use stage_id::{ParallelBranchId, StageId}; pub use start::StartRecord; pub use status::{ diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index cfea3dd4c..e296f323f 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -61,6 +61,15 @@ impl ActorRef { display, } } + + #[must_use] + pub fn system_worker() -> Self { + Self { + kind: ActorKind::System, + id: Some("worker".to_string()), + display: Some("system:worker".to_string()), + } + } } #[derive(Debug, Clone, PartialEq)] diff --git a/lib/crates/fabro-types/src/settings/cli.rs b/lib/crates/fabro-types/src/settings/cli.rs index fb38d1bef..46bf0abac 100644 --- a/lib/crates/fabro-types/src/settings/cli.rs +++ b/lib/crates/fabro-types/src/settings/cli.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use super::interp::InterpString; +use super::maps::StickyMap; use super::run::{AgentPermissions, McpEntryLayer, McpServerSettings}; /// A structurally resolved `[cli]` view for consumers. @@ -71,7 +72,7 @@ pub struct CliLoggingSettings { } /// A sparse `[cli]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct CliLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -119,7 +120,7 @@ pub enum CliAuthStrategy { } /// `[cli.exec]` — `fabro exec` defaults. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct CliExecLayer { /// Prevent idle sleep on macOS while an exec run is in flight. @@ -131,7 +132,7 @@ pub struct CliExecLayer { pub agent: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct CliExecModelLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -140,18 +141,18 @@ pub struct CliExecModelLayer { pub name: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct CliExecAgentLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub permissions: Option, /// Agent-scoped MCP entries for `fabro exec`. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub mcps: HashMap, + #[serde(default, skip_serializing_if = "StickyMap::is_empty")] + pub mcps: StickyMap, } /// `[cli.output]` — generic CLI output defaults. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct CliOutputLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -178,7 +179,7 @@ pub enum OutputVerbosity { } /// `[cli.updates]` — upgrade check toggle. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct CliUpdatesLayer { #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/lib/crates/fabro-types/src/settings/combine.rs b/lib/crates/fabro-types/src/settings/combine.rs new file mode 100644 index 000000000..985f14e66 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/combine.rs @@ -0,0 +1,335 @@ +use std::collections::HashMap; + +use super::cli::{ + CliAuthLayer, CliAuthStrategy, CliLoggingLayer, CliTargetLayer, OutputFormat, OutputVerbosity, +}; +use super::duration::Duration; +use super::features::FeaturesLayer; +use super::interp::InterpString; +use super::run::{ + AgentPermissions, ApprovalMode, DaytonaNetworkLayer, DaytonaSnapshotLayer, HookAgentMarker, + HookEntry, HookTlsMode, InterviewProviderLayer, LocalSandboxLayer, MergeStrategy, + ModelRefOrSplice, NotificationProviderLayer, RunArtifactsLayer, RunCheckpointLayer, + RunGoalLayer, RunMode, RunPrepareLayer, ScmGitHubLayer, StringOrSplice, WorktreeMode, +}; +use super::server::{ + GithubIntegrationStrategy, ObjectStoreLocalLayer, ObjectStoreProvider, ObjectStoreS3Layer, + ServerApiLayer, ServerAuthGithubLayer, ServerAuthMethod, ServerListenLayer, ServerLoggingLayer, + WebhookStrategy, +}; +use super::size::Size; + +pub trait Combine { + /// Combine two values, preferring the values in `self`. + #[must_use] + fn combine(self, other: Self) -> Self; +} + +impl Combine for Option { + fn combine(self, other: Self) -> Self { + match (self, other) { + (Some(this), Some(fallback)) => Some(this.combine(fallback)), + (this, fallback) => this.or(fallback), + } + } +} + +macro_rules! impl_combine_or_option { + ($($ty:ty),+ $(,)?) => { + $( + impl Combine for Option<$ty> { + fn combine(self, other: Self) -> Self { + self.or(other) + } + } + )+ + }; +} + +impl_combine_or_option!( + String, + bool, + u16, + u32, + u64, + usize, + i32, + Duration, + InterpString, + Size, + CliAuthStrategy, + OutputFormat, + OutputVerbosity, + AgentPermissions, + ApprovalMode, + HookAgentMarker, + HookTlsMode, + MergeStrategy, + RunMode, + WorktreeMode, + GithubIntegrationStrategy, + ObjectStoreProvider, + ServerAuthMethod, + WebhookStrategy, +); + +impl Combine for Option> { + fn combine(self, other: Self) -> Self { + self.or(other) + } +} + +impl Combine for Option> { + fn combine(self, other: Self) -> Self { + self.or(other) + } +} + +impl Combine for Option> { + fn combine(self, other: Self) -> Self { + self.or(other) + } +} + +macro_rules! impl_combine_self { + ($($ty:ty),+ $(,)?) => { + $( + impl Combine for $ty { + fn combine(self, _other: Self) -> Self { + self + } + } + )+ + }; +} + +impl_combine_self!( + CliAuthLayer, + CliLoggingLayer, + CliTargetLayer, + FeaturesLayer, + DaytonaNetworkLayer, + DaytonaSnapshotLayer, + InterviewProviderLayer, + LocalSandboxLayer, + NotificationProviderLayer, + RunArtifactsLayer, + RunGoalLayer, + RunPrepareLayer, + ScmGitHubLayer, + ObjectStoreLocalLayer, + ObjectStoreS3Layer, + ServerApiLayer, + ServerAuthGithubLayer, + ServerListenLayer, + ServerLoggingLayer, +); + +impl Combine for RunCheckpointLayer { + fn combine(self, other: Self) -> Self { + if self.exclude_globs.is_empty() { + other + } else { + self + } + } +} + +/// An element of a splice-aware sequence: either a regular value or the +/// `...` marker that asks the combiner to expand the fallback list inline. +pub trait SpliceMarker { + fn is_splice(&self) -> bool; +} + +impl SpliceMarker for ModelRefOrSplice { + fn is_splice(&self) -> bool { + matches!(self, Self::Splice) + } +} + +impl SpliceMarker for StringOrSplice { + fn is_splice(&self) -> bool { + matches!(self, Self::Splice) + } +} + +impl Combine for Vec { + fn combine(self, other: Self) -> Self { + splice_combine(other, self) + } +} + +impl Combine for Vec { + fn combine(self, other: Self) -> Self { + combine_hooks(&other, self) + } +} + +fn splice_combine(fallback: Vec, current: Vec) -> Vec { + if current.is_empty() { + return fallback; + } + let Some(pos) = current.iter().position(T::is_splice) else { + return current; + }; + let mut out = Vec::with_capacity(current.len() - 1 + fallback.len()); + for (index, entry) in current.into_iter().enumerate() { + if index == pos { + out.extend(fallback.iter().filter(|entry| !entry.is_splice()).cloned()); + } else if !entry.is_splice() { + out.push(entry); + } + } + out +} + +fn combine_hooks(fallback: &[HookEntry], current: Vec) -> Vec { + let mut out = Vec::with_capacity(fallback.len() + current.len()); + let mut appended_ids = Vec::new(); + + for fallback_entry in fallback { + if let Some(id) = &fallback_entry.id { + if let Some(replacement) = current + .iter() + .find(|entry| entry.id.as_deref() == Some(id.as_str())) + { + out.push(replacement.clone()); + appended_ids.push(id.clone()); + continue; + } + } + out.push(fallback_entry.clone()); + } + + for current_entry in current { + if let Some(id) = ¤t_entry.id { + if appended_ids.contains(id) { + continue; + } + } + out.push(current_entry); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, PartialEq, fabro_macros::Combine)] + struct FieldMergeLayer { + a: Option, + b: Option, + } + + #[derive(Debug, PartialEq)] + struct WholeReplaceLayer { + a: Option, + b: Option, + } + + impl Combine for WholeReplaceLayer { + fn combine(self, _other: Self) -> Self { + self + } + } + + #[track_caller] + fn assert_option_leaf(this: T, fallback: T) + where + T: Clone + std::fmt::Debug + PartialEq, + Option: Combine, + { + assert_eq!( + Some(this.clone()).combine(Some(fallback.clone())), + Some(this) + ); + assert_eq!( + Option::::None.combine(Some(fallback.clone())), + Some(fallback) + ); + } + + #[test] + fn option_leaf_types_prefer_self_or_fallback() { + assert_option_leaf("this".to_string(), "fallback".to_string()); + assert_option_leaf(true, false); + assert_option_leaf(1_u16, 2_u16); + assert_option_leaf(1_u32, 2_u32); + assert_option_leaf(1_u64, 2_u64); + assert_option_leaf(1_usize, 2_usize); + assert_option_leaf(1_i32, 2_i32); + assert_option_leaf(Duration::from_secs(1), Duration::from_secs(2)); + assert_option_leaf(InterpString::parse("this"), InterpString::parse("fallback")); + assert_option_leaf(Size::from_bytes(1), Size::from_bytes(2)); + assert_option_leaf(CliAuthStrategy::None, CliAuthStrategy::Jwt); + assert_option_leaf(OutputFormat::Json, OutputFormat::Text); + assert_option_leaf(OutputVerbosity::Quiet, OutputVerbosity::Verbose); + assert_option_leaf(AgentPermissions::ReadOnly, AgentPermissions::Full); + assert_option_leaf(ApprovalMode::Auto, ApprovalMode::Prompt); + assert_option_leaf(HookAgentMarker::Enabled, HookAgentMarker::Enabled); + assert_option_leaf(HookTlsMode::NoVerify, HookTlsMode::Verify); + assert_option_leaf(MergeStrategy::Rebase, MergeStrategy::Squash); + assert_option_leaf(RunMode::DryRun, RunMode::Normal); + assert_option_leaf(WorktreeMode::Always, WorktreeMode::Never); + assert_option_leaf( + GithubIntegrationStrategy::App, + GithubIntegrationStrategy::Token, + ); + assert_option_leaf(ObjectStoreProvider::S3, ObjectStoreProvider::Local); + assert_option_leaf(ServerAuthMethod::Github, ServerAuthMethod::DevToken); + assert_option_leaf(WebhookStrategy::ServerUrl, WebhookStrategy::TailscaleFunnel); + assert_option_leaf(vec!["this".to_string()], vec!["fallback".to_string()]); + assert_option_leaf(vec![ServerAuthMethod::Github], vec![ + ServerAuthMethod::DevToken, + ]); + assert_option_leaf( + HashMap::from([("this".to_string(), toml::Value::String("value".to_string()))]), + HashMap::from([( + "fallback".to_string(), + toml::Value::String("value".to_string()), + )]), + ); + } + + #[test] + fn recursive_option_combines_inner_fields() { + let this = Some(FieldMergeLayer { + a: Some(1), + b: None, + }); + let fallback = Some(FieldMergeLayer { + a: Some(2), + b: Some(3), + }); + + assert_eq!( + this.combine(fallback), + Some(FieldMergeLayer { + a: Some(1), + b: Some(3), + }) + ); + } + + #[test] + fn whole_replace_inner_does_not_inherit_fallback_fields() { + let this = Some(WholeReplaceLayer { + a: Some(1), + b: None, + }); + let fallback = Some(WholeReplaceLayer { + a: Some(2), + b: Some(3), + }); + + assert_eq!( + this.combine(fallback), + Some(WholeReplaceLayer { + a: Some(1), + b: None, + }) + ); + } +} diff --git a/lib/crates/fabro-types/src/settings/layer.rs b/lib/crates/fabro-types/src/settings/layer.rs index 188ece46d..c3dd77837 100644 --- a/lib/crates/fabro-types/src/settings/layer.rs +++ b/lib/crates/fabro-types/src/settings/layer.rs @@ -15,7 +15,7 @@ use super::server::ServerLayer; use super::workflow::WorkflowLayer; /// A sparse settings layer before merge/resolve. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] pub struct SettingsLayer { #[serde(default, rename = "_version", skip_serializing_if = "Option::is_none")] pub version: Option, diff --git a/lib/crates/fabro-types/src/settings/maps.rs b/lib/crates/fabro-types/src/settings/maps.rs new file mode 100644 index 000000000..f34bdb6a7 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/maps.rs @@ -0,0 +1,198 @@ +use std::collections::HashMap; +use std::collections::hash_map::IntoIter; +use std::ops::{Deref, DerefMut}; + +use serde::{Deserialize, Serialize}; + +use super::combine::Combine; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ReplaceMap(pub HashMap); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct StickyMap(pub HashMap); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct MergeMap(pub HashMap); + +macro_rules! impl_map_wrapper { + ($name:ident) => { + impl $name { + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + #[must_use] + pub fn into_inner(self) -> HashMap { + self.0 + } + } + + impl Deref for $name { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl DerefMut for $name { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } + } + + impl From> for $name { + fn from(value: HashMap) -> Self { + Self(value) + } + } + + impl Default for $name { + fn default() -> Self { + Self(HashMap::new()) + } + } + + impl IntoIterator for $name { + type IntoIter = IntoIter; + type Item = (String, V); + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } + } + }; +} + +impl_map_wrapper!(ReplaceMap); +impl_map_wrapper!(StickyMap); +impl_map_wrapper!(MergeMap); + +impl Combine for ReplaceMap { + fn combine(self, other: Self) -> Self { + if self.0.is_empty() { other } else { self } + } +} + +impl Combine for StickyMap { + fn combine(self, other: Self) -> Self { + let mut combined = other.0; + for (key, value) in self.0 { + combined.insert(key, value); + } + Self(combined) + } +} + +impl Combine for MergeMap { + fn combine(self, other: Self) -> Self { + let mut combined = other.0; + for (key, value) in self.0 { + let value = match combined.remove(&key) { + Some(fallback) => value.combine(fallback), + None => value, + }; + combined.insert(key, value); + } + Self(combined) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, PartialEq, fabro_macros::Combine)] + struct ValueLayer { + a: Option, + b: Option, + } + + #[test] + fn replace_map_self_wins_when_non_empty() { + let this = ReplaceMap(HashMap::from([("a".to_string(), "this".to_string())])); + let fallback = ReplaceMap(HashMap::from([ + ("a".to_string(), "fallback".to_string()), + ("b".to_string(), "fallback".to_string()), + ])); + + assert_eq!( + this.combine(fallback), + ReplaceMap(HashMap::from([("a".to_string(), "this".to_string())])) + ); + } + + #[test] + fn replace_map_empty_self_uses_fallback() { + let this = ReplaceMap::(HashMap::new()); + let fallback = ReplaceMap(HashMap::from([("a".to_string(), "fallback".to_string())])); + + assert_eq!( + this.combine(fallback), + ReplaceMap(HashMap::from([("a".to_string(), "fallback".to_string())])) + ); + } + + #[test] + fn replace_map_round_trips_as_toml_table() { + let parsed: ReplaceMap = + toml::from_str(r#"a = "one""#).expect("fixture should deserialize"); + + assert_eq!( + parsed, + ReplaceMap(HashMap::from([("a".to_string(), "one".to_string())])) + ); + + let serialized = toml::to_string(&parsed).expect("fixture should serialize"); + let reparsed: ReplaceMap = + toml::from_str(&serialized).expect("fixture should deserialize again"); + + assert_eq!(reparsed, parsed); + } + + #[test] + fn sticky_map_merges_keys_with_self_winning_conflicts() { + let this = StickyMap(HashMap::from([ + ("a".to_string(), "this".to_string()), + ("c".to_string(), "this".to_string()), + ])); + let fallback = StickyMap(HashMap::from([ + ("a".to_string(), "fallback".to_string()), + ("b".to_string(), "fallback".to_string()), + ])); + + assert_eq!( + this.combine(fallback), + StickyMap(HashMap::from([ + ("a".to_string(), "this".to_string()), + ("b".to_string(), "fallback".to_string()), + ("c".to_string(), "this".to_string()), + ])) + ); + } + + #[test] + fn merge_map_recursively_combines_values_for_matching_keys() { + let this = MergeMap(HashMap::from([("ops".to_string(), ValueLayer { + a: Some("this".to_string()), + b: None, + })])); + let fallback = MergeMap(HashMap::from([("ops".to_string(), ValueLayer { + a: Some("fallback".to_string()), + b: Some("fallback".to_string()), + })])); + + assert_eq!( + this.combine(fallback), + MergeMap(HashMap::from([("ops".to_string(), ValueLayer { + a: Some("this".to_string()), + b: Some("fallback".to_string()), + },)])) + ); + } +} diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 6b6638089..95831e6cd 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -10,10 +10,12 @@ //! exists. pub mod cli; +pub mod combine; pub mod duration; pub mod features; pub mod interp; pub mod layer; +pub mod maps; pub mod model_ref; pub mod project; pub mod run; @@ -26,10 +28,12 @@ pub use cli::{ CliAuthSettings, CliExecAgentSettings, CliExecModelSettings, CliExecSettings, CliLayer, CliLoggingSettings, CliNamespace, CliOutputSettings, CliTargetSettings, CliUpdatesSettings, }; +pub use combine::Combine; pub use duration::{Duration, ParseDurationError}; pub use features::{FeaturesLayer, FeaturesNamespace}; pub use interp::{InterpString, Provenance, ResolveEnvError, Resolved}; pub use layer::SettingsLayer; +pub use maps::{MergeMap, ReplaceMap, StickyMap}; pub use model_ref::{ AmbiguousModelRef, ModelRef, ModelRegistry, ParseModelRefError, ResolvedModelRef, }; diff --git a/lib/crates/fabro-types/src/settings/project.rs b/lib/crates/fabro-types/src/settings/project.rs index e62757aed..9b4098bc8 100644 --- a/lib/crates/fabro-types/src/settings/project.rs +++ b/lib/crates/fabro-types/src/settings/project.rs @@ -7,6 +7,8 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use super::maps::ReplaceMap; + /// A structurally resolved `[project]` view for consumers. #[derive(Debug, Clone, Default, PartialEq, Serialize)] pub struct ProjectNamespace { @@ -17,7 +19,7 @@ pub struct ProjectNamespace { } /// A sparse `[project]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct ProjectLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -28,6 +30,6 @@ pub struct ProjectLayer { /// `.` after layering when unspecified. #[serde(default, skip_serializing_if = "Option::is_none")] pub directory: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub metadata: HashMap, + #[serde(default, skip_serializing_if = "ReplaceMap::is_empty")] + pub metadata: ReplaceMap, } diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index 55c2cf0f7..fea4d7f85 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize}; use super::duration::Duration; use super::interp::InterpString; +use super::maps::{MergeMap, ReplaceMap, StickyMap}; use super::model_ref::ModelRef; /// A structurally resolved `[run]` view for consumers. @@ -417,7 +418,7 @@ pub struct ArtifactsSettings { } /// A sparse `[run]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct RunLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -425,8 +426,8 @@ pub struct RunLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub working_dir: Option, /// Flat string-to-string map. Replaces wholesale across layers. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub metadata: HashMap, + #[serde(default, skip_serializing_if = "ReplaceMap::is_empty")] + pub metadata: ReplaceMap, /// Run inputs: typed scalar values. Replaces wholesale across layers. #[serde(default, skip_serializing_if = "Option::is_none")] pub inputs: Option>, @@ -442,8 +443,8 @@ pub struct RunLayer { pub checkpoint: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub notifications: HashMap, + #[serde(default, skip_serializing_if = "MergeMap::is_empty")] + pub notifications: MergeMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub interviews: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -508,7 +509,7 @@ pub enum ResolvedGoalSource { } /// `[run.model]` — provider-neutral default model selection. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct RunModelLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -550,14 +551,14 @@ impl<'de> Deserialize<'de> for ModelRefOrSplice { } /// `[run.git]` — local git behavior such as commit author. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct RunGitLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub author: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct GitAuthorLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -591,7 +592,7 @@ pub struct PrepareStep { } /// `[run.execution]` — run posture knobs. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct RunExecutionLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -626,7 +627,7 @@ pub struct RunCheckpointLayer { } /// `[run.sandbox]` — sandbox selection and execution-environment surface. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct RunSandboxLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -636,8 +637,8 @@ pub struct RunSandboxLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub devcontainer: Option, /// Sticky merge-by-key across layers. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub env: HashMap, + #[serde(default, skip_serializing_if = "StickyMap::is_empty")] + pub env: StickyMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub local: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -661,14 +662,14 @@ pub enum WorktreeMode { Never, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct DaytonaSandboxLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_stop_interval: Option, /// Sticky merge-by-key (provider-native labels). - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub labels: HashMap, + #[serde(default, skip_serializing_if = "StickyMap::is_empty")] + pub labels: StickyMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub snapshot: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -708,7 +709,7 @@ pub enum DaytonaNetworkLayer { } /// `[run.notifications.]` — a keyed notification route. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct NotificationRouteLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -763,7 +764,7 @@ pub struct NotificationProviderLayer { } /// `[run.interviews]` — external interview delivery. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct InterviewsLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -784,14 +785,14 @@ pub struct InterviewProviderLayer { } /// `[run.agent]` — agent knobs only (permissions, MCPs). -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct RunAgentLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub permissions: Option, /// Agent-scoped MCP server entries, keyed by name. - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub mcps: HashMap, + #[serde(default, skip_serializing_if = "StickyMap::is_empty")] + pub mcps: StickyMap, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -935,7 +936,7 @@ pub enum HookEvent { } /// `[run.scm]` — remote SCM host/provider behavior. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct RunScmLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -957,7 +958,7 @@ pub struct RunScmLayer { pub struct ScmGitHubLayer; /// `[run.pull_request]` — provider-neutral PR behavior. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct RunPullRequestLayer { #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs index 9e4d300e7..9c31198de 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -15,6 +15,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::duration::Duration as DurationLayer; use super::interp::InterpString; +use super::maps::StickyMap; /// A structurally resolved `[server]` view for consumers. /// @@ -306,7 +307,7 @@ where } /// A sparse `[server]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct ServerLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -358,7 +359,7 @@ pub struct ServerApiLayer { } /// `[server.web]` — web surface settings. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct ServerWebLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -372,7 +373,7 @@ pub struct ServerWebLayer { /// When absent or resolved to no enabled API or web auth configuration, the /// default server startup posture is fail-closed. Demo and test helpers may /// explicitly opt in to insecure configurations. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct ServerAuthLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -388,7 +389,7 @@ pub struct ServerAuthGithubLayer { pub allowed_usernames: Vec, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct ServerIpAllowlistLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -397,7 +398,7 @@ pub struct ServerIpAllowlistLayer { pub trusted_proxy_count: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct ServerIpAllowlistOverrideLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -407,7 +408,7 @@ pub struct ServerIpAllowlistOverrideLayer { } /// `[server.storage]` — single managed local disk root. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct ServerStorageLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -415,7 +416,7 @@ pub struct ServerStorageLayer { } /// `[server.artifacts]` — object-store-backed artifact storage. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct ServerArtifactsLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -429,7 +430,7 @@ pub struct ServerArtifactsLayer { } /// `[server.slatedb]` — SlateDB bottomless storage plus tunables. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct ServerSlateDbLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -478,7 +479,7 @@ pub struct ObjectStoreS3Layer { } /// `[server.scheduler]` — server-managed execution policy. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct ServerSchedulerLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -497,7 +498,7 @@ pub struct ServerLoggingLayer { /// platforms and git providers (GitHub App, webhooks, etc.). First-pass /// integrations enumerate known providers rather than using a flatten-HashMap /// shape so strict unknown-field validation still holds. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct ServerIntegrationsLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -512,7 +513,7 @@ pub struct ServerIntegrationsLayer { /// `[server.integrations.github]` — GitHub App, credentials, and inbound /// webhooks. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct GithubIntegrationLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -525,14 +526,14 @@ pub struct GithubIntegrationLayer { pub client_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub slug: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub permissions: HashMap, + #[serde(default, skip_serializing_if = "StickyMap::is_empty")] + pub permissions: StickyMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub webhooks: Option, } /// `[server.integrations.slack]` — Slack workspace credentials and defaults. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct SlackIntegrationLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -542,7 +543,7 @@ pub struct SlackIntegrationLayer { } /// `[server.integrations.discord]` — Discord workspace configuration. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct DiscordIntegrationLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -550,14 +551,14 @@ pub struct DiscordIntegrationLayer { } /// `[server.integrations.teams]` — Microsoft Teams configuration. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct TeamsIntegrationLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub enabled: Option, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct IntegrationWebhooksLayer { #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/lib/crates/fabro-types/src/settings/workflow.rs b/lib/crates/fabro-types/src/settings/workflow.rs index 1568cc5c2..e7d47d045 100644 --- a/lib/crates/fabro-types/src/settings/workflow.rs +++ b/lib/crates/fabro-types/src/settings/workflow.rs @@ -7,6 +7,8 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use super::maps::ReplaceMap; + /// A structurally resolved `[workflow]` view for consumers. #[derive(Debug, Clone, Default, PartialEq, Serialize)] pub struct WorkflowNamespace { @@ -17,7 +19,7 @@ pub struct WorkflowNamespace { } /// A sparse `[workflow]` layer as it appears in a single settings file. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] pub struct WorkflowLayer { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -27,6 +29,6 @@ pub struct WorkflowLayer { /// Optional override for the default `workflow.fabro` graph path. #[serde(default, skip_serializing_if = "Option::is_none")] pub graph: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub metadata: HashMap, + #[serde(default, skip_serializing_if = "ReplaceMap::is_empty")] + pub metadata: ReplaceMap, } diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 969cbd7ac..6eb049bb9 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -2649,11 +2649,16 @@ pub enum RunEventSink { Store(RunStoreHandle), JsonLines(Arc>>>), Callback(Arc), + Map { + transform: Arc, + inner: Box, + }, Composite(Vec), } type RunEventSinkFuture = Pin> + Send + 'static>>; type RunEventSinkCallback = dyn Fn(RunEvent) -> RunEventSinkFuture + Send + Sync + 'static; +type RunEventTransform = dyn Fn(RunEvent) -> RunEvent + Send + Sync + 'static; impl RunEventSink { #[must_use] @@ -2695,23 +2700,39 @@ impl RunEventSink { Self::Composite(flattened) } + #[must_use] + pub fn map(transform: F, inner: Self) -> Self + where + F: Fn(RunEvent) -> RunEvent + Send + Sync + 'static, + { + Self::Map { + transform: Arc::new(transform), + inner: Box::new(inner), + } + } + pub async fn write_run_event(&self, event: &RunEvent) -> Result<()> { - let mut pending = vec![self]; - while let Some(sink) = pending.pop() { + let mut pending = vec![(self, event.clone())]; + while let Some((sink, event)) = pending.pop() { match sink { Self::Store(run_store) => { - run_store.append_run_event(event).await?; + run_store.append_run_event(&event).await?; } Self::JsonLines(writer) => { - let line = redacted_event_json(event)?; + let line = redacted_event_json(&event)?; let mut writer = writer.lock().await; writer.write_all(line.as_bytes()).await?; writer.write_all(b"\n").await?; writer.flush().await?; } - Self::Callback(callback) => callback(event.clone()).await?, + Self::Callback(callback) => callback(event).await?, + Self::Map { transform, inner } => { + pending.push((inner.as_ref(), transform(event))); + } Self::Composite(sinks) => { - pending.extend(sinks.iter().rev()); + for sink in sinks.iter().rev() { + pending.push((sink, event.clone())); + } } } } @@ -3174,6 +3195,46 @@ mod tests { assert_eq!(payload.as_value()["properties"]["action"], "pause"); } + #[tokio::test] + async fn run_event_sink_map_applies_transform_before_fanout() { + let first = Arc::new(AsyncMutex::new(Vec::new())); + let second = Arc::new(AsyncMutex::new(Vec::new())); + let first_events = Arc::clone(&first); + let second_events = Arc::clone(&second); + let sink = RunEventSink::map( + |mut event| { + event.actor = Some(ActorRef::user("alice".to_string())); + event + }, + RunEventSink::fanout(vec![ + RunEventSink::callback(move |event| { + let first_events = Arc::clone(&first_events); + async move { + first_events.lock().await.push(event); + Ok(()) + } + }), + RunEventSink::callback(move |event| { + let second_events = Arc::clone(&second_events); + async move { + second_events.lock().await.push(event); + Ok(()) + } + }), + ]), + ); + let event = to_run_event(&fixtures::RUN_7, &Event::RunPauseRequested { actor: None }); + + sink.write_run_event(&event).await.unwrap(); + + let first = first.lock().await; + let second = second.lock().await; + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_eq!(first[0].actor, Some(ActorRef::user("alice".to_string()))); + assert_eq!(second[0].actor, Some(ActorRef::user("alice".to_string()))); + } + #[tokio::test] async fn run_event_logger_registers_emitter_events_to_json_lines() { use tokio::io::{AsyncBufReadExt, BufReader}; diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 149e799d1..d335cd356 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -8,17 +8,15 @@ use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use fabro_config::Storage; +use fabro_config::{Storage, WorkflowSettings}; use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_model::{Catalog, Provider}; use fabro_sandbox::SandboxProvider; use fabro_sandbox::daytona::detect_repo_info; use fabro_store::Database; use fabro_template::{TemplateContext, render as render_template}; +use fabro_types::settings::SettingsLayer; use fabro_types::settings::run::RunMode; -use fabro_types::settings::{ - InterpString, ProjectNamespace, RunNamespace, SettingsLayer, WorkflowNamespace, -}; use fabro_types::{RunId, RunProvenance}; use fabro_util::json::normalize_json_value; use tokio::task::spawn_blocking; @@ -60,13 +58,6 @@ pub struct CreatedRun { pub dot_path: Option, } -struct ResolvedSettingsTree { - server_storage_root: InterpString, - project: ProjectNamespace, - workflow: WorkflowNamespace, - run: RunNamespace, -} - struct PersistCreateOptions { settings: SettingsLayer, run_id: Option, @@ -82,7 +73,11 @@ struct PersistCreateOptions { } /// Resolve workflow inputs, normalize settings, and persist a run directory. -pub async fn create(store: &Database, request: CreateRunInput) -> Result { +pub async fn create( + store: &Database, + request: CreateRunInput, + storage_root: PathBuf, +) -> Result { let resolved = resolve_workflow(ResolveWorkflowInput { workflow: request.workflow, settings: request.settings, @@ -113,18 +108,10 @@ pub async fn create(store: &Database, request: CreateRunInput) -> Result Result Error { Error::engine(err.to_string()) } -fn resolve_settings_tree(settings: &SettingsLayer) -> Result { - let resolver = fabro_config::Resolver::from_layer(settings); - let to_error = - |errors: Vec<_>| Error::Precondition(fabro_config::render_resolve_errors(&errors)); - Ok(ResolvedSettingsTree { - server_storage_root: resolver.storage_root(), - project: resolver.project().map_err(to_error)?, - workflow: resolver.workflow().map_err(to_error)?, - run: resolver.run().map_err(to_error)?, - }) -} - -fn combined_labels(settings: &ResolvedSettingsTree) -> HashMap { - let mut labels = settings.project.metadata.clone(); - labels.extend(settings.workflow.metadata.clone()); - labels.extend(settings.run.metadata.clone()); - labels -} - fn validate_sandbox_provider(settings: &SettingsLayer) -> Result<(), Error> { let resolved = fabro_config::resolve_run_from_file(settings) .map_err(|errors| Error::Precondition(fabro_config::render_resolve_errors(&errors)))?; @@ -735,25 +703,30 @@ mod tests { work [label="Work"] }"#; let dir = tempfile::tempdir().unwrap(); + let storage_root = dir.path().join("storage"); let store = memory_store(); - let err = create(&store, CreateRunInput { - workflow: WorkflowInput::DotSource { - source: dot.to_string(), - base_dir: None, + let err = create( + &store, + CreateRunInput { + workflow: WorkflowInput::DotSource { + source: dot.to_string(), + base_dir: None, + }, + settings: test_default_settings(), + cwd: dir.path().to_path_buf(), + workflow_slug: None, + workflow_path: None, + workflow_bundle: None, + submitted_manifest_bytes: None, + run_id: None, + host_repo_path: None, + repo_origin_url: None, + base_branch: None, + provenance: None, + configured_providers: Vec::new(), }, - settings: test_default_settings(), - cwd: dir.path().to_path_buf(), - workflow_slug: None, - workflow_path: None, - workflow_bundle: None, - submitted_manifest_bytes: None, - run_id: None, - host_repo_path: None, - repo_origin_url: None, - base_branch: None, - provenance: None, - configured_providers: Vec::new(), - }) + storage_root, + ) .await .unwrap_err(); @@ -765,57 +738,122 @@ mod tests { } } + #[tokio::test] + async fn create_reports_workflow_settings_errors_with_rendered_message() { + let dir = tempfile::tempdir().unwrap(); + let storage_root = dir.path().join("storage"); + let store = memory_store(); + let err = create( + &store, + CreateRunInput { + workflow: WorkflowInput::DotSource { + source: MINIMAL_DOT.to_string(), + base_dir: None, + }, + settings: { + use fabro_types::settings::run::{ + RunExecutionLayer, RunLayer, RunMode, RunSandboxLayer, + }; + let mut layer = SettingsLayer { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + sandbox: Some(RunSandboxLayer { + provider: Some("not-a-provider".to_string()), + ..RunSandboxLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsLayer::default() + }; + layer.ensure_test_auth_methods(); + layer + }, + cwd: dir.path().to_path_buf(), + workflow_slug: None, + workflow_path: None, + workflow_bundle: None, + submitted_manifest_bytes: None, + run_id: None, + host_repo_path: None, + repo_origin_url: None, + base_branch: None, + provenance: None, + configured_providers: Vec::new(), + }, + storage_root, + ) + .await + .unwrap_err(); + + match err { + Error::Precondition(message) => { + assert!(message.contains("run.sandbox.provider")); + assert!(!message.contains('\n')); + } + other => panic!("expected Precondition, got {other:?}"), + } + } + #[tokio::test] async fn create_persists_normalized_config_and_initial_state() { let dir = tempfile::tempdir().unwrap(); + let storage_root = dir.path().join("storage"); let store = memory_store(); - let created = create(&store, CreateRunInput { - workflow: WorkflowInput::DotSource { - source: MINIMAL_DOT.to_string(), - base_dir: None, + let created = create( + &store, + CreateRunInput { + workflow: WorkflowInput::DotSource { + source: MINIMAL_DOT.to_string(), + base_dir: None, + }, + settings: { + use fabro_types::settings::ReplaceMap; + use fabro_types::settings::run::{ + RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer, + RunPullRequestLayer, + }; + let mut metadata = HashMap::new(); + metadata.insert("env".to_string(), "test".to_string()); + let mut layer = SettingsLayer { + run: Some(RunLayer { + goal: Some(RunGoalLayer::Inline(InterpString::parse("override goal"))), + metadata: ReplaceMap::from(metadata), + model: Some(RunModelLayer { + name: Some(InterpString::parse("sonnet")), + ..RunModelLayer::default() + }), + pull_request: Some(RunPullRequestLayer { + enabled: Some(false), + ..RunPullRequestLayer::default() + }), + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() + }), + ..SettingsLayer::default() + }; + layer.ensure_test_auth_methods(); + layer + }, + cwd: dir.path().to_path_buf(), + workflow_slug: Some("slug".to_string()), + workflow_path: None, + workflow_bundle: None, + submitted_manifest_bytes: None, + run_id: Some(fixtures::RUN_1), + host_repo_path: Some(dir.path().display().to_string()), + repo_origin_url: None, + base_branch: Some("main".to_string()), + provenance: None, + configured_providers: Vec::new(), }, - settings: { - use fabro_types::settings::run::{ - RunExecutionLayer, RunGoalLayer, RunLayer, RunMode, RunModelLayer, - RunPullRequestLayer, - }; - let mut metadata = HashMap::new(); - metadata.insert("env".to_string(), "test".to_string()); - let mut layer = SettingsLayer { - run: Some(RunLayer { - goal: Some(RunGoalLayer::Inline(InterpString::parse("override goal"))), - metadata, - model: Some(RunModelLayer { - name: Some(InterpString::parse("sonnet")), - ..RunModelLayer::default() - }), - pull_request: Some(RunPullRequestLayer { - enabled: Some(false), - ..RunPullRequestLayer::default() - }), - execution: Some(RunExecutionLayer { - mode: Some(RunMode::DryRun), - ..RunExecutionLayer::default() - }), - ..RunLayer::default() - }), - ..SettingsLayer::default() - }; - layer.ensure_test_auth_methods(); - layer - }, - cwd: dir.path().to_path_buf(), - workflow_slug: Some("slug".to_string()), - workflow_path: None, - workflow_bundle: None, - submitted_manifest_bytes: None, - run_id: Some(fixtures::RUN_1), - host_repo_path: Some(dir.path().display().to_string()), - repo_origin_url: None, - base_branch: Some("main".to_string()), - provenance: None, - configured_providers: Vec::new(), - }) + storage_root.clone(), + ) .await .unwrap(); @@ -869,7 +907,13 @@ mod tests { run_store.state().await.unwrap().status.unwrap(), crate::run_status::RunStatus::Submitted ); - assert_eq!(created.run_dir, default_run_dir(&fixtures::RUN_1)); + assert_eq!( + created.run_dir, + Storage::new(&storage_root) + .run_scratch(&fixtures::RUN_1) + .root() + .to_path_buf() + ); assert!(created.run_dir.is_dir()); } @@ -878,41 +922,46 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let workspace = dir.path().join("workspace"); std::fs::create_dir_all(&workspace).unwrap(); + let storage_root = dir.path().join("storage"); let store = memory_store(); - let created = create(&store, CreateRunInput { - workflow: WorkflowInput::DotSource { - source: MINIMAL_DOT.to_string(), - base_dir: None, - }, - settings: { - use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; - let mut layer = SettingsLayer { - run: Some(RunLayer { - working_dir: Some(InterpString::parse("workspace")), - execution: Some(RunExecutionLayer { - mode: Some(RunMode::DryRun), - ..RunExecutionLayer::default() + let created = create( + &store, + CreateRunInput { + workflow: WorkflowInput::DotSource { + source: MINIMAL_DOT.to_string(), + base_dir: None, + }, + settings: { + use fabro_types::settings::run::{RunExecutionLayer, RunLayer, RunMode}; + let mut layer = SettingsLayer { + run: Some(RunLayer { + working_dir: Some(InterpString::parse("workspace")), + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() }), - ..RunLayer::default() - }), - ..SettingsLayer::default() - }; - layer.ensure_test_auth_methods(); - layer + ..SettingsLayer::default() + }; + layer.ensure_test_auth_methods(); + layer + }, + cwd: dir.path().to_path_buf(), + workflow_slug: None, + workflow_path: None, + workflow_bundle: None, + submitted_manifest_bytes: None, + run_id: Some(fixtures::RUN_2), + host_repo_path: None, + repo_origin_url: None, + base_branch: None, + provenance: None, + configured_providers: Vec::new(), }, - cwd: dir.path().to_path_buf(), - workflow_slug: None, - workflow_path: None, - workflow_bundle: None, - submitted_manifest_bytes: None, - run_id: Some(fixtures::RUN_2), - host_repo_path: None, - repo_origin_url: None, - base_branch: None, - provenance: None, - configured_providers: Vec::new(), - }) + storage_root, + ) .await .unwrap(); @@ -933,25 +982,30 @@ mod tests { #[tokio::test] async fn create_persists_repo_origin_url_from_request() { let dir = tempfile::tempdir().unwrap(); + let storage_root = dir.path().join("storage"); let store = memory_store(); - let created = create(&store, CreateRunInput { - workflow: WorkflowInput::DotSource { - source: MINIMAL_DOT.to_string(), - base_dir: None, + let created = create( + &store, + CreateRunInput { + workflow: WorkflowInput::DotSource { + source: MINIMAL_DOT.to_string(), + base_dir: None, + }, + settings: dry_run_only_settings(), + cwd: dir.path().to_path_buf(), + workflow_slug: None, + workflow_path: None, + workflow_bundle: None, + submitted_manifest_bytes: None, + run_id: Some(fixtures::RUN_2), + host_repo_path: None, + repo_origin_url: Some("https://github.com/acme/widgets".to_string()), + base_branch: None, + provenance: None, + configured_providers: Vec::new(), }, - settings: dry_run_only_settings(), - cwd: dir.path().to_path_buf(), - workflow_slug: None, - workflow_path: None, - workflow_bundle: None, - submitted_manifest_bytes: None, - run_id: Some(fixtures::RUN_2), - host_repo_path: None, - repo_origin_url: Some("https://github.com/acme/widgets".to_string()), - base_branch: None, - provenance: None, - configured_providers: Vec::new(), - }) + storage_root, + ) .await .unwrap(); @@ -1013,24 +1067,28 @@ mod tests { Duration::from_millis(1), None, )); - let created = create(store.as_ref(), CreateRunInput { - workflow: WorkflowInput::DotSource { - source: MINIMAL_DOT.to_string(), - base_dir: None, + let created = create( + store.as_ref(), + CreateRunInput { + workflow: WorkflowInput::DotSource { + source: MINIMAL_DOT.to_string(), + base_dir: None, + }, + settings: dry_run_with_storage(&storage_dir), + cwd: dir.path().to_path_buf(), + workflow_slug: Some("slug".to_string()), + workflow_path: None, + workflow_bundle: None, + submitted_manifest_bytes: None, + run_id: Some(fixtures::RUN_3), + host_repo_path: None, + repo_origin_url: None, + base_branch: None, + provenance: None, + configured_providers: Vec::new(), }, - settings: dry_run_with_storage(&storage_dir), - cwd: dir.path().to_path_buf(), - workflow_slug: Some("slug".to_string()), - workflow_path: None, - workflow_bundle: None, - submitted_manifest_bytes: None, - run_id: Some(fixtures::RUN_3), - host_repo_path: None, - repo_origin_url: None, - base_branch: None, - provenance: None, - configured_providers: Vec::new(), - }) + storage_dir.clone(), + ) .await .unwrap(); let run_store = store.open_run_reader(&created.run_id).await.unwrap(); @@ -1052,37 +1110,41 @@ mod tests { Duration::from_millis(1), None, )); - let created = create(store.as_ref(), CreateRunInput { - workflow: WorkflowInput::DotSource { - source: MINIMAL_DOT.to_string(), - base_dir: None, + let created = create( + store.as_ref(), + CreateRunInput { + workflow: WorkflowInput::DotSource { + source: MINIMAL_DOT.to_string(), + base_dir: None, + }, + settings: dry_run_with_storage(&storage_dir), + cwd: dir.path().to_path_buf(), + workflow_slug: Some("slug".to_string()), + workflow_path: None, + workflow_bundle: None, + submitted_manifest_bytes: None, + run_id: Some(fixtures::RUN_64), + host_repo_path: None, + repo_origin_url: None, + base_branch: None, + provenance: Some(fabro_types::RunProvenance { + server: Some(fabro_types::RunServerProvenance { + version: "0.9.0".to_string(), + }), + client: Some(fabro_types::RunClientProvenance { + user_agent: Some("fabro-cli/0.9.0".to_string()), + name: Some("fabro-cli".to_string()), + version: Some("0.9.0".to_string()), + }), + subject: Some(fabro_types::RunSubjectProvenance { + login: None, + auth_method: fabro_types::RunAuthMethod::Disabled, + }), + }), + configured_providers: Vec::new(), }, - settings: dry_run_with_storage(&storage_dir), - cwd: dir.path().to_path_buf(), - workflow_slug: Some("slug".to_string()), - workflow_path: None, - workflow_bundle: None, - submitted_manifest_bytes: None, - run_id: Some(fixtures::RUN_64), - host_repo_path: None, - repo_origin_url: None, - base_branch: None, - provenance: Some(fabro_types::RunProvenance { - server: Some(fabro_types::RunServerProvenance { - version: "0.9.0".to_string(), - }), - client: Some(fabro_types::RunClientProvenance { - user_agent: Some("fabro-cli/0.9.0".to_string()), - name: Some("fabro-cli".to_string()), - version: Some("0.9.0".to_string()), - }), - subject: Some(fabro_types::RunSubjectProvenance { - login: None, - auth_method: fabro_types::RunAuthMethod::Disabled, - }), - }), - configured_providers: Vec::new(), - }) + storage_dir, + ) .await .unwrap(); diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 087765282..2f4033cb7 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -1004,42 +1004,55 @@ mod tests { )) } - async fn persisted_workflow(dot: &str, run_dir: &Path) -> (Persisted, Arc) { + fn storage_root_and_run_dir(temp: &tempfile::TempDir) -> (PathBuf, PathBuf) { + let storage_root = temp.path().join("storage"); + let run_dir = fabro_config::Storage::new(&storage_root) + .run_scratch(&fixtures::RUN_1) + .root() + .to_path_buf(); + (storage_root, run_dir) + } + + async fn persisted_workflow(dot: &str, storage_root: &Path) -> (Persisted, Arc) { let store = memory_store(); - let created = crate::operations::create(&store, crate::operations::CreateRunInput { - workflow: crate::operations::WorkflowInput::DotSource { - source: dot.to_string(), - base_dir: None, - }, - settings: { - let mut layer = SettingsLayer { - run: Some(RunLayer { - execution: Some(RunExecutionLayer { - mode: Some(RunMode::DryRun), - ..RunExecutionLayer::default() + let created = crate::operations::create( + &store, + crate::operations::CreateRunInput { + workflow: crate::operations::WorkflowInput::DotSource { + source: dot.to_string(), + base_dir: None, + }, + settings: { + let mut layer = SettingsLayer { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() }), - ..RunLayer::default() - }), - ..SettingsLayer::default() - }; - layer.ensure_test_auth_methods(); - layer + ..SettingsLayer::default() + }; + layer.ensure_test_auth_methods(); + layer + }, + cwd: storage_root + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(), + workflow_slug: Some("test".to_string()), + workflow_path: None, + workflow_bundle: None, + submitted_manifest_bytes: None, + run_id: Some(fixtures::RUN_1), + host_repo_path: None, + repo_origin_url: None, + base_branch: None, + provenance: None, + configured_providers: Vec::new(), }, - cwd: run_dir - .parent() - .unwrap_or_else(|| Path::new(".")) - .to_path_buf(), - workflow_slug: Some("test".to_string()), - workflow_path: None, - workflow_bundle: None, - submitted_manifest_bytes: None, - run_id: Some(fixtures::RUN_1), - host_repo_path: None, - repo_origin_url: None, - base_branch: None, - provenance: None, - configured_providers: Vec::new(), - }) + storage_root.to_path_buf(), + ) .await .unwrap(); (created.persisted, store) @@ -1079,7 +1092,7 @@ mod tests { #[tokio::test] async fn start_captures_checkpoint_git_sha_in_conclusion() { let temp = tempfile::tempdir().unwrap(); - let run_dir = temp.path().join("run"); + let (storage_root, run_dir) = storage_root_and_run_dir(&temp); let emitter = Arc::new(Emitter::new(fixtures::RUN_1)); let registry = Arc::new(test_registry()); let injected = Arc::new(AtomicBool::new(false)); @@ -1114,7 +1127,7 @@ mod tests { }); } - let (_persisted, store) = persisted_workflow(MINIMAL_DOT, &run_dir).await; + let (_persisted, store) = persisted_workflow(MINIMAL_DOT, &storage_root).await; let started = start( &run_dir, test_start_services(&store, &run_dir, emitter, registry).await, @@ -1133,11 +1146,11 @@ mod tests { #[tokio::test] async fn start_loads_persisted_from_run_dir() { let temp = tempfile::tempdir().unwrap(); - let run_dir = temp.path().join("run"); + let (storage_root, run_dir) = storage_root_and_run_dir(&temp); let emitter = Arc::new(Emitter::new(fixtures::RUN_1)); let registry = Arc::new(test_registry()); - let (_persisted, store) = persisted_workflow(MINIMAL_DOT, &run_dir).await; + let (_persisted, store) = persisted_workflow(MINIMAL_DOT, &storage_root).await; let started = start( &run_dir, @@ -1154,7 +1167,7 @@ mod tests { #[tokio::test] async fn start_can_run_bundle_backed_child_workflow_without_workflow_bundle_json() { let temp = tempfile::tempdir().unwrap(); - let run_dir = temp.path().join("run"); + let (storage_root, run_dir) = storage_root_and_run_dir(&temp); let emitter = Arc::new(Emitter::new(fixtures::RUN_1)); let registry = Arc::new(test_registry()); let store = memory_store(); @@ -1188,39 +1201,43 @@ mod tests { }), ])); - crate::operations::create(&store, crate::operations::CreateRunInput { - workflow: crate::operations::WorkflowInput::Bundled( - workflow_bundle - .workflow(Path::new("workflow.fabro")) - .unwrap() - .clone(), - ), - settings: { - let mut layer = SettingsLayer { - run: Some(RunLayer { - execution: Some(RunExecutionLayer { - mode: Some(RunMode::DryRun), - ..RunExecutionLayer::default() + crate::operations::create( + &store, + crate::operations::CreateRunInput { + workflow: crate::operations::WorkflowInput::Bundled( + workflow_bundle + .workflow(Path::new("workflow.fabro")) + .unwrap() + .clone(), + ), + settings: { + let mut layer = SettingsLayer { + run: Some(RunLayer { + execution: Some(RunExecutionLayer { + mode: Some(RunMode::DryRun), + ..RunExecutionLayer::default() + }), + ..RunLayer::default() }), - ..RunLayer::default() - }), - ..SettingsLayer::default() - }; - layer.ensure_test_auth_methods(); - layer + ..SettingsLayer::default() + }; + layer.ensure_test_auth_methods(); + layer + }, + cwd: temp.path().to_path_buf(), + workflow_slug: Some("bundle-child".to_string()), + workflow_path: Some(PathBuf::from("workflow.fabro")), + workflow_bundle: Some(workflow_bundle), + submitted_manifest_bytes: None, + run_id: Some(fixtures::RUN_1), + host_repo_path: None, + repo_origin_url: None, + base_branch: None, + provenance: None, + configured_providers: Vec::new(), }, - cwd: temp.path().to_path_buf(), - workflow_slug: Some("bundle-child".to_string()), - workflow_path: Some(PathBuf::from("workflow.fabro")), - workflow_bundle: Some(workflow_bundle), - submitted_manifest_bytes: None, - run_id: Some(fixtures::RUN_1), - host_repo_path: None, - repo_origin_url: None, - base_branch: None, - provenance: None, - configured_providers: Vec::new(), - }) + storage_root, + ) .await .unwrap(); @@ -1237,12 +1254,12 @@ mod tests { #[tokio::test] async fn start_invokes_on_node_callback_before_execution() { let temp = tempfile::tempdir().unwrap(); - let run_dir = temp.path().join("run"); + let (storage_root, run_dir) = storage_root_and_run_dir(&temp); let emitter = Arc::new(Emitter::new(fixtures::RUN_1)); let registry = Arc::new(test_registry()); let visited = Arc::new(Mutex::new(Vec::new())); - let (_persisted, store) = persisted_workflow(MINIMAL_DOT, &run_dir).await; + let (_persisted, store) = persisted_workflow(MINIMAL_DOT, &storage_root).await; let started = start(&run_dir, StartServices { on_node: Some(Arc::new({ @@ -1263,11 +1280,11 @@ mod tests { #[tokio::test] async fn start_errors_when_checkpoint_exists() { let temp = tempfile::tempdir().unwrap(); - let run_dir = temp.path().join("run"); + let (storage_root, run_dir) = storage_root_and_run_dir(&temp); let emitter = Arc::new(Emitter::new(fixtures::RUN_1)); let registry = Arc::new(test_registry()); - let (_persisted, store) = persisted_workflow(MINIMAL_DOT, &run_dir).await; + let (_persisted, store) = persisted_workflow(MINIMAL_DOT, &storage_root).await; let services = test_start_services(&store, &run_dir, emitter, registry).await; // Seed an authoritative checkpoint event so start() sees it @@ -1332,11 +1349,11 @@ mod tests { #[tokio::test] async fn resume_errors_when_checkpoint_missing() { let temp = tempfile::tempdir().unwrap(); - let run_dir = temp.path().join("run"); + let (storage_root, run_dir) = storage_root_and_run_dir(&temp); let emitter = Arc::new(Emitter::new(fixtures::RUN_1)); let registry = Arc::new(test_registry()); - let (_persisted, store) = persisted_workflow(MINIMAL_DOT, &run_dir).await; + let (_persisted, store) = persisted_workflow(MINIMAL_DOT, &storage_root).await; let result = resume( &run_dir, @@ -1354,12 +1371,12 @@ mod tests { #[tokio::test] async fn resume_errors_when_run_already_finished_successfully() { let temp = tempfile::tempdir().unwrap(); - let run_dir = temp.path().join("run"); + let (storage_root, run_dir) = storage_root_and_run_dir(&temp); std::fs::create_dir_all(&run_dir).unwrap(); let emitter = Arc::new(Emitter::new(fixtures::RUN_1)); let registry = Arc::new(test_registry()); - let (_persisted, store) = persisted_workflow(MINIMAL_DOT, &run_dir).await; + let (_persisted, store) = persisted_workflow(MINIMAL_DOT, &storage_root).await; let checkpoint = Checkpoint::from_context( &Context::new(), diff --git a/test/twin/openai/src/config.rs b/test/twin/openai/src/config.rs index 1bb77929a..f7cd31dbd 100644 --- a/test/twin/openai/src/config.rs +++ b/test/twin/openai/src/config.rs @@ -11,20 +11,21 @@ pub struct Config { impl Config { pub fn from_env() -> Result { - let bind_addr = std::env::var("TWIN_OPENAI_BIND_ADDR") - .ok() + Self::from_lookup(&|name| std::env::var(name).ok()) + } + + pub fn from_lookup(lookup: &dyn Fn(&str) -> Option) -> Result { + let bind_addr = lookup("TWIN_OPENAI_BIND_ADDR") .map(|value| value.parse().context("invalid TWIN_OPENAI_BIND_ADDR")) .transpose()? .unwrap_or_else(|| SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3000)); - let require_auth = std::env::var("TWIN_OPENAI_REQUIRE_AUTH") - .ok() + let require_auth = lookup("TWIN_OPENAI_REQUIRE_AUTH") .map(|value| parse_bool_env(&value, "TWIN_OPENAI_REQUIRE_AUTH")) .transpose()? .unwrap_or(true); - let enable_admin = std::env::var("TWIN_OPENAI_ENABLE_ADMIN") - .ok() + let enable_admin = lookup("TWIN_OPENAI_ENABLE_ADMIN") .map(|value| parse_bool_env(&value, "TWIN_OPENAI_ENABLE_ADMIN")) .transpose()? .unwrap_or(true); diff --git a/test/twin/openai/tests/config_contract.rs b/test/twin/openai/tests/config_contract.rs index e49564332..283c14538 100644 --- a/test/twin/openai/tests/config_contract.rs +++ b/test/twin/openai/tests/config_contract.rs @@ -2,30 +2,14 @@ use twin_openai::config::Config; #[test] fn config_loads_from_environment() { - let prior_bind = std::env::var("TWIN_OPENAI_BIND_ADDR").ok(); - let prior_auth = std::env::var("TWIN_OPENAI_REQUIRE_AUTH").ok(); - let prior_admin = std::env::var("TWIN_OPENAI_ENABLE_ADMIN").ok(); - - std::env::set_var("TWIN_OPENAI_BIND_ADDR", "127.0.0.1:4100"); - std::env::set_var("TWIN_OPENAI_REQUIRE_AUTH", "false"); - std::env::set_var("TWIN_OPENAI_ENABLE_ADMIN", "false"); - - let config = Config::from_env().expect("config should load"); + let config = Config::from_lookup(&|name| match name { + "TWIN_OPENAI_BIND_ADDR" => Some("127.0.0.1:4100".to_string()), + "TWIN_OPENAI_REQUIRE_AUTH" | "TWIN_OPENAI_ENABLE_ADMIN" => Some("false".to_string()), + _ => None, + }) + .expect("config should load"); assert_eq!(config.bind_addr.to_string(), "127.0.0.1:4100"); assert!(!config.require_auth); assert!(!config.enable_admin); - - match prior_bind { - Some(value) => std::env::set_var("TWIN_OPENAI_BIND_ADDR", value), - None => std::env::remove_var("TWIN_OPENAI_BIND_ADDR"), - } - match prior_auth { - Some(value) => std::env::set_var("TWIN_OPENAI_REQUIRE_AUTH", value), - None => std::env::remove_var("TWIN_OPENAI_REQUIRE_AUTH"), - } - match prior_admin { - Some(value) => std::env::set_var("TWIN_OPENAI_ENABLE_ADMIN", value), - None => std::env::remove_var("TWIN_OPENAI_ENABLE_ADMIN"), - } }