Merge origin/main into feat/venice-search-provider

This commit is contained in:
Bryan Helmkamp 2026-08-21 21:04:28 -04:00
commit 3217a05aad
No known key found for this signature in database
124 changed files with 9509 additions and 1561 deletions

View file

@ -31,6 +31,18 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)
- Docker is the default runtime sandbox provider from `defaults.toml`. The Fabro process must have a working Docker client environment (`DOCKER_HOST`, socket access, Docker Desktop behavior, TLS settings, groups/permissions, and any remote daemon policy are operator responsibilities).
- The packaged compose service mounts `/var/run/docker.sock` so the server can create sibling run containers on the host daemon. This is host-root-equivalent under Docker's security model; only use it in the trusted, single-tenant deployment model described by the sandbox code/docs.
- Docker and Daytona are clone-based providers. When a run manifest has a GitHub origin, they clone it into the provider workspace. Present non-GitHub origins fail unless the provider has `skip_clone = true`; absent origins or `skip_clone = true` create an empty workspace without repository files.
- The sandbox layer also accepts an optional exact commit for future admitted
runs. An exact commit always requires a non-empty branch. Docker initializes
an empty repository, shallow-fetches the SHA at the same depth as a branch
clone, and checks it out; Daytona uses its official SDK clone with both
`branch` and `commit_id`. Both providers then point the admitted branch at
the commit and verify HEAD, so the workspace still reports the admitted
branch name. Keep those provider transports distinct, never fall back to a
newer branch HEAD, and do not wire this capability directly from legacy
`GitContext.sha`. The sandbox layer does not verify that the commit is
reachable from the branch; admission owns that check. Current production
callers remain branch-only until the RunIntent admission cutover supplies a
validated branch/SHA pair.
### Release automation
- `cargo dev release` — creates the next stable release tag. Use `cargo dev release --nightly` for a nightly prerelease. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation.

77
Cargo.lock generated
View file

@ -1870,7 +1870,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "daytona-api-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=73c9c458dd1a1d096afd3521175637af82afd8d8#73c9c458dd1a1d096afd3521175637af82afd8d8"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511"
dependencies = [
"reqwest 0.13.2",
"reqwest-middleware",
@ -1884,7 +1884,7 @@ dependencies = [
[[package]]
name = "daytona-sdk"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=73c9c458dd1a1d096afd3521175637af82afd8d8#73c9c458dd1a1d096afd3521175637af82afd8d8"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511"
dependencies = [
"daytona-api-client",
"daytona-toolbox-client",
@ -1904,13 +1904,15 @@ dependencies = [
[[package]]
name = "daytona-toolbox-client"
version = "0.1.0"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=73c9c458dd1a1d096afd3521175637af82afd8d8#73c9c458dd1a1d096afd3521175637af82afd8d8"
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511"
dependencies = [
"reqwest 0.13.2",
"reqwest-middleware",
"serde",
"serde_json",
"serde_repr",
"tokio",
"tokio-util",
"url",
]
@ -2072,7 +2074,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -2199,7 +2201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -2664,6 +2666,7 @@ dependencies = [
"fabro-static",
"fabro-test",
"fabro-types",
"futures",
"jsonwebtoken",
"serde",
"serde_json",
@ -4389,6 +4392,22 @@ dependencies = [
"webpki-roots 1.0.6",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@ -4441,7 +4460,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core 0.61.2",
]
[[package]]
@ -5316,6 +5335,23 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe 0.2.1",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@ -5390,7 +5426,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -6387,7 +6423,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
@ -6672,11 +6708,13 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@ -6688,6 +6726,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@ -6861,7 +6900,7 @@ dependencies = [
"errno 0.3.14",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -6920,7 +6959,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -7444,7 +7483,7 @@ version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno 0.3.14",
"errno 0.2.8",
"libc",
]
@ -8034,7 +8073,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -8080,7 +8119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -8232,6 +8271,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@ -9133,7 +9182,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]

View file

@ -97,8 +97,8 @@ twin-openai = { path = "test/twin/openai" }
twin-github = { path = "test/twin/github" }
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "73c9c458dd1a1d096afd3521175637af82afd8d8", package = "daytona-sdk" }
daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "73c9c458dd1a1d096afd3521175637af82afd8d8", package = "daytona-api-client" }
daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-sdk" }
daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-api-client" }
sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] }
fork = "0.2"
exec = "0.3"

View file

@ -186,4 +186,8 @@ Do not rebuild or mutate the `RunEvent` in downstream listeners.
Any JSONL sink, the run store, and SSE should reflect the same canonical envelope bytes after redaction.
An active workflow treats any run-event sink write failure as fatal. It cancels execution and
attempts to persist `run.failed` through the direct sink path. Persistence-error logs must include
the full source chain so an HTTP status or transport failure remains visible.
`status.json` remains the authoritative completion signal for detached runs. Terminal run status should only be written after all post-run work is finished.

View file

@ -11434,6 +11434,11 @@ components:
type: ["string", "null"]
workflow_slug:
type: ["string", "null"]
workflow_version_id:
description: Exact immutable root workflow version from which the run was admitted, when applicable.
oneOf:
- $ref: "#/components/schemas/WorkflowVersionId"
- type: "null"
automation:
oneOf:
- $ref: "#/components/schemas/AutomationRef"
@ -14442,6 +14447,17 @@ components:
type: object
additionalProperties:
type: string
additional_repositories:
type: array
description: |
Additional GitHub repositories, beyond the implicit run origin,
that the minted GITHUB_TOKEN must cover. Each entry is a full
`owner/repository` slug; every repository must share one owner
with the run origin. Omitted when empty; settings persisted
before this field existed deserialize to an empty set.
items:
type: string
uniqueItems: true
RunGoal:
oneOf:
@ -14628,6 +14644,12 @@ components:
properties:
enabled:
type: boolean
depth:
type: integer
format: int32
minimum: 0
default: 100
description: Git history depth. Set to 0 to clone full history.
RunBranchSettings:
type: object

View file

@ -1,8 +1,24 @@
---
title: "Venice search backend"
title: "Additional GitHub repositories and Venice search"
date: "2026-08-21"
---
## One token for the whole repository set
A run can now declare additional GitHub repositories that its stages may access through the managed `GITHUB_TOKEN`:
```toml
[run.integrations.github]
additional_repositories = ["fabro-sh/keystone"]
permissions = { contents = "read" }
```
The run origin stays implicit, and Fabro mints one installation token scoped to the origin plus every declared repository with the shared permission map. Inside stages, `gh` commands, raw GitHub API calls, plain Git over HTTPS, and the common SSH URL spellings (`git@github.com:owner/repo` and `ssh://git@github.com/owner/repo`) all work against the declared set — the SSH forms are transparently rewritten to authenticated HTTPS with no secret placed in Git configuration.
Every repository must share one owner and be reachable by the origin's GitHub App installation. Preflight resolves each repository's installation, mints the scoped token once, and probes every repository with `git ls-remote`, naming the exact repository when something is not accessible; run initialization enforces the same checks. A declared-but-inaccessible repository fails the run before its first stage.
Declaring additional repositories requires `contents = "read"` or `contents = "write"`. With `contents = "write"`, any stage can push to any declared repository — declare the smallest set and weakest permissions that work. See [Additional repositories](/integrations/github#additional-repositories) for details, including layering rules and `GH_TOKEN` precedence.
## Venice search backend for `web_search`
The built-in `web_search` tool now supports Venice as an automatic alternative to direct Brave Search. Fabro uses `BRAVE_SEARCH_API_KEY` when present. Otherwise it uses `VENICE_API_KEY` with Venice's Brave search engine. If neither key is present, the tool is not registered. Failed calls do not fall back between providers.

View file

@ -59,13 +59,18 @@ Fabro performs this selection once when creating a run and persists the chosen p
| `gemini-3.1-flash-lite` | gemini | `gemini-flash-lite`, `gemini-3.1-flash-lite-preview` | 1M | $0.25 / $1.50 | 200 tok/s |
| `kimi-k2.5` | moonshot | | 262K | $0.60 / $3.00 | 50 tok/s |
| `kimi-k3` | moonshot | `kimi` | 1M | $3.00 / $15.00 | n/a |
| `kimi-k3-fast` | venice | `kimi-fast` | 1M | $4.50 / $22.50 | n/a |
| `deepseek-v4-flash` | deepseek | `deepseek`, `deepseek-v4`, `deepseek-flash` | 1,048,576 | $0.14 / $0.28 | n/a |
| `deepseek-v4-pro` | deepseek | | 1,048,576 | $0.435 / $0.87 | n/a |
| `grok-4.6` | venice | `grok`, `grok46`, `grok-46` | 500K | $2.27 / $6.80 | n/a |
| `laguna-s-2.1` | poolside | `laguna`, `laguna-s` | 1M | $0.10 / $0.20 | n/a |
| `laguna-xs-2.1` | poolside | `laguna-xs` | 262K | $0.10 / $0.20 | n/a |
| `glm-5.2` | zai | `glm`, `glm5`, `glm52`, `glm5.2` | 1M | $1.40 / $4.40 | n/a |
| `glm-5.3` | venice | `glm`, `glm5`, `glm53`, `glm5.3`, `glm-5-3` | 1M | $1.75 / $5.50 | n/a |
| `minimax-m2.5` | minimax | `minimax` | 197K | $0.30 / $1.20 | 45 tok/s |
| `mercury-2` | inception | `mercury` | 131K | $0.25 / $0.75 | 1000 tok/s |
| `qwen3.8-max` | venice | `qwen`, `qwen-max`, `qwen3.8`, `qwen-3.8`, `qwen38`, `qwen-3.8-max`, `qwen38-max` | 1M | $2.50 / $7.50 | n/a |
| `qwen3.8-27b` | venice | `qwen-27b`, `qwen-3.8-27b`, `qwen38-27b` | 262K | $0.45 / $3.20 | n/a |
Each provider requires its own API key. Server-backed workflows read provider credentials from the server vault (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, or `POOLSIDE_API_KEY` set with `fabro secret set` or `fabro provider login`). Standalone SDK/CLI flows can opt into env-backed credential sources explicitly. See the [Quick Start](/getting-started/quick-start) for setup.
@ -169,6 +174,10 @@ Provider `billing_policy` defaults from `adapter` and controls usage-cost estima
Provider fields in configuration, APIs, and model routing are provider ID strings. Built-in names like `anthropic`, `openai`, and `gemini` still work, but custom IDs like `proxy` work anywhere a provider ID is accepted.
</Note>
### Venice
Fabro ships a built-in [Venice](/integrations/venice) provider with a curated catalog of Venice-hosted Kimi, Grok, GLM, DeepSeek, and Qwen models. Store its API key with `fabro provider login --provider venice`. Pin `provider = "venice"` when a shared model slug must use Venice instead of a higher-priority direct provider.
### Poolside
Fabro ships a built-in [Poolside](/integrations/poolside) provider for Laguna S 2.1 and Laguna XS 2.1 over Poolside's OpenAI-compatible API. Store a direct API key with `fabro provider login --provider poolside`. The same model slugs are also available through the opt-in OpenRouter provider; its vendor-namespaced strings remain provider-only `api_id` values.
@ -232,6 +241,7 @@ When no model or provider is specified, Fabro chooses the default offering on th
| `moonshot` | `kimi-k3` |
| `poolside` | `laguna-s-2.1` |
| `zai` | `glm-5.2` |
| `venice` | `deepseek-v4-flash` |
| `minimax` | `minimax-m2.5` |
| `inception` | `mercury-2` |

View file

@ -97,6 +97,7 @@
"integrations/litellm",
"integrations/bedrock",
"integrations/deepseek",
"integrations/venice",
"integrations/poolside",
"integrations/openrouter",
"integrations/modal",

View file

@ -258,7 +258,7 @@ memory = "4GB"
mode = "block"
```
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace. Set `[run.clone] enabled = false` to start with an empty workspace. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start with an empty workspace. Set `[run.clone] depth = 0` to clone full history. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
The image must provide `/bin/bash`; Fabro evaluates every sandbox command with it and has no `sh` fallback. Commands run in a **non-login** shell, so login profiles (`/etc/profile.d/*.sh`, `~/.bash_profile`, and `nvm`/`rbenv`/`sdkman` initializers) are not sourced — put anything they set into the Dockerfile's `ENV` instead. Fabro verifies Bash during initialization and again on resume, and fails with remediation rather than reporting the sandbox ready.

View file

@ -241,10 +241,16 @@ Configure whether clone-based sandboxes clone the run's GitHub origin before exe
```toml title="run.toml"
[run.clone]
enabled = true
depth = 100
```
Set `enabled = false` to start Docker and Daytona runs with an empty provider workspace. Use [prepare steps](#runprepare) to clone or create any files the workflow needs.
| Field | Description |
|---|---|
| `enabled` | When `false`, Fabro skips the repository clone. Defaults to `true`. |
| `depth` | Git history depth for Docker and Daytona. Defaults to `100`. Set it to `0` to clone full history. |
### `[run.run_branch]`
Configure Fabro's managed `fabro/run/<id>` checkpoint branch.
@ -363,6 +369,24 @@ Only requested permissions are included. The upper bound is the permission set g
This table follows the normal settings precedence order. A higher-precedence layer can set `permissions = {}` to clear inherited permissions and run without a GitHub token.
### `[run.integrations.github].additional_repositories`
Declare extra GitHub repositories, beyond the implicit run origin, that the minted `GITHUB_TOKEN` must cover. The one `permissions` map applies to the origin and every declared repository.
```toml title="run.toml"
[run.integrations.github]
additional_repositories = ["fabro-sh/keystone"]
permissions = { contents = "read" }
```
Each entry is a full `owner/repository` slug. Every repository in the effective set must share one owner and be reachable by the origin repository's GitHub App installation. A non-empty list requires `contents = "read"` or `contents = "write"`. Malformed slugs, case-insensitive duplicates, cross-owner sets, and sets larger than 499 entries fail configuration validation with indexed error paths such as `run.integrations.github.additional_repositories[1]`.
Unlike permissions-only configuration, declared additional repositories are a hard requirement: missing credentials, a missing origin, or an inaccessible declared repository fails preflight and run initialization with the repository named.
The higher-precedence list replaces the lower one wholesale — no union and no `...` splice — and `additional_repositories = []` explicitly clears an inherited list. `additional_repositories` and `permissions` resolve independently; if layering leaves repositories declared while permissions were cleared, resolution reports the invalid combination instead of dropping either field.
See [Additional repositories](/integrations/github#additional-repositories) for what works inside stages (`gh`, GitHub API, plain Git over HTTPS and the common SSH spellings) and for the security boundary.
### `[run.notifications]`
Define named notification routes for run events. Slack lifecycle notifications are configured here, not in server config.

View file

@ -141,6 +141,15 @@ provider = "daytona"
enabled = false
```
Daytona clones 100 commits by default. To keep only the newest commit, set a smaller clone depth:
```toml title="run.toml"
[run.clone]
depth = 1
```
Set `depth = 0` to clone the full repository history.
If the clone fails without GitHub access configured, Fabro suggests running the setup flow:
```

View file

@ -62,6 +62,7 @@ When you choose the GitHub App strategy, the CLI opens GitHub with a pre-filled
| Emails | Read | Read verified email for OAuth login |
| Dependabot alerts | Write | Read and manage repository vulnerability alerts |
| Organization projects | Write | Read and update organization Projects V2 |
| Packages | Read | Download private GitHub Packages (e.g. npm registry) with the sandbox `GITHUB_TOKEN` |
These permissions are included when Fabro registers a new app. For an existing GitHub App, add the missing permissions in the app's settings, then approve the permission update on each installation before workflows can use them.
@ -228,7 +229,7 @@ For public repositories, the clone works without credentials. The token is still
### GITHUB_TOKEN injection
When any settings layer declares `[run.integrations.github.permissions]`, Fabro prepares a scoped GitHub App token source and exposes it as the `GITHUB_TOKEN` environment variable in sandbox command and agent execution. Agents running inside the sandbox can use this token for GitHub API calls, cloning additional private repos, or pushing to branches. The GitHub CLI (`gh`) reads `GITHUB_TOKEN` automatically, so command stages can run `gh pr list`, `gh issue create`, and similar commands without an explicit `gh auth login`.
When any settings layer declares `[run.integrations.github.permissions]`, Fabro prepares a scoped GitHub App token source and exposes it as the `GITHUB_TOKEN` environment variable in sandbox command and agent execution. Agents running inside the sandbox can use this token for GitHub API calls and pushes within the granted permissions. The GitHub CLI (`gh`) reads `GITHUB_TOKEN` automatically, so command stages can run `gh pr list`, `gh issue create`, and similar commands without an explicit `gh auth login`.
```toml title="workflow.toml"
[run.integrations.github.permissions]
@ -238,6 +239,46 @@ pull_requests = "write"
Only the listed permissions are requested — the token is scoped to the minimum access needed. If the GitHub App isn't configured or the repository lacks an installation, the run logs a warning and continues without the token.
In App mode, the token covers only the run's origin repository unless the run declares [additional repositories](#additional-repositories). Injecting `GITHUB_TOKEN` alone does not make other private repositories reachable.
### Additional repositories
A run can declare extra GitHub repositories that its stages may access through the same `GITHUB_TOKEN`:
```toml title="workflow.toml"
[run.integrations.github]
additional_repositories = ["fabro-sh/keystone"]
permissions = { contents = "read" }
```
The run origin stays implicit — never list it. Each entry is a full `owner/repository` slug (no scheme, host, ref, or extra path component). Fabro mints **one** installation token scoped to the origin plus every declared repository, with the one shared `permissions` map applying to all of them.
What works against every declared repository, within the granted permissions:
- **`gh` CLI and raw GitHub API calls** through `GITHUB_TOKEN`.
- **Plain Git over HTTPS** (`git clone https://github.com/owner/repo`), through a secret-free credential helper that reads `$GITHUB_TOKEN` at invocation time.
- **The common SSH spellings** `git@github.com:owner/repo[.git]` and `ssh://git@github.com/owner/repo[.git]`, through per-repository SSH-to-HTTPS rewrites injected into the stage environment.
Fabro does not clone additional repositories for you; a workflow that needs one on disk adds its own clone step (`git clone https://github.com/owner/repo` or `gh repo clone owner/repo`).
Requirements and validation:
- Every repository in the effective set must share **one owner** and be reachable by the origin repository's GitHub App installation, because one App installation covers one account. Cross-owner declarations fail configuration validation; a same-owner repository outside the installation fails preflight and run initialization with the repository named.
- A non-empty `additional_repositories` requires `contents = "read"` or `contents = "write"` in the permission map.
- Malformed slugs, duplicates (repository identity is case-insensitive), and sets larger than 499 entries fail configuration validation with indexed error paths.
- Unlike permissions-only configuration, declared additional repositories are a hard requirement: missing GitHub credentials, a missing origin, or an inaccessible declared repository fails preflight and run initialization instead of continuing without the token.
- Layering: the higher-precedence `additional_repositories` list replaces the lower one wholesale (no union, no `...` splice), and `additional_repositories = []` explicitly clears an inherited list. `permissions` keeps its existing whole-map replacement behavior. If layering leaves repositories declared with permissions cleared, configuration resolution reports the invalid combination.
Behavior notes:
- **Token strategy (PAT):** the configured PAT is used as-is. The repository list drives validation and preflight probes, but it cannot narrow the PAT's inherent GitHub scope — App mode remains the least-authority option.
- **`GH_TOKEN` precedence:** `gh` checks `GH_TOKEN` before `GITHUB_TOKEN`. If the resolved run environment defines `GH_TOKEN`, `gh` uses it instead of the managed token; Fabro never sets or removes `GH_TOKEN`, and preflight warns when additional repositories are declared alongside one.
- **SSH rewrites match by prefix.** With `owner/repo` declared, the SSH spelling of `owner/repo-other` is also rewritten to HTTPS. The scoped token is invalid for undeclared repositories at GitHub, so authority is unchanged — but a private undeclared repository fails with a GitHub authorization error instead of a missing-credential or SSH error.
#### Security boundary
Workflow authors may name any repository reachable by the server's GitHub App installation; Fabro applies no second server-side repository intersection. The token is scoped server-side to exactly the declared set — a request to an undeclared repository fails at GitHub, and Fabro never mints an unscoped installation-wide token. With `contents = "write"`, **any stage can push to any declared repository**. Declare the smallest repository set and the weakest permissions that work.
Installation Access Tokens are short-lived. Fabro refreshes its own credentials before checkpoint pushes. For ACP/CLI agent turns launched with GitHub App push credentials, Fabro also re-mints the token and rewrites the sandbox's `origin` URL before the ACP process starts, then every 45 minutes for the lifetime of that turn. Refresh failures are logged and do not fail the stage.
`FABRO_PUSH_CRED_REFRESH_AHEAD` defaults to enabled; set it to `0`, `false`, `off`, `no`, or an empty value to disable both turn-entry and background refresh. `FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS` overrides the background interval, and `0` disables only the background loop. This refresh loop is ACP-specific; command and native/API agent stages do not run it. Reconnected sandboxes for resumed or parked runs currently lack the App credentials needed for ACP refresh, so the refresh is skipped there.

View file

@ -0,0 +1,125 @@
---
title: "Venice"
description: "Run Kimi, Grok, GLM, DeepSeek, and Qwen models through Venice"
---
[Venice](https://venice.ai/) provides an OpenAI-compatible API for hosted text models. Fabro enables the `venice` provider in its built-in catalog and maps stable Fabro model slugs to Venice's API model IDs.
## Prerequisites
- A Venice account
- An inference API key from [venice.ai/settings/api](https://venice.ai/settings/api)
- A running Fabro server
## Configure credentials
Store the API key in the target Fabro server vault:
```bash
fabro provider login --provider venice
# For a non-default remote server:
fabro provider login --server https://your-fabro.example --provider venice
# Or set the vault token directly:
fabro secret set VENICE_API_KEY
fabro secret --server https://your-fabro.example set VENICE_API_KEY
```
Standalone SDK usage outside a Fabro server can use an env-backed credential source explicitly:
```bash
export VENICE_API_KEY=<api-key>
```
Fabro sends bearer-authenticated Chat Completions requests to `https://api.venice.ai/api/v1`.
## Included models
| Fabro model slug | Venice API ID | Context | Max output | Role and aliases |
|---|---|---:|---:|---|
| `kimi-k3` | `kimi-k3` | 1,000,000 | 131,072 | Alias `kimi` |
| `kimi-k3-fast` | `kimi-k3-fast-api` | 1,000,000 | 131,072 | Alias `kimi-fast` |
| `grok-4.6` | `grok-4-6` | 500,000 | 32,000 | Aliases `grok`, `grok46`, `grok-46` |
| `glm-5.3` | `z-ai-glm-5-3` | 1,000,000 | 131,072 | Aliases `glm`, `glm5`, `glm53`, `glm5.3`, `glm-5-3` |
| `deepseek-v4-flash` | `deepseek-v4-flash-0731` | 1,000,000 | 32,768 | Provider default; aliases `deepseek`, `deepseek-v4`, `deepseek-flash` |
| `deepseek-v4-pro` | `deepseek-v4-pro-0813` | 1,000,000 | 32,768 | Alias `deepseek-pro` |
| `qwen3.8-max` | `qwen-3-8-max` | 1,000,000 | 131,072 | Aliases `qwen`, `qwen-max`, `qwen3.8`, `qwen-3.8`, `qwen38`, `qwen-3.8-max`, `qwen38-max` |
| `qwen3.8-27b` | `qwen-3-8-27b` | 262,144 | 131,072 | Aliases `qwen-27b`, `qwen-3.8-27b`, `qwen38-27b` |
Venice API IDs are also valid provider-scoped selectors. Fabro persists the stable Fabro slug and the selected provider when it creates a run.
## Select Venice explicitly
Some Venice models use the same stable slugs as direct providers. An unqualified selector chooses the highest-priority ready provider. For example, `deepseek` can select the direct DeepSeek provider when both API keys are configured.
Pin Venice when the run must use Venice:
```bash
fabro model list --provider venice
fabro model test --provider venice --model deepseek-v4-flash --deep
fabro run workflow.fabro --provider venice --model deepseek-v4-flash
```
In a workflow stylesheet:
```dot title="workflow.fabro"
digraph Example {
graph [
model_stylesheet="
* { provider: venice; model: deepseek-v4-flash; }
.complex { provider: venice; model: qwen; }
.fast { provider: venice; model: kimi-fast; }
"
]
start [shape=Mdiamond, label="Start"]
work [label="Implement", class="complex"]
check [label="Check", class="fast"]
exit [shape=Msquare, label="Exit"]
start -> work -> check -> exit
}
```
The generic Qwen aliases `qwen` and `qwen3.8` select Qwen 3.8 Max. Use a size-specific alias such as `qwen-27b` to select Qwen 3.8 27B.
## Capabilities and reasoning
All included models support tool calling and reasoning. Kimi K3, Kimi K3 Fast, Grok 4.6, Qwen 3.8 Max, and Qwen 3.8 27B also accept image input.
Fabro exposes native reasoning-effort controls only when Venice supports them:
| Model | Reasoning effort values |
|---|---|
| `grok-4.6` | `low`, `medium`, `high`, `xhigh` |
| `glm-5.3` | `low`, `high`, `max` |
| `deepseek-v4-flash` | `low`, `high`, `max` |
| `qwen3.8-27b` | `low`, `medium`, `xhigh` |
The other models reason by default but do not expose a Venice reasoning-effort control. Fabro omits sampling parameters for Kimi and DeepSeek because those routes do not use them with their configured reasoning behavior.
## Pricing and prompt caching
The built-in catalog uses Venice's published prices per million tokens:
| Model | Uncached input | Cache hit | Output |
|---|---:|---:|---:|
| `kimi-k3` | $3.75 | $0.375 | $18.75 |
| `kimi-k3-fast` | $4.50 | $0.45 | $22.50 |
| `grok-4.6` | $2.27 | $0.57 | $6.80 |
| `glm-5.3` | $1.75 | $0.325 | $5.50 |
| `deepseek-v4-flash` | $0.175 | $0.035 | $0.35 |
| `deepseek-v4-pro` | $1.65 | $0.165 | $4.95 |
| `qwen3.8-max` | $2.50 | $0.3125 | $7.50 |
| `qwen3.8-27b` | $0.45 | n/a | $3.20 |
Fabro reports cached input separately when Venice returns cache usage for the selected model. Prices and model availability can change upstream; use `fabro model list --provider venice` to inspect the catalog shipped with your Fabro version and the [Venice model catalog](https://docs.venice.ai/models/overview) for the current upstream service.
## Troubleshooting
**"No credential was found for provider 'venice'"** — Store `VENICE_API_KEY` in the server vault with `fabro provider login --provider venice`. Pass `--server` when configuring a remote Fabro server.
**A shared model used another provider** — Pin Venice with `--provider venice` or `provider: venice` in the workflow stylesheet. Unqualified selectors use provider priority.
**A Venice API model ID is rejected without a provider** — Use the stable Fabro slug for portable selection, or qualify the API ID with the provider, such as `venice:qwen-3-8-max`.

View file

@ -34,7 +34,7 @@ Files that omit `_version` are treated as version `1`. The legacy top-level `ver
| Scope | Examples |
|---|---|
| CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` |
| Shared run defaults | `[run.model]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github.permissions]`, `[run.hooks]`, `[run.agent.mcps]` |
| Shared run defaults | `[run.model]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github]`, `[run.hooks]`, `[run.agent.mcps]` |
| Shared LLM catalog | `[llm.providers.<id>]`, provider-scoped `[llm.providers.<id>.models.<slug>]` offerings, limits, features, controls, and costs |
| Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |

View file

@ -950,7 +950,8 @@ fn build_github_app_manifest(app_name: &str, port: u16, web_url: &str) -> serde_
"issues": "write",
"emails": "read",
"vulnerability_alerts": "write",
"organization_projects": "write"
"organization_projects": "write",
"packages": "read"
},
"default_events": []
})
@ -2682,6 +2683,10 @@ client_id = "client-id"
manifest["default_permissions"]["organization_projects"],
serde_json::json!("write"),
);
assert_eq!(
manifest["default_permissions"]["packages"],
serde_json::json!("read"),
);
}
#[test]

View file

@ -843,6 +843,7 @@ mod tests {
graph: fabro_types::Graph::new("test"),
graph_source: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
source_directory: None,
labels: std::collections::HashMap::default(),

View file

@ -161,13 +161,13 @@ pub(crate) async fn execute(
artifact_sink,
run_control: Some(run_control),
github_app,
github_permissions: run_spec
github_integration: run_spec
.settings
.run
.integrations
.github
.resolve_permissions()
.context("failed to resolve github permissions")?,
.resolve_integration()
.context("failed to resolve github integration")?,
vault,
catalog,
on_node: None,
@ -1762,7 +1762,10 @@ mod tests {
.parse::<EnvironmentProvider>()
.expect("test provider should parse");
run.integrations = RunIntegrationsSettings {
github: RunIntegrationsGithubSettings { permissions },
github: RunIntegrationsGithubSettings {
permissions,
..RunIntegrationsGithubSettings::default()
},
};
run
}

View file

@ -924,6 +924,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
"skip_git_hooks": false
},
"clone": {
"depth": 100,
"enabled": true
},
"environment": {

View file

@ -152,7 +152,8 @@ fn inspect_resolves_selector_via_server_endpoint() {
"commit_timeout_ms": 30000
},
"clone": {
"enabled": true
"enabled": true,
"depth": 100
},
"run_branch": {
"enabled": true,

View file

@ -47,6 +47,7 @@ pub(crate) fn run_projection_json(run_id: &str, status: &serde_json::Value) -> s
graph: Graph::new("Remote Workflow"),
graph_source: None,
workflow_slug: Some("remote-workflow".to_string()),
workflow_version_id: None,
automation: None,
source_directory: Some("/srv/repo".to_string()),
labels: std::collections::HashMap::default(),

View file

@ -12,8 +12,7 @@ use fabro_util::error::collect_chain;
use tokio::{fs, task};
use crate::git_checkout::{
GitCheckoutError, GitRepoCache, WorktreePrepareInput, github_metadata_url,
resolve_git_auth_config,
GitCheckoutError, GitRepoCache, WorktreePrepareInput, resolve_git_auth_config,
};
#[derive(Debug, Clone, PartialEq, Eq)]
@ -199,7 +198,7 @@ fn build_manifest_from_checkout(
let mut manifest = built.manifest;
manifest.git = Some(GitContext {
origin_url: github_metadata_url(&git_context.repo),
origin_url: git_context.repo.https_url(),
branch: git_context.ref_selector,
sha: Some(git_context.checked_out_sha),
dirty: DirtyStatus::Clean,

View file

@ -165,11 +165,9 @@ async fn bare_clone_may_be_corrupt(bare_dir: &Path) -> bool {
}
fn github_clone_url(repo: &GitHubRepositorySlug) -> String {
format!("https://github.com/{}/{}.git", repo.owner(), repo.repo())
}
pub(crate) fn github_metadata_url(repo: &GitHubRepositorySlug) -> String {
format!("https://github.com/{}/{}", repo.owner(), repo.repo())
let mut url = repo.https_url();
url.push_str(".git");
url
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -478,10 +476,7 @@ mod tests {
github_clone_url(&repo),
"https://github.com/fabro-sh/fabro.git"
);
assert_eq!(
github_metadata_url(&repo),
"https://github.com/fabro-sh/fabro"
);
assert_eq!(repo.https_url(), "https://github.com/fabro-sh/fabro");
assert!(!github_clone_url(&repo).contains('@'));
}
@ -491,10 +486,7 @@ mod tests {
assert_eq!(repo.owner(), "owner");
assert_eq!(repo.repo(), ".github");
assert_eq!(
github_metadata_url(&repo),
"https://github.com/owner/.github"
);
assert_eq!(repo.https_url(), "https://github.com/owner/.github");
}
#[test]

View file

@ -2059,7 +2059,8 @@ fn build_github_app_manifest(
"issues": "write",
"emails": "read",
"vulnerability_alerts": "write",
"organization_projects": "write"
"organization_projects": "write",
"packages": "read"
},
"default_events": []
})
@ -2375,6 +2376,21 @@ mod tests {
);
}
#[test]
fn github_app_manifest_includes_packages_read_permission() {
let manifest = build_github_app_manifest(
"Fabro Test",
"https://fabro.example/setup",
"https://fabro.example/auth/callback/github",
"https://fabro.example/setup",
);
assert_eq!(
manifest["default_permissions"]["packages"],
serde_json::json!("read"),
);
}
#[test]
fn token_validation_accepts_any_matching_source() {
let state = InstallAppState::for_test("expected");

View file

@ -38,6 +38,7 @@ use fabro_types::settings::interp::{InterpString, ResolveError};
use fabro_types::settings::run::{McpServerSettings, RunGoal};
use fabro_types::{
AutomationRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings,
WorkflowVersionId,
};
use fabro_util::workspace_glob::{WorkspaceGlob, WorkspaceGlobError};
use fabro_workflow::Error as WorkflowError;
@ -91,6 +92,7 @@ pub(crate) struct RawRunCompilerInput {
pub(crate) git: Option<GitContext>,
pub(crate) storage_root: PathBuf,
pub(crate) workflow_slug: Option<String>,
pub(crate) workflow_version_id: Option<WorkflowVersionId>,
pub(crate) provenance: RunProvenance,
pub(crate) web_url: Option<String>,
pub(crate) submitted_manifest_bytes: Option<Vec<u8>>,
@ -121,6 +123,7 @@ struct RunMetadata {
run_id: Option<RunId>,
storage_root: PathBuf,
workflow_slug: Option<String>,
workflow_version_id: Option<WorkflowVersionId>,
submitted_manifest_bytes: Option<Vec<u8>>,
title: Option<String>,
automation: Option<AutomationRef>,
@ -314,6 +317,7 @@ pub(crate) fn normalize_source(input: RawRunCompilerInput) -> Result<NormalizedR
git,
storage_root,
workflow_slug,
workflow_version_id,
provenance,
web_url,
submitted_manifest_bytes,
@ -373,6 +377,7 @@ pub(crate) fn normalize_source(input: RawRunCompilerInput) -> Result<NormalizedR
run_id,
storage_root,
workflow_slug,
workflow_version_id,
submitted_manifest_bytes,
title,
automation,
@ -533,6 +538,7 @@ pub(crate) fn assemble_run(pinned: PinnedRun) -> CreateRunPersistenceInput {
run_id,
storage_root,
workflow_slug,
workflow_version_id,
submitted_manifest_bytes,
title,
automation,
@ -545,6 +551,7 @@ pub(crate) fn assemble_run(pinned: PinnedRun) -> CreateRunPersistenceInput {
run_id: run_id.expect("run ID should be resolved before compilation"),
storage_root,
workflow_slug,
workflow_version_id,
submitted_manifest_bytes,
title,
automation,
@ -711,6 +718,7 @@ mod tests {
git: None,
storage_root: PathBuf::from("/tmp/fabro-storage"),
workflow_slug: None,
workflow_version_id: None,
provenance: provenance(),
web_url: None,
submitted_manifest_bytes: None,
@ -978,11 +986,13 @@ include = ["reports/{{ vars.path }}/*.json"]
trigger_id: Some("schedule".to_string()),
};
let submitted = b"submitted manifest".to_vec();
let workflow_version_id = fabro_types::test_support::test_workflow_version_id();
let mut input = raw_input(None, HashMap::new());
input.run_id = Some(run_id);
input.parent_id = Some(parent_id);
input.title = Some("Compiler boundary".to_string());
input.workflow_slug = Some("compiler-boundary".to_string());
input.workflow_version_id = Some(workflow_version_id);
input.web_url = Some(format!("https://fabro.test/runs/{run_id}"));
input.submitted_manifest_bytes = Some(submitted.clone());
input.automation = Some(automation.clone());
@ -1005,6 +1015,7 @@ include = ["reports/{{ vars.path }}/*.json"]
assert_eq!(persistence.run_id(), run_id);
assert_eq!(persistence.workflow_slug(), Some("compiler-boundary"));
assert_eq!(persistence.workflow_version_id(), Some(workflow_version_id));
assert_eq!(
persistence.submitted_manifest_bytes(),
Some(submitted.as_slice())

View file

@ -2376,20 +2376,21 @@ index 1111111..2222222 160000
let mut projection = fabro_store::RunProjection::new(
"Test run".to_string(),
fabro_types::RunSpec {
run_id: fabro_types::fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: fabro_types::Graph::new("test"),
graph_source: None,
workflow_slug: None,
automation: None,
source_directory: None,
labels: HashMap::default(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
run_id: fabro_types::fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: fabro_types::Graph::new("test"),
graph_source: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
source_directory: None,
labels: HashMap::default(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
},
chrono::Utc::now(),
);

View file

@ -12,6 +12,7 @@ use fabro_config::{
CliLayer, CliOutputLayer, EnvironmentLayer, MergeMap, RunLayer, SettingsLayer,
WorkflowSettingsBuilder, parse_input_overrides, parse_labels, project,
};
use fabro_github::token_source::{InstallationTokenSource, ResolvedToken, TokenSnapshot};
use fabro_graphviz::graph::{Graph, is_llm_handler_type};
use fabro_graphviz::render::apply_direction;
use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe};
@ -669,11 +670,11 @@ pub(crate) fn effective_sandbox_provider(settings: &RunNamespace) -> SandboxProv
}
fn resolve_daytona_config(settings: &RunNamespace) -> DaytonaConfig {
daytona_config_from_environment(&settings.environment, !settings.clone.enabled)
daytona_config_from_environment(&settings.environment, &settings.clone)
}
fn resolve_docker_config(settings: &RunNamespace) -> DockerSandboxOptions {
docker_config_from_environment(&settings.environment, !settings.clone.enabled)
docker_config_from_environment(&settings.environment, &settings.clone)
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -872,6 +873,18 @@ async fn check_git_remote_ref(
command.arg(branch);
}
run_ls_remote(command)
.await
.map_err(|message| redact_auth_url(&message, auth_url.as_ref()))
}
/// Run a prepared `git ls-remote` invocation with a 10s timeout, reducing a
/// failure to its most useful message: stderr, then stdout, then the exit
/// status.
async fn run_ls_remote(mut command: Command) -> std::result::Result<(), String> {
// Dropping a timed-out `Command::output` future does not stop the child
// unless kill-on-drop is enabled.
command.kill_on_drop(true);
let output = time::timeout(Duration::from_secs(10), command.output())
.await
.map_err(|_| "git ls-remote timed out after 10s".to_string())?
@ -883,14 +896,13 @@ async fn check_git_remote_ref(
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let message = if !stderr.is_empty() {
Err(if !stderr.is_empty() {
stderr
} else if !stdout.is_empty() {
stdout
} else {
format!("git ls-remote exited with status {}", output.status)
};
Err(redact_auth_url(&message, auth_url.as_ref()))
})
}
fn preflight_sandbox_spec(
@ -923,6 +935,7 @@ fn preflight_sandbox_spec(
run_id: None,
clone_origin_url,
clone_branch,
clone_commit_sha: None,
}
}
SandboxProviderKind::Daytona => {
@ -934,6 +947,7 @@ fn preflight_sandbox_spec(
run_id: None,
clone_origin_url,
clone_branch,
clone_commit_sha: None,
api_key: daytona_api_key,
}
}
@ -1198,32 +1212,233 @@ async fn run_github_token_check(
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
) -> bool {
if !resolved_run.integrations.github.is_token_requested() {
run_github_token_check_with(
checks,
prepared,
resolved_run,
github_app,
mint_scoped_github_token,
probe_github_repository,
)
.await
}
async fn run_github_token_check_with<M, MFut, P, PFut>(
checks: &mut Vec<CheckResult>,
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
mint_scoped_token: M,
probe_repository: P,
) -> bool
where
M: FnOnce(fabro_github::GitHubRepositoryAccess, fabro_github::GitHubCredentials) -> MFut,
MFut: Future<Output = std::result::Result<ResolvedToken, String>>,
P: Fn(fabro_types::GitHubRepositorySlug, ResolvedToken) -> PFut,
PFut: Future<Output = std::result::Result<(), String>>,
{
let github = &resolved_run.integrations.github;
if !github.is_token_requested() {
return true;
}
// Resolve InterpString permission values eagerly for token minting and
// for display in the preflight report.
let github_permissions = match resolved_run.integrations.github.resolve_permissions() {
Ok(permissions) => permissions,
let integration = match github.resolve_integration() {
Ok(integration) => integration,
Err(err) => {
checks.push(CheckResult {
name: "GitHub Token".into(),
status: CheckStatus::Error,
summary: "invalid permissions".into(),
details: vec![],
remediation: Some(format!("Failed to resolve GitHub permissions: {err}")),
});
return false;
return fail_github_token_check(
checks,
Vec::new(),
"invalid permissions",
format!("Failed to resolve GitHub permissions: {err}"),
);
}
};
let perm_details = github_permissions
let perm_details = integration
.permissions
.iter()
.map(|(key, value)| CheckDetail::new(format!("{key}: {value}")))
.collect::<Vec<_>>();
if !integration.has_additional_repositories() {
// Primary-only behavior is unchanged: a mint check when credentials
// and an origin exist, a warning otherwise, and no Git-content probe
// (permissions-only workflows may request non-contents permissions).
return check_primary_only_github_token(
checks,
prepared,
github_app,
&integration.permissions,
perm_details,
)
.await;
}
// `gh` checks GH_TOKEN before GITHUB_TOKEN, so a user-defined GH_TOKEN
// bypasses the managed scoped token for gh commands. Warn without
// failing; the value is the workflow author's responsibility.
if resolved_run.environment.env.contains_key(EnvVars::GH_TOKEN) {
checks.push(CheckResult {
name: "GH_TOKEN Override".into(),
status: CheckStatus::Warning,
summary: "gh will not use the managed token".into(),
details: vec![],
remediation: Some(
"The resolved run environment defines GH_TOKEN, which the gh CLI prefers over \
the managed GITHUB_TOKEN; gh commands will not use the token scoped to the \
declared repositories."
.to_string(),
),
});
}
let Some(origin_url) = prepared
.git
.as_ref()
.map(|git| git.origin_url.trim())
.filter(|url| !url.is_empty())
else {
return fail_github_token_check(
checks,
perm_details,
"missing origin",
"run.integrations.github.additional_repositories requires a GitHub run origin, but \
this run has no repository origin URL"
.to_string(),
);
};
let Some(creds) = github_app else {
return fail_github_token_check(
checks,
perm_details,
"missing credentials",
"run.integrations.github.additional_repositories requires GitHub credentials, but \
none are configured on the server"
.to_string(),
);
};
// The same validated access value runtime initialization constructs, so
// preflight and runtime cannot disagree about the effective set.
let access = match fabro_github::GitHubRepositoryAccess::new(
Some(origin_url),
&integration.additional_repositories,
integration.permissions.clone(),
) {
Ok(Some(access)) => access,
// `new` returns `Ok(None)` only when nothing is declared, and the
// declared set is non-empty here. Fail closed instead of panicking.
Ok(None) => {
return fail_github_token_check(
checks,
perm_details,
"missing origin",
"run.integrations.github.additional_repositories requires a GitHub run origin, \
but this run has no repository origin URL"
.to_string(),
);
}
Err(err) => {
return fail_github_token_check(
checks,
perm_details,
"invalid repository set",
format!("{err:#}"),
);
}
};
// One mint scoped to the whole effective set. In App mode the minter
// first resolves every repository's installation so a failure names the
// repository the App cannot see.
let token = match mint_scoped_token(access.clone(), creds).await {
Ok(token) => token,
Err(err) => {
return fail_github_token_check(checks, perm_details, "failed", err);
}
};
checks.push(CheckResult {
name: "GitHub Token".into(),
status: CheckStatus::Pass,
summary: "minted".into(),
details: perm_details.clone(),
remediation: None,
});
// Probe every effective repository with bounded concurrency, then report
// in deterministic primary-first order. Possession of a scoped token is
// not proof of access; the probe also verifies PAT/static credentials.
let targets: Vec<fabro_types::GitHubRepositorySlug> =
access.targets().into_iter().cloned().collect();
let probe_repository = &probe_repository;
let mut results: Vec<(usize, CheckResult)> =
stream::iter(targets.into_iter().enumerate().map(|(index, slug)| {
let token = token.clone();
let perm_details = perm_details.clone();
async move {
let check = match probe_repository(slug.clone(), token).await {
Ok(()) => CheckResult {
name: format!("GitHub Repository ({slug})"),
status: CheckStatus::Pass,
summary: "reachable".into(),
details: perm_details,
remediation: None,
},
Err(err) => CheckResult {
name: format!("GitHub Repository ({slug})"),
status: CheckStatus::Error,
summary: "failed".into(),
details: perm_details,
remediation: Some(format!("Failed to verify repository access: {err}")),
},
};
(index, check)
}
}))
.buffer_unordered(REPOSITORY_PROBE_CONCURRENCY)
.collect()
.await;
results.sort_by_key(|(index, _)| *index);
let mut ok = true;
for (_, check) in results {
if check.status != CheckStatus::Pass {
ok = false;
}
checks.push(check);
}
ok
}
/// Report one "GitHub Token" preflight failure and fail the check.
fn fail_github_token_check(
checks: &mut Vec<CheckResult>,
perm_details: Vec<CheckDetail>,
summary: &str,
remediation: String,
) -> bool {
checks.push(CheckResult {
name: "GitHub Token".into(),
status: CheckStatus::Error,
summary: summary.into(),
details: perm_details,
remediation: Some(remediation),
});
false
}
/// Bounded concurrency for per-repository `git ls-remote` probes.
const REPOSITORY_PROBE_CONCURRENCY: usize = 4;
async fn check_primary_only_github_token(
checks: &mut Vec<CheckResult>,
prepared: &PreparedManifest,
github_app: Option<fabro_github::GitHubCredentials>,
permissions: &HashMap<String, String>,
perm_details: Vec<CheckDetail>,
) -> bool {
if let (Some(creds), Some(git)) = (&github_app, prepared.git.as_ref()) {
match mint_github_token(creds, &git.origin_url, &github_permissions).await {
match mint_github_token(creds, &git.origin_url, permissions).await {
Ok(_) => {
checks.push(CheckResult {
name: "GitHub Token".into(),
@ -1257,6 +1472,62 @@ async fn run_github_token_check(
}
}
/// Production minter for the multi-repository path. The source owns the
/// effective set. In App mode its first resolve checks every repository's
/// installation before minting the scoped token.
async fn mint_scoped_github_token(
access: fabro_github::GitHubRepositoryAccess,
creds: fabro_github::GitHubCredentials,
) -> std::result::Result<ResolvedToken, String> {
let source =
InstallationTokenSource::for_access(&creds, &access).map_err(|err| format!("{err:#}"))?;
source.resolve().await.map_err(|err| format!("{err:#}"))
}
/// Production per-repository probe: a non-interactive
/// `git ls-remote <https-url> HEAD` authenticated through
/// [`fabro_github::GITHUB_CREDENTIAL_HELPER`] reading `GITHUB_TOKEN` from
/// the child process environment, so the token never appears in the URL,
/// argv, or rendered errors — exactly what the runtime `git_bridge`
/// configures in `fabro-workflow`.
async fn probe_github_repository(
slug: fabro_types::GitHubRepositorySlug,
token: ResolvedToken,
) -> std::result::Result<(), String> {
let url = slug.https_url();
probe_with_replication_retry(token.snapshot, || run_probe_ls_remote(&url, &token)).await
}
/// Retry auth-shaped failures with the SAME token: replication of a given
/// token only makes progress, while re-minting would restart the replication
/// clock. The sandbox git retry executor owns attempt limits,
/// classification, and pacing.
async fn probe_with_replication_retry<F, Fut>(
snapshot: TokenSnapshot,
mut run: F,
) -> std::result::Result<(), String>
where
F: FnMut() -> Fut,
Fut: Future<Output = std::result::Result<(), String>>,
{
let credential_context = fabro_sandbox::CredentialContext::from_snapshot(Some(&snapshot));
fabro_sandbox::retry_git_operation(
SandboxProviderKind::Local,
"repository probe",
&fabro_sandbox::RetryPlan::repository_probe(),
|_attempt| run(),
|message| fabro_sandbox::classify_failure(message, credential_context),
)
.await
}
async fn run_probe_ls_remote(url: &str, token: &ResolvedToken) -> std::result::Result<(), String> {
let mut command = Command::new("git");
fabro_github::apply_probe_git_env(&mut command, token.token.expose());
command.args(["ls-remote", url, "HEAD"]);
run_ls_remote(command).await
}
async fn mint_github_token(
creds: &fabro_github::GitHubCredentials,
origin_url: &str,
@ -2972,4 +3243,336 @@ dockerfile = { path = "Dockerfile" }
);
}
}
mod github_additional_repository_checks {
//! Seam-injected tests for the declared-additional-repositories
//! preflight path: one scoped mint, per-repository probes with
//! deterministic primary-first reporting, GH_TOKEN warning, and the
//! replication-lag retry policy.
use std::sync::Mutex as StdMutex;
use std::sync::atomic::{AtomicU64, Ordering};
use fabro_github::token_source::{
ResolvedToken, SecretString, TokenProvenance, TokenSnapshot,
};
use fabro_types::settings::run::RunIntegrationsGithubSettings;
use super::*;
fn static_token(secret: &str) -> ResolvedToken {
ResolvedToken {
token: SecretString::new(secret.to_string()),
snapshot: TokenSnapshot {
generation: 0,
provenance: TokenProvenance::Static,
},
refresh_failed: false,
}
}
fn fresh_minted_snapshot() -> TokenSnapshot {
let now = chrono::Utc::now();
TokenSnapshot {
generation: 1,
provenance: TokenProvenance::Minted {
minted_at: now,
expires_at: now + chrono::Duration::minutes(60),
},
}
}
fn declared(origin: &str, additional: &[&str]) -> (PreparedManifest, RunNamespace) {
let (prepared, mut resolved) = prepared_and_resolved_for_sandbox(
SandboxProviderKind::Local,
true,
Some(git_context(origin, "main")),
);
resolved.integrations.github = RunIntegrationsGithubSettings {
permissions: HashMap::from([(
"contents".to_string(),
InterpString::parse("read"),
)]),
additional_repositories: additional
.iter()
.map(|value| value.parse().expect("test slug should parse"))
.collect(),
};
(prepared, resolved)
}
fn pat_creds() -> fabro_github::GitHubCredentials {
fabro_github::GitHubCredentials::Pat("ghp_test".to_string())
}
#[tokio::test(start_paused = true)]
async fn reports_each_repository_primary_first_despite_probe_completion_order() {
let (prepared, resolved) = declared("https://github.com/acme/widgets", &[
"acme/zeta",
"acme/alpha",
]);
let minted = Arc::new(StdMutex::new(Vec::new()));
let minted_for_seam = Arc::clone(&minted);
let mut checks = Vec::new();
let ok = run_github_token_check_with(
&mut checks,
&prepared,
&resolved,
Some(pat_creds()),
move |access, _creds| {
minted_for_seam.lock().unwrap().push(access);
async { Ok(static_token("scoped-token")) }
},
|slug, _token| async move {
// Invert completion order: the primary finishes last.
let delay = match slug.repo() {
"widgets" => 30,
"alpha" => 20,
_ => 10,
};
time::sleep(Duration::from_millis(delay)).await;
Ok(())
},
)
.await;
assert!(ok);
// One mint listing every repository with the shared permissions.
let minted = minted.lock().unwrap();
assert_eq!(minted.len(), 1);
assert_eq!(minted[0].repository_names(), vec![
"widgets", "alpha", "zeta"
]);
assert_eq!(
minted[0].permissions().get("contents").map(String::as_str),
Some("read")
);
let names: Vec<&str> = checks.iter().map(|check| check.name.as_str()).collect();
assert_eq!(names, vec![
"GitHub Token",
"GitHub Repository (acme/widgets)",
"GitHub Repository (acme/alpha)",
"GitHub Repository (acme/zeta)",
]);
assert!(checks.iter().all(|check| check.status == CheckStatus::Pass));
// The token never reaches check output.
for check in &checks {
let rendered = format!("{check:?}");
assert!(!rendered.contains("scoped-token"), "{rendered}");
}
}
#[tokio::test]
async fn installation_resolution_failure_names_only_the_inaccessible_repository() {
let (prepared, resolved) =
declared("https://github.com/acme/widgets", &["acme/keystone"]);
let probes = Arc::new(AtomicU64::new(0));
let probes_for_seam = Arc::clone(&probes);
let mut checks = Vec::new();
let ok = run_github_token_check_with(
&mut checks,
&prepared,
&resolved,
Some(pat_creds()),
|_access, _creds| async {
Err(
"the GitHub App installation cannot see repository acme/keystone; add \
it to the installation's repository access"
.to_string(),
)
},
move |_slug, _token| {
probes_for_seam.fetch_add(1, Ordering::SeqCst);
async { Ok(()) }
},
)
.await;
assert!(!ok);
assert_eq!(
probes.load(Ordering::SeqCst),
0,
"no probes after a failed mint"
);
assert_eq!(checks.last().unwrap().name, "GitHub Token");
assert_eq!(checks.last().unwrap().status, CheckStatus::Error);
let remediation = checks.last().unwrap().remediation.as_deref().unwrap();
assert!(remediation.contains("acme/keystone"), "{remediation}");
assert!(!remediation.contains("acme/widgets"), "{remediation}");
}
#[tokio::test]
async fn successful_mint_with_failed_probe_still_fails() {
let (prepared, resolved) =
declared("https://github.com/acme/widgets", &["acme/keystone"]);
let mut checks = Vec::new();
let ok = run_github_token_check_with(
&mut checks,
&prepared,
&resolved,
Some(pat_creds()),
|_access, _creds| async { Ok(static_token("scoped-token")) },
|slug, _token| async move {
if slug.repo() == "keystone" {
Err("remote: Repository not found.".to_string())
} else {
Ok(())
}
},
)
.await;
assert!(!ok);
let keystone = checks
.iter()
.find(|check| check.name == "GitHub Repository (acme/keystone)")
.expect("keystone probe result should be reported");
assert_eq!(keystone.status, CheckStatus::Error);
assert!(
!keystone
.remediation
.as_deref()
.unwrap_or_default()
.contains("scoped-token")
);
let widgets = checks
.iter()
.find(|check| check.name == "GitHub Repository (acme/widgets)")
.expect("primary probe result should be reported");
assert_eq!(widgets.status, CheckStatus::Pass);
}
#[tokio::test]
async fn resolved_gh_token_warns_without_failing() {
let (prepared, mut resolved) =
declared("https://github.com/acme/widgets", &["acme/keystone"]);
resolved
.environment
.env
.insert("GH_TOKEN".to_string(), InterpString::parse("user-token"));
let mut checks = Vec::new();
let ok = run_github_token_check_with(
&mut checks,
&prepared,
&resolved,
Some(pat_creds()),
|_access, _creds| async { Ok(static_token("scoped-token")) },
|_slug, _token| async { Ok(()) },
)
.await;
assert!(ok, "a GH_TOKEN override warns but does not fail preflight");
let warning = checks
.iter()
.find(|check| check.name == "GH_TOKEN Override")
.expect("GH_TOKEN warning should be reported");
assert_eq!(warning.status, CheckStatus::Warning);
}
#[tokio::test]
async fn missing_origin_fails_for_declared_repositories() {
let (prepared, resolved) = {
let (mut prepared, resolved) =
declared("https://github.com/acme/widgets", &["acme/keystone"]);
prepared.git = None;
(prepared, resolved)
};
let mut checks = Vec::new();
let ok = run_github_token_check_with(
&mut checks,
&prepared,
&resolved,
Some(pat_creds()),
|_access, _creds| async { Ok(static_token("scoped-token")) },
|_slug, _token| async { Ok(()) },
)
.await;
assert!(!ok);
let check = checks.last().unwrap();
assert_eq!(check.summary, "missing origin");
assert!(
check
.remediation
.as_deref()
.unwrap_or_default()
.contains("requires a GitHub run origin"),
"{:?}",
check.remediation
);
}
#[tokio::test]
async fn missing_credentials_fail_for_declared_repositories() {
let (prepared, resolved) =
declared("https://github.com/acme/widgets", &["acme/keystone"]);
let mut checks = Vec::new();
let ok = run_github_token_check_with(
&mut checks,
&prepared,
&resolved,
None,
|_access, _creds| async { Ok(static_token("scoped-token")) },
|_slug, _token| async { Ok(()) },
)
.await;
assert!(!ok);
assert_eq!(checks.last().unwrap().summary, "missing credentials");
}
#[tokio::test(start_paused = true)]
async fn replication_lag_failure_retries_with_the_same_token_and_succeeds() {
let attempts = Arc::new(AtomicU64::new(0));
let attempts_for_run = Arc::clone(&attempts);
let result = probe_with_replication_retry(fresh_minted_snapshot(), move || {
let attempts = Arc::clone(&attempts_for_run);
async move {
if attempts.fetch_add(1, Ordering::SeqCst) == 0 {
Err("remote: Repository not found.".to_string())
} else {
Ok(())
}
}
})
.await;
assert!(result.is_ok());
assert_eq!(attempts.load(Ordering::SeqCst), 2);
}
#[tokio::test(start_paused = true)]
async fn static_credential_auth_failures_do_not_retry() {
let attempts = Arc::new(AtomicU64::new(0));
let attempts_for_run = Arc::clone(&attempts);
let static_snapshot = TokenSnapshot {
generation: 0,
provenance: TokenProvenance::Static,
};
let result = probe_with_replication_retry(static_snapshot, move || {
let attempts = Arc::clone(&attempts_for_run);
async move {
attempts.fetch_add(1, Ordering::SeqCst);
Err("remote: Repository not found.".to_string())
}
})
.await;
assert!(result.is_err());
assert_eq!(
attempts.load(Ordering::SeqCst),
1,
"a 404 with a static credential cannot become valid by waiting"
);
}
}
}

View file

@ -4081,15 +4081,15 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
return;
}
};
let github_permissions = match persisted
let github_integration = match persisted
.run_spec()
.settings
.run
.integrations
.github
.resolve_permissions()
.resolve_integration()
{
Ok(permissions) => permissions,
Ok(integration) => integration,
Err(err) => {
tracing::error!(
run_id = %run_id,
@ -4131,7 +4131,7 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
artifact_sink: Some(ArtifactSink::Store(state.artifact_store.clone())),
run_control: None,
github_app,
github_permissions,
github_integration,
vault: Arc::new(AsyncRwLock::new(vault.into_vault())),
catalog: state.catalog(),
on_node: None,

View file

@ -616,23 +616,24 @@ mod stage_events_tests {
async fn append_run_created(run_store: &fabro_store::RunDatabase, run_id: &RunId) {
workflow_event::append_event(run_store, run_id, &workflow_event::Event::RunCreated {
run_id: *run_id,
title: None,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::new(),
source_directory: None,
workflow_slug: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
run_id: *run_id,
title: None,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::new(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.expect("run.created should append");

View file

@ -1024,6 +1024,7 @@ mod tests {
labels: std::collections::BTreeMap::new(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,

View file

@ -730,6 +730,7 @@ pub(crate) async fn create_run_from_manifest(
git: manifest.git.clone(),
storage_root: state.server_storage_dir(),
workflow_slug: None,
workflow_version_id: None,
provenance: run_provenance(&headers, &actor),
web_url: None,
submitted_manifest_bytes: Some(submitted_manifest_bytes),

View file

@ -1917,6 +1917,7 @@ reasoning = false
graph,
graph_source: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
source_directory: None,
labels: HashMap::default(),

View file

@ -4649,6 +4649,7 @@ async fn append_default_run_created(run_store: &fabro_store::RunDatabase, run_id
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
@ -4701,6 +4702,7 @@ async fn create_slack_notification_run(
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: workflow_slug.map(str::to_string),
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
@ -5775,6 +5777,7 @@ async fn list_run_stages_distinguishes_visits() {
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
@ -5912,6 +5915,7 @@ async fn list_run_stages_exposes_execution_identity_for_resumed_stage() {
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
@ -7096,6 +7100,7 @@ async fn create_completed_run_ready_for_pull_request(
graph,
graph_source: None,
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
automation: None,
source_directory: Some("/tmp/project".to_string()),
git: git.clone(),
@ -7117,6 +7122,7 @@ async fn create_completed_run_ready_for_pull_request(
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: run_spec.source_directory.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
workflow_version_id: run_spec.workflow_version_id,
automation: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
@ -14090,6 +14096,7 @@ async fn create_preserved_local_sandbox_run(state: &Arc<AppState>, run_id: RunId
labels: std::collections::BTreeMap::default(),
source_directory: Some("/tmp/fabro-run".to_string()),
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
@ -14840,6 +14847,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() {
labels: std::collections::BTreeMap::default(),
source_directory: Some("/tmp/fabro-run".to_string()),
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,

View file

@ -56,24 +56,26 @@ async fn append_completed_run_with_final_patch(
) {
let run_store = store.create_run(run_id).await.expect("create run store");
workflow_event::append_event(&run_store, run_id, &workflow_event::Event::RunCreated {
run_id: *run_id,
title: None,
settings: serde_json::to_value(WorkflowSettings::default())
run_id: *run_id,
title: None,
settings: serde_json::to_value(WorkflowSettings::default())
.expect("workflow settings should serialize"),
graph: serde_json::to_value(Graph::new("test")).expect("graph should serialize"),
workflow_source: None,
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
graph: serde_json::to_value(Graph::new("test"))
.expect("graph should serialize"),
workflow_source: None,
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.expect("append RunCreated");

View file

@ -28,6 +28,7 @@ async fn shell_reports_real_docker_process_outcome() {
None,
None,
None,
None,
) else {
return;
};

View file

@ -25,6 +25,7 @@ fabro-http.workspace = true
fabro-redact.workspace = true
fabro-static.workspace = true
fabro-types = { path = "../../foundation/fabro-types" }
futures.workspace = true
jsonwebtoken.workspace = true
chrono.workspace = true
tracing.workspace = true

View file

@ -0,0 +1,587 @@
//! The validated effective repository set for one run's GitHub access.
//!
//! [`GitHubRepositoryAccess`] is the single value both server preflight and
//! workflow initialization construct from the run origin, the declared
//! additional repositories, and the resolved shared permissions — so the two
//! paths cannot disagree about which repositories a run's `GITHUB_TOKEN`
//! covers. It carries no token or key material.
use std::collections::{BTreeSet, HashMap};
use anyhow::{Context as _, bail};
use fabro_types::GitHubRepositorySlug;
use fabro_types::settings::run::RunIntegrationsGithubSettings;
use futures::stream::{self, StreamExt as _};
use crate::{GitHubAppCredentials, HttpClient, InstallationLookup, InstallationToken};
/// Keep GitHub installation lookups bounded while avoiding one network round
/// trip at a time for large declared repository sets.
const INSTALLATION_LOOKUP_CONCURRENCY: usize = 4;
/// The validated effective repository set for a run: the primary origin
/// repository plus zero or more distinct additional repositories, all with
/// one shared owner, and the shared permission map that scopes the token.
///
/// Secret-free by construction: `Debug` may render everywhere the run
/// pipeline logs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitHubRepositoryAccess {
primary: GitHubRepositorySlug,
/// Sorted, deduplicated, primary excluded.
additional: Vec<GitHubRepositorySlug>,
permissions: HashMap<String, String>,
}
impl GitHubRepositoryAccess {
/// Build the effective access request.
///
/// Returns `Ok(None)` when no origin URL is available and no additional
/// repositories are declared — the legacy "nothing to scope" state whose
/// handling stays with the caller. Every declared-additional invariant is
/// enforced here:
///
/// - a declared additional set requires a GitHub origin,
/// - the additional set cannot contain the primary repository,
/// - every additional repository shares the primary's owner
/// (case-insensitive) because one App installation covers one account,
/// - a declared additional set requires interpolated permissions with
/// `contents = "read"` or `contents = "write"`.
pub fn new(
origin_url: Option<&str>,
additional_repositories: &BTreeSet<GitHubRepositorySlug>,
permissions: HashMap<String, String>,
) -> anyhow::Result<Option<Self>> {
let origin_url = origin_url.map(str::trim).filter(|url| !url.is_empty());
let Some(origin_url) = origin_url else {
if additional_repositories.is_empty() {
return Ok(None);
}
bail!(
"run.integrations.github.additional_repositories requires a GitHub run origin; \
this run has no repository origin URL"
);
};
let normalized = crate::normalize_repo_origin_url(origin_url);
let (owner, repo) = crate::parse_github_owner_repo(&normalized)
.context("parsing GitHub origin for repository access")?;
let Some(primary) = GitHubRepositorySlug::try_new(&format!("{owner}/{repo}")) else {
bail!("run origin does not name a valid GitHub `owner/repository`: {owner}/{repo}");
};
if !additional_repositories.is_empty() {
validate_additional_permissions(&permissions)?;
}
let mut additional = Vec::with_capacity(additional_repositories.len());
for slug in additional_repositories {
if *slug == primary {
bail!(
"run.integrations.github.additional_repositories must not repeat the run \
origin repository `{primary}` the origin is always included"
);
}
if !slug.same_owner(&primary) {
bail!(
"additional repository `{slug}` has owner `{}` but the run origin `{primary}` \
has owner `{}`; all repositories must share one owner because one GitHub App \
installation covers one account",
slug.owner(),
primary.owner()
);
}
additional.push(slug.clone());
}
Ok(Some(Self {
primary,
additional,
permissions,
}))
}
#[must_use]
pub fn primary(&self) -> &GitHubRepositorySlug {
&self.primary
}
/// Every repository in the effective set, primary first, then the
/// additional repositories in their deterministic sorted order.
#[must_use]
pub fn targets(&self) -> Vec<&GitHubRepositorySlug> {
std::iter::once(&self.primary)
.chain(self.additional.iter())
.collect()
}
/// Project each validated slug to its repository-name component for the
/// installation-token mint request, which accepts names within the
/// selected installation. Every target shares the primary's owner, so the
/// projection loses nothing.
#[must_use]
pub fn repository_names(&self) -> Vec<String> {
self.targets()
.into_iter()
.map(|slug| slug.repo().to_string())
.collect()
}
#[must_use]
pub fn owner(&self) -> &str {
self.primary.owner()
}
#[must_use]
pub fn permissions(&self) -> &HashMap<String, String> {
&self.permissions
}
pub fn permissions_json(&self) -> anyhow::Result<serde_json::Value> {
serde_json::to_value(&self.permissions).context("serializing GitHub permissions")
}
#[must_use]
pub fn has_additional_repositories(&self) -> bool {
!self.additional.is_empty()
}
/// Resolve every target's App installation and require one shared
/// installation ID, so a repository the App cannot see — or one that
/// resolves to a different installation — is named before any token is
/// minted. Targets are checked in deterministic primary-first order.
async fn resolve_shared_installation_with_jwt(
&self,
client: &impl HttpClient,
jwt: &str,
base_url: &str,
) -> anyhow::Result<u64> {
let targets: Vec<GitHubRepositorySlug> = self.targets().into_iter().cloned().collect();
let mut lookups = stream::iter(targets.into_iter().map(|slug| async move {
let lookup =
crate::lookup_installation(client, jwt, base_url, slug.owner(), slug.repo())
.await
.with_context(|| format!("looking up the GitHub App installation for {slug}"));
(slug, lookup)
}))
.buffered(INSTALLATION_LOOKUP_CONCURRENCY);
let mut shared: Option<(u64, GitHubRepositorySlug)> = None;
while let Some((slug, lookup)) = lookups.next().await {
let lookup = lookup?;
let id = match lookup {
InstallationLookup::Found(id) => id,
InstallationLookup::NotFound => bail!(
"the GitHub App installation cannot see repository {slug}; add it to the \
installation's repository access"
),
InstallationLookup::Failed(status) => bail!(
"unexpected status {status} looking up the GitHub App installation for {slug}"
),
};
match &shared {
None => shared = Some((id, slug)),
Some((shared_id, first)) if *shared_id != id => bail!(
"repository {slug} belongs to GitHub App installation {id} but {first} \
belongs to installation {shared_id}; all repositories must share one \
installation"
),
Some(_) => {}
}
}
let (id, _) = shared.expect("the effective repository set always contains the primary");
Ok(id)
}
/// Resolve every target to one installation, then mint one token scoped
/// to this exact repository set without looking up the primary twice.
pub(crate) async fn mint_installation_token(
&self,
creds: &GitHubAppCredentials,
client: &impl HttpClient,
base_url: &str,
) -> anyhow::Result<InstallationToken> {
let jwt = crate::sign_app_jwt(&creds.app_id, &creds.private_key_pem)?;
let installation_id = self
.resolve_shared_installation_with_jwt(client, &jwt, base_url)
.await
.context("resolving the shared GitHub App installation")?;
crate::mint_installation_token_for_id_with_jwt(
client,
&jwt,
installation_id,
&self.repository_names(),
base_url,
self.permissions_json()?,
)
.await
}
}
/// A non-empty additional set needs a token that can reach repository
/// contents. Configuration resolution already checked literal values; this
/// is the runtime re-check after `{{ vars.* }}` interpolation.
fn validate_additional_permissions(permissions: &HashMap<String, String>) -> anyhow::Result<()> {
let Some(contents) = permissions.get("contents") else {
bail!(
"run.integrations.github.additional_repositories requires the `contents` permission \
(`read` or `write`)"
);
};
if !RunIntegrationsGithubSettings::contents_permission_allows_repository_access(contents) {
bail!(
"run.integrations.github.additional_repositories requires `contents = \"read\"` or \
`contents = \"write\"`, got `{contents}`"
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn slugs(values: &[&str]) -> BTreeSet<GitHubRepositorySlug> {
values
.iter()
.map(|value| value.parse().expect("test slug should parse"))
.collect()
}
fn contents_read() -> HashMap<String, String> {
HashMap::from([("contents".to_string(), "read".to_string())])
}
fn access(
origin: &str,
additional: &[&str],
permissions: HashMap<String, String>,
) -> anyhow::Result<Option<GitHubRepositoryAccess>> {
GitHubRepositoryAccess::new(Some(origin), &slugs(additional), permissions)
}
#[test]
fn https_and_both_ssh_origin_forms_normalize_to_the_same_primary() {
let origins = [
"https://github.com/fabro-sh/fabro.git",
"git@github.com:fabro-sh/fabro.git",
"ssh://git@github.com/fabro-sh/fabro.git",
"https://github.com/fabro-sh/fabro",
];
for origin in origins {
let access = access(origin, &[], HashMap::new())
.expect(origin)
.expect("origin should produce an access value");
assert_eq!(access.primary().to_string(), "fabro-sh/fabro", "{origin}");
}
}
#[test]
fn no_origin_and_no_additional_repositories_is_none() {
let access = GitHubRepositoryAccess::new(None, &BTreeSet::new(), HashMap::new()).unwrap();
assert!(access.is_none());
let blank =
GitHubRepositoryAccess::new(Some(" "), &BTreeSet::new(), HashMap::new()).unwrap();
assert!(blank.is_none());
}
#[test]
fn additional_repositories_require_an_origin() {
let err =
GitHubRepositoryAccess::new(None, &slugs(&["fabro-sh/keystone"]), contents_read())
.unwrap_err();
assert!(
err.to_string().contains("requires a GitHub run origin"),
"{err:#}"
);
}
#[test]
fn additional_repositories_require_a_github_origin() {
let err = access(
"https://gitlab.com/fabro-sh/fabro",
&["fabro-sh/keystone"],
contents_read(),
)
.unwrap_err();
assert!(err.to_string().contains("repository access"), "{err:#}");
}
#[test]
fn rejects_primary_duplication_regardless_of_url_spelling_or_case() {
let origins = [
"https://github.com/Fabro-SH/Fabro.git",
"git@github.com:fabro-sh/fabro.git",
"ssh://git@github.com/fabro-sh/fabro",
];
for origin in origins {
let err = access(origin, &["fabro-sh/FABRO"], contents_read()).unwrap_err();
assert!(
err.to_string().contains("must not repeat the run origin"),
"{origin}: {err:#}"
);
}
}
#[test]
fn rejects_an_additional_repository_with_a_different_owner() {
let err = access(
"https://github.com/fabro-sh/fabro",
&["lithoscomputer/conveyor"],
contents_read(),
)
.unwrap_err();
let message = err.to_string();
assert!(message.contains("lithoscomputer/conveyor"), "{message}");
assert!(message.contains("share one owner"), "{message}");
}
#[test]
fn rejects_a_declared_set_without_a_contents_permission() {
let missing = access(
"https://github.com/fabro-sh/fabro",
&["fabro-sh/keystone"],
HashMap::new(),
)
.unwrap_err();
assert!(
missing.to_string().contains("`contents` permission"),
"{missing:#}"
);
let wrong_level = access(
"https://github.com/fabro-sh/fabro",
&["fabro-sh/keystone"],
HashMap::from([("contents".to_string(), "admin".to_string())]),
)
.unwrap_err();
assert!(
wrong_level.to_string().contains("got `admin`"),
"{wrong_level:#}"
);
}
#[test]
fn targets_retain_every_full_slug_exactly_once_primary_first() {
let access = access(
"git@github.com:fabro-sh/fabro.git",
&["fabro-sh/keystone", "fabro-sh/arc"],
contents_read(),
)
.unwrap()
.unwrap();
let targets: Vec<String> = access.targets().iter().map(ToString::to_string).collect();
assert_eq!(targets, vec![
"fabro-sh/fabro",
"fabro-sh/arc",
"fabro-sh/keystone",
]);
assert_eq!(access.repository_names(), vec!["fabro", "arc", "keystone"]);
assert_eq!(access.owner(), "fabro-sh");
assert!(access.has_additional_repositories());
}
#[test]
fn debug_output_contains_only_repositories_and_permissions() {
let access = access(
"https://github.com/fabro-sh/fabro",
&["fabro-sh/arc"],
contents_read(),
)
.unwrap()
.unwrap();
let rendered = format!("{access:?}");
assert!(rendered.contains("fabro-sh"), "{rendered}");
assert!(rendered.contains("contents"), "{rendered}");
// The value carries no token or key material by construction; its
// fields are exactly the repository slugs and the permission map.
assert!(!rendered.to_lowercase().contains("token"), "{rendered}");
assert!(!rendered.to_lowercase().contains("key"), "{rendered}");
}
#[tokio::test]
async fn resolve_shared_installation_names_the_invisible_repository() {
use crate::HttpMethod;
use crate::tests_mock::{MockHttpClient, test_rsa_key};
let mock = MockHttpClient::new()
.on(
HttpMethod::Get,
"/repos/fabro-sh/fabro/installation",
200,
r#"{"id": 7}"#,
)
.on(
HttpMethod::Get,
"/repos/fabro-sh/keystone/installation",
404,
"{}",
);
let creds = GitHubAppCredentials {
app_id: "test".to_string(),
private_key_pem: test_rsa_key().to_string(),
slug: None,
};
let access = access(
"https://github.com/fabro-sh/fabro",
&["fabro-sh/keystone"],
contents_read(),
)
.unwrap()
.unwrap();
let err = access
.resolve_shared_installation_with_jwt(
&mock,
&crate::sign_app_jwt(&creds.app_id, &creds.private_key_pem).unwrap(),
"",
)
.await
.unwrap_err();
let message = err.to_string();
assert!(message.contains("fabro-sh/keystone"), "{message}");
assert!(message.contains("cannot see"), "{message}");
}
#[tokio::test]
async fn resolve_shared_installation_requires_one_installation_id() {
use crate::HttpMethod;
use crate::tests_mock::{MockHttpClient, test_rsa_key};
let mock = MockHttpClient::new()
.on(
HttpMethod::Get,
"/repos/fabro-sh/fabro/installation",
200,
r#"{"id": 7}"#,
)
.on(
HttpMethod::Get,
"/repos/fabro-sh/keystone/installation",
200,
r#"{"id": 8}"#,
);
let creds = GitHubAppCredentials {
app_id: "test".to_string(),
private_key_pem: test_rsa_key().to_string(),
slug: None,
};
let access = access(
"https://github.com/fabro-sh/fabro",
&["fabro-sh/keystone"],
contents_read(),
)
.unwrap()
.unwrap();
let err = access
.resolve_shared_installation_with_jwt(
&mock,
&crate::sign_app_jwt(&creds.app_id, &creds.private_key_pem).unwrap(),
"",
)
.await
.unwrap_err();
let message = err.to_string();
assert!(message.contains("installation 8"), "{message}");
assert!(message.contains("fabro-sh/keystone"), "{message}");
}
#[tokio::test]
async fn resolve_shared_installation_returns_the_shared_id() {
use crate::HttpMethod;
use crate::tests_mock::{MockHttpClient, test_rsa_key};
let mock = MockHttpClient::new()
.on(
HttpMethod::Get,
"/repos/fabro-sh/fabro/installation",
200,
r#"{"id": 7}"#,
)
.on(
HttpMethod::Get,
"/repos/fabro-sh/keystone/installation",
200,
r#"{"id": 7}"#,
);
let creds = GitHubAppCredentials {
app_id: "test".to_string(),
private_key_pem: test_rsa_key().to_string(),
slug: None,
};
let access = access(
"https://github.com/fabro-sh/fabro",
&["fabro-sh/keystone"],
contents_read(),
)
.unwrap()
.unwrap();
let id = access
.resolve_shared_installation_with_jwt(
&mock,
&crate::sign_app_jwt(&creds.app_id, &creds.private_key_pem).unwrap(),
"",
)
.await
.unwrap();
assert_eq!(id, 7);
}
#[tokio::test]
async fn access_mint_reuses_the_resolved_installation_id() {
use crate::HttpMethod;
use crate::tests_mock::{MockHttpClient, test_rsa_key};
let mock = MockHttpClient::new()
.on(
HttpMethod::Get,
"/repos/fabro-sh/fabro/installation",
200,
r#"{"id": 7}"#,
)
.on(
HttpMethod::Get,
"/repos/fabro-sh/keystone/installation",
200,
r#"{"id": 7}"#,
)
.on(
HttpMethod::Post,
"/app/installations/7/access_tokens",
201,
r#"{"token":"scoped","expires_at":"2099-01-01T00:00:00Z"}"#,
)
.with_req_body(
r#"{"repositories":["fabro","keystone"],"permissions":{"contents":"read"}}"#,
);
let creds = GitHubAppCredentials {
app_id: "test".to_string(),
private_key_pem: test_rsa_key().to_string(),
slug: None,
};
let access = access(
"https://github.com/fabro-sh/fabro",
&["fabro-sh/keystone"],
contents_read(),
)
.unwrap()
.unwrap();
let token = access
.mint_installation_token(&creds, &mock, "")
.await
.unwrap();
assert_eq!(token.token, "scoped");
assert_eq!(
mock.request_count(),
3,
"each repository should be looked up once before the mint"
);
}
}

View file

@ -9,13 +9,45 @@ use fabro_types::settings::run::MergeStrategy;
use serde::Deserialize;
use tokio::process::Command;
pub mod access;
pub mod token_source;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
#[cfg(test)]
pub(crate) mod tests_mock;
pub use access::GitHubRepositoryAccess;
pub const GITHUB_API_BASE_URL: &str = "https://api.github.com";
/// Git config key that routes github.com HTTPS credentials through
/// [`GITHUB_CREDENTIAL_HELPER`].
pub const GITHUB_CREDENTIAL_HELPER_KEY: &str = "credential.https://github.com.helper";
/// Secret-free git credential helper: reads `$GITHUB_TOKEN` from the
/// invoking git process's environment at invocation time, so the token never
/// lands in git configuration, argv, or rendered errors. Non-`get`
/// operations (`store`, `erase`) are ignored. The one definition shared by
/// the runtime git bridge, server preflight probes, and the live contract
/// test, so the probes always exercise exactly what the bridge configures.
pub const GITHUB_CREDENTIAL_HELPER: &str = r#"!f() { if [ "$1" = get ]; then echo username=x-access-token; echo "password=$GITHUB_TOKEN"; fi; }; f"#;
/// Configure a `git` invocation to authenticate to github.com through
/// [`GITHUB_CREDENTIAL_HELPER`] with `token`, isolated from user/system git
/// configuration and terminal prompts. The token is passed only through the
/// child process environment.
pub fn apply_probe_git_env(command: &mut Command, token: &str) {
command
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env(EnvVars::GITHUB_TOKEN, token)
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", GITHUB_CREDENTIAL_HELPER_KEY)
.env("GIT_CONFIG_VALUE_0", GITHUB_CREDENTIAL_HELPER);
}
/// Returns the GitHub API base URL, allowing override via `GITHUB_BASE_URL` env
/// var.
#[expect(
@ -151,6 +183,29 @@ impl GitHubAppCredentials {
base_url: &str,
permissions: serde_json::Value,
install_url: Option<&str>,
) -> anyhow::Result<InstallationToken> {
self.mint_installation_token_for_repositories(
client,
owner,
&[repo.to_string()],
base_url,
permissions,
install_url,
)
.await
}
/// Mint one installation token scoped to every repository in
/// `repository_names` (names within `owner`'s installation, primary
/// first) with the shared `permissions`.
pub async fn mint_installation_token_for_repositories(
&self,
client: &impl HttpClient,
owner: &str,
repository_names: &[String],
base_url: &str,
permissions: serde_json::Value,
install_url: Option<&str>,
) -> anyhow::Result<InstallationToken> {
let jwt = sign_app_jwt(&self.app_id, &self.private_key_pem)?;
let default_install_url = self.installation_url(owner);
@ -159,7 +214,7 @@ impl GitHubAppCredentials {
client,
&jwt,
owner,
repo,
repository_names,
base_url,
permissions,
install_url,
@ -475,47 +530,88 @@ pub async fn create_installation_access_token_with_permissions_and_install_url(
permissions: serde_json::Value,
install_url: Option<&str>,
) -> anyhow::Result<String> {
mint_installation_token_with_jwt(client, jwt, owner, repo, base_url, permissions, install_url)
.await
.map(|token| token.token)
mint_installation_token_with_jwt(
client,
jwt,
owner,
&[repo.to_string()],
base_url,
permissions,
install_url,
)
.await
.map(|token| token.token)
}
/// Outcome of one GitHub App installation lookup
/// (`GET /repos/{owner}/{repo}/installation`).
///
/// Status interpretation stays with callers because their user guidance
/// differs: the mint path speaks about the owner's App installation, the
/// multi-repository resolution names the specific repository.
pub(crate) enum InstallationLookup {
Found(u64),
/// 404: the App cannot see the repository (or is not installed at all).
NotFound,
/// Any other non-200 status.
Failed(u16),
}
/// Look up the App installation covering `owner/repo` and parse its id.
/// Transport and parse failures carry no caller-specific context; callers
/// attach their own.
pub(crate) async fn lookup_installation(
client: &impl HttpClient,
jwt: &str,
base_url: &str,
owner: &str,
repo: &str,
) -> anyhow::Result<InstallationLookup> {
#[derive(Deserialize)]
struct Installation {
id: u64,
}
let endpoint = format!("{base_url}/repos/{owner}/{repo}/installation");
let auth = format!("Bearer {jwt}");
let resp = client
.request(HttpMethod::Get, &endpoint, &github_headers(&auth), None)
.await?;
match resp.status {
200 => {
let installation: Installation = resp
.json()
.context("Failed to parse installation response")?;
Ok(InstallationLookup::Found(installation.id))
}
404 => Ok(InstallationLookup::NotFound),
status => Ok(InstallationLookup::Failed(status)),
}
}
async fn mint_installation_token_with_jwt(
client: &impl HttpClient,
jwt: &str,
owner: &str,
repo: &str,
repos: &[String],
base_url: &str,
permissions: serde_json::Value,
install_url: Option<&str>,
) -> anyhow::Result<InstallationToken> {
#[derive(Deserialize)]
struct Installation {
id: u64,
}
let Some(primary_repo) = repos.first() else {
bail!("installation token mint requires at least one repository");
};
#[derive(Deserialize)]
struct AccessToken {
token: String,
expires_at: DateTime<Utc>,
}
// Step 1: Find the installation for this repo
let installation_endpoint = format!("{base_url}/repos/{owner}/{repo}/installation");
let auth = format!("Bearer {jwt}");
let resp = client
.request(
HttpMethod::Get,
&installation_endpoint,
&github_headers(&auth),
None,
)
// Step 1: Find the installation via the primary repository. Multi-
// repository callers resolve every repository's installation up front
// (`GitHubRepositoryAccess::resolve_shared_installation`), so the
// primary stands for the whole set here.
let installation_id = match lookup_installation(client, jwt, base_url, owner, primary_repo)
.await
.context("Failed to look up GitHub App installation")?;
match resp.status {
200 => {}
404 => {
.context("Failed to look up GitHub App installation")?
{
InstallationLookup::Found(id) => id,
InstallationLookup::NotFound => {
let install_url = install_url.map_or_else(
|| format!("https://github.com/organizations/{owner}/settings/installations"),
str::to_string,
@ -525,37 +621,54 @@ async fn mint_installation_token_with_jwt(
Install it at {install_url}"
);
}
403 => {
InstallationLookup::Failed(403) => {
bail!(
"GitHub App installation is suspended. \
Re-enable it in your organization's GitHub App settings."
);
}
401 => {
InstallationLookup::Failed(401) => {
bail!(
"GitHub App authentication failed. \
Check that app_id and GITHUB_APP_PRIVATE_KEY are correct."
);
}
_ => {
bail!(
"Unexpected status {} looking up GitHub App installation",
resp.status
);
InstallationLookup::Failed(status) => {
bail!("Unexpected status {status} looking up GitHub App installation");
}
};
mint_installation_token_for_id_with_jwt(
client,
jwt,
installation_id,
repos,
base_url,
permissions,
)
.await
}
/// Create a repository-scoped token for an installation already resolved by
/// the caller.
pub(crate) async fn mint_installation_token_for_id_with_jwt(
client: &impl HttpClient,
jwt: &str,
installation_id: u64,
repos: &[String],
base_url: &str,
permissions: serde_json::Value,
) -> anyhow::Result<InstallationToken> {
#[derive(Deserialize)]
struct AccessToken {
token: String,
expires_at: DateTime<Utc>,
}
let installation: Installation = resp
.json()
.context("Failed to parse installation response")?;
// Step 2: Create a scoped access token
let token_url = format!(
"{base_url}/app/installations/{}/access_tokens",
installation.id
);
let auth = format!("Bearer {jwt}");
let token_url = format!("{base_url}/app/installations/{installation_id}/access_tokens");
let body = serde_json::json!({
"repositories": [repo],
"repositories": repos,
"permissions": permissions,
});
@ -573,8 +686,9 @@ async fn mint_installation_token_with_jwt(
201 => {}
422 => {
bail!(
"GitHub App does not have access to repository {repo}. \
Update the installation's repository permissions to include it."
"GitHub App does not have access to every requested repository ({}). \
Update the installation's repository permissions to include them.",
repos.join(", ")
);
}
401 => {
@ -1065,25 +1179,24 @@ pub async fn check_app_installed(
repo: &str,
base_url: &str,
) -> anyhow::Result<bool> {
let url = format!("{base_url}/repos/{owner}/{repo}/installation");
let auth = format!("Bearer {jwt}");
let resp = client
.request(HttpMethod::Get, &url, &github_headers(&auth), None)
let lookup = lookup_installation(client, jwt, base_url, owner, repo)
.await
.context("Failed to check GitHub App installation")?;
match resp.status {
200 => Ok(true),
404 => Ok(false),
401 => bail!(
match lookup {
InstallationLookup::Found(_) => Ok(true),
InstallationLookup::NotFound => Ok(false),
InstallationLookup::Failed(401) => bail!(
"GitHub App authentication failed. \
Check that app_id and GITHUB_APP_PRIVATE_KEY are correct."
),
403 => bail!(
InstallationLookup::Failed(403) => bail!(
"GitHub App installation is suspended. \
Re-enable it in your organization's GitHub App settings."
),
status => bail!("Unexpected status {status} checking GitHub App installation"),
InstallationLookup::Failed(status) => {
bail!("Unexpected status {status} checking GitHub App installation")
}
}
}
@ -1715,7 +1828,7 @@ mod tests {
// -----------------------------------------------------------------------
fn test_rsa_key() -> &'static str {
include_str!("testdata/rsa_private.pem")
tests_mock::test_rsa_key()
}
#[test]
@ -1769,89 +1882,7 @@ mod tests {
// MockHttpClient
// -----------------------------------------------------------------------
struct MockRoute {
method: HttpMethod,
path: String,
status: u16,
response_body: String,
assert_header: Option<(String, MockHeaderCheck)>,
assert_body_json: Option<serde_json::Value>,
}
enum MockHeaderCheck {
Equals(String),
}
struct MockHttpClient {
routes: Vec<MockRoute>,
}
impl MockHttpClient {
fn new() -> Self {
Self { routes: vec![] }
}
fn on(mut self, method: HttpMethod, path: &str, status: u16, body: &str) -> Self {
self.routes.push(MockRoute {
method,
path: path.to_string(),
status,
response_body: body.to_string(),
assert_header: None,
assert_body_json: None,
});
self
}
fn with_req_header(mut self, name: &str, value: &str) -> Self {
self.routes.last_mut().unwrap().assert_header =
Some((name.to_string(), MockHeaderCheck::Equals(value.to_string())));
self
}
fn with_req_body(mut self, json_str: &str) -> Self {
self.routes.last_mut().unwrap().assert_body_json =
Some(serde_json::from_str(json_str).unwrap());
self
}
}
impl HttpClient for MockHttpClient {
async fn request(
&self,
method: HttpMethod,
url: &str,
headers: &[(&str, &str)],
body: Option<&serde_json::Value>,
) -> anyhow::Result<HttpResponse> {
for route in &self.routes {
if method == route.method && url.ends_with(&route.path) {
if let Some((name, MockHeaderCheck::Equals(expected))) = &route.assert_header {
let (_, v) = headers
.iter()
.find(|(k, _)| *k == name.as_str())
.unwrap_or_else(|| {
panic!("Expected header '{name}' not found in request to {url}")
});
assert_eq!(*v, expected.as_str(), "Header '{name}' mismatch for {url}");
}
if let Some(expected_body) = &route.assert_body_json {
let actual = body.expect("Expected request body");
assert_eq!(actual, expected_body, "Request body mismatch for {url}");
}
return Ok(HttpResponse::new(route.status, route.response_body.clone()));
}
}
panic!(
"No mock route for {:?} {url}\nRegistered routes: {:?}",
method,
self.routes
.iter()
.map(|r| format!("{:?} {}", r.method, r.path))
.collect::<Vec<_>>()
);
}
}
use crate::tests_mock::{self, MockHttpClient};
// -----------------------------------------------------------------------
// create_installation_access_token — success
@ -1901,6 +1932,62 @@ mod tests {
);
}
/// The multi-repository mint sends one request listing every projected
/// repository name exactly once, primary first, with the shared
/// permissions; the installation lookup uses the primary repository.
#[tokio::test]
async fn multi_repository_mint_lists_every_repository_name_once() {
let access = GitHubRepositoryAccess::new(
Some("git@github.com:owner/repo.git"),
&[
"owner/keystone".parse().unwrap(),
"owner/arc".parse().unwrap(),
]
.into_iter()
.collect(),
std::collections::HashMap::from([("contents".to_string(), "read".to_string())]),
)
.unwrap()
.expect("origin should produce an access value");
let mock = MockHttpClient::new()
.on(
HttpMethod::Get,
"/repos/owner/repo/installation",
200,
r#"{"id": 123}"#,
)
.on(
HttpMethod::Post,
"/app/installations/123/access_tokens",
201,
r#"{"token": "ghs_multi", "expires_at": "2026-01-01T12:00:00Z"}"#,
)
.with_req_body(
r#"{"permissions":{"contents":"read"},"repositories":["repo","arc","keystone"]}"#,
);
let creds = GitHubAppCredentials {
app_id: "test".to_string(),
private_key_pem: test_rsa_key().to_string(),
slug: None,
};
let token = creds
.mint_installation_token_for_repositories(
&mock,
access.owner(),
&access.repository_names(),
"",
access.permissions_json().unwrap(),
None,
)
.await
.unwrap();
assert_eq!(token.token, "ghs_multi");
}
#[tokio::test]
async fn create_iat_requests_only_contents_write() {
let mock = MockHttpClient::new()

View file

@ -0,0 +1,104 @@
//! Crate-internal test doubles shared by the `lib.rs` and `access` test
//! modules: a scripted [`HttpClient`] and a throwaway RSA key for JWT
//! signing.
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::{HttpClient, HttpMethod, HttpResponse};
pub(crate) fn test_rsa_key() -> &'static str {
include_str!("testdata/rsa_private.pem")
}
pub(crate) struct MockRoute {
method: HttpMethod,
path: String,
status: u16,
response_body: String,
assert_header: Option<(String, MockHeaderCheck)>,
assert_body_json: Option<serde_json::Value>,
}
pub(crate) enum MockHeaderCheck {
Equals(String),
}
pub(crate) struct MockHttpClient {
routes: Vec<MockRoute>,
request_count: AtomicUsize,
}
impl MockHttpClient {
pub(crate) fn new() -> Self {
Self {
routes: vec![],
request_count: AtomicUsize::new(0),
}
}
pub(crate) fn on(mut self, method: HttpMethod, path: &str, status: u16, body: &str) -> Self {
self.routes.push(MockRoute {
method,
path: path.to_string(),
status,
response_body: body.to_string(),
assert_header: None,
assert_body_json: None,
});
self
}
pub(crate) fn with_req_header(mut self, name: &str, value: &str) -> Self {
self.routes.last_mut().unwrap().assert_header =
Some((name.to_string(), MockHeaderCheck::Equals(value.to_string())));
self
}
pub(crate) fn with_req_body(mut self, json_str: &str) -> Self {
self.routes.last_mut().unwrap().assert_body_json =
Some(serde_json::from_str(json_str).unwrap());
self
}
pub(crate) fn request_count(&self) -> usize {
self.request_count.load(Ordering::SeqCst)
}
}
impl HttpClient for MockHttpClient {
async fn request(
&self,
method: HttpMethod,
url: &str,
headers: &[(&str, &str)],
body: Option<&serde_json::Value>,
) -> anyhow::Result<HttpResponse> {
self.request_count.fetch_add(1, Ordering::SeqCst);
for route in &self.routes {
if method == route.method && url.ends_with(&route.path) {
if let Some((name, MockHeaderCheck::Equals(expected))) = &route.assert_header {
let (_, v) = headers
.iter()
.find(|(k, _)| *k == name.as_str())
.unwrap_or_else(|| {
panic!("Expected header '{name}' not found in request to {url}")
});
assert_eq!(*v, expected.as_str(), "Header '{name}' mismatch for {url}");
}
if let Some(expected_body) = &route.assert_body_json {
let actual = body.expect("Expected request body");
assert_eq!(actual, expected_body, "Request body mismatch for {url}");
}
return Ok(HttpResponse::new(route.status, route.response_body.clone()));
}
}
panic!(
"No mock route for {:?} {url}\nRegistered routes: {:?}",
method,
self.routes
.iter()
.map(|r| format!("{:?} {}", r.method, r.path))
.collect::<Vec<_>>()
);
}
}

View file

@ -15,7 +15,7 @@ use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context as _;
use anyhow::{Context as _, bail};
use chrono::{DateTime, Utc};
use tokio::sync::Mutex;
@ -146,29 +146,54 @@ pub(crate) trait InstallationTokenMinter: Send + Sync {
async fn mint(&self) -> anyhow::Result<InstallationToken>;
}
/// Repository scope an App-backed source owns.
enum AppTokenScope {
/// A single-repository source that resolves its installation during each
/// mint.
Repository {
owner: String,
repo: String,
permissions: serde_json::Value,
},
/// A validated declared set. Each mint resolves every target to one App
/// installation before creating the token.
Access(crate::GitHubRepositoryAccess),
}
/// Real minter backed by GitHub App credentials.
struct AppTokenMinter {
creds: GitHubAppCredentials,
http: fabro_http::HttpClient,
owner: String,
repo: String,
base_url: String,
permissions: serde_json::Value,
creds: GitHubAppCredentials,
http: fabro_http::HttpClient,
base_url: String,
scope: AppTokenScope,
}
#[async_trait::async_trait]
impl InstallationTokenMinter for AppTokenMinter {
async fn mint(&self) -> anyhow::Result<InstallationToken> {
self.creds
.mint_installation_token(
&self.http,
&self.owner,
&self.repo,
&self.base_url,
self.permissions.clone(),
None,
)
.await
match &self.scope {
AppTokenScope::Repository {
owner,
repo,
permissions,
} => {
self.creds
.mint_installation_token(
&self.http,
owner,
repo,
&self.base_url,
permissions.clone(),
None,
)
.await
}
AppTokenScope::Access(access) => {
access
.mint_installation_token(&self.creds, &self.http, &self.base_url)
.await
}
}
}
}
@ -220,6 +245,17 @@ pub struct InstallationTokenSource {
state: SourceState,
}
fn repository_set_display(owner: &str, repos: &[String]) -> anyhow::Result<String> {
match repos {
[primary] => Ok(format!("{owner}/{primary}")),
[primary, additional @ ..] => Ok(format!(
"{owner}/{primary} (+{} additional)",
additional.len()
)),
[] => bail!("token source requires at least one repository"),
}
}
impl InstallationTokenSource {
/// Build a source for `creds` against the repository in `origin_url`.
///
@ -244,6 +280,32 @@ impl InstallationTokenSource {
permissions: serde_json::Value,
) -> anyhow::Result<Arc<Self>> {
let repo_display = format!("{owner}/{repo}");
Self::with_app_scope(creds, repo_display, AppTokenScope::Repository {
owner,
repo,
permissions,
})
}
/// Build a source for a validated effective repository set. Minted
/// tokens are scoped to every repository in the set with the shared
/// permissions. App-backed sources also resolve every repository to one
/// shared installation before each mint. Caching, refresh margin, and
/// single-flight behavior are identical to the single-repository source.
pub fn for_access(
creds: &GitHubCredentials,
access: &crate::GitHubRepositoryAccess,
) -> anyhow::Result<Arc<Self>> {
let repository_names = access.repository_names();
let repo_display = repository_set_display(access.owner(), &repository_names)?;
Self::with_app_scope(creds, repo_display, AppTokenScope::Access(access.clone()))
}
fn with_app_scope(
creds: &GitHubCredentials,
repo_display: String,
scope: AppTokenScope,
) -> anyhow::Result<Arc<Self>> {
let state = match creds {
GitHubCredentials::Pat(token) => SourceState::Pat(SecretString::new(token.clone())),
GitHubCredentials::Installation(token) => SourceState::Installation(token.clone()),
@ -255,10 +317,8 @@ impl InstallationTokenSource {
minter: Box::new(AppTokenMinter {
creds: app.clone(),
http,
owner,
repo,
base_url: crate::github_api_base_url(),
permissions,
scope,
}),
cache: Mutex::new(None),
}
@ -502,6 +562,31 @@ mod tests {
assert!(!source.mints_installation_tokens());
}
/// A source built from a validated multi-repository access value uses the
/// same state machine as the single-repository constructor: static
/// credentials pass through, and App credentials share the cache
/// machinery exercised by the `with_minter` tests below.
#[tokio::test]
async fn for_access_source_resolves_like_the_single_repository_source() {
let access = crate::GitHubRepositoryAccess::new(
Some("https://github.com/owner/repo.git"),
&["owner/keystone".parse().unwrap()].into_iter().collect(),
std::collections::HashMap::from([("contents".to_string(), "read".to_string())]),
)
.unwrap()
.expect("origin should produce an access value");
let source = InstallationTokenSource::for_access(
&GitHubCredentials::Pat("ghp_pat".to_string()),
&access,
)
.unwrap();
let resolved = source.resolve().await.unwrap();
assert_eq!(resolved.token.expose(), "ghp_pat");
assert!(resolved.snapshot.is_static());
}
#[tokio::test]
async fn static_installation_token_resolves_until_expiry() {
let valid = InstallationTokenSource::for_origin(

View file

@ -0,0 +1,122 @@
//! Opt-in live GitHub App test for additional-repository access.
//!
//! Verifies against the real GitHub API that one installation token scoped
//! to the primary repository plus one declared additional repository grants
//! Git read access to both. Runs only in live mode with these variables set
//! (it skips clearly otherwise):
//!
//! - `FABRO_TEST_GITHUB_APP_ID` — GitHub App id
//! - `GITHUB_APP_PRIVATE_KEY` — App private key (PEM, or base64-encoded PEM)
//! - `FABRO_TEST_GITHUB_ORIGIN` — HTTPS origin URL of the primary repository
//! - `FABRO_TEST_GITHUB_ADDITIONAL_REPO` — an `owner/repository` slug the
//! installation can see, ideally private, sharing the origin's owner
//!
//! The repositories come from the environment so no private slug is baked
//! into durable test output, and the minted token is only ever passed to
//! `git` through the child process environment.
use std::collections::BTreeSet;
use std::process::Stdio;
use std::time::Duration;
use fabro_github::token_source::InstallationTokenSource;
use fabro_github::{GitHubAppCredentials, GitHubCredentials, GitHubRepositoryAccess};
use fabro_types::GitHubRepositorySlug;
use tokio::process::Command;
use tokio::time::sleep;
fn env_var(name: &str) -> String {
#[expect(
clippy::disallowed_methods,
reason = "live e2e configuration comes from the process environment by design"
)]
std::env::var(name).unwrap_or_else(|_| panic!("{name} must be set for this live test"))
}
async fn ls_remote_with_token(slug: &GitHubRepositorySlug, token: &str) -> bool {
let url = slug.https_url();
let mut command = Command::new("git");
fabro_github::apply_probe_git_env(&mut command, token);
let output = command
.args(["ls-remote", &url, "HEAD"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.output()
.await
.expect("git should run");
output.status.success()
}
#[fabro_macros::e2e_test(
live("FABRO_TEST_GITHUB_APP_ID"),
live("GITHUB_APP_PRIVATE_KEY"),
live("FABRO_TEST_GITHUB_ORIGIN"),
live("FABRO_TEST_GITHUB_ADDITIONAL_REPO")
)]
async fn scoped_token_reaches_the_declared_additional_repository() {
let app_id = env_var("FABRO_TEST_GITHUB_APP_ID");
let app = GitHubAppCredentials::from_env(Some(&app_id))
.expect("GITHUB_APP_PRIVATE_KEY should decode as PEM or base64 PEM")
.expect("GITHUB_APP_PRIVATE_KEY must be set for this live test");
let origin = env_var("FABRO_TEST_GITHUB_ORIGIN");
let additional: GitHubRepositorySlug = env_var("FABRO_TEST_GITHUB_ADDITIONAL_REPO")
.parse()
.expect("FABRO_TEST_GITHUB_ADDITIONAL_REPO must be an owner/repository slug");
let additional_set: BTreeSet<GitHubRepositorySlug> = [additional.clone()].into_iter().collect();
let access = GitHubRepositoryAccess::new(
Some(&origin),
&additional_set,
std::collections::HashMap::from([("contents".to_string(), "read".to_string())]),
)
.expect("access request should validate")
.expect("origin should produce an access value");
// The production choreography: every target resolves to one shared
// installation, then one mint scoped to the whole effective set.
let creds = GitHubCredentials::App(app.clone());
let source =
InstallationTokenSource::for_access(&creds, &access).expect("token source should build");
let resolved = source.resolve().await.expect("scoped mint should succeed");
let token = resolved.token.expose();
// The one token reads both the primary and the additional repository.
// A freshly minted token can hit GitHub's replication lag, so retry a
// few times with the same token before failing.
for slug in access.targets() {
let mut reachable = false;
for _ in 0..3 {
if ls_remote_with_token(slug, token).await {
reachable = true;
break;
}
sleep(Duration::from_secs(2)).await;
}
assert!(
reachable,
"scoped token should read every declared repository"
);
}
// Negative scope check: a token minted for the primary alone must not
// read the additional repository (proves server-side scoping, not just
// possession of a token).
let primary_only = GitHubRepositoryAccess::new(
Some(&origin),
&BTreeSet::new(),
std::collections::HashMap::from([("contents".to_string(), "read".to_string())]),
)
.expect("primary-only access should validate")
.expect("origin should produce an access value");
let narrow_source =
InstallationTokenSource::for_access(&GitHubCredentials::App(app), &primary_only)
.expect("primary-only token source should build");
let narrow = narrow_source
.resolve()
.await
.expect("primary-only mint should succeed");
assert!(
!ls_remote_with_token(&additional, narrow.token.expose()).await,
"a primary-only token must not read the additional repository"
);
}

View file

@ -269,91 +269,19 @@ mod tests {
.unwrap_or_else(|error| panic!("built-in model '{selector}' should resolve: {error}"))
}
/// One row of the route-equivalence table: model id plus the
/// `(deployment_id, transport, codec, billing_policy, agent_profile)`
/// tuple it must resolve to.
type RouteRow = (
&'static str,
&'static str,
AdapterKind,
CodecKind,
BillingPolicy,
AgentProfileKind,
);
/// The compat mapping as an executable table: every built-in catalog
/// model resolves to exactly this tuple. Adding or rerouting a built-in
/// model means updating this table deliberately.
#[test]
fn builtin_catalog_route_equivalence_table() {
use AdapterKind as T;
use AgentProfileKind as P;
use BillingPolicy as B;
use CodecKind as C;
#[rustfmt::skip]
let expected: &[RouteRow] = &[
// model id deployment_id transport codec billing profile
("claude-fable-5", "claude-fable-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Claude5),
("claude-haiku-4-5", "claude-haiku-4-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-opus-4-6", "claude-opus-4-6", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-opus-4-7", "claude-opus-4-7", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-opus-4-8", "claude-opus-4-8", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-opus-5", "claude-opus-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Claude5),
("claude-sonnet-4-5", "claude-sonnet-4-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-sonnet-4-6", "claude-sonnet-4-6", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Anthropic),
("claude-sonnet-5", "claude-sonnet-5", T::Anthropic, C::AnthropicMessages, B::Anthropic, P::Claude5),
("deepseek-v4-flash", "deepseek-v4-flash", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi),
("deepseek-v4-pro", "deepseek-v4-pro", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi),
("gemini-3-flash-preview", "gemini-3-flash-preview", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
("gemini-3.1-flash-lite", "gemini-3.1-flash-lite", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
("gemini-3.1-pro-preview", "gemini-3.1-pro-preview", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
("gemini-3.1-pro-preview-customtools", "gemini-3.1-pro-preview-customtools", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
("gemini-3.5-flash", "gemini-3.5-flash", T::Gemini, C::GeminiGenerate, B::Gemini, P::Gemini),
("glm-4.7", "glm-4.7", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi),
("glm-5.2", "glm-5.2", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi),
("gpt-5.4", "gpt-5.4", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::OpenAi),
("gpt-5.4-mini", "gpt-5.4-mini", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::OpenAi),
("gpt-5.4-pro", "gpt-5.4-pro", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::OpenAi),
("gpt-5.5", "gpt-5.5", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::OpenAi),
("gpt-5.5-pro", "gpt-5.5-pro", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::OpenAi),
("gpt-5.6-luna", "gpt-5.6-luna", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::Gpt56),
("gpt-5.6-sol", "gpt-5.6-sol", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::Gpt56),
("gpt-5.6-terra", "gpt-5.6-terra", T::OpenAi, C::OpenAiResponses, B::OpenAi, P::Gpt56),
("kimi-k2.5", "kimi-k2.5", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::Kimi),
("kimi-k3", "kimi-k3", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::Kimi),
("laguna-s-2.1", "poolside/laguna-s-2.1", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi),
("laguna-xs-2.1", "poolside/laguna-xs-2.1", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi),
("mercury-2", "mercury-2", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi),
("minimax-m2.5", "minimax-m2.5", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi),
("venice-uncensored-1-2", "venice-uncensored-1-2", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi),
("venice-uncensored-role-play", "venice-uncensored-role-play", T::OpenAiCompatible, C::OpenAiCompatible, B::OpenAi, P::OpenAi),
];
fn every_builtin_catalog_offering_resolves() {
let catalog = Catalog::builtin();
let mut model_ids: Vec<&str> = catalog
.list(None)
.iter()
.map(|model| model.id.as_str())
.collect();
model_ids.sort_unstable();
let mut expected_ids: Vec<&str> = expected.iter().map(|row| row.0).collect();
expected_ids.sort_unstable();
assert_eq!(
model_ids, expected_ids,
"route-equivalence table must cover every built-in model row"
);
for (model_id, deployment_id, transport, codec, billing_policy, agent_profile) in expected {
let model = select_from_all(catalog, model_id);
let route = resolve_route(catalog, model)
.unwrap_or_else(|| panic!("built-in model '{model_id}' should resolve"));
assert_eq!(route.deployment_id, *deployment_id, "{model_id}");
assert_eq!(route.transport, *transport, "{model_id}");
assert_eq!(route.codec, *codec, "{model_id}");
assert_eq!(route.billing_policy, *billing_policy, "{model_id}");
assert_eq!(route.agent_profile, *agent_profile, "{model_id}");
for model in catalog.list(None) {
let route = resolve_route(catalog, model).unwrap_or_else(|| {
panic!(
"built-in offering '{}/{}' should resolve",
model.provider, model.id
)
});
assert_eq!(route.provider, model.provider);
assert!(!route.deployment_id.is_empty());
}
}

View file

@ -2037,17 +2037,20 @@ reasoning = false
client
}
/// Live-dispatch counterpart of the adapter_registry route-equivalence
/// table: for every built-in model, `resolve_provider` lands on the same
/// provider the resolved route names.
/// For every built-in model selector, live dispatch and catalog selection
/// choose the same provider from the same ready-provider set.
#[tokio::test]
async fn dispatch_agrees_with_resolve_route_for_every_builtin_model() {
let catalog = catalog_with("");
let client = client_with_all_catalog_providers(&catalog).await;
let ready_providers = catalog.all_provider_ids();
for model in catalog.list(None) {
let route = adapter_registry::resolve_route(&catalog, model)
.expect("built-in model should resolve to a route");
let selected = catalog
.select(model.id.as_str(), None, &ready_providers)
.expect("built-in model should be selectable");
let route = adapter_registry::resolve_route(&catalog, selected)
.expect("selected built-in model should resolve to a route");
let mut request = test_request();
request.model = model.id.to_string();

View file

@ -63,7 +63,9 @@ pub(super) fn decode_response(
let wire_usage = api_resp.usage.as_ref();
let usage = wire_usage.map_or_else(TokenCounts::default, ApiUsage::token_counts);
let cost_usd = wire_usage.and_then(|u| u.cost);
let cost_usd = wire_usage
.and_then(|usage| usage.cost)
.or_else(|| api_resp.cost.as_ref().and_then(|cost| cost.usd));
let cost_source = translate::authoritative_cost_source(cost_usd);
Ok(Response {

View file

@ -29,8 +29,8 @@ pub(super) struct StreamState {
/// True after `finish_events()` has run (guards against duplicates).
finished: bool,
rate_limit: Option<RateLimitInfo>,
/// In-band USD cost from the usage chunk (OpenRouter), surfaced as
/// authoritative on the final response.
/// In-band USD cost from the response, surfaced as authoritative on the
/// final response.
cost_usd: Option<f64>,
}
@ -72,9 +72,13 @@ impl StreamState {
// Capture usage if present (often in a dedicated chunk).
if let Some(usage) = &chunk.usage {
self.usage = usage.token_counts();
// Keep a previously seen cost when a later usage chunk omits it.
self.cost_usd = usage.cost.or(self.cost_usd);
}
let cost_usd = chunk
.usage
.as_ref()
.and_then(|usage| usage.cost)
.or_else(|| chunk.cost.as_ref().and_then(|cost| cost.usd));
self.cost_usd = cost_usd.or(self.cost_usd);
let choices = chunk.choices.as_mut()?;
let choice = choices.first_mut()?;

View file

@ -125,6 +125,12 @@ pub(super) struct ApiResponse {
pub model: String,
pub choices: Vec<ApiChoice>,
pub usage: Option<ApiUsage>,
pub cost: Option<ApiCost>,
}
#[derive(serde::Deserialize)]
pub(super) struct ApiCost {
pub usd: Option<f64>,
}
#[derive(serde::Deserialize)]
@ -377,6 +383,7 @@ pub(super) struct StreamChunk {
pub model: Option<String>,
pub choices: Option<Vec<StreamChoice>>,
pub usage: Option<ApiUsage>,
pub cost: Option<ApiCost>,
}
#[derive(serde::Deserialize)]

View file

@ -795,6 +795,31 @@ async fn decode_usage_openrouter_cost_and_cache_write() {
fabro_test::fabro_json_snapshot!(response);
}
/// Venice reports authoritative USD cost in a top-level object rather than
/// the OpenRouter `usage.cost` field.
#[tokio::test]
async fn decode_usage_venice_top_level_cost() {
let response = decode_response(serde_json::json!({
"id": "chatcmpl_venice_test",
"object": "chat.completion",
"created": CREATED_TS,
"model": MODEL,
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "ok"},
"finish_reason": "stop"
}],
"cost": {"usd": 0.00042, "diem": 0.0},
"usage": {
"prompt_tokens": 12,
"completion_tokens": 2,
"total_tokens": 14
}
}))
.await;
fabro_test::fabro_json_snapshot!(response);
}
// ---------------------------------------------------------------------------
// Stream
// ---------------------------------------------------------------------------
@ -841,6 +866,20 @@ async fn stream_usage_openrouter_cost() {
fabro_test::fabro_json_snapshot!(events);
}
/// Venice streams authoritative USD cost in a top-level object on the usage
/// chunk.
#[tokio::test]
async fn stream_usage_venice_top_level_cost() {
let sse = support::sse_data_transcript(&[
r#"{"id":"chatcmpl_venice_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},"finish_reason":null}]}"#,
r#"{"id":"chatcmpl_venice_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#,
r#"{"id":"chatcmpl_venice_stream","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[],"cost":{"usd":0.00031,"diem":0.0},"usage":{"prompt_tokens":12,"completion_tokens":2,"total_tokens":14}}"#,
"[DONE]",
]);
let (_capture, events) = stream_capture(&base_request(MODEL), &sse).await;
fabro_test::fabro_json_snapshot!(events);
}
#[tokio::test]
async fn stream_tool_call_deltas() {
let sse = support::sse_data_transcript(&[

View file

@ -0,0 +1,55 @@
---
source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs
expression: rendered
---
{
"id": "chatcmpl_venice_test",
"model": "test-model",
"provider": "openai-compatible",
"message": {
"role": "assistant",
"content": [
{
"kind": "text",
"data": "ok"
}
]
},
"finish_reason": "stop",
"usage": {
"input_tokens": 12,
"output_tokens": 2,
"reasoning_tokens": 0,
"cache_read_tokens": 0,
"cache_write_tokens": 0
},
"raw": {
"id": "chatcmpl_venice_test",
"object": "chat.completion",
"created": 1700000000,
"model": "test-model",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "ok"
},
"finish_reason": "stop"
}
],
"cost": {
"usd": 0.00042,
"diem": 0.0
},
"usage": {
"prompt_tokens": 12,
"completion_tokens": 2,
"total_tokens": 14
}
},
"warnings": [],
"rate_limit": null,
"cost_usd": 0.00042,
"cost_source": "authoritative"
}

View file

@ -0,0 +1,60 @@
---
source: lib/components/fabro-llm/tests/it/wire/openai_compatible.rs
expression: rendered
---
[
{
"type": "stream_start"
},
{
"type": "text_start",
"text_id": null
},
{
"type": "text_delta",
"delta": "Hi",
"text_id": null
},
{
"type": "text_end",
"text_id": null
},
{
"type": "finish",
"finish_reason": "stop",
"usage": {
"input_tokens": 12,
"output_tokens": 2,
"reasoning_tokens": 0,
"cache_read_tokens": 0,
"cache_write_tokens": 0
},
"response": {
"id": "chatcmpl_venice_stream",
"model": "test-model",
"provider": "openai-compatible",
"message": {
"role": "assistant",
"content": [
{
"kind": "text",
"data": "Hi"
}
]
},
"finish_reason": "stop",
"usage": {
"input_tokens": 12,
"output_tokens": 2,
"reasoning_tokens": 0,
"cache_read_tokens": 0,
"cache_write_tokens": 0
},
"raw": null,
"warnings": [],
"rate_limit": null,
"cost_usd": 0.00031,
"cost_source": "authoritative"
}
}
]

View file

@ -8,6 +8,7 @@ pub(crate) enum CloneDecision {
GitHub {
origin_url: String,
branch: Option<String>,
commit_sha: Option<String>,
},
}
@ -71,6 +72,98 @@ pub(crate) fn repo_symlink_command(layout: &GitHubRepoLayout) -> String {
)
}
pub(crate) fn exact_repository_init_command(clone_url: &str, checkout_path: &str) -> String {
format!(
"{git} init -- {path} && git -C {path} remote add origin {origin}",
git = sandbox::GIT,
path = sandbox::shell_quote(checkout_path),
origin = sandbox::shell_quote(clone_url),
)
}
/// Fetch a single admitted commit with the same history depth a branch clone
/// gets, so both paths can reach the same number of parent commits.
///
/// The fetch names the commit directly rather than the branch: reachability of
/// the commit from the admitted branch is an admission-time invariant, not
/// something this layer re-verifies.
pub(crate) fn exact_fetch_command(
checkout_path: &str,
fetch_source: &str,
commit_sha: &str,
depth: Option<usize>,
) -> String {
let depth_arg = depth_argument(depth);
format!(
"{git} -C {} fetch{depth_arg} --no-tags {} -- {}",
sandbox::shell_quote(checkout_path),
sandbox::shell_quote(fetch_source),
sandbox::shell_quote(commit_sha),
git = sandbox::GIT,
)
}
/// Leading-space ` --depth N` fragment for a Git command, or empty when
/// `depth` is `None` to fetch full history.
pub(crate) fn depth_argument(depth: Option<usize>) -> String {
depth.map_or_else(String::new, |depth| format!(" --depth {depth}"))
}
/// Point the admitted branch at `revision` and attach HEAD to it.
///
/// The checkout attaches to a real branch instead of detaching so callers that
/// read the current branch back out of the workspace still see the admitted
/// branch name.
pub(crate) fn exact_branch_checkout_command(
checkout_path: &str,
branch: &str,
revision: &str,
) -> String {
format!(
"{git} -C {path} checkout -B {branch} {revision}",
path = sandbox::shell_quote(checkout_path),
branch = sandbox::shell_quote(branch),
revision = sandbox::shell_quote(revision),
git = sandbox::GIT,
)
}
/// Print the current HEAD commit and nothing else, for [`verify_exact_head`].
pub(crate) fn exact_head_revision_command(checkout_path: &str) -> String {
format!(
"{git} -C {path} rev-parse HEAD",
path = sandbox::shell_quote(checkout_path),
git = sandbox::GIT,
)
}
/// Check out the admitted branch and print the resulting HEAD in one shell
/// command; stdout is the `rev-parse HEAD` output for [`verify_exact_head`].
pub(crate) fn exact_checkout_verify_command(
checkout_path: &str,
branch: &str,
revision: &str,
) -> String {
format!(
"{} && {}",
exact_branch_checkout_command(checkout_path, branch, revision),
exact_head_revision_command(checkout_path),
)
}
pub(crate) fn verify_exact_head(output: &str, expected_sha: &str) -> crate::Result<()> {
let actual_sha = output.trim();
let actual_sha = normalize_exact_commit_sha(actual_sha).map_err(|err| {
crate::Error::context("Exact checkout produced an invalid HEAD commit ID", err)
})?;
if actual_sha != expected_sha {
return Err(crate::Error::message(
"Exact checkout HEAD did not match the requested commit",
));
}
Ok(())
}
fn trim_root(root: &str) -> &str {
let trimmed = root.trim_end_matches('/');
if trimmed.is_empty() { "/" } else { trimmed }
@ -97,7 +190,33 @@ pub(crate) fn decide_clone(
skip_clone: bool,
clone_origin_url: Option<&str>,
clone_branch: Option<&str>,
clone_commit_sha: Option<&str>,
) -> crate::Result<CloneDecision> {
let commit_sha = clone_commit_sha
.map(normalize_exact_commit_sha)
.transpose()?;
if commit_sha.is_some() {
if skip_clone {
return Err(crate::Error::message(
"Exact commit checkout requires cloning to be enabled",
));
}
if clone_origin_url.is_none_or(|url| url.trim().is_empty()) {
return Err(crate::Error::message(
"Exact commit checkout requires a repository origin",
));
}
// The branch names the checkout the run works on; it is not used to
// constrain which commits may be fetched. Admission is responsible for
// proving the commit belongs to the branch before it reaches here.
if clone_branch.is_none_or(|branch| branch.trim().is_empty()) {
return Err(crate::Error::message(
"Exact commit checkout requires a repository branch",
));
}
}
if skip_clone {
return Ok(CloneDecision::EmptyWorkspace {
reason: EmptyWorkspaceReason::SkipClone,
@ -122,9 +241,19 @@ pub(crate) fn decide_clone(
branch: clone_branch
.filter(|branch| !branch.trim().is_empty())
.map(str::to_string),
commit_sha,
})
}
fn normalize_exact_commit_sha(commit_sha: &str) -> crate::Result<String> {
if commit_sha.len() != 40 || !commit_sha.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(crate::Error::message(
"Exact commit SHA must be exactly 40 ASCII hexadecimal characters",
));
}
Ok(commit_sha.to_ascii_lowercase())
}
pub(crate) fn clean_clone_origin_for_record(clone_origin_url: Option<&str>) -> Option<String> {
clone_origin_url
.filter(|url| !url.trim().is_empty())
@ -136,22 +265,73 @@ pub(crate) fn repo_cloned_for_record(
clone_origin_url: Option<&str>,
) -> Option<bool> {
Some(matches!(
decide_clone(skip_clone, clone_origin_url, None).ok()?,
decide_clone(skip_clone, clone_origin_url, None, None).ok()?,
CloneDecision::GitHub { .. }
))
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use std::process::{Command, Output};
use super::*;
fn isolated_command(command: &mut Command) -> Output {
command
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_AUTHOR_NAME", "Fabro Test")
.env("GIT_AUTHOR_EMAIL", "fabro-test@example.com")
.env("GIT_COMMITTER_NAME", "Fabro Test")
.env("GIT_COMMITTER_EMAIL", "fabro-test@example.com")
.output()
.expect("test command should start")
}
#[expect(
clippy::disallowed_methods,
reason = "hermetic Git proof intentionally runs the local git executable synchronously"
)]
fn run_git(cwd: &Path, args: &[&str]) -> String {
let output = isolated_command(Command::new("git").current_dir(cwd).args(args));
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).expect("git output should be UTF-8")
}
#[expect(
clippy::disallowed_methods,
reason = "hermetic command-builder proof intentionally runs local Bash synchronously"
)]
fn run_shell(cwd: &Path, command: &str) -> String {
let output = isolated_command(Command::new("/bin/bash").current_dir(cwd).args([
"--noprofile",
"--norc",
"-c",
command,
]));
assert!(
output.status.success(),
"command failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).expect("command output should be UTF-8")
}
#[test]
fn skip_clone_overrides_present_origin() {
assert_eq!(
decide_clone(
true,
Some("https://gitlab.com/acme/widgets.git"),
Some("main")
Some("main"),
None,
)
.unwrap(),
CloneDecision::EmptyWorkspace {
@ -163,7 +343,7 @@ mod tests {
#[test]
fn missing_origin_creates_empty_workspace() {
assert_eq!(
decide_clone(false, None, None).unwrap(),
decide_clone(false, None, None, None).unwrap(),
CloneDecision::EmptyWorkspace {
reason: EmptyWorkspaceReason::MissingOrigin,
}
@ -176,23 +356,257 @@ mod tests {
decide_clone(
false,
Some("git@github.com:acme/widgets.git"),
Some("feature/work")
Some("feature/work"),
None,
)
.unwrap(),
CloneDecision::GitHub {
origin_url: "https://github.com/acme/widgets".to_string(),
branch: Some("feature/work".to_string()),
commit_sha: None,
}
);
}
#[test]
fn non_github_origin_fails_without_skip_clone() {
let error = decide_clone(false, Some("https://gitlab.com/acme/widgets.git"), None)
.expect_err("non-GitHub origins should fail");
let error = decide_clone(
false,
Some("https://gitlab.com/acme/widgets.git"),
None,
None,
)
.expect_err("non-GitHub origins should fail");
assert!(error.to_string().contains("GitHub repository origins only"));
}
#[test]
fn exact_commit_sha_is_validated_and_normalized() {
let lowercase = "0123456789abcdef0123456789abcdef01234567";
let uppercase = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
assert_eq!(
decide_clone(
false,
Some("https://github.com/acme/widgets"),
Some("moving-branch"),
Some(lowercase),
)
.unwrap(),
CloneDecision::GitHub {
origin_url: "https://github.com/acme/widgets".to_string(),
branch: Some("moving-branch".to_string()),
commit_sha: Some(lowercase.to_string()),
}
);
assert_eq!(
decide_clone(
false,
Some("https://github.com/acme/widgets"),
Some("main"),
Some(uppercase),
)
.unwrap(),
CloneDecision::GitHub {
origin_url: "https://github.com/acme/widgets".to_string(),
branch: Some("main".to_string()),
commit_sha: Some(uppercase.to_ascii_lowercase()),
}
);
}
#[test]
fn exact_commit_sha_rejects_noncanonical_inputs() {
for sha in [
"",
"0123456789abcdef0123456789abcdef0123456",
"0123456789abcdef0123456789abcdef012345678",
"0123456789abcdef0123456789abcdef0123456g",
" 0123456789abcdef0123456789abcdef01234567",
"0123456789abcdef0123456789abcdef01234567 ",
"0123456789abcdef0123456789abcdef012345é",
] {
let error = decide_clone(
false,
Some("https://github.com/acme/widgets"),
None,
Some(sha),
)
.expect_err("invalid exact commit SHA should fail");
assert!(
error.to_string().contains("40 ASCII hexadecimal"),
"unexpected error for {sha:?}: {error}"
);
}
}
#[test]
fn exact_checkout_requires_clone_origin_and_branch() {
let sha = "0123456789abcdef0123456789abcdef01234567";
let skip_error = decide_clone(
true,
Some("https://github.com/acme/widgets"),
Some("main"),
Some(sha),
)
.expect_err("exact checkout with skip-clone should fail");
assert!(skip_error.to_string().contains("requires cloning"));
for origin in [None, Some(""), Some(" ")] {
let error = decide_clone(false, origin, Some("main"), Some(sha))
.expect_err("exact checkout without an origin should fail");
assert!(error.to_string().contains("requires a repository origin"));
}
for branch in [None, Some(""), Some(" ")] {
let error = decide_clone(
false,
Some("https://github.com/acme/widgets"),
branch,
Some(sha),
)
.expect_err("exact checkout without a branch should fail");
assert!(error.to_string().contains("requires a repository branch"));
}
}
#[test]
fn docker_exact_checkout_commands_quote_inputs() {
let sha = "0123456789abcdef0123456789abcdef01234567";
let init = exact_repository_init_command(
"https://token@example.com/acme/widgets.git?x=a b",
"/repos/acme's widgets",
);
let fetch = exact_fetch_command(
"/repos/acme's widgets",
"https://token@example.com/acme/widgets.git?x=a b",
sha,
Some(10),
);
let checkout =
exact_checkout_verify_command("/repos/acme's widgets", "feature/a b", "FETCH_HEAD");
assert_eq!(
init,
"git -c maintenance.auto=0 -c gc.auto=0 init -- \"/repos/acme's widgets\" && git -C \"/repos/acme's widgets\" remote add origin 'https://token@example.com/acme/widgets.git?x=a b'"
);
assert_eq!(
fetch,
"git -c maintenance.auto=0 -c gc.auto=0 -C \"/repos/acme's widgets\" fetch --depth 10 --no-tags 'https://token@example.com/acme/widgets.git?x=a b' -- 0123456789abcdef0123456789abcdef01234567"
);
assert_eq!(
checkout,
"git -c maintenance.auto=0 -c gc.auto=0 -C \"/repos/acme's widgets\" checkout -B 'feature/a b' FETCH_HEAD && git -c maintenance.auto=0 -c gc.auto=0 -C \"/repos/acme's widgets\" rev-parse HEAD"
);
}
#[test]
fn exact_fetch_omits_depth_for_full_history() {
assert_eq!(
exact_fetch_command(
"/repos/acme/widgets",
"origin",
"0123456789abcdef0123456789abcdef01234567",
None,
),
"git -c maintenance.auto=0 -c gc.auto=0 -C /repos/acme/widgets fetch --no-tags origin -- 0123456789abcdef0123456789abcdef01234567"
);
}
#[test]
fn exact_checkout_verification_rejects_invalid_or_mismatched_head() {
let expected = "0123456789abcdef0123456789abcdef01234567";
verify_exact_head("0123456789ABCDEF0123456789ABCDEF01234567\n", expected)
.expect("uppercase command output should normalize");
let invalid = verify_exact_head("fatal: not a revision", expected)
.expect_err("non-SHA output should fail verification");
assert!(invalid.to_string().contains("invalid HEAD commit ID"));
assert!(!invalid.to_string().contains("fatal: not a revision"));
let mismatched = verify_exact_head("1123456789abcdef0123456789abcdef01234567", expected)
.expect_err("mismatched SHA should fail verification");
assert!(mismatched.to_string().contains("did not match"));
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "hermetic Git proof uses isolated synchronous temp-repository I/O"
)]
fn exact_checkout_fetches_admitted_commit_after_branch_advances() {
let temp = tempfile::tempdir().expect("tempdir");
let remote = temp.path().join("remote.git");
let source = temp.path().join("source");
let checkout = temp.path().join("exact checkout");
fs::create_dir(&source).expect("source directory");
run_git(temp.path(), &[
"init",
"--bare",
remote.to_str().expect("UTF-8 remote path"),
]);
run_git(&source, &["init"]);
fs::write(source.join("revision.txt"), "A\n").expect("write commit A");
run_git(&source, &["add", "revision.txt"]);
run_git(&source, &["commit", "-m", "commit A"]);
run_git(&source, &["branch", "-M", "main"]);
run_git(&source, &[
"remote",
"add",
"origin",
remote.to_str().expect("UTF-8 remote path"),
]);
run_git(&source, &["push", "-u", "origin", "main"]);
let admitted_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
fs::write(source.join("revision.txt"), "B\n").expect("write commit B");
run_git(&source, &["commit", "-am", "commit B"]);
run_git(&source, &["push", "origin", "main"]);
let advanced_sha = run_git(&source, &["rev-parse", "HEAD"]).trim().to_string();
assert_ne!(admitted_sha, advanced_sha);
let remote_path = remote.to_str().expect("UTF-8 remote path");
let checkout_path = checkout.to_str().expect("UTF-8 checkout path");
run_shell(
temp.path(),
&exact_repository_init_command(remote_path, checkout_path),
);
run_shell(
temp.path(),
&exact_fetch_command(checkout_path, remote_path, &admitted_sha, Some(10)),
);
let checked_out_sha = run_shell(
temp.path(),
&exact_checkout_verify_command(checkout_path, "main", "FETCH_HEAD"),
);
assert_eq!(checked_out_sha.trim(), admitted_sha);
assert_eq!(
fs::read_to_string(checkout.join("revision.txt")).expect("checked-out contents"),
"A\n"
);
assert_eq!(
run_git(&checkout, &["symbolic-ref", "HEAD"]).trim(),
"refs/heads/main",
"HEAD should stay attached to the admitted branch"
);
assert_eq!(
run_git(&checkout, &["rev-parse", "--abbrev-ref", "HEAD"]).trim(),
"main"
);
assert_eq!(
run_git(temp.path(), &[
"--git-dir",
remote_path,
"rev-parse",
"refs/heads/main",
],)
.trim(),
advanced_sha
);
}
#[test]
fn github_layout_maps_ssh_origin_to_repos_checkout_and_workspace_link() {
let layout = github_repo_layout(

View file

@ -18,6 +18,9 @@ pub struct DaytonaSettings {
pub labels: Option<HashMap<String, String>>,
pub snapshot: Option<DaytonaSnapshotSettings>,
pub network: Option<DaytonaNetwork>,
/// Git history depth for the repository clone; `None` clones full
/// history.
pub clone_depth: Option<i32>,
#[serde(default)]
pub skip_clone: bool,
}

File diff suppressed because it is too large Load diff

View file

@ -672,18 +672,22 @@ pub(crate) mod daytona {
DaytonaState::Creating
| DaytonaState::PendingBuild
| DaytonaState::BuildingSnapshot
| DaytonaState::PullingSnapshot => SandboxState::Provisioning,
DaytonaState::Starting => SandboxState::Starting,
DaytonaState::Started => SandboxState::Running,
DaytonaState::Stopping | DaytonaState::Archiving => SandboxState::Stopping,
| DaytonaState::PullingSnapshot
| DaytonaState::Forking => SandboxState::Provisioning,
DaytonaState::Starting | DaytonaState::Resuming => SandboxState::Starting,
DaytonaState::Started | DaytonaState::Snapshotting => SandboxState::Running,
DaytonaState::Stopping | DaytonaState::Archiving | DaytonaState::Pausing => {
SandboxState::Stopping
}
DaytonaState::Stopped => SandboxState::Stopped,
DaytonaState::Paused => SandboxState::Paused,
DaytonaState::Restoring => SandboxState::Restoring,
DaytonaState::Resizing => SandboxState::Resizing,
DaytonaState::Archived => SandboxState::Archived,
DaytonaState::Destroying => SandboxState::Deleting,
DaytonaState::Destroyed => SandboxState::Deleted,
DaytonaState::Error | DaytonaState::BuildFailed => SandboxState::Error,
DaytonaState::Unknown => SandboxState::Unknown,
DaytonaState::Unknown | DaytonaState::UnknownDefaultOpenApi => SandboxState::Unknown,
}
}
@ -755,6 +759,22 @@ pub(crate) mod daytona {
);
}
#[test]
fn pause_states_normalize_to_fabro_states() {
assert_eq!(
normalize_daytona_state(DaytonaState::Pausing),
SandboxState::Stopping
);
assert_eq!(
normalize_daytona_state(DaytonaState::Paused),
SandboxState::Paused
);
assert_eq!(
normalize_daytona_state(DaytonaState::Resuming),
SandboxState::Starting
);
}
#[test]
fn gibibytes_to_bytes_converts_positive_values() {
assert_eq!(gibibytes_to_bytes(2.0), Some(2 * 1024 * 1024 * 1024));

View file

@ -18,6 +18,7 @@ use bollard::image::CreateImageOptions;
use bollard::models::{ContainerInspectResponse, HostConfig};
use fabro_github::GitHubCredentials;
use fabro_github::token_source::InstallationTokenSource;
use fabro_types::settings::run::RunCloneSettings;
use fabro_types::{CommandOutputStream, CommandTermination, RunId, SandboxProviderKind};
use fabro_util::time::elapsed_ms;
use futures::StreamExt;
@ -48,7 +49,7 @@ const DOCKER_BASH_REQUIREMENT: &str = "Docker sandboxes require /bin/bash for ev
pub(crate) const WORKING_DIRECTORY: &str = "/workspace";
pub(crate) const REPOS_ROOT: &str = "/repos";
const GIT_CLONE_DEPTH: usize = 10;
const DEFAULT_GIT_CLONE_DEPTH: usize = RunCloneSettings::DEFAULT_DEPTH.unsigned_abs() as usize;
const GIT_CLONE_TIMEOUT: Duration = Duration::from_mins(5);
#[cfg(test)]
const EXEC_STOP_POLL_SLEEP_SECONDS: &str = "0.005";
@ -59,6 +60,14 @@ const EXEC_TERM_GRACE_SECONDS: &str = "0.02";
#[cfg(not(test))]
const EXEC_TERM_GRACE_SECONDS: &str = "0.2";
/// Whether a failing git step talked to the remote. Local steps cannot fail on
/// credentials, so they must not suggest reconfiguring the GitHub App.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CloneStep {
Network,
Local,
}
struct DockerCloneFailure {
error: crate::Error,
retry_reason: Option<git_retry::GitRetryReason>,
@ -113,6 +122,9 @@ pub struct DockerSandboxOptions {
pub auto_pull: bool,
/// Additional `KEY=VALUE` environment variables for the container.
pub env_vars: Vec<String>,
/// Maximum Git history depth fetched during clone; `None` fetches full
/// history.
pub clone_depth: Option<usize>,
/// Create an empty workspace instead of cloning even when an origin exists.
pub skip_clone: bool,
}
@ -126,6 +138,7 @@ impl Default for DockerSandboxOptions {
cpu_quota: None,
auto_pull: true,
env_vars: Vec::new(),
clone_depth: Some(DEFAULT_GIT_CLONE_DEPTH),
skip_clone: false,
}
}
@ -138,6 +151,7 @@ pub struct DockerSandbox {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_commit_sha: Option<String>,
container_id: OnceCell<String>,
repo_cloned: OnceCell<bool>,
working_directory: OnceCell<String>,
@ -168,7 +182,16 @@ impl DockerSandbox {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_commit_sha: Option<String>,
) -> crate::Result<Self> {
if clone_commit_sha.is_some() {
clone_source::decide_clone(
config.skip_clone,
clone_origin_url.as_deref(),
clone_branch.as_deref(),
clone_commit_sha.as_deref(),
)?;
}
let docker = Docker::connect_with_local_defaults().map_err(crate::Error::docker_connect)?;
Self::with_docker_client(
docker,
@ -177,6 +200,7 @@ impl DockerSandbox {
run_id,
clone_origin_url,
clone_branch,
clone_commit_sha,
)
}
@ -187,6 +211,7 @@ impl DockerSandbox {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_commit_sha: Option<String>,
) -> crate::Result<Self> {
let push_credentials = PushCredentialState::new(push_credentials::build_token_source(
github_app,
@ -199,6 +224,7 @@ impl DockerSandbox {
run_id,
clone_origin_url,
clone_branch,
clone_commit_sha,
container_id: OnceCell::new(),
repo_cloned: OnceCell::new(),
working_directory: OnceCell::new(),
@ -224,6 +250,7 @@ impl DockerSandbox {
run_id,
clone_origin_url.clone(),
clone_branch,
None,
)?;
sandbox.validate_managed_container(container_id).await?;
sandbox
@ -715,19 +742,23 @@ impl DockerSandbox {
Ok(())
}
/// Preserve a failed `git clone` result while masking the auth URL.
/// Preserve a failed git step result while masking the auth URL.
fn clone_failure_error(
&self,
result: ExecResult,
label: &'static str,
auth_url: Option<&fabro_redact::DisplaySafeUrl>,
step: CloneStep,
) -> crate::Error {
let error = result
.into_exec_error_with_redactor("git clone", |output| redact_auth_url(output, auth_url));
let message = if self.push_credentials.source().is_none() {
"Git clone failed. If this is a private repository, configure a GitHub App with \
`fabro install` and install it for your organization."
} else {
"Failed to clone repository into Docker sandbox"
let error =
result.into_exec_error_with_redactor(label, |output| redact_auth_url(output, auth_url));
let message = match step {
CloneStep::Network if self.push_credentials.source().is_none() => {
"Git clone failed. If this is a private repository, configure a GitHub App with \
`fabro install` and install it for your organization."
}
CloneStep::Network => "Failed to clone repository into Docker sandbox",
CloneStep::Local => "Failed to prepare the cloned repository in the Docker sandbox",
};
crate::Error::context(message, error)
}
@ -741,10 +772,104 @@ impl DockerSandbox {
err
}
/// Run a local (non-network) step of the exact checkout under the shared
/// clone deadline.
///
/// Materializing a large working tree takes far longer than the short fixed
/// timeout used for trivial container commands, so these steps get the same
/// budget the branch clone path gives its fetch and checkout.
async fn run_exact_local_git_command(
&self,
command: &str,
label: &'static str,
clone_deadline: time::Instant,
auth_url: Option<&fabro_redact::DisplaySafeUrl>,
) -> crate::Result<ExecResult> {
let remaining = clone_deadline.saturating_duration_since(time::Instant::now());
let timeout_ms = u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX);
if timeout_ms == 0 {
return Err(crate::Error::message(format!(
"{label} deadline expired before the step could run"
)));
}
let result = self
.docker_exec_shell(command, timeout_ms, Some("/"), None, None)
.await
.map_err(|error| crate::Error::context(format!("{label} transport failed"), error))?;
if result.is_success() {
Ok(result)
} else {
Err(self.clone_failure_error(result, label, auth_url, CloneStep::Local))
}
}
/// Run a network git command inside the container with clone retry
/// semantics under the shared clone deadline.
async fn retry_git_transfer(
&self,
command: &str,
op: &'static str,
label: &'static str,
exec_label: &'static str,
clone_deadline: time::Instant,
credential_context: CredentialContext,
auth_url: Option<&fabro_redact::DisplaySafeUrl>,
) -> Result<(), DockerCloneFailure> {
let plan = git_retry::RetryPlan::clone_default(Some(clone_deadline));
git_retry::retry_git_operation(
SandboxProviderKind::Docker,
op,
&plan,
|_attempt| async move {
let remaining = clone_deadline.saturating_duration_since(time::Instant::now());
let timeout_ms = u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX);
if timeout_ms == 0 {
return Err(DockerCloneFailure {
error: crate::Error::message(format!(
"{label} deadline expired before retry"
)),
retry_reason: None,
});
}
let result = self
.docker_exec_shell_streaming(ExecStreamingRequest {
timeout_ms: Some(timeout_ms),
working_dir: Some("/"),
..ExecStreamingRequest::new(command)
})
.await
.map_err(|error| DockerCloneFailure {
error: crate::Error::context(
format!("{label} transport failed"),
error,
),
retry_reason: None,
})?
.result;
if result.is_success() {
return Ok(());
}
let retry_reason = classify_docker_clone_result(&result, credential_context);
Err(DockerCloneFailure {
error: self.clone_failure_error(
result,
exec_label,
auth_url,
CloneStep::Network,
),
retry_reason,
})
},
|failure: &DockerCloneFailure| failure.retry_reason,
)
.await
}
async fn clone_github_repo(
&self,
origin_url: String,
branch: Option<String>,
commit_sha: Option<String>,
) -> crate::Result<()> {
self.verify_git_available().await?;
let layout = clone_source::github_repo_layout(&origin_url, WORKING_DIRECTORY, REPOS_ROOT)?;
@ -805,60 +930,93 @@ impl DockerSandbox {
}
}
let command = git_clone_command(clone_url, branch.as_deref(), &layout.primary_repo_path);
let clone_deadline = time::Instant::now() + GIT_CLONE_TIMEOUT;
let clone_plan = git_retry::RetryPlan::clone_default(Some(clone_deadline));
let clone_result = git_retry::retry_clone(
SandboxProviderKind::Docker,
"clone",
&clone_plan,
|_attempt| {
let command = command.as_str();
let auth_url = auth_url.as_ref();
async move {
let remaining = clone_deadline.saturating_duration_since(time::Instant::now());
let timeout_ms = u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX);
if timeout_ms == 0 {
return Err(DockerCloneFailure {
error: crate::Error::message(
"Docker git clone deadline expired before retry",
),
retry_reason: None,
});
}
let result = self
.docker_exec_shell_streaming(ExecStreamingRequest {
timeout_ms: Some(timeout_ms),
working_dir: Some("/"),
..ExecStreamingRequest::new(command)
})
.await
.map_err(|error| DockerCloneFailure {
error: crate::Error::context(
"Docker git clone transport failed",
error,
),
retry_reason: None,
})?
.result;
if result.is_success() {
return Ok(());
}
let retry_reason =
classify_docker_clone_result(&result, clone_credential_context);
Err(DockerCloneFailure {
error: self.clone_failure_error(result, auth_url),
retry_reason,
})
}
},
|failure: &DockerCloneFailure| failure.retry_reason,
)
.await;
if let Some(expected_sha) = commit_sha.as_deref() {
// `decide_clone` already rejects an exact commit without a branch;
// re-check here so the checkout can never silently drop the branch
// name callers read back out of the workspace.
let Some(branch) = branch.as_deref().filter(|branch| !branch.trim().is_empty()) else {
let error =
crate::Error::message("Exact commit checkout requires a repository branch");
return Err(self.report_clone_failure(&origin_url, error));
};
if let Err(failure) = clone_result {
let err = failure.error;
return Err(self.report_clone_failure(&origin_url, err));
let init_command =
clone_source::exact_repository_init_command(clone_url, &layout.primary_repo_path);
if let Err(error) = self
.run_exact_local_git_command(
&init_command,
"initialize Docker exact repository checkout",
clone_deadline,
auth_url.as_ref(),
)
.await
{
return Err(self.report_clone_failure(&origin_url, error));
}
let fetch_command = clone_source::exact_fetch_command(
&layout.primary_repo_path,
"origin",
expected_sha,
self.config.clone_depth,
);
if let Err(failure) = self
.retry_git_transfer(
&fetch_command,
"fetch",
"Docker exact fetch",
"git fetch exact commit",
clone_deadline,
clone_credential_context,
auth_url.as_ref(),
)
.await
{
return Err(self.report_clone_failure(&origin_url, failure.error));
}
let checkout_command = clone_source::exact_checkout_verify_command(
&layout.primary_repo_path,
branch,
"FETCH_HEAD",
);
let head = match self
.run_exact_local_git_command(
&checkout_command,
"git checkout exact commit",
clone_deadline,
auth_url.as_ref(),
)
.await
{
Ok(result) => result,
Err(error) => return Err(self.report_clone_failure(&origin_url, error)),
};
if let Err(error) = clone_source::verify_exact_head(&head.stdout, expected_sha) {
return Err(self.report_clone_failure(&origin_url, error));
}
} else {
let command = git_clone_command(
clone_url,
branch.as_deref(),
&layout.primary_repo_path,
self.config.clone_depth,
);
if let Err(failure) = self
.retry_git_transfer(
&command,
"clone",
"Docker git clone",
"git clone",
clone_deadline,
clone_credential_context,
auth_url.as_ref(),
)
.await
{
return Err(self.report_clone_failure(&origin_url, failure.error));
}
}
let symlink_command = clone_source::repo_symlink_command(&layout);
@ -1380,15 +1538,19 @@ async fn cache_docker_stdio_completion(
}
}
fn git_clone_command(clone_url: &str, branch: Option<&str>, checkout_path: &str) -> String {
let mut command = "git -c maintenance.auto=0 -c gc.auto=0 clone".to_string();
fn git_clone_command(
clone_url: &str,
branch: Option<&str>,
checkout_path: &str,
depth: Option<usize>,
) -> String {
let mut command = format!("{} clone", sandbox::GIT);
if let Some(branch) = branch {
command.push_str(" --branch ");
command.push_str(&shell_quote(branch));
command.push_str(" --single-branch");
}
command.push_str(" --depth ");
command.push_str(&GIT_CLONE_DEPTH.to_string());
command.push_str(&clone_source::depth_argument(depth));
command.push_str(" --no-tags");
command.push_str(" -- ");
command.push_str(&shell_quote(clone_url));
@ -1623,6 +1785,7 @@ impl Sandbox for DockerSandbox {
self.config.skip_clone,
self.clone_origin_url.as_deref(),
self.clone_branch.as_deref(),
self.clone_commit_sha.as_deref(),
)
.map_err(|e| self.fail_init(init_start, e))?;
@ -1640,8 +1803,12 @@ impl Sandbox for DockerSandbox {
}
let _ = self.repo_cloned.set(false);
}
CloneDecision::GitHub { origin_url, branch } => {
if let Err(e) = self.clone_github_repo(origin_url, branch).await {
CloneDecision::GitHub {
origin_url,
branch,
commit_sha,
} => {
if let Err(e) = self.clone_github_repo(origin_url, branch, commit_sha).await {
return Err(self.fail_init(init_start, e));
}
}
@ -2402,22 +2569,154 @@ mod tests {
let options = DockerSandboxOptions::default();
assert_eq!(options.image, "buildpack-deps:noble");
assert_eq!(options.network_mode.as_deref(), Some("bridge"));
assert_eq!(options.clone_depth, Some(DEFAULT_GIT_CLONE_DEPTH));
assert!(!options.skip_clone);
}
#[test]
fn clone_command_uses_depth_ten_without_tags_for_branch_clone() {
fn clone_command_uses_configured_depth_without_tags_for_branch_clone() {
let command = git_clone_command(
"https://github.com/fabro-sh/fabro",
Some("main"),
"/repos/fabro-sh/fabro",
Some(1),
);
assert_eq!(
command,
"git -c maintenance.auto=0 -c gc.auto=0 clone --branch main --single-branch --depth 10 --no-tags -- https://github.com/fabro-sh/fabro /repos/fabro-sh/fabro"
"git -c maintenance.auto=0 -c gc.auto=0 clone --branch main --single-branch --depth 1 --no-tags -- https://github.com/fabro-sh/fabro /repos/fabro-sh/fabro"
);
}
#[test]
fn clone_command_without_branch_retains_legacy_shape() {
let command = git_clone_command(
"https://github.com/fabro-sh/fabro",
None,
"/repos/fabro-sh/fabro",
Some(DEFAULT_GIT_CLONE_DEPTH),
);
assert_eq!(
command,
"git -c maintenance.auto=0 -c gc.auto=0 clone --depth 100 --no-tags -- https://github.com/fabro-sh/fabro /repos/fabro-sh/fabro"
);
}
#[test]
fn clone_command_omits_depth_for_full_clone() {
let command = git_clone_command(
"https://github.com/fabro-sh/fabro",
Some("main"),
"/repos/fabro-sh/fabro",
None,
);
assert_eq!(
command,
"git -c maintenance.auto=0 -c gc.auto=0 clone --branch main --single-branch --no-tags -- https://github.com/fabro-sh/fabro /repos/fabro-sh/fabro"
);
}
#[test]
fn invalid_exact_sha_fails_before_docker_connection() {
let error = DockerSandbox::new(
DockerSandboxOptions::default(),
None,
None,
Some("https://github.com/acme/widgets".to_string()),
Some("main".to_string()),
Some("not-a-sha".to_string()),
)
.err()
.expect("validation should run before connecting to Docker");
assert!(error.to_string().contains("40 ASCII hexadecimal"));
assert!(!error.to_string().contains("Docker daemon"));
}
#[test]
fn exact_sha_without_branch_fails_before_docker_connection() {
let error = DockerSandbox::new(
DockerSandboxOptions::default(),
None,
None,
Some("https://github.com/acme/widgets".to_string()),
None,
Some("0123456789abcdef0123456789abcdef01234567".to_string()),
)
.err()
.expect("branch validation should run before connecting to Docker");
assert!(error.to_string().contains("requires a repository branch"));
assert!(!error.to_string().contains("Docker daemon"));
}
#[test]
fn exact_checkout_failure_preserves_safe_source_chain() {
let docker = Docker::connect_with_http("http://127.0.0.1:2375", 5, API_DEFAULT_VERSION)
.expect("mock Docker client should connect");
let sandbox = test_docker_sandbox(docker, "test-container");
let token = "ghs_exact_checkout_secret";
let auth_url = fabro_github::embed_token_in_url("https://github.com/acme/widgets", token)
.expect("authenticated URL");
let error = sandbox.clone_failure_error(
ExecResult {
stdout: String::new(),
stderr: format!(
"fatal: unable to access {}: synthetic low-level failure",
auth_url.as_raw_url()
),
exit_code: Some(128),
termination: CommandTermination::Exited,
duration_ms: 1,
},
"git fetch exact commit",
Some(&auth_url),
CloneStep::Network,
);
let causes = error.causes();
assert!(
causes
.iter()
.any(|cause| cause.contains("git fetch exact commit failed")),
"source chain should retain the exec failure: {causes:?}"
);
let rendered = crate::display_for_log(&error);
assert!(!rendered.contains(token));
assert!(!rendered.contains(auth_url.as_raw_url().as_str()));
assert!(rendered.contains("synthetic low-level failure"));
}
#[test]
fn local_checkout_failure_does_not_blame_github_credentials() {
let docker = Docker::connect_with_http("http://127.0.0.1:2375", 5, API_DEFAULT_VERSION)
.expect("mock Docker client should connect");
let sandbox = test_docker_sandbox(docker, "test-container");
let failure = ExecResult {
stdout: String::new(),
stderr: "error: pathspec 'FETCH_HEAD' did not match".to_string(),
exit_code: Some(1),
termination: CommandTermination::Exited,
duration_ms: 1,
};
let local = crate::display_for_log(&sandbox.clone_failure_error(
failure.clone(),
"git checkout exact commit",
None,
CloneStep::Local,
));
assert!(!local.contains("fabro install"), "{local}");
assert!(local.contains("prepare the cloned repository"), "{local}");
let network = crate::display_for_log(&sandbox.clone_failure_error(
failure,
"git fetch exact commit",
None,
CloneStep::Network,
));
assert!(network.contains("fabro install"), "{network}");
}
#[test]
fn clone_result_uses_stderr_before_stdout() {
let result = ExecResult {
@ -2717,6 +3016,7 @@ mod tests {
None,
None,
None,
None,
)
.expect("test sandbox should build");
sandbox

View file

@ -8,7 +8,9 @@ use std::path::{Path, PathBuf};
use fabro_types::settings::ResolveError;
#[cfg(feature = "daytona")]
use fabro_types::settings::run::DockerfileSource as ResolvedDockerfileSource;
use fabro_types::settings::run::{EnvironmentNetworkMode, RunEnvironmentSettings};
use fabro_types::settings::run::{
EnvironmentNetworkMode, RunCloneSettings, RunEnvironmentSettings,
};
#[cfg(feature = "daytona")]
use crate::config::{
@ -23,19 +25,16 @@ use crate::docker::DockerSandboxOptions;
#[must_use]
pub fn daytona_config_from_environment(
settings: &RunEnvironmentSettings,
skip_clone: bool,
clone: &RunCloneSettings,
) -> DaytonaConfig {
DaytonaConfig {
auto_stop_interval: settings
.lifecycle
.auto_stop
.map(|duration| duration_to_minutes_i32(duration.as_std())),
labels: (!settings.labels.is_empty()).then(|| settings.labels.clone()),
snapshot: settings
.image
.dockerfile
.as_ref()
.map(|dockerfile| DaytonaSnapshotSettings {
labels: (!settings.labels.is_empty()).then(|| settings.labels.clone()),
snapshot: settings.image.dockerfile.as_ref().map(|dockerfile| {
DaytonaSnapshotSettings {
cpu: settings.resources.cpu,
memory: settings
.resources
@ -53,15 +52,17 @@ pub fn daytona_config_from_environment(
SandboxDockerfileSource::Path { path: path.clone() }
}
}),
}),
network: Some(match settings.network.mode {
}
}),
network: Some(match settings.network.mode {
EnvironmentNetworkMode::Block => DaytonaNetwork::Block,
EnvironmentNetworkMode::AllowAll => DaytonaNetwork::AllowAll,
EnvironmentNetworkMode::CidrAllowList => {
DaytonaNetwork::AllowList(settings.network.allow.clone())
}
}),
skip_clone,
clone_depth: clone.depth_limit(),
skip_clone: !clone.enabled,
}
}
@ -69,7 +70,7 @@ pub fn daytona_config_from_environment(
#[must_use]
pub fn docker_config_from_environment(
settings: &RunEnvironmentSettings,
skip_clone: bool,
clone: &RunCloneSettings,
) -> DockerSandboxOptions {
// No vault is available on this path (server preflight / manifest), so a
// `{{ secrets.* }}` value keeps its source form. Nothing else is left to
@ -84,25 +85,23 @@ pub fn docker_config_from_environment(
.iter()
.map(|(key, value)| (key.clone(), value.as_source()))
.collect();
docker_config_from_environment_env(settings, skip_clone, env)
docker_config_from_environment_env(settings, clone, env)
}
#[cfg(feature = "docker")]
pub fn docker_config_from_environment_with_secrets(
settings: &RunEnvironmentSettings,
skip_clone: bool,
clone: &RunCloneSettings,
secrets_lookup: impl FnMut(&str) -> Option<String>,
) -> Result<DockerSandboxOptions, ResolveError> {
let env = settings.resolve_env(secrets_lookup)?;
Ok(docker_config_from_environment_env(
settings, skip_clone, env,
))
Ok(docker_config_from_environment_env(settings, clone, env))
}
#[cfg(feature = "docker")]
fn docker_config_from_environment_env(
settings: &RunEnvironmentSettings,
skip_clone: bool,
clone: &RunCloneSettings,
env: std::collections::HashMap<String, String>,
) -> DockerSandboxOptions {
let mut env_vars = env
@ -133,7 +132,10 @@ fn docker_config_from_environment_env(
.cpu
.map(|cpu| i64::from(cpu).saturating_mul(100_000)),
env_vars,
skip_clone,
clone_depth: clone
.depth_limit()
.and_then(|depth| usize::try_from(depth).ok()),
skip_clone: !clone.enabled,
..DockerSandboxOptions::default()
}
}

View file

@ -199,7 +199,7 @@ pub fn classify_failure(message: &str, cred: CredentialContext) -> Option<GitRet
/// GitHub's guidance for token replication is to wait a few seconds and retry
/// with the same token. Sub-second delays land inside the same replication
/// window and spend an attempt for nothing.
fn clone_backoff() -> BackoffPolicy {
fn replication_backoff() -> BackoffPolicy {
BackoffPolicy {
initial_delay: Duration::from_secs(3),
factor: 3.0,
@ -229,6 +229,13 @@ pub struct RetryPlan {
}
impl RetryPlan {
/// Host-side repository probes use the same attempt count and pacing as
/// clone operations against a freshly minted token.
#[must_use]
pub fn repository_probe() -> Self {
Self::clone_default(None)
}
/// The clone policy both providers already trust: 3 attempts, 3s/9s
/// backoff, no plan-level bounds. Docker supplies its existing absolute
/// five-minute deadline through `outer_deadline`; Daytona supplies none.
@ -236,7 +243,7 @@ impl RetryPlan {
pub fn clone_default(outer_deadline: Option<time::Instant>) -> Self {
Self {
max_attempts: 3,
backoff: clone_backoff(),
backoff: replication_backoff(),
max_elapsed: None,
per_attempt_timeout: None,
outer_deadline,
@ -249,7 +256,7 @@ impl RetryPlan {
pub fn checkpoint_push() -> Self {
Self {
max_attempts: 3,
backoff: clone_backoff(),
backoff: replication_backoff(),
max_elapsed: Some(Duration::from_secs(90)),
per_attempt_timeout: Some(Duration::from_mins(1)),
outer_deadline: None,
@ -316,13 +323,13 @@ impl RetryPlan {
}
}
/// Run a clone operation, repeating it while the failure looks transient.
/// Run a git operation, repeating it while the failure looks transient.
///
/// `attempt` receives the 1-based attempt number. `classify` decides whether
/// an error is worth repeating; `None` returns it to the caller untouched.
/// A retry starts only when its backoff fits before the plan's effective
/// deadline. The final error is returned as-is.
pub(crate) async fn retry_clone<T, E, Attempt, Fut, Classify>(
pub async fn retry_git_operation<T, E, Attempt, Fut, Classify>(
provider: SandboxProviderKind,
op: &str,
plan: &RetryPlan,
@ -595,7 +602,7 @@ mod tests {
async fn first_success_runs_one_attempt() {
let attempts = Attempts::default();
let result = retry_clone(
let result = retry_git_operation(
SandboxProviderKind::Docker,
"clone",
&RetryPlan::clone_default(None),
@ -615,7 +622,7 @@ mod tests {
async fn retries_until_a_later_attempt_succeeds() {
let attempts = Attempts::default();
let result = retry_clone(
let result = retry_git_operation(
SandboxProviderKind::Docker,
"clone",
&RetryPlan::clone_default(None),
@ -641,7 +648,7 @@ mod tests {
async fn exhausted_attempts_return_the_final_error() {
let attempts = Attempts::default();
let result = retry_clone(
let result = retry_git_operation(
SandboxProviderKind::Docker,
"clone",
&RetryPlan::clone_default(None),
@ -665,7 +672,7 @@ mod tests {
async fn unretryable_failure_stops_immediately() {
let attempts = Attempts::default();
let result = retry_clone(
let result = retry_git_operation(
SandboxProviderKind::Docker,
"clone",
&RetryPlan::clone_default(None),
@ -692,7 +699,7 @@ mod tests {
let attempts = Attempts::default();
let deadline = time::Instant::now() + Duration::from_secs(2);
let result = retry_clone(
let result = retry_git_operation(
SandboxProviderKind::Docker,
"clone",
&RetryPlan::clone_default(Some(deadline)),
@ -715,7 +722,7 @@ mod tests {
async fn unbounded_plan_runs_all_attempts() {
let attempts = Attempts::default();
let result = retry_clone(
let result = retry_git_operation(
SandboxProviderKind::Daytona,
"clone",
&RetryPlan::clone_default(None),
@ -736,13 +743,13 @@ mod tests {
let attempts = Attempts::default();
let plan = RetryPlan {
max_attempts: 5,
backoff: clone_backoff(),
backoff: replication_backoff(),
max_elapsed: Some(Duration::from_secs(4)),
per_attempt_timeout: None,
outer_deadline: None,
};
let result = retry_clone(
let result = retry_git_operation(
SandboxProviderKind::Docker,
"push",
&plan,

View file

@ -43,7 +43,9 @@ pub use fabro_github::token_source::{
InstallationTokenSource, ResolvedToken, TokenProvenance, TokenSnapshot,
};
pub use fabro_types::{RunSandboxInstance, SandboxProviderKind};
pub use git_retry::{CredentialContext, GitRetryReason, RetryPlan, classify_failure};
pub use git_retry::{
CredentialContext, GitRetryReason, RetryPlan, classify_failure, retry_git_operation,
};
pub use local::LocalSandbox;
#[cfg(feature = "daytona")]
pub use provider::daytona::DaytonaSandboxProvider;

View file

@ -1,7 +1,6 @@
use std::collections::HashMap;
use async_trait::async_trait;
use daytona_sdk::DaytonaError;
use fabro_static::EnvVars;
use fabro_types::{SandboxInfo, SandboxProviderKind};
@ -89,7 +88,7 @@ impl SandboxProvider for DaytonaSandboxProvider {
let client = self.client().await?;
let sandbox = match client.get(id).await {
Ok(sandbox) => sandbox,
Err(err) if daytona_not_found(&err) => return Ok(None),
Err(err) if daytona::daytona_not_found(&err) => return Ok(None),
Err(err) => {
return Err(crate::Error::context(
format!("Failed to get Daytona sandbox '{id}'"),
@ -130,6 +129,7 @@ impl SandboxProvider for DaytonaSandboxProvider {
run_id,
clone_origin_url,
clone_branch,
None,
Some(api_key),
)
.await?;
@ -144,7 +144,7 @@ impl SandboxProvider for DaytonaSandboxProvider {
let client = self.client().await?;
let sandbox = match client.get(id).await {
Ok(sandbox) => sandbox,
Err(err) if daytona_not_found(&err) => return Ok(()),
Err(err) if daytona::daytona_not_found(&err) => return Ok(()),
Err(err) => {
return Err(crate::Error::context(
format!("Failed to get Daytona sandbox '{id}' before delete"),
@ -166,7 +166,3 @@ impl SandboxProvider for DaytonaSandboxProvider {
fn managed_from_sdk_sandbox(sandbox: &daytona_sdk::Sandbox) -> bool {
managed_labels::is_managed(&sandbox.labels)
}
fn daytona_not_found(err: &DaytonaError) -> bool {
matches!(err, DaytonaError::NotFound { .. }) || err.status_code() == Some(404)
}

View file

@ -104,6 +104,7 @@ impl SandboxProvider for DockerSandboxProvider {
run_id,
clone_origin_url,
clone_branch,
None,
)?;
sandbox.initialize().await?;
let container_id = sandbox.container_identifier()?.to_string();

View file

@ -23,7 +23,7 @@ use crate::git_retry::{self, CredentialContext, GitRetryReason, RetryPlan};
use crate::push_credentials::{CredentialLease, PushCredentialState, RefreshErrorKind};
/// Git command prefix that disables background maintenance.
const GIT: &str = "git -c maintenance.auto=0 -c gc.auto=0";
pub(crate) const GIT: &str = "git -c maintenance.auto=0 -c gc.auto=0";
pub const DEFAULT_EXEC_OUTPUT_TAIL_BYTES: usize = 8 * 1024;

View file

@ -32,6 +32,7 @@ pub enum SandboxSpec {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_commit_sha: Option<String>,
},
#[cfg(feature = "daytona")]
Daytona {
@ -40,6 +41,7 @@ pub enum SandboxSpec {
run_id: Option<RunId>,
clone_origin_url: Option<String>,
clone_branch: Option<String>,
clone_commit_sha: Option<String>,
api_key: Option<String>,
},
}
@ -202,6 +204,7 @@ impl SandboxSpec {
run_id,
clone_origin_url,
clone_branch,
clone_commit_sha,
} => {
let mut sandbox = DockerSandbox::new(
config.clone(),
@ -209,6 +212,7 @@ impl SandboxSpec {
*run_id,
clone_origin_url.clone(),
clone_branch.clone(),
clone_commit_sha.clone(),
)
.context("Failed to create Docker sandbox")?;
if let Some(callback) = event_callback {
@ -223,6 +227,7 @@ impl SandboxSpec {
run_id,
clone_origin_url,
clone_branch,
clone_commit_sha,
api_key,
} => {
let mut sandbox = DaytonaSandbox::new(
@ -231,6 +236,7 @@ impl SandboxSpec {
*run_id,
clone_origin_url.clone(),
clone_branch.clone(),
clone_commit_sha.clone(),
api_key.clone(),
)
.await
@ -276,6 +282,7 @@ mod tests {
run_id: None,
clone_origin_url: Some("git@github.com:brynary/rack-test.git".to_string()),
clone_branch: Some("main".to_string()),
clone_commit_sha: None,
};
let mut sandbox = MockSandbox::linux();
sandbox.working_dir = "/workspace/rack-test";
@ -300,6 +307,34 @@ mod tests {
runtime.primary_repo_link.as_deref(),
Some("/workspace/rack-test")
);
let runtime_json = serde_json::to_value(&runtime).expect("runtime should serialize");
assert!(runtime_json.get("clone_commit_sha").is_none());
}
#[cfg(feature = "docker")]
#[tokio::test]
async fn invalid_exact_checkout_spec_fails_before_provider_connection() {
let spec = SandboxSpec::Docker {
config: DockerSandboxOptions::default(),
github_app: None,
run_id: None,
clone_origin_url: Some("https://github.com/acme/widgets".to_string()),
clone_branch: Some("main".to_string()),
clone_commit_sha: Some("not-a-sha".to_string()),
};
let error = spec
.build(None)
.await
.err()
.expect("spec validation should run before Docker connection");
assert!(
error
.to_string()
.contains("Failed to create Docker sandbox")
);
assert!(format!("{error:#}").contains("40 ASCII hexadecimal"));
assert!(!format!("{error:#}").contains("Docker daemon"));
}
#[cfg(feature = "docker")]
@ -314,6 +349,7 @@ mod tests {
run_id: None,
clone_origin_url: Some("https://gitlab.com/acme/widgets".to_string()),
clone_branch: None,
clone_commit_sha: None,
};
let mut sandbox = MockSandbox::linux();
sandbox.working_dir = "/workspace";

View file

@ -37,6 +37,7 @@ mod daytona_streaming_live {
None,
None,
None,
None,
)
.await?,
);
@ -74,6 +75,7 @@ mod daytona_streaming_live {
None,
None,
None,
None,
)
.await?;
sandbox.initialize().await?;
@ -179,6 +181,7 @@ mod daytona_streaming_live {
None,
None,
None,
None,
)
.await?;
@ -228,6 +231,7 @@ mod daytona_streaming_live {
Some("https://github.com/brynary/rack-test".to_string()),
None,
None,
None,
)
.await?;
@ -295,6 +299,7 @@ mod daytona_streaming_live {
None,
None,
None,
None,
)
.await?;

View file

@ -40,6 +40,7 @@ async fn streaming_timeout_terminates_docker_exec_before_returning() {
None,
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox
@ -112,6 +113,7 @@ async fn streaming_command_receives_exact_stdin_and_eof() {
None,
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox
@ -173,6 +175,7 @@ async fn cloned_docker_sandbox_uses_repos_checkout_and_workspace_symlink() {
None,
Some("https://github.com/brynary/rack-test".to_string()),
None,
None,
)
.expect("docker sandbox should construct");
sandbox
@ -239,6 +242,7 @@ async fn docker_runs_clean_bash_through_both_command_paths() {
None,
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox
@ -332,6 +336,7 @@ async fn docker_glob_matches_patterns_containing_a_path_separator() {
None,
None,
None,
None,
)
.expect("docker sandbox should construct");
sandbox

View file

@ -77,6 +77,13 @@ impl BlobStore {
}
}
pub(crate) fn sqlite_pool_for_legacy_import(&self) -> Option<&SqlitePool> {
match &self.backend {
BlobBackend::Slate(_) => None,
BlobBackend::Sqlite(pool) => Some(pool),
}
}
pub async fn write(&self, bytes: &[u8]) -> Result<BlobHash> {
match &self.backend {
BlobBackend::Slate(repo) => {

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@ mod blob_store;
mod error;
mod keyed_mutex;
mod keys;
mod legacy_blob_import;
mod record;
mod run_sessions;
mod run_state;
@ -25,6 +26,7 @@ pub use fabro_types::{
BlobHash, EventEnvelope, PendingInterviewRecord, Run, RunProjection, StageId, StageProjection,
};
pub use keyed_mutex::{KeyedMutex, KeyedMutexGuard};
pub use legacy_blob_import::{LegacyBlobImportError, LegacyBlobImportReport};
pub use run_sessions::{
ProjectedRunSession, project_run_session, project_run_session_with_context,
project_run_sessions,

View file

@ -1040,6 +1040,7 @@ fn projection_from_created(event: &EventEnvelope) -> Result<RunProjection> {
graph: props.graph.clone(),
graph_source: props.workflow_source.clone(),
workflow_slug: props.workflow_slug.clone(),
workflow_version_id: props.workflow_version_id,
automation: props.automation.clone(),
source_directory: props.source_directory.clone(),
labels,
@ -2367,12 +2368,37 @@ mod tests {
let projection = RunProjection::apply_events(&[event]).unwrap();
assert_eq!(projection.retried_from, None);
assert_eq!(projection.spec.workflow_version_id, None);
assert_eq!(
build_summary(&projection, &fixtures::RUN_1).retried_from,
None
);
}
#[test]
fn run_created_projects_workflow_version_id_into_spec() {
let workflow_version_id = test_support::test_workflow_version_id();
let event = test_raw_event(
1,
"run.created",
&json!({
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"workflow_version_id": workflow_version_id,
"labels": {},
"provenance": test_support::test_run_provenance()
}),
None,
);
let projection = RunProjection::apply_events(&[event]).unwrap();
assert_eq!(
projection.spec.workflow_version_id,
Some(workflow_version_id)
);
}
#[test]
fn run_created_replay_ignores_unknown_properties() {
let provenance = test_support::test_run_provenance();

View file

@ -595,6 +595,7 @@ mod tests {
graph: Graph::new("test"),
graph_source: None,
workflow_slug: Some("test-workflow".to_string()),
workflow_version_id: None,
automation: None,
source_directory: None,
labels: HashMap::new(),

View file

@ -100,7 +100,7 @@ impl Database {
self.base_prefix.clone()
}
async fn open_db(&self) -> Result<slatedb::Db> {
pub(crate) async fn open_db(&self) -> Result<slatedb::Db> {
let db = self
.db
.get_or_try_init(|| async {
@ -587,6 +587,7 @@ mod tests {
graph,
graph_source: None,
workflow_slug: Some("night-sky".to_string()),
workflow_version_id: None,
automation: None,
source_directory: Some(format!("/tmp/{label}")),
labels: std::collections::HashMap::from([("team".to_string(), "infra".to_string())]),

View file

@ -14,6 +14,7 @@ use fabro_validate::Diagnostic;
use regex::Regex;
use thiserror::Error as ThisError;
use crate::event::RunEventPersistenceError;
use crate::outcome::{FailureDetail, Outcome, StageOutcome};
/// Classify an LLM error into a `FailureCategory` based on its structure.
@ -721,6 +722,12 @@ impl From<fabro_validate::ValidationError> for Error {
}
}
impl From<RunEventPersistenceError> for Error {
fn from(err: RunEventPersistenceError) -> Self {
Self::engine_with_source("run event persistence failed", err)
}
}
impl From<fabro_checkpoint::MetadataError> for Error {
fn from(err: fabro_checkpoint::MetadataError) -> Self {
match err {

View file

@ -18,7 +18,7 @@ pub use self::redaction::{
build_redacted_event_payload, event_payload_from_redacted_json, redacted_event_json,
};
pub use self::sink::{
RunEventLogger, RunEventSink, StoreProgressLogger, append_event, append_event_if,
append_event_to_sink,
RunEventLogger, RunEventPersistenceError, RunEventSink, StoreProgressLogger, append_event,
append_event_if, append_event_to_sink,
};
pub use crate::stage_scope::StageScope;

View file

@ -71,6 +71,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
labels,
source_directory,
workflow_slug,
workflow_version_id,
automation,
provenance,
manifest_blob,
@ -90,6 +91,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
labels: labels.clone(),
source_directory: source_directory.clone(),
workflow_slug: workflow_slug.clone(),
workflow_version_id: *workflow_version_id,
automation: automation.clone(),
provenance: provenance.clone(),
manifest_blob: *manifest_blob,
@ -1445,7 +1447,7 @@ mod tests {
use ::fabro_types::{
AutomationRef, EventBody, FailureReason, ParallelBranchId, Principal, RunNoticeCode,
RunNoticeLevel, RunProvenance, StageId, SystemActorKind, fixtures,
run_event as fabro_types,
run_event as fabro_types, test_support,
};
use chrono::Utc;
use fabro_agent::{
@ -2828,6 +2830,7 @@ mod tests {
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
};
let workflow_version_id = test_support::test_workflow_version_id();
let stored = to_run_event(&fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
@ -2838,6 +2841,7 @@ mod tests {
labels: BTreeMap::default(),
source_directory: Some("/tmp/run".to_string()),
workflow_slug: None,
workflow_version_id: Some(workflow_version_id),
automation: Some(automation.clone()),
provenance,
manifest_blob: None,
@ -2854,6 +2858,7 @@ mod tests {
panic!("expected run.created body");
};
assert_eq!(props.automation, Some(automation));
assert_eq!(props.workflow_version_id, Some(workflow_version_id));
}
#[test]

View file

@ -6,7 +6,7 @@ use ::fabro_types::{
PairTarget, ParallelBranchId, ParallelBranchResult, PendingReason, PermissionLevel, Principal,
PullRequestCreationId, PullRequestLink, ReviewTarget, RunFailure, RunId, RunNoticeLevel,
RunPairEndedReason, RunPairFailedReason, RunProvenance, RunRunnableSource, RunTiming,
SandboxProviderKind, StageId, StageOutcome, StageTiming, SuccessReason,
SandboxProviderKind, StageId, StageOutcome, StageTiming, SuccessReason, WorkflowVersionId,
run_event as fabro_types,
};
use fabro_agent::{AgentEvent, SandboxEvent};
@ -24,34 +24,36 @@ use crate::outcome::{BilledModelUsage, FailureDetail, Outcome};
)]
pub enum Event {
RunCreated {
run_id: RunId,
title: Option<String>,
settings: serde_json::Value,
graph: serde_json::Value,
run_id: RunId,
title: Option<String>,
settings: serde_json::Value,
graph: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
workflow_source: Option<String>,
labels: BTreeMap<String, String>,
workflow_source: Option<String>,
labels: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
source_directory: Option<String>,
source_directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
workflow_slug: Option<String>,
workflow_slug: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
automation: Option<AutomationRef>,
provenance: RunProvenance,
workflow_version_id: Option<WorkflowVersionId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
manifest_blob: Option<BlobHash>,
automation: Option<AutomationRef>,
provenance: RunProvenance,
#[serde(default, skip_serializing_if = "Option::is_none")]
spec_blob: Option<BlobHash>,
manifest_blob: Option<BlobHash>,
#[serde(default, skip_serializing_if = "Option::is_none")]
git: Option<GitContext>,
spec_blob: Option<BlobHash>,
#[serde(default, skip_serializing_if = "Option::is_none")]
fork_source_ref: Option<ForkSourceRef>,
git: Option<GitContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
retried_from: Option<RunId>,
fork_source_ref: Option<ForkSourceRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
parent_id: Option<RunId>,
retried_from: Option<RunId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
web_url: Option<String>,
parent_id: Option<RunId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
web_url: Option<String>,
},
WorkflowRunStarted {
name: String,

View file

@ -5,8 +5,9 @@ use std::sync::Arc;
use ::fabro_types::{RunEvent, RunId, RunProjection};
use anyhow::Result;
use fabro_store::RunDatabase;
use fabro_util::error::{SharedError, collect_chain};
use tokio::io::{AsyncWrite, AsyncWriteExt};
use tokio::sync::{Mutex as AsyncMutex, mpsc, oneshot};
use tokio::sync::{Mutex as AsyncMutex, mpsc, oneshot, watch};
use super::emitter::Emitter;
use super::redaction::{build_redacted_event_payload, redacted_event_json};
@ -42,9 +43,15 @@ pub async fn append_event_to_sink(
sink: &RunEventSink,
run_id: &RunId,
event: &Event,
) -> Result<()> {
) -> Result<(), RunEventPersistenceError> {
let stored = to_run_event(run_id, event);
sink.write_run_event(&stored).await
sink.write_run_event(&stored)
.await
.map_err(|err| RunEventPersistenceError::Write {
run_id: *run_id,
event: stored.body.event_name().to_string(),
source: SharedError::new(err),
})
}
#[derive(Clone)]
@ -149,86 +156,100 @@ impl RunEventSink {
)]
enum RunEventCommand {
Event(RunEvent),
Flush(oneshot::Sender<()>),
Flush(oneshot::Sender<Result<(), RunEventPersistenceError>>),
}
#[derive(Clone, Debug, thiserror::Error)]
pub enum RunEventPersistenceError {
#[error("failed to persist run event {event} for run {run_id}")]
Write {
run_id: RunId,
event: String,
#[source]
source: SharedError,
},
#[error("run event persistence task stopped")]
TaskStopped,
}
#[derive(Clone)]
pub struct RunEventLogger {
tx: mpsc::UnboundedSender<RunEventCommand>,
tx: mpsc::UnboundedSender<RunEventCommand>,
failure_rx: watch::Receiver<Option<RunEventPersistenceError>>,
}
impl RunEventLogger {
#[must_use]
pub fn new(sink: RunEventSink) -> Self {
let (tx, mut rx) = mpsc::unbounded_channel();
let (failure_tx, failure_rx) = watch::channel(None);
tokio::spawn(async move {
// A dropped run event is unrecoverable history loss, so the first
// one is an ERROR worth investigating. A broken sink fails for
// every event that follows, so report the rest as a count at flush
// instead of one ERROR per event. Flush runs per stage and per
// agent turn, so only losses since the last summary are reported.
let mut write_failures: u64 = 0;
let mut summarized_failures: u64 = 0;
// The watch channel is the single record of the latched failure:
// the worker is its only writer, so borrowing it here cannot race.
while let Some(command) = rx.recv().await {
match command {
RunEventCommand::Event(event) => {
if failure_tx.borrow().is_some() {
continue;
}
if let Err(err) = sink.write_run_event(&event).await {
write_failures += 1;
if write_failures == 1 {
tracing::error!(
run_id = %event.run_id,
event = %event.body.event_name(),
error = %err,
"Failed to write run event",
);
} else {
tracing::debug!(
run_id = %event.run_id,
event = %event.body.event_name(),
failures = write_failures,
error = %err,
"Failed to write run event",
);
}
let rendered_error = collect_chain(err.as_ref()).join(": ");
tracing::error!(
run_id = %event.run_id,
event = %event.body.event_name(),
error = %rendered_error,
"Failed to persist run event; stopping workflow",
);
failure_tx.send_replace(Some(RunEventPersistenceError::Write {
run_id: event.run_id,
event: event.body.event_name().to_string(),
source: SharedError::new(err),
}));
}
}
RunEventCommand::Flush(tx) => {
if write_failures > summarized_failures {
tracing::error!(
lost = write_failures - summarized_failures,
total = write_failures,
"Run events were lost to write failures",
);
summarized_failures = write_failures;
}
let _ = tx.send(());
let result = failure_tx.borrow().clone().map_or(Ok(()), Err);
let _ = tx.send(result);
}
}
}
});
Self { tx }
Self { tx, failure_rx }
}
pub fn register(&self, emitter: &Emitter) {
let tx = self.tx.clone();
emitter.on_event(move |event| {
if tx.send(RunEventCommand::Event(event.clone())).is_err() {
tracing::warn!("Run event logger channel closed while forwarding event");
tracing::error!(
run_id = %event.run_id,
event = %event.body.event_name(),
"Run event persistence task stopped while forwarding event",
);
}
});
}
pub async fn flush(&self) {
pub async fn wait_for_failure(&self) -> RunEventPersistenceError {
let mut failure_rx = self.failure_rx.clone();
let failure = failure_rx.wait_for(Option::is_some).await;
match failure {
Ok(failure) => failure
.clone()
.expect("wait_for only returns values matching the predicate"),
Err(_) => RunEventPersistenceError::TaskStopped,
}
}
pub async fn flush(&self) -> Result<(), RunEventPersistenceError> {
let (tx, rx) = oneshot::channel();
if self.tx.send(RunEventCommand::Flush(tx)).is_err() {
tracing::warn!("Run event logger channel closed before flush");
return;
}
if rx.await.is_err() {
tracing::warn!("Run event logger flush dropped before completion");
return Err(RunEventPersistenceError::TaskStopped);
}
rx.await
.unwrap_or(Err(RunEventPersistenceError::TaskStopped))
}
}
@ -249,14 +270,15 @@ impl StoreProgressLogger {
self.inner.register(emitter);
}
pub async fn flush(&self) {
self.inner.flush().await;
pub async fn flush(&self) -> Result<(), RunEventPersistenceError> {
self.inner.flush().await
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use ::fabro_types::{Graph, RunNoticeLevel, WorkflowSettings, fixtures};
use fabro_types::test_support;
@ -279,23 +301,24 @@ mod tests {
);
let run_store = store.create_run(&fixtures::RUN_7).await.unwrap();
append_event(&run_store, &fixtures::RUN_7, &Event::RunCreated {
run_id: fixtures::RUN_7,
title: None,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::new(),
source_directory: None,
workflow_slug: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
run_id: fixtures::RUN_7,
title: None,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::new(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();
@ -438,7 +461,7 @@ mod tests {
logger.register(&emitter);
emitter.emit(&Event::RunPaused);
logger.flush().await;
logger.flush().await.unwrap();
let mut reader = BufReader::new(reader);
let mut line = String::new();
@ -447,4 +470,38 @@ mod tests {
let payload = event_payload_from_redacted_json(line.trim_end(), &fixtures::RUN_8).unwrap();
assert_eq!(payload.as_value()["event"], "run.paused");
}
#[tokio::test]
async fn run_event_logger_latches_write_failure_and_preserves_cause_chain() {
let writes = Arc::new(AtomicUsize::new(0));
let writes_for_sink = Arc::clone(&writes);
let sink = RunEventSink::callback(move |_| {
writes_for_sink.fetch_add(1, Ordering::SeqCst);
async {
Err(
anyhow::anyhow!("request failed with status 413 Payload Too Large")
.context("worker lost canonical run store during append run event"),
)
}
});
let logger = RunEventLogger::new(sink);
let emitter = Emitter::new(fixtures::RUN_8);
logger.register(&emitter);
emitter.emit(&Event::RunPaused);
let failure = logger.wait_for_failure().await;
let rendered = collect_chain(&failure).join(": ");
assert!(rendered.contains("run.paused"), "{rendered}");
assert!(
rendered.contains("worker lost canonical run store"),
"{rendered}"
);
assert!(rendered.contains("413 Payload Too Large"), "{rendered}");
emitter.emit(&Event::RunUnpaused);
let flush_failure = logger.flush().await.unwrap_err();
assert_eq!(collect_chain(&flush_failure), collect_chain(&failure));
assert_eq!(writes.load(Ordering::SeqCst), 1);
}
}

View file

@ -352,24 +352,25 @@ mod tests {
let store = test_store();
let run = store.create_run(&fixtures::RUN_1).await.unwrap();
append_event(&run, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(fabro_types::WorkflowSettings::default())
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(fabro_types::WorkflowSettings::default())
.unwrap(),
graph: serde_json::to_value(fabro_types::Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
graph: serde_json::to_value(fabro_types::Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();

View file

@ -0,0 +1,438 @@
//! Secret-free Git bridging environment for additional-repository access.
//!
//! When a run declares additional GitHub repositories, every resolved
//! command/tool/ACP environment receives `GIT_CONFIG_COUNT` /
//! `GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n` entries that make plain Git
//! commands work against the declared set through the managed
//! `GITHUB_TOKEN`:
//!
//! - a credential helper for `https://github.com` that reads `$GITHUB_TOKEN`
//! from the invoking Git process's environment at invocation time, so token
//! refresh flows through per-stage environment resolution with no bridging
//! update;
//! - per-repository `url.<https>.insteadOf` rewrites for the
//! `git@github.com:owner/repo[.git]` and
//! `ssh://git@github.com/owner/repo[.git]` SSH spellings of each effective
//! repository.
//!
//! None of the values contain a secret; the token lives only in
//! `GITHUB_TOKEN`.
//!
//! The credential helper is host-scoped to `https://github.com`, not
//! path-scoped. This is safe because the token is scoped server-side to the
//! declared repository set and is only ever offered to github.com. It does
//! change one failure mode for *undeclared* repositories: public HTTPS
//! clones are unaffected (Git tries unauthenticated first), while private
//! undeclared HTTPS repositories fail with a GitHub authorization error
//! instead of a missing-credential error. Both fail; only the diagnostic
//! differs.
//!
//! `insteadOf` matches by string prefix, not exactly: a rule for
//! `owner/repo` also matches `owner/repo-other`. An undeclared repository
//! that shares a declared prefix is therefore rewritten to HTTPS; the scoped
//! token is invalid for it at GitHub, so authority is unchanged, but its Git
//! transport changes from SSH to HTTPS.
use std::collections::HashMap;
use fabro_github::{GITHUB_CREDENTIAL_HELPER, GITHUB_CREDENTIAL_HELPER_KEY};
use fabro_types::GitHubRepositorySlug;
use crate::error::Error;
/// Section base for the effective repositories' HTTPS routes.
const GITHUB_HTTPS_BASE: &str = "https://github.com/";
/// Merge the bridging entries into `env` for the effective repository set
/// (primary first). Appends after any valid user-provided `GIT_CONFIG_COUNT`
/// overlay without overwriting it, and fails with a configuration error when
/// the user overlay is malformed rather than silently replacing it.
pub(crate) fn merge_git_bridge_env(
env: &mut HashMap<String, String>,
targets: &[&GitHubRepositorySlug],
) -> Result<(), Error> {
let start = user_git_config_count(env)?;
let entries = bridge_entries(targets, GITHUB_HTTPS_BASE);
let total = start + entries.len();
for (offset, (key, value)) in entries.into_iter().enumerate() {
let index = start + offset;
env.insert(format!("GIT_CONFIG_KEY_{index}"), key);
env.insert(format!("GIT_CONFIG_VALUE_{index}"), value);
}
env.insert("GIT_CONFIG_COUNT".to_string(), total.to_string());
// Fail instead of hanging when access is missing or invalid; a user who
// explicitly configured prompting keeps their value.
env.entry("GIT_TERMINAL_PROMPT".to_string())
.or_insert_with(|| "0".to_string());
Ok(())
}
/// The bridge's Git config entries in order: the credential helper, then two
/// SSH-to-HTTPS rewrites per repository. `https_base` is
/// [`GITHUB_HTTPS_BASE`] in production; contract tests substitute a local
/// `file://` root to prove real Git applies the generated entries without
/// touching the network.
fn bridge_entries(targets: &[&GitHubRepositorySlug], https_base: &str) -> Vec<(String, String)> {
let mut entries = Vec::with_capacity(1 + targets.len() * 2);
entries.push((
GITHUB_CREDENTIAL_HELPER_KEY.to_string(),
GITHUB_CREDENTIAL_HELPER.to_string(),
));
for slug in targets {
let owner = slug.owner();
let repo = slug.repo();
let https = format!("{https_base}{owner}/{repo}");
// One prefix rule per SSH spelling covers both the bare and `.git`
// suffixed forms.
entries.push((
format!("url.{https}.insteadOf"),
format!("git@github.com:{owner}/{repo}"),
));
entries.push((
format!("url.{https}.insteadOf"),
format!("ssh://git@github.com/{owner}/{repo}"),
));
}
entries
}
/// Validate and measure a user-provided `GIT_CONFIG_COUNT` overlay so the
/// bridge appends after it. Orphaned `GIT_CONFIG_KEY_n` entries without a
/// count are inert to Git and are treated as absent.
fn user_git_config_count(env: &HashMap<String, String>) -> Result<usize, Error> {
let Some(raw) = env.get("GIT_CONFIG_COUNT") else {
return Ok(0);
};
let count: usize = raw.trim().parse().map_err(|_| {
Error::Precondition(format!(
"environment variable GIT_CONFIG_COUNT must be a non-negative integer to combine \
with Fabro's Git bridging entries, got `{raw}`"
))
})?;
for index in 0..count {
let key = format!("GIT_CONFIG_KEY_{index}");
let value = format!("GIT_CONFIG_VALUE_{index}");
if !env.contains_key(&key) || !env.contains_key(&value) {
return Err(Error::Precondition(format!(
"GIT_CONFIG_COUNT is {count} but {key} or {value} is missing; fix the indexed \
Git config overlay so Fabro can append its bridging entries after it"
)));
}
}
Ok(count)
}
#[cfg(test)]
#[expect(
clippy::disallowed_methods,
clippy::disallowed_types,
reason = "contract tests drive the installed git binary synchronously in non-async tests"
)]
mod tests {
use std::path::Path;
use std::process::Command;
use super::*;
fn slug(value: &str) -> GitHubRepositorySlug {
value.parse().expect("test slug should parse")
}
fn bridged_env(
base_env: HashMap<String, String>,
targets: &[&GitHubRepositorySlug],
) -> HashMap<String, String> {
let mut env = base_env;
merge_git_bridge_env(&mut env, targets).expect("bridge entries should merge");
env
}
/// Run `git` with ONLY the bridge-relevant environment: the inherited
/// user/system/global Git config is disabled so assertions observe just
/// the generated entries.
fn git(args: &[&str], env: &HashMap<String, String>, cwd: &Path) -> std::process::Output {
let mut command = Command::new("git");
command
.args(args)
.current_dir(cwd)
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_ASKPASS", "true");
for (key, value) in env {
command.env(key, value);
}
command.output().expect("git should run")
}
/// Create a bare fixture answering both the bare and `.git`-suffixed
/// routes, the way GitHub serves both HTTPS spellings.
fn init_bare_fixture(root: &Path, owner_repo: &str) -> String {
let fixture = root.join(format!("{owner_repo}.git"));
std::fs::create_dir_all(&fixture).unwrap();
let init = Command::new("git")
.args(["init", "--bare", "--initial-branch=main"])
.arg(&fixture)
.output()
.expect("git init should run");
assert!(init.status.success(), "{init:?}");
#[cfg(unix)]
std::os::unix::fs::symlink(&fixture, root.join(owner_repo)).unwrap();
format!("file://{}/", root.display())
}
#[test]
fn no_targets_means_no_bridge_call_and_empty_env_stays_empty() {
// The caller only bridges when the additional set is non-empty; the
// pure entry builder is still total for the primary-only case.
assert_eq!(bridge_entries(&[], GITHUB_HTTPS_BASE).len(), 1);
let env: HashMap<String, String> = HashMap::new();
assert!(!env.contains_key("GIT_CONFIG_COUNT"));
}
#[test]
fn merges_helper_rewrites_count_and_terminal_prompt() {
let keystone = slug("fabro-sh/keystone");
let fabro = slug("fabro-sh/fabro");
let env = bridged_env(HashMap::new(), &[&fabro, &keystone]);
assert_eq!(env.get("GIT_CONFIG_COUNT").map(String::as_str), Some("5"));
assert_eq!(
env.get("GIT_CONFIG_KEY_0").map(String::as_str),
Some("credential.https://github.com.helper")
);
assert_eq!(
env.get("GIT_CONFIG_KEY_1").map(String::as_str),
Some("url.https://github.com/fabro-sh/fabro.insteadOf")
);
assert_eq!(
env.get("GIT_CONFIG_VALUE_1").map(String::as_str),
Some("git@github.com:fabro-sh/fabro")
);
assert_eq!(
env.get("GIT_CONFIG_VALUE_2").map(String::as_str),
Some("ssh://git@github.com/fabro-sh/fabro")
);
assert_eq!(
env.get("GIT_TERMINAL_PROMPT").map(String::as_str),
Some("0")
);
// No secrets anywhere in the generated values.
for (key, value) in &env {
assert!(!value.contains("ghs_"), "{key}={value}");
}
}
#[test]
fn respects_an_explicit_user_terminal_prompt() {
let keystone = slug("fabro-sh/keystone");
let env = bridged_env(
HashMap::from([("GIT_TERMINAL_PROMPT".to_string(), "1".to_string())]),
&[&keystone],
);
assert_eq!(
env.get("GIT_TERMINAL_PROMPT").map(String::as_str),
Some("1")
);
}
#[test]
fn appends_after_a_valid_user_git_config_overlay() {
let keystone = slug("fabro-sh/keystone");
let env = bridged_env(
HashMap::from([
("GIT_CONFIG_COUNT".to_string(), "1".to_string()),
("GIT_CONFIG_KEY_0".to_string(), "user.name".to_string()),
("GIT_CONFIG_VALUE_0".to_string(), "Overlay User".to_string()),
]),
&[&keystone],
);
assert_eq!(env.get("GIT_CONFIG_COUNT").map(String::as_str), Some("4"));
assert_eq!(
env.get("GIT_CONFIG_KEY_0").map(String::as_str),
Some("user.name"),
"user entry must survive at its original index"
);
assert_eq!(
env.get("GIT_CONFIG_KEY_1").map(String::as_str),
Some("credential.https://github.com.helper")
);
// Real Git sees both the user's entry and the appended bridge entry.
let dir = tempfile::tempdir().unwrap();
let output = git(&["config", "--list"], &env, dir.path());
assert!(output.status.success(), "{output:?}");
let listed = String::from_utf8_lossy(&output.stdout);
assert!(listed.contains("user.name=Overlay User"), "{listed}");
assert!(
listed.contains("credential.https://github.com.helper"),
"{listed}"
);
}
#[test]
fn rejects_a_malformed_user_git_config_overlay() {
let keystone = slug("fabro-sh/keystone");
let mut non_numeric = HashMap::from([("GIT_CONFIG_COUNT".to_string(), "two".to_string())]);
let err = merge_git_bridge_env(&mut non_numeric, &[&keystone]).unwrap_err();
assert!(err.to_string().contains("GIT_CONFIG_COUNT"), "{err}");
let mut missing_index = HashMap::from([
("GIT_CONFIG_COUNT".to_string(), "2".to_string()),
("GIT_CONFIG_KEY_0".to_string(), "user.name".to_string()),
("GIT_CONFIG_VALUE_0".to_string(), "Overlay".to_string()),
]);
let err = merge_git_bridge_env(&mut missing_index, &[&keystone]).unwrap_err();
assert!(err.to_string().contains("GIT_CONFIG_KEY_1"), "{err}");
}
/// With the bridge active, `git credential fill` for github.com resolves
/// through the generated helper and reads `$GITHUB_TOKEN` from the
/// invoking process environment at invocation time.
#[test]
fn credential_helper_reads_github_token_at_invocation_time() {
use std::io::Write as _;
let keystone = slug("fabro-sh/keystone");
let mut env = bridged_env(HashMap::new(), &[&keystone]);
env.insert("GITHUB_TOKEN".to_string(), "test-token-value".to_string());
let dir = tempfile::tempdir().unwrap();
let mut command = Command::new("git");
command
.args(["credential", "fill"])
.current_dir(dir.path())
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
for (key, value) in &env {
command.env(key, value);
}
let mut child = command.spawn().expect("git credential fill should spawn");
child
.stdin
.as_mut()
.unwrap()
.write_all(b"protocol=https\nhost=github.com\npath=fabro-sh/keystone\n\n")
.unwrap();
let output = child.wait_with_output().unwrap();
assert!(output.status.success(), "{output:?}");
let filled = String::from_utf8_lossy(&output.stdout);
assert!(filled.contains("username=x-access-token"), "{filled}");
assert!(filled.contains("password=test-token-value"), "{filled}");
}
/// Real Git applies the generated `insteadOf` rewrites: the exact SSH
/// spellings of a declared repository resolve to their HTTPS-analog
/// route (a local `file://` fixture here, so no network is involved),
/// while `GIT_SSH_COMMAND=false` proves SSH is never attempted.
#[test]
fn declared_ssh_urls_rewrite_to_the_https_route() {
let root = tempfile::tempdir().unwrap();
let base = init_bare_fixture(root.path(), "fabro-sh/keystone");
let keystone = slug("fabro-sh/keystone");
let mut env: HashMap<String, String> = HashMap::new();
for (offset, (key, value)) in bridge_entries(&[&keystone], &base).into_iter().enumerate() {
env.insert(format!("GIT_CONFIG_KEY_{offset}"), key);
env.insert(format!("GIT_CONFIG_VALUE_{offset}"), value);
}
env.insert("GIT_CONFIG_COUNT".to_string(), "3".to_string());
env.insert("GIT_SSH_COMMAND".to_string(), "false".to_string());
for url in [
"ssh://git@github.com/fabro-sh/keystone.git",
"ssh://git@github.com/fabro-sh/keystone",
"git@github.com:fabro-sh/keystone.git",
"git@github.com:fabro-sh/keystone",
] {
let output = git(&["ls-remote", url], &env, root.path());
assert!(
output.status.success(),
"{url} should rewrite to the fixture route: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
/// An undeclared SSH URL that shares no declared prefix is not
/// rewritten: Git still routes it to SSH, where the scripted
/// `GIT_SSH_COMMAND=false` fails immediately without network access.
#[test]
fn undeclared_ssh_urls_are_not_rewritten() {
let root = tempfile::tempdir().unwrap();
let base = init_bare_fixture(root.path(), "fabro-sh/keystone");
let keystone = slug("fabro-sh/keystone");
let mut env: HashMap<String, String> = HashMap::new();
for (offset, (key, value)) in bridge_entries(&[&keystone], &base).into_iter().enumerate() {
env.insert(format!("GIT_CONFIG_KEY_{offset}"), key);
env.insert(format!("GIT_CONFIG_VALUE_{offset}"), value);
}
env.insert("GIT_CONFIG_COUNT".to_string(), "3".to_string());
env.insert("GIT_SSH_COMMAND".to_string(), "false".to_string());
env.insert("GITHUB_TOKEN".to_string(), "test-token-value".to_string());
let output = git(
&["ls-remote", "git@github.com:fabro-sh/undeclared"],
&env,
root.path(),
);
assert!(!output.status.success(), "{output:?}");
let stderr = String::from_utf8_lossy(&output.stderr);
// Not rewritten: the failure never mentions the local HTTPS-analog
// fixture route, so Git still chose the SSH transport.
assert!(
!stderr.contains(&root.path().display().to_string()),
"undeclared URL must not be rewritten to the fixture route: {stderr}"
);
assert!(!stderr.contains("test-token-value"), "{stderr}");
}
/// Prefix collision: with `fabro-sh/keystone` declared, both SSH
/// spellings of `fabro-sh/keystone-other` are rewritten to the HTTPS
/// route (prefix match), where access fails — at GitHub this is an
/// authorization error for the scoped token — and no token leaks into
/// the output.
#[test]
fn prefix_colliding_undeclared_repositories_rewrite_and_fail_without_token_leak() {
let root = tempfile::tempdir().unwrap();
let base = init_bare_fixture(root.path(), "fabro-sh/keystone");
let keystone = slug("fabro-sh/keystone");
let mut env: HashMap<String, String> = HashMap::new();
for (offset, (key, value)) in bridge_entries(&[&keystone], &base).into_iter().enumerate() {
env.insert(format!("GIT_CONFIG_KEY_{offset}"), key);
env.insert(format!("GIT_CONFIG_VALUE_{offset}"), value);
}
env.insert("GIT_CONFIG_COUNT".to_string(), "3".to_string());
env.insert("GIT_SSH_COMMAND".to_string(), "false".to_string());
env.insert("GITHUB_TOKEN".to_string(), "test-token-value".to_string());
for url in [
"git@github.com:fabro-sh/keystone-other",
"ssh://git@github.com/fabro-sh/keystone-other.git",
] {
let output = git(&["ls-remote", url], &env, root.path());
assert!(!output.status.success(), "{url}: {output:?}");
let stderr = String::from_utf8_lossy(&output.stderr);
// The failure names the (missing) HTTPS-analog fixture route,
// proving the prefix rule rewrote the URL away from SSH.
assert!(
stderr.contains("keystone-other"),
"{url} must be rewritten away from SSH, got: {stderr}"
);
assert!(
stderr.contains(&root.path().display().to_string()),
"{url} must land on the rewritten route, got: {stderr}"
);
assert!(!stderr.contains("test-token-value"), "{stderr}");
}
}
}

View file

@ -489,24 +489,25 @@ mod tests {
run_store,
&fixtures::RUN_1,
&crate::event::Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(fabro_types::WorkflowSettings::default())
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(fabro_types::WorkflowSettings::default())
.unwrap(),
graph: serde_json::to_value(fabro_types::Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
graph: serde_json::to_value(fabro_types::Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
},
)
.await
@ -629,7 +630,7 @@ mod tests {
.execute(&node, &context, &graph, tmp.path(), &services)
.await
.unwrap();
logger.flush().await;
logger.flush().await.unwrap();
let state = run_store.state().await.unwrap();
let node_state = state.stage(&StageId::new("plan", 1)).unwrap();
@ -656,7 +657,7 @@ mod tests {
.execute(&node, &context, &graph, tmp.path(), &services)
.await
.unwrap();
logger.flush().await;
logger.flush().await.unwrap();
let state = run_store.state().await.unwrap();
let node_state = state.stage(&StageId::new("work", 1)).unwrap();
@ -1150,7 +1151,7 @@ All checks passed.
.execute(&node, &context, &graph, tmp.path(), &services)
.await
.unwrap();
logger.flush().await;
logger.flush().await.unwrap();
let state = run_store.state().await.unwrap();
let node_state = state.stage(&StageId::new("step", 1)).unwrap();
@ -1554,7 +1555,7 @@ Some text in between.
.execute(&node, &context, &graph, tmp.path(), &services)
.await
.unwrap();
logger.flush().await;
logger.flush().await.unwrap();
let state = run_store.state().await.unwrap();
let node_state = state.stage(&StageId::new("report", 1)).unwrap();

View file

@ -363,20 +363,21 @@ mod tests {
Ok(RunProjection::new(
"Test run".to_string(),
RunSpec {
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: None,
automation: None,
source_directory: None,
labels: std::collections::HashMap::default(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
source_directory: None,
labels: std::collections::HashMap::default(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
},
chrono::Utc::now(),
))
@ -464,23 +465,24 @@ mod tests {
run_store,
&fixtures::RUN_1,
&crate::event::Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
},
)
.await
@ -1075,7 +1077,7 @@ mod tests {
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
logger.flush().await;
logger.flush().await.unwrap();
let snapshot = run_store.state().await.unwrap();
let node_state = snapshot.stage(&StageId::new("script_node", 1)).unwrap();
@ -1106,7 +1108,7 @@ mod tests {
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
logger.flush().await;
logger.flush().await.unwrap();
let snapshot = run_store.state().await.unwrap();
let node_state = snapshot.stage(&StageId::new("script_node", 1)).unwrap();
@ -1133,7 +1135,7 @@ mod tests {
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
logger.flush().await;
logger.flush().await.unwrap();
let snapshot = run_store.state().await.unwrap();
let node_state = snapshot.stage(&StageId::new("script_node", 1)).unwrap();
@ -1160,7 +1162,7 @@ mod tests {
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
logger.flush().await;
logger.flush().await.unwrap();
let snapshot = run_store.state().await.unwrap();
let node_state = snapshot.stage(&StageId::new("script_node", 1)).unwrap();
@ -1185,7 +1187,7 @@ mod tests {
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
logger.flush().await;
logger.flush().await.unwrap();
let snapshot = run_store.state().await.unwrap();
let node_state = snapshot.stage(&StageId::new("script_node", 1)).unwrap();
@ -1210,7 +1212,7 @@ mod tests {
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
logger.flush().await;
logger.flush().await.unwrap();
let snapshot = run_store.state().await.unwrap();
let node_state = snapshot.stage(&StageId::new("script_node", 1)).unwrap();
@ -1240,7 +1242,7 @@ mod tests {
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap_err();
logger.flush().await;
logger.flush().await.unwrap();
let snapshot = run_store.state().await.unwrap();
let node_state = snapshot.stage(&StageId::new("script_node", 1)).unwrap();
@ -1267,7 +1269,7 @@ mod tests {
.execute(&node, &context, &graph, run_dir.path(), &services)
.await
.unwrap();
logger.flush().await;
logger.flush().await.unwrap();
let snapshot = run_store.state().await.unwrap();
let node = snapshot

View file

@ -473,8 +473,9 @@ impl AgentAcpBackend {
emitter.notice(
RunNoticeLevel::Info,
RunNoticeCode::GithubTokenRefreshLimited,
"ACP agent stages receive workflow env at process launch; stages running beyond \
token expiry may need to be retried.",
"ACP agent stages receive workflow env at process launch; GITHUB_TOKEN access to \
every declared repository expires together, so stages running beyond token \
expiry may need to be retried.",
);
}
provider

View file

@ -944,24 +944,25 @@ mod tests {
run_store,
&fixtures::RUN_1,
&crate::event::Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(fabro_types::WorkflowSettings::default())
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(fabro_types::WorkflowSettings::default())
.unwrap(),
graph: serde_json::to_value(fabro_types::Graph::new("test")).unwrap(),
workflow_source: None,
labels: BTreeMap::default(),
source_directory: None,
workflow_slug: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
graph: serde_json::to_value(fabro_types::Graph::new("test")).unwrap(),
workflow_source: None,
labels: BTreeMap::default(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
},
)
.await
@ -1404,7 +1405,7 @@ mod tests {
.execute(&node, &context, &graph, Path::new("/tmp/test"), &services)
.await
.unwrap();
logger.flush().await;
logger.flush().await.unwrap();
assert_eq!(outcome.status, StageOutcome::Succeeded);
let results: Vec<ParallelBranchResult> =

View file

@ -267,24 +267,25 @@ mod tests {
run_store,
&fixtures::RUN_1,
&crate::event::Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(fabro_types::WorkflowSettings::default())
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(fabro_types::WorkflowSettings::default())
.unwrap(),
graph: serde_json::to_value(fabro_types::Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
graph: serde_json::to_value(fabro_types::Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
},
)
.await
@ -569,7 +570,7 @@ mod tests {
.execute(&node, &context, &graph, tmp.path(), &services)
.await
.unwrap();
logger.flush().await;
logger.flush().await.unwrap();
let state = run_store.state().await.unwrap();
let node_state = state.stage(&StageId::new("classify", 1)).unwrap();

View file

@ -293,6 +293,7 @@ pub mod error;
pub mod event;
pub mod file_resolver;
pub mod git;
pub(crate) mod git_bridge;
pub(crate) mod graph;
pub mod handler;
mod hook_context;

View file

@ -782,6 +782,7 @@ mod tests {
labels: BTreeMap::new(),
source_directory: Some("/tmp/project".to_string()),
workflow_slug: Some("metadata".to_string()),
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,

View file

@ -215,24 +215,25 @@ mod tests {
async fn seed_created(run_store: &fabro_store::RunDatabase, run_id: &RunId) {
event::append_event(run_store, run_id, &Event::RunCreated {
run_id: *run_id,
title: None,
settings: serde_json::to_value(fabro_types::WorkflowSettings::default())
run_id: *run_id,
title: None,
settings: serde_json::to_value(fabro_types::WorkflowSettings::default())
.unwrap(),
graph: serde_json::to_value(fabro_types::Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
graph: serde_json::to_value(fabro_types::Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();

View file

@ -17,7 +17,7 @@ use fabro_store::{Database, RunDatabase};
use fabro_template::TemplateContext;
use fabro_types::{
AutomationRef, BlobHash, ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance,
WorkflowSettings,
WorkflowSettings, WorkflowVersionId,
};
use fabro_util::json::normalize_json_value;
use tokio::task::spawn_blocking;
@ -100,6 +100,7 @@ impl CreateRunInput {
run_id,
storage_root,
workflow_slug,
workflow_version_id: None,
submitted_manifest_bytes,
title,
automation,
@ -133,6 +134,7 @@ pub struct CreateRunPersistenceMetadata {
pub run_id: RunId,
pub storage_root: PathBuf,
pub workflow_slug: Option<String>,
pub workflow_version_id: Option<WorkflowVersionId>,
pub submitted_manifest_bytes: Option<Vec<u8>>,
pub title: Option<String>,
pub automation: Option<AutomationRef>,
@ -202,6 +204,7 @@ pub struct CreateRunPersistenceInput {
run_id: RunId,
run_dir: PathBuf,
workflow_slug: Option<String>,
workflow_version_id: Option<WorkflowVersionId>,
submitted_manifest_bytes: Option<Vec<u8>>,
title: Option<String>,
automation: Option<AutomationRef>,
@ -229,6 +232,10 @@ impl CreateRunPersistenceInput {
self.workflow_slug.as_deref()
}
pub fn workflow_version_id(&self) -> Option<WorkflowVersionId> {
self.workflow_version_id
}
pub fn submitted_manifest_bytes(&self) -> Option<&[u8]> {
self.submitted_manifest_bytes.as_deref()
}
@ -397,6 +404,7 @@ pub fn assemble_create_run_persistence_input(
run_id,
storage_root,
workflow_slug,
workflow_version_id,
submitted_manifest_bytes,
title,
automation,
@ -417,6 +425,7 @@ pub fn assemble_create_run_persistence_input(
run_id,
run_dir,
workflow_slug,
workflow_version_id,
submitted_manifest_bytes,
title,
automation,
@ -438,6 +447,7 @@ pub async fn persist_create_run(
run_id,
run_dir,
workflow_slug,
workflow_version_id,
submitted_manifest_bytes,
title,
automation,
@ -465,6 +475,7 @@ pub async fn persist_create_run(
graph: validated.graph().clone(),
graph_source: Some(validated.source().to_string()),
workflow_slug,
workflow_version_id,
automation,
source_directory: Some(source_directory),
labels,
@ -552,6 +563,7 @@ async fn persist_created_run(
.collect::<BTreeMap<_, _>>(),
source_directory: record.source_directory.clone(),
workflow_slug: record.workflow_slug.clone(),
workflow_version_id: record.workflow_version_id,
automation: record.automation.clone(),
provenance: record.provenance.clone(),
manifest_blob,
@ -1709,6 +1721,7 @@ reasoning = false
run_id: fixtures::RUN_1,
storage_root: PathBuf::from("/tmp/storage"),
workflow_slug: None,
workflow_version_id: None,
submitted_manifest_bytes: None,
title: None,
automation: None,
@ -1761,7 +1774,9 @@ reasoning = false
std::fs::write(&dot_path, "this is no longer a graph").unwrap();
let materialized = materialize_create_run(compiled, catalog.as_ref()).unwrap();
let metadata = persistence_metadata(&request, fixtures::RUN_2, &storage_root);
let workflow_version_id = test_support::test_workflow_version_id();
let mut metadata = persistence_metadata(&request, fixtures::RUN_2, &storage_root);
metadata.workflow_version_id = Some(workflow_version_id);
let input = assemble_create_run_persistence_input(materialized, metadata);
let store = memory_store();
let created = persist_create_run(store.as_ref(), input).await.unwrap();
@ -1775,6 +1790,7 @@ reasoning = false
let state = run_store.state().await.unwrap();
assert_eq!(state.spec.graph.goal(), "Compiled goal");
assert_eq!(state.spec.automation, Some(automation));
assert_eq!(state.spec.workflow_version_id, Some(workflow_version_id));
let events = run_store.list_events().await.unwrap();
assert_eq!(
events
@ -1790,6 +1806,7 @@ reasoning = false
created.workflow_source.as_deref(),
Some(compiled_source.as_str())
);
assert_eq!(created.workflow_version_id, Some(workflow_version_id));
let manifest_blob = created
.manifest_blob
.as_ref()

View file

@ -149,27 +149,28 @@ async fn persist_forked_run(
.map_err(|err| Error::engine(err.to_string()))?;
event::append_event(&run_store, &spec.run_id, &Event::RunCreated {
run_id: spec.run_id,
title: None,
settings: serde_json::to_value(&spec.settings)
run_id: spec.run_id,
title: None,
settings: serde_json::to_value(&spec.settings)
.map_err(|err| Error::engine(err.to_string()))?,
graph: serde_json::to_value(&spec.graph)
graph: serde_json::to_value(&spec.graph)
.map_err(|err| Error::engine(err.to_string()))?,
workflow_source: projection.spec.graph_source.clone(),
labels: spec.labels.clone().into_iter().collect(),
source_directory: spec.source_directory.clone(),
workflow_slug: spec.workflow_slug.clone(),
automation: spec.automation.clone(),
provenance: spec.provenance.clone(),
manifest_blob: spec.manifest_blob,
workflow_source: projection.spec.graph_source.clone(),
labels: spec.labels.clone().into_iter().collect(),
source_directory: spec.source_directory.clone(),
workflow_slug: spec.workflow_slug.clone(),
workflow_version_id: spec.workflow_version_id,
automation: spec.automation.clone(),
provenance: spec.provenance.clone(),
manifest_blob: spec.manifest_blob,
// Content-addressed, so the forked run reads the source run's
// unredacted spec bytes through the same id.
spec_blob: spec.spec_blob,
git: spec.git.clone(),
fork_source_ref: spec.fork_source_ref.clone(),
retried_from: None,
parent_id: None,
web_url: None,
spec_blob: spec.spec_blob,
git: spec.git.clone(),
fork_source_ref: spec.fork_source_ref.clone(),
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.map_err(|err| Error::engine(err.to_string()))?;
@ -371,30 +372,32 @@ mod tests {
let source = store.create_run(&source_run_id).await.unwrap();
let graph = Graph::new("fork-source");
let settings = WorkflowSettings::default();
let workflow_version_id = test_support::test_workflow_version_id();
event::append_event(&source, &source_run_id, &Event::RunCreated {
run_id: source_run_id,
title: None,
settings: serde_json::to_value(&settings).unwrap(),
graph: serde_json::to_value(&graph).unwrap(),
workflow_source: Some("digraph fork_source {}".to_string()),
labels: BTreeMap::new(),
source_directory: Some("/client/source".to_string()),
workflow_slug: Some("fork-source".to_string()),
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: Some(fabro_types::GitContext {
run_id: source_run_id,
title: None,
settings: serde_json::to_value(&settings).unwrap(),
graph: serde_json::to_value(&graph).unwrap(),
workflow_source: Some("digraph fork_source {}".to_string()),
labels: BTreeMap::new(),
source_directory: Some("/client/source".to_string()),
workflow_slug: Some("fork-source".to_string()),
workflow_version_id: Some(workflow_version_id),
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: Some(fabro_types::GitContext {
origin_url: "https://github.com/example/repo.git".to_string(),
branch: "main".to_string(),
sha: None,
dirty: fabro_types::DirtyStatus::Clean,
}),
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();
@ -463,6 +466,10 @@ mod tests {
assert_eq!(node.response.as_deref(), Some("historical response"));
assert_eq!(forked_state.checkpoints.len(), 1);
assert_eq!(
forked_state.spec.workflow_version_id,
Some(workflow_version_id)
);
assert_eq!(
forked_state.spec.fork_source_ref.unwrap().source_run_id,
source_run_id

View file

@ -43,8 +43,7 @@ pub async fn resume(run_dir: &Path, services: StartServices) -> Result<Started,
&services.run_id,
&Event::RunSubmitted { definition_blob },
)
.await
.map_err(|err| Error::engine(err.to_string()))?;
.await?;
Box::pin(execute_persisted_run(run_dir, Some(resume_state), services)).await
}

View file

@ -48,6 +48,7 @@ pub async fn retry_run(
graph,
graph_source,
workflow_slug,
workflow_version_id,
automation,
source_directory,
labels,
@ -76,6 +77,7 @@ pub async fn retry_run(
labels: labels.into_iter().collect::<BTreeMap<_, _>>(),
source_directory,
workflow_slug,
workflow_version_id,
automation,
provenance: input.provenance.clone(),
manifest_blob,
@ -123,7 +125,7 @@ mod tests {
use fabro_types::{
AuthMethod, BlobHash, DirtyStatus, FailureReason, ForkSourceRef, GitContext, Graph,
IdpIdentity, Principal, PullRequestLink, RunRunnableSource, RunServerProvenance, RunTiming,
WorkflowSettings, fixtures,
WorkflowSettings, fixtures, test_support,
};
use object_store::memory::InMemory;
@ -186,6 +188,7 @@ mod tests {
labels: labels.into_iter().collect(),
source_directory: Some("/workspace/source".to_string()),
workflow_slug: Some("retry-source".to_string()),
workflow_version_id: Some(test_support::test_workflow_version_id()),
automation: None,
provenance: provenance("source-user"),
manifest_blob,
@ -393,6 +396,10 @@ mod tests {
Some(&"test".to_string())
);
assert_eq!(retry_state.spec.graph.name, "retry_source");
assert_eq!(
retry_state.spec.workflow_version_id,
Some(test_support::test_workflow_version_id())
);
assert_eq!(
retry_state.spec.graph_source.as_deref(),
Some("digraph retry_source { start -> exit }")

View file

@ -1,4 +1,5 @@
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use std::future::Future;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@ -17,10 +18,11 @@ use fabro_sandbox::{DockerSandboxOptions, SandboxSpec};
use fabro_static::EnvVars;
use fabro_types::settings::run::{
ApprovalMode, McpServerSettings as ResolvedMcpServerSettings, PullRequestSettings,
ResolvedMcpEntry, RunMode, RunNamespace as ResolvedRunSettings,
ResolvedGithubIntegration, ResolvedMcpEntry, RunMode, RunNamespace as ResolvedRunSettings,
RunPrepareSettings as ResolvedRunPrepareSettings,
};
use fabro_types::{ManifestPath, RunId, RunRunnableSource, SandboxProviderKind};
use fabro_util::error::collect_chain;
use fabro_vault::Vault;
use tokio::runtime::Handle;
use tokio::sync::RwLock as AsyncRwLock;
@ -31,7 +33,8 @@ use crate::artifact_upload::ArtifactSink;
use crate::context::Context;
use crate::error::{self, Error};
use crate::event::{
Emitter, Event, EventBody, RunEventLogger, RunEventSink, RunNoticeLevel, append_event_to_sink,
Emitter, Event, EventBody, RunEventLogger, RunEventPersistenceError, RunEventSink,
RunNoticeLevel, append_event_to_sink,
};
use crate::handler::HandlerRegistry;
use crate::model_fallback::{ModelFallbackNotice, ResolvedModelFallbacks, resolve_model_fallbacks};
@ -104,9 +107,10 @@ pub struct StartServices {
pub artifact_sink: Option<ArtifactSink>,
pub run_control: Option<Arc<RunControlState>>,
pub github_app: Option<fabro_github::GitHubCredentials>,
/// Server-resolved GitHub integration permissions to inject into the
/// sandbox env. Empty when github integration has no permissions.
pub github_permissions: HashMap<String, String>,
/// The resolved GitHub integration request (interpolated permissions
/// plus declared additional repositories) to inject into the sandbox
/// env. Empty when the github integration requests no token.
pub github_integration: ResolvedGithubIntegration,
pub vault: Arc<AsyncRwLock<Vault>>,
pub catalog: Arc<Catalog>,
pub on_node: crate::OnNodeCallback,
@ -157,8 +161,7 @@ pub async fn start(run_dir: &Path, services: StartServices) -> Result<Started, E
actor: None,
},
)
.await
.map_err(|err| Error::engine(err.to_string()))?;
.await?;
append_event_to_sink(
&services.event_sink,
&services.run_id,
@ -167,8 +170,7 @@ pub async fn start(run_dir: &Path, services: StartServices) -> Result<Started, E
actor: None,
},
)
.await
.map_err(|err| Error::engine(err.to_string()))?;
.await?;
}
Box::pin(execute_persisted_run(run_dir, None, services)).await
@ -198,7 +200,7 @@ pub(super) async fn execute_persisted_run(
return Err(error);
}
if let Err(err) = append_event_to_sink(&event_sink, &run_id, &Event::RunStarting).await {
let error = Error::engine(err.to_string());
let error = Error::from(err);
let _ = persist_detached_failure(
run_id,
&run_store,
@ -318,7 +320,13 @@ async fn emit_workflow_run_failed(
conclusion.billing,
);
if let Err(err) = append_event_to_sink(event_sink, &run_id, &failure_event).await {
tracing::warn!(error = %err, "Failed to append run.failed event");
let rendered_error = collect_chain(&err).join(": ");
tracing::error!(
run_id = %run_id,
event = "run.failed",
error = %rendered_error,
"Failed to append run.failed event",
);
}
}
@ -347,6 +355,40 @@ async fn persist_terminal_engine_failure(
.await;
}
fn stop_for_run_event_persistence_failure(
cancel_token: &CancellationToken,
error: RunEventPersistenceError,
) -> Error {
cancel_token.cancel();
error.into()
}
/// Race a pipeline step against the first latched run-event persistence
/// failure. When the failure wins, the step future is dropped mid-flight and
/// the run token is cancelled.
async fn race_persistence<T>(
logger: &RunEventLogger,
cancel_token: &CancellationToken,
step: impl Future<Output = T>,
) -> Result<T, Error> {
tokio::select! {
result = step => Ok(result),
failure = logger.wait_for_failure() => {
Err(stop_for_run_event_persistence_failure(cancel_token, failure))
}
}
}
async fn flush_or_stop(
logger: &RunEventLogger,
cancel_token: &CancellationToken,
) -> Result<(), Error> {
logger
.flush()
.await
.map_err(|failure| stop_for_run_event_persistence_failure(cancel_token, failure))
}
impl RunSession {
async fn new(persisted: &Persisted, services: StartServices) -> Result<Self, Error> {
let record = persisted.run_spec();
@ -430,6 +472,7 @@ impl RunSession {
run_id: Some(record.run_id),
clone_origin_url: record.repo_origin_url().map(str::to_string),
clone_branch: record.base_branch().map(str::to_string),
clone_commit_sha: None,
},
SandboxProviderKind::Daytona => {
let api_key = vault_guard
@ -441,6 +484,7 @@ impl RunSession {
run_id: Some(record.run_id),
clone_origin_url: record.repo_origin_url().map(str::to_string),
clone_branch: record.base_branch().map(str::to_string),
clone_commit_sha: None,
api_key,
}
}
@ -450,11 +494,13 @@ impl RunSession {
.environment
.resolve_env(secret_lookup)
.map_err(|err| Error::engine_with_source("failed to resolve run environment", err))?;
let github_permissions: Option<HashMap<String, String>> =
(!services.github_permissions.is_empty()).then(|| services.github_permissions.clone());
let github_integration = services
.github_integration
.is_token_requested()
.then(|| services.github_integration.clone());
let sandbox_env = SandboxEnvSpec {
toml_env,
github_permissions,
github_integration,
origin_url: record.repo_origin_url().map(str::to_string),
};
@ -589,7 +635,7 @@ fn resolve_sandbox_provider(settings: &ResolvedRunSettings) -> SandboxProviderKi
}
fn resolve_daytona_config(settings: &ResolvedRunSettings) -> DaytonaConfig {
daytona_config_from_environment(&settings.environment, !settings.clone.enabled)
daytona_config_from_environment(&settings.environment, &settings.clone)
}
fn resolve_docker_config(
@ -598,7 +644,7 @@ fn resolve_docker_config(
) -> Result<DockerSandboxOptions, Error> {
docker_config_from_environment_with_secrets(
&settings.environment,
!settings.clone.enabled,
&settings.clone,
secrets_lookup,
)
.map_err(|err| Error::engine_with_source("failed to resolve Docker environment config", err))
@ -689,6 +735,7 @@ impl RunSession {
resume: Option<ResumeState>,
) -> Result<Started, Error> {
let on_node = self.on_node.clone();
let run_cancel_token = self.cancel_token.clone();
let record = persisted.run_spec();
let run_options = RunOptions {
@ -777,7 +824,19 @@ impl RunSession {
seed_context: self.seed_context,
fabro_run_tools: self.fabro_run_tools,
};
let mut initialized = Box::pin(pipeline::initialize(persisted, init_options)).await?;
let mut initialized = match race_persistence(
&store_progress_logger,
&run_cancel_token,
Box::pin(pipeline::initialize(persisted, init_options)),
)
.await?
{
Ok(initialized) => initialized,
Err(err) => {
flush_or_stop(&store_progress_logger, &run_cancel_token).await?;
return Err(err);
}
};
initialized.on_node = on_node;
let sandbox_for_cleanup = Arc::clone(&initialized.engine.run.sandbox);
@ -801,8 +860,15 @@ impl RunSession {
steering_hub_for_drain.drain_pending_at_run_end();
});
let executed = pipeline::execute(initialized).await;
store_progress_logger.flush().await;
flush_or_stop(&store_progress_logger, &run_cancel_token).await?;
let executed = race_persistence(
&store_progress_logger,
&run_cancel_token,
Box::pin(pipeline::execute(initialized)),
)
.await?;
flush_or_stop(&store_progress_logger, &run_cancel_token).await?;
let final_context = Some(executed.final_context.clone());
let finalize_opts = FinalizeOptions {
@ -822,16 +888,21 @@ impl RunSession {
model: self.pr_model,
};
let concluding = async {
let concluded = Box::pin(pipeline::conclude(executed, &finalize_opts)).await?;
let published = Box::pin(pipeline::publish(concluded, &publish_opts)).await;
Box::pin(pipeline::finalize(published, &finalize_opts)).await
};
let finalized = match concluding.await {
let concluding = race_persistence(
&store_progress_logger,
&run_cancel_token,
Box::pin(async {
let concluded = Box::pin(pipeline::conclude(executed, &finalize_opts)).await?;
let published = Box::pin(pipeline::publish(concluded, &publish_opts)).await;
Box::pin(pipeline::finalize(published, &finalize_opts)).await
}),
)
.await?;
let finalized = match concluding {
Ok(finalized) => finalized,
Err(err) => {
self.steering_hub.drain_pending_at_run_end();
store_progress_logger.flush().await;
flush_or_stop(&store_progress_logger, &run_cancel_token).await?;
return Err(err);
}
};
@ -840,7 +911,7 @@ impl RunSession {
// scopeguard above re-runs as a no-op (drain is idempotent on an
// already-empty buffer) on the way out of scope.
self.steering_hub.drain_pending_at_run_end();
store_progress_logger.flush().await;
flush_or_stop(&store_progress_logger, &run_cancel_token).await?;
scopeguard::ScopeGuard::into_inner(cleanup_guard);
@ -1000,13 +1071,20 @@ impl Drop for DetachedRunCompletionGuard {
0,
)
.await;
let _ = append_event_to_sink(&event_sink, &run_id, &Event::RunNotice {
if let Err(err) = append_event_to_sink(&event_sink, &run_id, &Event::RunNotice {
level: RunNoticeLevel::Error,
code: code.to_string(),
message: message.to_string(),
exec_output_tail: None,
})
.await;
.await
{
let rendered_error = collect_chain(&err).join(": ");
tracing::warn!(
error = %rendered_error,
"Failed to append detached completion notice",
);
}
});
}
}
@ -1030,7 +1108,11 @@ async fn persist_detached_failure(
exec_output_tail: None,
};
if let Err(err) = append_event_to_sink(event_sink, &run_id, &event).await {
tracing::warn!(error = %err, "Failed to append detached failure notice");
let rendered_error = collect_chain(&err).join(": ");
tracing::warn!(
error = %rendered_error,
"Failed to append detached failure notice",
);
}
Ok(())
@ -1089,8 +1171,19 @@ mod tests {
work -> exit
}"#;
const BLOCKING_DOT: &str = r#"digraph Test {
graph [goal="Wait forever"]
start [shape=Mdiamond]
block [type="blocking"]
exit [shape=Msquare]
start -> block
block -> exit
}"#;
struct TimedOutcomeHandler;
struct BlockingHandler;
fn timed_success_outcome() -> Outcome {
let mut outcome = Outcome::success();
outcome.timing = Some(StageTiming::new(0, 100, 50));
@ -1122,6 +1215,20 @@ mod tests {
}
}
#[async_trait::async_trait]
impl Handler for BlockingHandler {
async fn execute(
&self,
_node: &fabro_graphviz::graph::Node,
_context: &Context,
_graph: &fabro_graphviz::graph::Graph,
_run_dir: &Path,
_services: &EngineServices,
) -> Result<Outcome, Error> {
std::future::pending().await
}
}
fn memory_store() -> Arc<Database> {
Arc::new(Database::new(
Arc::new(InMemory::new()),
@ -1283,6 +1390,7 @@ reasoning = false
let settings = settings_from_run_layer(RunLayer {
clone: Some(RunCloneLayer {
enabled: Some(false),
depth: Some(1),
}),
..RunLayer::default()
});
@ -1293,6 +1401,45 @@ reasoning = false
.skip_clone
);
assert!(resolve_daytona_config(&settings.run).skip_clone);
assert_eq!(resolve_daytona_config(&settings.run).clone_depth, Some(1));
assert_eq!(
resolve_docker_config(&settings.run, |_| None)
.unwrap()
.clone_depth,
Some(1)
);
}
#[test]
fn zero_clone_depth_requests_full_history_from_clone_providers() {
let settings = settings_from_run_layer(RunLayer {
clone: Some(RunCloneLayer {
enabled: None,
depth: Some(0),
}),
..RunLayer::default()
});
assert_eq!(resolve_daytona_config(&settings.run).clone_depth, None);
assert_eq!(
resolve_docker_config(&settings.run, |_| None)
.unwrap()
.clone_depth,
None
);
}
#[test]
fn clone_providers_default_to_depth_100() {
let settings = settings_from_run_layer(RunLayer::default());
assert_eq!(resolve_daytona_config(&settings.run).clone_depth, Some(100));
assert_eq!(
resolve_docker_config(&settings.run, |_| None)
.unwrap()
.clone_depth,
Some(100)
);
}
#[test]
@ -1723,7 +1870,7 @@ reasoning = false
artifact_sink: None,
run_control: None,
github_app: None,
github_permissions: HashMap::new(),
github_integration: ResolvedGithubIntegration::default(),
vault: Arc::new(AsyncRwLock::new(start_vault(&[]))),
catalog: test_catalog(),
on_node: None,
@ -2085,6 +2232,74 @@ reasoning = false
assert!(run_store.state().await.unwrap().conclusion.is_some());
}
#[tokio::test]
async fn event_persistence_failure_stops_execution_and_fails_run() {
let temp = tempfile::tempdir().unwrap();
let (storage_root, run_dir) = storage_root_and_run_dir(&temp);
let emitter = Arc::new(Emitter::new(fixtures::RUN_1));
let mut registry = test_registry();
registry.register("blocking", Box::new(BlockingHandler));
let (_persisted, store) = persisted_workflow(BLOCKING_DOT, &storage_root).await;
let run_store = store.open_run(&fixtures::RUN_1).await.unwrap();
let canonical_sink = RunEventSink::store(run_store.clone());
let mut services = test_start_services(&store, &run_dir, emitter, Arc::new(registry)).await;
let cancel_token = services.cancel_token.clone();
services.event_sink = RunEventSink::callback(move |event| {
let canonical_sink = canonical_sink.clone();
async move {
if matches!(&event.body, EventBody::StageStarted(_))
&& event.node_id.as_deref() == Some("block")
{
return Err(anyhow::anyhow!(
"request failed with status 413 Payload Too Large"
)
.context("worker lost canonical run store during append run event"));
}
canonical_sink.write_run_event(&event).await
}
});
let result = tokio::time::timeout(Duration::from_secs(2), start(&run_dir, services))
.await
.expect("event persistence failure should stop the blocking stage");
let Err(error) = result else {
panic!("event persistence failure should fail the run");
};
assert!(cancel_token.is_cancelled());
let rendered = error.display_with_causes();
assert!(
rendered.contains("run event persistence failed"),
"{rendered}"
);
assert!(rendered.contains("stage.started"), "{rendered}");
assert!(rendered.contains("413 Payload Too Large"), "{rendered}");
let projection = run_store.state().await.unwrap();
assert!(matches!(projection.status, RunStatus::Failed { .. }));
let events = run_store.list_events().await.unwrap();
let run_failed = events
.iter()
.find_map(|event| match &event.event.body {
EventBody::RunFailed(properties) => Some(properties),
_ => None,
})
.expect("persistence failure should emit run.failed");
assert!(
run_failed
.failure
.detail
.causes
.iter()
.any(|cause| cause.contains("413 Payload Too Large"))
);
assert!(
events
.iter()
.all(|event| !matches!(&event.event.body, EventBody::RunCompleted(_)))
);
}
#[tokio::test]
async fn start_can_run_bundle_backed_child_workflow_without_workflow_bundle_json() {
let temp = tempfile::tempdir().unwrap();

View file

@ -241,20 +241,21 @@ mod tests {
RunProjection::new(
"Test run".to_string(),
RunSpec {
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: None,
automation: None,
source_directory: None,
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
source_directory: None,
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
},
Utc::now(),
)

View file

@ -156,6 +156,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI
graph,
graph_source: None,
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
automation: None,
source_directory: Some(
std::env::current_dir()
@ -208,23 +209,24 @@ async fn seed_created_and_starting(
graph: &Graph,
) {
append_event(run_store, &run_options.run_id, &Event::RunCreated {
run_id: run_options.run_id,
title: None,
settings: serde_json::to_value(&run_options.settings).unwrap(),
graph: serde_json::to_value(graph).unwrap(),
workflow_source: None,
labels: run_options.labels.clone().into_iter().collect(),
source_directory: Some(std::env::current_dir().unwrap().display().to_string()),
workflow_slug: run_options.workflow_slug.clone(),
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: run_options.pre_run_git.clone(),
fork_source_ref: run_options.fork_source_ref.clone(),
retried_from: None,
parent_id: None,
web_url: None,
run_id: run_options.run_id,
title: None,
settings: serde_json::to_value(&run_options.settings).unwrap(),
graph: serde_json::to_value(graph).unwrap(),
workflow_source: None,
labels: run_options.labels.clone().into_iter().collect(),
source_directory: Some(std::env::current_dir().unwrap().display().to_string()),
workflow_slug: run_options.workflow_slug.clone(),
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: run_options.pre_run_git.clone(),
fork_source_ref: run_options.fork_source_ref.clone(),
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();
@ -285,7 +287,7 @@ async fn execute_test_run_with_options(
hooks: HookSettings { hooks: vec![] },
sandbox_env: SandboxEnvSpec {
toml_env: HashMap::new(),
github_permissions: None,
github_integration: None,
origin_url: None,
},
vault: auth_test_support::empty_vault(),
@ -302,7 +304,7 @@ async fn execute_test_run_with_options(
.unwrap();
let executed = execute(initialized).await;
store_logger.flush().await;
store_logger.flush().await.unwrap();
executed
}
@ -347,7 +349,7 @@ async fn execute_runs_start_to_exit_and_returns_final_context() {
hooks: HookSettings { hooks: vec![] },
sandbox_env: SandboxEnvSpec {
toml_env: HashMap::new(),
github_permissions: None,
github_integration: None,
origin_url: None,
},
vault: auth_test_support::empty_vault(),
@ -486,7 +488,7 @@ async fn resumed_in_flight_node_starts_a_new_stage_execution() {
hooks: HookSettings { hooks: vec![] },
sandbox_env: SandboxEnvSpec {
toml_env: HashMap::new(),
github_permissions: None,
github_integration: None,
origin_url: None,
},
vault: auth_test_support::empty_vault(),
@ -597,7 +599,7 @@ async fn run_with_lifecycle(
hooks: HookSettings { hooks: vec![] },
sandbox_env: SandboxEnvSpec {
toml_env: HashMap::new(),
github_permissions: None,
github_integration: None,
origin_url: None,
},
vault: auth_test_support::empty_vault(),

View file

@ -787,23 +787,24 @@ mod tests {
async fn seeded_run_store() -> RunDatabase {
let run_store = test_store().create_run(&test_run_id()).await.unwrap();
append_event(&run_store, &test_run_id(), &Event::RunCreated {
run_id: test_run_id(),
title: None,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(fabro_types::Graph::new("metadata")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::new(),
source_directory: Some("/tmp/project".to_string()),
workflow_slug: Some("metadata".to_string()),
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
run_id: test_run_id(),
title: None,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(fabro_types::Graph::new("metadata")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::new(),
source_directory: Some("/tmp/project".to_string()),
workflow_slug: Some("metadata".to_string()),
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();
@ -906,20 +907,21 @@ mod tests {
RunProjection::new(
"Test run".to_string(),
RunSpec {
run_id: test_run_id(),
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: None,
automation: None,
source_directory: None,
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
run_id: test_run_id(),
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
source_directory: None,
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
},
chrono::Utc::now(),
)
@ -1112,8 +1114,8 @@ mod tests {
let temp = tempfile::tempdir().unwrap();
let run_dir = temp.path().join("run");
std::fs::create_dir_all(&run_dir).unwrap();
let inner_store = test_store().create_run(&test_run_id()).await.unwrap();
let run_store = inner_store;
let run_store = seeded_run_store().await;
crate::test_support::mark_run_running(&run_store, &test_run_id()).await;
let emitter = Arc::new(Emitter::new(test_run_id()));
let store_logger = StoreProgressLogger::new(run_store.clone());
store_logger.register(&emitter);
@ -1156,7 +1158,7 @@ mod tests {
})
.await
.unwrap();
store_logger.flush().await;
store_logger.flush().await.unwrap();
assert_eq!(concluded.conclusion.status, StageOutcome::Succeeded);
}

View file

@ -24,6 +24,7 @@ use super::types::{InitOptions, Initialized, LlmSpec, Persisted, SandboxEnvSpec}
use crate::error::Error;
use crate::event::{Event, RunNoticeCode, RunNoticeLevel};
use crate::git::GitAuthor;
use crate::git_bridge;
use crate::handler::llm::{AgentAcpBackend, AgentApiBackend, BackendRouter, routing};
use crate::handler::{HandlerRegistry, default_registry};
#[cfg(test)]
@ -37,10 +38,14 @@ use crate::services::{
use crate::stage_execution::{StageExecutionSeed, StageExecutionTracker};
use crate::steering_hub::SteeringHub;
type BuiltSandboxEnv = (
HashMap<String, String>,
Option<Arc<InstallationTokenSource>>,
);
struct BuiltSandboxEnv {
env: HashMap<String, String>,
github_token: Option<Arc<InstallationTokenSource>>,
/// The validated effective repository set behind `github_token`.
/// Present only in App mode or when additional repositories are
/// declared; drives the eager access validation at initialization.
github_access: Option<fabro_github::GitHubRepositoryAccess>,
}
async fn run_hooks(
hook_runner: Option<&HookRunner>,
@ -91,41 +96,113 @@ fn build_sandbox_env(
spec: &SandboxEnvSpec,
github_app: Option<&fabro_github::GitHubCredentials>,
) -> Result<BuiltSandboxEnv, Error> {
let env = spec.toml_env.clone();
let mut env = spec.toml_env.clone();
let Some(permissions) = spec.github_permissions.as_ref().filter(|p| !p.is_empty()) else {
return Ok((env, None));
let no_token = |env| BuiltSandboxEnv {
env,
github_token: None,
github_access: None,
};
let Some(integration) = spec
.github_integration
.as_ref()
.filter(|integration| integration.is_token_requested())
else {
return Ok(no_token(env));
};
let declares_additional = integration.has_additional_repositories();
let Some(creds) = github_app else {
return Ok((env, None));
if declares_additional {
// Legacy permissions-only configuration stays best-effort, but a
// declared additional set is an explicit access requirement.
return Err(Error::Precondition(
"run.integrations.github.additional_repositories requires GitHub credentials, \
but none are configured"
.to_string(),
));
}
return Ok(no_token(env));
};
let source = match creds {
fabro_github::GitHubCredentials::Pat(token) => {
Some(InstallationTokenSource::pat(token.clone()))
}
fabro_github::GitHubCredentials::Installation(token) => {
Some(InstallationTokenSource::installation(token.clone()))
}
fabro_github::GitHubCredentials::App(_) => {
let Some(origin_url) = spec.origin_url.as_deref() else {
return Ok((env, None));
};
let https_url = fabro_github::ssh_url_to_https(origin_url);
let (owner, repo) = fabro_github::parse_github_owner_repo(&https_url)
.map_err(|err| Error::engine_with_anyhow("Failed to parse GitHub origin", err))?;
let permissions = serde_json::to_value(permissions).map_err(|err| {
Error::engine_with_source("Failed to serialize GitHub permissions", err)
})?;
Some(
InstallationTokenSource::for_repository(creds, owner, repo, permissions).map_err(
|err| Error::engine_with_anyhow("Failed to build GitHub token source", err),
)?,
// Validate the effective repository set whenever it matters: App mode
// scopes the mint to it, and any declared additional set must hold its
// invariants regardless of credential kind. Legacy PAT/static
// permissions-only runs skip it to preserve their origin-agnostic
// behavior.
let github_access =
if declares_additional || matches!(creds, fabro_github::GitHubCredentials::App(_)) {
fabro_github::GitHubRepositoryAccess::new(
spec.origin_url.as_deref(),
&integration.additional_repositories,
integration.permissions.clone(),
)
}
.map_err(|err| {
Error::engine_with_anyhow("Failed to validate GitHub repository access", err)
})?
} else {
None
};
let github_token = match github_access.as_ref() {
Some(access) => Some(InstallationTokenSource::for_access(creds, access).map_err(
|err| Error::engine_with_anyhow("Failed to build GitHub token source", err),
)?),
None => match creds {
fabro_github::GitHubCredentials::Pat(token) => {
Some(InstallationTokenSource::pat(token.clone()))
}
fabro_github::GitHubCredentials::Installation(token) => {
Some(InstallationTokenSource::installation(token.clone()))
}
// No origin URL and nothing declared: keep the legacy App-mode
// best-effort skip.
fabro_github::GitHubCredentials::App(_) => None,
},
};
Ok((env, source))
if declares_additional {
let access = github_access
.as_ref()
.expect("access is always constructed when additional repositories are declared");
git_bridge::merge_git_bridge_env(&mut env, &access.targets())?;
}
Ok(BuiltSandboxEnv {
env,
github_token,
github_access,
})
}
/// When additional repositories are declared, resolve their token before the
/// first workflow stage. App-backed sources first check that every target is
/// on one installation. Static credentials resolve locally; the first Git
/// operation remains their access check. Legacy permissions-only runs skip
/// eager resolution.
async fn resolve_declared_repository_token(built: &BuiltSandboxEnv) -> Result<(), Error> {
let Some(_) = built
.github_access
.as_ref()
.filter(|access| access.has_additional_repositories())
else {
return Ok(());
};
// `build_sandbox_env` guarantees a token source whenever additional
// repositories are declared; fail closed if that ever breaks.
let Some(source) = built.github_token.as_ref() else {
return Err(Error::Precondition(
"run.integrations.github.additional_repositories requires GitHub credentials, but \
none are configured"
.to_string(),
));
};
source.resolve().await.map_err(|err| {
Error::engine_with_anyhow(
"Failed to resolve the GitHub token for the declared repository set",
err,
)
})?;
Ok(())
}
async fn build_registry(
@ -440,10 +517,16 @@ pub async fn initialize(
});
}
let (base_env, github_token) = build_sandbox_env(
let built_env = build_sandbox_env(
&options.sandbox_env,
options.run_options.github_app.as_ref(),
)?;
resolve_declared_repository_token(&built_env).await?;
let BuiltSandboxEnv {
env: base_env,
github_token,
github_access: _,
} = built_env;
let tool_env_provider = Arc::new(WorkflowToolEnvProvider {
base_env: base_env.clone(),
github_token: github_token.clone(),
@ -665,7 +748,7 @@ mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_interview::AutoApproveInterviewer;
use fabro_sandbox::SandboxSpec;
use fabro_store::Database;
use fabro_store::{Database, RunDatabase};
use fabro_types::settings::run::RunModelControls;
use fabro_types::{
EventBody, ForkSourceRef, RunEvent, RunId, WorkflowSettings, fixtures, test_support,
@ -710,6 +793,37 @@ mod tests {
))
}
async fn seed_run_created(
run_store: &RunDatabase,
settings: serde_json::Value,
graph: serde_json::Value,
source_directory: Option<String>,
fork_source_ref: Option<ForkSourceRef>,
) {
crate::event::append_event(run_store, &test_run_id(), &Event::RunCreated {
run_id: test_run_id(),
title: None,
settings,
graph,
workflow_source: None,
labels: BTreeMap::new(),
source_directory,
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref,
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();
}
fn simple_graph() -> (Graph, String) {
let source = r"digraph test {
start [shape=Mdiamond];
@ -815,7 +929,7 @@ mod tests {
hooks: fabro_hooks::HookSettings { hooks: vec![] },
sandbox_env: SandboxEnvSpec {
toml_env: HashMap::new(),
github_permissions: None,
github_integration: None,
origin_url: None,
},
vault: auth_test_support::empty_vault(),
@ -851,6 +965,7 @@ mod tests {
graph,
graph_source: None,
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
automation: None,
source_directory: Some(std::env::current_dir().unwrap().display().to_string()),
git: Some(fabro_types::GitContext {
@ -944,7 +1059,7 @@ mod tests {
let initialized = initialize(persisted, InitOptions {
sandbox_env: SandboxEnvSpec {
toml_env: HashMap::from([("TEST_KEY".to_string(), "value".to_string())]),
github_permissions: None,
github_integration: None,
origin_url: None,
},
..test_init_options(
@ -1035,27 +1150,14 @@ mod tests {
let mut run_options = test_settings(&run_dir);
run_options.settings = settings;
run_options.fork_source_ref = fork_source_ref;
crate::event::append_event(&run_store, &test_run_id(), &Event::RunCreated {
run_id: test_run_id(),
title: None,
settings: serde_json::to_value(&run_options.settings).unwrap(),
graph: serde_json::to_value(&graph).unwrap(),
workflow_source: None,
labels: BTreeMap::new(),
source_directory: Some(workspace.display().to_string()),
workflow_slug: Some("test".to_string()),
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: run_options.fork_source_ref.clone(),
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();
seed_run_created(
&run_store,
serde_json::to_value(&run_options.settings).unwrap(),
serde_json::to_value(&graph).unwrap(),
Some(workspace.display().to_string()),
run_options.fork_source_ref.clone(),
)
.await;
initialize(persisted, InitOptions {
resume: Some(ResumeState::for_test(
@ -1266,7 +1368,7 @@ mod tests {
hooks: fabro_hooks::HookSettings { hooks: vec![] },
sandbox_env: SandboxEnvSpec {
toml_env: HashMap::new(),
github_permissions: None,
github_integration: None,
origin_url: None,
},
vault,
@ -1321,10 +1423,18 @@ mod tests {
let run_dir = temp.path().join("run");
std::fs::create_dir_all(&run_dir).unwrap();
let (graph, source) = simple_graph();
let persisted = test_persisted(graph, source, &run_dir);
let persisted = test_persisted(graph.clone(), source, &run_dir);
let emitter = Arc::new(crate::event::Emitter::new(test_run_id()));
let store = memory_store();
let run_store = store.create_run(&test_run_id()).await.unwrap();
seed_run_created(
&run_store,
serde_json::to_value(WorkflowSettings::default()).unwrap(),
serde_json::to_value(graph).unwrap(),
None,
None,
)
.await;
let store_logger = StoreProgressLogger::new(run_store.clone());
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
emitter.on_event({
@ -1361,7 +1471,7 @@ mod tests {
hooks: fabro_hooks::HookSettings { hooks: vec![] },
sandbox_env: SandboxEnvSpec {
toml_env: HashMap::new(),
github_permissions: None,
github_integration: None,
origin_url: None,
},
vault: auth_test_support::empty_vault(),
@ -1375,7 +1485,7 @@ mod tests {
})
.await
.unwrap();
store_logger.flush().await;
store_logger.flush().await.unwrap();
assert_eq!(initialized.run_options.run_dir, run_dir);
assert!(
@ -1503,7 +1613,7 @@ mod tests {
hooks: fabro_hooks::HookSettings { hooks: vec![] },
sandbox_env: SandboxEnvSpec {
toml_env: HashMap::new(),
github_permissions: None,
github_integration: None,
origin_url: None,
},
vault: auth_test_support::empty_vault(),
@ -1519,4 +1629,169 @@ mod tests {
assert!(matches!(result, Err(Error::Cancelled)));
}
mod github_integration_env {
//! Focused tests for `build_sandbox_env` /
//! `resolve_declared_repository_token` around declared additional
//! repositories. Installation-resolution failure naming is covered
//! by `fabro_github::access` tests; these prove the initialization
//! wiring: hard errors for declared sets, best-effort behavior for
//! legacy permissions-only configuration.
use fabro_github::test_support::{InstallationTokenMinter, installation_token_source};
use fabro_github::{GitHubAppCredentials, GitHubCredentials, InstallationToken};
use fabro_types::settings::run::ResolvedGithubIntegration;
use super::*;
fn integration(additional: &[&str]) -> ResolvedGithubIntegration {
ResolvedGithubIntegration {
permissions: HashMap::from([(
"contents".to_string(),
"read".to_string(),
)]),
additional_repositories: additional
.iter()
.map(|value| value.parse().expect("test slug should parse"))
.collect(),
}
}
fn spec(
origin: Option<&str>,
github_integration: Option<ResolvedGithubIntegration>,
) -> SandboxEnvSpec {
SandboxEnvSpec {
toml_env: HashMap::new(),
github_integration,
origin_url: origin.map(str::to_string),
}
}
#[test]
fn declared_additional_repositories_require_credentials() {
let spec = spec(
Some("https://github.com/fabro-sh/fabro"),
Some(integration(&["fabro-sh/keystone"])),
);
let Err(err) = build_sandbox_env(&spec, None) else {
panic!("declared additional repositories without credentials must fail");
};
assert!(
err.to_string().contains("requires GitHub credentials"),
"{err}"
);
}
#[test]
fn declared_additional_repositories_require_an_origin() {
let spec = spec(None, Some(integration(&["fabro-sh/keystone"])));
let creds = GitHubCredentials::Pat("ghp_x".to_string());
let Err(err) = build_sandbox_env(&spec, Some(&creds)) else {
panic!("declared additional repositories without an origin must fail");
};
assert!(
err.to_string().contains("GitHub repository access"),
"{err}"
);
}
#[test]
fn declared_repositories_inject_bridge_entries_and_keep_the_pat_source() {
let spec = spec(
Some("https://github.com/fabro-sh/fabro"),
Some(integration(&["fabro-sh/keystone"])),
);
let creds = GitHubCredentials::Pat("ghp_x".to_string());
let built = build_sandbox_env(&spec, Some(&creds)).unwrap();
assert!(built.github_token.is_some());
let access = built.github_access.expect("access should be constructed");
assert!(access.has_additional_repositories());
// Helper entry plus two SSH rewrites for each of the two
// effective repositories (origin + declared additional).
assert_eq!(
built.env.get("GIT_CONFIG_COUNT").map(String::as_str),
Some("5")
);
assert_eq!(
built.env.get("GIT_CONFIG_KEY_0").map(String::as_str),
Some("credential.https://github.com.helper")
);
assert_eq!(
built.env.get("GIT_TERMINAL_PROMPT").map(String::as_str),
Some("0")
);
}
#[test]
fn legacy_permissions_only_configuration_stays_best_effort() {
// No credentials: no error, no token source, no bridge entries.
let no_creds = spec(
Some("https://github.com/fabro-sh/fabro"),
Some(integration(&[])),
);
let built = build_sandbox_env(&no_creds, None).unwrap();
assert!(built.github_token.is_none());
assert!(!built.env.contains_key("GIT_CONFIG_COUNT"));
// App credentials without an origin: legacy best-effort skip.
let creds = GitHubCredentials::App(GitHubAppCredentials {
app_id: "1".to_string(),
private_key_pem: "unused".to_string(),
slug: None,
});
let no_origin = spec(None, Some(integration(&[])));
let built = build_sandbox_env(&no_origin, Some(&creds)).unwrap();
assert!(built.github_token.is_none());
assert!(built.github_access.is_none());
}
struct FailingMinter;
#[async_trait::async_trait]
impl InstallationTokenMinter for FailingMinter {
async fn mint(&self) -> anyhow::Result<InstallationToken> {
Err(anyhow::anyhow!("scripted mint failure"))
}
}
#[tokio::test]
async fn eager_validation_fails_when_the_declared_token_cannot_resolve() {
let access = fabro_github::GitHubRepositoryAccess::new(
Some("https://github.com/fabro-sh/fabro"),
&["fabro-sh/keystone".parse().unwrap()].into_iter().collect(),
HashMap::from([("contents".to_string(), "read".to_string())]),
)
.unwrap();
let built = BuiltSandboxEnv {
env: HashMap::new(),
github_token: Some(installation_token_source(
"fabro-sh/fabro (+1 additional)",
Arc::new(FailingMinter),
)),
github_access: access,
};
let err = resolve_declared_repository_token(&built).await.unwrap_err();
let message = err.to_string();
assert!(message.contains("declared repository set"), "{message}");
}
#[tokio::test]
async fn eager_validation_skips_legacy_permissions_only_runs() {
let built = BuiltSandboxEnv {
env: HashMap::new(),
github_token: Some(installation_token_source(
"fabro-sh/fabro",
Arc::new(FailingMinter),
)),
github_access: None,
};
resolve_declared_repository_token(&built)
.await
.expect("legacy permissions-only runs must not resolve eagerly");
}
}
}

View file

@ -171,6 +171,7 @@ mod tests {
graph,
graph_source: None,
workflow_slug: Some("ship".to_string()),
workflow_version_id: None,
automation: None,
source_directory: Some("/tmp/project".to_string()),
git: Some(fabro_types::GitContext {
@ -220,6 +221,7 @@ mod tests {
labels: record.labels.clone().into_iter().collect(),
source_directory: record.source_directory.clone(),
workflow_slug: record.workflow_slug.clone(),
workflow_version_id: None,
automation: record.automation.clone(),
provenance: record.provenance.clone(),
manifest_blob: None,

View file

@ -819,20 +819,21 @@ mod tests {
RunProjection::new(
"Test run".to_string(),
RunSpec {
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: None,
automation: None,
source_directory: None,
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
source_directory: None,
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
},
Utc::now(),
)
@ -1096,44 +1097,46 @@ mod tests {
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
let run_spec = RunSpec {
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: Some("test".to_string()),
automation: None,
source_directory: Some("/tmp/project".to_string()),
git: Some(fabro_types::GitContext {
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
automation: None,
source_directory: Some("/tmp/project".to_string()),
git: Some(fabro_types::GitContext {
origin_url: String::new(),
branch: "main".to_string(),
sha: None,
dirty: fabro_types::DirtyStatus::Clean,
}),
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: run_spec.source_directory.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
automation: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
spec_blob: None,
git: run_spec.git.clone(),
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: run_spec.source_directory.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
workflow_version_id: run_spec.workflow_version_id,
automation: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
spec_blob: None,
git: run_spec.git.clone(),
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();
@ -1165,44 +1168,46 @@ mod tests {
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
let run_spec = RunSpec {
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: Some("test".to_string()),
automation: None,
source_directory: Some("/tmp/project".to_string()),
git: Some(fabro_types::GitContext {
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
automation: None,
source_directory: Some("/tmp/project".to_string()),
git: Some(fabro_types::GitContext {
origin_url: String::new(),
branch: "main".to_string(),
sha: None,
dirty: fabro_types::DirtyStatus::Clean,
}),
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: run_spec.source_directory.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
automation: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
spec_blob: None,
git: run_spec.git.clone(),
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: run_spec.source_directory.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
workflow_version_id: run_spec.workflow_version_id,
automation: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
spec_blob: None,
git: run_spec.git.clone(),
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();
@ -1589,39 +1594,41 @@ mod tests {
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
let run_spec = RunSpec {
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: Some("test".to_string()),
automation: None,
source_directory: Some("/tmp/project".to_string()),
git: None,
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
automation: None,
source_directory: Some("/tmp/project".to_string()),
git: None,
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: run_spec.source_directory.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: run_spec.source_directory.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
workflow_version_id: run_spec.workflow_version_id,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();
@ -1808,39 +1815,41 @@ mod tests {
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
// Seed a completed run so the PR body can include run details.
let run_spec = RunSpec {
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: None,
automation: None,
source_directory: None,
git: None,
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
source_directory: None,
git: None,
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: None,
workflow_slug: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();

View file

@ -8,7 +8,9 @@ use fabro_mcp::config::McpServerSettings;
use fabro_model::{Catalog, ProviderId};
use fabro_sandbox::SandboxSpec;
use fabro_template::TemplateContext;
use fabro_types::settings::run::{PullRequestSettings, RunModelControls};
use fabro_types::settings::run::{
PullRequestSettings, ResolvedGithubIntegration, RunModelControls,
};
use fabro_types::{ManifestPath, RunId, RunProjection};
use fabro_validate::{Diagnostic, Severity};
use fabro_vault::Vault;
@ -246,7 +248,10 @@ pub struct LlmSpec {
#[derive(Clone)]
pub struct SandboxEnvSpec {
pub toml_env: HashMap<String, String>,
pub github_permissions: Option<HashMap<String, String>>,
/// The resolved GitHub integration request (interpolated permissions
/// plus declared additional repositories). `None` when the run requests
/// no `GITHUB_TOKEN`.
pub github_integration: Option<ResolvedGithubIntegration>,
pub origin_url: Option<String>,
}

View file

@ -490,23 +490,24 @@ mod tests {
let run_spec = sample_run_spec();
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: run_spec.source_directory.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
automation: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
spec_blob: None,
git: run_spec.git.clone(),
fork_source_ref: run_spec.fork_source_ref.clone(),
retried_from: None,
parent_id: None,
web_url: None,
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: run_spec.source_directory.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
workflow_version_id: None,
automation: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
spec_blob: None,
git: run_spec.git.clone(),
fork_source_ref: run_spec.fork_source_ref.clone(),
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();

View file

@ -680,25 +680,26 @@ mod tests {
let projection = RunProjection::new(
"Metadata".to_string(),
RunSpec {
run_id: fabro_types::fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: fabro_types::Graph::new("metadata"),
graph_source: None,
workflow_slug: Some("metadata".to_string()),
automation: None,
source_directory: Some("/Users/client/project".to_string()),
git: Some(GitContext {
run_id: fabro_types::fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: fabro_types::Graph::new("metadata"),
graph_source: None,
workflow_slug: Some("metadata".to_string()),
workflow_version_id: None,
automation: None,
source_directory: Some("/Users/client/project".to_string()),
git: Some(GitContext {
origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
branch: "main".to_string(),
sha: None,
dirty: DirtyStatus::Clean,
}),
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
},
chrono::Utc::now(),
);

View file

@ -146,23 +146,24 @@ mod tests {
async fn append_created_event(run_store: &fabro_store::RunDatabase) {
let record = test_run_spec();
append_event(run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&record.settings).unwrap(),
graph: serde_json::to_value(&record.graph).unwrap(),
workflow_source: Some("digraph test {}".to_string()),
labels: std::collections::BTreeMap::new(),
source_directory: Some("/tmp/test".to_string()),
workflow_slug: Some("test".to_string()),
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&record.settings).unwrap(),
graph: serde_json::to_value(&record.graph).unwrap(),
workflow_source: Some("digraph test {}".to_string()),
labels: std::collections::BTreeMap::new(),
source_directory: Some("/tmp/test".to_string()),
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();

View file

@ -198,20 +198,21 @@ mod tests {
fn projection_with_stages(stages: &[(&str, u32, u32)]) -> RunProjection {
let spec = RunSpec {
run_id: RunId::new(),
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: None,
automation: None,
source_directory: None,
labels: std::collections::HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
run_id: RunId::new(),
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: None,
workflow_version_id: None,
automation: None,
source_directory: None,
labels: std::collections::HashMap::new(),
provenance: test_support::test_run_provenance(),
manifest_blob: None,
definition_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
};
let mut projection = RunProjection::new(String::new(), spec, Utc::now());
for (node_id, visit, seq) in stages {

View file

@ -52,7 +52,11 @@ pub(crate) fn test_configured_provider_ids(
/// persisted before tests reopen the run store.
async fn execute_and_emit_terminal(initialized: InitializedState) -> Executed {
let executed = Box::pin(pipeline::execute(initialized.initialized)).await;
initialized.store_logger.flush().await;
initialized
.store_logger
.flush()
.await
.expect("test run events should persist");
let state = executed.engine.run.run_store.state().await.ok();
let billing = state.as_ref().and_then(billing_from_projection);
let event = build_terminal_event(
@ -65,7 +69,11 @@ async fn execute_and_emit_terminal(initialized: InitializedState) -> Executed {
billing,
);
executed.engine.run.emitter.emit(&event);
initialized.store_logger.flush().await;
initialized
.store_logger
.flush()
.await
.expect("test run events should persist");
executed
}
@ -182,34 +190,35 @@ async fn initialized(
.expect("failed to create slate-backed test run store");
let run_store = inner_store;
append_event(&run_store, &run_options.run_id, &Event::RunCreated {
run_id: run_options.run_id,
title: None,
settings: serde_json::to_value(&run_options.settings)
run_id: run_options.run_id,
title: None,
settings: serde_json::to_value(&run_options.settings)
.expect("failed to serialize settings"),
graph: serde_json::to_value(graph).expect("failed to serialize graph"),
workflow_source: None,
labels: run_options
graph: serde_json::to_value(graph).expect("failed to serialize graph"),
workflow_source: None,
labels: run_options
.labels
.clone()
.into_iter()
.collect::<BTreeMap<_, _>>(),
source_directory: Some(sandbox.working_directory().to_string()),
workflow_slug: run_options.workflow_slug.clone(),
automation: None,
provenance: fabro_types::RunProvenance {
source_directory: Some(sandbox.working_directory().to_string()),
workflow_slug: run_options.workflow_slug.clone(),
workflow_version_id: None,
automation: None,
provenance: fabro_types::RunProvenance {
server: None,
client: None,
subject: fabro_types::Principal::System {
system_kind: fabro_types::SystemActorKind::Engine,
},
},
manifest_blob: None,
spec_blob: None,
git: run_options.pre_run_git.clone(),
fork_source_ref: run_options.fork_source_ref.clone(),
retried_from: None,
parent_id: None,
web_url: None,
manifest_blob: None,
spec_blob: None,
git: run_options.pre_run_git.clone(),
fork_source_ref: run_options.fork_source_ref.clone(),
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.expect("failed to seed run.created event in run store");
@ -480,7 +489,11 @@ pub async fn run_graph_with_state_and_llm_source(
)
.await;
let executed = pipeline::execute(initialized.initialized).await;
initialized.store_logger.flush().await;
initialized
.store_logger
.flush()
.await
.expect("test run events should persist");
let outcome = executed.outcome?;
let state = executed
.engine

Some files were not shown because too many files have changed in this diff Show more