diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index ee2eb143e..3cc616b16 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -14447,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: diff --git a/docs/public/changelog/2026-08-21.mdx b/docs/public/changelog/2026-08-21.mdx new file mode 100644 index 000000000..7dac75da1 --- /dev/null +++ b/docs/public/changelog/2026-08-21.mdx @@ -0,0 +1,20 @@ +--- +title: "Additional GitHub repositories" +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. diff --git a/docs/public/docs.json b/docs/public/docs.json index 284e5e4cb..efc9025ce 100644 --- a/docs/public/docs.json +++ b/docs/public/docs.json @@ -294,6 +294,13 @@ "tab": "Changelog", "icon": "clock-rotate-left", "groups": [ + { + "group": "August 2026", + "icon": "clock-rotate-left", + "pages": [ + "changelog/2026-08-21" + ] + }, { "group": "July 2026", "icon": "clock-rotate-left", diff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx index d2ce9bd5e..4e09b13f8 100644 --- a/docs/public/execution/run-configuration.mdx +++ b/docs/public/execution/run-configuration.mdx @@ -363,6 +363,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. diff --git a/docs/public/integrations/github.mdx b/docs/public/integrations/github.mdx index a08185561..4beab0b18 100644 --- a/docs/public/integrations/github.mdx +++ b/docs/public/integrations/github.mdx @@ -228,7 +228,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 +238,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. diff --git a/docs/public/reference/user-configuration.mdx b/docs/public/reference/user-configuration.mdx index cf60ea2a4..e68f36896 100644 --- a/docs/public/reference/user-configuration.mdx +++ b/docs/public/reference/user-configuration.mdx @@ -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.]`, `[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.]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github]`, `[run.hooks]`, `[run.agent.mcps]` | | Shared LLM catalog | `[llm.providers.]`, provider-scoped `[llm.providers..models.]` 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]` | diff --git a/lib/components/fabro-github/tests/live_access.rs b/lib/components/fabro-github/tests/live_access.rs new file mode 100644 index 000000000..2440b6547 --- /dev/null +++ b/lib/components/fabro-github/tests/live_access.rs @@ -0,0 +1,146 @@ +//! 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 base64::engine::general_purpose::STANDARD; +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")) +} + +fn private_key_pem() -> String { + let raw = env_var("GITHUB_APP_PRIVATE_KEY"); + if raw.starts_with("-----") { + return raw; + } + let bytes = base64::Engine::decode(&STANDARD, &raw) + .expect("GITHUB_APP_PRIVATE_KEY is not valid base64"); + String::from_utf8(bytes).expect("GITHUB_APP_PRIVATE_KEY decoded to invalid UTF-8") +} + +async fn ls_remote_with_token(slug: &GitHubRepositorySlug, token: &str) -> bool { + let url = format!("https://github.com/{}/{}", slug.owner(), slug.repo()); + let output = Command::new("git") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GITHUB_TOKEN", token) + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "credential.https://github.com.helper") + .env( + "GIT_CONFIG_VALUE_0", + r#"!f() { if [ "$1" = get ]; then echo username=x-access-token; echo "password=$GITHUB_TOKEN"; fi; }; f"#, + ) + .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 = GitHubAppCredentials { + app_id: env_var("FABRO_TEST_GITHUB_APP_ID"), + private_key_pem: private_key_pem(), + slug: None, + }; + 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 = [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"); + + // Every target resolves to one shared installation. + access + .resolve_shared_installation_via_api(&app) + .await + .expect("all repositories should share one App installation"); + + // One mint scoped to the whole effective set. + let source = InstallationTokenSource::for_access(&GitHubCredentials::App(app.clone()), &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" + ); +} diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index 0fb3cccd0..73f0bfd19 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -208,6 +208,19 @@ fn main() { ("DiffSummary", "fabro_types::DiffSummary", &[]), ("RepositoryRef", "fabro_types::RepositoryRef", &[]), ("WorkflowSettings", "fabro_types::WorkflowSettings", &[]), + // Run-level GitHub integration settings reuse the canonical resolved + // types instead of generating parallel API DTOs; the wire shape is + // identical (InterpString serializes as its source string). + ( + "RunIntegrationsSettings", + "fabro_types::settings::run::RunIntegrationsSettings", + &[], + ), + ( + "RunIntegrationsGithubSettings", + "fabro_types::settings::run::RunIntegrationsGithubSettings", + &[], + ), ("ServerSettings", "fabro_types::ServerSettings", &[]), ( "ServerNamespace", diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index c53e682c7..a845a8252 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -25,7 +25,10 @@ pub mod types { ReasoningEffortFeature, Speed as BillingSpeed, TokenCounts as CompletionUsage, }; pub use fabro_types::run_event::AgentSessionActivatedProps; - pub use fabro_types::settings::run::{McpHttpProtocol, RunModelControls, RunModelSettings}; + pub use fabro_types::settings::run::{ + McpHttpProtocol, RunIntegrationsGithubSettings, RunIntegrationsSettings, RunModelControls, + RunModelSettings, + }; pub use fabro_types::settings::server::{ GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings, LogDestination, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings, diff --git a/lib/foundation/fabro-api/tests/run_integrations_round_trip.rs b/lib/foundation/fabro-api/tests/run_integrations_round_trip.rs index 243268f1c..bd5af9330 100644 --- a/lib/foundation/fabro-api/tests/run_integrations_round_trip.rs +++ b/lib/foundation/fabro-api/tests/run_integrations_round_trip.rs @@ -1,8 +1,10 @@ -//! JSON parity test for `RunIntegrationsGithubSettings`. +//! JSON parity and type-identity tests for `RunIntegrationsGithubSettings`. //! -//! Asserts that the API-side generated `RunIntegrationsGithubSettings` and -//! the canonical Rust resolved type round-trip through the same JSON shape. -//! Covers both the populated and empty-permissions cases. +//! Asserts that the API-side `RunIntegrationsGithubSettings` is the +//! canonical Rust resolved type (via `with_replacement` in `build.rs`) and +//! that both names round-trip through the same JSON shape. Covers the +//! populated and empty permissions cases as well as populated and empty +//! `additional_repositories` sets. use fabro_api::types::{ RunIntegrationsGithubSettings as ApiRunIntegrationsGithubSettings, @@ -11,6 +13,22 @@ use fabro_api::types::{ use fabro_types::settings::run::{RunIntegrationsGithubSettings, RunIntegrationsSettings}; use serde_json::json; +/// Type-identity witnesses: the generated API names are the canonical Rust +/// types, not parallel DTOs. Compiles only when they are the same type. +#[expect(dead_code, reason = "compile-time type-identity witness")] +fn github_settings_type_identity( + value: ApiRunIntegrationsGithubSettings, +) -> RunIntegrationsGithubSettings { + value +} + +#[expect(dead_code, reason = "compile-time type-identity witness")] +fn integrations_settings_type_identity( + value: ApiRunIntegrationsSettings, +) -> RunIntegrationsSettings { + value +} + #[test] fn run_integrations_github_settings_round_trips_with_permissions() { let json_value = json!({ @@ -44,6 +62,43 @@ fn run_integrations_github_settings_round_trips_empty_permissions() { assert_eq!(serde_json::to_value(&canonical).unwrap(), json_value); } +#[test] +fn run_integrations_github_settings_round_trips_additional_repositories() { + let json_value = json!({ + "permissions": { "contents": "read" }, + "additional_repositories": ["fabro-sh/arc", "fabro-sh/keystone"], + }); + + let api: ApiRunIntegrationsGithubSettings = + serde_json::from_value(json_value.clone()).expect("api type should parse repositories"); + let canonical: RunIntegrationsGithubSettings = + serde_json::from_value(json_value.clone()).expect("canonical type should parse"); + + assert_eq!(serde_json::to_value(&api).unwrap(), json_value); + assert_eq!(serde_json::to_value(&canonical).unwrap(), json_value); +} + +#[test] +fn run_integrations_github_settings_omits_an_empty_repository_set() { + // An absent field and an explicit empty array both deserialize to the + // empty set, and the empty set serializes back with the field omitted — + // keeping single-repository settings byte-identical to older releases. + let empty_array = json!({ + "permissions": {}, + "additional_repositories": [], + }); + let omitted = json!({ "permissions": {} }); + + let api: ApiRunIntegrationsGithubSettings = + serde_json::from_value(empty_array).expect("api type should parse an empty array"); + let canonical: RunIntegrationsGithubSettings = + serde_json::from_value(omitted.clone()).expect("canonical type should parse"); + + assert!(api.additional_repositories.is_empty()); + assert_eq!(serde_json::to_value(&api).unwrap(), omitted); + assert_eq!(serde_json::to_value(&canonical).unwrap(), omitted); +} + #[test] fn run_integrations_settings_round_trips() { let json_value = json!({ diff --git a/lib/packages/fabro-api-client/src/models/run-integrations-github-settings.ts b/lib/packages/fabro-api-client/src/models/run-integrations-github-settings.ts index a28ae5b4b..df9e432ba 100644 --- a/lib/packages/fabro-api-client/src/models/run-integrations-github-settings.ts +++ b/lib/packages/fabro-api-client/src/models/run-integrations-github-settings.ts @@ -16,4 +16,8 @@ export interface RunIntegrationsGithubSettings { 'permissions': { [key: string]: string; }; + /** + * 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. + */ + 'additional_repositories'?: Array; } diff --git a/lib/packages/fabro-api-client/src/models/run-spec.ts b/lib/packages/fabro-api-client/src/models/run-spec.ts index a9ed38078..a9e41bec6 100644 --- a/lib/packages/fabro-api-client/src/models/run-spec.ts +++ b/lib/packages/fabro-api-client/src/models/run-spec.ts @@ -39,7 +39,7 @@ export interface RunSpec { 'graph_source'?: string | null; 'workflow_slug'?: string | null; /** - * SHA-256 identity of validated canonical workflow-version bytes. + * SHA-256 identity of validated canonical workflow-version bytes. Hex input is case-insensitive; Fabro emits the canonical lowercase form. */ 'workflow_version_id'?: string | null; 'automation'?: AutomationRef | null;