diff --git a/Cargo.lock b/Cargo.lock index d15f1dee6..210ae9fab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2526,6 +2526,7 @@ dependencies = [ "fabro-checkpoint", "fabro-client", "fabro-db", + "fabro-github", "fabro-http", "fabro-interview", "fabro-llm", @@ -2543,6 +2544,7 @@ dependencies = [ "lithos-llm", "object_store", "pebble-coding-agent", + "percent-encoding", "petri-attractor-steps", "petri-execution", "petri-frontend-attractor", @@ -5161,7 +5163,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "petri-attractor-steps" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#91d1b77b3927cb3fefcc4a412fdf89ce04857277" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#11465f23feafe303a18e255b5c51ca40e0d008f7" dependencies = [ "async-trait", "globset", @@ -5192,7 +5194,7 @@ dependencies = [ [[package]] name = "petri-driver" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#91d1b77b3927cb3fefcc4a412fdf89ce04857277" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#11465f23feafe303a18e255b5c51ca40e0d008f7" dependencies = [ "async-trait", "getrandom 0.3.4", @@ -5212,7 +5214,7 @@ dependencies = [ [[package]] name = "petri-engine" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#91d1b77b3927cb3fefcc4a412fdf89ce04857277" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#11465f23feafe303a18e255b5c51ca40e0d008f7" dependencies = [ "petri-ir", "serde", @@ -5224,7 +5226,7 @@ dependencies = [ [[package]] name = "petri-execution" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#91d1b77b3927cb3fefcc4a412fdf89ce04857277" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#11465f23feafe303a18e255b5c51ca40e0d008f7" dependencies = [ "async-trait", "petri-driver", @@ -5248,7 +5250,7 @@ dependencies = [ [[package]] name = "petri-executor" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#91d1b77b3927cb3fefcc4a412fdf89ce04857277" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#11465f23feafe303a18e255b5c51ca40e0d008f7" dependencies = [ "async-trait", "libc", @@ -5263,7 +5265,7 @@ dependencies = [ [[package]] name = "petri-executor-sandbox" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#91d1b77b3927cb3fefcc4a412fdf89ce04857277" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#11465f23feafe303a18e255b5c51ca40e0d008f7" dependencies = [ "async-trait", "petri-executor", @@ -5285,7 +5287,7 @@ dependencies = [ [[package]] name = "petri-frontend" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#91d1b77b3927cb3fefcc4a412fdf89ce04857277" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#11465f23feafe303a18e255b5c51ca40e0d008f7" dependencies = [ "marked-yaml", "petri-ir", @@ -5299,7 +5301,7 @@ dependencies = [ [[package]] name = "petri-frontend-attractor" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#91d1b77b3927cb3fefcc4a412fdf89ce04857277" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#11465f23feafe303a18e255b5c51ca40e0d008f7" dependencies = [ "minijinja", "petri-frontend", @@ -5316,7 +5318,7 @@ dependencies = [ [[package]] name = "petri-frontend-fabro" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#91d1b77b3927cb3fefcc4a412fdf89ce04857277" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#11465f23feafe303a18e255b5c51ca40e0d008f7" dependencies = [ "petri-frontend", "petri-frontend-attractor", @@ -5332,7 +5334,7 @@ dependencies = [ [[package]] name = "petri-frontend-native" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#91d1b77b3927cb3fefcc4a412fdf89ce04857277" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#11465f23feafe303a18e255b5c51ca40e0d008f7" dependencies = [ "petri-frontend", "petri-ir", @@ -5343,7 +5345,7 @@ dependencies = [ [[package]] name = "petri-ir" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#91d1b77b3927cb3fefcc4a412fdf89ce04857277" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#11465f23feafe303a18e255b5c51ca40e0d008f7" dependencies = [ "regex", "serde", @@ -5356,7 +5358,7 @@ dependencies = [ [[package]] name = "petri-runtime" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#91d1b77b3927cb3fefcc4a412fdf89ce04857277" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#11465f23feafe303a18e255b5c51ca40e0d008f7" dependencies = [ "async-trait", "petri-driver", @@ -5377,7 +5379,7 @@ dependencies = [ [[package]] name = "petri-steps" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#91d1b77b3927cb3fefcc4a412fdf89ce04857277" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#11465f23feafe303a18e255b5c51ca40e0d008f7" dependencies = [ "async-trait", "petri-executor", @@ -5393,7 +5395,7 @@ dependencies = [ [[package]] name = "petri-store" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#91d1b77b3927cb3fefcc4a412fdf89ce04857277" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#11465f23feafe303a18e255b5c51ca40e0d008f7" dependencies = [ "async-trait", "getrandom 0.3.4", @@ -5408,7 +5410,7 @@ dependencies = [ [[package]] name = "petri-testkit" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/petri.git?branch=main#91d1b77b3927cb3fefcc4a412fdf89ce04857277" +source = "git+https://github.com/lithoscomputer/petri.git?branch=main#11465f23feafe303a18e255b5c51ca40e0d008f7" dependencies = [ "async-trait", "petri-driver", diff --git a/docs/public/integrations/github.mdx b/docs/public/integrations/github.mdx index 52a22b6d9..9f2d9eac0 100644 --- a/docs/public/integrations/github.mdx +++ b/docs/public/integrations/github.mdx @@ -260,7 +260,9 @@ contents = "write" pull_requests = "write" ``` -Only the listed permissions are requested — the token is scoped to the minimum access needed. If the GitHub App isn't configured or the repository lacks an installation, the run logs a warning and continues without the token. +Only the listed permissions are requested. If no GitHub credentials are configured, Fabro does not inject a managed token. If configured credentials cannot resolve a token, the execution scope fails to initialize. + +GitHub-target runs also receive Git read access to their origin without declaring an integration permission map. In App mode this default token requests only `contents = "read"`, and it is used by Git's credential helper. Declaring integration permissions additionally supplies `GITHUB_TOKEN` to command and agent processes. Explicit workflow or ACP environment values take precedence over the managed `GITHUB_TOKEN`. In App mode, the token covers only the run's origin repository unless the run declares [additional repositories](#additional-repositories). Injecting `GITHUB_TOKEN` alone does not make other private repositories reachable. @@ -279,8 +281,8 @@ The run origin stays implicit — never list it. Each entry is a full `owner/rep What works against every declared repository, within the granted permissions: - **`gh` CLI and raw GitHub API calls** through `GITHUB_TOKEN`. -- **Plain Git over HTTPS** (`git clone https://github.com/owner/repo`), through a secret-free credential helper that reads `$GITHUB_TOKEN` at invocation time. -- **The common SSH spellings** `git@github.com:owner/repo[.git]` and `ssh://git@github.com/owner/repo[.git]`, through per-repository SSH-to-HTTPS rewrites injected into the stage environment. +- **Plain Git over HTTPS** (`git clone https://github.com/owner/repo`), through a credential helper that reads a private, renewable store outside the workspace. +- **The common SSH spellings** `git@github.com:owner/repo[.git]` and `ssh://git@github.com/owner/repo[.git]`, through SSH-to-HTTPS rewrites injected into the stage environment. Fabro does not clone additional repositories for you; a workflow that needs one on disk adds its own clone step (`git clone https://github.com/owner/repo` or `gh repo clone owner/repo`). @@ -296,15 +298,15 @@ Behavior notes: - **Token strategy (PAT):** the configured PAT is used as-is. The repository list drives validation and preflight probes, but it cannot narrow the PAT's inherent GitHub scope — App mode remains the least-authority option. - **`GH_TOKEN` precedence:** `gh` checks `GH_TOKEN` before `GITHUB_TOKEN`. If the resolved run environment defines `GH_TOKEN`, `gh` uses it instead of the managed token; Fabro never sets or removes `GH_TOKEN`, and preflight warns when additional repositories are declared alongside one. -- **SSH rewrites match by prefix.** With `owner/repo` declared, the SSH spelling of `owner/repo-other` is also rewritten to HTTPS. The scoped token is invalid for undeclared repositories at GitHub, so authority is unchanged — but a private undeclared repository fails with a GitHub authorization error instead of a missing-credential or SSH error. +- **GitHub SSH URLs are rewritten to HTTPS.** The helper supplies credentials only for the origin and declared repository paths, including their `.git` spellings. An undeclared private repository does not receive a credential from this helper. #### Security boundary Workflow authors may name any repository reachable by the server's GitHub App installation; Fabro applies no second server-side repository intersection. The token is scoped server-side to exactly the declared set — a request to an undeclared repository fails at GitHub, and Fabro never mints an unscoped installation-wide token. With `contents = "write"`, **any stage can push to any declared repository**. Declare the smallest repository set and the weakest permissions that work. -Installation Access Tokens are short-lived. Fabro's own pushes present a fresh token on each call. Git commands the agent runs inside the sandbox read the token through a credential store the sandbox driver configures for the checkout; the token never appears in the repository's remote URL or configuration. For ACP/CLI agent turns launched with GitHub App push credentials, Fabro re-mints the token and rewrites that store before the ACP process starts, then every 45 minutes for the lifetime of that turn. Refresh failures are logged and do not fail the stage. +Installation Access Tokens are short-lived. Fabro reuses cached tokens until they are within ten minutes of expiry. Each process launch resolves its current credentials, and a background task checks the Git credential store every minute while the execution scope is acquired. Command stages, native agent tools, ACP agents, and resumed scopes use the same mechanism. A long-lived ACP agent's later Git operations read the renewed store. The token never appears in the repository's remote URL, Git configuration, or workspace snapshots; the private store is removed when the scope releases. Background refresh failures are logged and retried, and a still-valid cached token remains usable. -`FABRO_PUSH_CRED_REFRESH_AHEAD` defaults to enabled; set it to `0`, `false`, `off`, `no`, or an empty value to disable both turn-entry and background refresh. `FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS` overrides the background interval, and `0` disables only the background loop. This refresh loop is ACP-specific; command and native/API agent stages do not run it. Reconnected sandboxes for resumed or parked runs currently lack the App credentials needed for ACP refresh, so the refresh is skipped there. +`GITHUB_TOKEN` is a process environment variable, so a process that is already running keeps its launch-time value. The renewable Git helper continues to work across token rotation, but a long-running process that calls the GitHub API through `GITHUB_TOKEN` must restart or obtain a new token separately. Static PATs cannot be renewed by Fabro. Publication uses a separate token source with the permissions needed to push and open pull requests. The permissions table follows the standard layer-merge order (workflow > project > user > defaults). Set defaults at `[run.integrations.github.permissions]` in `~/.fabro/settings.toml` so every run inherits a baseline; tighten or override per-workflow as needed. A higher layer that defines `permissions = {}` clears the inherited map (no token requested). diff --git a/lib/apps/fabro-cli/src/commands/run/petri_worker.rs b/lib/apps/fabro-cli/src/commands/run/petri_worker.rs index ba2a834ff..9a8323757 100644 --- a/lib/apps/fabro-cli/src/commands/run/petri_worker.rs +++ b/lib/apps/fabro-cli/src/commands/run/petri_worker.rs @@ -84,6 +84,7 @@ use fabro_petri::providers::{DaytonaCredentials, SandboxProviderConfig}; use fabro_petri::runtime::{self, RuntimeSpec}; use fabro_petri::secrets::VaultSecrets; use fabro_petri::source::RunSource; +use fabro_petri::stage_credentials::StageCredentials; use fabro_petri::{HttpRunStore, admission}; use fabro_static::EnvVars; use fabro_store::RunProjection; @@ -171,7 +172,7 @@ pub(super) async fn execute(worker: PetriWorker<'_>) -> Result<()> { let run_tools = run_tool_services(&worker); let catalog = command_context::load_cli_catalog().context("failed to build worker LLM catalog")?; - let runtime = runtime_spec( + let mut runtime = runtime_spec( catalog.clone(), &vault, &worker.run_state, @@ -215,6 +216,7 @@ pub(super) async fn execute(worker: PetriWorker<'_>) -> Result<()> { None } }; + runtime.stage_credentials = StageCredentials::for_run(&worker.run_state.spec, github.as_ref())?; let mut source = RunSource::for_run( worker.run_state.spec.target.as_ref(), &worker.run_state.spec.settings.run, @@ -657,6 +659,7 @@ async fn runtime_spec( .with_http_client(fabro_http::http_client().ok()) }); Ok(RuntimeSpec { + stage_credentials: None, sandbox: SandboxProviderConfig::from_lookup(daytona, crate::process_env_var), settings_toml: None, mcp_catalog_toml: None, diff --git a/lib/apps/fabro-server/src/manifest_validation.rs b/lib/apps/fabro-server/src/manifest_validation.rs index acbe81a3d..f47a63e26 100644 --- a/lib/apps/fabro-server/src/manifest_validation.rs +++ b/lib/apps/fabro-server/src/manifest_validation.rs @@ -56,13 +56,14 @@ fn offline_runtime(run: Option<&RunLayer>) -> RuntimeSpec { ..SettingsLayer::default() }; RuntimeSpec { - sandbox: SandboxProviderConfig::default(), - settings_toml: toml::to_string(&layer).ok(), - mcp_catalog_toml: None, - model_client: None, - dry_run: false, - fabro_home: None, - run_tools: None, + stage_credentials: None, + sandbox: SandboxProviderConfig::default(), + settings_toml: toml::to_string(&layer).ok(), + mcp_catalog_toml: None, + model_client: None, + dry_run: false, + fabro_home: None, + run_tools: None, } } diff --git a/lib/apps/fabro-server/src/server/petri_runs.rs b/lib/apps/fabro-server/src/server/petri_runs.rs index 734a58025..2c7c2d815 100644 --- a/lib/apps/fabro-server/src/server/petri_runs.rs +++ b/lib/apps/fabro-server/src/server/petri_runs.rs @@ -99,6 +99,7 @@ pub(crate) fn runtime_spec( } }; RuntimeSpec { + stage_credentials: None, sandbox, settings_toml, mcp_catalog_toml, diff --git a/lib/components/fabro-petri/Cargo.toml b/lib/components/fabro-petri/Cargo.toml index f9cd9a456..9cb0d049b 100644 --- a/lib/components/fabro-petri/Cargo.toml +++ b/lib/components/fabro-petri/Cargo.toml @@ -19,6 +19,8 @@ workspace = true test-support = ["dep:petri_testkit"] [dependencies] +fabro-github = { path = "../fabro-github" } +percent-encoding.workspace = true fabro-api = { path = "../../foundation/fabro-api" } fabro-client = { path = "../../foundation/fabro-client" } fabro-db = { path = "../../foundation/fabro-db" } @@ -57,6 +59,7 @@ tokio-util.workspace = true tracing.workspace = true [dev-dependencies] +fabro-github = { path = "../fabro-github", features = ["test-support"] } fabro-macros = { path = "../../foundation/fabro-macros" } fabro-petri = { path = ".", features = ["test-support"] } fabro-tool = { path = "../fabro-tool" } diff --git a/lib/components/fabro-petri/src/engine.rs b/lib/components/fabro-petri/src/engine.rs index 369149be5..b833bc12d 100644 --- a/lib/components/fabro-petri/src/engine.rs +++ b/lib/components/fabro-petri/src/engine.rs @@ -226,6 +226,11 @@ pub async fn run(request: RunRequest) -> Result { if let Some(secrets) = request.secrets { runtime = runtime.secrets(SharedSecrets(secrets)); } + if let Some(credentials) = request.runtime.stage_credentials.clone() { + let masker = runtime.masker(); + runtime = + runtime.executor_layer(move |executor| credentials.executor(executor, masker.clone())); + } if let Some(blobs) = &request.blobs { runtime = runtime.capability(RunBlobs::output_store(Arc::clone(blobs))); } diff --git a/lib/components/fabro-petri/src/lib.rs b/lib/components/fabro-petri/src/lib.rs index 03cd854ec..e50758dfe 100644 --- a/lib/components/fabro-petri/src/lib.rs +++ b/lib/components/fabro-petri/src/lib.rs @@ -85,6 +85,7 @@ pub mod run_store; pub mod runtime; pub mod secrets; pub mod source; +pub mod stage_credentials; #[cfg(feature = "test-support")] pub mod test_support; pub mod workspace; diff --git a/lib/components/fabro-petri/src/runtime.rs b/lib/components/fabro-petri/src/runtime.rs index da6a3b129..0cb377727 100644 --- a/lib/components/fabro-petri/src/runtime.rs +++ b/lib/components/fabro-petri/src/runtime.rs @@ -32,38 +32,41 @@ use tracing::debug; use crate::host_tools; use crate::providers::{self, SandboxProviderConfig}; +use crate::stage_credentials::StageCredentials; /// What every Petri runtime Fabro builds is configured with. #[derive(Clone, Default)] pub struct RuntimeSpec { + /// Run-specific GitHub credentials applied to processes at execution. + pub stage_credentials: Option, /// Explicit provider configuration. Factories connect only at acquire. - pub sandbox: SandboxProviderConfig, + pub sandbox: SandboxProviderConfig, /// The operator's settings layer, as `~/.fabro/settings.toml` text: the /// lowest of the three layers the Fabro frontend reads (`[run.model]` /// defaults, `[[run.hooks]]`, `[run.agent.mcps]`, `[run.environment]` /// and the `[environments.]` catalog a bundle may name). - pub settings_toml: Option, + pub settings_toml: Option, /// The server's MCP catalog, as the TOML text the Fabro frontend /// resolves `[run.agent.mcps.] id = "..."` references against: a /// table keyed by catalog id, each entry in the inline /// `[run.agent.mcps.]` shape. `None` leaves every reference /// refused, as the standalone runner refuses it. - pub mcp_catalog_toml: Option, + pub mcp_catalog_toml: Option, /// The model client the native agent and prompt steps call, and the /// catalog the admission pass resolves model selectors against. `None` /// leaves every LLM node unpinned and every model call unconfigured. - pub model_client: Option, + pub model_client: Option, /// Simulate steps (Fabro's `--dry-run` handlers) in local workspaces, /// without acquiring the configured Docker or Daytona sandboxes. - pub dry_run: bool, + pub dry_run: bool, /// The Fabro home the skills step reads; `None` leaves it to Petri's /// own lookup (`FABRO_HOME`, else `$HOME/.fabro`). - pub fabro_home: Option, + pub fabro_home: Option, /// Fabro's run tools for every native agent session of the run, when /// the run enables them (`[run.agent] fabro_tools` and the worker /// token's `agent:run_tools` scope); `None` gives the sessions Pebble's /// tools alone. See [`crate::host_tools`]. - pub run_tools: Option, + pub run_tools: Option, } impl RuntimeSpec { diff --git a/lib/components/fabro-petri/src/stage_credentials.rs b/lib/components/fabro-petri/src/stage_credentials.rs new file mode 100644 index 000000000..35101db3d --- /dev/null +++ b/lib/components/fabro-petri/src/stage_credentials.rs @@ -0,0 +1,626 @@ +//! GitHub credentials for every process in a run's execution scopes. +//! +//! Git reads a private, renewable store outside the workspace. Processes get +//! the helper configuration and, when requested, a fresh `GITHUB_TOKEN` at +//! spawn. A long-lived agent's Git commands read the renewed store. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use fabro_github::token_source::InstallationTokenSource; +use fabro_github::{GitHubCredentials, GitHubRepositoryAccess}; +use fabro_types::settings::run::RunMode; +use fabro_types::{GitHubRepositorySlug, RunSpec, RunTarget}; +use fabro_util::shell; +use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; +use petri_runtime::executor::{ + AcquireContext, DirectoryEntry, EnvError, EnvHandle, ExecEnv, Executor, Masker, PreviewUrl, + ProcessHandle, ProcessSpec, ReleaseReport, ScopeOutcome, ScopeSpec, +}; +use petri_runtime::ir::LogStream; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; +use tokio::time; +use tracing::warn; + +const REFRESH_INTERVAL: Duration = Duration::from_mins(1); +const CREDENTIAL_TIMEOUT: Duration = Duration::from_secs(30); + +/// Token policy resolved by Fabro, without GitHub policy in Petri. +#[derive(Clone)] +pub struct StageCredentials { + git_tokens: Arc, + api_tokens: Option>, + repositories: Vec, +} + +impl StageCredentials { + /// Default origin access is read-only. Declared permissions govern the + /// integration token and additional repositories. Static tokens retain + /// their existing scope; publication has a separate write-token source. + pub fn for_run( + spec: &RunSpec, + credentials: Option<&GitHubCredentials>, + ) -> anyhow::Result> { + if spec.settings.run.execution.mode == RunMode::DryRun { + return Ok(None); + } + let Some(RunTarget::Git(target)) = &spec.target else { + return Ok(None); + }; + let repository = target.clone().validate()?.repository().clone(); + let integration = spec + .settings + .run + .integrations + .github + .resolve_integration()?; + let access = GitHubRepositoryAccess::new( + Some(&repository.https_url()), + &integration.additional_repositories, + integration.permissions.clone(), + )?; + let Some(credentials) = credentials else { + return Ok(None); + }; + let api_tokens = if integration.is_token_requested() { + access + .as_ref() + .map(|access| InstallationTokenSource::for_access(credentials, access)) + .transpose()? + } else { + None + }; + let (git_tokens, repositories) = if matches!( + integration.permissions.get("contents").map(String::as_str), + Some("read" | "write") + ) { + let Some(access) = access else { + return Ok(None); + }; + let Some(tokens) = &api_tokens else { + return Ok(None); + }; + ( + tokens.clone(), + access.targets().into_iter().cloned().collect(), + ) + } else { + ( + InstallationTokenSource::for_repository( + credentials, + repository.owner().to_string(), + repository.repo().to_string(), + serde_json::json!({"contents":"read"}), + )?, + vec![repository], + ) + }; + Ok(Some(Self { + git_tokens, + api_tokens, + repositories, + })) + } + + pub(crate) fn executor(&self, inner: Arc, masker: Masker) -> Arc { + Arc::new(CredentialExecutor { + inner, + credentials: self.clone(), + masker, + scopes: Mutex::new(HashMap::new()), + }) + } +} + +struct CredentialExecutor { + inner: Arc, + credentials: StageCredentials, + masker: Masker, + scopes: Mutex>, +} + +struct ScopeRefresh { + env: Arc, + task: JoinHandle<()>, +} + +impl Drop for ScopeRefresh { + fn drop(&mut self) { + self.task.abort(); + } +} + +#[async_trait] +impl Executor for CredentialExecutor { + async fn acquire( + &self, + scope: &ScopeSpec, + ctx: &AcquireContext, + ) -> Result { + let handle = self.inner.acquire(scope, ctx).await?; + let inner = handle.exec(); + let directory = match command( + &inner, + "umask 077; mktemp -d /tmp/fabro-git-credentials.XXXXXXXX", + Vec::new(), + ) + .await + { + Ok(directory) => directory.trim().to_string(), + Err(error) => { + self.inner.release(handle, ScopeOutcome::Failed).await; + return Err(error); + } + }; + let env = Arc::new(CredentialEnv { + inner, + credentials: self.credentials.clone(), + masker: self.masker.clone(), + directory, + update: Mutex::new(()), + }); + if let Err(error) = env.refresh().await { + let _ = env.cleanup().await; + self.inner.release(handle, ScopeOutcome::Failed).await; + return Err(error); + } + let refresh_env = env.clone(); + let task = tokio::spawn(async move { + loop { + time::sleep(REFRESH_INTERVAL).await; + if let Err(error) = refresh_env.refresh().await { + warn!(error = %refresh_env.masker.mask(&error.to_string()), "could not refresh the sandbox's GitHub credentials; retrying"); + } + } + }); + self.scopes + .lock() + .await + .insert(handle.instance().to_string(), ScopeRefresh { + env: env.clone(), + task, + }); + Ok(handle.with_exec(env)) + } + + async fn release(&self, handle: EnvHandle, outcome: ScopeOutcome) -> ReleaseReport { + let mut problems = Vec::new(); + let refresh = self.scopes.lock().await.remove(handle.instance()); + if let Some(mut refresh) = refresh { + refresh.task.abort(); + let _ = (&mut refresh.task).await; + if refresh.env.cleanup().await.is_err() { + problems.push("could not remove the sandbox's GitHub credential store".to_string()); + } + } + let mut report = self.inner.release(handle, outcome).await; + report.problems.extend(problems); + report + } +} + +struct CredentialEnv { + inner: Arc, + credentials: StageCredentials, + masker: Masker, + directory: String, + update: Mutex<()>, +} + +impl CredentialEnv { + async fn refresh(&self) -> Result<(), EnvError> { + let _update = self.update.lock().await; + let token = time::timeout(CREDENTIAL_TIMEOUT, self.credentials.git_tokens.resolve()) + .await + .map_err(|_| EnvError::backend("github", "refresh", "token resolution timed out"))? + .map_err(|_| EnvError::backend("github", "refresh", "token resolution failed"))?; + self.masker.register_explicit(token.token.expose()); + let password = utf8_percent_encode(token.token.expose(), NON_ALPHANUMERIC).to_string(); + self.masker.register_explicit(&password); + 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"), + ] + }) + .collect::(); + self.masker.register_explicit(&store); + let path = shell::shell_quote(&format!("{}/store", self.directory)); + command(&self.inner, &format!("umask 077; printf '%s' \"$FABRO_GIT_CREDENTIAL_STORE\" > {path}.new && mv -f {path}.new {path}"), vec![("FABRO_GIT_CREDENTIAL_STORE".to_string(), store)]).await?; + Ok(()) + } + + async fn cleanup(&self) -> Result<(), EnvError> { + command( + &self.inner, + &format!("rm -rf -- {}", shell::shell_quote(&self.directory)), + Vec::new(), + ) + .await + .map(|_| ()) + } + + fn git_env(&self, spec: &mut ProcessSpec) -> Result<(), EnvError> { + let count = spec + .env + .get("GIT_CONFIG_COUNT") + .map(ToString::to_string) + .or_else(|| self.inner.ambient_env("GIT_CONFIG_COUNT")) + .unwrap_or_else(|| "0".to_string()) + .parse::() + .ok() + .filter(|count| *count <= 256) + .ok_or_else(|| { + EnvError::backend("github", "environment", "invalid GIT_CONFIG_COUNT") + })?; + // Preserve fetch/publish headers and workflow configuration. The store + // returns a credential only for an allowed repository path. + let entries = [ + ( + "credential.https://github.com.useHttpPath", + "true".to_string(), + ), + ("credential.https://github.com.helper", String::new()), + ( + "credential.https://github.com.helper", + format!( + "store --file={}", + shell::shell_quote(&format!("{}/store", self.directory)) + ), + ), + ( + "url.https://github.com/.insteadOf", + "git@github.com:".to_string(), + ), + ( + "url.https://github.com/.insteadOf", + "ssh://git@github.com/".to_string(), + ), + ]; + for index in 0..count { + for prefix in ["GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_"] { + let key = format!("{prefix}{index}"); + if !spec.env.contains_key(key.as_str()) { + if let Some(value) = self.inner.ambient_env(&key) { + spec.env.insert(key.into(), value.into()); + } + } + } + } + for (offset, (key, value)) in entries.iter().enumerate() { + spec.env.insert( + format!("GIT_CONFIG_KEY_{}", count + offset).into(), + (*key).into(), + ); + spec.env.insert( + format!("GIT_CONFIG_VALUE_{}", count + offset).into(), + value.as_str().into(), + ); + } + spec.env.insert( + "GIT_CONFIG_COUNT".into(), + (count + entries.len()).to_string().into(), + ); + spec.env + .entry("GIT_TERMINAL_PROMPT".into()) + .or_insert_with(|| "0".into()); + Ok(()) + } +} + +#[async_trait] +impl ExecEnv for CredentialEnv { + async fn spawn(&self, mut spec: ProcessSpec) -> Result, EnvError> { + self.refresh().await?; + if let Some(tokens) = &self.credentials.api_tokens { + if !spec.env.contains_key("GITHUB_TOKEN") + && self.inner.ambient_env("GITHUB_TOKEN").is_none() + { + let resolved = time::timeout(CREDENTIAL_TIMEOUT, tokens.resolve()) + .await + .map_err(|_| { + EnvError::backend("github", "resolve", "token resolution timed out") + })? + .map_err(|_| { + EnvError::backend("github", "resolve", "token resolution failed") + })?; + self.masker.register_explicit(resolved.token.expose()); + spec.env + .insert("GITHUB_TOKEN".into(), resolved.token.expose().into()); + } + } + self.git_env(&mut spec)?; + self.inner.spawn(spec).await + } + fn workspace_path(&self) -> &str { + self.inner.workspace_path() + } + async fn read_file(&self, path: &Path) -> Result>, EnvError> { + self.inner.read_file(path).await + } + async fn read_file_limited( + &self, + path: &Path, + limit: usize, + ) -> Result>, EnvError> { + self.inner.read_file_limited(path, limit).await + } + async fn write_file(&self, path: &Path, contents: &[u8]) -> Result<(), EnvError> { + self.inner.write_file(path, contents).await + } + async fn list_directory( + &self, + path: &Path, + depth: usize, + ) -> Result, EnvError> { + self.inner.list_directory(path, depth).await + } + fn grace(&self) -> Duration { + self.inner.grace() + } + fn host_address(&self) -> Result<&str, EnvError> { + self.inner.host_address() + } + fn ambient_env(&self, name: &str) -> Option { + self.inner.ambient_env(name) + } + fn shares_host_filesystem(&self) -> bool { + self.inner.shares_host_filesystem() + } + async fn preview_url(&self, port: u16) -> Result, EnvError> { + self.inner.preview_url(port).await + } + async fn release_preview_url(&self, port: u16) -> Result<(), EnvError> { + self.inner.release_preview_url(port).await + } +} + +async fn command( + env: &Arc, + script: &str, + extra_env: Vec<(String, String)>, +) -> Result { + let spec = ProcessSpec::new("sh", &["-c", script]) + .with_timeout(Some(CREDENTIAL_TIMEOUT)) + .with_env( + extra_env + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect(), + ); + let mut handle = env.spawn(spec).await?; + let mut stdout = String::new(); + if let Some(mut lines) = handle.lines() { + while let Some(line) = lines.recv().await { + // Never echo stderr from an operation installing credentials. + if line.stream == LogStream::Stdout { + stdout.push_str(&line.line); + stdout.push('\n'); + } + } + } + if !handle.wait().await?.is_success() { + return Err(EnvError::backend( + "github", + "credential_store", + "credential store operation failed", + )); + } + Ok(stdout) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::os::unix::fs::PermissionsExt as _; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use chrono::Utc; + use fabro_github::InstallationToken; + use fabro_github::test_support::{self, InstallationTokenMinter}; + use fabro_types::test_support as types_support; + use petri_runtime::executor::{MapSecrets, SecretProvider as _, StdinMode}; + use petri_runtime::ir::ScopeId; + use tokio::io::AsyncWriteExt as _; + + use super::*; + use crate::providers::{self, SandboxProviderConfig}; + + struct RotatingMinter(AtomicUsize); + + #[async_trait] + impl InstallationTokenMinter for RotatingMinter { + async fn mint(&self) -> anyhow::Result { + let generation = self.0.fetch_add(1, Ordering::SeqCst) + 1; + Ok(InstallationToken { + token: format!("scripted-token-generation-{generation}"), + // Each resolve exercises renewal without waiting an hour. + expires_at: Utc::now() + chrono::Duration::minutes(5), + }) + } + } + + fn credentials(api: bool) -> 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), + repositories: vec![GitHubRepositorySlug::try_new("acme/private").expect("slug")], + } + } + + #[test] + fn default_origin_access_does_not_request_an_api_token() { + let mut spec = types_support::test_run_spec(); + spec.target = Some( + serde_json::from_value( + serde_json::json!({"kind":"git", "repo":"acme/private", "branch":"main"}), + ) + .expect("target"), + ); + let pat = GitHubCredentials::Pat("scripted-personal-access-token".to_string()); + let default = StageCredentials::for_run(&spec, Some(&pat)) + .expect("policy") + .expect("credentials"); + assert!(default.api_tokens.is_none()); + assert_eq!(default.repositories.len(), 1); + spec.settings + .run + .integrations + .github + .permissions + .insert("contents".to_string(), "read".into()); + spec.settings + .run + .integrations + .github + .additional_repositories + .insert(GitHubRepositorySlug::try_new("acme/another").expect("slug")); + let declared = StageCredentials::for_run(&spec, Some(&pat)) + .expect("policy") + .expect("credentials"); + assert!(declared.api_tokens.is_some()); + assert_eq!(declared.repositories.len(), 2); + } + + #[tokio::test] + async fn a_running_process_reads_renewed_git_credentials_and_release_cleans_up() { + let dir = tempfile::tempdir().expect("directory"); + let runtime = providers::standard_runtime(&SandboxProviderConfig::default()); + let router = runtime.sandbox_router_for(dir.path()).expect("router"); + let secrets = MapSecrets::empty(); + let executor = credentials(false).executor(router, secrets.masker()); + let handle = executor + .acquire( + &ScopeSpec::new(ScopeId::new(0), "credentials"), + &AcquireContext::bare(), + ) + .await + .expect("acquire"); + let env = handle.exec(); + let script = "printf 'protocol=https\\nhost=github.com\\npath=acme/private\\n\\n' | git credential fill; printf 'READY\\n'; read answer; printf 'protocol=https\\nhost=github.com\\npath=acme/private\\n\\n' | git credential fill"; + let mut process = env + .spawn(ProcessSpec::new("sh", &["-c", script]).with_stdin(StdinMode::Piped)) + .await + .expect("launch"); + let mut lines = process.lines().expect("lines"); + let mut first = String::new(); + while let Some(line) = lines.recv().await { + if line.line == "READY" { + break; + } + first.push_str(&line.line); + } + assert!( + first.contains("password=scripted-token-generation-2"), + "first token is delivered (password present: {}, masked: {}, stderr present: {})", + first.contains("password="), + first.contains("***"), + first.contains("fatal:") + ); + // Another spawn rotates the store while the original process lives. + command(&env, "true", Vec::new()) + .await + .expect("refresh via spawn"); + process + .stdin() + .expect("stdin") + .write_all(b"continue\n") + .await + .expect("continue"); + let mut second = String::new(); + while let Some(line) = lines.recv().await { + second.push_str(&line.line); + } + assert!( + second.contains("password=scripted-token-generation-3"), + "the same process uses the renewed helper" + ); + assert!(process.wait().await.expect("exit").is_success()); + assert!( + !secrets + .masker() + .mask(&format!("{first}{second}")) + .contains("scripted-token") + ); + assert!(command(&env, "printf 'protocol=https\\nhost=github.com\\npath=acme/unrelated\\n\\n' | git credential fill", Vec::new()).await.is_err(), "the helper refuses an undeclared repository"); + command(&env, "printf 'protocol=https\\nhost=github.com\\npath=acme/private.git\\n\\n' | git credential fill >/dev/null", Vec::new()).await.expect("the .git spelling is authenticated too"); + let probe = command( + &env, + "git config --get credential.https://github.com.helper", + Vec::new(), + ) + .await + .expect("helper"); + let file = probe + .trim() + .strip_prefix("store --file=") + .expect("store path"); + assert_eq!( + fs::metadata(file).expect("file").permissions().mode() & 0o777, + 0o600 + ); + let report = executor.release(handle, ScopeOutcome::Succeeded).await; + assert!(report.is_clean(), "{report:?}"); + assert!( + !Path::new(file).exists(), + "release removes the credential store" + ); + } + + #[tokio::test] + async fn declared_api_tokens_reach_processes_and_explicit_environment_wins() { + let dir = tempfile::tempdir().expect("directory"); + let runtime = providers::standard_runtime(&SandboxProviderConfig::default()); + let router = runtime.sandbox_router_for(dir.path()).expect("router"); + let secrets = MapSecrets::empty(); + let executor = credentials(true).executor(router, secrets.masker()); + let handle = executor + .acquire( + &ScopeSpec::new(ScopeId::new(0), "api-credentials"), + &AcquireContext::bare(), + ) + .await + .expect("acquire"); + let env = handle.exec(); + command( + &env, + "case $GITHUB_TOKEN in scripted-token-generation-*) exit 0;; *) exit 1;; esac", + Vec::new(), + ) + .await + .expect("the integration token reaches the child"); + command( + &env, + "test \"$GITHUB_TOKEN\" = command-token-override", + vec![( + "GITHUB_TOKEN".to_string(), + "command-token-override".to_string(), + )], + ) + .await + .expect("explicit command environment wins"); + assert!( + secrets + .masker() + .contains_secret("scripted-token-generation-3") + ); + assert!( + executor + .release(handle, ScopeOutcome::Succeeded) + .await + .is_clean() + ); + } +}