This commit is contained in:
Dávid Balatoni 2026-10-27 12:02:23 +01:00 • committed by GitHub
commit 9e2fabe931
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 2761 additions and 607 deletions

View file

@ -25,6 +25,31 @@ describe("automation workflow source form values", () => {
expect(workflowSourceFromFormValues(values)).toBeUndefined();
});
test("create-from-run reads the repository from origins on the configured GitHub host", () => {
const fromOrigin = (originUrl: string, githubWebUrl?: string) =>
automationFormValuesFromRun(
{
title: "Release",
workflow: { name: "Release", graph_name: "release", slug: "release" },
repository: { name: "", origin_url: originUrl },
sandbox: null,
} as any,
null,
null,
undefined,
githubWebUrl,
).targetRepository;
expect(fromOrigin("https://github.com/acme/widgets.git")).toBe("acme/widgets");
expect(fromOrigin("git@github.com:acme/widgets.git")).toBe("acme/widgets");
expect(fromOrigin("https://ghe.example.com/acme/widgets.git")).toBe("");
const ghes = "https://ghe.example.com";
expect(fromOrigin("https://ghe.example.com/acme/widgets.git", ghes)).toBe("acme/widgets");
expect(fromOrigin("git@ghe.example.com:acme/widgets.git", ghes)).toBe("acme/widgets");
expect(fromOrigin("https://github.com/acme/widgets.git", ghes)).toBe("");
});
test("branch, tag, and SHA selectors serialize with target precedence", () => {
const base = {
...EMPTY_AUTOMATION_FORM,

View file

@ -100,6 +100,7 @@ export function automationFormValuesFromRun(
runState?: RunProjection | null,
settings?: WorkflowSettings | null,
environments?: Environment[],
githubWebUrl: string = DEFAULT_GITHUB_WEB_URL,
): AutomationFormValues {
const name = firstPresentString(
run.title,
@ -117,7 +118,7 @@ export function automationFormValuesFromRun(
const targetRepository = canonicalTarget?.repo
?? githubRepositoryFromSettings(settings)
?? githubRepositoryName(run.repository?.name)
?? githubRepositoryFromOriginUrl(run.repository?.origin_url)
?? githubRepositoryFromOriginUrl(run.repository?.origin_url, githubWebUrl)
?? "";
const cloneBranch = sandboxRuntime(run.sandbox)?.clone_branch;
const sourceEnvironment = settings?.run?.environment;
@ -245,20 +246,30 @@ function githubRepositoryName(value: string | null | undefined): string | null {
return `${match[1]}/${match[2]}`;
}
function githubRepositoryFromOriginUrl(value: string | null | undefined): string | null {
const DEFAULT_GITHUB_WEB_URL = "https://github.com";
function githubRepositoryFromOriginUrl(
value: string | null | undefined,
githubWebUrl: string,
): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;
const scpLikeMatch = trimmed.match(
/^git@github\.com:([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/([A-Za-z0-9._-]+?)(?:\.git)?$/i,
);
if (scpLikeMatch) {
return githubRepositoryName(`${scpLikeMatch[1]}/${scpLikeMatch[2]}`);
let githubHostname: string;
try {
githubHostname = new URL(githubWebUrl).hostname.toLowerCase();
} catch {
return null;
}
const scpPrefix = `git@${githubHostname}:`;
if (trimmed.toLowerCase().startsWith(scpPrefix)) {
return githubRepositoryName(trimmed.slice(scpPrefix.length));
}
try {
const url = new URL(trimmed);
if (url.hostname.toLowerCase() !== "github.com") return null;
if (url.hostname.toLowerCase() !== githubHostname) return null;
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length !== 2) return null;
return githubRepositoryName(`${parts[0]}/${parts[1]}`);

View file

@ -13,6 +13,7 @@ const INSTALL_ERROR_MESSAGE =
const INSTALL_PREFILL = {
canonical_url: "https://fabro.example.com",
object_store_local_root: "/home/test/.fabro/storage/objects",
github_web_url: "https://github.com",
};
const SESSION_RESPONSE = {
@ -243,6 +244,67 @@ describe("InstallApp", () => {
}
});
test.each([
["https://github.com", "github.com/settings/tokens"],
["https://ghe.example.com", "ghe.example.com/settings/tokens"],
])("links the GitHub token page on %s", async (githubWebUrl, label) => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;
console.error = ((...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("react-test-renderer is deprecated")
) {
return;
}
originalConsoleError(...args);
}) as typeof console.error;
try {
const fetchMock = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
...SESSION_RESPONSE,
prefill: { ...INSTALL_PREFILL, github_web_url: githubWebUrl },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
),
);
useInstallFetchMock(fetchMock as unknown as typeof fetch);
const testWindow = createTestWindow("https://fabro.example.com/install/github");
testWindow.sessionStorage.setItem("fabro-install-token", "test-install-token");
(globalThis as { window?: unknown }).window = testWindow;
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = createInstallRenderer(
<MemoryRouter initialEntries={["/install/github"]}>
<Routes>
<Route path="/install/*" element={<InstallApp />} />
</Routes>
</MemoryRouter>,
);
});
await waitFor(() => {
expect(renderTreeText(renderer!.toJSON())).toContain("Connect GitHub");
});
const link = renderer!.root.findAll(
(node) => node.type === "a" && node.props.href === `${githubWebUrl}/settings/tokens`,
);
expect(link.length).toBeGreaterThan(0);
expect(renderTreeText(renderer!.toJSON())).toContain(label);
await act(async () => {
renderer?.unmount();
});
} finally {
console.error = originalConsoleError;
}
});
test("renders the GitHub App done screen after the manifest callback resolves", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const originalConsoleError = console.error;

View file

@ -1148,6 +1148,7 @@ function GithubStep({
runStepSubmit: RunStepSubmit;
dispatchInstall: (action: InstallAction) => void;
}) {
const tokensUrl = `${session.prefill.github_web_url}/settings/tokens`;
return (
<StepPanel
title="Connect GitHub"
@ -1267,8 +1268,8 @@ function GithubStep({
Create a fine-grained or classic token with{" "}
<code className="font-mono text-fg-2">repo</code> scope.
</p>
<ExternalLink href="https://github.com/settings/tokens">
github.com/settings/tokens
<ExternalLink href={tokensUrl}>
{tokensUrl.replace(/^https?:\/\//, "")}
</ExternalLink>
</HelpDisclosure>
</div>

View file

@ -15,6 +15,7 @@ let currentRunStateLoading = false;
let currentRunSettings: any = null;
let currentEnvironments: any[] = [];
let currentEnvironmentsError: unknown = null;
let currentGithubWebUrl = "https://github.com";
const queryCalls: Array<{ hook: string; id: string | undefined }> = [];
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
let teardownReactEnv: (() => void) | undefined;
@ -67,6 +68,11 @@ mock.module("../lib/queries", () => ({
isLoading: false,
};
},
useServerSettings: () => ({
data: { server: { integrations: { github: { web_url: currentGithubWebUrl } } } },
error: null,
isLoading: false,
}),
useRunState: (id: string | undefined) => {
queryCalls.push({ hook: "useRunState", id });
return {
@ -277,6 +283,7 @@ beforeEach(() => {
currentRunState = null;
currentRunStateLoading = false;
currentRunSettings = null;
currentGithubWebUrl = "https://github.com";
currentEnvironments = [
{ id: "default", provider: "docker" },
{ id: "daytona-smoke", provider: "daytona" },
@ -416,6 +423,21 @@ describe("AutomationsNew", () => {
);
});
test("run origin on the configured GitHub Enterprise host prefills the target repository", async () => {
currentGithubWebUrl = "https://ghe.example.com";
currentRun = makeRun({
repository: {
name: "",
origin_url: "https://ghe.example.com/acme/widgets.git",
provider: "github",
},
});
const { renderer } = await renderAutomationsNew("/automations/new?from_run=run_1");
expect(fieldValue(renderer, "Run target repository")).toBe("acme/widgets");
});
test("automationFormValuesFromRun kebab-cases the workflow name fallback", () => {
const run = makeRun({
workflow: {

View file

@ -6,7 +6,13 @@ import type { Environment } from "@qltysh/fabro-api-client";
import { ApiError, apiData, automationsApi } from "../lib/api-client";
import { queryKeys } from "../lib/query-keys";
import { useEnvironments, useRun, useRunSettings, useRunState } from "../lib/queries";
import {
useEnvironments,
useRun,
useRunSettings,
useRunState,
useServerSettings,
} from "../lib/queries";
import {
AutomationFormFields,
EMPTY_AUTOMATION_FORM,
@ -37,6 +43,7 @@ export default function AutomationsNew() {
const runStateQuery = useRunState(fromRunId);
const settingsQuery = useRunSettings(fromRunId);
const environmentsQuery = useEnvironments();
const serverSettingsQuery = useServerSettings();
const environments = environmentsQuery.data?.data;
const environmentsPending = environmentsQuery.isLoading && !environmentsQuery.data;
const environmentsError = Boolean(environmentsQuery.error);
@ -86,6 +93,7 @@ export default function AutomationsNew() {
runStateQuery.data ?? null,
settingsQuery.data ?? null,
environments,
serverSettingsQuery.data?.server.integrations.github.web_url,
);
return (

View file

@ -303,8 +303,8 @@ When a field is not set, Fabro derives it from the run's GitHub credential:
| Credential | Name | Email |
|---|---|---|
| GitHub App (`strategy = "app"`) | `<slug>[bot]` | `<id>+<slug>[bot]@users.noreply.github.com` |
| Token (`strategy = "token"`) | the token's user login | `<id>+<login>@users.noreply.github.com` |
| GitHub App (`strategy = "app"`) | `<slug>[bot]` | `<id>+<slug>[bot]@users.noreply.github.com` (`users.noreply.<host>` on an enterprise host) |
| Token (`strategy = "token"`) | the token's user login | `<id>+<login>@users.noreply.github.com` (`users.noreply.<host>` on an enterprise host) |
| None | `Fabro` | `noreply@fabro.sh` |
Setting both `name` and `email` skips the credential lookup. Setting one field overlays it on the derived identity. A lookup failure for the selected credential fails the run at setup; Fabro never silently switches to another author. The resolved identity is recorded in the run's event stream as `git.identity.resolved` and in the run state as `git_identity`.
@ -323,6 +323,15 @@ Configure GitHub integration auth. `strategy = "token"` is the default and uses
strategy = "token"
```
| Key | Description | Default |
|---|---|---|
| `strategy` | `token` or `app` | `token` |
| `app_id`, `client_id`, `slug` | GitHub App identity (App mode) | none |
| `web_url` | Web origin of the GitHub deployment, such as `https://ghe.example.com`; must be `https` (`http` only for loopback). Override with `GITHUB_WEB_URL`. Read at server startup: changing it needs a restart | `https://github.com` |
| `api_url` | REST API base URL; may be plain `http` (for a proxy or mock). Override with `GITHUB_BASE_URL` | derived from `web_url` (`https://api.github.com`, `https://api.<host>` for `*.ghe.com`, `<web_url>/api/v3` otherwise) |
For GitHub Enterprise Server or GitHub Enterprise Cloud, set `web_url` (see [GitHub Enterprise Server and GitHub Enterprise Cloud](/integrations/github#github-enterprise-server-and-github-enterprise-cloud)). The CLI and the run worker read the host from the local `settings.toml` or `GITHUB_WEB_URL`. Put `web_url` in `settings.toml` before `fabro install`; the installer does not persist it from the environment.
For GitHub App mode, set `strategy = "app"` and include `app_id`, `client_id`, and `slug`. Webhook delivery is configured under `[server.integrations.github.webhooks]`:
```toml title="settings.toml"

View file

@ -6638,12 +6638,17 @@ components:
required:
- canonical_url
- object_store_local_root
- github_web_url
properties:
canonical_url:
type: string
format: uri
object_store_local_root:
type: string
github_web_url:
type: string
description: Web origin of the GitHub deployment the install targets, without a trailing slash.
example: https://github.com
InstallLlmValidationResponse:
description: Successful response from install-time LLM credential validation.
@ -15672,7 +15677,17 @@ components:
- client_id
- slug
- webhooks
- web_url
- api_url
properties:
web_url:
type: string
description: Web origin of the GitHub deployment, without a trailing slash.
example: https://github.com
api_url:
type: string
description: REST API base URL of the GitHub deployment, without a trailing slash.
example: https://api.github.com
enabled:
type: boolean
strategy:

View file

@ -0,0 +1,22 @@
---
title: "GitHub Enterprise Server and Enterprise Cloud hosts"
date: "2026-10-25"
---
Fabro can now target a GitHub Enterprise Server instance or a GitHub
Enterprise Cloud tenant with data residency (`https://<subdomain>.ghe.com`)
instead of github.com. Set the deployment's web origin once and Fabro derives
the REST, GraphQL and OAuth URLs, clone URLs, pull request links, Git
credential scoping and the sandbox `gh` configuration from it:
```toml
[server.integrations.github]
web_url = "https://ghe.example.com"
```
`GITHUB_WEB_URL` overrides the setting, and the existing `GITHUB_BASE_URL`
still overrides the REST API base. Without the new setting, github.com
behavior is unchanged.
See [GitHub Enterprise Server and GitHub Enterprise Cloud](/integrations/github#github-enterprise-server-and-github-enterprise-cloud)
for setup, custom certificate authorities and known limitations.

View file

@ -303,6 +303,13 @@
"tab": "Changelog",
"icon": "clock-rotate-left",
"groups": [
{
"group": "October 2026",
"icon": "clock-rotate-left",
"pages": [
"changelog/2026-10-25"
]
},
{
"group": "September 2026",
"icon": "clock-rotate-left",

View file

@ -121,6 +121,7 @@ slug = "fabro-a3f2"
| `app_id` | Numeric GitHub App ID |
| `client_id` | OAuth Client ID for the app |
| `slug` | App slug, used for linking to the GitHub App settings page |
| `web_url`, `api_url` | Optional. Target GitHub Enterprise Server or Enterprise Cloud instead of github.com; see [GitHub Enterprise Server and GitHub Enterprise Cloud](#github-enterprise-server-and-github-enterprise-cloud) |
### Server vault
@ -174,6 +175,75 @@ fabro install github
This walks through just the GitHub setup steps — choosing a strategy, registering an app, or storing a token — and restarts the server when done. Stale settings and secrets from the previous strategy are cleaned up automatically.
## GitHub Enterprise Server and GitHub Enterprise Cloud
Fabro talks to github.com by default. To use a GitHub Enterprise Server (GHES) instance, or a GitHub Enterprise Cloud tenant with data residency (`https://<subdomain>.ghe.com`), set the web origin of that deployment. Fabro derives every other URL from it.
```toml title="settings.toml"
[server.integrations.github]
web_url = "https://ghe.example.com"
# api_url = "https://ghe.example.com/api/v3" # optional override
```
| Setting | Environment override | Description |
|---|---|---|
| `server.integrations.github.web_url` | `GITHUB_WEB_URL` | Web origin of the GitHub deployment, for example `https://ghe.example.com`. Defaults to `https://github.com`. Must be `https` and must not contain a path, query or fragment (plain `http` is accepted only for loopback hosts such as `localhost` and `127.0.0.1`). |
| `server.integrations.github.api_url` | `GITHUB_BASE_URL` | REST API base URL. Optional: when unset it is derived from `web_url` as shown below. May be plain `http` on any host, for example to reach GitHub through a proxy or a mock; `web_url` must still be `https`. |
The environment variables win over `settings.toml`. `GITHUB_WEB_URL` alone re-derives the API URL from the new web origin; `GITHUB_BASE_URL` alone replaces only the API URL.
The server reads the host once at startup, so changing `web_url` or `api_url` takes effect only after a server restart.
### Derived URLs
| | github.com | GitHub Enterprise Cloud (`https://octocorp.ghe.com`) | GitHub Enterprise Server (`https://ghe.example.com`) |
|---|---|---|---|
| REST API | `https://api.github.com` | `https://api.octocorp.ghe.com` | `https://ghe.example.com/api/v3` |
| GraphQL | `https://api.github.com/graphql` | `https://api.octocorp.ghe.com/graphql` | `https://ghe.example.com/api/graphql` |
| OAuth | `https://github.com/login/oauth/...` | `https://octocorp.ghe.com/login/oauth/...` | `https://ghe.example.com/login/oauth/...` |
| Repository clone URL | `https://github.com/owner/repo` | `https://octocorp.ghe.com/owner/repo` | `https://ghe.example.com/owner/repo` |
| Pull request link | `https://github.com/owner/repo/pull/7` | `https://octocorp.ghe.com/owner/repo/pull/7` | `https://ghe.example.com/owner/repo/pull/7` |
| Commit email domain | `users.noreply.github.com` | `users.noreply.octocorp.ghe.com` | `users.noreply.ghe.example.com` |
A host named exactly `github.com` is treated as github.com, `ghe.com` or `*.ghe.com` as GitHub Enterprise Cloud, and any other host as GitHub Enterprise Server.
### Where the host is read
The server, the run worker and the CLI all need to agree on the host:
- The **server** reads `web_url` and `api_url` from its `settings.toml` and the environment overrides above. The server passes `GITHUB_WEB_URL` and `GITHUB_BASE_URL` on to run workers it starts.
- The **run worker** reads the same `settings.toml` (through `FABRO_HOME`) and the same environment variables.
- The **CLI** reads the host from the local `settings.toml` or from `GITHUB_WEB_URL`; it does not ask a remote server. If you run `fabro` against a remote server, set the same `web_url` in your local `settings.toml` (or export `GITHUB_WEB_URL`) so repository URLs, `fabro install` and `fabro doctor` target the enterprise host.
Put `web_url` in `settings.toml` before running `fabro install`. The installer does not persist the host, so `GITHUB_WEB_URL` alone is not enough: a server restarted without that variable falls back to github.com. The installer uses it for `gh auth token --hostname <host>`, for the GitHub App manifest registration and for the links it prints. The `App:` link it prints points at `/github-apps/<slug>` on GitHub Enterprise Server and at `/apps/<slug>` on github.com and GitHub Enterprise Cloud.
### Authentication
- **Tokens.** Personal access tokens, fine-grained tokens and OAuth tokens use the same `ghp_`, `github_pat_`, `gho_`, `ghu_` and `ghs_` formats on GHES and GHEC. `fabro install` runs `gh auth token --hostname <host>`, so run `gh auth login --hostname ghe.example.com` first.
- **GitHub App.** `fabro install` registers the App through the manifest flow against `<web_url>/settings/apps/new` (or the organization equivalent) and exchanges the code on the host's API. This needs a GHES version that supports GitHub App manifests. If your instance does not, register the App by hand and set `app_id`, `client_id` and `slug` under `[server.integrations.github]`, then store the private key and client secret as described in [Server vault](#server-vault).
- **Browser sign-in.** OAuth login redirects to `<web_url>/login/oauth/authorize`, and sessions record the enterprise host as the identity issuer.
### Custom certificate authorities
Enterprise hosts often use a certificate from a private CA. Fabro always verifies TLS certificates and has no setting to disable verification.
Fabro's HTTP client uses rustls with the platform certificate verifier:
- **Linux:** `SSL_CERT_FILE` and `SSL_CERT_DIR` are honoured, in addition to the system trust store. The server passes both on to the run workers it starts.
- **macOS:** the system Keychain is used. **Windows:** the Windows certificate store is used. The variables are not read on either platform, so install the CA in the system store.
- **Sandboxes:** containers do not inherit the host's trust store. Bake the CA into the sandbox image, or set `SSL_CERT_FILE` and `GIT_SSL_CAINFO` in the sandbox environment so Git and the `gh` CLI trust it.
### The `gh` CLI in sandboxes
The `gh` CLI reads `GITHUB_TOKEN` only for github.com. For any other host Fabro also injects `GH_HOST=<host>` and `GH_ENTERPRISE_TOKEN=<token>` next to `GITHUB_TOKEN`, so `gh pr list` and similar commands in command stages work without `gh auth login`. Git's credential helper and the `git@<host>:` and `ssh://git@<host>/` rewrites are scoped to the configured host.
### Known limitations
- One GitHub host per server. Repositories on any other host (including github.com when `web_url` points at an enterprise host) are rejected.
- Features that an older GHES release does not provide, such as newer App permissions or auto-merge, surface as the error GitHub returns.
- Avatars and the Content Security Policy follow the configured host: for a non-github.com host, `img-src` allows the web origin and `https://avatars.<host>` instead of `avatars.githubusercontent.com`. A GHES instance that serves avatars from another origin shows broken avatar images.
- The Fabro CLI release channel and documentation links stay on github.com.
## `token` mode
Choose **GitHub CLI** in `fabro install` to use the default local-user flow. The installer:

View file

@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use anyhow::Result;
use fabro_api::types as api_types;
use fabro_config::user::active_settings_path;
use fabro_github::GitHubHost;
use fabro_types::settings::replace_wildcard_host;
pub(crate) use fabro_util::check_report::{
CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus,
@ -125,10 +126,14 @@ fn wildcard_public_url_details(path: &Path) -> Vec<CheckDetail> {
.unwrap_or(&bad_urls[0])
.suggestion
.as_str();
let github_host = toml_string_at(&doc, &["server", "integrations", "github", "web_url"])
.and_then(|web_url| GitHubHost::new(web_url, None).ok())
.and_then(|host| fabro_github::with_env_overrides(&host).ok())
.unwrap_or_default();
let github_settings_url = toml_string_at(&doc, &["server", "integrations", "github", "slug"])
.map_or_else(
|| "the GitHub App settings page".to_string(),
|slug| format!("https://github.com/settings/apps/{slug}"),
|slug| format!("{}/settings/apps/{slug}", github_host.web_url()),
);
details.push(CheckDetail {
text: format!(
@ -470,6 +475,40 @@ url = "http://0.0.0.0:32276"
assert!(details.contains("https://github.com/settings/apps/octocat-fabro"));
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "unit test stages a temporary settings.toml fixture with sync std::fs"
)]
fn check_config_links_the_github_app_on_the_enterprise_host() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.toml");
std::fs::write(
&settings_path,
r#"
_version = 1
[server.web]
url = "http://0.0.0.0:32276"
[server.integrations.github]
slug = "octocat-fabro"
web_url = "https://ghe.example.com"
"#,
)
.unwrap();
let result = check_config(Some(settings_path));
let details = result
.details
.iter()
.map(|detail| detail.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(details.contains("https://ghe.example.com/settings/apps/octocat-fabro"));
}
#[test]
fn check_version_parity_warns_on_mismatch() {
let result = check_version_parity("0.0.0-test");

View file

@ -22,6 +22,7 @@ use crate::args::{GraphArgs, GraphDirection, GraphOutputFormat};
use crate::command_context::CommandContext;
use crate::commands::run::output::api_diagnostics_to_local;
use crate::shared::{absolute_or_current, print_diagnostics, print_json_pretty, relative_path};
use crate::user_config;
pub(crate) async fn run(
args: &GraphArgs,
@ -39,6 +40,7 @@ pub(crate) async fn run(
cwd: ctx.cwd().to_path_buf(),
environment_defaults: fabro_environment::seeded_catalog_layer(),
user_settings_path: Some(active_settings_path(None)),
github_host: user_config::github_host()?,
..Default::default()
})?;
let client = ctx.server().await?;

View file

@ -27,6 +27,7 @@ use fabro_config::bind::Bind;
use fabro_config::daemon::ServerDaemon;
use fabro_config::user::{SETTINGS_CONFIG_FILENAME, default_storage_dir};
use fabro_config::{Storage, UserSettingsBuilder, envfile};
use fabro_github::GitHubHost;
use fabro_install::{
GITHUB_APP_VAULT_KEYS, GITHUB_INSTALL_SECRET_KEYS, InstallListenConfig, InstallPersistencePlan,
PendingDevTokenWrite, PendingSettingsWrite, SecretStoreWrite,
@ -498,7 +499,8 @@ impl InstallInputSource for InteractiveInstallInputSource {
s: &Styles,
_printer: Printer,
) -> Result<GitHubInstallSelection> {
let gh_available = GhCli::detect().await.is_some();
let github_host = user_config::github_host()?;
let gh_available = GhCli::detect(&github_host).await.is_some();
let token_label = if gh_available {
"Personal Access Token — use your existing `gh` login"
@ -518,7 +520,7 @@ impl InstallInputSource for InteractiveInstallInputSource {
match strategy {
0 => {
let token = if gh_available {
fabro_github::gh_auth_token()
fabro_github::gh_auth_token(&github_host)
.await
.context("Run `gh auth login` and rerun `fabro install`.")?
} else {
@ -528,7 +530,7 @@ impl InstallInputSource for InteractiveInstallInputSource {
Ok(GitHubInstallSelection::Token { token })
}
1 => {
let (owner, username) = prompt_github_app_owner(s).await?;
let (owner, username) = prompt_github_app_owner(s, &github_host).await?;
Ok(GitHubInstallSelection::App { owner, username })
}
_ => unreachable!("prompt_select returned an out-of-range index"),
@ -694,9 +696,10 @@ impl InstallInputSource for NonInteractiveInstallInputSource {
_s: &Styles,
_printer: Printer,
) -> Result<GitHubInstallSelection> {
let github_host = user_config::github_host()?;
match self.args.github_strategy {
Some(InstallGitHubStrategyArg::Token) => {
let token = fabro_github::gh_auth_token()
let token = fabro_github::gh_auth_token(&github_host)
.await
.context("Run `gh auth login` and rerun `fabro install`.")?;
fabro_github::validate_static_github_token(&token)?;
@ -708,7 +711,7 @@ impl InstallInputSource for NonInteractiveInstallInputSource {
"non-interactive install requires --github-owner for --github-strategy app",
)?,
)?,
username: best_effort_github_username().await,
username: best_effort_github_username(&github_host).await,
}),
None => bail!("non-interactive install requires --github-strategy"),
}
@ -780,9 +783,10 @@ async fn choose_install_github_selection(
return input.choose_github_install(s, printer).await;
}
let github_host = user_config::github_host()?;
match github_args.strategy {
Some(InstallGitHubStrategyArg::Token) => {
let token = fabro_github::gh_auth_token()
let token = fabro_github::gh_auth_token(&github_host)
.await
.context("Run `gh auth login` and rerun `fabro install github`.")?;
fabro_github::validate_static_github_token(&token)?;
@ -792,7 +796,7 @@ async fn choose_install_github_selection(
owner: GitHubAppOwner::parse_scripted(github_args.owner.as_deref().context(
"install github --non-interactive requires --owner for --strategy app",
)?)?,
username: best_effort_github_username().await,
username: best_effort_github_username(&github_host).await,
}),
None => bail!("install github --non-interactive requires --strategy"),
}
@ -824,11 +828,12 @@ impl GitHubAppOwner {
Ok(Self::Organization(org.to_string()))
}
fn manifest_form_action(&self) -> String {
fn manifest_form_action(&self, github_host: &GitHubHost) -> String {
let web_url = github_host.web_url();
match self {
Self::Personal => "https://github.com/settings/apps/new".to_string(),
Self::Personal => format!("{web_url}/settings/apps/new"),
Self::Organization(org) => {
format!("https://github.com/organizations/{org}/settings/apps/new")
format!("{web_url}/organizations/{org}/settings/apps/new")
}
}
}
@ -860,8 +865,8 @@ impl GitHubAppOwner {
}
}
async fn best_effort_github_username() -> Option<String> {
let gh = GhCli::detect().await?;
async fn best_effort_github_username(github_host: &GitHubHost) -> Option<String> {
let gh = GhCli::detect(github_host).await?;
gh.authenticated_user().await
}
@ -872,10 +877,13 @@ async fn best_effort_github_username() -> Option<String> {
/// Always offers a manual "Other" option so org app managers can enter a slug.
///
/// Returns `(owner, username)`.
async fn prompt_github_app_owner(_s: &Styles) -> Result<(GitHubAppOwner, Option<String>)> {
async fn prompt_github_app_owner(
_s: &Styles,
github_host: &GitHubHost,
) -> Result<(GitHubAppOwner, Option<String>)> {
let spinner = cyan_spinner("Checking GitHub CLI...");
let Some(gh) = GhCli::detect().await else {
let Some(gh) = GhCli::detect(github_host).await else {
spinner.finish_and_clear();
return Ok((GitHubAppOwner::Personal, None));
};
@ -974,6 +982,7 @@ async fn setup_github_app(
printer: Printer,
) -> Result<GitHubAppRegistration> {
let app_name = owner.app_name(username);
let github_host = user_config::github_host()?;
// Bind to random port
let listener = TcpListener::bind("127.0.0.1:0")
@ -999,7 +1008,7 @@ async fn setup_github_app(
let code_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(code_tx)));
let shutdown_tx = std::sync::Arc::new(std::sync::Mutex::new(Some(shutdown_tx)));
let form_action = owner.manifest_form_action();
let form_action = owner.manifest_form_action(&github_host);
let index_html = format!(
r#"<!DOCTYPE html>
<html>
@ -1117,7 +1126,8 @@ async fn setup_github_app(
let client = fabro_http::http_client()?;
let resp = client
.post(format!(
"https://api.github.com/app-manifests/{code}/conversions"
"{}/app-manifests/{code}/conversions",
github_host.api_url()
))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "fabro-cli")
@ -1159,7 +1169,7 @@ async fn setup_github_app(
printer,
" {}",
s.dim
.apply_to(format!("App: https://github.com/apps/{slug}"))
.apply_to(format!("App: {}", github_host.app_page_url(&slug)))
);
// Return secret pairs
@ -2569,16 +2579,29 @@ client_id = "client-id"
fn github_app_owner_personal_url() {
let owner = GitHubAppOwner::Personal;
assert_eq!(
owner.manifest_form_action(),
owner.manifest_form_action(&GitHubHost::default()),
"https://github.com/settings/apps/new"
);
}
#[test]
fn github_app_owner_urls_follow_the_enterprise_host() {
let host = GitHubHost::new("https://ghe.example.com", None).unwrap();
assert_eq!(
GitHubAppOwner::Personal.manifest_form_action(&host),
"https://ghe.example.com/settings/apps/new"
);
assert_eq!(
GitHubAppOwner::Organization("my-org".to_string()).manifest_form_action(&host),
"https://ghe.example.com/organizations/my-org/settings/apps/new"
);
}
#[test]
fn github_app_owner_org_url() {
let owner = GitHubAppOwner::Organization("my-org".to_string());
assert_eq!(
owner.manifest_form_action(),
owner.manifest_form_action(&GitHubHost::default()),
"https://github.com/organizations/my-org/settings/apps/new"
);
}

View file

@ -12,6 +12,7 @@ use crate::commands::run::output::{
use crate::commands::run::overrides::preflight_args_overrides;
use crate::manifest_args::preflight_manifest_args;
use crate::shared::{cyan_spinner, print_json_pretty};
use crate::user_config;
pub(crate) async fn execute(
mut args: PreflightArgs,
@ -32,6 +33,7 @@ pub(crate) async fn execute(
args: preflight_manifest_args(&args),
environment_defaults: fabro_environment::seeded_catalog_layer(),
user_settings_path: Some(active_settings_path(None)),
github_host: user_config::github_host()?,
})?;
let spinner = (!ctx.json_output()).then(|| cyan_spinner("Running checks..."));

View file

@ -11,6 +11,7 @@ use tokio::task::spawn_blocking;
use crate::args::{RepoInitArgs, ServerTargetArgs};
use crate::command_context::CommandContext;
use crate::user_config;
#[expect(
clippy::disallowed_methods,
@ -193,8 +194,11 @@ async fn check_github_app_installation(target: &ServerTargetArgs, base_ctx: &Com
};
// Convert SSH URL to HTTPS and parse owner/repo
let Ok(host) = user_config::github_host() else {
return; // Unreadable server settings — skip silently
};
let https_url = fabro_github::ssh_url_to_https(&remote_url);
let Ok((owner, repo)) = fabro_github::parse_github_owner_repo(&https_url) else {
let Ok((owner, repo)) = fabro_github::parse_github_owner_repo(&host, &https_url) else {
return; // Not a GitHub repo — skip silently
};

View file

@ -14,7 +14,7 @@ use super::{resolution, selection};
use crate::args::RunArgs;
use crate::command_context::CommandContext;
use crate::commands::resolve_run_id;
use crate::user_config::{RunSettingsKeyPresence, read_project_run_settings_key_presence};
use crate::user_config::{self, RunSettingsKeyPresence, read_project_run_settings_key_presence};
pub(crate) struct CreatedRun {
pub(crate) run_id: RunId,
@ -104,7 +104,10 @@ pub(crate) async fn create_run(
&& args.target_repo.is_none()
&& args.target_repo_selector.is_none() =>
{
fabro_manifest::configured_repo_origin_url_for_location(package.workflow_location())?
fabro_manifest::configured_repo_origin_url_for_location(
&user_config::github_host()?,
package.workflow_location(),
)?
}
_ => None,
};

View file

@ -109,7 +109,7 @@ use tracing::{info, warn};
use super::publish;
use super::runner::{self, WorkerTitlePhase};
use crate::args::RunWorkerMode;
use crate::command_context;
use crate::{command_context, user_config};
/// What the worker holds when it hands a run to Petri.
pub(super) struct PetriWorker<'a> {
@ -221,10 +221,17 @@ pub(super) async fn execute(worker: PetriWorker<'_>) -> Result<()> {
None
}
};
let read_tokens = publish::read_token_source(&worker.run_state.spec, github.as_ref());
runtime.stage_credentials =
StageCredentials::for_run(&worker.run_state.spec, github.as_ref(), read_tokens.clone())?;
let github_host = user_config::github_host()?;
let read_tokens =
publish::read_token_source(&worker.run_state.spec, github.as_ref(), &github_host);
runtime.stage_credentials = StageCredentials::for_run(
&worker.run_state.spec,
github.as_ref(),
&github_host,
read_tokens.clone(),
)?;
let mut source = RunSource::for_run(
&github_host,
worker.run_state.spec.target.as_ref(),
&worker.run_state.spec.settings.run,
None,
@ -236,6 +243,7 @@ pub(super) async fn execute(worker: PetriWorker<'_>) -> Result<()> {
run_id,
&worker.run_state.spec,
github,
github_host,
Arc::new(VaultCredentialSource::new(Arc::clone(&vault))),
Arc::new(catalog),
Arc::clone(&records),

View file

@ -39,7 +39,7 @@ use fabro_static::EnvVars;
use fabro_store::platform_records::{PlatformRecord, PullRequestCreatedRecord};
use fabro_types::settings::run::{PullRequestSettings, RunMode};
use fabro_types::settings::server::GithubIntegrationStrategy;
use fabro_types::{GitHubRepositorySlug, RunId, RunSpec, RunTarget};
use fabro_types::{GitHubHost, GitHubRepositorySlug, RunId, RunSpec, RunTarget};
use fabro_vault::Vault;
use fabro_workflow::pull_request::{self, AutoMergeOptions, OpenPullRequestRequest};
use tokio::time;
@ -77,11 +77,11 @@ pub(super) fn github_credentials(vault: &Vault) -> Result<Option<GitHubCredentia
}
/// The run's GitHub repository, when its target names one.
fn repository(spec: &RunSpec) -> Option<GitHubRepositorySlug> {
fn repository(spec: &RunSpec, host: &GitHubHost) -> Option<GitHubRepositorySlug> {
let Some(RunTarget::Git(target)) = spec.target.as_ref() else {
return None;
};
Some(target.clone().validate().ok()?.repository().clone())
Some(target.clone().validate(host).ok()?.repository().clone())
}
/// A cached token source for the run's repository with `permissions`, or
@ -89,12 +89,14 @@ fn repository(spec: &RunSpec) -> Option<GitHubRepositorySlug> {
fn token_source(
spec: &RunSpec,
credentials: Option<&GitHubCredentials>,
host: &GitHubHost,
permissions: serde_json::Value,
) -> Option<Arc<InstallationTokenSource>> {
let repository = repository(spec)?;
let repository = repository(spec, host)?;
let credentials = credentials?;
match InstallationTokenSource::for_repository(
credentials,
host,
repository.owner().to_string(),
repository.repo().to_string(),
permissions,
@ -114,8 +116,14 @@ fn token_source(
pub(super) fn read_token_source(
spec: &RunSpec,
credentials: Option<&GitHubCredentials>,
host: &GitHubHost,
) -> Option<Arc<InstallationTokenSource>> {
token_source(spec, credentials, serde_json::json!({ "contents": "read" }))
token_source(
spec,
credentials,
host,
serde_json::json!({ "contents": "read" }),
)
}
/// Fetch credentials over the run's read-only token source.
@ -160,6 +168,7 @@ pub(super) struct GitHubPublisher {
/// The run's model, when its settings name one.
model: Option<String>,
credentials: Option<GitHubCredentials>,
host: GitHubHost,
/// The run's `contents: write` token source; `None` without credentials.
push_tokens: Option<Arc<InstallationTokenSource>>,
pull_request: Option<PullRequestSettings>,
@ -176,6 +185,7 @@ impl GitHubPublisher {
run_id: RunId,
spec: &RunSpec,
credentials: Option<GitHubCredentials>,
host: GitHubHost,
llm_source: Arc<dyn CredentialProvider>,
catalog: Arc<Catalog>,
records: Arc<dyn PlatformRecords>,
@ -192,10 +202,11 @@ impl GitHubPublisher {
let Some(RunTarget::Git(target)) = spec.target.as_ref() else {
return None;
};
let repository = repository(spec)?;
let repository = repository(spec, &host)?;
let push_tokens = token_source(
spec,
credentials.as_ref(),
&host,
serde_json::json!({ "contents": "write" }),
);
Some(Self {
@ -205,6 +216,7 @@ impl GitHubPublisher {
goal: spec.graph.goal.clone(),
model: settings.model.name.clone(),
credentials,
host,
push_tokens,
pull_request: settings
.pull_request
@ -243,7 +255,7 @@ impl GitHubPublisher {
.ok_or_else(|| "no LLM model is available to write the pull request".to_string())?;
// The run so far, for the pull request's details: best effort.
let run_state = self.client.get_run_state(&self.run_id).await.ok();
let origin_url = self.repository.https_url();
let origin_url = self.repository.https_url(&self.host);
let created = pull_request::open_pull_request(OpenPullRequestRequest {
run_id: self.run_id,
github: context,
@ -293,7 +305,15 @@ impl RunPublisher for GitHubPublisher {
.resolve()
.await
.map_err(|err| format!("no push credential for {}: {err:#}", self.repository))?;
push(&self.repository, &resolved.token, site, branch, sha).await
push(
&self.repository,
&self.host,
&resolved.token,
site,
branch,
sha,
)
.await
}
async fn publish(&self, publication: &Publication) -> Result<(), String> {
@ -313,8 +333,7 @@ impl RunPublisher for GitHubPublisher {
.credentials
.as_ref()
.ok_or_else(|| "GitHub credentials are unavailable".to_string())?;
let base_url = fabro_github::github_api_base_url();
let context = GitHubContext::new(credentials, &base_url);
let context = GitHubContext::new(credentials, &self.host);
Box::pin(self.open_pull_request(context, settings, publication)).await
}
}
@ -325,12 +344,13 @@ impl RunPublisher for GitHubPublisher {
/// repository alone and never appears in the error.
async fn push(
repository: &GitHubRepositorySlug,
host: &GitHubHost,
token: &SecretString,
site: &Site,
branch: &str,
sha: &str,
) -> Result<(), String> {
let mut url = repository.https_url();
let mut url = repository.https_url(host);
url.push_str(".git");
let credential = basic(token);
let env = credential
@ -399,6 +419,7 @@ mod tests {
RunId::new(),
spec,
None,
GitHubHost::default(),
Arc::new(NoCredentials),
Arc::new(test_support::test_catalog()),
Arc::new(MemoryPlatformRecords::new()),

View file

@ -7,7 +7,7 @@ use std::time::Duration;
use anyhow::{Context as _, bail};
use fabro_manifest::CollectedWorkflowClosure;
use fabro_proc::ProcessError;
use fabro_types::{GitHubRepositorySlug, GitRunTarget, repository};
use fabro_types::{GitHubHost, GitHubRepositorySlug, GitRunTarget, repository};
use tokio::process::Command;
use tokio::{fs, signal as tokio_signal, task};
use tokio_util::sync::CancellationToken;
@ -69,17 +69,19 @@ pub(super) enum RemoteWorkflowError {
/// the same Git configuration: the user's global and system config applies,
/// repository-local config from wherever the CLI was invoked does not.
pub(super) struct NativeGit {
host: GitHubHost,
timeout: Duration,
#[cfg(test)]
pub(super) environment: Vec<(String, String)>,
}
impl NativeGit {
pub(super) fn new() -> Self {
pub(super) fn new(host: GitHubHost) -> Self {
Self {
timeout: Duration::from_mins(2),
host,
timeout: Duration::from_mins(2),
#[cfg(test)]
environment: Vec::new(),
environment: Vec::new(),
}
}
@ -157,7 +159,7 @@ impl NativeGit {
patterns: &[String],
cancel: &CancellationToken,
) -> anyhow::Result<String> {
let url = repository.https_url();
let url = repository.https_url(&self.host);
let mut args = vec!["ls-remote", "--symref", &url];
args.extend(patterns.iter().map(String::as_str));
let bytes = self.command("metadata lookup", root, &args, cancel).await?;
@ -193,7 +195,7 @@ impl NativeGit {
tag: None,
sha: Some(sha),
};
Ok(target.validate()?.into_target())
Ok(target.validate(&self.host)?.into_target())
}
/// Resolve `revision` to a commit SHA using metadata lookups run from
@ -298,7 +300,7 @@ impl NativeGit {
"--depth=1",
"--no-tags",
"--no-recurse-submodules",
&repository.https_url(),
&repository.https_url(&self.host),
sha,
],
cancel,
@ -570,7 +572,7 @@ mod tests {
),
)
.unwrap();
let mut git = NativeGit::new();
let mut git = NativeGit::new(GitHubHost::default());
git.environment = vec![
("GIT_CONFIG_GLOBAL".into(), config.to_str().unwrap().into()),
("GIT_CONFIG_NOSYSTEM".into(), "1".into()),
@ -1081,7 +1083,7 @@ mod tests {
let path = root.path().join("git");
std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut git = NativeGit::new();
let mut git = NativeGit::new(GitHubHost::default());
git.environment
.push(("PATH".into(), root.path().to_str().unwrap().into()));
(root, git)

View file

@ -7,6 +7,7 @@ use tokio::task;
use super::remote_workflow::{Interruption, NativeGit};
use super::selection::{TargetSelection, WorkflowSelection};
use crate::user_config;
/// Owns the canonical collector result without copying its contents. Local
/// location metadata remains available for settings warnings and target
@ -55,7 +56,7 @@ pub(super) async fn workflow(
selector,
revision,
} => {
let git = NativeGit::new();
let git = NativeGit::new(user_config::github_host()?);
let (repository, selector, revision) =
(repository.clone(), selector.clone(), revision.clone());
let closure = interruption
@ -84,7 +85,7 @@ pub(super) async fn target(
if !provider.clones_workspace() {
bail!("Git targets require a clone-enabled environment");
}
let git = NativeGit::new();
let git = NativeGit::new(user_config::github_host()?);
let (repository, branch) = (repository.clone(), branch.clone());
let target = interruption
.owned(move |cancel| async move {
@ -101,9 +102,11 @@ pub(super) async fn target(
// The existing observer can push/query Git synchronously. Preserve its
// behavior without blocking a Tokio worker or promising a new timeout.
let provider = provider.clone();
let host = user_config::github_host()?;
let configured_repo_origin_url = configured_repo_origin_url.map(str::to_owned);
let derived = task::spawn_blocking(move || {
fabro_manifest::derive_run_target_for_provider(
&host,
&provider,
&path,
configured_repo_origin_url.as_deref(),

View file

@ -224,6 +224,7 @@ async fn run_install_mode(bootstrap: InstallBootstrap, printer: Printer) -> Resu
&bootstrap.storage_dir,
&bootstrap.config_path,
)
.with_github_host(user_config::github_host()?)
.with_finish_hook(persist_install_dev_token_hook());
install::serve_install_command(bootstrap.bind_request, state, move |bind| {
announce_install_mode(bind, &token, styles, printer);

View file

@ -10,6 +10,7 @@ use crate::args::ValidateArgs;
use crate::command_context::CommandContext;
use crate::commands::run::output::api_diagnostics_to_local;
use crate::shared::{print_diagnostics, print_json_pretty, relative_path};
use crate::user_config;
pub(crate) fn run(
args: &ValidateArgs,
@ -22,6 +23,7 @@ pub(crate) fn run(
cwd: base_ctx.cwd().to_path_buf(),
environment_defaults: fabro_environment::seeded_catalog_layer(),
user_settings_path: Some(active_settings_path(None)),
github_host: user_config::github_host()?,
..Default::default()
})?;
let response = manifest_validation::validate_manifest(&RunLayer::default(), &built.manifest)?;

View file

@ -1,3 +1,4 @@
use fabro_github::GitHubHost;
use tokio::process::Command;
use tracing::debug;
@ -6,19 +7,20 @@ use tracing::debug;
/// All methods degrade gracefully: if `gh` is missing or not authenticated
/// the caller receives `None` / empty results rather than errors.
pub(crate) struct GhCli {
_private: (),
command: String,
host: GitHubHost,
}
impl GhCli {
/// Attempt to find an authenticated `gh` CLI on PATH.
///
/// Returns `None` if `gh` is not installed or not authenticated
/// against github.com.
pub(crate) async fn detect() -> Option<Self> {
Self::detect_with_command("gh").await
/// against the configured GitHub host.
pub(crate) async fn detect(host: &GitHubHost) -> Option<Self> {
Self::detect_with_command("gh", host).await
}
async fn detect_with_command(command: &str) -> Option<Self> {
async fn detect_with_command(command: &str, host: &GitHubHost) -> Option<Self> {
let version = Command::new(command).arg("--version").output().await;
let Ok(output) = version else {
debug!("gh CLI not found on PATH");
@ -30,16 +32,19 @@ impl GhCli {
}
let auth = Command::new(command)
.args(["auth", "status", "--hostname", "github.com"])
.args(["auth", "status", "--hostname", host.hostname()])
.output()
.await;
match auth {
Ok(o) if o.status.success() => {
debug!("gh CLI available and authenticated for github.com");
Some(Self { _private: () })
debug!("gh CLI available and authenticated for {}", host.hostname());
Some(Self {
command: command.to_string(),
host: host.clone(),
})
}
_ => {
debug!("gh is not authenticated for github.com");
debug!("gh is not authenticated for {}", host.hostname());
None
}
}
@ -47,8 +52,15 @@ impl GhCli {
/// Return the login of the authenticated GitHub user, or `None` on failure.
pub(crate) async fn authenticated_user(&self) -> Option<String> {
let output = Command::new("gh")
.args(["api", "--hostname", "github.com", "/user", "--jq", ".login"])
let output = Command::new(&self.command)
.args([
"api",
"--hostname",
self.host.hostname(),
"/user",
"--jq",
".login",
])
.output()
.await
.ok()?;
@ -64,11 +76,11 @@ impl GhCli {
///
/// Returns an empty vec on any failure (network, parse, etc.).
pub(crate) async fn list_admin_orgs(&self) -> Vec<String> {
let output = Command::new("gh")
let output = Command::new(&self.command)
.args([
"api",
"--hostname",
"github.com",
self.host.hostname(),
"--paginate",
"/user/memberships/orgs",
"--jq",
@ -101,7 +113,51 @@ mod tests {
#[tokio::test]
async fn detect_returns_none_when_gh_is_missing() {
let result = GhCli::detect_with_command("fabro-test-gh-that-should-not-exist").await;
let result = GhCli::detect_with_command(
"fabro-test-gh-that-should-not-exist",
&GitHubHost::default(),
)
.await;
assert!(result.is_none());
}
#[cfg(unix)]
#[tokio::test]
#[expect(
clippy::disallowed_methods,
reason = "unit test stages a fake gh script and reads its argument log with sync std::fs"
)]
async fn gh_invocations_target_the_configured_host() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("args.log");
let script = dir.path().join("gh");
std::fs::write(
&script,
format!("#!/bin/sh\necho \"$@\" >> '{}'\n", log.display()),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
for (web_url, hostname) in [
("https://github.com", "github.com"),
("https://ghe.example.com:8443", "ghe.example.com"),
] {
let host = GitHubHost::new(web_url, None).unwrap();
let gh = GhCli::detect_with_command(script.to_str().unwrap(), &host)
.await
.unwrap();
gh.authenticated_user().await;
gh.list_admin_orgs().await;
let logged = std::fs::read_to_string(&log).unwrap();
std::fs::remove_file(&log).unwrap();
let lines = logged.lines().collect::<Vec<_>>();
assert_eq!(lines[0], "--version");
assert_eq!(lines[1], format!("auth status --hostname {hostname}"));
assert!(lines[2].starts_with(&format!("api --hostname {hostname} /user")));
assert!(lines[3].starts_with(&format!("api --hostname {hostname} --paginate")));
}
}
}

View file

@ -14,7 +14,7 @@ use fabro_static::EnvVars;
use fabro_types::settings::RunNamespace;
use fabro_types::settings::cli::CliTargetSettings;
use fabro_types::settings::server::LogDestination;
use fabro_types::{ServerSettings, UserSettings};
use fabro_types::{GitHubHost, ServerSettings, UserSettings};
use fabro_util::error::SharedError;
use fabro_util::version::FABRO_VERSION;
use tokio::fs;
@ -23,6 +23,14 @@ use tracing::debug;
use crate::args::ServerTargetArgs;
/// The GitHub deployment configured in the local server settings, with the
/// `GITHUB_WEB_URL` / `GITHUB_BASE_URL` overrides applied.
pub(crate) fn github_host() -> Result<GitHubHost> {
let configured = ServerSettingsBuilder::load_default_github_host()
.context("loading the GitHub host from the server settings")?;
fabro_github::with_env_overrides(&configured)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct RunSettingsKeyPresence {
pub(crate) run: bool,

View file

@ -4,6 +4,7 @@
)]
use fabro_http::Url;
use fabro_types::GitHubHost;
#[doc(hidden)]
#[derive(Debug, Clone)]
@ -13,11 +14,12 @@ pub struct GithubEndpoints {
}
impl GithubEndpoints {
pub fn production_defaults() -> Self {
pub fn from_host(host: &GitHubHost) -> Self {
Self {
oauth_base: Url::parse("https://github.com/").expect("github oauth base should parse"),
api_base: Url::parse("https://api.github.com/")
.expect("github api base should parse"),
oauth_base: Url::parse(&format!("{}/", host.web_url()))
.expect("GitHubHost web_url should parse as a URL"),
api_base: Url::parse(&format!("{}/", host.api_url()))
.expect("GitHubHost api_url should parse as a URL"),
}
}
@ -32,16 +34,42 @@ impl GithubEndpoints {
#[cfg(test)]
mod tests {
use fabro_types::GitHubHost;
use super::GithubEndpoints;
#[test]
fn production_defaults_match_current_github_urls() {
let endpoints = GithubEndpoints::production_defaults();
fn default_host_matches_current_github_urls() {
let endpoints = GithubEndpoints::from_host(&GitHubHost::default());
assert_eq!(endpoints.oauth_base.as_str(), "https://github.com/");
assert_eq!(endpoints.api_base.as_str(), "https://api.github.com/");
}
#[test]
fn enterprise_host_uses_its_web_origin_and_api_url() {
let host = GitHubHost::new("https://ghe.example.com", None).unwrap();
let endpoints = GithubEndpoints::from_host(&host);
assert_eq!(endpoints.oauth_base.as_str(), "https://ghe.example.com/");
assert_eq!(
endpoints.api_base.as_str(),
"https://ghe.example.com/api/v3/"
);
assert_eq!(
endpoints
.oauth_base
.join("login/oauth/authorize")
.unwrap()
.as_str(),
"https://ghe.example.com/login/oauth/authorize"
);
assert_eq!(
endpoints.api_base.join("user").unwrap().as_str(),
"https://ghe.example.com/api/v3/user"
);
}
#[test]
fn with_bases_uses_custom_urls() {
let endpoints = GithubEndpoints::with_bases(

View file

@ -128,6 +128,7 @@ pub(crate) struct ProductionAutomationRunMaterializer {
remote_resolver: Arc<dyn AutomationGitRemoteResolver>,
repo_cache: Arc<GitRepoCache>,
version_store: WorkflowVersionStore,
host: fabro_types::GitHubHost,
}
/// Where to fetch a repository from and how to authenticate.
@ -143,9 +144,9 @@ trait AutomationGitRemoteResolver: Send + Sync {
}
struct ServerGitHubRemoteResolver {
credentials: Option<fabro_github::GitHubCredentials>,
api_base_url: String,
http_client: Option<fabro_http::HttpClient>,
credentials: Option<fabro_github::GitHubCredentials>,
host: fabro_types::GitHubHost,
http_client: Option<fabro_http::HttpClient>,
}
#[async_trait]
@ -154,12 +155,12 @@ impl AutomationGitRemoteResolver for ServerGitHubRemoteResolver {
let auth = git_checkout::resolve_git_read_auth_config(
self.credentials.as_ref(),
repo,
&self.api_base_url,
&self.host,
self.http_client.clone(),
)
.await?;
Ok(GitRemote {
clone_url: git_checkout::github_clone_url(repo),
clone_url: git_checkout::github_clone_url(repo, &self.host),
auth,
})
}
@ -168,7 +169,7 @@ impl AutomationGitRemoteResolver for ServerGitHubRemoteResolver {
impl ProductionAutomationRunMaterializer {
pub(crate) fn new(
github_credentials: Option<fabro_github::GitHubCredentials>,
github_api_base_url: String,
github_host: fabro_types::GitHubHost,
http_client: Option<fabro_http::HttpClient>,
repo_cache: Arc<GitRepoCache>,
version_store: WorkflowVersionStore,
@ -176,11 +177,12 @@ impl ProductionAutomationRunMaterializer {
Self {
remote_resolver: Arc::new(ServerGitHubRemoteResolver {
credentials: github_credentials,
api_base_url: github_api_base_url,
host: github_host.clone(),
http_client,
}),
repo_cache,
version_store,
host: github_host,
}
}
@ -232,13 +234,13 @@ impl AutomationRunMaterializer for ProductionAutomationRunMaterializer {
) -> Result<AutomationRunMaterialized, RunMaterializeError> {
let validated_target = input
.target
.validate()
.validate(&self.host)
.map_err(|source| RunMaterializeError::InvalidTarget { source })?;
let target_repo = validated_target.repository().clone();
let mut exact_target = validated_target.into_target();
let workflow_source = input
.workflow_source
.map(GitRunTarget::validate)
.map(|source| source.validate(&self.host))
.transpose()
.map_err(|source| RunMaterializeError::InvalidWorkflowSource { source })?;
// A workflow source naming the target's exact coordinate shares its
@ -772,7 +774,7 @@ mod tests {
) -> ProductionAutomationRunMaterializer {
ProductionAutomationRunMaterializer::new(
None,
"https://api.github.com".to_string(),
fabro_types::GitHubHost::default(),
None,
Arc::new(GitRepoCache::new(root.join("cache"))),
store,

View file

@ -4,24 +4,17 @@
//! `index.html` so any inline `<script>` hashes don't drift from the
//! template. Third-party sources are enumerated explicitly.
use std::sync::OnceLock;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use fabro_types::GitHubHost;
use sha2::{Digest, Sha256};
static POLICY: OnceLock<String> = OnceLock::new();
pub(crate) const INSTALL_MODE_SCRIPT_BODY: &str = "window.__FABRO_MODE__ = \"install\";";
pub fn policy() -> &'static str {
POLICY.get_or_init(build_policy)
}
fn build_policy() -> String {
pub fn policy(host: &GitHubHost) -> String {
let mut script_hashes = inline_script_hashes_from_embedded_index();
script_hashes.push(script_hash(INSTALL_MODE_SCRIPT_BODY));
build_policy_with_hashes(&script_hashes)
build_policy_with_hashes(&script_hashes, host)
}
fn inline_script_hashes_from_embedded_index() -> Vec<String> {
@ -70,7 +63,18 @@ fn script_hash(script: &str) -> String {
format!("sha256-{}", STANDARD.encode(hash))
}
fn build_policy_with_hashes(script_hashes: &[String]) -> String {
/// Avatars of a GitHub Enterprise host are served from the host itself
/// (`https://avatars.<host>`) or from its web origin, not from
/// githubusercontent.com.
fn github_img_sources(host: &GitHubHost) -> String {
if host.is_github_com() {
"https://avatars.githubusercontent.com".to_string()
} else {
format!("{} https://avatars.{}", host.web_url(), host.host())
}
}
fn build_policy_with_hashes(script_hashes: &[String], host: &GitHubHost) -> String {
let inline_script_sources = if script_hashes.is_empty() {
String::new()
} else {
@ -92,7 +96,7 @@ fn build_policy_with_hashes(script_hashes: &[String]) -> String {
// terminal WebSocket working across browsers that do not treat `'self'`
// as matching WebSocket schemes for connect-src. `frame-src https:`
// allows signed sandbox VNC preview iframes from dynamic Daytona preview
// hosts. `https://github.com` in form-action allows the install-mode
// hosts. The GitHub web origin in form-action allows the install-mode
// GitHub App manifest POST handoff. Loopback resume targets keep CLI
// OAuth working when the browser-visible origin and canonical origin use
// different loopback hostnames on a dynamically chosen port.
@ -101,15 +105,17 @@ fn build_policy_with_hashes(script_hashes: &[String]) -> String {
script-src 'self'{inline_script_sources} 'wasm-unsafe-eval'; \
style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; \
font-src 'self' https://fonts.gstatic.com; \
img-src 'self' data: blob: https://avatars.githubusercontent.com; \
img-src 'self' data: blob: {img_sources}; \
connect-src 'self' ws: wss:; \
worker-src 'self' blob:; \
frame-src 'self' https:; \
manifest-src 'self'; \
frame-ancestors 'none'; \
base-uri 'self'; \
form-action 'self' https://github.com http://127.0.0.1:*/auth/cli/resume http://localhost:*/auth/cli/resume; \
object-src 'none'"
form-action 'self' {web_url} http://127.0.0.1:*/auth/cli/resume http://localhost:*/auth/cli/resume; \
object-src 'none'",
img_sources = github_img_sources(host),
web_url = host.web_url(),
)
}
@ -151,7 +157,7 @@ mod tests {
#[test]
fn policy_allows_install_mode_inline_bootstrap() {
let policy = build_policy();
let policy = policy(&GitHubHost::default());
let expected_hash = script_hash(INSTALL_MODE_SCRIPT_BODY);
assert!(
policy.contains(&format!("'{expected_hash}'")),
@ -161,7 +167,7 @@ mod tests {
#[test]
fn policy_allows_cli_loopback_resume_form_posts() {
let policy = build_policy_with_hashes(&[]);
let policy = build_policy_with_hashes(&[], &GitHubHost::default());
assert!(
policy.contains("form-action 'self' https://github.com http://127.0.0.1:*/auth/cli/resume http://localhost:*/auth/cli/resume"),
"CLI auth can submit the resume form to the canonical loopback origin on a dynamic port: {policy}"
@ -170,7 +176,7 @@ mod tests {
#[test]
fn policy_is_constructed_with_expected_directives() {
let policy = build_policy_with_hashes(&["sha256-abc".to_string()]);
let policy = build_policy_with_hashes(&["sha256-abc".to_string()], &GitHubHost::default());
assert!(policy.contains("default-src 'self'"));
assert!(
policy.contains("script-src 'self' 'sha256-abc' 'wasm-unsafe-eval'"),
@ -191,9 +197,31 @@ mod tests {
assert!(policy.contains("object-src 'none'"));
}
#[test]
fn github_com_policy_is_unchanged() {
let policy = build_policy_with_hashes(&[], &GitHubHost::default());
assert!(policy.contains(
"img-src 'self' data: blob: https://avatars.githubusercontent.com; connect-src"
));
}
#[test]
fn enterprise_policy_uses_the_configured_web_origin() {
let host = GitHubHost::new("https://ghe.example.com", None).unwrap();
let policy = build_policy_with_hashes(&[], &host);
assert!(policy.contains(
"img-src 'self' data: blob: https://ghe.example.com https://avatars.ghe.example.com; connect-src"
));
assert!(policy.contains(
"form-action 'self' https://ghe.example.com http://127.0.0.1:*/auth/cli/resume"
));
assert!(!policy.contains("https://github.com"));
assert!(!policy.contains("githubusercontent.com"));
}
#[test]
fn policy_is_valid_without_inline_scripts() {
let policy = build_policy_with_hashes(&[]);
let policy = build_policy_with_hashes(&[], &GitHubHost::default());
assert!(
policy.contains("script-src 'self' 'wasm-unsafe-eval'"),
"empty hash list should not produce a stray space: {policy}"
@ -204,7 +232,7 @@ mod tests {
fn embedded_spa_index_builds_a_policy() {
// Guards against the embedded asset going missing or becoming
// unreadable in a way that would leave the CSP header blank.
let policy = build_policy();
let policy = policy(&GitHubHost::default());
assert!(
policy.contains("script-src 'self'"),
"embedded SPA policy should contain a script-src directive"

View file

@ -1189,6 +1189,7 @@ mod runs {
},
automation: None,
repository: Some(RepositoryRef::from_origin_and_source(
&fabro_types::GitHubHost::default(),
repo_origin_url,
source_directory.as_deref(),
)),

View file

@ -399,7 +399,7 @@ async fn check_github_app(state: &AppState) -> CheckResult {
};
let probe = timeout(
EXTERNAL_SERVICE_PROBE_TIMEOUT,
http.get(format!("{}/user", fabro_github::github_api_base_url()))
http.get(format!("{}/user", state.github_host.api_url()))
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "fabro-server")
@ -549,7 +549,7 @@ async fn check_github_app(state: &AppState) -> CheckResult {
};
let auth_result = timeout(
EXTERNAL_SERVICE_PROBE_TIMEOUT,
fabro_github::get_authenticated_app(&http, &jwt, &fabro_github::github_api_base_url()),
fabro_github::get_authenticated_app(&http, &jwt, state.github_host.api_url()),
)
.await;
match auth_result {

View file

@ -264,8 +264,11 @@ async fn bare_clone_may_be_corrupt(bare_dir: &Path) -> bool {
}
}
pub(crate) fn github_clone_url(repo: &GitHubRepositorySlug) -> String {
let mut url = repo.https_url();
pub(crate) fn github_clone_url(
repo: &GitHubRepositorySlug,
host: &fabro_types::GitHubHost,
) -> String {
let mut url = repo.https_url(host);
url.push_str(".git");
url
}
@ -313,17 +316,15 @@ impl GitAuthConfig {
pub(crate) async fn resolve_git_read_auth_config(
credentials: Option<&fabro_github::GitHubCredentials>,
repo: &GitHubRepositorySlug,
github_api_base_url: &str,
host: &fabro_types::GitHubHost,
http_client: Option<fabro_http::HttpClient>,
) -> anyhow::Result<Option<GitAuthConfig>> {
let Some(credentials) = credentials else {
return Ok(None);
};
let context = match http_client {
Some(client) => {
fabro_github::GitHubContext::with_http_client(credentials, github_api_base_url, client)
}
None => fabro_github::GitHubContext::new(credentials, github_api_base_url),
Some(client) => fabro_github::GitHubContext::with_http_client(credentials, host, client),
None => fabro_github::GitHubContext::new(credentials, host),
};
let credentials =
fabro_github::resolve_read_only_clone_credentials(&context, repo.owner(), repo.repo())
@ -742,11 +743,14 @@ mod tests {
assert_eq!(repo.owner(), "fabro-sh");
assert_eq!(repo.repo(), "fabro");
assert_eq!(
github_clone_url(&repo),
github_clone_url(&repo, &fabro_types::GitHubHost::default()),
"https://github.com/fabro-sh/fabro.git"
);
assert_eq!(repo.https_url(), "https://github.com/fabro-sh/fabro");
assert!(!github_clone_url(&repo).contains('@'));
assert_eq!(
repo.https_url(&fabro_types::GitHubHost::default()),
"https://github.com/fabro-sh/fabro"
);
assert!(!github_clone_url(&repo, &fabro_types::GitHubHost::default()).contains('@'));
}
#[test]
@ -755,13 +759,16 @@ mod tests {
assert_eq!(repo.owner(), "owner");
assert_eq!(repo.repo(), ".github");
assert_eq!(repo.https_url(), "https://github.com/owner/.github");
assert_eq!(
repo.https_url(&fabro_types::GitHubHost::default()),
"https://github.com/owner/.github"
);
}
#[test]
fn bare_cache_command_plans_use_argv_prompt_disable_and_timeouts() {
let repo = repository_slug("fabro-sh/fabro");
let clone_url = github_clone_url(&repo);
let clone_url = github_clone_url(&repo, &fabro_types::GitHubHost::default());
let temp = TempDir::new().unwrap();
let bare_dir = temp.path().join("fabro-sh/fabro.git");
let worktree_dir = temp.path().join("worktree/repo");
@ -849,7 +856,7 @@ mod tests {
#[test]
fn credential_config_env_keeps_clone_url_uncredentialed() {
let repo = repository_slug("fabro-sh/fabro");
let clone_url = github_clone_url(&repo);
let clone_url = github_clone_url(&repo, &fabro_types::GitHubHost::default());
let auth = GitAuthConfig::from_parts("x-access-token", "ghu_secret");
let plan = build_bare_clone_plan(&clone_url, Path::new("/tmp/fabro-checkout"), Some(&auth));

View file

@ -1,4 +1,5 @@
use fabro_static::EnvVars;
use fabro_types::GitHubHost;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use tokio::process::Command;
@ -56,6 +57,7 @@ pub struct TailscaleFunnelManager {
impl TailscaleFunnelManager {
pub async fn start(
main_server_port: u16,
host: &GitHubHost,
app_id: &str,
private_key_pem: &str,
) -> anyhow::Result<Self> {
@ -63,7 +65,9 @@ impl TailscaleFunnelManager {
info!(port = main_server_port, url = %funnel_url, "Tailscale funnel enabled");
let webhook_url = format!("{funnel_url}{WEBHOOK_ROUTE}");
match fabro_github::update_app_webhook_config(app_id, private_key_pem, &webhook_url).await {
match fabro_github::update_app_webhook_config(host, app_id, private_key_pem, &webhook_url)
.await
{
Ok(()) => {
info!(url = %webhook_url, "GitHub App webhook URL updated");
}

View file

@ -30,7 +30,7 @@ use fabro_static::EnvVars;
use fabro_store::ArtifactStore;
use fabro_types::settings::server::ObjectStoreSettings;
use fabro_types::settings::{is_wildcard_host, validate_public_url_with_label};
use fabro_types::{SandboxProviderKind, ServerSettings};
use fabro_types::{GitHubHost, SandboxProviderKind, ServerSettings};
use fabro_util::version::FABRO_VERSION;
use fabro_util::{Home, session_secret};
use fabro_vault::SecretType as VaultSecretType;
@ -52,7 +52,7 @@ use crate::sandbox_access::{
};
use crate::serve::{self, DEFAULT_TCP_PORT};
use crate::server_secrets::{ServerSecrets, process_env_snapshot};
use crate::{security_headers, server, static_files};
use crate::{csp, security_headers, server, static_files};
#[derive(Clone)]
pub struct InstallAppState {
@ -65,6 +65,7 @@ pub struct InstallAppState {
first_operator: Arc<Mutex<Option<InstallOperatorFingerprint>>>,
finish_in_progress: Arc<AtomicBool>,
upstreams: InstallUpstreamConfig,
github_host: GitHubHost,
static_asset_root: Option<Arc<Path>>,
on_finish: Option<Arc<dyn Fn() + Send + Sync>>,
finish_hook: Option<InstallFinishHook>,
@ -91,7 +92,6 @@ struct InstallOperatorFingerprint {
remote_ip: Option<String>,
}
pub const DEFAULT_INSTALL_GITHUB_API_BASE_URL: &str = "https://api.github.com";
const DEFAULT_INSTALL_TCP_LISTEN_ADDRESS: &str = "127.0.0.1:32276";
const REDACTED_SECRET_VALUE: &str = "[REDACTED]";
const VALIDATION_TIMEOUT: Duration = Duration::from_secs(20);
@ -115,6 +115,7 @@ impl InstallAppState {
first_operator: Arc::new(Mutex::new(None)),
finish_in_progress: Arc::new(AtomicBool::new(false)),
upstreams: InstallUpstreamConfig::default(),
github_host: GitHubHost::default(),
static_asset_root: None,
on_finish: None,
finish_hook: None,
@ -157,6 +158,7 @@ impl InstallAppState {
first_operator: Arc::new(Mutex::new(None)),
finish_in_progress: Arc::new(AtomicBool::new(false)),
upstreams: InstallUpstreamConfig::default(),
github_host: GitHubHost::default(),
static_asset_root: None,
on_finish: None,
finish_hook: None,
@ -203,6 +205,12 @@ impl InstallAppState {
self
}
#[must_use]
pub fn with_github_host(mut self, host: GitHubHost) -> Self {
self.github_host = host;
self
}
#[must_use]
pub fn with_github_api_base_url(mut self, base_url: impl Into<String>) -> Self {
self.upstreams.github_api_base_url = Some(base_url.into());
@ -566,11 +574,12 @@ enum GitHubAppOwner {
}
impl GitHubAppOwner {
fn manifest_form_action(&self) -> String {
fn manifest_form_action(&self, host: &GitHubHost) -> String {
let web_url = host.web_url();
match self {
Self::Personal => "https://github.com/settings/apps/new".to_string(),
Self::Personal => format!("{web_url}/settings/apps/new"),
Self::Organization(org) => {
format!("https://github.com/organizations/{org}/settings/apps/new")
format!("{web_url}/organizations/{org}/settings/apps/new")
}
}
}
@ -602,6 +611,7 @@ impl TryFrom<GithubAppOwnerInput> for GitHubAppOwner {
}
pub fn build_install_router(state: InstallAppState) -> Router {
let csp_policy = Arc::new(csp::policy(&state.github_host));
let static_asset_root = state.static_asset_root.clone();
Router::new()
@ -670,7 +680,10 @@ pub fn build_install_router(state: InstallAppState) -> Router {
// router; a first-run setup over a slow link needs compression just
// as much.
.layer(server::compression_layer())
.layer(middleware::from_fn(security_headers::layer))
.layer(middleware::from_fn_with_state(
csp_policy,
security_headers::layer,
))
}
struct InstallFinishGuard {
@ -783,6 +796,7 @@ async fn get_install_session(
"prefill": {
"canonical_url": detect_canonical_url(&headers),
"object_store_local_root": default_local_object_store_root(&state),
"github_web_url": state.github_host.web_url(),
}
}))
.into_response()
@ -1422,7 +1436,7 @@ async fn post_install_github_app_manifest(
Json(serde_json::json!({
"manifest": manifest,
"github_form_action": owner.manifest_form_action(),
"github_form_action": owner.manifest_form_action(&state.github_host),
"state": state_token,
}))
.into_response()
@ -2178,7 +2192,7 @@ async fn validate_github_token(state: &InstallAppState, token: &str) -> anyhow::
.upstreams
.github_api_base_url
.clone()
.unwrap_or_else(|| DEFAULT_INSTALL_GITHUB_API_BASE_URL.to_string());
.unwrap_or_else(|| state.github_host.api_url().to_string());
let endpoint = install_upstream_endpoint(&base_url, &["user"])?;
let client = install_http_client_for_url(&base_url)?;
let response = client
@ -2210,7 +2224,7 @@ async fn exchange_github_app_manifest_code(
.upstreams
.github_api_base_url
.clone()
.unwrap_or_else(|| DEFAULT_INSTALL_GITHUB_API_BASE_URL.to_string());
.unwrap_or_else(|| state.github_host.api_url().to_string());
let endpoint = install_upstream_endpoint(&base_url, &["app-manifests", code, "conversions"])?;
let client = install_http_client_for_url(&base_url)?;
let response = client
@ -2319,15 +2333,15 @@ mod tests {
use serde_json::json;
use super::{
DEFAULT_INSTALL_GITHUB_API_BASE_URL, GitHubAppOwner, GithubAppInstall, GithubInstallState,
InstallAppState, InstallAwsCredentialPair, InstallFinishGuard,
InstallObjectStoreCredentialMode, InstallObjectStoreInput, InstallObjectStoreProvider,
InstallObjectStoreState, InstallSandboxProviderState, InstallSandboxState,
InstallTokenQuery, LlmProvidersInput, PendingInstall, ServerConfigInput, ServerSecrets,
build_github_app_manifest, classify_object_store_validation_error, detect_canonical_url,
install_catalog_provider, install_object_store_lookup, lock_unpoisoned,
post_install_finish, provider_base_url_override, resolve_install_object_store_state,
token_is_valid, write_artifact_store_metadata,
GitHubAppOwner, GitHubHost, GithubAppInstall, GithubInstallState, InstallAppState,
InstallAwsCredentialPair, InstallFinishGuard, InstallObjectStoreCredentialMode,
InstallObjectStoreInput, InstallObjectStoreProvider, InstallObjectStoreState,
InstallSandboxProviderState, InstallSandboxState, InstallTokenQuery, LlmProvidersInput,
PendingInstall, ServerConfigInput, ServerSecrets, build_github_app_manifest,
classify_object_store_validation_error, detect_canonical_url, install_catalog_provider,
install_object_store_lookup, lock_unpoisoned, post_install_finish,
provider_base_url_override, resolve_install_object_store_state, token_is_valid,
write_artifact_store_metadata,
};
#[test]
@ -2529,10 +2543,38 @@ mod tests {
}
#[test]
fn install_github_requests_default_to_fixed_github_api_base_url() {
fn install_github_requests_default_to_the_host_api_url() {
let state = InstallAppState::for_test("expected");
assert_eq!(state.github_host.api_url(), "https://api.github.com");
let state =
state.with_github_host(GitHubHost::new("https://ghe.example.com", None).unwrap());
assert_eq!(
DEFAULT_INSTALL_GITHUB_API_BASE_URL,
"https://api.github.com"
state.github_host.api_url(),
"https://ghe.example.com/api/v3"
);
}
#[test]
fn manifest_form_action_targets_the_host_web_origin() {
let github_com = GitHubHost::default();
let ghes = GitHubHost::new("https://ghe.example.com", None).unwrap();
let org = GitHubAppOwner::Organization("acme".to_string());
assert_eq!(
GitHubAppOwner::Personal.manifest_form_action(&github_com),
"https://github.com/settings/apps/new"
);
assert_eq!(
org.manifest_form_action(&github_com),
"https://github.com/organizations/acme/settings/apps/new"
);
assert_eq!(
GitHubAppOwner::Personal.manifest_form_action(&ghes),
"https://ghe.example.com/settings/apps/new"
);
assert_eq!(
org.manifest_form_action(&ghes),
"https://ghe.example.com/organizations/acme/settings/apps/new"
);
}

View file

@ -442,7 +442,7 @@ async fn build_preflight_report(
check: &ManifestCheck,
shape: &WorkflowShape,
) -> Result<(CheckReport, bool)> {
let mut checks = base_preflight_checks(prepared, shape);
let mut checks = base_preflight_checks(prepared, shape, &state.github_host);
if check.has_errors() {
return Ok((
CheckReport {
@ -513,10 +513,17 @@ async fn build_preflight_report(
prepared,
&resolved_run,
github_app.clone(),
&state.github_host,
)
.await;
let github_token_ok = run_github_token_check(
&mut checks,
prepared,
&resolved_run,
github_app,
&state.github_host,
)
.await;
let github_token_ok =
run_github_token_check(&mut checks, prepared, &resolved_run, github_app).await;
let checks_ok = sandbox_ok && repository_access_ok && github_token_ok;
@ -532,12 +539,16 @@ async fn build_preflight_report(
))
}
fn base_preflight_checks(prepared: &PreparedManifest, shape: &WorkflowShape) -> Vec<CheckResult> {
fn base_preflight_checks(
prepared: &PreparedManifest,
shape: &WorkflowShape,
host: &fabro_types::GitHubHost,
) -> Vec<CheckResult> {
let repo_summary = prepared.git.as_ref().map_or_else(
|| "unknown".to_string(),
|git| {
let https = fabro_github::ssh_url_to_https(&git.origin_url);
fabro_github::parse_github_owner_repo(&https).map_or_else(
fabro_github::parse_github_owner_repo(host, &https).map_or_else(
|_| git.origin_url.clone(),
|(owner, repo)| format!("{owner}/{repo}"),
)
@ -699,6 +710,7 @@ async fn run_repository_access_check(
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
host: &fabro_types::GitHubHost,
) -> bool {
run_repository_access_check_with(
checks,
@ -706,7 +718,8 @@ async fn run_repository_access_check(
prepared,
resolved_run,
github_app,
check_git_remote_ref,
host,
|request, github_app| check_git_remote_ref(request, github_app, host),
)
.await
}
@ -717,6 +730,7 @@ async fn run_repository_access_check_with<F, Fut>(
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
host: &fabro_types::GitHubHost,
check_remote_ref: F,
) -> bool
where
@ -734,7 +748,7 @@ where
};
let origin_url = fabro_github::normalize_repo_origin_url(&git.origin_url);
if let Err(err) = fabro_github::parse_github_owner_repo(&origin_url) {
if let Err(err) = fabro_github::parse_github_owner_repo(host, &origin_url) {
checks.push(CheckResult {
name: "Repository Access".into(),
status: CheckStatus::Error,
@ -780,11 +794,12 @@ where
async fn check_git_remote_ref(
request: GitRemoteRefCheck,
github_app: Option<fabro_github::GitHubCredentials>,
host: &fabro_types::GitHubHost,
) -> Result<(), String> {
let auth_url = match github_app.as_ref() {
Some(creds) => Some(
fabro_github::resolve_authenticated_url(
&fabro_github::GitHubContext::new(creds, &fabro_github::github_api_base_url()),
&fabro_github::GitHubContext::new(creds, host),
&request.origin_url,
)
.await
@ -940,14 +955,16 @@ async fn run_github_token_check(
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
host: &fabro_types::GitHubHost,
) -> bool {
run_github_token_check_with(
checks,
prepared,
resolved_run,
github_app,
mint_scoped_github_token,
probe_github_repository,
host,
|access, creds| mint_scoped_github_token(access, creds, host),
|slug, token| probe_github_repository(slug, token, host),
)
.await
}
@ -957,6 +974,7 @@ async fn run_github_token_check_with<M, MFut, P, PFut>(
prepared: &PreparedManifest,
resolved_run: &RunNamespace,
github_app: Option<fabro_github::GitHubCredentials>,
host: &fabro_types::GitHubHost,
mint_scoped_token: M,
probe_repository: P,
) -> bool
@ -998,6 +1016,7 @@ where
checks,
prepared,
github_app,
host,
&integration.permissions,
perm_details,
)
@ -1050,6 +1069,7 @@ where
// 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(
host,
Some(origin_url),
&integration.additional_repositories,
integration.permissions.clone(),
@ -1163,11 +1183,12 @@ async fn check_primary_only_github_token(
checks: &mut Vec<CheckResult>,
prepared: &PreparedManifest,
github_app: Option<fabro_github::GitHubCredentials>,
host: &fabro_types::GitHubHost,
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, permissions).await {
match mint_github_token(creds, host, &git.origin_url, permissions).await {
Ok(_) => {
checks.push(CheckResult {
name: "GitHub Token".into(),
@ -1207,9 +1228,10 @@ async fn check_primary_only_github_token(
async fn mint_scoped_github_token(
access: fabro_github::GitHubRepositoryAccess,
creds: fabro_github::GitHubCredentials,
host: &fabro_types::GitHubHost,
) -> std::result::Result<ResolvedToken, String> {
let source =
InstallationTokenSource::for_access(&creds, &access).map_err(|err| format!("{err:#}"))?;
let source = InstallationTokenSource::for_access(&creds, host, &access)
.map_err(|err| format!("{err:#}"))?;
source.resolve().await.map_err(|err| format!("{err:#}"))
}
@ -1222,9 +1244,10 @@ async fn mint_scoped_github_token(
async fn probe_github_repository(
slug: fabro_types::GitHubRepositorySlug,
token: ResolvedToken,
host: &fabro_types::GitHubHost,
) -> std::result::Result<(), String> {
let url = slug.https_url();
probe_with_replication_retry(token.snapshot, || run_probe_ls_remote(&url, &token)).await
let url = slug.https_url(host);
probe_with_replication_retry(token.snapshot, || run_probe_ls_remote(host, &url, &token)).await
}
/// Retry auth-shaped failures with the SAME token: replication of a given
@ -1347,30 +1370,29 @@ where
}
}
async fn run_probe_ls_remote(url: &str, token: &ResolvedToken) -> std::result::Result<(), String> {
async fn run_probe_ls_remote(
host: &fabro_types::GitHubHost,
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());
fabro_github::apply_probe_git_env(&mut command, host, token.token.expose());
command.args(["ls-remote", url, "HEAD"]);
run_ls_remote(command).await
}
async fn mint_github_token(
creds: &fabro_github::GitHubCredentials,
host: &fabro_types::GitHubHost,
origin_url: &str,
permissions: &HashMap<String, String>,
) -> Result<String> {
let https_url = fabro_github::ssh_url_to_https(origin_url);
let (owner, repo) = fabro_github::parse_github_owner_repo(&https_url)?;
let (owner, repo) = fabro_github::parse_github_owner_repo(host, &https_url)?;
let client = fabro_http::http_client()?;
let perms_json = serde_json::to_value(permissions)?;
creds
.resolve_bearer_token(
&client,
&owner,
&repo,
&fabro_github::github_api_base_url(),
perms_json,
)
.resolve_bearer_token(&client, &owner, &repo, host, perms_json)
.await
}
@ -1889,6 +1911,7 @@ provider = "local"
&prepared,
&resolved,
None,
&fabro_types::GitHubHost::default(),
move |request, _github_app| {
calls_for_check.lock().unwrap().push(request);
async { Ok(()) }
@ -1918,6 +1941,7 @@ provider = "local"
&prepared,
&resolved,
None,
&fabro_types::GitHubHost::default(),
move |request, _github_app| {
calls_for_check.lock().unwrap().push(request);
async { Ok(()) }
@ -1959,6 +1983,7 @@ provider = "local"
&prepared,
&resolved,
None,
&fabro_types::GitHubHost::default(),
move |request, _github_app| {
calls_for_check.lock().unwrap().push(request);
async { Ok(()) }
@ -1991,6 +2016,7 @@ provider = "local"
&prepared,
&resolved,
None,
&fabro_types::GitHubHost::default(),
|_request, _github_app| async { Err("remote branch not found".to_string()) },
)
.await;
@ -3155,6 +3181,7 @@ dockerfile = { path = "Dockerfile" }
&prepared,
&resolved,
Some(pat_creds()),
&fabro_types::GitHubHost::default(),
move |access, _creds| {
minted_for_seam.lock().unwrap().push(access);
async { Ok(static_token("scoped-token")) }
@ -3212,6 +3239,7 @@ dockerfile = { path = "Dockerfile" }
&prepared,
&resolved,
Some(pat_creds()),
&fabro_types::GitHubHost::default(),
|_access, _creds| async {
Err(
"the GitHub App installation cannot see repository acme/keystone; add \
@ -3250,6 +3278,7 @@ dockerfile = { path = "Dockerfile" }
&prepared,
&resolved,
Some(pat_creds()),
&fabro_types::GitHubHost::default(),
|_access, _creds| async { Ok(static_token("scoped-token")) },
|slug, _token| async move {
if slug.repo() == "keystone" {
@ -3296,6 +3325,7 @@ dockerfile = { path = "Dockerfile" }
&prepared,
&resolved,
Some(pat_creds()),
&fabro_types::GitHubHost::default(),
|_access, _creds| async { Ok(static_token("scoped-token")) },
|_slug, _token| async { Ok(()) },
)
@ -3324,6 +3354,7 @@ dockerfile = { path = "Dockerfile" }
&prepared,
&resolved,
Some(pat_creds()),
&fabro_types::GitHubHost::default(),
|_access, _creds| async { Ok(static_token("scoped-token")) },
|_slug, _token| async { Ok(()) },
)
@ -3354,6 +3385,7 @@ dockerfile = { path = "Dockerfile" }
&prepared,
&resolved,
None,
&fabro_types::GitHubHost::default(),
|_access, _creds| async { Ok(static_token("scoped-token")) },
|_slug, _token| async { Ok(()) },
)

View file

@ -4,26 +4,26 @@
//! can still override any header by setting their own value first — the
//! defaults here only fill in what's missing.
use axum::extract::Request;
use std::sync::Arc;
use axum::extract::{Request, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, header};
use axum::middleware::Next;
use axum::response::Response;
use crate::csp;
pub async fn layer(req: Request, next: Next) -> Response {
pub async fn layer(State(csp_policy): State<Arc<String>>, req: Request, next: Next) -> Response {
let is_https = request_is_https(&req);
let mut response = next.run(req).await;
apply_defaults(response.headers_mut(), is_https);
apply_csp(response.headers_mut());
apply_csp(response.headers_mut(), &csp_policy);
response
}
fn apply_csp(headers: &mut HeaderMap) {
fn apply_csp(headers: &mut HeaderMap, policy: &str) {
// Report-only while we tune the policy: browsers evaluate violations and log
// them to the console without blocking any resources. Switch back to the
// enforcing `content-security-policy` header once the policy is clean.
if let Ok(value) = HeaderValue::from_str(csp::policy()) {
if let Ok(value) = HeaderValue::from_str(policy) {
headers
.entry(HeaderName::from_static(
"content-security-policy-report-only",
@ -202,7 +202,7 @@ mod tests {
#[test]
fn csp_is_report_only_not_enforced() {
let mut headers = HeaderMap::new();
apply_csp(&mut headers);
apply_csp(&mut headers, "default-src 'self'");
assert!(headers.contains_key("content-security-policy-report-only"));
assert!(!headers.contains_key("content-security-policy"));
}

View file

@ -334,7 +334,9 @@ async fn start_webhook_strategy(
);
return Ok(None);
};
match TailscaleFunnelManager::start(port, &app_id, &private_key_pem).await {
match TailscaleFunnelManager::start(port, &state.github_host, &app_id, &private_key_pem)
.await
{
Ok(manager) => Ok(Some(manager)),
Err(err) => {
error!(
@ -356,8 +358,13 @@ async fn start_webhook_strategy(
)
})?;
let webhook_url = format!("{}{WEBHOOK_ROUTE}", server_api_url.trim_end_matches('/'));
match fabro_github::update_app_webhook_config(&app_id, &private_key_pem, &webhook_url)
.await
match fabro_github::update_app_webhook_config(
&state.github_host,
&app_id,
&private_key_pem,
&webhook_url,
)
.await
{
Ok(()) => info!(url = %webhook_url, "GitHub App webhook URL updated"),
Err(err) => warn!(
@ -737,7 +744,7 @@ where
preloaded_vault: startup_vault.into_vault(),
server_secrets,
env_lookup,
github_api_base_url: None,
github_host: None,
active_config_path,
http_client: None,
sandbox_inventory: None,

View file

@ -159,7 +159,7 @@ use crate::worker_control::{
use crate::worker_runtime::{LocalWorkerRuntime, WorkerLaunchSpec, WorkerRef, WorkerRuntime};
use crate::worker_token::{WorkerScopeSet, WorkerTokenKeys, issue_worker_token_with_scopes};
use crate::{
canonical_host, demo, diagnostics, run_manifest, security_headers, static_files, web_auth,
canonical_host, csp, demo, diagnostics, run_manifest, security_headers, static_files, web_auth,
};
mod automation_scheduler;
@ -1152,28 +1152,28 @@ pub struct AppState {
control_request_locks: KeyedMutex<RunId>,
parent_link_lock: AsyncMutex<()>,
pub(super) server_secrets: ServerSecrets,
pub(crate) llm_source: Arc<dyn CredentialProvider>,
pub(super) server_secrets: ServerSecrets,
pub(crate) llm_source: Arc<dyn CredentialProvider>,
/// The database pool the stores share, for the Petri run store a
/// server-process run writes its records through.
pub(crate) db_pool: DbPool,
manifest_run_defaults: RwLock<Arc<RunLayer>>,
manifest_run_settings: RwLock<std::result::Result<RunNamespace, SharedError>>,
pub(crate) db_pool: DbPool,
manifest_run_defaults: RwLock<Arc<RunLayer>>,
manifest_run_settings: RwLock<std::result::Result<RunNamespace, SharedError>>,
pub(crate) server_settings: RwLock<Arc<ServerSettings>>,
effective_web_url: RwLock<String>,
catalog: RwLock<Arc<Catalog>>,
pub(crate) env_lookup: EnvLookup,
pub(crate) github_api_base_url: String,
active_config_path: PathBuf,
http_client: Option<fabro_http::HttpClient>,
sandbox_inventory: SandboxInventory,
shutdown: CancellationToken,
shutting_down: AtomicBool,
effective_web_url: RwLock<String>,
catalog: RwLock<Arc<Catalog>>,
pub(crate) env_lookup: EnvLookup,
pub(crate) github_host: fabro_types::GitHubHost,
active_config_path: PathBuf,
http_client: Option<fabro_http::HttpClient>,
sandbox_inventory: SandboxInventory,
shutdown: CancellationToken,
shutting_down: AtomicBool,
/// Test switch: execute runs in this process instead of a worker.
execute_in_process: bool,
slack_service: Option<Arc<SlackService>>,
slack_started: AtomicBool,
github_webhook_secret: Option<String>,
execute_in_process: bool,
slack_service: Option<Arc<SlackService>>,
slack_started: AtomicBool,
github_webhook_secret: Option<String>,
}
pub(crate) struct AppStores {
@ -1277,7 +1277,7 @@ impl AppState {
.map_err(|source| RunMaterializeError::LoadCredentials { source })?;
ProductionAutomationRunMaterializer::new(
credentials,
self.github_api_base_url.clone(),
self.github_host.clone(),
self.http_client.clone(),
Arc::clone(&self.automation_repo_cache),
fabro_workflow_version::WorkflowVersionStore::new(self.store_ref().blobs()),
@ -1352,7 +1352,7 @@ pub(crate) struct AppStateConfig {
pub(crate) preloaded_vault: Vault,
pub(crate) server_secrets: ServerSecrets,
pub(crate) env_lookup: EnvLookup,
pub(crate) github_api_base_url: Option<String>,
pub(crate) github_host: Option<fabro_types::GitHubHost>,
pub(crate) active_config_path: PathBuf,
pub(crate) http_client: Option<fabro_http::HttpClient>,
pub(crate) sandbox_inventory: Option<SandboxInventory>,
@ -1924,8 +1924,9 @@ pub fn build_router_with_options(
} = options;
let translation_state = Arc::clone(&state);
let state_for_canonical_host = Arc::clone(&state);
let github_endpoints =
github_endpoints.unwrap_or_else(|| Arc::new(GithubEndpoints::production_defaults()));
let github_endpoints = github_endpoints
.unwrap_or_else(|| Arc::new(GithubEndpoints::from_host(&state.github_host)));
let csp_policy = Arc::new(csp::policy(&state.github_host));
let webhook_secret = state.github_webhook_secret.clone();
let principal_layer = middleware::from_fn_with_state(Arc::clone(&state), principal_middleware);
let api_common = if web_enabled {
@ -2032,7 +2033,10 @@ pub fn build_router_with_options(
},
canonical_host::redirect_middleware,
))
.layer(middleware::from_fn(security_headers::layer))
.layer(middleware::from_fn_with_state(
csp_policy,
security_headers::layer,
))
.layer(middleware::from_fn(http_log_middleware))
.layer(middleware::from_fn(request_id::layer))
}
@ -2519,7 +2523,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
preloaded_vault,
server_secrets,
env_lookup,
github_api_base_url,
github_host,
active_config_path,
http_client,
sandbox_inventory,
@ -2574,7 +2578,17 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
// Petri's records live on the shared pool; the view tables live where the
// run summary store keeps the `runs` row (the same database in the
// server, a fixture of its own in a test).
let petri_projector = Projector::new(db_pool.clone(), store.run_summary_store().pool());
let github_host = match github_host {
Some(host) => host,
None => fabro_github::github_host(
&resolved_settings.server_settings.server.integrations.github,
)?,
};
let petri_projector = Projector::with_github_host(
db_pool.clone(),
store.run_summary_store().pool(),
github_host.clone(),
);
{
let projector = Arc::clone(&petri_projector);
store.set_platform_record_hook(Arc::new(move |run_id| projector.signal(run_id)));
@ -2643,7 +2657,6 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
}
};
let worker_tokens = worker_token_keys_from_server_secrets(&server_secrets)?;
let github_api_base_url = github_api_base_url.unwrap_or_else(fabro_github::github_api_base_url);
let storage_root = PathBuf::from(&current_server_settings.server.storage.root);
let automation_repo_cache = Arc::new(GitRepoCache::new(
Storage::new(&storage_root)
@ -2721,7 +2734,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
effective_web_url: RwLock::new(current_effective_web_url),
catalog: RwLock::new(current_catalog),
env_lookup: Arc::clone(&env_lookup),
github_api_base_url,
github_host,
active_config_path,
http_client,
sandbox_inventory,

View file

@ -6,6 +6,7 @@ use axum::http::{HeaderValue, header};
use fabro_store::platform_records::{
PlatformRecord, PullRequestLinkedRecord, PullRequestRequestedRecord,
};
use fabro_types::GitHubHost;
use super::super::{
ApiError, AppState, CloseRunPullRequestResponse, CreateRunPullRequestRequest, IntoResponse,
@ -47,15 +48,22 @@ const PULL_REQUEST_CREATION_RETRY_AFTER: Duration = Duration::from_secs(1);
clippy::disallowed_types,
reason = "Pull-request API validates public github.com URLs; these raw URLs are not credential-bearing log output."
)]
fn parse_github_owner_repo_from_url(url: &str, kind: &str) -> Result<(String, String), ApiError> {
fn parse_github_owner_repo_from_url(
host: &GitHubHost,
url: &str,
kind: &str,
) -> Result<(String, String), ApiError> {
let parsed = fabro_http::Url::parse(url)
.map_err(|err| ApiError::bad_request(format!("Invalid {kind}: {err}")))?;
match parsed.host_str() {
Some("github.com") => {}
Some(host) => {
Some(got) if got == host.hostname() => {}
Some(got) => {
return Err(ApiError::with_code(
StatusCode::BAD_REQUEST,
format!("Pull request operations support github.com only (got {host})."),
format!(
"Pull request operations support {} only (got {got}).",
host.host()
),
"unsupported_host",
));
}
@ -66,19 +74,40 @@ fn parse_github_owner_repo_from_url(url: &str, kind: &str) -> Result<(String, St
}
}
fabro_github::parse_github_owner_repo(url).map_err(|err| ApiError::bad_request(err.to_string()))
fabro_github::parse_github_owner_repo(host, url)
.map_err(|err| ApiError::bad_request(err.to_string()))
}
#[expect(
clippy::disallowed_types,
reason = "Pull-request links are public GitHub URLs; the raw URL is only compared with the configured host."
)]
fn pull_request_record_from_link_request(
host: &GitHubHost,
body: &LinkRunPullRequestRequest,
) -> Result<PullRequestLink, ApiError> {
PullRequestLink::from_github_url(body.html_url.trim()).map_err(|err| {
let reject = |err: String| {
let code = if err.contains("GitHub pull request URL") {
"unsupported_pull_request_provider"
} else {
"invalid_pull_request_url"
};
ApiError::with_code(StatusCode::BAD_REQUEST, err, code)
};
let raw_url = body.html_url.trim();
let off_host = fabro_http::Url::parse(raw_url)
.is_ok_and(|url| url.origin().ascii_serialization() != host.web_url());
if off_host {
return Err(reject(format!(
"Pull request link must be a GitHub pull request URL like {}/owner/repo/pull/123.",
host.web_url()
)));
}
let link = PullRequestLink::from_github_url(raw_url).map_err(reject)?;
// Store the canonical URL, not the caller's spelling of it.
Ok(PullRequestLink {
html_url: host.pull_request_url(&link.owner, &link.repo, link.number),
..link
})
}
@ -123,7 +152,7 @@ pub(in crate::server) fn server_github_context<'a>(
})?;
Ok(fabro_github::GitHubContext::with_http_client(
creds,
state.github_api_base_url.as_str(),
&state.github_host,
http_client,
))
}
@ -272,7 +301,11 @@ impl<'a> RunPrInputs<'a> {
));
}
let normalized_origin = fabro_github::normalize_repo_origin_url(origin_url);
parse_github_owner_repo_from_url(&normalized_origin, "repo origin URL")?;
parse_github_owner_repo_from_url(
&state.github_host,
&normalized_origin,
"repo origin URL",
)?;
Ok(Self {
goal: run_spec.graph.goal(),
base_branch,
@ -456,7 +489,7 @@ async fn link_run_pull_request(
Json(body): Json<LinkRunPullRequestRequest>,
) -> Response {
let _create_guard = state.pull_request_create_locks.lock(id).await;
let pull_request = match pull_request_record_from_link_request(&body) {
let pull_request = match pull_request_record_from_link_request(&state.github_host, &body) {
Ok(record) => record,
Err(err) => return err.into_response(),
};
@ -612,9 +645,12 @@ async fn close_run_pull_request(
#[cfg(test)]
mod tests {
use axum::http::StatusCode;
use fabro_types::{DirtyStatus, GitContext, StartRecord, blob_ref};
use fabro_api::types::LinkRunPullRequestRequest;
use fabro_types::{DirtyStatus, GitContext, GitHubHost, StartRecord, blob_ref};
use super::RunPrInputs;
use super::{
RunPrInputs, parse_github_owner_repo_from_url, pull_request_record_from_link_request,
};
use crate::test_support::{test_app_state, test_concluded_run_projection};
/// A finished run with everything a pull request needs and `patch` as
@ -679,4 +715,69 @@ mod tests {
assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(error.detail(), "The saved final patch is missing.");
}
fn ghes() -> GitHubHost {
GitHubHost::new("https://ghe.example.com", None).unwrap()
}
fn link_request(html_url: &str) -> LinkRunPullRequestRequest {
serde_json::from_value(serde_json::json!({ "html_url": html_url })).unwrap()
}
#[test]
fn link_request_accepts_the_configured_host_and_stores_the_canonical_url() {
let link = pull_request_record_from_link_request(
&ghes(),
&link_request("https://GHE.example.com/acme/widgets/pull/7/"),
)
.unwrap();
assert_eq!(link.html_url, "https://ghe.example.com/acme/widgets/pull/7");
assert_eq!(
(link.owner.as_str(), link.repo.as_str(), link.number),
("acme", "widgets", 7)
);
let default = pull_request_record_from_link_request(
&GitHubHost::default(),
&link_request("https://github.com/acme/widgets/pull/7"),
)
.unwrap();
assert_eq!(default.html_url, "https://github.com/acme/widgets/pull/7");
}
#[test]
fn link_request_rejects_other_hosts_naming_the_configured_one() {
let error = pull_request_record_from_link_request(
&ghes(),
&link_request("https://github.com/acme/widgets/pull/7"),
)
.unwrap_err();
assert_eq!(error.status(), StatusCode::BAD_REQUEST);
assert!(
error
.detail()
.contains("https://ghe.example.com/owner/repo/pull/123"),
"{}",
error.detail()
);
}
#[test]
fn repo_origin_must_be_on_the_configured_host() {
let (owner, repo) = parse_github_owner_repo_from_url(
&ghes(),
"https://ghe.example.com/acme/widgets",
"origin",
)
.unwrap();
assert_eq!((owner.as_str(), repo.as_str()), ("acme", "widgets"));
let error =
parse_github_owner_repo_from_url(&ghes(), "https://github.com/acme/widgets", "origin")
.unwrap_err();
assert_eq!(
error.detail(),
"Pull request operations support ghe.example.com only (got github.com)."
);
}
}

View file

@ -556,7 +556,7 @@ pub(crate) async fn create_run_from_intent(
let explicit_title_supplied = intent.title.is_some();
// Validate the pure, in-memory request facts before paying for
// blob-store reads and closure lowering.
let ValidatedRunTarget { target, git } = match intent.target.validate() {
let ValidatedRunTarget { target, git } = match intent.target.validate(&state.github_host) {
Ok(validated) => validated,
Err(error) => return run_intent_admission_error(error.into()),
};

View file

@ -455,7 +455,7 @@ async fn get_github_repo(
}
let settings = state.server_settings();
let github_settings = &settings.server.integrations.github;
let base_url = fabro_github::github_api_base_url();
let base_url = state.github_host.api_url();
let (token, client) = match github_settings.strategy {
GithubIntegrationStrategy::App => {
if github_settings.app_id.is_none() {
@ -493,9 +493,14 @@ async fn get_github_repo(
.into_response();
}
};
let install_url = creds.installation_url(&owner).unwrap_or_else(|| {
format!("https://github.com/organizations/{owner}/settings/installations")
});
let install_url = creds
.installation_url(&state.github_host, &owner)
.unwrap_or_else(|| {
format!(
"{}/organizations/{owner}/settings/installations",
state.github_host.web_url()
)
});
let client = match state.http_client() {
Ok(http) => http,
@ -505,7 +510,7 @@ async fn get_github_repo(
}
};
let installed =
match fabro_github::check_app_installed(&client, &jwt, &owner, &name, &base_url)
match fabro_github::check_app_installed(&client, &jwt, &owner, &name, base_url)
.await
{
Ok(installed) => installed,
@ -537,7 +542,7 @@ async fn get_github_repo(
&jwt,
&owner,
&name,
&base_url,
&state.github_host,
serde_json::json!({ "contents": "write", "pull_requests": "write" }),
Some(&install_url),
)

View file

@ -474,6 +474,7 @@ pub(crate) async fn execute(state: Arc<AppState>, run_id: RunId) {
// The in-process path serves the server's tests: a Git target is
// fetched without a credential, and nothing is published.
let source = RunSource::for_run(
&state.github_host,
run_state.spec.target.as_ref(),
&run_state.spec.settings.run,
None,

View file

@ -2114,7 +2114,7 @@ fn slack_app_state_with_settings_and_secret_sources(
preloaded_vault: vault,
server_secrets: load_test_server_secrets(server_env_path, server_secret_env),
env_lookup: default_env_lookup(),
github_api_base_url: None,
github_host: None,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_inventory: None,
@ -5102,7 +5102,9 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings(
preloaded_vault,
server_secrets: load_test_server_secrets(server_env_path, HashMap::new()),
env_lookup: Arc::new(env_lookup),
github_api_base_url,
github_host: github_api_base_url.map(|url| {
fabro_types::GitHubHost::new(&url, Some(&url)).expect("mock GitHub URL should be valid")
}),
active_config_path,
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_inventory: None,
@ -6302,6 +6304,60 @@ slug = "fabro"
assert!(location.starts_with("https://github.com/login/oauth/authorize?"));
}
#[tokio::test]
async fn auth_login_github_redirects_to_the_configured_enterprise_host() {
let source = r#"
_version = 1
[server.auth]
methods = ["github"]
[server.web]
enabled = true
url = "http://localhost:3000"
[server.auth.github]
allowed_usernames = ["octocat"]
[server.integrations.github]
app_id = "123"
client_id = "Iv1.testclient"
slug = "fabro"
web_url = "https://ghe.example.com"
"#;
let app = build_router(
test_app_state_with_session_key(
server_settings_from_toml(source),
manifest_run_defaults_from_toml(source),
Some("github-redirect-test-key-0123456789"),
),
AuthMode::Enabled(ConfiguredAuth {
methods: vec![ServerAuthMethod::Github],
dev_token: None,
jwt_key: None,
jwt_issuer: None,
}),
);
let response = app
.oneshot(
Request::builder()
.uri("/auth/login/github")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let response = checked_response!(response, StatusCode::SEE_OTHER).await;
let location = response
.headers()
.get(axum::http::header::LOCATION)
.and_then(|value| value.to_str().ok())
.unwrap();
assert!(location.starts_with("https://ghe.example.com/login/oauth/authorize?"));
}
#[tokio::test]
async fn logout_redirects_to_login_page() {
let app = test_app_with();
@ -10808,7 +10864,7 @@ methods = ["dev-token"]
preloaded_vault,
server_secrets: ServerSecrets::load(server_env_path, HashMap::new()).unwrap(),
env_lookup: default_env_lookup(),
github_api_base_url: None,
github_host: None,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_inventory: None,
@ -10866,7 +10922,7 @@ fn slack_service_respects_disabled_server_config_even_with_vault_tokens() {
HashMap::new(),
),
env_lookup: default_env_lookup(),
github_api_base_url: None,
github_host: None,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
sandbox_inventory: None,

View file

@ -68,6 +68,15 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[
// the vault and supplies it explicitly to the in-process provider.
EnvVars::DAYTONA_API_URL,
EnvVars::DAYTONA_ORGANIZATION_ID,
// The GitHub deployment's non-secret selection. The worker's GitHub
// client reads the web origin and API base overrides; the settings
// reach it through FABRO_HOME.
EnvVars::GITHUB_WEB_URL,
EnvVars::GITHUB_BASE_URL,
// Private CA bundle for the worker's TLS stack (rustls platform verifier;
// honoured on Linux only), e.g. for a GitHub Enterprise Server host.
EnvVars::SSL_CERT_FILE,
EnvVars::SSL_CERT_DIR,
// A test's checkpoint gates: the worker's hooks hold at a named point
// until the test releases them, so a crash can be placed there.
EnvVars::FABRO_TEST_CHECKPOINT_GATES,

View file

@ -302,7 +302,7 @@ impl TestAppStateBuilder {
preloaded_vault,
server_secrets: load_test_server_secrets(server_env_path, self.server_secret_env),
env_lookup: self.env_lookup,
github_api_base_url: None,
github_host: None,
active_config_path,
http_client: Some(
fabro_http::test_http_client().expect("test HTTP client should build"),

View file

@ -762,12 +762,12 @@ async fn callback_github(
v: 2,
login: profile.login.clone(),
auth_method: AuthMethod::Github,
identity: IdpIdentity::new("https://github.com", profile.id.to_string())
identity: IdpIdentity::new(state.github_host.web_url(), profile.id.to_string())
.expect("GitHub profile id should produce a valid identity"),
name: profile.name.unwrap_or_else(|| profile.login.clone()),
email: primary_email,
avatar_url: profile.avatar_url,
user_url: format!("https://github.com/{}", profile.login),
user_url: format!("{}/{}", state.github_host.web_url(), profile.login),
iat: now.timestamp(),
exp: (now + chrono::Duration::days(30)).timestamp(),
};
@ -997,7 +997,7 @@ mod tests {
use fabro_config::{RunLayer, ServerSettingsBuilder};
use fabro_static::EnvVars;
use fabro_types::settings::server::ServerAuthMethod;
use fabro_types::{AuthMethod, IdpIdentity, Principal};
use fabro_types::{AuthMethod, GitHubHost, IdpIdentity, Principal};
use fabro_vault::SecretType;
use serde_json::json;
use tower::ServiceExt;
@ -1109,7 +1109,9 @@ client_id = "github-client-id"
translation_state,
crate::auth::auth_translation_middleware,
))
.layer(Extension(Arc::new(GithubEndpoints::production_defaults())))
.layer(Extension(Arc::new(GithubEndpoints::from_host(
&GitHubHost::default(),
))))
.layer(Extension(auth_mode))
.with_state(state)
}
@ -1130,7 +1132,9 @@ client_id = "github-client-id"
Arc::clone(&captured),
crate::test_support::capture_auth_context,
))
.layer(Extension(Arc::new(GithubEndpoints::production_defaults())))
.layer(Extension(Arc::new(GithubEndpoints::from_host(
&GitHubHost::default(),
))))
.layer(Extension(auth_mode))
.with_state(state);
(app, captured)

View file

@ -142,8 +142,7 @@ fn legacy_target(
tag,
sha,
})
.validate()
.map(|validated| validated.target)
.canonical()
.map_err(|source| AutomationStoreError::LegacyTarget {
path: path.to_path_buf(),
source,

View file

@ -166,8 +166,7 @@ pub fn validate_workflow_source(
source: AutomationGitWorkflowSource,
) -> Result<AutomationGitWorkflowSource, AutomationValidationError> {
source
.validate()
.map(fabro_types::ValidatedGitRunTarget::into_target)
.canonical()
.map_err(|source| AutomationValidationError::InvalidWorkflowSource { source })
}
@ -404,8 +403,7 @@ fn validate_target(target: RunTarget) -> Result<RunTarget, AutomationValidationE
});
}
target
.validate()
.map(|validated| validated.target)
.canonical()
.map_err(|source| AutomationValidationError::InvalidTarget { source })
}

View file

@ -9,8 +9,8 @@
use std::collections::{BTreeSet, HashMap};
use anyhow::{Context as _, bail};
use fabro_types::GitHubRepositorySlug;
use fabro_types::settings::run::RunIntegrationsGithubSettings;
use fabro_types::{GitHubHost, GitHubRepositorySlug};
use futures::stream::{self, StreamExt as _};
use crate::{GitHubAppCredentials, HttpClient, InstallationLookup, InstallationToken};
@ -48,6 +48,7 @@ impl GitHubRepositoryAccess {
/// - a declared additional set requires interpolated permissions with
/// `contents = "read"` or `contents = "write"`.
pub fn new(
host: &GitHubHost,
origin_url: Option<&str>,
additional_repositories: &BTreeSet<GitHubRepositorySlug>,
permissions: HashMap<String, String>,
@ -64,7 +65,7 @@ impl GitHubRepositoryAccess {
};
let normalized = crate::normalize_repo_origin_url(origin_url);
let (owner, repo) = crate::parse_github_owner_repo(&normalized)
let (owner, repo) = crate::parse_github_owner_repo(host, &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}");
@ -257,7 +258,12 @@ mod tests {
additional: &[&str],
permissions: HashMap<String, String>,
) -> anyhow::Result<Option<GitHubRepositoryAccess>> {
GitHubRepositoryAccess::new(Some(origin), &slugs(additional), permissions)
GitHubRepositoryAccess::new(
&GitHubHost::default(),
Some(origin),
&slugs(additional),
permissions,
)
}
#[test]
@ -278,19 +284,34 @@ mod tests {
#[test]
fn no_origin_and_no_additional_repositories_is_none() {
let access = GitHubRepositoryAccess::new(None, &BTreeSet::new(), HashMap::new()).unwrap();
let access = GitHubRepositoryAccess::new(
&GitHubHost::default(),
None,
&BTreeSet::new(),
HashMap::new(),
)
.unwrap();
assert!(access.is_none());
let blank =
GitHubRepositoryAccess::new(Some(" "), &BTreeSet::new(), HashMap::new()).unwrap();
let blank = GitHubRepositoryAccess::new(
&GitHubHost::default(),
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();
let err = GitHubRepositoryAccess::new(
&GitHubHost::default(),
None,
&slugs(&["fabro-sh/keystone"]),
contents_read(),
)
.unwrap_err();
assert!(
err.to_string().contains("requires a GitHub run origin"),
"{err:#}"

View file

@ -2,11 +2,12 @@
//!
//! A run's commits carry the identity of the credential the run uses:
//! the App's bot account (`<slug>[bot]`) or the authenticated user of a
//! personal access token. Both resolve to a stable GitHub.com noreply
//! address, `{id}+{login}@users.noreply.github.com`, so GitHub attributes
//! personal access token. Both resolve to a stable noreply
//! address, `{id}+{login}@users.noreply.<host>`, so GitHub attributes
//! the commits to that account without a private email.
use anyhow::{Context as _, bail};
use fabro_types::GitHubHost;
use serde::Deserialize;
use crate::{GitHubAppCredentials, HttpClient, HttpMethod, github_headers, sign_app_jwt};
@ -21,10 +22,10 @@ pub struct GitHubAccountIdentity {
}
impl GitHubAccountIdentity {
/// The GitHub.com noreply address GitHub attributes to this account.
/// The noreply address `host` attributes to this account.
#[must_use]
pub fn noreply_email(&self) -> String {
format!("{}+{}@users.noreply.github.com", self.id, self.login)
pub fn noreply_email(&self, host: &GitHubHost) -> String {
format!("{}+{}@{}", self.id, self.login, host.noreply_email_domain())
}
}
@ -153,6 +154,24 @@ mod tests {
}
}
#[test]
fn noreply_email_uses_the_hosts_domain() {
let identity = GitHubAccountIdentity {
login: "octo".to_string(),
id: 7,
};
let ghes = GitHubHost::new("https://ghe.example.com", None).unwrap();
assert_eq!(
identity.noreply_email(&ghes),
"7+octo@users.noreply.ghe.example.com"
);
let ghec = GitHubHost::new("https://octocorp.ghe.com", None).unwrap();
assert_eq!(
identity.noreply_email(&ghec),
"7+octo@users.noreply.octocorp.ghe.com"
);
}
#[test]
fn noreply_email_uses_id_plus_login() {
let identity = GitHubAccountIdentity {
@ -160,7 +179,7 @@ mod tests {
id: 281_434_857,
};
assert_eq!(
identity.noreply_email(),
identity.noreply_email(&GitHubHost::default()),
"281434857+fabro-sh[bot]@users.noreply.github.com"
);
}
@ -251,7 +270,7 @@ mod tests {
assert_eq!(identity.login, "my-app[bot]");
assert_eq!(identity.id, 4242);
assert_eq!(
identity.noreply_email(),
identity.noreply_email(&GitHubHost::default()),
"4242+my-app[bot]@users.noreply.github.com"
);
// The configured slug skips the `/app` lookup entirely.

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,7 @@ use std::time::Duration;
use anyhow::{Context as _, bail};
use chrono::{DateTime, Utc};
use fabro_types::GitHubHost;
use tokio::sync::Mutex;
use crate::{GitHubAppCredentials, GitHubCredentials, InstallationToken};
@ -162,10 +163,10 @@ enum AppTokenScope {
/// Real minter backed by GitHub App credentials.
struct AppTokenMinter {
creds: GitHubAppCredentials,
http: fabro_http::HttpClient,
base_url: String,
scope: AppTokenScope,
creds: GitHubAppCredentials,
http: fabro_http::HttpClient,
host: GitHubHost,
scope: AppTokenScope,
}
#[async_trait::async_trait]
@ -182,7 +183,7 @@ impl InstallationTokenMinter for AppTokenMinter {
&self.http,
owner,
repo,
&self.base_url,
&self.host,
permissions.clone(),
None,
)
@ -190,7 +191,7 @@ impl InstallationTokenMinter for AppTokenMinter {
}
AppTokenScope::Access(access) => {
access
.mint_installation_token(&self.creds, &self.http, &self.base_url)
.mint_installation_token(&self.creds, &self.http, self.host.api_url())
.await
}
}
@ -263,24 +264,26 @@ impl InstallationTokenSource {
/// pass through and ignore it.
pub fn for_origin(
creds: &GitHubCredentials,
host: &GitHubHost,
origin_url: &str,
permissions: serde_json::Value,
) -> anyhow::Result<Arc<Self>> {
let normalized = crate::normalize_repo_origin_url(origin_url);
let (owner, repo) = crate::parse_github_owner_repo(&normalized)
let (owner, repo) = crate::parse_github_owner_repo(host, &normalized)
.context("parsing GitHub origin for token source")?;
Self::for_repository(creds, owner, repo, permissions)
Self::for_repository(creds, host, owner, repo, permissions)
}
/// Build a source for an already parsed GitHub repository.
pub fn for_repository(
creds: &GitHubCredentials,
host: &GitHubHost,
owner: String,
repo: String,
permissions: serde_json::Value,
) -> anyhow::Result<Arc<Self>> {
let repo_display = format!("{owner}/{repo}");
Self::with_app_scope(creds, repo_display, AppTokenScope::Repository {
Self::with_app_scope(creds, host, repo_display, AppTokenScope::Repository {
owner,
repo,
permissions,
@ -294,15 +297,22 @@ impl InstallationTokenSource {
/// single-flight behavior are identical to the single-repository source.
pub fn for_access(
creds: &GitHubCredentials,
host: &GitHubHost,
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()))
Self::with_app_scope(
creds,
host,
repo_display,
AppTokenScope::Access(access.clone()),
)
}
fn with_app_scope(
creds: &GitHubCredentials,
host: &GitHubHost,
repo_display: String,
scope: AppTokenScope,
) -> anyhow::Result<Arc<Self>> {
@ -317,7 +327,7 @@ impl InstallationTokenSource {
minter: Box::new(AppTokenMinter {
creds: app.clone(),
http,
base_url: crate::github_api_base_url(),
host: host.clone(),
scope,
}),
cache: Mutex::new(None),
@ -550,6 +560,7 @@ mod tests {
async fn pat_resolves_as_static_generation_zero() {
let source = InstallationTokenSource::for_origin(
&GitHubCredentials::Pat("ghp_pat".to_string()),
&GitHubHost::default(),
"https://github.com/owner/repo.git",
serde_json::json!({ "contents": "write" }),
)
@ -569,6 +580,7 @@ mod tests {
#[tokio::test]
async fn for_access_source_resolves_like_the_single_repository_source() {
let access = crate::GitHubRepositoryAccess::new(
&GitHubHost::default(),
Some("https://github.com/owner/repo.git"),
&["owner/keystone".parse().unwrap()].into_iter().collect(),
std::collections::HashMap::from([("contents".to_string(), "read".to_string())]),
@ -578,6 +590,7 @@ mod tests {
let source = InstallationTokenSource::for_access(
&GitHubCredentials::Pat("ghp_pat".to_string()),
&GitHubHost::default(),
&access,
)
.unwrap();
@ -594,6 +607,7 @@ mod tests {
token: "ghs_static".to_string(),
expires_at: Utc::now() + chrono::Duration::minutes(30),
}),
&GitHubHost::default(),
"https://github.com/owner/repo.git",
serde_json::json!({}),
)
@ -607,6 +621,7 @@ mod tests {
token: "ghs_expired".to_string(),
expires_at: Utc::now() - chrono::Duration::seconds(1),
}),
&GitHubHost::default(),
"https://github.com/owner/repo.git",
serde_json::json!({}),
)

View file

@ -4,10 +4,15 @@ use fabro_github::{
get_pull_request, merge_pull_request, resolve_authenticated_url, sign_app_jwt,
};
use fabro_test::{GitHubAppOptions, GitHubAppState, TwinGitHub};
use fabro_types::GitHubHost;
use fabro_types::settings::run::MergeStrategy;
const TEST_RSA_KEY: &str = include_str!("../src/testdata/rsa_private.pem");
fn twin_host(twin: &TwinGitHub) -> GitHubHost {
GitHubHost::new(&twin.base_url, Some(&twin.base_url)).expect("twin URL should be a valid host")
}
fn github_credentials() -> GitHubCredentials {
GitHubCredentials::App(GitHubAppCredentials {
app_id: "42".to_string(),
@ -40,7 +45,8 @@ fn standard_app_state() -> GitHubAppState {
async fn create_and_get_pull_request() {
let twin = TwinGitHub::start(standard_app_state()).await;
let creds = github_credentials();
let ctx = &GitHubContext::new(&creds, &twin.base_url);
let host = twin_host(&twin);
let ctx = &GitHubContext::new(&creds, &host);
let created = create_pull_request(
ctx,
@ -71,7 +77,8 @@ async fn create_and_get_pull_request() {
async fn create_merge_and_verify_state() {
let twin = TwinGitHub::start(standard_app_state()).await;
let creds = github_credentials();
let ctx = &GitHubContext::new(&creds, &twin.base_url);
let host = twin_host(&twin);
let ctx = &GitHubContext::new(&creds, &host);
let created = create_pull_request(
ctx, "acme", "widgets", "main", "feature", "Merge me", "PR body", false,
@ -104,7 +111,8 @@ async fn create_close_and_verify_state() {
let twin = TwinGitHub::start(standard_app_state()).await;
let creds = github_credentials();
let ctx = &GitHubContext::new(&creds, &twin.base_url);
let host = twin_host(&twin);
let ctx = &GitHubContext::new(&creds, &host);
let created = create_pull_request(
ctx, "acme", "widgets", "main", "feature", "Close me", "PR body", false,
@ -130,7 +138,8 @@ async fn enable_auto_merge_persists() {
let twin = TwinGitHub::start(standard_app_state()).await;
let creds = github_credentials();
let ctx = &GitHubContext::new(&creds, &twin.base_url);
let host = twin_host(&twin);
let ctx = &GitHubContext::new(&creds, &host);
let created = create_pull_request(
ctx,
@ -160,10 +169,9 @@ async fn enable_auto_merge_persists() {
};
let jwt = sign_app_jwt(&app_creds.app_id, &app_creds.private_key_pem).unwrap();
let client = fabro_test::test_http_client();
let token =
create_installation_access_token_for_pr(&client, &jwt, "acme", "widgets", &twin.base_url)
.await
.unwrap();
let token = create_installation_access_token_for_pr(&client, &jwt, "acme", "widgets", &host)
.await
.unwrap();
let detail: serde_json::Value = fabro_test::test_http_client()
.get(format!(
@ -192,9 +200,10 @@ async fn enable_auto_merge_persists() {
async fn resolve_authenticated_url_embeds_token() {
let twin = TwinGitHub::start(standard_app_state()).await;
let creds = github_credentials();
let host = twin_host(&twin);
let url = resolve_authenticated_url(
&GitHubContext::new(&creds, &twin.base_url),
&GitHubContext::new(&creds, &host),
"https://github.com/acme/widgets.git",
)
.await
@ -214,9 +223,10 @@ async fn resolve_authenticated_url_embeds_token() {
async fn resolve_authenticated_url_errors_on_non_github_url() {
let twin = TwinGitHub::start(standard_app_state()).await;
let creds = github_credentials();
let host = twin_host(&twin);
let error = resolve_authenticated_url(
&GitHubContext::new(&creds, &twin.base_url),
&GitHubContext::new(&creds, &host),
"https://gitlab.com/foo/bar",
)
.await

View file

@ -21,7 +21,7 @@ use std::time::Duration;
use fabro_github::token_source::InstallationTokenSource;
use fabro_github::{GitHubAppCredentials, GitHubCredentials, GitHubRepositoryAccess};
use fabro_types::GitHubRepositorySlug;
use fabro_types::{GitHubHost, GitHubRepositorySlug};
use tokio::process::Command;
use tokio::time::sleep;
@ -34,9 +34,9 @@ fn env_var(name: &str) -> String {
}
async fn ls_remote_with_token(slug: &GitHubRepositorySlug, token: &str) -> bool {
let url = slug.https_url();
let url = slug.https_url(&GitHubHost::default());
let mut command = Command::new("git");
fabro_github::apply_probe_git_env(&mut command, token);
fabro_github::apply_probe_git_env(&mut command, &GitHubHost::default(), token);
let output = command
.args(["ls-remote", &url, "HEAD"])
.stdout(Stdio::null())
@ -65,6 +65,7 @@ async fn scoped_token_reaches_the_declared_additional_repository() {
let additional_set: BTreeSet<GitHubRepositorySlug> = [additional.clone()].into_iter().collect();
let access = GitHubRepositoryAccess::new(
&GitHubHost::default(),
Some(&origin),
&additional_set,
std::collections::HashMap::from([("contents".to_string(), "read".to_string())]),
@ -75,8 +76,8 @@ async fn scoped_token_reaches_the_declared_additional_repository() {
// 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 source = InstallationTokenSource::for_access(&creds, &GitHubHost::default(), &access)
.expect("token source should build");
let resolved = source.resolve().await.expect("scoped mint should succeed");
let token = resolved.token.expose();
@ -102,15 +103,19 @@ async fn scoped_token_reaches_the_declared_additional_repository() {
// read the additional repository (proves server-side scoping, not just
// possession of a token).
let primary_only = GitHubRepositoryAccess::new(
&GitHubHost::default(),
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_source = InstallationTokenSource::for_access(
&GitHubCredentials::App(app),
&GitHubHost::default(),
&primary_only,
)
.expect("primary-only token source should build");
let narrow = narrow_source
.resolve()
.await

View file

@ -31,8 +31,8 @@ use fabro_template::validate_static_reference;
use fabro_types::settings::interp::InterpString;
use fabro_types::settings::run::{ApprovalMode, ResolvedGoalSource, ResolvedRunGoal, RunMode};
use fabro_types::{
DirtyStatus, GitContext, GitHubRepositorySlug, GitRunTarget, ManifestPath, ReferenceKind,
RunTarget, SandboxProviderKind, WorkflowSettings,
DirtyStatus, GitContext, GitHubHost, GitHubRepositorySlug, GitRunTarget, ManifestPath,
ReferenceKind, RunTarget, SandboxProviderKind, WorkflowSettings,
};
use fabro_workflow::git::{self, GitSyncStatus};
pub use fabro_workflow_version::CollectedWorkflowClosure;
@ -60,6 +60,9 @@ pub struct ManifestBuildInput {
/// Path to the user settings file (for inclusion in
/// `RunManifest.configs`). `None` skips the user config entry.
pub user_settings_path: Option<PathBuf>,
/// The GitHub deployment `run.scm` repositories and git origins are
/// resolved against; github.com unless the caller configures another.
pub github_host: GitHubHost,
}
#[derive(Debug)]
@ -223,7 +226,8 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
&working_directory,
)?;
let configured_repo_origin_url = configured_repo_origin_url(&workflow_settings);
let configured_repo_origin_url =
configured_repo_origin_url(&input.github_host, &workflow_settings);
let git = build_legacy_git_context(&working_directory, configured_repo_origin_url.as_deref());
let args = input.args.filter(|args| !manifest_args_is_empty(args));
@ -357,13 +361,17 @@ pub enum ExactCommitStatus {
/// branch so clone-based execution can resolve the observed commit.
#[must_use]
pub fn observe_git_run_target(
host: &GitHubHost,
repo_path: &Path,
configured_repo_origin_url: Option<&str>,
) -> Option<GitRunTargetObservation> {
let local = inspect_local_git(repo_path, configured_repo_origin_url)?;
let legacy_git_context = local.legacy_git_context;
let mut run_target =
github_run_target(&legacy_git_context.origin_url, &legacy_git_context.branch);
let mut run_target = github_run_target(
host,
&legacy_git_context.origin_url,
&legacy_git_context.branch,
);
let mut exact_commit = ExactCommitStatus::Unpublished;
if let Some(target) = run_target.as_mut() {
let publish_status = publish_manifest_branch_best_effort(
@ -459,6 +467,7 @@ pub enum RunTargetDerivationError {
/// Returns the reason a clone-based target could not be derived; every
/// variant's message is written for the CLI caller.
pub fn derive_run_target_for_provider(
host: &GitHubHost,
provider: &SandboxProviderKind,
canonical_cwd: &Path,
configured_repo_origin_url: Option<&str>,
@ -476,7 +485,7 @@ pub fn derive_run_target_for_provider(
dirty_worktree: false,
});
}
let Some(observation) = observe_git_run_target(canonical_cwd, configured_repo_origin_url)
let Some(observation) = observe_git_run_target(host, canonical_cwd, configured_repo_origin_url)
else {
return Ok(DerivedRunTarget {
target: none_target_for_unversioned_directory(canonical_cwd)?,
@ -546,6 +555,7 @@ fn none_target_for_unversioned_directory(
///
/// Returns an error when either config exists but cannot be read or parsed.
pub fn configured_repo_origin_url_for_location(
host: &GitHubHost,
location: &WorkflowLocation,
) -> Result<Option<String>> {
let workflow = location
@ -565,7 +575,7 @@ pub fn configured_repo_origin_url_for_location(
repository: workflow.repository.or(project.repository),
github: workflow.github.or(project.github),
};
Ok(configured_repo_origin_url_from_scm_layer(&scm))
Ok(configured_repo_origin_url_from_scm_layer(host, &scm))
}
struct LocalGitObservation {
@ -623,8 +633,8 @@ fn build_legacy_git_context(
Some(local.legacy_git_context)
}
fn github_run_target(origin_url: &str, branch: &str) -> Option<GitRunTarget> {
let (owner, repository) = fabro_github::parse_github_owner_repo(origin_url).ok()?;
fn github_run_target(host: &GitHubHost, origin_url: &str, branch: &str) -> Option<GitRunTarget> {
let (owner, repository) = fabro_github::parse_github_owner_repo(host, origin_url).ok()?;
let slug = GitHubRepositorySlug::try_new(&format!("{owner}/{repository}"))?;
let validated = RunTarget::Git(GitRunTarget {
repo: slug.to_string(),
@ -632,7 +642,7 @@ fn github_run_target(origin_url: &str, branch: &str) -> Option<GitRunTarget> {
tag: None,
sha: None,
})
.validate()
.validate(host)
.ok()?;
let RunTarget::Git(target) = validated.target else {
unreachable!("a validated Git target must remain a Git target")
@ -640,9 +650,10 @@ fn github_run_target(origin_url: &str, branch: &str) -> Option<GitRunTarget> {
Some(target)
}
fn configured_repo_origin_url(settings: &WorkflowSettings) -> Option<String> {
fn configured_repo_origin_url(host: &GitHubHost, settings: &WorkflowSettings) -> Option<String> {
let scm = &settings.run.scm;
configured_repo_origin_url_from_scm(
host,
scm.provider.as_deref(),
scm.owner.as_deref(),
scm.repository.as_deref(),
@ -650,6 +661,7 @@ fn configured_repo_origin_url(settings: &WorkflowSettings) -> Option<String> {
}
fn configured_repo_origin_url_from_scm(
host: &GitHubHost,
provider: Option<&str>,
owner: Option<&str>,
repository: Option<&str>,
@ -662,13 +674,17 @@ fn configured_repo_origin_url_from_scm(
if owner.trim().is_empty() || repository.trim().is_empty() {
return None;
}
let origin = format!("https://github.com/{owner}/{repository}");
let origin = host.repository_url(owner, repository);
let normalized = fabro_github::normalize_repo_origin_url(&origin);
(!normalized.is_empty()).then_some(normalized)
}
fn configured_repo_origin_url_from_scm_layer(scm: &RunScmLayer) -> Option<String> {
fn configured_repo_origin_url_from_scm_layer(
host: &GitHubHost,
scm: &RunScmLayer,
) -> Option<String> {
configured_repo_origin_url_from_scm(
host,
scm.provider.as_deref(),
scm.owner.as_deref(),
scm.repository.as_deref(),
@ -865,6 +881,36 @@ pub(crate) mod test_fixtures {
#[cfg(test)]
mod tests {
#[test]
fn scm_origin_and_run_target_follow_the_configured_host() {
let ghes = GitHubHost::new("https://ghe.example.com", None).unwrap();
assert_eq!(
configured_repo_origin_url_from_scm(
&ghes,
Some("github"),
Some("acme"),
Some("widgets")
)
.as_deref(),
Some("https://ghe.example.com/acme/widgets")
);
assert_eq!(
configured_repo_origin_url_from_scm(
&GitHubHost::default(),
None,
Some("acme"),
Some("widgets")
)
.as_deref(),
Some("https://github.com/acme/widgets")
);
let target =
github_run_target(&ghes, "https://ghe.example.com/acme/widgets", "main").unwrap();
assert_eq!(target.repo, "acme/widgets");
assert!(github_run_target(&ghes, "https://github.com/acme/widgets", "main").is_none());
}
use fabro_workflow::git::head_sha;
use super::*;
@ -2050,8 +2096,12 @@ exit 1
]);
run_git(&workspace, &["push", "origin", "feature"]);
let observation =
observe_git_run_target(&workspace, Some("https://github.com/acme/widgets")).unwrap();
let observation = observe_git_run_target(
&GitHubHost::default(),
&workspace,
Some("https://github.com/acme/widgets"),
)
.unwrap();
let target = observation.run_target.as_ref().unwrap();
let legacy = &observation.legacy_git_context;
@ -2077,8 +2127,12 @@ exit 1
"https://github.com/acme/widgets",
]);
let observation =
observe_git_run_target(&workspace, Some("https://github.com/acme/widgets")).unwrap();
let observation = observe_git_run_target(
&GitHubHost::default(),
&workspace,
Some("https://github.com/acme/widgets"),
)
.unwrap();
let target = observation.run_target.as_ref().unwrap();
assert_eq!(target.sha, observation.legacy_git_context.sha);
@ -2112,8 +2166,12 @@ exit 1
]);
mark_origin_branch_synced(&workspace, "feature");
let observation =
observe_git_run_target(&workspace, Some("https://github.com/acme/widgets")).unwrap();
let observation = observe_git_run_target(
&GitHubHost::default(),
&workspace,
Some("https://github.com/acme/widgets"),
)
.unwrap();
let target = observation.run_target.as_ref().unwrap();
assert_eq!(target.sha, None);
@ -2138,7 +2196,8 @@ exit 1
std::fs::create_dir_all(&workspace).unwrap();
init_git_repo(&workspace, branch, "https://github.com/acme/widgets");
let observation = observe_git_run_target(&workspace, None).unwrap();
let observation =
observe_git_run_target(&GitHubHost::default(), &workspace, None).unwrap();
assert_eq!(observation.run_target, None, "branch {branch}");
assert_eq!(observation.legacy_git_context.branch, branch);
@ -2162,9 +2221,12 @@ exit 1
"https://github.com/acme/widgets",
]);
let failed =
observe_git_run_target(&failed_workspace, Some("https://github.com/acme/widgets"))
.unwrap();
let failed = observe_git_run_target(
&GitHubHost::default(),
&failed_workspace,
Some("https://github.com/acme/widgets"),
)
.unwrap();
assert_eq!(failed.run_target.as_ref().unwrap().sha, None);
assert!(failed.legacy_git_context.sha.is_some());
assert_eq!(failed.legacy_git_context.dirty, DirtyStatus::Clean);
@ -2179,6 +2241,7 @@ exit 1
);
let mismatched = observe_git_run_target(
&GitHubHost::default(),
&mismatched_workspace,
Some("https://github.com/acme/configured"),
)
@ -2198,7 +2261,7 @@ exit 1
init_git_repo(&workspace, "feature", bare_origin.to_str().unwrap());
std::fs::write(workspace.join("dirty.txt"), "dirty").unwrap();
let unsupported = observe_git_run_target(&workspace, None).unwrap();
let unsupported = observe_git_run_target(&GitHubHost::default(), &workspace, None).unwrap();
assert!(unsupported.run_target.is_none());
assert_eq!(unsupported.legacy_git_context.dirty, DirtyStatus::Dirty);
assert_eq!(
@ -2208,15 +2271,15 @@ exit 1
let not_repo = temp.path().join("not-repo");
std::fs::create_dir_all(&not_repo).unwrap();
assert!(observe_git_run_target(&not_repo, None).is_none());
assert!(observe_git_run_target(&GitHubHost::default(), &not_repo, None).is_none());
let unborn = temp.path().join("unborn");
std::fs::create_dir_all(&unborn).unwrap();
run_git(&unborn, &["init", "--quiet"]);
assert!(observe_git_run_target(&unborn, None).is_none());
assert!(observe_git_run_target(&GitHubHost::default(), &unborn, None).is_none());
run_git(&workspace, &["checkout", "--detach", "--quiet"]);
assert!(observe_git_run_target(&workspace, None).is_none());
assert!(observe_git_run_target(&GitHubHost::default(), &workspace, None).is_none());
}
fn init_git_repo(path: &Path, branch: &str, origin_url: &str) {

View file

@ -148,9 +148,10 @@ impl RunView {
}
PlatformRecord::PullRequestCreated(record) => {
let link = PullRequestLink {
owner: record.owner.clone(),
repo: record.repo.clone(),
number: record.number,
owner: record.owner.clone(),
repo: record.repo.clone(),
number: record.number,
html_url: record.html_url.clone(),
};
projection.pull_request = Some(link.clone());
if let Some(creation) = projection

View file

@ -67,7 +67,7 @@ use std::time::Duration;
use fabro_db::DbPool;
use fabro_store::platform_records::{PlatformRecordStore, now_ms};
use fabro_store::{RunProjection, RunSummaryStore};
use fabro_types::{RunId, RunStreamItem};
use fabro_types::{GitHubHost, RunId, RunStreamItem};
use fabro_util::error::collect_chain;
use fabro_util::sync;
use petri_execution::events::{EventId, EventSource, RunEvent};
@ -236,6 +236,8 @@ pub struct Projector {
/// stream grew. A wake-up for the stream's readers, never a source of
/// facts; a reader that lags re-reads from its cursor.
committed: broadcast::Sender<RunId>,
/// The GitHub deployment run origins are classified against.
github_host: GitHubHost,
}
impl std::fmt::Debug for Projector {
@ -250,7 +252,14 @@ impl Projector {
/// passes its one pool twice.
#[must_use]
pub fn new(records: DbPool, views: DbPool) -> Arc<Self> {
Self::with_github_host(records, views, GitHubHost::default())
}
/// [`Projector::new`] classifying run origins against `github_host`.
#[must_use]
pub fn with_github_host(records: DbPool, views: DbPool, github_host: GitHubHost) -> Arc<Self> {
Arc::new(Self {
github_host,
store: SqliteRunStore::new(records.clone()),
platform: PlatformRecordStore::new(views.clone()),
records,
@ -725,9 +734,14 @@ impl Projector {
.map_err(ProjectError::Database)?;
}
if let Some(projection) = view.projection.as_ref() {
RunSummaryStore::write_petri_run_row_on_connection(&mut tx, &run_id, projection)
.await
.map_err(ProjectError::Store)?;
RunSummaryStore::write_petri_run_row_on_connection(
&mut tx,
&self.github_host,
&run_id,
projection,
)
.await
.map_err(ProjectError::Store)?;
}
tx.commit().await.map_err(ProjectError::Database)?;
Ok(true)

View file

@ -19,7 +19,7 @@ use std::fmt;
use std::sync::Arc;
use fabro_types::settings::run::{RunCloneSettings, RunMode, RunNamespace};
use fabro_types::{GitRunTarget, RunTarget};
use fabro_types::{GitHubHost, GitRunTarget, RunTarget};
/// The revision a run starts from, in the order the target names it: an
/// exact commit wins over a tag, and a tag over the branch head.
@ -170,6 +170,7 @@ impl RunSource {
/// repository checks nothing out.
#[must_use]
pub fn for_run(
host: &GitHubHost,
target: Option<&RunTarget>,
settings: &RunNamespace,
credentials: Option<Arc<dyn SourceCredentials>>,
@ -180,8 +181,8 @@ impl RunSource {
if settings.execution.mode == RunMode::DryRun {
return None;
}
let validated = target.clone().validate().ok()?;
let origin = validated.repository().https_url();
let validated = target.clone().validate(host).ok()?;
let origin = validated.repository().https_url(host);
Self::for_target(validated.target(), origin, &settings.clone, credentials)
}

View file

@ -12,10 +12,10 @@ use std::time::Duration;
use async_trait::async_trait;
use fabro_github::token_source::{InstallationTokenSource, ResolvedToken};
use fabro_github::{GITHUB_CREDENTIAL_HELPER_KEY, GitHubCredentials, GitHubRepositoryAccess};
use fabro_github::{GitHubCredentials, GitHubRepositoryAccess};
use fabro_static::EnvVars;
use fabro_types::settings::run::{RunIntegrationsGithubSettings, RunMode};
use fabro_types::{GitHubRepositorySlug, RunSpec, RunTarget};
use fabro_types::{GitHubHost, GitHubRepositorySlug, RunSpec, RunTarget};
use fabro_util::shell;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use petri_runtime::executor::{
@ -38,6 +38,7 @@ pub struct StageCredentials {
git_tokens: Arc<InstallationTokenSource>,
api_tokens: Option<Arc<InstallationTokenSource>>,
repositories: Vec<GitHubRepositorySlug>,
host: GitHubHost,
}
impl StageCredentials {
@ -48,6 +49,7 @@ impl StageCredentials {
pub fn for_run(
spec: &RunSpec,
credentials: Option<&GitHubCredentials>,
host: &GitHubHost,
read_tokens: Option<Arc<InstallationTokenSource>>,
) -> anyhow::Result<Option<Self>> {
if spec.settings.run.execution.mode == RunMode::DryRun {
@ -56,7 +58,7 @@ impl StageCredentials {
let Some(RunTarget::Git(target)) = &spec.target else {
return Ok(None);
};
let repository = target.clone().validate()?.repository().clone();
let repository = target.clone().validate(host)?.repository().clone();
let integration = spec
.settings
.run
@ -64,7 +66,8 @@ impl StageCredentials {
.github
.resolve_integration()?;
let Some(access) = GitHubRepositoryAccess::new(
Some(&repository.https_url()),
host,
Some(&repository.https_url(host)),
&integration.additional_repositories,
integration.permissions.clone(),
)?
@ -76,7 +79,7 @@ impl StageCredentials {
};
let api_tokens = integration
.is_token_requested()
.then(|| InstallationTokenSource::for_access(credentials, &access))
.then(|| InstallationTokenSource::for_access(credentials, host, &access))
.transpose()?;
let contents_declared = integration
.permissions
@ -96,6 +99,7 @@ impl StageCredentials {
git_tokens,
api_tokens,
repositories,
host: host.clone(),
}))
}
@ -261,14 +265,15 @@ impl CredentialEnv {
}
let password = utf8_percent_encode(token.token.expose(), NON_ALPHANUMERIC).to_string();
self.masker.register_explicit(&password);
let authority = self.credentials.host.host();
let store = self
.credentials
.repositories
.iter()
.flat_map(|repo| {
[
format!("https://x-access-token:{password}@github.com/{repo}\n"),
format!("https://x-access-token:{password}@github.com/{repo}.git\n"),
format!("https://x-access-token:{password}@{authority}/{repo}\n"),
format!("https://x-access-token:{password}@{authority}/{repo}.git\n"),
]
})
.collect::<String>();
@ -307,24 +312,18 @@ impl CredentialEnv {
})?;
// Preserve fetch/publish headers and workflow configuration. The store
// returns a credential only for an allowed repository path.
let host = &self.credentials.host;
let helper_key = host.credential_helper_key();
let rewrite_key = format!("url.{}/.insteadOf", host.web_url());
let entries = [
(
"credential.https://github.com.useHttpPath",
format!("credential.{}.useHttpPath", host.web_url()),
"true".to_string(),
),
(GITHUB_CREDENTIAL_HELPER_KEY, String::new()),
(
GITHUB_CREDENTIAL_HELPER_KEY,
format!("store --file={}", self.store_path()),
),
(
"url.https://github.com/.insteadOf",
"git@github.com:".to_string(),
),
(
"url.https://github.com/.insteadOf",
"ssh://git@github.com/".to_string(),
),
(helper_key.clone(), String::new()),
(helper_key, format!("store --file={}", self.store_path())),
(rewrite_key.clone(), format!("git@{}:", host.hostname())),
(rewrite_key, format!("ssh://git@{}/", host.hostname())),
];
for index in 0..count {
for prefix in ["GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_"] {
@ -339,7 +338,7 @@ impl CredentialEnv {
for (offset, (key, value)) in entries.iter().enumerate() {
env.insert(
format!("GIT_CONFIG_KEY_{}", count + offset).into(),
(*key).into(),
key.as_str().into(),
);
env.insert(
format!("GIT_CONFIG_VALUE_{}", count + offset).into(),
@ -369,6 +368,18 @@ impl SpawnEnv for CredentialEnv {
if let Some(tokens) = &self.credentials.api_tokens {
let resolved = self.resolve(tokens, "resolve").await?;
env.insert(EnvVars::GITHUB_TOKEN.into(), resolved.token.expose().into());
// The `gh` CLI reads GITHUB_TOKEN for github.com only; any other
// host needs GH_HOST and GH_ENTERPRISE_TOKEN.
if !self.credentials.host.is_github_com() {
env.insert(
EnvVars::GH_HOST.into(),
self.credentials.host.hostname().into(),
);
env.insert(
EnvVars::GH_ENTERPRISE_TOKEN.into(),
resolved.token.expose().into(),
);
}
}
// The store and the helper configuration that names it live in the
// scope's sandbox; a one-shot container cannot read either.
@ -465,17 +476,150 @@ mod tests {
}
fn credentials(api: bool) -> StageCredentials {
credentials_on(api, GitHubHost::default())
}
fn credentials_on(api: bool, host: GitHubHost) -> StageCredentials {
let tokens = test_support::installation_token_source(
"acme/private",
Arc::new(RotatingMinter(AtomicUsize::new(0))),
);
StageCredentials {
git_tokens: tokens.clone(),
api_tokens: api.then_some(tokens),
git_tokens: tokens.clone(),
api_tokens: api.then_some(tokens),
repositories: vec![GitHubRepositorySlug::try_new("acme/private").expect("slug")],
host,
}
}
#[expect(
clippy::disallowed_methods,
reason = "sync read of a test fixture's credential store; not on a Tokio path"
)]
/// The environment a process spawn in a scope receives under `host`, and
/// the credential store written for it.
async fn process_env_on(host: GitHubHost) -> (BTreeMap<SmolStr, SmolStr>, String) {
let dir = tempfile::tempdir().expect("directory");
let runtime = providers::standard_runtime(&SandboxProviderConfig::default());
let router = runtime.sandbox_router_for(dir.path()).expect("router");
let handle = router
.acquire(
&ScopeSpec::new(ScopeId::new(0), "host-credentials"),
&AcquireContext::bare(),
)
.await
.expect("acquire");
let store = dir.path().join("store-directory");
fs::create_dir(&store).expect("store directory");
let env = CredentialEnv {
inner: handle.exec(),
credentials: credentials_on(true, host),
masker: MapSecrets::empty().masker(),
directory: store.display().to_string(),
written: Mutex::new(None),
};
let mut process = BTreeMap::new();
env.apply(SpawnTarget::Process, &mut process)
.await
.expect("apply");
let written = fs::read_to_string(store.join("store")).expect("store");
assert!(
router
.release(handle, ScopeOutcome::Succeeded)
.await
.is_clean()
);
(process, written)
}
/// The git config entries of `env`, in order, as `(key, value)`.
fn git_config(env: &BTreeMap<SmolStr, SmolStr>) -> Vec<(String, String)> {
let count: usize = env["GIT_CONFIG_COUNT"].parse().expect("count");
(0..count)
.map(|index| {
(
env[format!("GIT_CONFIG_KEY_{index}").as_str()].to_string(),
env[format!("GIT_CONFIG_VALUE_{index}").as_str()].to_string(),
)
})
.collect()
}
#[tokio::test]
async fn github_com_scopes_keep_the_default_git_config_and_no_gh_host() {
let (env, store) = process_env_on(GitHubHost::default()).await;
let config = git_config(&env);
let keys: Vec<_> = config.iter().map(|(key, _)| key.as_str()).collect();
assert_eq!(keys, [
"credential.https://github.com.useHttpPath",
"credential.https://github.com.helper",
"credential.https://github.com.helper",
"url.https://github.com/.insteadOf",
"url.https://github.com/.insteadOf",
]);
assert_eq!(config[3].1, "git@github.com:");
assert_eq!(config[4].1, "ssh://git@github.com/");
assert!(store.contains("@github.com/acme/private\n"));
assert!(store.contains("@github.com/acme/private.git\n"));
assert!(!env.contains_key("GH_HOST"));
assert!(!env.contains_key("GH_ENTERPRISE_TOKEN"));
}
#[tokio::test]
async fn a_ghes_scope_scopes_git_and_gh_credentials_to_its_host() {
let host = GitHubHost::new("https://ghe.example.com", None).expect("host");
let (env, store) = process_env_on(host).await;
let config = git_config(&env);
assert_eq!(
config[0],
(
"credential.https://ghe.example.com.useHttpPath".to_string(),
"true".to_string()
)
);
assert_eq!(
config[1],
(
"credential.https://ghe.example.com.helper".to_string(),
String::new()
)
);
assert_eq!(config[2].0, "credential.https://ghe.example.com.helper");
assert!(config[2].1.starts_with("store --file="));
assert_eq!(
config[3],
(
"url.https://ghe.example.com/.insteadOf".to_string(),
"git@ghe.example.com:".to_string()
)
);
assert_eq!(
config[4],
(
"url.https://ghe.example.com/.insteadOf".to_string(),
"ssh://git@ghe.example.com/".to_string()
)
);
assert!(config.iter().all(|(key, _)| !key.contains("github.com")));
assert!(store.contains("@ghe.example.com/acme/private\n"));
assert!(store.contains("@ghe.example.com/acme/private.git\n"));
assert!(!store.contains("github.com"));
assert_eq!(env["GH_HOST"], "ghe.example.com");
assert_eq!(env["GH_ENTERPRISE_TOKEN"], env["GITHUB_TOKEN"]);
assert!(env["GITHUB_TOKEN"].starts_with("scripted-token-generation-"));
}
#[tokio::test]
async fn a_ghes_scope_with_a_port_keeps_the_port_out_of_ssh_and_gh_host() {
let host = GitHubHost::new("https://ghe.example.com:8443", None).expect("host");
let (env, _store) = process_env_on(host).await;
let config = git_config(&env);
assert_eq!(config[3].0, "url.https://ghe.example.com:8443/.insteadOf");
assert_eq!(config[3].1, "git@ghe.example.com:");
assert_eq!(config[4].1, "ssh://git@ghe.example.com/");
assert_eq!(env["GH_HOST"], "ghe.example.com");
}
#[test]
fn default_origin_access_does_not_request_an_api_token() {
let mut spec = types_support::test_run_spec();
@ -487,9 +631,14 @@ mod tests {
);
let pat = GitHubCredentials::Pat("scripted-personal-access-token".to_string());
let read = InstallationTokenSource::pat("scripted-personal-access-token".to_string());
let default = StageCredentials::for_run(&spec, Some(&pat), Some(Arc::clone(&read)))
.expect("policy")
.expect("credentials");
let default = StageCredentials::for_run(
&spec,
Some(&pat),
&GitHubHost::default(),
Some(Arc::clone(&read)),
)
.expect("policy")
.expect("credentials");
assert!(default.api_tokens.is_none());
assert_eq!(default.repositories.len(), 1);
spec.settings
@ -504,9 +653,10 @@ mod tests {
.github
.additional_repositories
.insert(GitHubRepositorySlug::try_new("acme/another").expect("slug"));
let declared = StageCredentials::for_run(&spec, Some(&pat), Some(read))
.expect("policy")
.expect("credentials");
let declared =
StageCredentials::for_run(&spec, Some(&pat), &GitHubHost::default(), Some(read))
.expect("policy")
.expect("credentials");
assert!(declared.api_tokens.is_some());
assert_eq!(declared.repositories.len(), 2);
}

View file

@ -27,7 +27,7 @@
use std::sync::Arc;
use fabro_types::{
ArtifactSource, BlobHash, DiffSummary, GitIdentity, PairId, PairTarget, Principal,
ArtifactSource, BlobHash, DiffSummary, GitHubHost, GitIdentity, PairId, PairTarget, Principal,
PullRequestCreationId, PullRequestLink, RunControlAction, RunId, RunNoticeLevel, RunSpec,
RunStatus,
};
@ -557,27 +557,37 @@ pub struct PullRequestFailedRecord {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PullRequestLinkedRecord {
pub owner: String,
pub repo: String,
pub number: u64,
pub owner: String,
pub repo: String,
pub number: u64,
/// The pull request's URL when it is not on github.com; github.com links
/// are derived, so their records are unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub html_url: Option<String>,
}
impl PullRequestLinkedRecord {
#[must_use]
pub fn link(&self) -> PullRequestLink {
PullRequestLink {
owner: self.owner.clone(),
repo: self.repo.clone(),
number: self.number,
owner: self.owner.clone(),
repo: self.repo.clone(),
number: self.number,
html_url: self.html_url.clone().unwrap_or_else(|| {
GitHubHost::default().pull_request_url(&self.owner, &self.repo, self.number)
}),
}
}
#[must_use]
pub fn from_link(link: &PullRequestLink) -> Self {
Self {
owner: link.owner.clone(),
repo: link.repo.clone(),
number: link.number,
owner: link.owner.clone(),
repo: link.repo.clone(),
number: link.number,
html_url: (link.html_url
!= GitHubHost::default().pull_request_url(&link.owner, &link.repo, link.number))
.then(|| link.html_url.clone()),
}
}
}
@ -941,16 +951,18 @@ mod tests {
}
PlatformRecordKind::PullRequestLinked => {
PlatformRecord::PullRequestLinked(PullRequestLinkedRecord {
owner: "fabro-sh".to_string(),
repo: "fabro".to_string(),
number: 7,
owner: "fabro-sh".to_string(),
repo: "fabro".to_string(),
number: 7,
html_url: None,
})
}
PlatformRecordKind::PullRequestUnlinked => {
PlatformRecord::PullRequestUnlinked(PullRequestLinkedRecord {
owner: "fabro-sh".to_string(),
repo: "fabro".to_string(),
number: 7,
owner: "fabro-sh".to_string(),
repo: "fabro".to_string(),
number: 7,
html_url: None,
})
}
PlatformRecordKind::NotificationSent => {

View file

@ -2,7 +2,7 @@
//! the board and the scheduler read, derived from the projection.
use fabro_types::{
AskFabro, RepositoryRef, Run, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin,
AskFabro, GitHubHost, RepositoryRef, Run, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin,
RunProjection, RunSize, RunTimestamps, WorkflowRef, sum_usage,
};
use lithos_llm::types::Usage;
@ -10,7 +10,7 @@ use lithos_llm::types::Usage;
/// The run summary (`Run`) a projection stands for: what the run list, the
/// board and the scheduler read.
#[must_use]
pub fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
pub fn build_summary(host: &GitHubHost, state: &RunProjection, run_id: &RunId) -> Run {
let goal = state.spec.graph.goal().to_string();
let diff_summary = state
.conclusion
@ -65,6 +65,7 @@ pub fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
},
automation: state.spec.automation.clone(),
repository: Some(RepositoryRef::from_origin_and_source(
host,
repo_origin_url,
source_directory.as_deref(),
)),

View file

@ -6,7 +6,7 @@ use std::fmt::Write as _;
use std::sync::{Arc, LazyLock, PoisonError, RwLock};
use chrono::{DateTime, Utc};
use fabro_types::{Run, RunId, RunSize, RunStatusKind, RunTiming, timing};
use fabro_types::{GitHubHost, Run, RunId, RunSize, RunStatusKind, RunTiming, timing};
use sqlx::query::Query;
use sqlx::sqlite::{SqliteArguments, SqliteConnection, SqliteRow};
use sqlx::{QueryBuilder, Row as _, Sqlite, SqlitePool};
@ -202,13 +202,15 @@ impl RunSummaryStore {
/// Write the `runs` row of a run from its projection, on a connection
/// the caller holds a transaction on: the columns the list views and
/// the scheduler read, and the summary JSON.
/// the scheduler read, and the summary JSON. `host` is the GitHub
/// deployment the run's origin is classified against.
pub async fn write_petri_run_row_on_connection(
connection: &mut SqliteConnection,
host: &GitHubHost,
run_id: &RunId,
projection: &RunProjection,
) -> Result<()> {
let record = PreparedRunSummary::from_projection(run_id, projection);
let record = PreparedRunSummary::from_projection(host, run_id, projection);
bind_run_columns(
sqlx::query(UPSERT_PETRI_RUN_SQL).bind(run_id.to_string()),
&record,
@ -408,8 +410,8 @@ struct PreparedRunSummary {
}
impl PreparedRunSummary {
fn from_projection(run_id: &RunId, projection: &RunProjection) -> Self {
let mut run = build_summary(projection, run_id);
fn from_projection(host: &GitHubHost, run_id: &RunId, projection: &RunProjection) -> Self {
let mut run = build_summary(host, projection, run_id);
if run.timing.is_none() {
let at = run
.timestamps
@ -706,6 +708,7 @@ mod tests {
let mut transaction = store.pool.begin().await.unwrap();
RunSummaryStore::write_petri_run_row_on_connection(
&mut transaction,
&fabro_types::GitHubHost::default(),
&projection.spec.run_id,
projection,
)

View file

@ -71,7 +71,7 @@ impl FabroRunCreateParams {
if let Some(target) = &spec.target {
target
.clone()
.validate()
.canonical()
.map_err(|error| ToolError::from_anyhow(&error.into()))?;
}
for (key, value) in &spec.args.inputs {

View file

@ -1,7 +1,7 @@
use anyhow::{Context as _, anyhow};
use async_trait::async_trait;
use fabro_github::{
GitHubCredentials, create_installation_access_token_for_projects, sign_app_jwt,
GitHubCredentials, GitHubHost, create_installation_access_token_for_projects, sign_app_jwt,
};
use tokio::sync::OnceCell;
@ -35,7 +35,7 @@ pub struct GitHubTracker {
owner: String,
repo: String,
project_number: u64,
base_url: String,
host: GitHubHost,
project_node_id: OnceCell<String>,
}
@ -46,7 +46,7 @@ impl GitHubTracker {
owner: String,
repo: String,
project_number: u64,
base_url: String,
host: GitHubHost,
) -> Self {
Self {
creds,
@ -54,13 +54,13 @@ impl GitHubTracker {
owner,
repo,
project_number,
base_url,
host,
project_node_id: OnceCell::new(),
}
}
fn graphql_url(&self) -> String {
format!("{}/graphql", self.base_url)
self.host.graphql_url()
}
async fn fresh_token(&self) -> anyhow::Result<String> {
@ -73,7 +73,7 @@ impl GitHubTracker {
&jwt,
&self.owner,
&self.repo,
&self.base_url,
&self.host,
)
.await
.context("creating GitHub App installation token")
@ -645,7 +645,7 @@ mod tests {
"owner".to_string(),
"repo".to_string(),
1,
server_url.to_string(),
GitHubHost::new(server_url, Some(server_url)).unwrap(),
)
}
@ -1225,7 +1225,7 @@ mod tests {
#[tokio::test]
async fn github_tracker_fetch_issues_by_ids_empty() {
let pem = test_rsa_key();
let tracker = mock_github_tracker("http://unused", pem);
let tracker = mock_github_tracker("http://127.0.0.1:1", pem);
// Empty input → no HTTP calls at all
let issues = tracker.fetch_issues_by_ids(&[]).await.unwrap();

View file

@ -465,9 +465,13 @@ async fn reconcile_existing_pull_request(
.await;
Ok(Some(CreatedPullRequest {
link: PullRequestLink {
owner: owner.to_string(),
repo: repo.to_string(),
number: existing.number,
owner: owner.to_string(),
repo: repo.to_string(),
number: existing.number,
html_url: req
.github
.host()
.pull_request_url(owner, repo, existing.number),
},
title: existing.title,
base_branch: req.base_branch.to_string(),
@ -553,8 +557,8 @@ pub async fn open_pull_request(
req: OpenPullRequestRequest<'_>,
) -> Result<CreatedPullRequest, String> {
let https_url = ssh_url_to_https(req.origin_url);
let (owner, repo) =
github_app::parse_github_owner_repo(&https_url).map_err(|err| format!("{err:#}"))?;
let (owner, repo) = github_app::parse_github_owner_repo(req.github.host(), &https_url)
.map_err(|err| format!("{err:#}"))?;
// Verify before generating content: this is the cheap check, and a stale
// branch would otherwise cost a full LLM call before failing.
@ -621,10 +625,15 @@ pub async fn open_pull_request(
)
.await;
let html_url = req
.github
.host()
.pull_request_url(&owner, &repo, created.number);
let link = PullRequestLink {
owner,
repo,
number: created.number,
html_url,
};
Ok(CreatedPullRequest {
@ -1272,9 +1281,11 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
let payload = pr_content_json("Fix bug", "Narrative.");
let harness = setup_fallback_test_harness_with_branch_sha(&payload, "stale-sha").await;
let github_base_url = harness.github_server.url("");
let github_host =
fabro_github::GitHubHost::new("https://github.com", Some(&github_base_url)).unwrap();
let error = open_pull_request(OpenPullRequestRequest {
run_id: RunId::new(),
github: fabro_github::GitHubContext::new(&harness.creds, &github_base_url),
github: fabro_github::GitHubContext::new(&harness.creds, &github_host),
origin_url: "https://github.com/owner/repo.git",
base_branch: "main",
head_branch: "fabro/run/123",
@ -1543,7 +1554,7 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
&payload,
"final-sha",
serde_json::json!([{
"html_url": "https://github.com/owner/repo/pull/7",
"html_url": "https://github.com/OWNER/Repo/pull/7",
"number": 7,
"node_id": "PR_existing",
"title": "Reconciled title",
@ -1553,7 +1564,9 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
.await;
let github_base_url = harness.github_server.url("");
let github = github_app::GitHubContext::new(&harness.creds, &github_base_url);
let github_host =
fabro_github::GitHubHost::new("https://github.com", Some(&github_base_url)).unwrap();
let github = github_app::GitHubContext::new(&harness.creds, &github_host);
let result = open_pull_request(OpenPullRequestRequest {
run_id: RunId::new(),
@ -1576,6 +1589,8 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
.expect("reconciliation should adopt the existing pull request");
assert_eq!(result.link.number, 7);
// The URL follows the stored coordinates, not the API's spelling.
assert_eq!(result.link.html_url, "https://github.com/owner/repo/pull/7");
assert_eq!(result.title, "Reconciled title");
// Adoption must not cost an LLM call or a create request.
assert_eq!(
@ -1601,7 +1616,9 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
let harness = setup_fallback_test_harness(&payload).await;
let github_base_url = harness.github_server.url("");
let github = github_app::GitHubContext::new(&harness.creds, &github_base_url);
let github_host =
fabro_github::GitHubHost::new("https://github.com", Some(&github_base_url)).unwrap();
let github = github_app::GitHubContext::new(&harness.creds, &github_host);
let result = open_pull_request(OpenPullRequestRequest {
run_id: RunId::new(),
@ -1624,6 +1641,9 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
.expect("PR creation should succeed");
assert_eq!(result.title, "Fix telemetry leak");
// The mock API answers with an `example.test` URL; the stored URL is
// derived from the stored coordinates instead.
assert_eq!(result.link.html_url, "https://github.com/owner/repo/pull/1");
harness.assert_mocks_called_once().await;
}
@ -1635,7 +1655,9 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
let harness = setup_fallback_test_harness(&payload).await;
let github_base_url = harness.github_server.url("");
let github = github_app::GitHubContext::new(&harness.creds, &github_base_url);
let github_host =
fabro_github::GitHubHost::new("https://github.com", Some(&github_base_url)).unwrap();
let github = github_app::GitHubContext::new(&harness.creds, &github_host);
// Single ~200-char line, no `Plan:` / heading prefix, no newlines.
let goal = "x".repeat(200);

View file

@ -485,6 +485,7 @@ mod tests {
let mut transaction = summaries.pool().begin().await.unwrap();
RunSummaryStore::write_petri_run_row_on_connection(
&mut transaction,
&fabro_types::GitHubHost::default(),
&fixtures::RUN_1,
&projection,
)

View file

@ -143,9 +143,10 @@ fn run_summary_json_matches_openapi_shape() {
deletions: 4,
}),
pull_request: Some(PullRequestLink {
owner: "fabro-sh".to_string(),
repo: "fabro".to_string(),
number: 123,
owner: "fabro-sh".to_string(),
repo: "fabro".to_string(),
number: 123,
html_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(),
}),
current_question: None,
superseded_by: None,

View file

@ -4,14 +4,15 @@ use std::path::Path;
use fabro_types::settings::run::McpServerSettings;
use fabro_types::settings::{RunNamespace, WorkflowNamespace};
use fabro_types::{ServerSettings, UserSettings, WorkflowSettings};
use fabro_types::{GitHubHost, ServerSettings, UserSettings, WorkflowSettings};
use fabro_util::error::SharedError;
use crate::defaults::DEFAULTS_LAYER;
use crate::load::load_settings_path;
use crate::parse::{SettingsSource, validate_settings_source};
use crate::resolve::{
ResolveError, resolve_cli, resolve_project, resolve_run, resolve_server, resolve_workflow,
ResolveError, resolve_cli, resolve_github_host, resolve_project, resolve_run, resolve_server,
resolve_workflow,
};
use crate::user::load_settings_config;
use crate::{
@ -86,6 +87,20 @@ impl ServerSettingsBuilder {
Self::from_layer(&layer)
}
/// The configured GitHub host alone, for callers that have no complete
/// server configuration to resolve (the CLI without a local server).
pub fn load_default_github_host() -> Result<GitHubHost> {
let layer = load_settings_config(None)?;
let github = layer
.server
.and_then(|server| server.integrations)
.and_then(|integrations| integrations.github)
.unwrap_or_default();
let mut errors = Vec::new();
let host = resolve_github_host(&github, &mut errors);
finish_result(host, "failed to resolve the GitHub host", errors)
}
pub fn from_toml(source: &str) -> Result<ServerSettings> {
let layer = parse_settings_toml(source, SettingsSource::ActiveSettings)?;
Self::from_layer(&layer)

View file

@ -233,6 +233,10 @@ pub struct GithubIntegrationLayer {
pub slug: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webhooks: Option<IntegrationWebhooksLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_url: Option<String>,
}
/// `[server.integrations.slack]` — Slack workspace credentials and defaults.

View file

@ -13,6 +13,7 @@ pub use error::ResolveError;
use fabro_types::settings::InterpString;
pub use project::resolve_project;
pub use run::resolve_run;
pub(crate) use server::resolve_github_host;
pub use server::resolve_server;
pub use workflow::resolve_workflow;

View file

@ -1,7 +1,6 @@
use std::collections::BTreeMap;
use std::path::Path;
use fabro_types::SandboxProviderKind;
use fabro_types::settings::server::{
GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings,
ObjectStoreProvider, ObjectStoreSettings, SandboxPluginSettings, ServerApiSettings,
@ -11,6 +10,7 @@ use fabro_types::settings::server::{
ServerSchedulerSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings,
WebhookStrategy,
};
use fabro_types::{GitHubHost, SandboxProviderKind};
use fabro_util::Home;
use super::{
@ -19,9 +19,10 @@ use super::{
};
use crate::user::default_storage_dir;
use crate::{
IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer,
ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer,
ServerSandboxLayer, ServerSandboxProviderLayer, ServerStorageLayer, ServerWebLayer,
GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
ServerApiLayer, ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer,
ServerListenLayer, ServerSandboxLayer, ServerSandboxProviderLayer, ServerStorageLayer,
ServerWebLayer,
};
pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> ServerNamespace {
@ -29,7 +30,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> Se
let listen = resolve_listen(layer.listen.as_ref(), errors);
let web = resolve_web(layer.web.as_ref());
let auth = resolve_auth(layer.auth.as_ref(), errors);
let integrations = resolve_integrations(layer.integrations.as_ref());
let integrations = resolve_integrations(layer.integrations.as_ref(), errors);
validate_github_webhook_strategy(&integrations, layer.api.as_ref(), errors);
let api_url = layer.api.as_ref().and_then(|api| api.url.clone());
@ -330,7 +331,10 @@ fn resolve_object_store(
}
}
fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegrationsSettings {
fn resolve_integrations(
layer: Option<&ServerIntegrationsLayer>,
errors: &mut Vec<ResolveError>,
) -> ServerIntegrationsSettings {
ServerIntegrationsSettings {
github: layer
.and_then(|integrations| integrations.github.as_ref())
@ -344,13 +348,15 @@ fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegr
github.client_id.as_deref(),
);
warn_if_demoted_template("server.integrations.github.slug", github.slug.as_deref());
let host = resolve_github_host(github, errors);
GithubIntegrationSettings {
enabled: github.enabled.unwrap_or(true),
strategy: github.strategy.unwrap_or_default(),
app_id: github.app_id.clone(),
host,
enabled: github.enabled.unwrap_or(true),
strategy: github.strategy.unwrap_or_default(),
app_id: github.app_id.clone(),
client_id: github.client_id.clone(),
slug: github.slug.clone(),
webhooks: github.webhooks.as_ref().map(resolve_github_webhooks),
slug: github.slug.clone(),
webhooks: github.webhooks.as_ref().map(resolve_github_webhooks),
}
})
.unwrap_or_default(),
@ -375,6 +381,21 @@ fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegr
}
}
pub(crate) fn resolve_github_host(
layer: &GithubIntegrationLayer,
errors: &mut Vec<ResolveError>,
) -> GitHubHost {
let default = GitHubHost::default();
let web_url = layer.web_url.as_deref().unwrap_or(default.web_url());
GitHubHost::new(web_url, layer.api_url.as_deref()).unwrap_or_else(|err| {
errors.push(ResolveError::Invalid {
path: format!("server.integrations.github.{}", err.field()),
reason: err.to_string(),
});
default
})
}
fn resolve_github_webhooks(layer: &IntegrationWebhooksLayer) -> IntegrationWebhooksSettings {
IntegrationWebhooksSettings {
strategy: layer.strategy,

View file

@ -3,11 +3,11 @@
reason = "sync test fixture setup and raw template source assertions; not on a Tokio path"
)]
use fabro_types::SandboxProviderKind;
use fabro_types::settings::server::{
GithubIntegrationStrategy, LogDestination, ObjectStoreSettings, ServerAuthMethod,
ServerListenSettings, ServerNamespace,
};
use fabro_types::{GitHubHost, SandboxProviderKind};
use fabro_util::Home;
use temp_env::with_var;
@ -108,6 +108,8 @@ fn resolved_server_integrations_disable_slack_when_config_is_absent() {
"client_id": null,
"slug": null,
"webhooks": null,
"web_url": "https://github.com",
"api_url": "https://api.github.com",
},
"slack": {
"enabled": false,
@ -527,6 +529,72 @@ enabled = true
);
}
#[test]
fn github_host_defaults_to_github_com() {
let settings = resolve_server(&parse("_version = 1\n"));
assert_eq!(settings.integrations.github.host, GitHubHost::default());
let settings = resolve_server(&parse(
"_version = 1\n\n[server.integrations.github]\nenabled = true\n",
));
assert_eq!(settings.integrations.github.host, GitHubHost::default());
}
#[test]
fn github_host_derives_api_url_from_web_url() {
let settings = resolve_server(&parse(
r#"
_version = 1
[server.integrations.github]
web_url = "https://ghe.example.com"
"#,
));
let host = &settings.integrations.github.host;
assert_eq!(host.web_url(), "https://ghe.example.com");
assert_eq!(host.api_url(), "https://ghe.example.com/api/v3");
}
#[test]
fn github_host_honours_explicit_api_url() {
let settings = resolve_server(&parse(
r#"
_version = 1
[server.integrations.github]
web_url = "https://ghe.example.com"
api_url = "https://api.ghe.example.com"
"#,
));
assert_eq!(
settings.integrations.github.host.api_url(),
"https://api.ghe.example.com"
);
}
#[test]
fn github_host_rejects_invalid_web_url() {
let file = parse(
r#"
_version = 1
[server.integrations.github]
web_url = "http://ghe.example.com"
"#,
);
let error =
ServerSettingsBuilder::from_layer(&file).expect_err("plain http should be rejected");
let errors = resolve_errors(error);
assert!(
errors.iter().any(|err| {
err.to_string()
.contains("server.integrations.github.web_url")
&& err.to_string().contains("must use https")
}),
"unexpected errors: {errors:?}"
);
}
#[test]
fn parsing_rejects_removed_server_ip_allowlist() {
let err = r#"

View file

@ -118,12 +118,15 @@ impl EnvVars {
pub const ZAI_API_KEY: &'static str = "ZAI_API_KEY";
// GitHub, OAuth, and Slack
pub const GH_ENTERPRISE_TOKEN: &'static str = "GH_ENTERPRISE_TOKEN";
pub const GH_HOST: &'static str = "GH_HOST";
pub const GH_TOKEN: &'static str = "GH_TOKEN";
pub const GITHUB_APP_CLIENT_SECRET: &'static str = "GITHUB_APP_CLIENT_SECRET";
pub const GITHUB_APP_PRIVATE_KEY: &'static str = "GITHUB_APP_PRIVATE_KEY";
pub const GITHUB_APP_WEBHOOK_SECRET: &'static str = "GITHUB_APP_WEBHOOK_SECRET";
pub const GITHUB_BASE_URL: &'static str = "GITHUB_BASE_URL";
pub const GITHUB_TOKEN: &'static str = "GITHUB_TOKEN";
pub const GITHUB_WEB_URL: &'static str = "GITHUB_WEB_URL";
pub const OAUTH_CALLBACK_PATH: &'static str = "OAUTH_CALLBACK_PATH";
pub const OAUTH_CLIENT_ID: &'static str = "OAUTH_CLIENT_ID";
pub const OAUTH_ISSUER: &'static str = "OAUTH_ISSUER";
@ -185,6 +188,8 @@ impl EnvVars {
pub const RUST_BACKTRACE: &'static str = "RUST_BACKTRACE";
pub const RUST_LOG: &'static str = "RUST_LOG";
pub const SHELL: &'static str = "SHELL";
pub const SSL_CERT_DIR: &'static str = "SSL_CERT_DIR";
pub const SSL_CERT_FILE: &'static str = "SSL_CERT_FILE";
pub const TERM: &'static str = "TERM";
pub const TMPDIR: &'static str = "TMPDIR";
pub const TWIN_OPENAI_BIND_ADDR: &'static str = "TWIN_OPENAI_BIND_ADDR";
@ -279,12 +284,15 @@ mod tests {
EnvVars::OPENROUTER_API_KEY,
EnvVars::POOLSIDE_API_KEY,
EnvVars::ZAI_API_KEY,
EnvVars::GH_ENTERPRISE_TOKEN,
EnvVars::GH_HOST,
EnvVars::GH_TOKEN,
EnvVars::GITHUB_APP_CLIENT_SECRET,
EnvVars::GITHUB_APP_PRIVATE_KEY,
EnvVars::GITHUB_APP_WEBHOOK_SECRET,
EnvVars::GITHUB_BASE_URL,
EnvVars::GITHUB_TOKEN,
EnvVars::GITHUB_WEB_URL,
EnvVars::OAUTH_CALLBACK_PATH,
EnvVars::OAUTH_CLIENT_ID,
EnvVars::OAUTH_ISSUER,
@ -339,6 +347,8 @@ mod tests {
EnvVars::RUST_BACKTRACE,
EnvVars::RUST_LOG,
EnvVars::SHELL,
EnvVars::SSL_CERT_DIR,
EnvVars::SSL_CERT_FILE,
EnvVars::TERM,
EnvVars::TMPDIR,
EnvVars::TWIN_OPENAI_BIND_ADDR,

View file

@ -121,9 +121,15 @@ mod tests {
#[test]
fn leaves_legacy_aliases_and_non_secret_config_unclassified() {
for name in [
// A sandbox-injected copy of the resolved token, not a vault secret.
EnvVars::GH_ENTERPRISE_TOKEN,
EnvVars::GH_HOST,
EnvVars::GH_TOKEN,
EnvVars::GITHUB_BASE_URL,
EnvVars::GITHUB_WEB_URL,
EnvVars::SLACK_BASE_URL,
EnvVars::SSL_CERT_DIR,
EnvVars::SSL_CERT_FILE,
EnvVars::DAYTONA_API_URL,
EnvVars::DAYTONA_ORGANIZATION_ID,
EnvVars::DAYTONA_SERVER_URL,

View file

@ -0,0 +1,457 @@
use serde::{Deserialize, Serialize};
const DEFAULT_WEB_URL: &str = "https://github.com";
const DEFAULT_API_URL: &str = "https://api.github.com";
/// Rejected GitHub host setting, naming the offending field.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{field} {problem}")]
pub struct GitHubHostError {
field: &'static str,
problem: String,
}
impl GitHubHostError {
/// Name of the rejected setting: `web_url` or `api_url`.
#[must_use]
pub fn field(&self) -> &'static str {
self.field
}
fn new(field: &'static str, problem: impl Into<String>) -> Self {
Self {
field,
problem: problem.into(),
}
}
}
/// The GitHub deployment Fabro talks to: github.com (the default), a GitHub
/// Enterprise Cloud tenant with data residency (`*.ghe.com`) or a GitHub
/// Enterprise Server instance. The web origin is the one source of truth;
/// every other URL is derived from it unless the API URL is given explicitly.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "GitHubHostWire")]
pub struct GitHubHost {
web_url: String,
api_url: String,
}
#[derive(Deserialize)]
struct GitHubHostWire {
#[serde(default)]
web_url: Option<String>,
#[serde(default)]
api_url: Option<String>,
}
impl TryFrom<GitHubHostWire> for GitHubHost {
type Error = GitHubHostError;
fn try_from(wire: GitHubHostWire) -> Result<Self, Self::Error> {
Self::new(
wire.web_url.as_deref().unwrap_or(DEFAULT_WEB_URL),
wire.api_url.as_deref(),
)
}
}
impl Default for GitHubHost {
fn default() -> Self {
Self {
web_url: DEFAULT_WEB_URL.to_string(),
api_url: DEFAULT_API_URL.to_string(),
}
}
}
#[expect(
clippy::disallowed_types,
reason = "GitHub host settings are public service origins; validation errors name the field and rule but never echo the value, which may embed credentials."
)]
fn parse_origin(
field: &'static str,
raw: &str,
plain_http_anywhere: bool,
) -> Result<url::Url, GitHubHostError> {
let raw = raw.trim().trim_end_matches('/');
let parsed = url::Url::parse(raw).map_err(|_| {
GitHubHostError::new(field, "must be an https URL such as https://github.com")
})?;
let host = parsed
.host_str()
.ok_or_else(|| GitHubHostError::new(field, "must include a host"))?;
let loopback = matches!(host, "localhost" | "127.0.0.1" | "[::1]");
match parsed.scheme() {
"https" => {}
"http" if loopback || plain_http_anywhere => {}
_ => {
let problem = if plain_http_anywhere {
"must use http or https"
} else {
"must use https; http is only allowed for localhost"
};
return Err(GitHubHostError::new(field, problem));
}
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(GitHubHostError::new(field, "must not contain credentials"));
}
if parsed.query().is_some() || parsed.fragment().is_some() {
return Err(GitHubHostError::new(
field,
"must not contain a query or fragment",
));
}
Ok(parsed)
}
impl GitHubHost {
/// Builds a host from the web origin and an optional explicit API URL.
/// Without `api_url`, the API URL is derived from the web origin.
pub fn new(web_url: &str, api_url: Option<&str>) -> Result<Self, GitHubHostError> {
let web = parse_origin("web_url", web_url, false)?;
if !matches!(web.path(), "" | "/") {
return Err(GitHubHostError::new(
"web_url",
"must not contain a path; GitHub is never served under a path prefix",
));
}
let web_origin = web.origin().ascii_serialization();
let api_url = match api_url {
Some(api) => {
parse_origin("api_url", api, true)?;
api.trim().trim_end_matches('/').to_string()
}
None => derive_api_url(&web_origin, web.host_str().unwrap_or_default()),
};
Ok(Self {
web_url: web_origin,
api_url,
})
}
/// Web origin without a trailing slash, e.g. `https://ghe.example.com`.
#[must_use]
pub fn web_url(&self) -> &str {
&self.web_url
}
/// REST API base without a trailing slash, e.g.
/// `https://ghe.example.com/api/v3`.
#[must_use]
pub fn api_url(&self) -> &str {
&self.api_url
}
/// Authority of the web origin, e.g. `ghe.example.com` or
/// `ghe.example.com:8443`.
#[must_use]
pub fn host(&self) -> &str {
self.web_url
.split_once("://")
.map_or(self.web_url.as_str(), |(_, authority)| authority)
}
/// The host name of the web origin, without any port.
#[must_use]
pub fn hostname(&self) -> &str {
let authority = self.host();
authority
.rsplit_once(':')
.map_or(authority, |(name, port)| {
if port.bytes().all(|byte| byte.is_ascii_digit()) {
name
} else {
authority
}
})
}
#[must_use]
pub fn is_github_com(&self) -> bool {
self.host() == "github.com"
}
/// Public page of a GitHub App; also the base of its install link.
/// GitHub Enterprise Server serves it under `/github-apps/`.
#[must_use]
pub fn app_page_url(&self, slug: &str) -> String {
let prefix = if self.is_github_com() || self.hostname().ends_with(".ghe.com") {
"apps"
} else {
"github-apps"
};
format!("{}/{prefix}/{slug}", self.web_url)
}
/// GraphQL endpoint derived from the REST API URL.
#[must_use]
pub fn graphql_url(&self) -> String {
match self.api_url.strip_suffix("/api/v3") {
Some(base) => format!("{base}/api/graphql"),
None => format!("{}/graphql", self.api_url),
}
}
#[must_use]
pub fn repository_url(&self, owner: &str, repo: &str) -> String {
format!("{}/{owner}/{repo}", self.web_url)
}
#[must_use]
pub fn pull_request_url(&self, owner: &str, repo: &str, number: u64) -> String {
format!("{}/pull/{number}", self.repository_url(owner, repo))
}
/// Domain of GitHub's private noreply commit emails.
#[must_use]
pub fn noreply_email_domain(&self) -> String {
format!("users.noreply.{}", self.hostname())
}
/// Git config key that routes this host's HTTPS credentials to a helper.
#[must_use]
pub fn credential_helper_key(&self) -> String {
format!("credential.{}.helper", self.web_url)
}
/// Whether `origin` is a git remote URL on this host (HTTPS, HTTP, scp-like
/// SSH or `ssh://`).
#[must_use]
pub fn is_origin(&self, origin: &str) -> bool {
let host = self.host();
let hostname = self.hostname();
origin.starts_with(&format!("{}/", self.web_url))
|| origin.starts_with(&format!("http://{host}/"))
|| origin.starts_with(&format!("git@{hostname}:"))
|| origin.starts_with(&format!("ssh://git@{hostname}/"))
}
}
fn derive_api_url(web_origin: &str, hostname: &str) -> String {
if hostname == "github.com" {
DEFAULT_API_URL.to_string()
} else if hostname == "ghe.com" || hostname.ends_with(".ghe.com") {
web_origin.replacen("://", "://api.", 1)
} else {
format!("{web_origin}/api/v3")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn host(web: &str) -> GitHubHost {
GitHubHost::new(web, None).unwrap()
}
#[test]
fn default_matches_github_com_constants() {
let default = GitHubHost::default();
assert_eq!(default.web_url(), "https://github.com");
assert_eq!(default.api_url(), "https://api.github.com");
assert_eq!(default, host("https://github.com"));
assert!(default.is_github_com());
assert_eq!(default.graphql_url(), "https://api.github.com/graphql");
assert_eq!(default.repository_url("o", "r"), "https://github.com/o/r");
assert_eq!(
default.pull_request_url("o", "r", 7),
"https://github.com/o/r/pull/7"
);
assert_eq!(default.noreply_email_domain(), "users.noreply.github.com");
assert_eq!(
default.credential_helper_key(),
"credential.https://github.com.helper"
);
assert!(default.is_origin("git@github.com:o/r.git"));
assert!(default.is_origin("https://github.com/o/r"));
assert!(default.is_origin("http://github.com/o/r"));
assert!(default.is_origin("ssh://git@github.com/o/r"));
assert!(!default.is_origin("https://gitlab.com/o/r"));
}
#[test]
fn data_residency_host_derives_urls() {
let ghec = host("https://octocorp.ghe.com");
assert_eq!(ghec.api_url(), "https://api.octocorp.ghe.com");
assert_eq!(ghec.graphql_url(), "https://api.octocorp.ghe.com/graphql");
assert_eq!(ghec.host(), "octocorp.ghe.com");
assert!(!ghec.is_github_com());
assert_eq!(
ghec.repository_url("o", "r"),
"https://octocorp.ghe.com/o/r"
);
assert_eq!(
ghec.pull_request_url("o", "r", 7),
"https://octocorp.ghe.com/o/r/pull/7"
);
assert_eq!(
ghec.noreply_email_domain(),
"users.noreply.octocorp.ghe.com"
);
assert_eq!(
ghec.credential_helper_key(),
"credential.https://octocorp.ghe.com.helper"
);
assert!(ghec.is_origin("git@octocorp.ghe.com:o/r.git"));
assert!(ghec.is_origin("ssh://git@octocorp.ghe.com/o/r"));
assert!(!ghec.is_origin("https://github.com/o/r"));
}
#[test]
fn server_host_derives_urls() {
let ghes = host("https://ghe.example.com/");
assert_eq!(ghes.web_url(), "https://ghe.example.com");
assert_eq!(ghes.api_url(), "https://ghe.example.com/api/v3");
assert_eq!(ghes.graphql_url(), "https://ghe.example.com/api/graphql");
assert_eq!(ghes.host(), "ghe.example.com");
assert_eq!(ghes.repository_url("o", "r"), "https://ghe.example.com/o/r");
assert_eq!(
ghes.pull_request_url("o", "r", 7),
"https://ghe.example.com/o/r/pull/7"
);
assert_eq!(ghes.noreply_email_domain(), "users.noreply.ghe.example.com");
assert_eq!(
ghes.credential_helper_key(),
"credential.https://ghe.example.com.helper"
);
assert!(ghes.is_origin("git@ghe.example.com:o/r.git"));
assert!(ghes.is_origin("https://ghe.example.com/o/r"));
}
#[test]
fn host_keeps_port_and_lowercases() {
let ghes = host("https://GHE.Example.com:8443");
assert_eq!(ghes.web_url(), "https://ghe.example.com:8443");
assert_eq!(ghes.host(), "ghe.example.com:8443");
assert_eq!(ghes.hostname(), "ghe.example.com");
}
#[test]
fn explicit_api_url_wins() {
let ghes = GitHubHost::new(
"https://ghe.example.com",
Some("https://api.ghe.example.com/"),
)
.unwrap();
assert_eq!(ghes.api_url(), "https://api.ghe.example.com");
assert_eq!(ghes.graphql_url(), "https://api.ghe.example.com/graphql");
}
#[test]
fn loopback_http_is_accepted() {
let local = host("http://127.0.0.1:1234");
assert_eq!(local.web_url(), "http://127.0.0.1:1234");
assert_eq!(local.api_url(), "http://127.0.0.1:1234/api/v3");
let mock = GitHubHost::new("http://127.0.0.1:1234", Some("http://127.0.0.1:1234")).unwrap();
assert_eq!(mock.graphql_url(), "http://127.0.0.1:1234/graphql");
}
#[test]
fn invalid_values_name_the_problem() {
let plain_http = GitHubHost::new("http://ghe.example.com", None).unwrap_err();
assert!(
plain_http.to_string().starts_with("web_url must use https"),
"{plain_http}"
);
let bare = GitHubHost::new("ghe.example.com", None).unwrap_err();
assert!(
bare.to_string().starts_with("web_url must be an https URL"),
"{bare}"
);
let path = GitHubHost::new("https://ghe.example.com/github", None).unwrap_err();
assert!(
path.to_string().contains("must not contain a path"),
"{path}"
);
let api =
GitHubHost::new("https://ghe.example.com", Some("ftp://ghe.example.com")).unwrap_err();
assert_eq!(api.field(), "api_url");
assert!(
api.to_string().starts_with("api_url must use http"),
"{api}"
);
}
#[test]
fn errors_never_echo_the_value() {
for web in [
"http://user:secret@ghe.example.com",
"https://user:secret@ghe.example.com",
"secret ghe.example.com",
] {
let error = GitHubHost::new(web, None).unwrap_err();
assert!(!error.to_string().contains("secret"), "{error}");
}
let error = GitHubHost::new(
"https://ghe.example.com",
Some("ftp://user:secret@ghe.example.com"),
)
.unwrap_err();
assert!(!error.to_string().contains("secret"), "{error}");
}
#[test]
fn api_url_accepts_plain_http_on_any_host() {
let ghes = GitHubHost::new(
"https://ghe.example.com",
Some("http://github-mirror.corp:8080/"),
)
.unwrap();
assert_eq!(ghes.api_url(), "http://github-mirror.corp:8080");
}
#[test]
fn port_is_kept_for_http_urls_only() {
let ghes = host("https://ghe.example.com:8443");
assert_eq!(ghes.noreply_email_domain(), "users.noreply.ghe.example.com");
assert!(ghes.is_origin("git@ghe.example.com:o/r.git"));
assert!(ghes.is_origin("ssh://git@ghe.example.com/o/r"));
assert!(ghes.is_origin("https://ghe.example.com:8443/o/r"));
}
#[test]
fn only_github_com_itself_derives_the_github_com_api() {
let sub = host("https://foo.github.com");
assert!(!sub.is_github_com());
assert_eq!(sub.api_url(), "https://foo.github.com/api/v3");
}
#[test]
fn app_page_url_follows_the_host_kind() {
assert_eq!(
GitHubHost::default().app_page_url("my-app"),
"https://github.com/apps/my-app"
);
assert_eq!(
host("https://octocorp.ghe.com").app_page_url("my-app"),
"https://octocorp.ghe.com/apps/my-app"
);
assert_eq!(
host("https://ghe.example.com").app_page_url("my-app"),
"https://ghe.example.com/github-apps/my-app"
);
}
#[test]
fn serde_round_trips_and_validates() {
let ghes = host("https://ghe.example.com");
let json = serde_json::to_value(&ghes).unwrap();
assert_eq!(
json,
serde_json::json!({
"web_url": "https://ghe.example.com",
"api_url": "https://ghe.example.com/api/v3",
})
);
assert_eq!(serde_json::from_value::<GitHubHost>(json).unwrap(), ghes);
assert!(
serde_json::from_value::<GitHubHost>(
serde_json::json!({"web_url": "http://x.example.com", "api_url": "https://x.example.com"})
)
.is_err()
);
}
}

View file

@ -15,6 +15,7 @@ pub mod diff;
pub mod engine;
pub mod failure_signature;
pub mod git_identity;
pub mod github_host;
mod id;
mod input_scalar;
pub mod interview;
@ -86,6 +87,7 @@ pub use diff::{DiffStats, DiffSummary, RunDiff};
pub use engine::{PetriAdmission, PetriGraphRef};
pub use failure_signature::FailureSignature;
pub use git_identity::{GitIdentity, GitIdentitySource};
pub use github_host::{GitHubHost, GitHubHostError};
pub use input_scalar::{
JsonScalarToTomlError, TomlScalarToJsonError, json_scalar_to_toml_value,
toml_scalar_to_json_value,

View file

@ -3,6 +3,7 @@ use serde::de::Error as DeError;
use serde::ser::SerializeStruct;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::GitHubHost;
use crate::id::ulid_id;
ulid_id!(PullRequestCreationId);
@ -60,18 +61,18 @@ impl PullRequestCreation {
/// Minimal GitHub pull request reference stored on a workflow run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PullRequestLink {
pub owner: String,
pub repo: String,
pub number: u64,
pub owner: String,
pub repo: String,
pub number: u64,
/// The URL GitHub serves the pull request at, on whichever host the
/// server is configured for.
pub html_url: String,
}
impl PullRequestLink {
#[must_use]
pub fn html_url(&self) -> String {
format!(
"https://github.com/{}/{}/pull/{}",
self.owner, self.repo, self.number
)
self.html_url.clone()
}
pub fn from_github_url(url: &str) -> Result<Self, String> {
@ -88,7 +89,7 @@ impl Serialize for PullRequestLink {
state.serialize_field("owner", &self.owner)?;
state.serialize_field("repo", &self.repo)?;
state.serialize_field("number", &self.number)?;
state.serialize_field("html_url", &self.html_url())?;
state.serialize_field("html_url", &self.html_url)?;
state.end()
}
}
@ -115,34 +116,33 @@ impl<'de> Deserialize<'de> for PullRequestLink {
let (Some(owner), Some(repo), Some(number)) = (wire.owner, wire.repo, wire.number) else {
return Err(D::Error::custom("missing pull request owner/repo/number"));
};
let link = Self {
owner,
repo,
number,
};
if let Some(html_url) = wire.html_url {
let url_link =
github_pull_request_link_from_url(&html_url).map_err(D::Error::custom)?;
if url_link != link {
return Err(D::Error::custom(
"pull request html_url does not match owner/repo/number",
));
}
// Records written before the link stored its URL are all github.com.
let html_url = wire
.html_url
.unwrap_or_else(|| GitHubHost::default().pull_request_url(&owner, &repo, number));
let url_link = github_pull_request_link_from_url(&html_url).map_err(D::Error::custom)?;
// GitHub owner and repository names are case-insensitive.
if !url_link.owner.eq_ignore_ascii_case(&owner)
|| !url_link.repo.eq_ignore_ascii_case(&repo)
|| url_link.number != number
{
return Err(D::Error::custom(
"pull request html_url does not match owner/repo/number",
));
}
Ok(link)
Ok(url_link)
}
}
#[expect(
clippy::disallowed_types,
reason = "Pull request links are public github.com URLs stored for display and coordinate inference."
reason = "Pull request links are public GitHub URLs stored for display and coordinate inference."
)]
fn github_pull_request_link_from_url(raw_url: &str) -> Result<PullRequestLink, String> {
let parsed =
url::Url::parse(raw_url).map_err(|err| format!("Invalid pull request URL: {err}"))?;
if parsed.scheme() != "https" || parsed.host_str() != Some("github.com") {
if parsed.scheme() != "https" || parsed.host_str().is_none() {
return Err(
"Pull request link must be a GitHub pull request URL like https://github.com/owner/repo/pull/123."
.to_string(),
@ -168,6 +168,7 @@ fn github_pull_request_link_from_url(raw_url: &str) -> Result<PullRequestLink, S
owner: (*owner).to_string(),
repo: (*repo).to_string(),
number,
html_url: raw_url.to_string(),
})
}
@ -325,11 +326,12 @@ mod tests {
use super::*;
#[test]
fn pull_request_link_serializes_computed_html_url() {
fn pull_request_link_serializes_its_html_url() {
let link = PullRequestLink {
owner: "fabro-sh".to_string(),
repo: "fabro".to_string(),
number: 270,
owner: "fabro-sh".to_string(),
repo: "fabro".to_string(),
number: 270,
html_url: "https://github.com/fabro-sh/fabro/pull/270".to_string(),
};
assert_eq!(
@ -343,6 +345,27 @@ mod tests {
);
}
#[test]
fn pull_request_link_owner_and_repo_match_html_url_case_insensitively() {
let link = serde_json::from_value::<PullRequestLink>(json!({
"owner": "Acme",
"repo": "Widgets",
"number": 7,
"html_url": "https://github.com/acme/widgets/pull/7"
}))
.unwrap();
assert_eq!(link.number, 7);
assert!(
serde_json::from_value::<PullRequestLink>(json!({
"owner": "Acme",
"repo": "Other",
"number": 7,
"html_url": "https://github.com/acme/widgets/pull/7"
}))
.is_err()
);
}
#[test]
fn pull_request_link_rejects_extra_legacy_record_fields() {
let result = serde_json::from_value::<PullRequestLink>(json!({
@ -356,4 +379,51 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn pull_request_link_without_html_url_is_a_github_com_link() {
let link = serde_json::from_value::<PullRequestLink>(json!({
"owner": "fabro-sh",
"repo": "fabro",
"number": 270
}))
.unwrap();
assert_eq!(link.html_url, "https://github.com/fabro-sh/fabro/pull/270");
}
#[test]
fn pull_request_link_keeps_the_enterprise_url_it_was_given() {
let value = json!({
"owner": "acme",
"repo": "widgets",
"number": 7,
"html_url": "https://ghe.example.com/acme/widgets/pull/7"
});
let link = serde_json::from_value::<PullRequestLink>(value.clone()).unwrap();
assert_eq!(
link.html_url(),
"https://ghe.example.com/acme/widgets/pull/7"
);
assert_eq!(serde_json::to_value(&link).unwrap(), value);
assert_eq!(
PullRequestLink::from_github_url("https://octocorp.ghe.com/acme/widgets/pull/7")
.unwrap()
.owner,
"acme"
);
}
#[test]
fn pull_request_link_rejects_a_url_that_disagrees_with_its_coordinates() {
let result = serde_json::from_value::<PullRequestLink>(json!({
"owner": "acme",
"repo": "widgets",
"number": 8,
"html_url": "https://ghe.example.com/acme/widgets/pull/7"
}));
assert!(result.is_err());
}
}

View file

@ -5,6 +5,8 @@ use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::GitHubHost;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryRef {
pub name: String,
@ -15,12 +17,13 @@ pub struct RepositoryRef {
impl RepositoryRef {
pub fn from_origin_and_source(
host: &GitHubHost,
origin_url: Option<String>,
source_directory: Option<&str>,
) -> Self {
Self {
name: repository_name(origin_url.as_deref(), source_directory),
provider: repository_provider(origin_url.as_deref()),
provider: repository_provider(host, origin_url.as_deref()),
origin_url,
}
}
@ -65,10 +68,10 @@ impl GitHubRepositorySlug {
&self.repo
}
/// Canonical credential-free HTTPS URL for this GitHub repository.
/// Canonical credential-free HTTPS URL for this repository on `host`.
#[must_use]
pub fn https_url(&self) -> String {
format!("https://github.com/{self}")
pub fn https_url(&self, host: &GitHubHost) -> String {
host.repository_url(&self.owner, &self.repo)
}
/// Whether `other` names the same repository owner, ignoring ASCII case.
@ -264,24 +267,17 @@ pub enum RepositoryProvider {
Unknown,
}
fn repository_provider(origin_url: Option<&str>) -> RepositoryProvider {
fn repository_provider(host: &GitHubHost, origin_url: Option<&str>) -> RepositoryProvider {
let Some(origin) = origin_url.filter(|origin| !origin.trim().is_empty()) else {
return RepositoryProvider::Unknown;
};
if is_github_origin(origin) {
if host.is_origin(origin) {
RepositoryProvider::Github
} else {
RepositoryProvider::Git
}
}
fn is_github_origin(origin: &str) -> bool {
origin.starts_with("git@github.com:")
|| origin.starts_with("https://github.com/")
|| origin.starts_with("http://github.com/")
|| origin.starts_with("ssh://git@github.com/")
}
fn repository_name(origin_url: Option<&str>, source_directory: Option<&str>) -> String {
origin_url
.and_then(repository_name_from_origin)
@ -331,6 +327,48 @@ fn path_basename(path: &str) -> Option<&str> {
mod tests {
use super::*;
#[test]
fn repository_provider_is_github_only_for_origins_on_the_host() {
let ghes = GitHubHost::new("https://ghe.example.com", None).unwrap();
let provider = |host: &GitHubHost, origin: &str| {
RepositoryRef::from_origin_and_source(host, Some(origin.to_string()), None).provider
};
assert_eq!(
provider(&GitHubHost::default(), "git@github.com:acme/widgets.git"),
RepositoryProvider::Github
);
assert_eq!(
provider(
&GitHubHost::default(),
"https://ghe.example.com/acme/widgets"
),
RepositoryProvider::Git
);
assert_eq!(
provider(&ghes, "https://ghe.example.com/acme/widgets"),
RepositoryProvider::Github
);
assert_eq!(
provider(&ghes, "ssh://git@ghe.example.com/acme/widgets"),
RepositoryProvider::Github
);
assert_eq!(
provider(&ghes, "https://github.com/acme/widgets"),
RepositoryProvider::Git
);
}
#[test]
fn https_url_follows_the_host() {
let slug = GitHubRepositorySlug::try_new("acme/widgets").unwrap();
let ghes = GitHubHost::new("https://octocorp.ghe.com", None).unwrap();
assert_eq!(
slug.https_url(&ghes),
"https://octocorp.ghe.com/acme/widgets"
);
}
#[test]
fn valid_slugs_preserve_exact_parts() {
let cases = [
@ -404,8 +442,11 @@ mod tests {
fn slug_https_url_preserves_spelling_and_has_no_credentials() {
let slug: GitHubRepositorySlug = "Fabro-SH/Keystone".parse().unwrap();
assert_eq!(slug.https_url(), "https://github.com/Fabro-SH/Keystone");
assert!(!slug.https_url().contains('@'));
assert_eq!(
slug.https_url(&GitHubHost::default()),
"https://github.com/Fabro-SH/Keystone"
);
assert!(!slug.https_url(&GitHubHost::default()).contains('@'));
}
#[test]

View file

@ -3,7 +3,9 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{DirtyStatus, GitContext, GitHubRepositorySlug, RunId, WorkflowVersionId, repository};
use crate::{
DirtyStatus, GitContext, GitHubHost, GitHubRepositorySlug, RunId, WorkflowVersionId, repository,
};
/// A request to create a run from an immutable workflow version.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -73,6 +75,18 @@ pub struct GitRunTarget {
}
impl GitRunTarget {
/// Validates the coordinate and returns it in canonical form, for callers
/// that do not need the operational Git projection (and so no host).
///
/// # Errors
///
/// Same as [`GitRunTarget::validate`].
pub fn canonical(self) -> Result<Self, GitCoordinateValidationError> {
// The host does not affect the canonical form.
self.validate(&GitHubHost::default())
.map(ValidatedGitRunTarget::into_target)
}
/// Validates and canonicalizes this Git coordinate without resolving remote
/// repository state.
///
@ -80,7 +94,10 @@ impl GitRunTarget {
///
/// Returns an error when the repository slug, branch, tag, or exact commit
/// does not use the canonical grammar accepted for Git-backed runs.
pub fn validate(self) -> Result<ValidatedGitRunTarget, GitCoordinateValidationError> {
pub fn validate(
self,
host: &GitHubHost,
) -> Result<ValidatedGitRunTarget, GitCoordinateValidationError> {
let Self {
repo,
branch,
@ -104,7 +121,7 @@ impl GitRunTarget {
})
.transpose()?;
let git = GitContext {
origin_url: repository.https_url(),
origin_url: repository.https_url(host),
branch: branch.clone(),
sha: sha.clone(),
dirty: DirtyStatus::Clean,
@ -123,6 +140,18 @@ impl GitRunTarget {
}
impl RunTarget {
/// Validates the target and returns it in canonical form, for callers
/// that do not need the operational Git projection (and so no host).
///
/// # Errors
///
/// Same as [`RunTarget::validate`].
pub fn canonical(self) -> Result<Self, TargetValidationError> {
// The host does not affect the canonical form.
self.validate(&GitHubHost::default())
.map(|validated| validated.target)
}
/// The wire `kind` discriminator (`git`, `none`, or `folder`), for
/// diagnostics.
pub fn kind_name(&self) -> &'static str {
@ -139,10 +168,10 @@ impl RunTarget {
///
/// Returns an error when a Git target's repository slug, branch, tag, or
/// exact commit does not use the canonical grammar accepted for runs.
pub fn validate(self) -> Result<ValidatedRunTarget, TargetValidationError> {
pub fn validate(self, host: &GitHubHost) -> Result<ValidatedRunTarget, TargetValidationError> {
match self {
Self::Git(target) => {
let validated = target.validate().map_err(TargetValidationError::from)?;
let validated = target.validate(host).map_err(TargetValidationError::from)?;
Ok(ValidatedRunTarget {
target: Self::Git(validated.target),
git: Some(validated.git),

View file

@ -11,7 +11,7 @@ use std::net::SocketAddr;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::SandboxProviderKind;
use crate::{GitHubHost, SandboxProviderKind};
/// A structurally resolved `[server]` view for consumers.
///
@ -297,6 +297,9 @@ pub struct GithubIntegrationSettings {
pub client_id: Option<String>,
pub slug: Option<String>,
pub webhooks: Option<IntegrationWebhooksSettings>,
/// The GitHub deployment (`web_url`/`api_url` on the wire).
#[serde(flatten)]
pub host: GitHubHost,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View file

@ -1,7 +1,8 @@
use std::collections::HashMap;
use fabro_types::{
GitRunTarget, RunIntent, RunIntentArgs, RunTarget, WorkflowVersionId, normalize_git_commit_sha,
GitHubHost, GitRunTarget, RunIntent, RunIntentArgs, RunTarget, WorkflowVersionId,
normalize_git_commit_sha,
};
use serde_json::json;
@ -195,7 +196,7 @@ fn target_validation_normalizes_sha_without_network_resolution() {
tag: Some("release/v1".to_string()),
sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
})
.validate()
.validate(&GitHubHost::default())
.unwrap();
let git = validated
.git
@ -225,7 +226,7 @@ fn git_target_validation_carries_the_parsed_repository_proof() {
tag: None,
sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
}
.validate()
.validate(&GitHubHost::default())
.unwrap();
assert_eq!(validated.repository().owner(), "Fabro-Sh");
@ -238,7 +239,7 @@ fn git_target_validation_carries_the_parsed_repository_proof() {
#[test]
fn run_intent_none_target_validates_without_a_git_projection() {
let validated = RunTarget::None {}.validate().unwrap();
let validated = RunTarget::None {}.validate(&GitHubHost::default()).unwrap();
assert_eq!(validated.target, RunTarget::None {});
assert_eq!(validated.git, None);
}
@ -249,7 +250,7 @@ fn run_intent_folder_target_is_preserved_for_provider_admission() {
path: "/srv/fabro/workspaces/example".to_string(),
};
let validated = target.clone().validate().unwrap();
let validated = target.clone().validate(&GitHubHost::default()).unwrap();
assert_eq!(validated.target, target);
assert_eq!(validated.git, None);
@ -266,7 +267,7 @@ fn target_validation_rejects_invalid_grammar() {
tag: tag.map(str::to_string),
sha: sha.map(str::to_string),
})
.validate()
.validate(&GitHubHost::default())
};
assert_eq!(
@ -348,3 +349,21 @@ fn git_target_round_trips_all_branch_tag_sha_states() {
);
}
}
#[test]
fn target_validation_derives_the_origin_from_the_configured_host() {
let host = GitHubHost::new("https://ghe.example.com", None).unwrap();
let validated = RunTarget::Git(GitRunTarget {
repo: "acme/widgets".to_string(),
branch: "main".to_string(),
tag: None,
sha: None,
})
.validate(&host)
.unwrap();
assert_eq!(
validated.git.unwrap().origin_url,
"https://ghe.example.com/acme/widgets"
);
}

View file

@ -21,6 +21,14 @@ import type { GithubIntegrationStrategy } from './github-integration-strategy';
import type { IntegrationWebhooksSettings } from './integration-webhooks-settings';
export interface GithubIntegrationSettings {
/**
* Web origin of the GitHub deployment, without a trailing slash.
*/
'web_url': string;
/**
* REST API base URL of the GitHub deployment, without a trailing slash.
*/
'api_url': string;
'enabled': boolean;
'strategy': GithubIntegrationStrategy;
'app_id': string | null;

View file

@ -20,4 +20,8 @@
export interface InstallPrefill {
'canonical_url': string;
'object_store_local_root': string;
/**
* Web origin of the GitHub deployment the install targets, without a trailing slash.
*/
'github_web_url': string;
}