mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-11-11 08:37:16 +00:00
3269 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8f47275d84
|
feat(llm): add Poolside Laguna models
Add Poolside as a built-in OpenAI-compatible provider and expose Laguna S 2.1 and XS 2.1 both directly and through OpenRouter. Include vault/env credential registration, secret redaction, live coverage, catalog tests, and user documentation. |
||
|
|
8a243085d0
|
Merge remote-tracking branch 'origin/main' into codex/glm-5-2-support
# Conflicts: # lib/crates/fabro-llm/tests/integration.rs # lib/crates/fabro-model/src/catalog.rs |
||
|
|
04f55666f9
|
Merge pull request #588 from fabro-sh/codex/openrouter-kimi-k3
Add Kimi K3 via OpenRouter |
||
|
|
1511c84f48
|
Test GLM 5.2 through Z.AI | ||
|
|
d8613fa052
|
Merge pull request #568 from thewoolleyman/push-credential-refresh-ahead
feat(workflow): keep push credentials fresh across long ACP turns |
||
|
|
c67cfef141
|
Add GLM 5.2 model support | ||
|
|
129c893c86
|
Add Kimi K3 via OpenRouter | ||
|
|
03b1b790d1
|
Merge pull request #587 from fabro-sh/codex/kimi-k3-openai-compat
Add Kimi K3 support and fix OpenAI-compatible tools |
||
|
|
5d345cf393
|
Fix OpenAI-compatible parity fixture | ||
|
|
ca11cac25d
|
Add Kimi K3 catalog support | ||
|
|
a96f90bbd8
|
Fix OpenAI-compatible agent tools | ||
|
|
49c30aa4a6
|
Let repo init use the server default environment | ||
|
|
2843b33d92 |
Simplify runs read model: single-source mappings, leaner queries
Consolidate duplicated logic from the SQLite runs read model review: - Derive the status sort CASE and board-column filter from a new RunStatusKind::board_rank(), replacing three hand-maintained copies of the status/column mapping; add a test upserting every status variant so the migration CHECK can't silently drift - Share RunSize bucket thresholds between from_total_usd_micros and the generated size-sort CASE via RunSize::BUCKET_MAX_USD_MICROS - Resolve run selectors from a lean identity query instead of decoding every stored summary per request - Delete the RunsSortKey/RunsSortDirection adapter enums; the store sort enums now carry the wire serde names - Consolidate the workflow display-name fallback chain into WorkflowRef::display_name() (store, CLI, run lookup) - Share pagination clamping and the paginated list envelope across handlers - Reconcile now skips rows whose source seq is unchanged and batch-deletes stale rows; drop the two indexes no query can use - Hold the summary store OnceLock cell in RunDatabaseInner instead of a snapshot so late attachment reaches already-open writers - Misc: expect() on COUNT(*) sign, %err logging, shared wall-time helper, shared SQLite test fixture, dead billing fallback removed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
22844300af | Add SQLite runs read model | ||
|
|
6ec4c9a13d |
Simplify SQLite stores after review pass
Consolidate the legacy-import helpers (backup path naming, RFC 3339 timestamp parsing, import report) into fabro-db and reuse them from the vault, automation, variable, and environment stores. Add SecretStore::open_snapshot to collapse the repeated open/snapshot/into_vault chain. Let automation trigger canonicalization live solely in normalize_replace, replace its redundant second full validation with a targeted manual-id collision check, single-source the automation SELECT projection, and gate list_automation_runs on a lightweight existence query. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c12264c16d | Migrate automations to SQLite | ||
|
|
431399826d |
Simplify SQLite stores after review
- Delete the dead test-only Vault-based env-secrets migration and point the startup migration tests at the production migrate_to_store path over a real SQLite-backed SecretStore - Extract shared legacy-import helpers (timestamped backup rename, is_toml_file) into fabro_db::legacy and parse_rfc3339_utc into fabro-db, replacing four per-crate copies - Take one secrets snapshot in migrate_to_store instead of per-name queries - Share one bind order between the MCP store INSERT and UPDATE statements - Return SecretEntry directly from entry_from_row - Unify the environment/MCP store blocking loaders into a generic load_store_blocking helper Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ec3933d5de | Move MCP servers to SQLite storage | ||
|
|
a2c2781a6e |
Declare a removal deadline on the legacy secrets JSON import
The automations legacy import declares REMOVAL_DEADLINE per docs/internal/migrations-strategy.md; the secrets JSON import predates it and never did. Add the same constant and log field so the temporary migration's lifespan is visible in code and in startup logs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f2cbc016ee |
Clean up SQLite secrets migration and fix CLI env credential regression
Review pass over the secrets-to-SQLite migration: - Add SecretStore::open() consolidating the connect/migrate/import-legacy sequence repeated at five call sites; fabro-agent and fabro-cli drop their fabro-db dependency - Restore process-env LLM credential lookup in the standalone CLI/agent sources via SqlVaultCredentialSource::new (regression: vault_only dropped the env fallback that VaultCredentialSource::new provided) - Fix five install tests that still asserted against the legacy secrets.json, which the importer renames to .bak - Make AppStateConfig.preloaded_vault required, deleting the fallback that re-read the already-renamed legacy file; drop the now-unused vault_path field and demote load_startup_vault to test-only - Skip the snapshot clones and CAS retry in resolve() when the vault holds no OAuth secrets (per-request hot path) - Remove dead persist_with_secret_store, the VaultSecretWrite alias, the secret_type_string one-liner (now SecretType::as_str), the impossible RowCountOverflow error, and duplicated row parsing - Run check_crypto concurrently with the other diagnostics checks Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6860852c9c | Simplify SQLite secret storage | ||
|
|
d325a2c1e6 | Migrate secrets storage to SQLite | ||
|
|
f97ac8df3c |
Simplify pre-migration snapshot internals
- Detect applied migrations via sqlx's Migrate trait (ensure_migrations_table + list_applied_migrations) instead of hand-querying the _sqlx_migrations bookkeeping table, so the check cannot drift from what Migrator::run actually applies. - Write the snapshot to a staging file and rename it into place, so a failure mid-copy never leaves a partial file at the snapshot path. - Derive the database path from the pool's connect options instead of storing a duplicate copy on Database. - Drop the invented "fabro.sqlite3" fallback filename from pre_migration_snapshot_path; append the suffix to the path directly. - Deduplicate the snapshot-inspection blocks in the test behind small connect_read_only/table_exists helpers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f91c71b8e0 |
Snapshot fabro.sqlite3 before applying new migrations
A binary downgrade after new SQLite migrations have been applied fails
sqlx's startup validation ("migration was previously applied but is
missing in the resolved migrations") and previously left the operator
with no rollback artifact: the shared database had no backup, so
recovering meant hand-editing _sqlx_migrations and dropping tables.
Database::migrate now writes a consistent single-file snapshot to
<db>.pre-migration.bak (via VACUUM INTO, mode 0600) before applying any
migration the database has not seen. Rollback is: stop the server,
replace the database file with the snapshot, delete -wal/-shm siblings,
start the previous binary. Fresh databases and no-op migrates skip the
snapshot, so the file always preserves the state from immediately before
the most recent schema change. A snapshot failure fails the migration:
no rollback artifact, no schema change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
c623bf7432 |
Finalize parallel branch stages instead of leaving them Running
Parallel branches run in spawned tasks that bypass the engine's StageStarted/StageCompleted lifecycle, so a branch stage was created Running by its first branch-scoped event and never reached a terminal state. On a successful run nothing swept it (only RunFailed does), so the fan-out rows spun forever with a `--` duration even after the run and its fan-in finished. Fold ParallelBranchStarted/ParallelBranchCompleted in the projection: seed started_at for the live timer, then set the terminal state and wall-time from the branch's own completion event. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
eccbed80b7
|
Remove unused SecretRedactor registry and update stale comments (#574)
The exact-value secret registry (`SecretRedactor`) was built as
infrastructure ahead of its wiring, but the wiring was never merged —
the team settled on content-based redaction (entropy analysis + gitleaks
patterns) as the sole mechanism. The type had zero consumers outside its
own crate. This PR removes it and corrects two doc comments that
described the abandoned design as pending.
**What changed:**
1. `fabro-redact/src/secret_registry.rs` deleted in full (~217 lines),
with its `mod` declaration and `pub use` re-export removed from
`lib.rs`. `Region`, `redact_string`, `redact_json_value`,
`DisplaySafeUrl`, and everything else in the crate are untouched.
2. The `resolve_extra_headers` doc in `fabro-auth` no longer promises
future exact-match registration. It now honestly states that low-entropy
header values not shaped like credentials are not caught by
content-based redaction.
3. The `InterpString` module doc in `fabro-types` no longer describes a
pending per-run registry. It states the real architecture: resolved
secret values are plain strings, and redaction is content-based applied
at output serialization.
**Known limitation (pre-existing, not introduced here):** a declared
secret whose value is a low-entropy ordinary word (e.g. an environment
name) is not caught by content-based detection. This was the gap
`SecretRedactor` was meant to fill; it is an accepted trade-off, not a
regression from this PR.
### Fabro Details
<details>
<summary>Ran 8 stages in 26m 21s for $2.27</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 34s | – | 0 |
| preflight_lint | 2m 45s | – | 0 |
| implement | 0s | – | 0 |
| simplify_fable | 9m 44s | $2.27 | 0 |
| simplify_gpt | 0s | – | 0 |
| verify | 10m 50s | – | 0 |
| **Total** | **26m 21s** | **$2.27** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-8; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD. Be sure to use the rust-style-guide skill to help you follow this repo's Rust style conventions.", model="gpt-55", reasoning_effort="xhigh"]
simplify_fable [label="Simplify (Fable)", prompt="@prompts/simplify.md", model="claude-fable-5", reasoning_effort="xhigh"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, timeout="1800s", script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_fable -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
|
||
|
|
7e97fba7f6 |
feat(workflow): keep push credentials fresh across long ACP turns
GitHub App installation tokens expire ~60 min after minting. On a long run, the origin token baked into the sandbox clone at clone time is stale by the time a late ACP node (e.g. the PR node) runs `git push`, causing an `Invalid username or token` failure. Two host-driven mechanisms, both using the existing `Sandbox::refresh_push_credentials()` (re-mint + `git remote set-url origin`) over the exec channel — no new inbound surface: 1. Turn-entry re-mint at each ACP node entry, so a push early in the turn uses a fresh token. 2. A background refresh-ahead loop, scoped to the turn via a drop-guard, that re-mints every 45 min so a single push-bearing turn that itself exceeds the TTL stays fresh. A normal sub-interval turn never ticks; a failed/timed-out tick retries sooner so a transient error cannot leave a longer-than-interval expired-token window. Both refresh calls are timeout-bounded (30s) so a stalled GitHub API cannot hang node entry. FABRO_PUSH_CRED_REFRESH_AHEAD (default on; falsy = empty/0/ false/off/no, case-insensitive) disables the whole feature — turn-entry and loop — for operators who manage `origin` themselves; FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS overrides the interval (0 disables just the loop). Both are added to the worker env allowlist. refresh_push_credentials now returns RefreshOutcome (Refreshed vs Skipped) so callers log accurately: Refreshed only when a GitHub App installation token was actually re-minted; a static PAT or pre-minted Installation token (nothing to re-mint) short-circuits to Skipped before the set-url exec. Known follow-ups documented in-code: (a) resumed runs reconnect without App creds, so refresh no-ops until they are threaded through the reconnect path; (b) no freshness check on the per-entry mint; (c) the background set-url can contend with the agent's own git on .git/config.lock; (d) parallel ACP branches each run their own loop; (e) the refresh lives in the ACP handler only though the stale-origin problem is stage-agnostic (native/command stages are not covered); (f) refresh failures are logged via tracing but not surfaced as a RunNotice event. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
18d4628291
|
Add OpenAI GPT-5.6 models (#566)
## Summary - add GPT-5.6 Sol, Terra, and Luna to the built-in OpenAI model catalog with current capabilities, limits, aliases, and pricing - route all three models through the OpenAI Responses API and keep the Codex-safe 272K context policy - make GPT-5.6 Sol the OpenAI default and update the model docs and Ask Fabro API contract ## Testing - cargo nextest run -p fabro-model - cargo nextest run -p fabro-llm builtin_catalog_route_equivalence_table - cargo nextest run -p fabro-server list_models - cargo nextest run -p fabro-server --features test-support run_responses_include_ask_fabro_affordance - cargo +nightly-2026-04-14 fmt --check --all - cargo +nightly-2026-04-14 clippy -p fabro-model -p fabro-llm --all-targets -- -D warnings - git diff --check |
||
|
|
96372c5a3c
|
Demote server.integrations.slack.default_channel to a plain string (#565)
The `server.integrations.slack.default_channel` field was typed
`Option<InterpString>` but was never documented as interpolable — every
doc example uses a plain channel name like `#releases`. It resolved only
env vars, only once at server startup, and that capability was inherited
from a uniform schema-staging design, not a deliberate feature. This
brings it in line with every other server-scope config field, which were
already demoted to plain literals under the project rule that
interpolation belongs to fields resolved with run context.
## What changed
- **Type** (`fabro-types`, `fabro-config` layers):
`Option<InterpString>` → `Option<String>` in `SlackIntegrationSettings`
and `SlackIntegrationLayer`.
- **Demotion warning** (`resolve/server.rs`): calls
`warn_if_demoted_template` at resolve time with the field path
`server.integrations.slack.default_channel`, matching the pattern used
for earlier server-field demotions. A value still containing a `{{ env.*
}}`-shaped token is stored verbatim and triggers a startup warning — no
resolution, no error.
- **Startup wiring** (`server.rs`): the `value.resolve(process_env_var)`
call and its error mapping are deleted; the literal string is passed
directly to `SlackService::new`, which already accepts `Option<String>`.
- **System status handler** (`handler/system.rs`): removed the
now-unnecessary `display_interp` helper that called `resolve_or_source`;
the field is cloned directly into the metadata map.
- **Wire shape**: unchanged. `InterpString` serialized as its raw source
string, so stored/wire JSON is identical before and after. The OpenAPI
spec is untouched.
## What is not changing
Per-run Slack channels — `run.notifications.<route>.slack.channel` and
`run.interviews.slack.channel` — remain `InterpString` with variable
substitution at run creation. Those are the intended interpolating
surface and are correct as-is.
## Migration signal
Anyone who placed a `{{ env.NAME }}` token in
`server.integrations.slack.default_channel` (only possible during ~3
months of nightly builds) will see a startup warning naming the field.
The value is treated as a literal; no data is lost and startup does not
fail.
## Interview-prompt routing observation (step 4)
The interview-prompt posting path checks `run.interviews.slack.channel`
first and falls back to the server default only when the run-scope field
is absent — the preference already exists. No routing change is needed
or made here.
### Fabro Details
<details>
<summary>Ran 8 stages in 41m 23s for $10.10</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 41s | – | 0 |
| preflight_lint | 2m 42s | – | 0 |
| implement | 0s | – | 0 |
| simplify_fable | 27m 43s | $10.10 | 0 |
| simplify_gpt | 0s | – | 0 |
| verify | 7m 44s | – | 0 |
| **Total** | **41m 23s** | **$10.10** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-8; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD. Be sure to use the rust-style-guide skill to help you follow this repo's Rust style conventions.", model="gpt-55", reasoning_effort="xhigh"]
simplify_fable [label="Simplify (Fable)", prompt="@prompts/simplify.md", model="claude-fable-5", reasoning_effort="xhigh"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, timeout="1800s", script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_fable -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
|
||
|
|
9daca83b35
|
Migrate provider extra_headers from typed refs to interpolation strings (#564)
## Summary
Provider `extra_headers` previously required values to be typed TOML
tables (`{ env = "X" }`, `{ literal = "Y" }`, `{ vault = "Z" }`). This
PR migrates them to the project's standard interpolation string format:
plain text for literals, `{{ env.NAME }}` tokens for environment
variables, and `{{ secrets.NAME }}` tokens for vault secrets. This
brings `extra_headers` in line with the rest of the interpolation system
and unlocks mixed-segment values like `Bearer {{ secrets.GATEWAY_TOKEN
}}`.
### What changed and why
**Config authoring surface (`fabro-config`):**
`ProviderSettings.extra_headers` changes from `Option<HashMap<String,
HeaderValueRef>>` to `Option<HashMap<String, InterpString>>`. The
`Combine` impl and all re-exports are updated accordingly.
**Catalog layer (`fabro-model`):**
`ProviderCatalogSettings.extra_headers` and
`CatalogProvider.extra_headers` become `HashMap<String, String>` — raw
interpolation source strings. This is required by the crate dependency
direction: `fabro-types` (which owns `InterpString`) depends on
`fabro-model`, so `fabro-model` cannot hold `InterpString` without
creating a cycle. The source string is re-parsed and resolved in
`fabro-auth` at credential-build time.
**Credential resolution (`fabro-auth`):** Both `CredentialResolver`
(vault-backed) and `EnvCredentialSource` (env-only) are rewritten to
parse each header source string as an `InterpString` and resolve it with
a `ResolveCtx` scoped to `env` + `secrets` only. A new
`resolve_extra_headers` helper is shared between the two paths. Vault
resolution uses `vault_token_lookup`, which wraps `vault_get_token` and
maps any non-Token vault entry to `None` — so file and OAuth vault
entries fail closed rather than resolving incorrectly. `vars.*` and
`inputs.*` tokens are not in scope and produce `Unavailable` errors
automatically.
**New error variant:** `ResolveError::Interpolation { provider, source
}` surfaces header resolution failures as diagnosable auth issues. The
inner `source` (an `InterpResolveError`) names only the token namespace
and name — never a resolved value.
**`{ literal = "..." }` guardrail removed:** `HeaderValueRef`
deliberately rejected bare string header values to discourage pasting
credentials. `InterpString` accepts any string. This is an intentional
change; the mitigation is documentation — use `{{ secrets.NAME }}` for
credential-shaped values, not bare literals.
**Redactor registration gap (noted, not fixed here):** Secrets resolved
into provider headers at the credential boundary do not flow through the
run boundary's exact-match redaction registry. Exposure is low (headers
are host-side and outbound-only, never logged), but a follow-up should
thread a registering lookup through `VaultCredentialSource`. A code
comment at the resolution site marks the gap.
### Breaking change
Existing `extra_headers` config using `{ env = "X" }`, `{ literal = "Y"
}`, or `{ vault = "Z" }` table syntax **will fail to parse** after this
change. Users must migrate to the token form: plain strings for
literals, `{{ env.X }}` for env vars, `{{ secrets.X }}` for vault
secrets. A changelog entry is included.
### Plan Summary
- Update `ProviderSettings.extra_headers` → `InterpString` in
`fabro-config`
- Collapse authoring `InterpString` → source `String` in
`provider_settings_to_catalog` (allowlisted `as_source()` call)
- Delete `HeaderValueRef` and its serde/display/parse machinery from
`fabro-model`
- Rewrite both auth resolution paths to use `InterpString::parse +
resolve_with`; add `Interpolation` error variant
- Add `vault_token_lookup` helper for token-only fail-closed vault
resolution
- Update test TOML in `fabro-llm`, builtin catalog comment in
`openrouter.toml`, and all hand-written + generated docs
### Fabro Details
<details>
<summary>Ran 8 stages in 108m 43s for $34.44</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 5m 33s | – | 0 |
| preflight_lint | 5m 59s | – | 0 |
| implement | 43m 23s | $17.34 | 0 |
| simplify_fable | 32m 49s | $13.09 | 0 |
| simplify_gpt | 6m 25s | $4.01 | 0 |
| verify | 13m 58s | – | 0 |
| **Total** | **108m 43s** | **$34.44** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-8; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD. Be sure to use the rust-style-guide skill to help you follow this repo's Rust style conventions.", model="gpt-55", reasoning_effort="xhigh"]
simplify_fable [label="Simplify (Fable)", prompt="@prompts/simplify.md", model="claude-fable-5", reasoning_effort="xhigh"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, timeout="1800s", script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_fable -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
|
||
|
|
d5dcd11793
|
Remove unused provenance tracking from config interpolation (#562)
The `Resolved` / `Provenance` types in `fabro-types` interp tracked
which env vars and secrets contributed to a resolved value, but no
production code ever read `.provenance` — every caller immediately
discarded it with `.map(|r| r.value)`. The redaction design this
metadata anticipated was superseded by per-run exact-value registration
(`fabro_redact::SecretRedactor`); origin-tagging on resolved strings
can't reach the surfaces where secrets actually leak (subprocess output,
diffs, tool output), so it added no coverage.
This PR removes the dead scaffolding with zero behavior change:
- `resolve` / `resolve_with` now return `Result<String, ResolveError>`
directly; `Resolved` and `Provenance` are deleted along with the
name-accumulation logic inside `resolve_with`.
- All call sites drop the now-unnecessary `.map(|r| r.value)` unwrap.
- Provenance assertions in tests are removed; all value/error assertions
are preserved.
- The module doc is updated to describe the actual model: secret values
are intended to be registered into a per-run exact-value redactor at
resolution time; sensitivity is not tracked on resolved strings.
- A comment on `ResolvedRunGoal` / `ResolvedGoalSource` (an unrelated
run-metadata concept sharing the word "provenance") is rephrased to
avoid confusion with the deleted type.
`Provenance` no longer appears in `fabro-types/src/settings/mod.rs`
exports. The unrelated `RunClientProvenance` / `RunServerProvenance`
run-spec types are untouched.
### Fabro Details
<details>
<summary>Ran 8 stages in 39m 23s for $7.58</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 17s | – | 0 |
| preflight_lint | 2m 33s | – | 0 |
| implement | 11m 6s | $4.23 | 0 |
| simplify_fable | 8m 27s | $1.60 | 0 |
| simplify_gpt | 5m 58s | $1.75 | 0 |
| verify | 8m 36s | – | 0 |
| **Total** | **39m 23s** | **$7.58** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-8; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD. Be sure to use the rust-style-guide skill to help you follow this repo's Rust style conventions.", model="gpt-55", reasoning_effort="xhigh"]
simplify_fable [label="Simplify (Fable)", prompt="@prompts/simplify.md", model="claude-fable-5", reasoning_effort="xhigh"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, timeout="1800s", script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_fable -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
|
||
|
|
790762fb8d
|
feat(config): make the per-node checkpoint commit timeout configurable (#552)
Some checks failed
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
## Problem
The post-node run-branch checkpoint commit runs repository commit hooks
unless `skip_git_hooks` is enabled, but its sandbox command timeout was
hardcoded to 30 seconds. Consumers whose hooks run a multi-minute gate
cannot complete a checkpoint.
## Change
Adds `commit_timeout_ms` to the existing `[run.checkpoint]` table.
- Defaults to `30000`, preserving existing behavior.
- Threads the value through config raw layer -> merge -> resolve ->
resolved settings -> `RunOptions` -> `GitState` -> both checkpoint call
sites.
- Applies the configured timeout to checkpoint `git add -A` and `git
commit`.
- Keeps old serialized run manifests compatible via serde default.
## Testing
- `cargo +nightly-2026-04-14 fmt --all`
- `cargo +nightly-2026-04-14 clippy --locked --workspace --all-targets
-- -D warnings`
- `cargo nextest run --locked -p fabro-config -p fabro-types -p
fabro-workflow`
- 1795 passed, 31 skipped
- `cargo nextest run --locked -p fabro-cli
attach_json_errors_without_prompting_for_human_input`
- `cargo nextest run --locked --workspace --status-level slow --profile
ci --no-fail-fast`
- 6951 passed, 3 timed out, 187 skipped
- The 3 timeouts are preexisting on clean `upstream/main`: verified by
running `CARGO_TARGET_DIR=/data/projects/fabro/target cargo nextest run
--locked -p fabro-cli --profile ci --no-fail-fast workflow::acp::acp`
from a detached worktree at `upstream/main` (`8c7d5dc7d`), which timed
out the same three tests:
-
`workflow::acp::acp_artifacts_are_listed_when_touched_file_mtime_precedes_attempt_start`
-
`workflow::acp::acp_backend_does_not_inject_registered_provider_credentials`
- `workflow::acp::acp_backend_workflow`
## Compatibility
No behavior change without explicit opt-in. Omitted config resolves to
the existing 30 second timeout, and old serialized run manifests
deserialize unchanged.
---------
Co-authored-by: thewoolleyman <chad@thewoolleyman.com>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
8b2830d772
|
feat(deploy): support Tailscale Services deployments (#560)
Created from HEAD commit
|
||
|
|
6d55875645
|
fix(store): keep pre-#530 runs loadable (serde default on run.prepare) (#554)
## Summary Runs created before #530 disappear from the run list after upgrading, because their persisted `run.created` event can no longer be deserialized. #530 renamed `RunPrepareSettings`'s field from `commands: Vec<String>` to `steps: Vec<PreparedStep>`. That struct is persisted inside the `run.created` event (`WorkflowSettings.run.prepare`). Events written by older versions carry a `prepare` object with a `commands` key and **no** `steps` key. Because `steps` had no serde default, deserializing such an event fails with: ``` Serialization error: missing field `steps` ``` `warm_projection_cache` catches that error per-run and **skips** the run (`fabro_store::slate: Skipping run during projection cache warmup`), so every pre-#530 run silently vanishes from the run list. The event data is intact on disk — it just can't be read back. This is an event-schema back-compat break: any type persisted in an event must stay readable across the field renames/additions that happen after it was written. ## Fix Add `#[serde(default)]` at the container level on `RunPrepareSettings`, so a `prepare` object missing `steps` (and/or `timeout_ms`) falls back to the existing `Default` impl (empty steps, product-default timeout) instead of failing the whole run. The unknown legacy `commands` key is ignored (the struct has no `deny_unknown_fields`). - New runs always serialize explicit `steps`, so nothing changes for them — the #530 feature is unaffected. - Pre-#530 runs load again with an empty prepare phase, which is faithful: those runs already executed; this only rebuilds a read model for display. `#[serde(default)]` is already the evolution idiom in this same struct tree (e.g. `RunModelSettings.controls`). ## Test plan - [x] `cargo test -p fabro-types` — added two regression tests that deserialize the exact pre-#530 event shape (`{ commands, timeout_ms }`, no `steps`) and an empty object, asserting both load instead of erroring. - [x] Built the patched server and pointed it at a real `~/.fabro/storage` that had 119 pre-#530 runs being skipped. After the fix, 0 runs are skipped and all 119 appear in `GET /api/v1/runs`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Bryan Helmkamp <bryan@brynary.com> |
||
|
|
9008058eab
|
Resolve secret tokens at the run boundary (worker-side, fail-closed) (#545)
## Summary
`{{ secrets.NAME }}` tokens in workflow config (MCP transport, prepare
steps, run environment) now resolve from the server vault at the run
boundary — the same late-binding point where `{{ env.* }}` tokens
resolve. Secret values are never persisted and never left literal in
resolved commands or env; a missing or non-Token secret aborts startup
with a clear error.
## What changed
**`fabro-types` — `run.rs`**
- `resolve_env_string` (shared choke-point for MCP and prepare) gains a
`secrets_lookup` parameter and routes through
`ResolveCtx::new().with_env(...).with_secrets(...)` / `resolve_with`.
- `McpServerSettings::resolve_transport_env` and
`RunPrepareSettings::resolve_step_env` thread the new parameter through.
- `RunEnvironmentSettings::resolve_env` becomes fallible
(`Result<HashMap<_,_>, ResolveError>`). Per-value error handling
preserves the historical env fallback for `Namespace::Env`-only errors
while failing closed for `Namespace::Secrets` errors. The intentional
`as_source()` fallback is gated behind its
`#[expect(clippy::disallowed_methods)]` with an explicit reason.
**`fabro-workflow` — `start.rs`**
- A single vault read guard is acquired once at the top of
`RunSession::new`, replacing the previous per-site reads (Daytona key,
etc.).
- `vault_token_lookup` wraps `fabro_auth::vault_get_token` — returning
`Some(value)` only for `Token`-type secrets; `Oauth` and `File` secrets
become `None` (fail-closed).
- The shared `secret_lookup` closure is threaded into
`runtime_mcp_server`, `runtime_setup_commands`, and `resolve_env`.
`resolve_docker_config` gains the same parameter and now returns
`Result`.
**`fabro-sandbox` — `from_environment.rs`**
- `docker_config_from_environment` (server-preflight path, no vault
available) retains `resolve_or_source` behavior unchanged.
- New `docker_config_from_environment_with_secrets` is the vault-backed
variant used by `start.rs`.
**`fabro-cli` — `exec.rs`**
- `fabro exec` has no vault; passes `|_| None` for secrets, preserving
existing behavior with updated call signature.
### Plan summary
- **B.1** — Secret lookup threaded through `resolve_env_string` /
`resolve_transport_env` / `resolve_step_env` / `resolve_env` in
`fabro-types`.
- **B.2** — Vault-backed `secret_lookup` closure built once in
`RunSession::new` and passed to all boundary resolvers in `start.rs`.
- **B.3** — Persistence invariant test: a created run's persisted
`RunCreated` event still carries `{{ secrets.DEPLOY_TOKEN }}` in source
form, not the resolved value.
- **B.4** — Verification (fmt, clippy, nextest, release build) with
hermetic temp-vault tests.
### Key design decisions
- **Fail closed everywhere secrets are referenced** — no source fallback
for secret tokens, even in `resolve_env` which otherwise keeps the env
fallback. This is enforced by checking
`value.references(Namespace::Secrets)` before the fallback branch.
- **Token-only** — `vault_get_token` enforces this; `Oauth` and `File`
secrets silently become `None` and then hard-error via the resolver, not
a panic.
- **Single vault read guard per `RunSession::new`** — acquired once,
shared across MCP / prepare / env resolvers, then dropped before the
struct is returned. Mirrors how the Daytona key was already read.
- **`fabro exec` stays unchanged behaviorally** — the added `|_| None`
secrets argument makes the new signature explicit about having no vault.
### Fabro Details
<details>
<summary>Ran 9 stages in 82m 55s for $24.43</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 21s | – | 0 |
| preflight_lint | 2m 34s | – | 0 |
| implement | 46m 58s | $15.83 | 0 |
| simplify_opus | 12m 43s | $4.79 | 0 |
| simplify_gpt | 6m 43s | $3.18 | 0 |
| verify | 6m 41s | – | 0 |
| fixup | 4m 33s | $0.63 | 0 |
| **Total** | **82m 55s** | **$24.43** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-8; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, timeout="1800s", script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
|
||
|
|
c1ff4a3e33
|
fabro-redact: add SecretRedactor for per-run exact-value redaction (#542)
Adds a `SecretRedactor` primitive to `fabro-redact` so that low-entropy
secret values (e.g. environment names, short tokens) are redacted even
when the existing content-based heuristics (`redact_string`,
`redact_json_value`) would leave them alone.
The type is a cheap, `Clone`-able handle backed by
`Arc<RwLock<Vec<String>>>`, so a clone handed to another subsystem
shares the same registry. `register` ignores empty/whitespace-only
values to prevent a footgun that would blank all output. `redact_into`
sorts and merges match regions before substituting, so a secret that is
a prefix of another longer secret is handled correctly (longest wins via
union). `redact_json` walks string leaves in objects and arrays; object
keys are left intact.
This is an inert library primitive — it changes no existing behavior and
is wired up by Plan C. The existing `"REDACTED"` literal is extracted to
a `pub(crate) REDACTION_MARKER` constant so both the old path and the
new one stay in sync.
### Fabro Details
<details>
<summary>Ran 8 stages in 43m 24s for $5.69</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 23s | – | 0 |
| preflight_lint | 2m 33s | – | 0 |
| implement | 20m 1s | $3.09 | 0 |
| simplify_opus | 4m 13s | $1.27 | 0 |
| simplify_gpt | 7m 29s | $1.33 | 0 |
| verify | 6m 16s | – | 0 |
| **Total** | **43m 24s** | **$5.69** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-8; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, timeout="1800s", script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
|
||
|
|
1806e91d7e
|
Fix web app load performance: caching, compression, and eager chunk loading (#550)
## Problem Loading the web UI from a remote server took **~11 seconds to first render on every refresh**. A HAR capture against a remote deployment showed the page downloading **13.5 MB of JavaScript across 356 files, uncompressed, on every single page load** — even though the assets are content-hashed and served with `Cache-Control: immutable`. Four compounding causes: 1. **`Pragma: no-cache` defeated the browser cache.** The security-headers middleware stamped `Pragma: no-cache` onto every response, including hashed assets that set a year-long immutable `Cache-Control`. Browsers treat a response `Pragma: no-cache` as `Cache-Control: no-cache` and check it *before* `max-age` (Chromium zeroes freshness on it), and since assets carried no validators, "revalidate" degraded into a full re-download. Empirically visible in the HAR: Google-Fonts woff2s served from cache (`transfer = 0`) during the same page load where all 356 of our assets re-downloaded in full. 2. **No response compression.** The server had no compression layer; 13.5 MB of JS compresses to ~2.5 MB with brotli. 3. **The HTML force-loaded every chunk.** `writeIndexHtml` emitted a `<script type="module">` tag for all 356 outputs. Only 2.9 MB is statically reachable from the entry; the other ~10.7 MB is dynamic-import-only code (syntax grammars, Graphviz WASM, xterm, diff file tree) that was being downloaded eagerly at high priority. 4. **The immutable heuristic over-matched.** Any dash in a filename counted as a content hash, so stable-named files (`pierre-diffs-worker/worker-portable.js`, `apple-touch-icon.png`) would be pinned in browser caches for a year across deploys once fix 1 made immutable caching effective. ## Changes - **`security_headers`**: apply the `no-store`/`Pragma: no-cache` defaults only when the handler didn't set its own `Cache-Control`. API responses keep the conservative defaults. - **Compression**: `tower-http` `CompressionLayer` (brotli + gzip) on both the main router and the install-mode router (install mode serves the same SPA bundle through a separate router). Default predicate keeps SSE (`text/event-stream`), gRPC, images, and tiny bodies identity-encoded. Quality pinned to `Precise(4)` — tower-http's default defers to the codec default, and brotli's default is quality 11 (seconds of CPU per multi-megabyte asset). - **Entry-only HTML**: `writeIndexHtml` emits script tags only for `kind === "entry-point"` outputs. The module graph pulls static imports (depth 1, so no waterfall); dynamic `import()` chunks load on demand. - **Cache-control classifier + validators**: only files matching the bundler's actual output shape (`assets/<stem>-<hash8>.js|css`, lowercase base-36) get `immutable`. Everything else is `no-cache` **with a strong ETag** and `If-None-Match` → `304` support, so index.html / app.css / the pierre worker revalidate in one cheap conditional request instead of a full re-download. ## Impact (measured on the built bundle) | | Before | After | |---|---|---| | Cold load, ~1 MB/s link | 13.5 MB raw ≈ **11–14 s** | ~0.8 MB compressed eager payload ≈ **~1 s** | | Refresh | full re-download, same 11–14 s | served from cache + one 304 ≈ **instant** | | Eager JS on first render | 13.56 MB / 356 files | 2.88 MB raw (0.79 MB gzip) / 6 files | ## Verification - 959 fabro-server tests pass (incl. new coverage); fmt + clippy clean; `bun run typecheck` passes (the 5 pre-existing bun test failures reproduce identically on `main` — missing `@pierre/diffs/dist/worker` fixture + flaky InstallApp timing tests). - New integration tests pin compression through **both** serving shapes that matter: regular routes and the SPA fallback service, each via tower `oneshot` **and** over a real TCP connection through hyper (raw-socket assertions, so no client auto-decompression can mask a regression). - Live-verified against a debug server: hashed assets get `immutable` + brotli and no `Pragma`; mutable assets get `no-cache` + ETag and answer conditionals with `304`; API responses keep `no-store`. - Headless Chrome boots the rebuilt SPA from the entry-only HTML and fully renders the UI. ## Notes for reviewers - The ETag is skipped for immutable assets deliberately — they never revalidate, so hashing multi-MB bodies per request would be pure overhead. - Install mode previously had **no** compression and shares the same bundle; it gets the same layer via a shared `compression_layer()` helper. - `bun test` has a pre-existing suite (`production build copies Pierre worker assets`) that fails without `@pierre/diffs/dist/worker` present locally; unrelated to this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ec0a08afb3
|
fix: grant organization_projects to auto-created GitHub Apps for Projects V2 (#544)
## What
Adds the `organization_projects: write` permission to the GitHub App
manifest used when Fabro auto-creates a GitHub App, in **both** install
flows:
- `lib/crates/fabro-server/src/install.rs` (web-UI install)
- `lib/crates/fabro-cli/src/commands/install.rs` (CLI install)
A test assertion in the CLI install tests guards the new permission.
## Why
The GitHub Projects V2 tracker mints a scoped installation token
requesting `{ "issues": "write", "organization_projects": "write" }`
(`create_installation_access_token_for_projects`,
`fabro-github/src/lib.rs`). GitHub only lets an installation token
request a **subset** of the permissions the app was granted at install
time — and `organization_projects` was never in the manifest. So on any
auto-created Fabro app, the token request comes back **422** and the
tracker fails before it can make a single GraphQL call.
`issues: write` (also requested by that helper) is already covered by
the manifest; `organization_projects` was the missing piece.
## Note on rollout
Manifest `default_permissions` are applied at **app-creation time**, so
this only affects **newly** auto-created apps. Existing apps need the
permission added manually in their settings, and each installation must
approve it.
## Follow-up (not in this PR)
The `422` branch in `mint_installation_token_with_jwt` reports "GitHub
App does not have access to repository {repo}" — which misattributes a
missing-permission failure to repository access. Worth softening the
message to mention permissions too; left out here to keep this PR
focused on the scope change.
## Test
- `cargo nextest run -p fabro-cli --
manifest_includes_callback_urls_and_setup_url` passes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
3e0db1febf
|
feat: grant Dependabot alerts read/write to auto-created GitHub Apps (#543)
## What Adds the `vulnerability_alerts: write` fine-grained permission to the GitHub App manifest used when Fabro auto-creates a GitHub App, in **both** install flows: - `lib/crates/fabro-server/src/install.rs` (web-UI install) - `lib/crates/fabro-cli/src/commands/install.rs` (CLI install) `write` on `vulnerability_alerts` grants both read and write of Dependabot alerts (write implies read for fine-grained permissions). The two manifest builders are byte-for-byte identical by design, so both are updated together. A test assertion in the CLI install tests guards the new permission. ## Why We need auto-created Fabro apps to be able to read and manage Dependabot alerts. ## Note on rollout Manifest `default_permissions` are applied at **app-creation time**, so this only affects **newly** auto-created apps. Any app already created won't pick this up automatically — the owner must add the permission in the app's settings, and each existing installation must approve the new permission request. ## Test - `cargo nextest run -p fabro-cli -- manifest_includes_callback_urls_and_setup_url` passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
bb369181b6
|
Fix Sandbox::glob to use consistent glob semantics across all provide… (#546)
## Summary
`Sandbox::glob` worked correctly on the Local provider but silently
returned empty results on Docker and Daytona for any pattern containing
`/` or `**` (e.g. `*/SKILL.md`). This broke skill discovery on every
remote sandbox — the production path — and degraded the agent's `Glob`
tool for common patterns like `**/*.rs`.
## Root cause
The remote providers delegated matching to `find -name <pattern>`, but
`find -name` only matches the basename and rejects patterns containing
`/`. So `find <base> -name "*/SKILL.md"` exits 0 with empty output while
the file is sitting right there.
## Fix
Glob is two distinct operations: **traversal** (needs filesystem access)
and **matching** (pure string logic). The fix separates them cleanly:
- A new `glob_match` module (`src/glob_match.rs`) provides `GlobMatcher`
and `traversal_root` helpers, backed by the already-present `glob`
crate's `Pattern` matcher with `require_literal_separator: true` so `*`
stays within a single path segment.
- Remote providers (Docker, Daytona) now run `find <root> -type f`
(traversal only) and pass results through `GlobMatcher` on the host
side.
- Daytona additionally gains a `list_files_recursive` path that uses the
Daytona filesystem API directly instead of shelling out, which is more
robust when the shell is fail-closed.
- Local is also rerouted through `GlobMatcher` with a
`collect_local_files` walker, making all three providers share identical
matching semantics by construction. mtime-based sort is preserved using
metadata collected during traversal.
```mermaid
flowchart TB
caller["glob(pattern, path)"]
traversal_root["traversal_root(base, pattern)\nextract literal prefix"]
list["list files under root\n(find -type f / fs API / std::fs)"]
matcher["GlobMatcher::new(base, pattern)\nglob::Pattern + MatchOptions"]
filter["filter candidates"]
sort["sort results"]
caller --> traversal_root --> list --> filter
caller --> matcher --> filter --> sort
```
### Plan Summary
- New `glob_match.rs` module: `GlobMatcher`, `traversal_root`,
`join_path` utilities + unit tests proving parity with `glob::glob` on
shared fixtures
- Docker: replace `find -name` with `find -type f` + host-side
`GlobMatcher`
- Daytona: replace `find -name` with `list_files_recursive` (Daytona FS
API) + `GlobMatcher`
- Local: replace `glob::glob()` walk with `collect_local_files`
(symlink-safe) + `GlobMatcher`; mtime sort preserved
- New `LocalSandbox::glob` tests: relative path resolution, `**` depth,
`*/SKILL.md` one-level semantics, symlink non-recursion
### Fabro Details
<details>
<summary>Ran 8 stages in 64m 31s for $14.97</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 21s | – | 0 |
| preflight_lint | 2m 41s | – | 0 |
| implement | 39m 47s | $11.23 | 0 |
| simplify_opus | 7m 36s | $2.55 | 0 |
| simplify_gpt | 3m 48s | $1.19 | 0 |
| verify | 7m 47s | – | 0 |
| **Total** | **64m 31s** | **$14.97** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (11 nodes and 14
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-8; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, timeout="1800s", script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Scott Werner <stwerner@vt.edu>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
bec4b90ad3
|
Move environments to SQLite storage (#539)
## Summary Move server-managed environments from sibling TOML files into SQLite, matching the storage model already used by variables and secrets. This adds: - an `environments` SQLite table with DB-level validation for IDs, revisions, providers, network modes, booleans, and JSON fields - a SQLite-backed `EnvironmentStore` with cached synchronous reads, transactional create/replace/delete, synthetic unpersisted `local`, and `default` as an ordinary seeded row users can delete - one-time legacy import from `environments/*.toml` next to the active server `settings.toml`, including relative Dockerfile path inlining and backup rename to `environments.imported-<timestamp>.bak` - install/test/CLI seeding of `default` directly into SQLite instead of writing `environments/default.toml` - docs updates for API/SQLite-managed server environments and legacy import behavior The REST API shape is unchanged; path Dockerfile sources remain rejected over the environments API. ## Testing - `cargo nextest run -p fabro-db -p fabro-environment` - 15 passed - `cargo nextest run -p fabro-server --features test-support environments` - 16 passed - `cargo nextest run -p fabro-server --features test-support install` - 60 passed - `cargo nextest run -p fabro-server --features test-support create_run_rejects_disabled_sandbox_provider` - 1 passed - `cargo nextest run -p fabro-server --features test-support system_sandbox_provider` - 2 passed - `cargo nextest run -p fabro-cli install` - 132 passed - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` |
||
|
|
0244736b05
|
Resolve run.prepare.steps env and interpolation at the run boundary (#530)
## What
Per-step environment in `run.prepare.steps[].env` was parsed and then
**dropped** before it reached the resolved run settings, so prepare
steps could never see their declared env. This PR carries that env all
the way through to the executor, resolves prepare-step interpolation at
the run boundary, and fixes an argv-quoting bug.
Three things:
1. **Per-step env is carried through.** `RunPrepareSettings` now holds
`steps: Vec<PreparedStep>` (command plus per-step `env`) instead of a
flat `commands: Vec<String>`. The per-step env reaches `exec_command`,
which already accepts per-command env vars, and is merged on top of the
base sandbox environment.
2. **Interpolation resolves at the run boundary.** Prepare-step
`script`/`command` and per-step `env` values are carried in source form
out of the portable config resolve layer (so `fabro validate` stays
portable and never requires env to be set). Their `{{ env.* }}` tokens
resolve in the process that actually runs the steps, via
`RunPrepareSettings::resolve_step_env` — mirroring the existing MCP
transport env resolution. A missing env var is a **hard error**
(fail-closed); there is no fallback to the unresolved literal.
3. **Argv is shell-quoted.** Argv-style prepare steps were assembled
with `join(" ")`, so an argument containing spaces or quotes was
re-split by the shell. They are now shell-quoted per element with the
shared `shell_quote()` helper. `script` steps stay verbatim because they
are raw shell snippets.
## How
- `RunPrepareSettings.commands: Vec<String>` becomes
`RunPrepareSettings.steps: Vec<PreparedStep>` where `PreparedStep {
command, env }`. The server-side `{{ vars.* }}` substitution pass now
walks each step's command and env.
- New `RunPrepareSettings::resolve_step_env(env_lookup)` resolves `{{
env.* }}` in each step's command and env values, returning a hard error
on a missing var (and a loud `Unavailable` error for reserved
`secrets`/`inputs` tokens).
- The run boundary (`fabro_workflow::operations::start`) gains
`runtime_setup_commands`, the prepare-step counterpart to
`runtime_mcp_server`. `LifecycleOptions` now carries `Vec<SetupCommand>`
(command + env), and the initialize phase passes each step's env to
`exec_command`.
- `resolve_prepare` shell-quotes each argv element and carries per-step
env in source form. The stale lint suppression on the resolved fields is
rewritten to describe the deliberate source preservation that now
resolves at the run boundary.
- The shell-quoting helper moves to a shared `fabro_util::shell` module
(backed by `shlex`); `fabro_sandbox::shell_quote` delegates to it so the
config resolve layer and sandbox code share one audited implementation.
- The OpenAPI `RunPrepareSettings` schema and the generated TypeScript
client are updated to the new `steps`/`PreparedStep` shape.
## Testing
- `cargo build --workspace`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D
warnings`
- `cargo nextest run` for `fabro-util`, `fabro-types`, `fabro-config`,
`fabro-sandbox`, `fabro-api`, `fabro-workflow`, `fabro-server`,
`fabro-cli` (provider keys stripped) — all green.
- `cd lib/packages/fabro-api-client && bun run typecheck` — clean.
New tests cover: per-step env carried through resolution; script/command
+ env resolved at the run boundary; a missing env var is a hard error
(in both the command and a per-step env value); reserved `secrets`
tokens surface as `Unavailable`; argv elements are shell-quoted (an arg
with spaces/quotes is correctly quoted) while a `script` stays verbatim;
and an end-to-end check that per-step env reaches the executed setup
command (with a negative control proving the success is attributable to
the per-step env).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
287afd7928
|
Hooks: typed end-to-end interpolation, narrow header tokens, fail-closed resolution (#528)
## What
Makes hook interpolation typed end-to-end and fail-closed, and removes
the bespoke template engine on HTTP-hook headers.
- **Typed end-to-end.** Hook `command`, `url`, header values, `prompt`,
and `model` are now carried as a typed `InterpString` from the config
resolve layer all the way to the executor. The executor resolves each
segment at hook fire time from the typed value instead of collapsing it
to a `String` and re-parsing it. This mirrors the MCP transport env
resolution boundary (`resolve_transport_env` / `runtime_mcp_server`).
- **Narrow header tokens.** HTTP-hook headers previously ran through
MiniJinja with an env allowlist
(`TemplateContext::with_env_lookup_allowed`). They now resolve through
the same narrow `{{ ns.NAME }}` token resolver as every other hook field
— no template engine, no allowlist.
- **Fail-closed everywhere.** A missing or out-of-scope `{{ env.* }}` /
`{{ secrets.* }}` token in a command, URL, header, prompt, or model is
now a hard error that blocks the hook rather than firing it with a
half-resolved or empty value. Previously command hooks failed closed but
http/prompt/agent hooks failed open (warned and proceeded), which could
dispatch an HTTP request with an empty credential header or run an LLM
call against a half-rendered prompt. Transport-level outcomes (non-2xx
responses, connection errors, unparseable bodies) stay fail-open.
A follow-up cleanup commit removes the template engine's `env` namespace
(`with_env_lookup` / `with_env_lookup_allowed` / the `EnvLookup`
object), which the header path was the last consumer of.
## How
- `fabro-types` and `fabro-hooks` `HookType` / `HookDefinition` now type
the interpolatable fields as `InterpString`. `InterpString` serializes
as its raw source, so persisted run specs and checkpoints round-trip
unchanged.
- The `fabro-config` resolve layer clones the typed `InterpString`
through instead of calling `as_source()`, so the fields no longer leak
unresolved template text — the old "source preservation" `#[expect]`
annotations on the hook resolvers are gone.
- The executor's single `resolve_interp` helper resolves a typed
`InterpString` and is shared by the command, http, prompt, and agent
paths; resolution failure maps to `HookDecision::Block`, which the
runner already reports loudly (error for blocking hooks, warn for
non-blocking).
## Testing
- New unit tests: fire-time resolution from the typed value (no
re-parse), narrow-token header resolution, and fail-closed behavior for
HTTP url, HTTP header, and prompt hooks on a missing variable (the hook
does not fire and the resolution error surfaces).
- Existing hook tests updated and kept green.
- Gates: `cargo build --workspace`, `cargo +nightly-2026-04-14 fmt
--check --all`, `cargo +nightly-2026-04-14 clippy --workspace
--all-targets -- -D warnings`, and `cargo nextest run` for the touched
crates (`fabro-hooks`, `fabro-types`, `fabro-config`, `fabro-template`,
`fabro-workflow`, `fabro-server`, and the `fabro-cli` hook/config
tests), all green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
173968a780
|
feat(server): mcp-servers HTTP API — handlers + AppState wiring (#532)
Some checks are pending
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
TypeScript / Build (push) Waiting to run
## What
Adds the **mcp-servers HTTP API**: `GET/POST /api/v1/mcp-servers` and
`GET/PUT/DELETE /api/v1/mcp-servers/{id}` on top of the merged
`fabro-mcp-store` foundation and OpenAPI spec.
This includes the AppState wiring needed for the catalog to work end to
end: `McpServerStore` construction from `{active-config-dir}/mcps/`, an
`AppState` accessor, the `fabro-server` dependency, and route
registration for list/create/get/replace/delete handlers.
The API mirrors the automations concurrency pattern with ETags on
read/write responses and required `If-Match` headers for replace/delete.
## Resolved before merge
- **Credential-omitting read model:** read responses now return
`McpServerView` / `McpTransportView`, so stored env/header values are
not exposed by GET/list/create/replace responses. Responses include only
`env_keys` / `header_keys`; persisted values remain available to runtime
execution.
- **Manifest catalog references:** run manifest validation, graph
rendering, preflight, and run creation now resolve server-managed MCP
catalog references such as `[run.agent.mcps.<name>] id = "..."`.
- **Schema strictness:** unknown MCP transport fields are rejected,
aligning the reused Rust domain type with the OpenAPI
`additionalProperties: false` contract.
- **Create response headers:** the `POST /mcp-servers` 201 response now
documents its `ETag` header in OpenAPI.
## Follow-up intentionally left out
Credential-literal validation remains structural only: create/replace
currently accept literal env/header values and persist them for runtime
use. The warn-vs-hard-reject UX is a separate follow-up for the settings
UI; it is not a response-omission issue.
## Testing
Current PR checks are green:
- Rust: format, clippy, generated docs, Linux tests
- TypeScript: build, test, typecheck
Local checks run during the simplify/CI-fix pass:
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy --locked --workspace --all-targets
-- -D warnings`
- `cargo nextest run -p fabro-config run_agent_mcps`
- `cargo nextest run -p fabro-mcp-store`
- `cargo nextest run -p fabro-api --test mcp_server_round_trip`
- `cargo build -p fabro-api`
- `cargo nextest run -p fabro-server --features test-support
system_sandbox_provider`
- `cargo nextest run -p fabro-server --features test-support --test it
mcp_servers`
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
164d9dcfbc
|
Move variables to SQLite storage (#537)
## Summary This moves workflow-visible variables from JSON file storage into SQLite-backed storage, establishing the first durable SQL table while preserving the existing variable API behavior. ## What Changed - Added a `fabro-db` crate with bundled SQLite, an embedded migration for the `variables` table, and a `Database` owner for `connect()`, `migrate()`, `health_check()`, and pool access. - Replaced the `fabro-variable` JSON file store with an async SQLx-backed `VariableStore` that preserves sorted listing, case-sensitive names, empty string values, name validation, and description-preserving upserts. - Wired server startup to create `<storage>/db/fabro.sqlite3`, run SQLite migrations, import legacy variables when needed, and pass the shared pool into server state. - Grouped live server stores under `AppStores` so runs, variables, vault, environments, and automations share one state boundary while artifacts remain separate. - Updated variable handlers, run creation, validation, and test support for async SQLite-backed variable access. - Added schema, store-level, legacy import, and API-level persistence coverage for variables. ## Legacy JSON Migration On startup, Fabro looks for `<storage>/variables.json`. If it is missing, startup is a no-op for legacy variables. If the file exists, Fabro parses and validates the full file before mutating SQLite. Valid entries are inserted with `ON CONFLICT(name) DO NOTHING`, so existing SQLite values remain authoritative and only missing names are imported from the legacy file. After a successful import transaction, the source file is renamed to a timestamped backup such as `variables.json.imported-<timestamp>.bak`. A later startup naturally skips the import because the original source path no longer exists. Invalid JSON or invalid variable names leave the source file in place for operator repair. Variable values are not logged during import. Logs include only safe metadata such as source/backup paths, row counts, and variable names. ## Verification - `cargo nextest run -p fabro-db -p fabro-variable` - `cargo nextest run -p fabro-server --features test-support variables` - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` --- [](https://github.com/EveryInc/compound-engineering-plugin) Generated with GPT-5 via [Codex](https://openai.com/codex) |
||
|
|
2307468bc6
|
fix(cli): use server catalog for provider login (#529)
Some checks are pending
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Build (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
## Summary `fabro provider login --server ... --provider openrouter` now asks the selected Fabro server for provider metadata before reading, validating, and storing API keys, so server-enabled providers are accepted even when the local CLI catalog does not know them. This adds a server-side credential test endpoint that validates submitted API keys against the server's effective catalog without persisting them, then keeps saving the resulting secret to the selected target server. OpenAI Codex device login remains client-side for the browser/device flow, with the resulting OAuth credential stored on the selected server. The OpenRouter docs and model docs are updated to use the current `--provider openrouter` login syntax and clarify that remote deployments need the server host settings updated. ## Testing - `cargo nextest run -p fabro-client -p fabro-server -p fabro-cli provider` - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo +nightly-2026-04-14 clippy -p fabro-client -p fabro-server -p fabro-cli --all-targets -- -D warnings` - `rg -n "provider login openrouter|fabro provider login [a-z]" docs/public lib/crates/fabro-cli/tests lib/crates/fabro-cli/src -g '*.md' -g '*.mdx' -g '*.rs'` --- [](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 (context compacted, extended thinking) via [Codex](https://openai.com/codex) |
||
|
|
94df98bb34
|
fabro doctor: check Docker daemon when Docker sandbox is enabled (#525)
## Summary Fixes #501. Adds a Docker sandbox diagnostics check so `fabro doctor` verifies the Docker daemon when the Docker sandbox provider is enabled. Disabled Docker providers are reported as disabled without touching the local daemon. ## What changed - Added `DockerSandboxProvider::check_daemon()` using Bollard `ping()` only, with no container/image side effects. - Added a `Docker Sandbox` check to server diagnostics with pass/error/timeout handling and operator remediation. - Updated demo diagnostics and doctor/server test fixtures so tests that do not exercise Docker explicitly disable the provider. - Added deterministic tests for enabled success, enabled failure, enabled timeout, and disabled skip paths. ## Verification - `cargo check -p fabro-server -p fabro-sandbox -p fabro-cli` - `cargo test -p fabro-server docker_sandbox --lib` - `cargo test -p fabro-server --features test-support diagnostics_reports_under_scoped_daytona_api_key --lib` - `cargo test -p fabro-cli --test it cmd::doctor` - `git diff --check` Not run locally: pinned nightly `fmt`/`clippy` because this environment has Homebrew Rust only and no `rustup` for `nightly-2026-04-14`. --------- Co-authored-by: Bryan Helmkamp <bryan@brynary.com> |
||
|
|
ee7453418b
|
Unify @file inlining under an ImportableTemplate type (prompt + goal) (#527)
## What
Introduces an `ImportableTemplate` type that unifies the "inline content
**or**
`@path` file import" concept used by node `prompt`s, the graph `goal`,
and
`output_schema`. This is the last template-side piece of the
interpolation
unification: a single named type now owns the `@`-classification and
static-reference validation that was previously hand-rolled in three
places.
This is a **behavior-preserving refactor** — no user-visible change.
## How
- New `ImportableTemplate { Inline(String), Import { path } }` in
`transforms/importable_template.rs`, with `parse` (classifies a value —
a
leading `@` marks a file import), `import_path`, and `validate` (rejects
template syntax in an import path). Callers of templated fields classify
the
**already-rendered** string, because a leading `@` can be produced by
rendering (e.g. `{{ inputs.prompt_file }}` → `@prompts/work.md`).
- `prompt` + `goal`: render the inline value, then — if it's an `@file`
import —
load and render the file contents via the type. The missing-file →
literal
passthrough is preserved.
- `output_schema`: shares the same classification but is loaded
**verbatim** (it
is intentionally not a template), keeping its hard-error-on-missing-file
behavior.
- Deletes the dead `resolve_file_ref` helper (no non-test callers) and
inlines
the trivial `render_file_contents` wrapper.
- Migrates the `FilesystemFileResolver` coverage (tilde, `..`,
fallback-dir
precedence, missing file) — which previously only existed through
`resolve_file_ref`'s tests — onto direct `file_resolver` tests.
`TemplateTransform` and the import transform are untouched, so
goal-before-
prompts ordering and the goal-self-reference guard are preserved
exactly.
## Scope
Covers the DOT node `prompt` + graph `goal` `@file` path. The
settings-layer
`run.goal` resolution is intentionally left as-is — it uses a different
model
(interpolates env into the file path and does not render file contents),
so
folding it in would be a semantic change, not a refactor. That
convergence can
be a deliberate follow-up.
## Testing
- `cargo nextest run -p fabro-workflow` — 1182 passed (31
e2e/credentialed
skipped). New unit tests on the type (classification, validation) and
the
migrated `FilesystemFileResolver` tests.
- Regression net kept green: file-inlining (prompt/goal, output_schema
verbatim/error/routing, `{% include %}` rooting, fallback dir), the
`TemplateTransform` goal/self-reference/ordering tests, and the
cross-pass
`reports_goal_self_reference_once_across_passes`.
- `cargo +nightly fmt --check --all` and nightly
`clippy --workspace --all-targets -- -D warnings` clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
8a7ad7200b
|
feat(types): introduce ResolvedMcpEntry for run agent MCP entries (#526)
## What
Changes `RunAgentSettings.mcps` from `HashMap<String,
McpServerSettings>` to `HashMap<String, ResolvedMcpEntry>`, a two-state
enum:
- `Resolved(McpServerSettings)` — an inline, fully-resolved MCP server
(every code path produces this today).
- `Reference { id, enabled }` — an unresolved reference to a named
server in the MCP catalog.
This is the **type-shape foundation only**: every current path still
produces `Resolved`, and no reference parsing or catalog lookup is added
here. It unblocks a later server-side pass that swaps `Reference` →
`Resolved` against the MCP server store before a run spec is persisted,
so persisted runs stay self-contained snapshots.
## Why this shape
- `ResolvedMcpEntry` is `#[serde(untagged)]` with `Resolved` first, so a
resolved entry (de)serializes as a bare `McpServerSettings` with no enum
tag — preserving backward compatibility with run specs persisted before
the enum existed.
- `McpServerRef` uses `deny_unknown_fields`, so the two variants can
never collide (`McpServerSettings` requires `name` + `transport`, which
a reference rejects).
- `McpServerRef.id` is a plain `String`, keeping `fabro-types` decoupled
from the MCP store crate.
## Consumers updated
- **fabro-config** `resolve_agent`: wraps each enabled inline entry as
`Resolved`, reusing the shared `resolve_enabled_mcps` enable-filter.
- **fabro-types** `RunNamespace::substitute_variables`: only walks
`Resolved` entries (references carry no templates).
- **fabro-workflow** `operations/start.rs`: extracts `Resolved` at the
post-persistence worker-startup consumer; a surviving `Reference` is an
invariant violation, guarded with `debug_assert!` plus a hard error.
- **fabro-cli** `exec.rs`: the `run.agent.mcps` fallback for `fabro
exec` keeps only `Resolved` inline servers; catalog references are
run-only on this CLI-direct path (no server-side resolver).
## Tests
- Back-compat round-trip proving old-format bare-`McpServerSettings`
maps (JSON and TOML) deserialize as all-`Resolved`.
- A `{ id, enabled }` value parses as `Reference` while a full server
config parses as `Resolved`.
- `Resolved` serializes back out as a bare `McpServerSettings`.
Independent of the in-flight MCP server store and OpenAPI-spec PRs;
mergeable on its own.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
c311b6c67f
|
feat(api): add /api/v1/mcp-servers OpenAPI endpoints (#522)
## What Adds the HTTP contract for managing server-defined MCP servers. The handler implementation follows in a later change. - New `/api/v1/mcp-servers` paths: `list`, `create`, `retrieve`, `replace`, `delete`, with ETag / `If-Match` optimistic concurrency mirroring the automations conventions. - New schemas: `McpServer`, `CreateMcpServerRequest`, `ReplaceMcpServerRequest`, `McpServerListResponse`. - **Collapsed a duplicate `McpTransport` schema** into the single canonical one and gave it a proper `discriminator` plus the previously-missing optional `protocol` field (`streamable_http` | `sse`). This also fixes a latent gap in the existing run-config projection and is non-breaking (`protocol` is `#[serde(default)]`). ## Testing - `cargo build -p fabro-api` is green — progenitor generates the client methods and types cleanly from the new spec. ## Notes / follow-ups for the handler change - Recommended `with_replacement` mapping (reuse, no parallel DTOs): `McpServer` → `McpServerDefinition`, create/replace → `McpServerDraft`/`McpServerReplace`, transport → existing `fabro_types::McpTransport`/`McpHttpProtocol`; list envelopes become small DTOs. - Parity caveat: progenitor emits `i64` for the `u64` timeouts and `i32` for the `u16 port`; harmless under `with_replacement`, but the handler change must add identity/JSON-parity tests and not skip `with_replacement` for those types. - `createMcpServer` returns ETag on 201 (Environments convention) so the UI gets the fresh revision. - The "warn vs hard-reject credential-looking literal values" question is recorded in the request-schema descriptions and intentionally not enforced. - Part of a short series adding server-managed MCP servers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |