Commit graph

3881 commits

Author SHA1 Message Date
fabro-sh-fabro[bot]
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>
2026-07-02 16:59:41 -04:00
Scott Werner
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>
2026-07-02 16:58:47 -04:00
André Mazoni
332642f5c3
fix(web): respect workflow's rankdir on run overview graph (#549)
Some checks are pending
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
TypeScript / Build (push) Waiting to run
## Summary
Fixes the graph that was always being rendered as `left-to-right` even
when the workflow's `rankdir` is `top-to-bottom`

## Test plan
- [x] `bun run typecheck` (fabro-web)
- [x] `bun test` (fabro-web, full suite — 625 pass)
- [x] Manually load a run whose workflow declares `rankdir TB` and
confirm the graph renders top-to-bottom on first load, with the
toolbar's LR/TB buttons still working as manual overrides

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 11:35:24 -04:00
André Mazoni
91bd115d53
fix(web): Enabling scroll in the Stages sidebar on the run overview/stages tabs (#541)
This change enables scrolling the stages sidebar on the run's
overview/stages page. Without it, for long runs with lots of stages, the
entire page scrolls, hiding the graph while it's running.


https://github.com/user-attachments/assets/c5405a5b-8480-46f8-8d7c-4cd4914f6228

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:03:17 -04:00
fabro-releases[bot]
0e30ae30ba Bump version to 0.282.0-nightly.0 2026-07-02 10:17:41 +00:00
Scott Werner
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>
2026-07-01 22:13:26 +00:00
Scott Werner
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>
2026-07-01 22:05:25 +00:00
fabro-sh-fabro[bot]
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>
2026-07-01 16:13:08 -04:00
fabro-sh-fabro[bot]
c945fb404b
feat: add MCP servers settings UI at /settings/mcps (#540)
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
## Summary

Adds a full CRUD management UI for server-managed MCP servers at
`/settings/mcps`, consuming the already-shipped `MCPServersApi` backend.
The implementation mirrors the existing `/settings/environments` pages
exactly in structure, naming, and component conventions.

## What changed

### Step 1 — Shared `KeyValueEditor` extracted
`KeyValueEditor`, `KeyValueEntry`, `entriesFromMap`, and
`mapFromEntries` are moved from `environment-form.tsx` into a new
`components/key-value-editor.tsx`. The component gains an optional
`renderEntryHint` prop so per-row warnings can be injected without
coupling the editor to credential logic. `Label` is promoted from
`environment-form.tsx` to `settings-panel.tsx` so both forms can use it.

### Step 2–4 — Query plumbing
- `query-keys.ts`: `mcpServers.{list, detail}` keys.
- `api-client.ts`: `mcpServersApi` instance (same pattern as
`environmentsApi`).
- `queries.ts`: `useMcpServers()` and `useMcpServer(id)` SWR hooks.

### Step 5 — Credential heuristics (`lib/credential-heuristics.ts`)
Pure functions `looksLikeCredential`, `secretNameForKey`,
`secretReference`. Key-name matching covers `authorization`, `password`,
`token`, `api[-_]?key`, `_key`/`_token`/`_secret` suffixes.
Value-entropy fallback fires for strings ≥ 20 chars, no spaces, mixed
case/digit classes. Template references (`{{ secrets.* }}`) are never
flagged.

### Step 6–7 — Form model + component (`components/mcp-server-form.tsx`)
- Flat `McpServerFormValues` discriminated on `McpTransportKind`.
- `defaultMcpServerFormValues`, `mcpServerToFormValues` (populates
`env`/`headers` from `env_keys`/`header_keys` with **empty values** —
the §5 write-only design), `createRequestFromForm`,
`replaceRequestFromForm`, `isMcpServerFormValid`, `credentialWarnings`.
- `McpServerFormFields` renders stdio / http / sandbox panels switching
on `values.transport`. Per-row credential nudge opens the secrets-new
page in a new tab and substitutes a `{{ secrets.NAME }}` reference; save
is never blocked by the heuristic.
- On edit, a row with a non-empty key and empty value blocks save with
an inline error (the intentional overwrite guard).

### Step 8–10 — Route pages
| File | Mirrors |
|---|---|
| `routes/settings-mcps.tsx` | `settings-environments.tsx` |
| `routes/settings-mcps-new.tsx` | `settings-environments-new.tsx` |
| `routes/settings-mcps-edit.tsx` | `settings-environments-edit.tsx` |

The edit page shows a write-only-values banner whenever the transport
has any `env_keys`/`header_keys`, uses `key={server.revision}` to
remount the form on external change, and translates 409 responses into
the `staleAwareMessage` pattern.

### Steps 11–12 — Router + nav
Three routes registered under `settings` children. `PuzzlePieceIcon` nav
entry added to the same section as Environments.

### Plan Summary
- Extract `KeyValueEditor` to shared component with hint-injection slot
- Credential heuristics library (pure, fully unit-tested)
- MCP form model: flat values ↔ discriminated API types, write-only-key
guard
- List / new / edit pages following environments pattern exactly
- Route registration and settings nav link


### Fabro Details

<details>
<summary>Ran 9 stages in 65m 58s for $20.54</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 47s | – | 0 |
| preflight_lint | 4m 15s | – | 0 |
| implement | 26m 29s | $12.27 | 0 |
| simplify_opus | 7m 41s | $4.95 | 0 |
| simplify_gpt | 6m 51s | $2.69 | 0 |
| verify | 15m 44s | – | 0 |
| fixup | 1m 41s | $0.63 | 0 |
| **Total** | **65m 58s** | **$20.54** | **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, 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>
2026-07-01 10:32:37 -04:00
Bryan Helmkamp
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`
2026-07-01 10:31:58 -04:00
Scott Werner
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>
2026-07-01 10:31:26 -04:00
fabro-releases[bot]
c631ce557b Bump version to 0.281.0-nightly.0 2026-07-01 10:38:53 +00:00
Scott Werner
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>
2026-06-30 17:07:11 -04:00
Scott Werner
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>
2026-06-30 15:06:24 -04:00
Bryan Helmkamp
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`

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
Generated with GPT-5 via [Codex](https://openai.com/codex)
2026-06-30 12:17:46 -04:00
fabro-releases[bot]
7507a2279a Bump version to 0.278.0-nightly.0
Some checks failed
Rust / Format (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Generated Docs (push) Has been cancelled
Rust / Test (Linux) (push) Has been cancelled
Rust / Test (macOS) (push) Has been cancelled
2026-06-28 10:18:39 +00:00
Bryan Helmkamp
ba6372d555
security: patch react-router CVE alerts (#535)
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
## Summary
- Updates direct web runtime dependency `react-router` from `7.12.0` to
`7.15.1` in `apps/fabro-web`.
- Regenerates the root Bun workspace lockfile.
- Expected to resolve Dependabot alerts:
  - https://github.com/fabro-sh/fabro/security/dependabot/31
  - https://github.com/fabro-sh/fabro/security/dependabot/32
  - https://github.com/fabro-sh/fabro/security/dependabot/33
  - https://github.com/fabro-sh/fabro/security/dependabot/34
  - https://github.com/fabro-sh/fabro/security/dependabot/35
  - https://github.com/fabro-sh/fabro/security/dependabot/36
  - https://github.com/fabro-sh/fabro/security/dependabot/37

## Grouping
- Grouped these alerts because they all affect the same direct package,
same manifest, same runtime scope, and same verification path.
- Kept separate from the Rust `tar` alert because it touches a different
ecosystem and lockfile.

## Verification
- `bun pm why react-router` resolves `react-router@7.15.1` for
`fabro-web`.
- `cd apps/fabro-web && bun run typecheck`
- `cd apps/fabro-web && bun test --isolate` (625 passed, 0 failed)
- `cd apps/fabro-web && bun run build`
- `git diff --check`

## Residual alerts
- Rust `tar` alert 30 is handled separately in
https://github.com/fabro-sh/fabro/pull/534.

Co-authored-by: Release Repro <release-repro@example.com>
2026-06-27 12:10:39 -04:00
Bryan Helmkamp
f015814835
security: patch tar CVE alert (#534)
## Summary
- Updates transitive Rust dependency `tar` from `0.4.45` to `0.4.46` in
`Cargo.lock`.
- Expected to resolve Dependabot alert:
https://github.com/fabro-sh/fabro/security/dependabot/30
- Dependency path: `fabro-sandbox` -> `tar`.

## Grouping
- Kept this separate from the web alerts because it is a Rust
lockfile-only patch with a separate verification path.

## Verification
- `cargo tree -i tar` resolves `tar v0.4.46`.
- `cargo build --workspace`
- `cargo nextest run --workspace` (6860 passed, 185 skipped; nextest
reported 1 leaky test warning as non-fatal)
- `git diff --check`

## Residual alerts
- React Router alerts 31-37 are intentionally handled in a separate web
PR.

Co-authored-by: Release Repro <release-repro@example.com>
2026-06-27 12:10:30 -04:00
fabro-releases[bot]
f03936a02b Bump version to 0.277.0-nightly.0 2026-06-27 10:00:50 +00:00
Bryan Helmkamp
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'`

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 (context compacted, extended thinking) via
[Codex](https://openai.com/codex)
2026-06-26 08:46:38 -04:00
fabro-releases[bot]
6529f120de Bump version to 0.276.0-nightly.0 2026-06-26 10:23:30 +00:00
Haoqian
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>
2026-06-25 21:09:31 -04:00
Bryan Helmkamp
2fb2d93735
feat(web): add OpenRouter provider logo (#531)
Adds `openrouter.svg` so OpenRouter renders its brand mark on
`/settings/models` instead of the letter-initial fallback. The icon is
the official OpenRouter mark (monochrome, `currentColor`), normalized to
match the other provider logos. No code change needed — the route
already resolves `/images/providers/<provider.id>.svg`, and the catalog
provider id is `openrouter`.

---

[![Compound Engineering
v2.60.0](https://img.shields.io/badge/Compound_Engineering-v2.60.0-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with Claude Opus 4.8 (1M context, extended thinking) via
[Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:47:51 -04:00
Bryan Helmkamp
bb77806900
Sync Cargo.lock for fabro-mcp-store 2026-06-25 16:43:26 -04:00
Scott Werner
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>
2026-06-25 15:51:46 -04:00
Scott Werner
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>
2026-06-25 15:50:36 -04:00
Scott Werner
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>
2026-06-25 15:50:13 -04:00
Scott Werner
4b7c2690c8
feat(mcp): add server-side MCP server store (fabro-mcp-store) (#521)
## What

Adds the storage foundation for server-managed MCP servers: a durable
store plus its domain model. No server wiring, HTTP API, or UI yet —
this is standalone scaffolding that later PRs build on.

- New **`fabro-mcp-store`** crate: a concrete, filesystem-backed
`McpServerStore` — one TOML file per definition under
`{active-config-dir}/mcps/`, an in-memory cache, and a SHA-256
content-hash revision for optimistic concurrency. Modeled directly on
`AutomationStore`. Includes an id-only `ids()` accessor for cheap
listing that avoids cloning the (potentially sensitive) env/header maps
a full definition carries.
- New **`McpServerDefinition` / `McpServerDraft` / `McpServerReplace`**
domain model (plus `McpServerId` / `McpServerRevision` and structural
validation) in `fabro-types`, reusing the existing `McpTransport`. These
stay persistence-independent; the on-disk TOML DTO and the filesystem
plumbing live in `fabro-mcp-store`.

Nothing in the workspace depends on the new crate yet. Wiring
`McpServerStore` into the server, the HTTP API, and the UI are follow-up
PRs.

## Testing

- `fabro-mcp-store`: 7/7 (empty/missing dir, non-TOML ignored,
malformed/invalid-filename fail load, CRUD round-trip, stale-revision
and duplicate-create rejected).
- `fabro-types`: `mcp_store` validation and round-trip tests pass.
`cargo build --workspace`, fmt, and clippy all green.

## Notes

- The domain model derives `PartialEq` but not `Eq` because
`McpTransport` carries `HashMap`s (differs from `Automation*`, matches
the transport's capabilities).
- Validation is structural for now (id format, non-empty name,
well-formed transport); credential-literal validation is deliberately
deferred to the API layer (flagged TODO).
- The store is concrete by design (no trait): a future move off per-file
TOML is a one-time migration, not a runtime backend choice. The revision
is currently derived from the canonical TOML bytes — the one
storage-coupled detail to revisit if that move happens.
- Part of a short series adding server-managed MCP servers; independent
of the sibling PRs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:50:02 -04:00
fabro-releases[bot]
ba56a170d8 Bump version to 0.275.0-nightly.0
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
2026-06-25 12:49:18 +00:00
Bryan Helmkamp
e9bccbcc1c
ci: re-enable scheduled nightly release 2026-06-25 08:34:43 -04:00
Scott Werner
a483402092
Interpolate run variables ({{ vars.* }}) in node prompts and goals (#524)
## What

Threads the run's variable store through the workflow transform pipeline
so
node `prompt`s and the graph `goal` can interpolate `{{ vars.* }}`.

Until now `{{ vars.* }}` only resolved in settings-level fields (e.g.
`run.goal`) via the server-side `substitute_variables` pass. Node
prompts are
DOT graph attributes that pass never touched, so `{{ vars.* }}` in a
prompt
rendered as undefined. This closes that gap.

Builds on the earlier template-context slice (adds `vars` to
`TemplateContext`); this PR wires it end to end.

## How

- `TransformOptions` carries a `vars` map, threaded into the import,
  file-inlining, and template transforms — and propagated into imported
subgraphs, so imported prompts interpolate vars too. Every prompt/goal
render
  context gains the variable map.
- The create API accepts `vars` (`CreateRunInput` →
`preprocess_and_validate` →
  `TransformOptions`).
- The server snapshots its `VariableStore` at run creation
  (`VariableStore::value_map()`) and passes it in — the same store the
  settings-goal substitution already reads.

## Scope decisions

- Goal `@file` contents interpolate vars too; **import paths stay
inputs-only**
(structural file resolution, conceptually outside the prompt/goal
scope).
- Offline / CLI / `fabro validate` render with an empty var map, so
`{{ vars.* }}` is undefined there: a warning at validate, a hard error
at
  run-create — identical to how `inputs` behaves offline.

## Testing

- Transform-level: node-prompt and goal interpolation; unknown-var
warning.
- Create-pipeline: vars resolve; an unknown var warns at validate and
promotes
  to a hard error at run-create.
- End-to-end server test: `POST /variables` + `POST /runs`, asserting
the
  rendered prompt in the persisted `run.created` event.

Verified: `cargo +nightly fmt --check`, nightly `clippy -D warnings`
(including
the `test-support`-gated server integration binary), the tests above,
and a
full-workspace `cargo check`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:18:09 -04:00
Scott Werner
274203bfda
refactor(server): extract reusable git-checkout/materialization core (#523)
## What

Foundational refactor toward running a workflow that lives in one repo
against a *different* workspace repo (shared / external workflows). No
public API surface and no behavior change for automations — it only
reshapes internals behind a reusable seam.

- **New `git_checkout` module.** Lifts the git-clone +
manifest-from-checkout machinery out of `automation_materializer`:
`GitRepoCache` (cached bare clone + per-call worktree), the git command
plans, credential resolution/redaction, and GitHub owner/repo slug
parsing/validation. All `pub(crate)`; no module is exported.
- **Split the workflow source from the git context.**
`build_manifest_from_checkout` now takes the *workflow-source checkout*
(which workflow to bundle) and the *git context* (which repo the run
clones and executes in) as separate inputs. Automations are the case
where both coincide. This is the seam a future external-workflow
resolver needs.
- **Decoupled the builder input.** `ManifestFromCheckoutInput` no longer
embeds `AutomationRunMaterializeInput`; it takes only the fields it
needs plus a caller-supplied error context, so it's reusable without
automation-specific types.

## Review fixes folded in

- **Error type points the right way.** The shared materialize error
moved into `git_checkout` as the provider-neutral `RunMaterializeError`
(same variants, neutral messages). The foundation module no longer
depends back on its consumer, and a bad workflow-source slug no longer
reports "invalid automation target".
- **Required git context, not `Option`.** No caller omits it today;
widening to optional later is backwards-compatible if a real case
appears.

## Testing

- `cargo build -p fabro-server`, pinned-nightly `fmt --all` and `clippy
-p fabro-server --all-targets -D warnings`: clean.
- `cargo nextest run -p fabro-server`: 729/732 pass. The 3 failures are
graphviz SVG-render-subprocess tests (`get_graph_returns_svg`,
`render_graph_from_manifest_*`) that fail identically on the clean
baseline in this environment — pre-existing and unrelated.
- The rewritten unit test proves the split: a manifest built from a
workflow-source checkout while `manifest.git` points at a *different*
repo and ref.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:17:44 -04:00
Scott Werner
b6ecbe20a9
fix(mcp): honor inline enabled=false and per-server tool_timeout (#520)
## What

Two latent fixes to MCP server config handling, independent of any new
feature:

1. **`enabled = false` is now honored for inline MCP servers.** Entries
under `[run.agent.mcps.*]` and `[cli.exec.agent.mcps.*]` accepted an
`enabled` flag that resolution silently ignored, so a disabled server
still started. Disabled entries are now dropped from the resolved set.
Absent `enabled` still means enabled.
2. **Explicitly configured empty `cli.exec.agent.mcps` sets are
preserved.** If every `cli.exec` MCP entry is disabled, `fabro exec` now
treats that as an intentional empty override instead of falling back to
`run.agent.mcps`.
3. **Per-server `tool_timeout_secs` now applies to MCP tool calls.** The
value was carried through config but never reached the call path. The
connection manager now owns each server timeout and applies it when
calling tools.

## Testing

- New and updated tests cover StickyMap same-key replacement across
layers, `enabled = false` skipped for run and `cli.exec`, absent
`enabled` kept, higher-layer disable shadowing, explicit empty
`cli.exec` MCP overrides, and configured tool timeout behavior.
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo nextest run -p fabro-config -p fabro-agent -p fabro-mcp`: 737
passed, 93 skipped.
- `cargo +nightly-2026-04-14 clippy -p fabro-config -p fabro-agent -p
fabro-mcp -p fabro-cli --all-targets -- -D warnings`
- `cargo test --locked -p fabro-workflow --test it --no-run`

## Notes

- **Behavior change** worth a changelog entry: disabled inline MCPs are
now actually disabled, explicit empty `cli.exec` MCP overrides are
respected, and per-server tool timeouts now take effect.
- First of a short series adding server-managed MCP servers; this PR is
self-contained and independent of the others.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:17:27 -04:00
Scott Werner
41fb7e7e1f
inputs is template-only: reject {{ inputs.* }} in InterpString with a clear error (D12) (#513)
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
Implements the `inputs`-template-only half of **D12**. Independent off
`main` — touches only `fabro-types` interp; no overlap with #511 or
#512.

## What changes for users

`{{ inputs.* }}` in an `InterpString` field (command, script, header,
env, URL — MCP transports, prepare steps, hooks, server settings) now
fails with a **clear, actionable message**:

> `{{ inputs.X }}` is only available in prompts and goals, not in
command, script, header, env, or URL fields

It *already* failed there (no resolve context ever provided an inputs
lookup, so it errored as a generic "unavailable"); this makes the
rejection explicit and points the user at where `inputs` belongs.

## How

- **`ResolveCtx` drops its unused `inputs` lookup** (`with_inputs` had
zero production callers). The type now structurally cannot resolve
`inputs` in an `InterpString` field; `lookup_for(Inputs)` returns
`None`.
- The `Unavailable` error message is `inputs`-specific and points to
prompts/goals.
- **`substitute_with` still preserves `inputs` tokens**
(unknown-namespace passthrough), so `run.goal` — an `InterpString` that
feeds a template — keeps forwarding `{{ inputs.* }}` to its prompt/goal
render. This is the load-bearing behavior that makes "inputs works in
goals" coexist with "inputs rejected in InterpString fields", and it's
covered by an existing test
(`substitute_variables_preserves_late_bound_tokens`).
- Module docs updated: three resolvable namespaces in `InterpString`
(`env`/`vars`/`secrets`); `inputs` is template-only.

## Note on timing

The rejection fires at **resolve time** (use-time / run boundary), not
at `fabro validate`. That matches how the other late-bound namespaces
behave and keeps this PR small; a validate-time fail-fast would need to
distinguish goal (forwards inputs) from pure-`InterpString` fields and
is a larger, separate change if we want it.

## Tests

`resolve_with_rejects_inputs_as_template_only` (rejection + friendly
message); `substitute_variables_preserves_late_bound_tokens` confirms
goal forwarding is unaffected.

Verified: `cargo build --workspace`, nightly `clippy --workspace
--all-targets -D warnings`, `fmt`, `cargo nextest run --workspace`
(**6796 passed**).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:43:18 -04:00
Scott Werner
882d11288b
A goal can't reference itself; prompts can reference the goal (#512)
Some checks failed
Rust / Format (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Generated Docs (push) Has been cancelled
Rust / Test (Linux) (push) Has been cancelled
Rust / Test (macOS) (push) Has been cancelled
Implements the goal self-reference behavior for interpolation
unification. Independent off `main` — no dependency on the other interp
PRs (touches only the template/goal-render path).

## What changes for users

A graph `goal` is a template that interpolates `{{ inputs.* }}`
(unchanged). A node `prompt` can reference the rendered goal via `{{
goal }}` (unchanged). **New:** a goal can **no longer reference itself**
— `{{ goal }}` *inside* a goal was previously a silent passthrough (left
as the literal text `{{ goal }}`); it's now a clear error.

```
graph [goal="Refine {{ goal }}"]   # error: a goal cannot reference itself
work  [prompt="Work on {{ goal }}"] # fine: prompts reference the rendered goal
```

## How

- **Structural guarantee:** the goal renders with **no `goal` key in
scope** (`TemplateContext::new().with_inputs(..)` instead of the
`for_input_scan` passthrough), so a self-reference can't resolve.
- **Friendly lint:** before rendering, `resolved_goal` checks the goal
template for a top-level `goal` reference — new
`fabro_template::references_top_level_variable`, backed by MiniJinja
`undeclared_variables` — and emits a dedicated `goal_self_reference`
diagnostic (`Severity::Error`) with a clear message and fix-it, instead
of a generic "undefined variable `goal`". Fails `fabro validate` and
run-create alike.

The goal is resolved in two transform passes (FileInlining +
TemplateTransform); the diagnostic is emitted **once** (FileInlining
discards its goal-resolution diagnostics; TemplateTransform is the
canonical emitter).

## Behavior change (release notes)

A goal containing `{{ goal }}` now **errors** instead of passing through
as literal text. The error message is the migration signal.

## Tests

- `references_top_level_variable` detection
- transform-level rejection (`Severity::Error`)
- single-emission across the two passes
- end-to-end `validate` rejection
- existing goal/prompt tests still green (prompts reference goal; goal
interpolates inputs)

## Verification

- `cargo build --workspace`
- `cargo +nightly clippy --workspace --all-targets -- -D warnings`
- `cargo +nightly fmt --check`
- `cargo nextest run --workspace`: 6800 passed

Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:14:28 -04:00
Scott Werner
5b3b3b0a6b
Resolve {{ env.* }} in MCP transport config at the run and exec boundaries (#511)
First **enhancing** PR of the interpolation unification: now that the
reducing PRs have pinned interpolation to the workflow config language,
this adds real `{{ env.* }}` resolution for MCP server transports — at
**both** the `fabro run` and `fabro exec` boundaries.

Independent off `main` — **no dependency on #510** (zero file overlap;
#510 touches the control-plane server settings). Builds on the
already-merged InterpString foundation (#472).

## What users get

MCP server transport fields now interpolate `{{ env.* }}` tokens,
resolved **at the boundary where the server is actually launched**:

- **stdio / sandbox**: `command`, `args`, and per-server `env` values
- **http**: `url` and `headers`

A literal value passes through unchanged; a `{{ env.NAME }}` token is
resolved against the launching process's environment. **Missing env var
is a hard error** (D3) instead of the previous behavior where the raw
token leaked downstream as literal text. Reserved `secrets`/`inputs`
tokens (no resolver here yet) surface as a loud `Unavailable` error
rather than passing through.

Resolution happens at the run/exec boundary, not in the shared config
resolve layer, so `fabro validate` stays portable (env presence is a
runtime concern, not a validation one).

## Both consumers, one resolver

`fabro run` and `fabro exec` read the **same** MCP representation —
`run.agent.mcps` and `cli.exec.agent.mcps` both parse through
`McpEntryLayer` (InterpString) and collapse via the same
`resolve_mcp_entry`. Originally only the run boundary resolved env, so a
file-sourced `[cli.exec.agent.mcps.*.env] KEY = "{{ env.X }}"` (from
`~/.fabro/settings.toml`) resolved under `run` but **leaked the raw
token under `exec`** — a silent asymmetry that would generate confusing
bug reports.

This PR closes that by moving the resolution onto the type as
`McpServerSettings::resolve_transport_env` (in `fabro-types`, next to
the `vars` half `substitute_mcp_transport`), so both consumers share one
resolver with no drift:

- `runtime_mcp_server` (run worker) → resolves against the worker
process env
- `fabro exec` → resolves against the CLI process env

`runtime_mcp_server` becomes a thin wrapper that just adds the server
name to the error.

## Tests

- `fabro-types`: 5 `resolve_transport_env` unit tests — literal
passthrough, stdio command+env, http url+headers, sandbox env,
missing-env hard error, and the reserved-`secrets` loud-fail case.
- `fabro-workflow`: the 5 existing `runtime_mcp_server_*` tests are
unchanged and now exercise the shared resolver through the wrapper.

Files: `fabro-config/src/resolve/run.rs`,
`fabro-types/src/settings/run.rs`,
`fabro-workflow/src/operations/start.rs`,
`fabro-cli/src/commands/exec.rs`.

Verified: `cargo build`, nightly `clippy --all-targets -D warnings`,
nightly `fmt --check`, and `cargo nextest run -p fabro-types -p
fabro-workflow -p fabro-config -p fabro-cli` (2679 passed with ambient
provider keys stripped; the one failure otherwise is the pre-existing
ambient-`*_API_KEY` flake, unrelated to MCP).

> Note: the shared resolver takes `Resolved.value` and drops interp
`Provenance` (consistent with every other resolved path today —
`Provenance` currently has zero consumers, and resolved MCP transport
values never surface in logs/events/API). Whether MCP env should carry
provenance for precise redaction vs. relying on content-based
`fabro-redact` is tracked as an open decision under D4.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:14:04 -04:00
Scott Werner
2bd04c7935
Demote control-plane config to plain String; native FABRO_WEB_URL read (#510)
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
Third reducing PR of the interpolation unification (**D11 resolution
(c)**): the control plane never interpolates. `InterpString` is now
strictly the user-facing workflow config language; server identity,
storage, listen, object-store, and GitHub App identifiers are plain
`String`, consumed where needed with no resolution point.

## Demoted to `String` (was `InterpString`)

Both the layer and resolved types:

- `server.listen.unix.path`, `server.api.url`, `server.web.url`
- `server.storage.root`, `server.artifacts.prefix`,
`server.slatedb.prefix`
- object store: `Local.root`, S3 `bucket` / `region` / `endpoint`
(shared by artifacts + slatedb)
- `github.app_id` / `client_id` / `slug`

**Kept `InterpString`:** `slack.default_channel` (run-time consumption —
the one server-defined survivor). `server.listen.tcp.address` stays the
`SocketAddr` `parsed_value` special case.

## Native `FABRO_WEB_URL` read

Deployment-time late binding now goes through a native env read instead
of a `{{ env.* }}` token: `FABRO_WEB_URL` overrides `server.web.url`
(**env override > settings literal > default**), applied in
`canonical_origin` and reused by the JWT issuer, cookie-secure check,
and system-info. `docker/split-web` no longer ferries the value through
a settings token (compose still sets the env var). `canonical_origin`'s
error message now advertises a knob that is actually true for everyone.

## Behavior change (release notes)

- `{{ env.* }}` / `{{ vars.* }}` tokens in the demoted server fields are
now **literal text**, not interpolated. The resolve layer emits
`warn_if_demoted_template` for every demoted field, so operators with
tokens still in server config **fail loud** rather than silently
treating the token as a literal.
- Operators who relied on env-based storage location should use the
existing native `FABRO_STORAGE_DIR` (`--storage-dir`) override.
`FABRO_STORAGE_ROOT` promotion is intentionally deferred (not a proven
need).

## Cleanup

`fabro-server`'s `crate::interp` shrinks to just the process-env lookup
facade; `resolve_interp` / `_path` / `_with` and the
`AppState::resolve_interp` seam are deleted (nothing resolves
server-scope `InterpString` anymore).

## Verification

- `cargo build --workspace` 
- `cargo +nightly clippy --workspace --all-targets -- -D warnings` 
(incl. the `as_source` gate)
- `cargo +nightly fmt --check --all` 
- `cargo nextest run --workspace`: 6305 passed; added two tests covering
the `FABRO_WEB_URL` override precedence (env-wins and settings-literal
fallback).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:03:14 -04:00
fabro-releases[bot]
1626240220 Bump version to 0.267.0-nightly.0
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
2026-06-17 16:50:57 +00:00
Scott Werner
accd91a0a6
Demote non-interpolating config fields to plain String (#492)
Some checks are pending
Rust / Test (Linux) (push) Waiting to run
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
# Demote non-category leak fields to plain `String`

Second slice of the interpolation unification — the first **reducing**
PR
stacked on the foundation (#472), per the reduce-first sequencing:
narrowing
changes land before capability additions. (The other reducing slice, the
DOT
de-templating, already landed independently as #474.)

## Why

The target model gives `InterpString` to fields in five categories —
`command` / `script` / `headers` / `env` / `url` — wherever they appear.
A
handful of fields were typed `InterpString` but are *identifiers or
commit
content*, not in any category:

- `run.model.provider` / `run.model.name`
- `cli.exec.model.provider` / `cli.exec.model.name`
- `run.git.author.name` / `run.git.author.email`
- `run.scm.owner` / `run.scm.repository`

Their consumers never resolved them — they leaked raw source text via
`as_source()`. This PR demotes them to plain `String` (layer and
resolved
structs) with **no interpolation**.

## The principle: only `InterpString` fields access variables

These fields are dropped from the variable substitute pass entirely, so
both
`{{ vars.* }}` and `{{ env.* }}` are now literal text. This **removes an
incidental behavior**: run-scoped plain-`String` fields used to get
`{{ vars.* }}` substituted via the String pass (a lucky accident), while
`env` always leaked literally. Variable access becomes deliberate and
typed
rather than accidental; if any of these fields should support variables
later, that's a controlled promotion back to `InterpString`.

## Behavior changes (honest list)

- **The incidental run-scoped `{{ vars.* }}` substitution on these eight
fields stops working.** To keep the removal visible rather than silent,
a
`tracing::warn!` fires at resolve time when a demoted field still
contains
claimed template tokens (`warn_if_demoted_template`). Unclaimed `{{ ...
}}`
  text (jq programs, Go templates) never interpolated and does not warn.
- `{{ env.* }}` / `{{ secrets.* }}` / `{{ inputs.* }}` never resolved on
  these fields, so nothing else changes.

## Added in review: D11 demotions (separate commit, revertable)

The rule got refined during review: a field is `InterpString` iff it is
in one
of the five categories **and resolved at the run boundary** (the only
point
where `vars`/`secrets`/`inputs` exist — they're server state, so
connect-time
and startup-time fields can't reach them even in principle). A separate
commit
applies the clean subset so it can be cherry-picked out if we change
course:

- `cli.target.http.url` / `cli.target.unix.path` — consumed at CLI
connect
time; consumers only ever leaked raw source, so nothing working is
removed.
- `run.working_dir` — **the outlier; see the PR comment.** Its `{{
vars.* }}`
  substitution worked; demoted on the category test alone.

## What's deliberately NOT here

- The **control-plane fields** (`server.storage.root` /
`listen.unix.path` /
  S3 fields / `github.app_id/client_id/slug` / `server.api.url` /
  `server.web.url`) — untouched here, demoted in a follow-up PR.
**Resolved during review** (see the resolution comment): `InterpString`
was
  conflating the user-facing workflow language with the internal control
plane. Control-plane fields never interpolate; the few deployment knobs
that need late binding (e.g. `FABRO_WEB_URL`, whose only real usage is
the
  split-web PoC ferrying a compose env var across a file mount) become
explicit native `EnvVars` reads, and `fabro-server/src/interp.rs`
shrinks
to deletion. `slack.default_channel` stays `InterpString` (consumed with
  run context).

## Implementation notes

- Consumers move from `as_source()` to direct `String` access; the
  foundation's `#[expect(disallowed_methods, ... demotion pending ...)]`
  annotations for these fields are removed (no longer `InterpString`).
- `fabro-checkpoint`'s author plumbing and `fabro-manifest`'s scm fields
  simplify accordingly.

## Verification

- `cargo build --workspace`
- `cargo nextest run --workspace` → 6684 passed, 181 skipped
- `cargo +nightly fmt --check --all`
- `cargo +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>
2026-06-16 13:16:31 -04:00
Ryan Neal
d5b2220ed3
feat(llm): Amazon Bedrock provider — Converse codec, SigV4 + API-key auth (#459)
Adds **Amazon Bedrock** as an opt-in built-in provider, over Bedrock's
unified **Converse / ConverseStream** API. One codec serves every
Converse-capable family — Claude, Amazon Nova, Meta Llama, Mistral,
DeepSeek, Moonshot Kimi, Z.AI GLM, MiniMax, NVIDIA Nemotron, and OpenAI
gpt-oss — because AWS translates the envelope to each model's native
dialect server-side. Auth is either **AWS SigV4** (the default
credential chain — env / profile / IMDS / IRSA / SSO, resolved per
request so sessions refresh) or a **Bedrock API key**
(`AWS_BEARER_TOKEN_BEDROCK`, bearer). Disabled by default (the Ollama /
OpenRouter opt-in pattern).

This is the redo of #459's original Claude-only `InvokeModel` adapter,
rebuilt on the gateway-refactor seams (#481–#497). @depopry's SigV4
signer, AWS event-stream frame decoder, `BedrockAuth`, the `aws_sigv4`
credential grammar, `AdapterKind::Bedrock`, region-from-base_url, and
the lean-deps decision are preserved and authored by him on the first
two commits; the per-family `BedrockCodec` trait he wrote turned out to
be the crate-wide `Codec` seam in miniature, so the refactor promoted
exactly that shape. The original Claude-only description is preserved in
a comment below.

## What's here

- **`AdapterKind::Bedrock` × `CodecKind::BedrockConverse`** on the
route, plus the `aws_sigv4` credential source (no static secret — the
adapter signs at request time; `fabro-auth` stays AWS-free).
*(@depopry)*
- **SigV4 signer + AWS event-stream `FrameDecoder`** on the lean AWS
stack (no `aws-sdk-bedrockruntime`; transport stays on `fabro-http`).
Re-targeted at Converse's direct-JSON stream frames; the signer resolves
credentials per request. *(@depopry)*
- **`bedrock_converse` codec** — Converse envelope (`system[]`, typed
content blocks, `inferenceConfig`, `toolConfig`), prompt caching via
`cachePoint`, thinking-signature round-trip through `reasoningContent`,
usage mapped onto the disjoint `TokenCounts` buckets,
`provider_options.bedrock` passthrough. Plus the adapter shell and an
event-stream byte loop beside the transport's shared SSE loop.
- **Catalog**: `bedrock.toml` (Claude incl. Fable 5, Nova 2, Llama 4,
Mistral, DeepSeek, Kimi, GLM, MiniMax, Nemotron, gpt-oss — cross-region
inference-profile ids, per-model `billing_policy` so Claude bills
Anthropic-style) and a companion **`bedrock-openai`** provider for
GPT-5.5/5.4 over the `bedrock-mantle` Responses endpoint (pure config
over the existing `openai_responses` codec, zero new code).
- Secrets registry (`AWS_BEARER_TOKEN_BEDROCK`), gitleaks rules for both
Bedrock key formats, the `docs/integrations/bedrock` guide, and live e2e
tests.

## Live verification (confirmed end-to-end against a real AWS account)

Verified on a real Bedrock account (us-east-2, SigV4 + bearer):

- **SigV4 + Converse** — multiple families (Claude, Nova, DeepSeek, …)
via the full settings → catalog → route → adapter → codec path.
- **ConverseStream** — streaming deltas through the workflow engine.
- **Multi-turn tool use** — agent loop with tool calls round-tripping
(no-arg tools included).
- **Multi-model routing** — Claude + DeepSeek pinned in one run through
the single Converse codec.
- **mantle Responses** — `openai.gpt-5.5` answered via the
`bedrock-openai` provider (bearer auth).

The exercise caught and fixed several issues that unit tests (static
creds, mocked transports) could not — see the follow-up commits below.

## Follow-up fixes from live testing (commits on top of the foundation)

1. **Worker AWS env** — the workflow worker scrubs its env to an
allowlist, so SigV4 (which re-resolves from the ambient chain per
request) couldn't work through `fabro run`. The AWS credential-chain
inputs now cross into the worker.
2. **Vault bearer key** — Bedrock was the only key-based provider
missing a `vault:` credential ref, so `fabro secret set
AWS_BEARER_TOKEN_BEDROCK` silently didn't feed it. Now resolves env →
vault → SigV4.
3. **Converse tool-encoding hardening** — a no-arg tool call's
`toolUse.input` is now a `{}` object (Bedrock rejects null), and every
tool `inputSchema` gets a top-level `type: "object"` (strict families
like DeepSeek reject a typeless schema Claude tolerates).
4. **Nova output cap** — `amazon.nova-2-lite` max_output 65536 → 65535
(Bedrock's per-request limit).

Earlier fixes already folded into the foundation commits: the
`aws-config` sleep-impl (default chain panicked) and AWS error-body
decoding (top-level `message`/`Message`/`__type` → proper messages
instead of "Unknown error").

## Manual testing & setup

See `docs/integrations/bedrock` — now documents the non-obvious account
setup that live testing surfaced: the per-Region Anthropic use-case
approval, `aws-marketplace:Subscribe` for third-party models, the Fable
5 / Mythos-class data-sharing opt-in, and the bearer-vs-SigV4 precedence
override for running Converse + mantle side by side.

## Open decision / discussion

- **Model-id naming** — Bedrock rows use dotted ids mirroring Bedrock's
native inference-profile ids (`us.anthropic.claude-sonnet-4-6`,
`openai.gpt-5.5`), which also makes them the wire `api_id`. Third scheme
alongside bare ids and OpenRouter's `vendor/model` slashes. No collision
risk (enforced at catalog build). Open to a uniform scheme if preferred.
- **`BEDROCK_API_KEY` alias** — see the comment thread; the AWS console
hands some users `export BEDROCK_API_KEY=` while the SDK-standard var is
`AWS_BEARER_TOKEN_BEDROCK`. Question of whether to accept both.

## Deferred (named follow-ups)

- **`qwen.qwen3-coder-next`** — omitted pending a verified Bedrock
model/inference-profile id (its fabro id isn't a valid Bedrock
identifier; needs an explicit `api_id`). Re-add once confirmed via `aws
bedrock list-inference-profiles`.
- **Claude Mythos 5** — Anthropic-Messages-only on `bedrock-mantle`
(limited preview).
- **Converse structured output** (`response_format` rejected with a
clear error).
- **`reasoning_effort` on Converse rows** via
`additionalModelRequestFields` (the `bedrock-openai` GPT rows already
accept effort levels).
- **CountTokens** route (`count_input_tokens` returns `None`).

## Verification

`cargo nextest run --workspace`: green except the pre-existing
environment-dependent fabro-workflow failures (identical on main).
clippy `-D warnings` + pinned-nightly fmt clean. Codec unit tests +
adapter httpmock tests + frame-decoder/signer locks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Scott Werner <scott@sublayer.com>
Co-authored-by: Scott Werner <stwerner@vt.edu>
2026-06-16 11:46:49 -04:00
Bryan Helmkamp
dbf4829b47
fix(graphviz): render comments with template braces (#509)
## Summary

Fixes #508.

This changes Graphviz render preparation so Fabro DOT is normalized
before graph-level style defaults are injected. That keeps leading
comments such as `// ... {{ goal }} ...` from being mistaken for the
graph body opening brace, while continuing to reuse the existing
parser/normalizer path for Fabro-specific syntax like dotted attribute
keys.

## Verification

- `cargo nextest run -p fabro-graphviz`
- `cargo +nightly-2026-04-14 fmt --check --all`

Co-authored-by: Chad Woolley <thewoolleyman@gmail.com>
2026-06-15 15:15:01 -04:00
Adrian Muraru
a769336c39
feat(workflow): support overriding cwd for local sandbox provider (#467)
Some checks failed
Rust / Format (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Generated Docs (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
Rust / Test (Linux) (push) Has been cancelled
Rust / Test (macOS) (push) Has been cancelled
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
## Problem

The `local` sandbox uses the run's `source_directory` (the CLI's cwd at
invocation time) as its working directory and `create_dir_all`s it on
the server (`LocalSandbox::initialize` in `fabro-sandbox`). That is
correct when the CLI and the server share a host — the agent operates
directly on the user's project tree.

When the server is **remote** from the CLI — e.g. `fabro serve` running
in a container in Kubernetes, driven over HTTP with the `local` sandbox
— the client's cwd (e.g. `/Users/alice/project`) does not exist on the
server. The sandbox then tries to create that path as the (often
unprivileged) server user and fails at init:

```
sandbox.failed provider="local" error="Failed to create working directory" causes=["Permission denied (os error 13)"]
```

and the run dies with `workflow_error` before the agent starts.

## Fix

When `source_directory` is absent or does not exist on the server, fall
back to a server-writable `workspace` directory under the run's scratch
dir instead of recreating the client path. **Same-host behavior is
unchanged**: an existing `source_directory` is still used as-is.

The selection is extracted into a small pure helper,
`local_working_directory(source_directory, run_dir)`, so it can be
unit-tested directly.

## Testing

- `cargo test -p fabro-workflow local_working_directory` — 3 new tests
(existing source dir → used; absent → fallback;
present-but-missing-on-server → fallback)
- `cargo check -p fabro-workflow`

🤖 Generated with [Claude Code](https://claude.com/claude-code)


Thanks for fabro @brynary!

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:32:32 -04:00
Bryan Helmkamp
bc0bda73a6
feat(web): add server-managed Environments CRUD settings UI (#462)
Some checks are pending
Rust / Clippy (push) Waiting to run
Rust / Format (push) Waiting to run
TypeScript / Build (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
## What

Adds a CRUD interface for **server-managed Environments** at
`/settings/environments`, driven by the `/api/v1/environments` REST API
(list / create / retrieve / replace / delete), and reshapes how built-in
environments are provisioned and protected.

The page lives in the **Workflows** settings nav section (also
introduced in this branch), positioned before Variables.

## Why

The Environments REST API shipped (#453) but had no UI — environments
could only be managed via the API/CLI. This gives operators a web UI
alongside Variables and Secrets, and along the way tightens the model:
environments are seeded at install time (not silently re-created on
every boot), and the `default` fallback is an ordinary, deletable
environment.

## Web UI

**Pages & component**
- `settings-environments.tsx` — list view: provider badge,
image/resource summary, row actions (Edit/Delete). **"New environment"
is a dropdown** of the enabled sandbox providers; the chosen provider is
fixed for the environment's lifetime.
- `settings-environments-new.tsx` / `settings-environments-edit.tsx` —
create/edit flows; create reads the provider from a query param.
- `environment-form.tsx` — shared form, reorganized:
- **General** panel (merged identity + image): id, and an **image-source
selector** (Image reference *vs* inline Dockerfile) that shows,
requires, and sends only the selected, mutually-exclusive source.
- **Resources**: CPU / memory / disk as **range sliders** (CPU 1–8,
memory 1–16 GB, disk 1–20 GB), each always writing a concrete value.
  - **Environment variables** key/value editor.
- **Advanced** progressive-disclosure section holding **Network** (a
single "Block all network access" toggle — allow-all vs block) and
**Lifecycle** (preserve / stop-on-terminal / auto-stop). Opens by
default when any advanced value is non-default.
- The in-form **provider control and the Labels editor were removed** —
labels remain API-managed and are round-tripped untouched so UI edits
never clear them.

**Data layer**: `environmentsApi` client, `queryKeys.environments`,
`useEnvironments` / `useEnvironment` SWR hooks.

**Nav & routing**: "Environments" item in the Workflows section before
Variables; routes registered in `router.tsx`.

## Backend: seed at install, deletable `default`

- **Seeding moved to install time.** The server no longer seeds
built-ins on startup; `EnvironmentStore::load_or_seed` → `load`
(load-only). A new public `seed_environments(dir)` (idempotent,
preserves operator edits) is called by both the web installer and the
CLI installer. An uninstalled instance therefore has no managed
environments, and a run selecting an absent environment fails explicitly
(`unknown environment: default`) rather than resurrecting a built-in.
- **`default` is no longer protected.** The delete guard and the
`Protected` error variant are gone; deleting `default` succeeds (204)
and removes the run fallback on purpose — forcing an explicit choice.
`local` is unchanged (reserved, in-memory).
- **`volumes` removed** from environment settings across the OpenAPI
spec, generated Rust + TS clients, config layers,
sandbox/server/workflow plumbing, docs, and tests.

## API contract details honored
- Edit sends the environment `revision` as `If-Match`; 409 conflicts
surface a "changed since you opened it" message.
- The REST API accepts inline Dockerfiles only — the form never sends a
Dockerfile path.

## Verification
- Rust: `cargo build` (touched crates) , `cargo nextest -p
fabro-environment` 21/21 , server env unit + `tests/it` integration 2/2
+ 15/15 , `clippy` (nightly, touched crates, all targets) clean , `fmt
--check` clean . Full `--workspace` suite not run here — worth a CI
pass.
- Web: `bun run typecheck` , `bun run build` ,
`environment-form.test.ts` 5/5 . Web suite: 512 pass / 1 unrelated
pre-existing `RunDetail` failure.
- **Not visually verified in-browser** — the local app is login-gated
and automated loads redirect to `/login`; rendering of the form, the
New-environment dropdown, and `default` delete should be confirmed in a
logged-in session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: fabro-sh-0530[bot] <281434857+fabro-sh-0530[bot]@users.noreply.github.com>
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Release Repro <release-repro@example.com>
2026-06-13 08:44:38 -04:00
Scott Werner
64ece23473
feat(llm): add OpenRouter as an opt-in built-in provider (#497)
The first feature payoff of the gateway refactor series (#481–#496):
OpenRouter lands as **pure configuration over the `openai_compatible`
codec** — no new adapter, no new `AdapterKind`, no OpenRouter codec
fork. Redone from #438, which prototyped this pre-refactor as ~2,500
lines including a dedicated adapter and parallel codec plumbing; this
PR's fabro-llm diff is the usage-superset decode plus a TOML file.

## What's here (3 commits)

**Per-model `billing_policy` override (fabro-model)** — a model row may
override its provider's billing family: the aggregator case, where
Claude served through an OpenAI-compatible provider bills
Anthropic-style cache reads/writes. `pricing_for`/`billing_facts_for`
and the resolved `Route` read the model-effective policy; unknown
passthrough model ids keep the provider policy. Pinned by a pricing test
(cache writes bill at 1.25× input under the override, $0 under the
provider's OpenAI default).

**Aggregator usage superset in the `openai_compatible` codec** — the
wire usage struct gains tolerant optional fields:
- `prompt_tokens_details.cached_tokens` / `cache_write_tokens` and
`completion_tokens_details.reasoning_tokens` normalize into their
disjoint `TokenCounts` buckets with the same subtraction convention as
the `openai_responses` codec
- in-band `usage.cost` (OpenRouter returns it on every response)
surfaces as `Response.cost_usd` with `cost_source = authoritative`, on
both blocking and streamed responses — #494's client-side estimate
stamping already defers to it by construction
- **deliberate behavior change owned here**: compat providers that
report cached-token details now see them split out of `input_tokens`
(previously ignored — the wire pin placed in PR 0 anticipating exactly
this change flips, and two new OpenRouter-shaped wire pins land)

**The provider package** — `openrouter.toml` (disabled by default, the
Ollama opt-in pattern; curated vendor-namespaced model list; Claude rows
set `billing_policy = "anthropic"`; attribution headers deliberately not
sent unless the operator opts in via `extra_headers`),
`OPENROUTER_API_KEY` env/secret registry entries, a gitleaks rule for
`sk-or-v1-` keys, a live e2e test asserting authoritative cost, and docs
(integration guide + models concept + config reference).

## Deliberate scope cuts (fidelity follow-ups, per the plan)

- `reasoning_details[]` parse + verbatim multi-turn echo,
`cache_control` multipart emission, `provider`/`native_finish_reason`
field reads — the new wire pin proves they're tolerated and ignored
today
- Typed reasoning-param-style / routing codec params — no catalog row
can request reasoning effort yet (no `controls.reasoning_effort`
declared), and routing prefs already pass through
`provider_options.openrouter` verbatim via the existing
adapter-name-keyed merge; typed params land when an operator-level knob
actually needs them
- The OpenRouter Anthropic skin (`/api/v1/messages`) — a future pure
config row pairing the existing `anthropic_messages` codec with bearer
transport

## Verification

- `cargo nextest run --workspace --no-fail-fast`: 6724 passed; only the
known 5 pre-existing environment-dependent fabro-workflow failures
(identical on main)
- Wire snapshots: one deliberate flip
(`decode_usage_ignores_token_details` →
`decode_usage_parses_token_details`) + two new OpenRouter pins (blocking
cost/cache-write, streamed cost); all other snapshots unmodified
- clippy `-D warnings` + pinned-nightly fmt clean
- Builtin catalog unchanged for existing providers: OpenRouter is
`enabled = false`, so the #493 route-equivalence table is untouched

Credit to #438 for the provider research, catalog curation, gitleaks
rule, and docs structure.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:39:09 -04:00
Bryan Helmkamp
62486c8103
fix(server): escalate automation materialization failures
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
2026-06-12 11:27:22 -04:00
Zach Feldman
bc70da1a22
fix(web): resolve Bun workspace-hoisted node_modules in build script (#495)
## Why

The build script in `apps/fabro-web/scripts/build.ts` hardcoded two
paths
that assumed packages live in `apps/fabro-web/node_modules/`:

- `./node_modules/.bin/tailwindcss` (the Tailwind CLI invocation)
- `join(rootPath, "node_modules", "@pierre", "diffs", ...)` (the worker
asset copy)

This repo uses Bun workspaces (root `package.json` has `workspaces:
['apps/*',
'lib/packages/*']`), so `bun install` hoists all packages to the repo
root.
Any fresh contributor install broke `bun run dev` immediately with:

```
ENOENT: no such file or directory, posix_spawn './node_modules/.bin/tailwindcss'
```

followed by:

```
ENOENT: no such file or directory, lstat '.../apps/fabro-web/node_modules/@pierre/diffs/...'
```

## What changed

- `tailwindcss` is now resolved via `Bun.which("tailwindcss")`, which
searches
`PATH` and the workspace root `node_modules/.bin/`, with the old path as
fallback.
- `pierreWorkerDir` now resolves from a `workspaceRoot` derived via
`new URL("../../..", import.meta.url)` (repo root), matching where Bun
actually
  installs workspace dependencies.

## Verification

`bun run dev` from `apps/fabro-web/` completes a full build successfully
after a
clean `bun install` from the repo root.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
2026-06-12 08:23:56 -04:00
Scott Werner
eff3a5a9cb
refactor(llm): resolve request dispatch through the catalog route (#496)
PR 8 of the gateway refactor series (after #493) — the optional closer:
`Client` dispatch goes through the route machinery #493 introduced,
instead of an inline ad-hoc lookup.

## What's here

`Client::resolve_provider`'s hand-rolled catalog hop
(`catalog.get(model)` → provider id) becomes
`adapter_registry::resolve_route`. Fallback order is byte-identical:
explicit `request.provider` wins, then the model's catalog route, then
the default provider, then the existing configuration error.

This puts route resolution on the live request path, so the
route-equivalence table from #493 now pins actual dispatch rather than a
helper nothing calls: a new live-dispatch sweep asserts every built-in
model's request lands on the provider its route names, alongside
explicit-provider-wins and unknown-model-default pins.

## Scope notes

- **No public API change** — `resolve_provider` is private; all frozen
`Client` methods are untouched.
- The route's `codec`/`deployment_id` still aren't handed to adapters:
`ProviderAdapter::complete(&Request)` is frozen (prod-implemented in
fabro-cli), and every allowed pairing equals the adapter's built-in
codec until the feature PRs. This PR is deliberately just the dispatch
seam, so the OpenRouter redo's Client-side wiring is a no-op.

## Verification

- `cargo nextest run --workspace --no-fail-fast` (post-rebase onto
#493's merge): green except the same 5 pre-existing
environment-dependent fabro-workflow failures, identical on main
- clippy `-D warnings` + pinned-nightly fmt clean
- Wire snapshots untouched

This closes the refactor series. Remaining: the already-open cost PR
(#494), then the feature redos — OpenRouter (#438) and Bedrock (#459).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 05:48:30 -04:00
Scott Werner
23d3644787
feat(llm): catalog-estimated completion cost on Response (#494)
Standalone pre-OpenRouter step, pulled forward from the #438 triage (the
gateway-refactor plan's "additive feature PR alongside the redo"):
completion responses carry a USD cost with provenance.

## What's here

**`Response.cost_usd` + `Response.cost_source`** — new optional fields
(`skip_serializing_if` keeps the wire shape byte-identical when unset).
`CostSource` (`authoritative` | `estimated`) lives in fabro-model's
billing vocabulary next to `UsdMicros`/`TokenCounts`, since the API
layer reuses it.

**`fabro-llm/src/cost.rs`** — `estimate_cost_usd`, a thin wrapper over
the existing `Catalog::price_tokens` billing machinery (billing-policy-
and speed-aware), ported from #438's prototype with attribution. One fix
over the prototype: model aliases and provider names are canonicalized
before building the `ModelRef` — `ModelPricing::bill` rejects
non-canonical refs, so the original would silently skip cost on alias
requests (caught by a new test).

**Client-level stamping** — one generic post-decode site instead of
#438's ~8 per-adapter sites (which predate the codec refactor):
`Client::complete` stamps blocking responses and `Client::stream` stamps
`Finish` events, beneath the middleware chain so middleware observes
final responses. Codecs stay wire-translation-only — zero wire-snapshot
churn — and every registered adapter (including custom
`register_provider` ones) gets the same treatment. Stamping never
overwrites an existing cost, so future authoritative in-band costs
(OpenRouter) take precedence by construction.

**API surface** — `cost_usd`/`cost_source` on `CompletionResponse`
(OpenAPI spec + handler + regenerated TS client). The streaming endpoint
already carries cost implicitly since `Finish` events serialize the
`Response` verbatim; this makes the blocking surface match. `CostSource`
reuses the canonical fabro-model type via `with_replacement`, with the
standard round-trip test pinning type identity and JSON parity.

## Deliberately not here (stays with the OpenRouter redo per the plan's
hard rule)

- Authoritative `usage.cost` parsing in the `openai_compatible` codec
wire structs
- Cached-token usage parsing (changes observable usage values)
- Per-model `billing_policy` schema field

## Verification

- `cargo nextest run --workspace --no-fail-fast`: 6701 passed; only the
known 5 pre-existing environment-dependent fabro-workflow failures
(identical on main)
- All fabro-llm wire snapshots unmodified; new pins: cost estimation
unit tests (incl. alias canonicalization), Client stamping tests
(blocking, streaming, beneath middleware, no-catalog), fabro-api
`CostSource` round-trip
- clippy `-D warnings` + pinned-nightly fmt clean; `bun run typecheck`
clean in fabro-web

Independent of the route-vocabulary work in #493 — branches directly off
main. After both land, the OpenRouter redo shrinks to config + typed
codec params + authoritative-cost decode.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:55:47 -04:00
Scott Werner
d10fcd5e91
refactor(model): put the codec on the route (#493)
PR 7 of the gateway refactor series (after #481, #488, #487, #489, #491)
— the series capstone: the wire dialect becomes route vocabulary in
fabro-model config instead of a structural implication of the adapter
type.

## What's here

**`fabro-model/src/codec.rs` (new)** — `CodecKind`
(`anthropic_messages`, `openai_responses`, `openai_compatible`,
`gemini_generate`; strum per house style).
`CodecKind::default_for(AdapterKind)` reproduces the historical
adapter→dialect fusion exactly.

**Catalog schema** — optional `codec` on provider rows and model rows
(the multiplexer case), sparse-merged with the existing `.or()` pattern.
Omitted everywhere in the built-in catalog, so **all defaults reproduce
today's routes**. Explicit pairings outside the adapter's default are
rejected at catalog build (`UnsupportedProviderCodec` /
`UnsupportedModelCodec`) so no new route combination is silently enabled
by configuration — the field is vocabulary for the OpenRouter/Bedrock
feature PRs, not a new capability. `Catalog::effective_codec` mirrors
`effective_agent_profile`. fabro-config mirrors the field through
`LlmLayer` (`ProviderSettings.codec`, `ModelSettings.codec`) and the
catalog-settings conversion.

**Route resolution** — `adapter_registry::resolve_route(catalog, model)`
assembles `(provider row, model row)` into `Route { provider, transport,
codec, deployment_id, billing_policy, agent_profile }`.

**Route-equivalence table test** — every built-in model row pinned to
its resolved tuple as an executable table (23 rows), with a coverage
assert so a new built-in model can't land without a deliberate table
edit. This is the "compat mapping as an executable table, not a comment"
test from the plan.

**`AdapterConfig` cleanup** — the OpenAI-only fields (`codex_mode`,
`org_id`, `project_id`) move out of the shared struct into
`AdapterKindOptions::OpenAi(OpenAiAdapterOptions)`; the client populates
them only for OpenAi-kind routes, which is the only factory that ever
read them.

## Deliberate scope cuts

- **No per-model `billing_policy`** — that schema change exists solely
for the OpenRouter redo, which owns it.
- **`codec_params` and `supports_count_tokens` stay adapter-internal** —
the registry `Route` carries what the catalog defines; the per-route
knobs in the adapters' `RouteConfig` move out when a second
codec/transport pairing actually exists (OpenRouter's anthropic skin /
Bedrock). Wiring `resolve_route` into `Client` request dispatch is the
optional PR 8 and is likewise deferred.
- **No user-facing docs for `codec`** — every accepted value equals the
default, so there is nothing actionable to document yet; docs land with
the first feature PR that enables a non-default pairing.

## Verification

- `cargo nextest run --workspace --no-fail-fast` (re-run post-rebase
onto #491's merge): 6701 passed; the only failures are the same 5
pre-existing environment-dependent fabro-workflow failures noted in
#491, identical on main
- fabro-llm: 548 passed — all wire snapshots unmodified
- clippy `-D warnings` + pinned-nightly fmt clean

This ends the refactor series: the seams exist. Next up are a standalone
cost PR (`cost.rs` + `Response.cost_usd`/`CostSource`, pulled forward
from the #438 triage as its own pre-OpenRouter step) and then the
feature redos — OpenRouter (#438: one TOML + typed codec params) and
Bedrock (#459: sigv4/eventstream transport + config, private codec layer
deleted).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:11:05 -04:00
Bryan Helmkamp
f1b021b6ab
docs: refresh changelog and sync product docs
Changelog: add entries for 2026-05-28 through 2026-06-09, regenerate
the 2026-05-26/27 entries to cover their full days, and add the
missing 2026-05-27 navigation entry.

Product docs: scope workflow templating docs to prompt + goal (#474),
document the server-managed environments directory and seeded
built-ins (#446/#453), add a new Automations page, and list the
Automations/Environments/Variables endpoints in the API reference nav.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:04:31 -04:00