mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
Merge remote-tracking branch 'origin/main' into pr-764
# Conflicts: # lib/components/fabro-sandbox/src/daytona/mod.rs # lib/components/fabro-sandbox/src/push_credentials.rs # lib/components/fabro-sandbox/src/sandbox.rs
This commit is contained in:
commit
7d771e9b96
80 changed files with 1748 additions and 737 deletions
|
|
@ -3,6 +3,7 @@ import type { BoardColumn, Run } from "@qltysh/fabro-api-client";
|
|||
|
||||
import {
|
||||
buildBoardColumns,
|
||||
buildFilterOptions,
|
||||
loadStoredRunsWorkspaceSearchParams,
|
||||
placeArchivedColumnLast,
|
||||
persistRunsWorkspacePreferences,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
shouldRefreshBoardForEvent,
|
||||
} from "./runs";
|
||||
import { summarizeBatchLifecycleAction } from "../components/runs-list/batch-lifecycle";
|
||||
import { mapRunListItem } from "../data/runs";
|
||||
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
|
||||
|
||||
function boardRun(id: string, column: BoardColumn, questionText?: string): Run {
|
||||
|
|
@ -217,6 +219,38 @@ describe("runs route board mapping", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("runs route filter options", () => {
|
||||
function runWith(id: string, repoName: string, workflowName: string): Run {
|
||||
const run = boardRun(id, "running");
|
||||
return {
|
||||
...run,
|
||||
repository: { ...run.repository, name: repoName },
|
||||
workflow: { ...run.workflow, name: workflowName },
|
||||
};
|
||||
}
|
||||
|
||||
test("derives sorted unique options from run items", () => {
|
||||
const items = [
|
||||
runWith("a", "qlty/beta", "release"),
|
||||
runWith("b", "qlty/alpha", "hello"),
|
||||
runWith("c", "qlty/beta", "release"),
|
||||
].map(mapRunListItem);
|
||||
|
||||
expect(buildFilterOptions(items, (item) => item.repo, "all")).toEqual(["alpha", "beta"]);
|
||||
expect(buildFilterOptions(items, (item) => item.workflow, "all")).toEqual([
|
||||
"hello",
|
||||
"release",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps the active selection when no loaded run matches it", () => {
|
||||
const items = [runWith("a", "qlty/beta", "release")].map(mapRunListItem);
|
||||
|
||||
expect(buildFilterOptions(items, (item) => item.repo, "gamma")).toEqual(["beta", "gamma"]);
|
||||
expect(buildFilterOptions([], (item) => item.workflow, "release")).toEqual(["release"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runs route workspace preferences", () => {
|
||||
class MemoryStorage {
|
||||
values = new Map<string, string>();
|
||||
|
|
|
|||
|
|
@ -140,6 +140,18 @@ export function buildBoardColumns(
|
|||
});
|
||||
}
|
||||
|
||||
export function buildFilterOptions(
|
||||
items: RunItem[],
|
||||
pick: (item: RunItem) => string,
|
||||
selected: string,
|
||||
): string[] {
|
||||
const values = new Set(items.map(pick));
|
||||
// Keep the active selection visible even when no loaded run matches it,
|
||||
// e.g. a stored repo filter while paginating the list view.
|
||||
if (selected !== "all") values.add(selected);
|
||||
return Array.from(values).sort();
|
||||
}
|
||||
|
||||
export function placeArchivedColumnLast(columns: Column[], includeArchived: boolean): Column[] {
|
||||
if (!includeArchived) return columns;
|
||||
const archived = columns.find((column) => column.id === "archived");
|
||||
|
|
@ -771,18 +783,18 @@ export default function Runs() {
|
|||
);
|
||||
const hasGitHubAuth = authConfig.data?.methods.includes("github") === true;
|
||||
const serverUrl = systemInfo.data?.server_url;
|
||||
const allRepos = Array.from(
|
||||
new Set(
|
||||
initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => String(item.repo))),
|
||||
),
|
||||
// Filter options come from the loaded runs: all runs in columns view, the
|
||||
// current page in list view (until a facets endpoint provides the full set).
|
||||
const filterSourceItems: RunItem[] =
|
||||
view === "list"
|
||||
? (listRunsPage.data?.data ?? []).map(mapRunListItem)
|
||||
: initialColumns.flatMap((col: Column) => col.items);
|
||||
const allRepos = buildFilterOptions(filterSourceItems, (item) => item.repo, repoFilter);
|
||||
const allWorkflows = buildFilterOptions(
|
||||
filterSourceItems,
|
||||
(item) => item.workflow,
|
||||
workflowFilter,
|
||||
);
|
||||
allRepos.sort();
|
||||
const allWorkflows = Array.from(
|
||||
new Set(
|
||||
initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => String(item.workflow))),
|
||||
),
|
||||
);
|
||||
allWorkflows.sort();
|
||||
const [columnsState, setColumnsState] = useState(() => ({
|
||||
base: initialColumns,
|
||||
columns: initialColumns,
|
||||
|
|
|
|||
|
|
@ -596,6 +596,15 @@ paths:
|
|||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DiagnosticsReport"
|
||||
"504":
|
||||
description: Diagnostics operation timed out
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/openapi.json:
|
||||
get:
|
||||
|
|
@ -11441,6 +11450,8 @@ components:
|
|||
type: ["string", "null"]
|
||||
definition_blob:
|
||||
type: ["string", "null"]
|
||||
spec_blob:
|
||||
type: ["string", "null"]
|
||||
git:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/GitContext"
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ Do not rewrite working code. Make targeted fixes to the specific failures.
|
|||
|
||||
### Max visits as a safety valve
|
||||
|
||||
`max_visits=5` on the `fix` node prevents infinite loops. If the agent can't pass in 5 iterations, the workflow moves on with the best result so far. Tune this based on spec complexity: a 30-line spec might need 2 iterations, a 2,000-line spec might need 10.
|
||||
`max_visits=5` on the `fix` node prevents infinite loops. The node can execute up to 5 times; a sixth visit fails the run rather than looping forever. Tune this based on spec complexity: a 30-line spec might need 2 iterations, a 2,000-line spec might need 10.
|
||||
|
||||
### Goal gate on full conformance
|
||||
|
||||
|
|
|
|||
|
|
@ -321,7 +321,7 @@ memory = "8GB"
|
|||
| `network.allow` | CIDRs for `cidr_allow_list`; entries are validated as CIDRs. |
|
||||
| `lifecycle.preserve` | Keep the created sandbox after the run finishes. |
|
||||
| `lifecycle.stop_on_terminal` | Stop the sandbox when the run reaches a terminal state. |
|
||||
| `lifecycle.auto_stop` | Daytona auto-stop duration, such as `"30m"`. |
|
||||
| `lifecycle.auto_stop` | Daytona auto-stop duration, such as `"30m"`. Defaults to `"120m"`; `"0s"` disables auto-stop. |
|
||||
| `labels` | Provider labels. Merge by key across layers. |
|
||||
| `env` | Environment variables passed to command and agent execution. Merge by key across layers. |
|
||||
|
||||
|
|
|
|||
|
|
@ -198,6 +198,10 @@ The `lifecycle.auto_stop` setting tells Daytona to stop the sandbox after a peri
|
|||
auto_stop = "30m"
|
||||
```
|
||||
|
||||
When `auto_stop` is unset, Fabro applies a default of 120 minutes so a sandbox leaked by an interrupted run is still reclaimed. Set `auto_stop = "0s"` to disable auto-stop and let the sandbox run indefinitely.
|
||||
|
||||
Daytona counts inactivity from the last sandbox interaction (a command, file operation, or other API call). Time an agent spends on LLM inference does not touch the sandbox, so intervals shorter than your longest inference call risk stopping the sandbox mid-run.
|
||||
|
||||
## Server defaults
|
||||
|
||||
When running via `fabro server start`, the server config at `~/.fabro/settings.toml` can set default Daytona settings for all runs. Run config TOML values override server defaults. Labels are **merged** — run config labels win on key collisions. The `network` setting uses simple override (run config replaces the server default entirely).
|
||||
|
|
|
|||
|
|
@ -849,6 +849,7 @@ mod tests {
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1012,6 +1012,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
}
|
||||
},
|
||||
"source_directory": "[TEMP_DIR]",
|
||||
"spec_blob": "[BLOB_HASH]",
|
||||
"title": "Wait for approval",
|
||||
"web_url": "http://localhost:3000/runs/[ULID]",
|
||||
"workflow_slug": "human-gate",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ pub(crate) fn run_projection_json(run_id: &str, status: &serde_json::Value) -> s
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use base64::Engine as _;
|
|||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use fabro_auth::auth_issue_message;
|
||||
use fabro_llm::client::Client as LlmClient;
|
||||
use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe};
|
||||
use fabro_llm::model_test::{ModelTestStatus, run_basic_model_probe_with_timeout};
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use fabro_redact::redact_string;
|
||||
use fabro_sandbox::{DockerSandboxProvider, daytona};
|
||||
|
|
@ -23,6 +23,9 @@ use tokio::time::timeout;
|
|||
|
||||
use crate::server::AppState;
|
||||
|
||||
const EXTERNAL_SERVICE_PROBE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const DOCKER_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
fn http_client_or_check(
|
||||
name: &str,
|
||||
status: CheckStatus,
|
||||
|
|
@ -252,13 +255,20 @@ async fn probe_single_provider(
|
|||
None,
|
||||
);
|
||||
};
|
||||
let model_id = model.id.clone();
|
||||
let model_id = model.id.to_string();
|
||||
|
||||
let outcome = run_basic_model_probe_with_timeout(
|
||||
&model_id,
|
||||
&provider,
|
||||
client,
|
||||
EXTERNAL_SERVICE_PROBE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
|
||||
let outcome = run_basic_model_probe(model_id.as_str(), &provider, client).await;
|
||||
match outcome.status {
|
||||
ModelTestStatus::Ok => ProviderProbeResult {
|
||||
provider,
|
||||
model_id: Some(model_id.to_string()),
|
||||
model_id: Some(model_id),
|
||||
status: ProviderProbeStatus::Ok,
|
||||
error_message: None,
|
||||
diagnostic_detail: None,
|
||||
|
|
@ -267,12 +277,7 @@ async fn probe_single_provider(
|
|||
let raw = outcome
|
||||
.error_message
|
||||
.unwrap_or_else(|| "provider probe failed".to_string());
|
||||
provider_probe_error(
|
||||
provider,
|
||||
Some(model_id.to_string()),
|
||||
redact_string(&raw),
|
||||
None,
|
||||
)
|
||||
provider_probe_error(provider, Some(model_id), redact_string(&raw), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -388,7 +393,7 @@ async fn check_github_app(state: &AppState) -> CheckResult {
|
|||
Err(result) => return result,
|
||||
};
|
||||
let probe = timeout(
|
||||
Duration::from_secs(15),
|
||||
EXTERNAL_SERVICE_PROBE_TIMEOUT,
|
||||
http.get(format!("{}/user", fabro_github::github_api_base_url()))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
|
|
@ -538,7 +543,7 @@ async fn check_github_app(state: &AppState) -> CheckResult {
|
|||
Err(result) => return result,
|
||||
};
|
||||
let auth_result = timeout(
|
||||
Duration::from_secs(15),
|
||||
EXTERNAL_SERVICE_PROBE_TIMEOUT,
|
||||
fabro_github::get_authenticated_app(&http, &jwt, &fabro_github::github_api_base_url()),
|
||||
)
|
||||
.await;
|
||||
|
|
@ -581,7 +586,7 @@ async fn check_docker_sandbox(state: &AppState) -> CheckResult {
|
|||
.await
|
||||
.map_err(|err| err.display_with_causes())
|
||||
},
|
||||
Duration::from_secs(5),
|
||||
DOCKER_PROBE_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -656,7 +661,14 @@ async fn check_cloud_sandbox(state: &AppState) -> CheckResult {
|
|||
};
|
||||
};
|
||||
|
||||
match state.check_daytona_api_key(api_key).await {
|
||||
let probe = state
|
||||
.check_daytona_api_key_with_timeout(api_key, EXTERNAL_SERVICE_PROBE_TIMEOUT)
|
||||
.await;
|
||||
cloud_sandbox_probe_check(probe)
|
||||
}
|
||||
|
||||
fn cloud_sandbox_probe_check(probe: anyhow::Result<daytona::DaytonaKeyCheck>) -> CheckResult {
|
||||
match probe {
|
||||
Ok(check) if check.ok() => CheckResult {
|
||||
name: "Cloud Sandbox".to_string(),
|
||||
status: CheckStatus::Pass,
|
||||
|
|
@ -678,13 +690,29 @@ async fn check_cloud_sandbox(state: &AppState) -> CheckResult {
|
|||
daytona::required_perms_display()
|
||||
)),
|
||||
},
|
||||
Err(err) => CheckResult {
|
||||
name: "Cloud Sandbox".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: "Daytona credential rejected".to_string(),
|
||||
details: vec![CheckDetail::new(format!("{err:#}"))],
|
||||
remediation: Some("Verify DAYTONA_API_KEY value and Daytona reachability".to_string()),
|
||||
},
|
||||
Err(err) => {
|
||||
if let Some(timeout) = err.downcast_ref::<daytona::DaytonaCredentialProbeTimeout>() {
|
||||
return CheckResult {
|
||||
name: "Cloud Sandbox".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: format!("timeout ({:?})", timeout.timeout()),
|
||||
details: vec![CheckDetail::new("Daytona probe timed out".to_string())],
|
||||
remediation: Some(
|
||||
"Verify DAYTONA_API_KEY value and Daytona reachability".to_string(),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
CheckResult {
|
||||
name: "Cloud Sandbox".to_string(),
|
||||
status: CheckStatus::Error,
|
||||
summary: "Daytona credential rejected".to_string(),
|
||||
details: vec![CheckDetail::new(format!("{err:#}"))],
|
||||
remediation: Some(
|
||||
"Verify DAYTONA_API_KEY value and Daytona reachability".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -750,7 +778,7 @@ async fn check_brave_search(state: &AppState) -> CheckResult {
|
|||
Err(result) => return result,
|
||||
};
|
||||
|
||||
let probe = timeout(Duration::from_secs(15), async move {
|
||||
let probe = timeout(EXTERNAL_SERVICE_PROBE_TIMEOUT, async move {
|
||||
http.get("https://api.search.brave.com/res/v1/web/search?q=test&count=1")
|
||||
.header("X-Subscription-Token", api_key)
|
||||
.send()
|
||||
|
|
@ -1154,6 +1182,18 @@ enabled = false
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_cloud_sandbox_reports_timeout() {
|
||||
let result = cloud_sandbox_probe_check(Err(anyhow::Error::new(
|
||||
daytona::DaytonaCredentialProbeTimeout::new(Duration::from_millis(1)),
|
||||
)));
|
||||
|
||||
assert_eq!(result.name, "Cloud Sandbox");
|
||||
assert_eq!(result.status, CheckStatus::Error);
|
||||
assert_eq!(result.summary, "timeout (1ms)");
|
||||
assert_eq!(result.details[0].text, "Daytona probe timed out");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_brave_search_ignores_env_backed_api_key() {
|
||||
let state = TestAppStateBuilder::new()
|
||||
|
|
|
|||
|
|
@ -2387,6 +2387,7 @@ index 1111111..2222222 160000
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1455,6 +1455,15 @@ impl AppState {
|
|||
pub(crate) async fn check_daytona_api_key(
|
||||
&self,
|
||||
api_key: String,
|
||||
) -> anyhow::Result<daytona::DaytonaKeyCheck> {
|
||||
self.check_daytona_api_key_with_timeout(api_key, daytona::DAYTONA_CREDENTIAL_PROBE_TIMEOUT)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn check_daytona_api_key_with_timeout(
|
||||
&self,
|
||||
api_key: String,
|
||||
probe_timeout: Duration,
|
||||
) -> anyhow::Result<daytona::DaytonaKeyCheck> {
|
||||
let base_url = self
|
||||
.config_env_lookup(EnvVars::DAYTONA_API_URL)
|
||||
|
|
@ -1463,8 +1472,14 @@ impl AppState {
|
|||
let org_id = self.config_env_lookup(EnvVars::DAYTONA_ORGANIZATION_ID);
|
||||
|
||||
let http_client = fabro_http::http_client().context("failed to build HTTP client")?;
|
||||
daytona::check_daytona_api_key_with(&base_url, org_id.as_deref(), api_key, http_client)
|
||||
.await
|
||||
daytona::check_daytona_api_key_with_timeout(
|
||||
&base_url,
|
||||
org_id.as_deref(),
|
||||
api_key,
|
||||
http_client,
|
||||
probe_timeout,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Borrow the persistent store so sibling modules can open run readers
|
||||
|
|
|
|||
|
|
@ -627,6 +627,7 @@ mod stage_events_tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -1923,6 +1923,7 @@ reasoning = false
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use fabro_slack::config::{
|
||||
|
|
@ -9,6 +11,7 @@ use fabro_slack::config::{
|
|||
use fabro_static::EnvVars;
|
||||
use fabro_types::settings::server::GithubIntegrationSettings;
|
||||
use fabro_vault::Vault;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::super::{
|
||||
AggregateBilling, AggregateBillingTotals, ApiError, AppState, BilledTokenCounts,
|
||||
|
|
@ -21,6 +24,8 @@ use super::super::{
|
|||
resource_sampler, spawn_blocking, system_sandbox_provider, to_i64,
|
||||
};
|
||||
|
||||
const SERVER_DIAGNOSTICS_TIMEOUT: Duration = Duration::from_secs(25);
|
||||
|
||||
pub(super) fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/repos/github/{owner}/{name}", get(get_github_repo))
|
||||
|
|
@ -683,11 +688,34 @@ async fn get_github_repo(
|
|||
}
|
||||
|
||||
async fn run_diagnostics(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(diagnostics::run_all(state.as_ref()).await),
|
||||
diagnostics_response_with_timeout(
|
||||
Box::pin(diagnostics::run_all(state.as_ref())),
|
||||
SERVER_DIAGNOSTICS_TIMEOUT,
|
||||
)
|
||||
.into_response()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn diagnostics_response_with_timeout<F>(
|
||||
diagnostics: F,
|
||||
operation_timeout: Duration,
|
||||
) -> Response
|
||||
where
|
||||
F: Future<Output = diagnostics::DiagnosticsReport>,
|
||||
{
|
||||
let Ok(report) = timeout(operation_timeout, diagnostics).await else {
|
||||
tracing::warn!(
|
||||
timeout_secs = operation_timeout.as_secs(),
|
||||
"server diagnostics timed out"
|
||||
);
|
||||
return ApiError::with_code(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"Server diagnostics timed out.",
|
||||
"diagnostics_timeout",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
(StatusCode::OK, Json(report)).into_response()
|
||||
}
|
||||
|
||||
pub(in crate::server) async fn openapi_spec() -> Response {
|
||||
|
|
@ -737,3 +765,26 @@ async fn get_aggregate_billing(
|
|||
};
|
||||
(StatusCode::OK, Json(response)).into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_response_returns_gateway_timeout_before_client_deadline() {
|
||||
let response = diagnostics_response_with_timeout(
|
||||
std::future::pending::<diagnostics::DiagnosticsReport>(),
|
||||
Duration::from_millis(1),
|
||||
)
|
||||
.await;
|
||||
|
||||
let body = fabro_test::expect_axum_json(
|
||||
response,
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"GET /api/v1/system/diagnostics timeout",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(body["errors"][0]["code"], "diagnostics_timeout");
|
||||
assert_eq!(body["errors"][0]["detail"], "Server diagnostics timed out.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4650,6 +4650,7 @@ async fn append_default_run_created(run_store: &fabro_store::RunDatabase, run_id
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
@ -4701,6 +4702,7 @@ async fn create_slack_notification_run(
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
@ -5774,6 +5776,7 @@ async fn list_run_stages_distinguishes_visits() {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
@ -5910,6 +5913,7 @@ async fn list_run_stages_exposes_execution_identity_for_resumed_stage() {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
@ -7097,6 +7101,7 @@ async fn create_completed_run_ready_for_pull_request(
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
fork_source_ref: None,
|
||||
};
|
||||
|
||||
|
|
@ -7113,6 +7118,7 @@ async fn create_completed_run_ready_for_pull_request(
|
|||
automation: None,
|
||||
provenance: run_spec.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
@ -14085,6 +14091,7 @@ async fn create_preserved_local_sandbox_run(state: &Arc<AppState>, run_id: RunId
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
@ -14834,6 +14841,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ async fn append_completed_run_with_final_patch(
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ pub fn make_edit_file_tool() -> RegisteredTool {
|
|||
};
|
||||
|
||||
ctx.env
|
||||
.write_file(file_path, &new_content)
|
||||
.write_existing_file(file_path, &new_content)
|
||||
.await
|
||||
.map_err(|e| e.display_with_causes())?;
|
||||
Ok(format!("Successfully edited {file_path}"))
|
||||
|
|
@ -1002,6 +1002,7 @@ mod tests {
|
|||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), "Successfully wrote to /out.txt");
|
||||
assert_eq!(env.existing_file_write_count(), 0);
|
||||
let written = env.written_files.lock().unwrap();
|
||||
assert_eq!(written.len(), 1);
|
||||
assert_eq!(written[0].0, "/out.txt");
|
||||
|
|
@ -1036,6 +1037,7 @@ mod tests {
|
|||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap(), "Successfully edited /f.txt");
|
||||
assert_eq!(env.existing_file_write_count(), 1);
|
||||
let written = env.written_files.lock().unwrap();
|
||||
assert_eq!(written.len(), 1);
|
||||
assert_eq!(written[0].1, "goodbye world");
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ description = "GitHub App authentication and API helpers for Fabro"
|
|||
[lib]
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ use tokio::process::Command;
|
|||
|
||||
pub mod token_source;
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
|
||||
pub const GITHUB_API_BASE_URL: &str = "https://api.github.com";
|
||||
|
||||
/// Returns the GitHub API base URL, allowing override via `GITHUB_BASE_URL` env
|
||||
|
|
|
|||
26
lib/components/fabro-github/src/test_support.rs
Normal file
26
lib/components/fabro-github/src/test_support.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::InstallationToken;
|
||||
use crate::token_source::{InstallationTokenMinter as InnerMinter, InstallationTokenSource};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait InstallationTokenMinter: Send + Sync {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken>;
|
||||
}
|
||||
|
||||
struct TestMinterAdapter(Arc<dyn InstallationTokenMinter>);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl InnerMinter for TestMinterAdapter {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
||||
self.0.mint().await
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn installation_token_source(
|
||||
repo: impl Into<String>,
|
||||
minter: Arc<dyn InstallationTokenMinter>,
|
||||
) -> Arc<InstallationTokenSource> {
|
||||
InstallationTokenSource::with_minter(repo.into(), Box::new(TestMinterAdapter(minter)))
|
||||
}
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
//! Cached GitHub installation-token source.
|
||||
//!
|
||||
//! One [`InstallationTokenSource`] serves every GitHub-token consumer for an
|
||||
//! origin repository — the clone-based sandbox providers and the run-metadata
|
||||
//! writer share a single source, so "reuse a token until near expiry" is the
|
||||
//! default behavior instead of a per-call-site special case. Reusing mature
|
||||
//! tokens keeps consumers out of GitHub's token-replication lag window, where
|
||||
//! a token minted milliseconds earlier is rejected with 404 "Repository not
|
||||
//! found" or an authentication failure.
|
||||
//! One [`InstallationTokenSource`] can serve GitHub-token consumers that share
|
||||
//! a repository and permission scope. Reusing mature tokens keeps consumers
|
||||
//! out of GitHub's token-replication lag window, where a token minted
|
||||
//! milliseconds earlier is rejected with 404 "Repository not found" or an
|
||||
//! authentication failure.
|
||||
//!
|
||||
//! The source also reports *provenance*: when it minted the token it returned,
|
||||
//! and which mint generation it belongs to. Retry classification, logging, and
|
||||
|
|
@ -134,14 +132,17 @@ impl fmt::Debug for SecretString {
|
|||
/// boundaries.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedToken {
|
||||
pub token: SecretString,
|
||||
pub snapshot: TokenSnapshot,
|
||||
pub token: SecretString,
|
||||
pub snapshot: TokenSnapshot,
|
||||
/// The source tried to refresh an expiring token, but returned the still-
|
||||
/// valid cached token after the mint failed.
|
||||
pub refresh_failed: bool,
|
||||
}
|
||||
|
||||
/// Mints installation tokens for [`InstallationTokenSource`]. Abstracted so
|
||||
/// tests can script mint results without HTTP.
|
||||
#[async_trait::async_trait]
|
||||
pub trait InstallationTokenMinter: Send + Sync {
|
||||
pub(crate) trait InstallationTokenMinter: Send + Sync {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken>;
|
||||
}
|
||||
|
||||
|
|
@ -181,11 +182,12 @@ struct CachedToken {
|
|||
impl CachedToken {
|
||||
fn resolved(&self, provenance: TokenProvenance) -> ResolvedToken {
|
||||
ResolvedToken {
|
||||
token: SecretString::new(self.token.token.clone()),
|
||||
snapshot: TokenSnapshot {
|
||||
token: SecretString::new(self.token.token.clone()),
|
||||
snapshot: TokenSnapshot {
|
||||
generation: self.generation,
|
||||
provenance,
|
||||
},
|
||||
refresh_failed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -231,6 +233,16 @@ impl InstallationTokenSource {
|
|||
let normalized = crate::normalize_repo_origin_url(origin_url);
|
||||
let (owner, repo) = crate::parse_github_owner_repo(&normalized)
|
||||
.context("parsing GitHub origin for token source")?;
|
||||
Self::for_repository(creds, owner, repo, permissions)
|
||||
}
|
||||
|
||||
/// Build a source for an already parsed GitHub repository.
|
||||
pub fn for_repository(
|
||||
creds: &GitHubCredentials,
|
||||
owner: String,
|
||||
repo: String,
|
||||
permissions: serde_json::Value,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
let repo_display = format!("{owner}/{repo}");
|
||||
let state = match creds {
|
||||
GitHubCredentials::Pat(token) => SourceState::Pat(SecretString::new(token.clone())),
|
||||
|
|
@ -258,9 +270,28 @@ impl InstallationTokenSource {
|
|||
}))
|
||||
}
|
||||
|
||||
/// Build a minting source over a custom minter. For tests.
|
||||
/// Build a source for a personal access token.
|
||||
#[must_use]
|
||||
pub fn with_minter(repo: String, minter: Box<dyn InstallationTokenMinter>) -> Arc<Self> {
|
||||
pub fn pat(token: String) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
repo: String::new(),
|
||||
state: SourceState::Pat(SecretString::new(token)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a source for a pre-minted installation token.
|
||||
#[must_use]
|
||||
pub fn installation(token: InstallationToken) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
repo: String::new(),
|
||||
state: SourceState::Installation(token),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a minting source over a custom minter.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
#[must_use]
|
||||
pub(crate) fn with_minter(repo: String, minter: Box<dyn InstallationTokenMinter>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
repo,
|
||||
state: SourceState::App {
|
||||
|
|
@ -299,7 +330,29 @@ impl InstallationTokenSource {
|
|||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
self.mint_locked(minter.as_ref(), &mut cache).await
|
||||
match self.mint_locked(minter.as_ref(), &mut cache).await {
|
||||
Ok(resolved) => Ok(resolved),
|
||||
Err(err) => {
|
||||
if let Some(cached) = cache.as_ref() {
|
||||
if cached.token.valid_token().is_ok() {
|
||||
tracing::warn!(
|
||||
error = %format!("{err:#}"),
|
||||
repo = %self.repo,
|
||||
generation = cached.generation,
|
||||
expires_at = %cached.token.expires_at,
|
||||
"GitHub installation token refresh failed; using cached token"
|
||||
);
|
||||
let mut resolved = cached.resolved(TokenProvenance::Reused {
|
||||
minted_at: cached.minted_at,
|
||||
expires_at: cached.token.expires_at,
|
||||
});
|
||||
resolved.refresh_failed = true;
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -329,11 +382,12 @@ impl InstallationTokenSource {
|
|||
SourceState::App { .. } => unreachable!("resolve_static called for App credentials"),
|
||||
};
|
||||
Ok(ResolvedToken {
|
||||
token: secret,
|
||||
snapshot: TokenSnapshot {
|
||||
token: secret,
|
||||
snapshot: TokenSnapshot {
|
||||
generation: 0,
|
||||
provenance: TokenProvenance::Static,
|
||||
},
|
||||
refresh_failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -520,6 +574,27 @@ mod tests {
|
|||
assert_eq!(second.token.expose(), "ghs_gen2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_uses_a_valid_cached_token_when_refresh_fails() {
|
||||
let (source, minter) = mintable(vec![
|
||||
MintAction::Token("ghs_gen1", Utc::now() + chrono::Duration::minutes(5)),
|
||||
MintAction::Error("mint failed"),
|
||||
]);
|
||||
|
||||
let first = source.resolve().await.unwrap();
|
||||
let second = source.resolve().await.unwrap();
|
||||
|
||||
assert_eq!(minter.calls(), 2);
|
||||
assert_eq!(first.snapshot.generation, 1);
|
||||
assert_eq!(second.snapshot.generation, 1);
|
||||
assert!(second.refresh_failed);
|
||||
assert!(matches!(
|
||||
second.snapshot.provenance,
|
||||
TokenProvenance::Reused { .. }
|
||||
));
|
||||
assert_eq!(second.token.expose(), "ghs_gen1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_resolves_share_one_generation() {
|
||||
// Single mint in the script: a second mint would panic on an empty
|
||||
|
|
|
|||
|
|
@ -355,7 +355,14 @@ pub fn error_from_status_code(
|
|||
// error types
|
||||
let kind = match status_code {
|
||||
401 => ProviderErrorKind::Authentication,
|
||||
403 => ProviderErrorKind::AccessDenied,
|
||||
// A 412 is never about the request: no LLM request carries
|
||||
// conditional-request preconditions. Fireworks documents it as
|
||||
// "Account is suspended or there's an issue with account status",
|
||||
// also emitted for a LoRA model that failed to load
|
||||
// (https://docs.fireworks.ai/guides/inference-error-codes). The same
|
||||
// family as `account_deactivated`: deterministic here, but another
|
||||
// provider has independent billing and model inventory.
|
||||
403 | 412 => ProviderErrorKind::AccessDenied,
|
||||
404 => ProviderErrorKind::NotFound,
|
||||
408 => {
|
||||
return Error::RequestTimeout {
|
||||
|
|
@ -728,6 +735,53 @@ mod tests {
|
|||
assert_eq!(err.provider_kind(), Some(ProviderErrorKind::QuotaExceeded));
|
||||
}
|
||||
|
||||
/// Fireworks reports an account suspension (spending cap reached or
|
||||
/// unpaid invoices) as HTTP 412 with `code: "PRECONDITION_FAILED"` in
|
||||
/// the body. A chat completion carries no conditional-request
|
||||
/// preconditions, so a 412 is always an account-level lockout, never a
|
||||
/// defect in the request: it must not classify as `InvalidRequest`, and
|
||||
/// a fallback provider with independent billing must stay eligible.
|
||||
#[test]
|
||||
fn account_suspension_412_is_failover_eligible() {
|
||||
let err = error_from_status_code(
|
||||
412,
|
||||
"Account lithoscomputer is suspended, possibly due to reaching \
|
||||
the monthly spending limit or failure to pay past invoices."
|
||||
.into(),
|
||||
"fireworks".into(),
|
||||
// The openai_compatible dialect reads `error.type` as the code,
|
||||
// so the discriminating `PRECONDITION_FAILED` only reaches this
|
||||
// mapping through the status code.
|
||||
Some("error".into()),
|
||||
Some(serde_json::json!({
|
||||
"error": {
|
||||
"message": "Account lithoscomputer is suspended, possibly due to reaching the monthly spending limit or failure to pay past invoices. Please go to https://fireworks.ai/account/billing for more information.",
|
||||
"param": null,
|
||||
"code": "PRECONDITION_FAILED",
|
||||
"type": "error"
|
||||
},
|
||||
"request_id": "chatcmpl-d9652b89a6604931ac27dddd5ef5bdc0"
|
||||
})),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(err.provider_kind(), Some(ProviderErrorKind::AccessDenied));
|
||||
assert!(!err.retryable());
|
||||
assert!(err.failover_eligible());
|
||||
|
||||
// A bare 412 with no parseable body classifies the same way.
|
||||
let err = error_from_status_code(
|
||||
412,
|
||||
"Precondition Failed".into(),
|
||||
"fireworks".into(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(err.provider_kind(), Some(ProviderErrorKind::AccessDenied));
|
||||
assert!(err.failover_eligible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_from_error_code_covers_every_dialect() {
|
||||
for (code, expected) in [
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
|
|
@ -63,22 +64,38 @@ pub async fn run_basic_model_probe(
|
|||
model_id: &str,
|
||||
provider: impl ToString,
|
||||
client: Arc<Client>,
|
||||
) -> ModelTestOutcome {
|
||||
run_basic_model_probe_with_timeout(
|
||||
model_id,
|
||||
provider,
|
||||
client,
|
||||
Duration::from_secs(ModelTestMode::Basic.timeout_secs()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn run_basic_model_probe_with_timeout(
|
||||
model_id: &str,
|
||||
provider: impl ToString,
|
||||
client: Arc<Client>,
|
||||
probe_timeout: Duration,
|
||||
) -> ModelTestOutcome {
|
||||
let params = GenerateParams::new(model_id, client)
|
||||
.provider(provider.to_string())
|
||||
.prompt("Say OK")
|
||||
.max_tokens(16);
|
||||
|
||||
let result = time::timeout(
|
||||
Duration::from_secs(ModelTestMode::Basic.timeout_secs()),
|
||||
generate::generate(params),
|
||||
)
|
||||
.await;
|
||||
basic_model_probe_outcome(generate::generate(params), probe_timeout).await
|
||||
}
|
||||
|
||||
match result {
|
||||
async fn basic_model_probe_outcome<F>(probe: F, probe_timeout: Duration) -> ModelTestOutcome
|
||||
where
|
||||
F: Future<Output = Result<GenerateResult, crate::Error>>,
|
||||
{
|
||||
match time::timeout(probe_timeout, probe).await {
|
||||
Ok(Ok(_)) => ModelTestOutcome::ok(),
|
||||
Ok(Err(err)) => ModelTestOutcome::error(err.to_string()),
|
||||
Err(_) => ModelTestOutcome::error("timeout (30s)"),
|
||||
Err(_) => ModelTestOutcome::error(format!("timeout ({probe_timeout:?})")),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -244,6 +261,18 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn basic_model_probe_reports_configured_timeout() {
|
||||
let outcome = basic_model_probe_outcome(
|
||||
std::future::pending::<Result<GenerateResult, crate::Error>>(),
|
||||
Duration::from_millis(1),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome.status, ModelTestStatus::Error);
|
||||
assert_eq!(outcome.error_message.as_deref(), Some("timeout (1ms)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_test_omits_effort_for_reasoning_without_effort_controls() {
|
||||
let info = test_model_with(ModelFeatures {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ futures-util = { workspace = true, optional = true }
|
|||
rustls = { version = "0.23", default-features = false, features = ["std", "ring"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
fabro-github = { path = "../fabro-github", features = ["test-support"] }
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
tempfile = "3"
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fmt::Write;
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
|
@ -64,12 +65,24 @@ pub const DEFAULT_DAYTONA_API_URL: &str = "https://app.daytona.io/api";
|
|||
pub(crate) const DAYTONA_DASHBOARD_SANDBOXES_URL: &str =
|
||||
"https://app.daytona.io/dashboard/sandboxes";
|
||||
const FABRO_SANDBOX_USER_AGENT: &str = concat!("fabro-sandbox/", env!("CARGO_PKG_VERSION"));
|
||||
const DAYTONA_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const DAYTONA_START_TIMEOUT: Duration = Duration::from_mins(1);
|
||||
pub const DAYTONA_CREDENTIAL_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const DAYTONA_BASH_SESSION_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
/// Upper bound on explicit and Drop-triggered Daytona cleanup calls (session
|
||||
/// deletion, temporary stdin files) so a stalled REST call cannot block
|
||||
/// cancellation/timeout paths indefinitely.
|
||||
const DAYTONA_CLEANUP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Budget for waiting out an in-flight Daytona lifecycle transition (for
|
||||
/// example an auto-stop racing an activation) before giving up. Transitions
|
||||
/// normally finish within seconds; the budget only bounds a wedged sandbox.
|
||||
const DAYTONA_STATE_CHANGE_TIMEOUT: Duration = Duration::from_mins(2);
|
||||
/// Poll interval while waiting out an in-flight Daytona lifecycle transition.
|
||||
const DAYTONA_STATE_CHANGE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
/// Auto-stop applied when `lifecycle.auto_stop` is unset. Omitting the field
|
||||
/// would inherit Daytona's server-side default of 15 idle minutes, which is
|
||||
/// shorter than a single long inference call and stops the sandbox mid-run;
|
||||
/// 120 minutes clears any realistic call while still reclaiming sandboxes
|
||||
/// leaked by a dead worker. An explicit `0` disables auto-stop entirely.
|
||||
const DEFAULT_AUTO_STOP_INTERVAL_MINUTES: i32 = 120;
|
||||
|
||||
/// Permissions a Daytona API key needs for Fabro's snapshot and sandbox flow.
|
||||
pub const REQUIRED_DAYTONA_PERMISSIONS: &[Permissions] = &[
|
||||
|
|
@ -158,6 +171,24 @@ pub struct DaytonaKeyCheck {
|
|||
pub missing: Vec<Permissions>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("Daytona credential probe timed out after {timeout:?}")]
|
||||
pub struct DaytonaCredentialProbeTimeout {
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl DaytonaCredentialProbeTimeout {
|
||||
#[must_use]
|
||||
pub const fn new(timeout: Duration) -> Self {
|
||||
Self { timeout }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn timeout(&self) -> Duration {
|
||||
self.timeout
|
||||
}
|
||||
}
|
||||
|
||||
impl DaytonaKeyCheck {
|
||||
pub fn ok(&self) -> bool {
|
||||
self.missing.is_empty()
|
||||
|
|
@ -255,6 +286,23 @@ pub async fn check_daytona_api_key_with(
|
|||
org_id: Option<&str>,
|
||||
api_key: String,
|
||||
http_client: fabro_http::HttpClient,
|
||||
) -> anyhow::Result<DaytonaKeyCheck> {
|
||||
check_daytona_api_key_with_timeout(
|
||||
base_url,
|
||||
org_id,
|
||||
api_key,
|
||||
http_client,
|
||||
DAYTONA_CREDENTIAL_PROBE_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn check_daytona_api_key_with_timeout(
|
||||
base_url: &str,
|
||||
org_id: Option<&str>,
|
||||
api_key: String,
|
||||
http_client: fabro_http::HttpClient,
|
||||
probe_timeout: Duration,
|
||||
) -> anyhow::Result<DaytonaKeyCheck> {
|
||||
let work = async {
|
||||
let client = build_daytona_client_with(
|
||||
|
|
@ -289,12 +337,21 @@ pub async fn check_daytona_api_key_with(
|
|||
})
|
||||
};
|
||||
|
||||
match time::timeout(DAYTONA_PROBE_TIMEOUT, work).await {
|
||||
daytona_credential_probe_with_timeout(work, probe_timeout).await
|
||||
}
|
||||
|
||||
async fn daytona_credential_probe_with_timeout<F>(
|
||||
probe: F,
|
||||
probe_timeout: Duration,
|
||||
) -> anyhow::Result<DaytonaKeyCheck>
|
||||
where
|
||||
F: Future<Output = anyhow::Result<DaytonaKeyCheck>>,
|
||||
{
|
||||
match time::timeout(probe_timeout, probe).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(anyhow::anyhow!(
|
||||
"Daytona credential probe timed out after {}s",
|
||||
DAYTONA_PROBE_TIMEOUT.as_secs()
|
||||
)),
|
||||
Err(_) => Err(anyhow::Error::new(DaytonaCredentialProbeTimeout::new(
|
||||
probe_timeout,
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -331,12 +388,38 @@ fn command_kind(command: &str) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, strum::Display)]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
enum DaytonaLifecycleAction {
|
||||
Start,
|
||||
Stop,
|
||||
}
|
||||
|
||||
impl DaytonaLifecycleAction {
|
||||
async fn execute(
|
||||
self,
|
||||
client: &daytona_sdk::Client,
|
||||
sandbox_name: &str,
|
||||
) -> Result<(), DaytonaError> {
|
||||
match self {
|
||||
Self::Start => client.start(sandbox_name).await.map(drop),
|
||||
Self::Stop => client.stop(sandbox_name).await.map(drop),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_complete(self, state: Option<SandboxState>) -> bool {
|
||||
match self {
|
||||
Self::Start => state == Some(SandboxState::Started),
|
||||
Self::Stop => matches!(state, Some(SandboxState::Stopped | SandboxState::Destroyed)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sandbox that runs all operations inside a Daytona cloud sandbox.
|
||||
pub struct DaytonaSandbox {
|
||||
config: DaytonaConfig,
|
||||
client: daytona_sdk::Client,
|
||||
api_key: Option<String>,
|
||||
github_app: Option<GitHubCredentials>,
|
||||
push_credentials: PushCredentialState,
|
||||
sandbox: OnceCell<daytona_sdk::Sandbox>,
|
||||
snapshot_name: OnceCell<String>,
|
||||
|
|
@ -379,7 +462,6 @@ impl DaytonaSandbox {
|
|||
config,
|
||||
client,
|
||||
api_key,
|
||||
github_app,
|
||||
push_credentials,
|
||||
sandbox: OnceCell::new(),
|
||||
snapshot_name: OnceCell::new(),
|
||||
|
|
@ -432,7 +514,6 @@ impl DaytonaSandbox {
|
|||
config: DaytonaConfig::default(),
|
||||
client,
|
||||
api_key,
|
||||
github_app: None,
|
||||
push_credentials: PushCredentialState::new(None),
|
||||
sandbox: sandbox_cell,
|
||||
snapshot_name: OnceCell::new(),
|
||||
|
|
@ -520,6 +601,19 @@ impl DaytonaSandbox {
|
|||
resolve_path(path, self.working_directory())
|
||||
}
|
||||
|
||||
async fn upload_file_content(&self, resolved_path: &str, content: &str) -> crate::Result<()> {
|
||||
let sandbox = self.sandbox()?;
|
||||
let fs_svc = sandbox
|
||||
.fs()
|
||||
.await
|
||||
.map_err(|e| crate::Error::context("Failed to get fs service", e))?;
|
||||
|
||||
fs_svc
|
||||
.upload_file_bytes(resolved_path, content.as_bytes())
|
||||
.await
|
||||
.map_err(|e| crate::Error::context(format!("Failed to write file {resolved_path}"), e))
|
||||
}
|
||||
|
||||
/// Verify a Daytona sandbox evaluates commands as non-login Bash.
|
||||
///
|
||||
/// Runs on a freshly created sandbox before any Fabro-owned setup, and
|
||||
|
|
@ -585,16 +679,16 @@ impl DaytonaSandbox {
|
|||
/// non-POSIX, and completion assertions all hold.
|
||||
///
|
||||
/// Costs one session round trip plus a single status poll per sandbox
|
||||
/// lifecycle transition. `DAYTONA_PROBE_TIMEOUT` is the outer backstop for
|
||||
/// a stalled REST call; the inner [`BASH_PROBE_TIMEOUT_MS`] is the deadline
|
||||
/// for the command itself. Session cleanup runs outside that deadline under
|
||||
/// its own bounded timeout.
|
||||
/// lifecycle transition. `DAYTONA_BASH_SESSION_PROBE_TIMEOUT` is the outer
|
||||
/// backstop for a stalled REST call; the inner [`BASH_PROBE_TIMEOUT_MS`] is
|
||||
/// the deadline for the command itself. Session cleanup runs outside that
|
||||
/// deadline under its own bounded timeout.
|
||||
async fn probe_bash_session(sandbox: &daytona_sdk::Sandbox) -> crate::Result<()> {
|
||||
let deadline = time::Instant::now() + DAYTONA_PROBE_TIMEOUT;
|
||||
let deadline = time::Instant::now() + DAYTONA_BASH_SESSION_PROBE_TIMEOUT;
|
||||
let timeout_error = || {
|
||||
crate::Error::message(format!(
|
||||
"Daytona Bash session check timed out after {}s",
|
||||
DAYTONA_PROBE_TIMEOUT.as_secs()
|
||||
DAYTONA_BASH_SESSION_PROBE_TIMEOUT.as_secs()
|
||||
))
|
||||
};
|
||||
let mut session = match time::timeout_at(deadline, DaytonaSession::create(sandbox)).await {
|
||||
|
|
@ -736,7 +830,10 @@ impl DaytonaSandbox {
|
|||
daytona_sdk::SandboxBaseParams {
|
||||
name: Some(name),
|
||||
env_vars: Some(clean_bash_env(None)),
|
||||
auto_stop_interval: self.config.auto_stop_interval,
|
||||
auto_stop_interval: self
|
||||
.config
|
||||
.auto_stop_interval
|
||||
.or(Some(DEFAULT_AUTO_STOP_INTERVAL_MINUTES)),
|
||||
labels: Some(managed_labels::merge_for_run(
|
||||
self.config.labels.as_ref(),
|
||||
self.run_id.as_ref(),
|
||||
|
|
@ -861,6 +958,157 @@ impl DaytonaSandbox {
|
|||
"Timed out waiting for snapshot '{name}' to become active"
|
||||
)))
|
||||
}
|
||||
|
||||
async fn wait_for_stable_state(
|
||||
&self,
|
||||
sandbox_name: &str,
|
||||
) -> Result<Option<SandboxState>, DaytonaError> {
|
||||
loop {
|
||||
time::sleep(DAYTONA_STATE_CHANGE_POLL_INTERVAL).await;
|
||||
let state = self.client.get(sandbox_name).await?.state;
|
||||
if !is_transitional_state(state) {
|
||||
return Ok(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_lifecycle_action(
|
||||
&self,
|
||||
sandbox_name: &str,
|
||||
action: DaytonaLifecycleAction,
|
||||
deadline: time::Instant,
|
||||
) -> crate::Result<()> {
|
||||
loop {
|
||||
let request = time::timeout_at(deadline, action.execute(&self.client, sandbox_name));
|
||||
match request.await {
|
||||
Ok(Ok(())) => return Ok(()),
|
||||
Ok(Err(source)) if is_state_change_in_progress(&source) => {
|
||||
tracing::debug!(
|
||||
action = %action,
|
||||
sandbox = sandbox_name,
|
||||
"Daytona lifecycle request rejected during state change"
|
||||
);
|
||||
match time::timeout_at(deadline, self.wait_for_stable_state(sandbox_name)).await
|
||||
{
|
||||
Ok(Ok(state)) if action.is_complete(state) => return Ok(()),
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(wait_source)) => {
|
||||
return Err(crate::Error::context(
|
||||
format!(
|
||||
"Failed to inspect Daytona sandbox while waiting to {action}"
|
||||
),
|
||||
wait_source,
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(crate::Error::context(
|
||||
format!("Timed out waiting to {action} Daytona sandbox"),
|
||||
source,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(source)) => {
|
||||
return Err(crate::Error::context(
|
||||
format!("Failed to {action} Daytona sandbox"),
|
||||
source,
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(crate::Error::message(format!(
|
||||
"Timed out waiting to {action} Daytona sandbox"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_error(&self, error: crate::Error) -> crate::Result<()> {
|
||||
self.emit(SandboxEvent::StartFailed {
|
||||
provider: "daytona".into(),
|
||||
error: error.to_string(),
|
||||
causes: error.causes(),
|
||||
});
|
||||
Err(error)
|
||||
}
|
||||
|
||||
fn stop_error(&self, error: crate::Error) -> crate::Result<()> {
|
||||
self.emit(SandboxEvent::StopFailed {
|
||||
provider: "daytona".into(),
|
||||
error: error.to_string(),
|
||||
causes: error.causes(),
|
||||
});
|
||||
Err(error)
|
||||
}
|
||||
|
||||
async fn start_with_deadline(&self, deadline: time::Instant) -> crate::Result<()> {
|
||||
self.emit(SandboxEvent::StartStarted {
|
||||
provider: "daytona".into(),
|
||||
});
|
||||
let start = Instant::now();
|
||||
let result = async {
|
||||
let sandbox = self.sandbox()?;
|
||||
self.run_lifecycle_action(&sandbox.name, DaytonaLifecycleAction::Start, deadline)
|
||||
.await?;
|
||||
Self::probe_bash(sandbox).await
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
return self.start_error(error);
|
||||
}
|
||||
self.emit(SandboxEvent::StartCompleted {
|
||||
provider: "daytona".into(),
|
||||
duration_ms: elapsed_ms(start),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop_with_deadline(&self, deadline: time::Instant) -> crate::Result<()> {
|
||||
self.emit(SandboxEvent::StopStarted {
|
||||
provider: "daytona".into(),
|
||||
});
|
||||
let start = Instant::now();
|
||||
let result = async {
|
||||
let sandbox = self.sandbox()?;
|
||||
self.run_lifecycle_action(&sandbox.name, DaytonaLifecycleAction::Stop, deadline)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
return self.stop_error(error);
|
||||
}
|
||||
self.emit(SandboxEvent::StopCompleted {
|
||||
provider: "daytona".into(),
|
||||
duration_ms: elapsed_ms(start),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_state_change_in_progress(err: &DaytonaError) -> bool {
|
||||
err.status_code() == Some(400)
|
||||
&& err
|
||||
.message()
|
||||
.to_ascii_lowercase()
|
||||
.contains("state change in progress")
|
||||
}
|
||||
|
||||
fn is_transitional_state(state: Option<SandboxState>) -> bool {
|
||||
matches!(
|
||||
state,
|
||||
Some(
|
||||
SandboxState::Creating
|
||||
| SandboxState::Restoring
|
||||
| SandboxState::Destroying
|
||||
| SandboxState::Starting
|
||||
| SandboxState::Stopping
|
||||
| SandboxState::PendingBuild
|
||||
| SandboxState::BuildingSnapshot
|
||||
| SandboxState::PullingSnapshot
|
||||
| SandboxState::Archiving
|
||||
| SandboxState::Resizing
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// Detect the git remote URL and current branch from a local repository.
|
||||
|
|
@ -1072,10 +1320,11 @@ impl Sandbox for DaytonaSandbox {
|
|||
// against the clone token instead of believing nothing was
|
||||
// ever embedded.
|
||||
let resolved_token = match self.push_credentials.source() {
|
||||
Some(source) => Some(source.mint_for_clone().await.map_err(|e| {
|
||||
let err = crate::Error::message(format!(
|
||||
"Failed to get GitHub App credentials for clone: {e}"
|
||||
));
|
||||
Some(source) => Some(source.mint_for_clone().await.map_err(|source| {
|
||||
let err = crate::Error::context_anyhow(
|
||||
"Failed to get GitHub App credentials for clone",
|
||||
source,
|
||||
);
|
||||
self.emit(SandboxEvent::GitCloneFailed {
|
||||
url: origin_url.clone(),
|
||||
error: err.to_string(),
|
||||
|
|
@ -1297,7 +1546,7 @@ impl Sandbox for DaytonaSandbox {
|
|||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
origin = %origin_url,
|
||||
origin = %fabro_redact::redacted_url_for_log(&origin_url),
|
||||
error = %e,
|
||||
"Failed to build authenticated origin URL — \
|
||||
subsequent git push from this sandbox will fail"
|
||||
|
|
@ -1306,7 +1555,7 @@ impl Sandbox for DaytonaSandbox {
|
|||
}
|
||||
}
|
||||
}
|
||||
Err(e) if self.github_app.is_none() => {
|
||||
Err(e) if self.push_credentials.source().is_none() => {
|
||||
let err = crate::Error::context(
|
||||
"Git clone failed. If this is a private repository, \
|
||||
configure a GitHub App with `fabro install` and install it \
|
||||
|
|
@ -1356,76 +1605,47 @@ impl Sandbox for DaytonaSandbox {
|
|||
}
|
||||
|
||||
async fn start(&self) -> crate::Result<()> {
|
||||
self.emit(SandboxEvent::StartStarted {
|
||||
provider: "daytona".into(),
|
||||
});
|
||||
let start = Instant::now();
|
||||
let sandbox = self.sandbox()?;
|
||||
if let Err(e) = self.client.start(&sandbox.name).await {
|
||||
let err = crate::Error::context("Failed to start Daytona sandbox", e);
|
||||
self.emit(SandboxEvent::StartFailed {
|
||||
provider: "daytona".into(),
|
||||
error: err.to_string(),
|
||||
causes: err.causes(),
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
if let Err(err) = Self::probe_bash(sandbox).await {
|
||||
self.emit(SandboxEvent::StartFailed {
|
||||
provider: "daytona".into(),
|
||||
error: err.to_string(),
|
||||
causes: err.causes(),
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
let duration_ms = elapsed_ms(start);
|
||||
self.emit(SandboxEvent::StartCompleted {
|
||||
provider: "daytona".into(),
|
||||
duration_ms,
|
||||
});
|
||||
Ok(())
|
||||
self.start_with_deadline(time::Instant::now() + DAYTONA_STATE_CHANGE_TIMEOUT)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn activate(&self) -> crate::Result<()> {
|
||||
let sandbox = self.sandbox()?;
|
||||
let current = self.client.get(&sandbox.name).await.map_err(|e| {
|
||||
crate::Error::context("Failed to inspect Daytona sandbox before activation", e)
|
||||
})?;
|
||||
if current.state == Some(SandboxState::Started) {
|
||||
let deadline = time::Instant::now() + DAYTONA_STATE_CHANGE_TIMEOUT;
|
||||
let current = time::timeout_at(deadline, self.client.get(&sandbox.name))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
crate::Error::message("Timed out inspecting Daytona sandbox before activation")
|
||||
})?
|
||||
.map_err(|e| {
|
||||
crate::Error::context("Failed to inspect Daytona sandbox before activation", e)
|
||||
})?;
|
||||
let state = if is_transitional_state(current.state) {
|
||||
time::timeout_at(deadline, self.wait_for_stable_state(&sandbox.name))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
crate::Error::message(
|
||||
"Timed out waiting for Daytona sandbox state change before activation",
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
crate::Error::context(
|
||||
"Failed to wait for Daytona sandbox state change before activation",
|
||||
e,
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
current.state
|
||||
};
|
||||
if state == Some(SandboxState::Started) {
|
||||
return Ok(());
|
||||
}
|
||||
if current.state == Some(SandboxState::Starting) {
|
||||
return current
|
||||
.wait_for_start(Some(DAYTONA_START_TIMEOUT))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::Error::context("Failed to wait for Daytona sandbox activation", e)
|
||||
});
|
||||
}
|
||||
self.start().await
|
||||
self.start_with_deadline(deadline).await
|
||||
}
|
||||
|
||||
async fn stop(&self) -> crate::Result<()> {
|
||||
self.emit(SandboxEvent::StopStarted {
|
||||
provider: "daytona".into(),
|
||||
});
|
||||
let start = Instant::now();
|
||||
let sandbox = self.sandbox()?;
|
||||
if let Err(e) = self.client.stop(&sandbox.name).await {
|
||||
let err = crate::Error::context("Failed to stop Daytona sandbox", e);
|
||||
self.emit(SandboxEvent::StopFailed {
|
||||
provider: "daytona".into(),
|
||||
error: err.to_string(),
|
||||
causes: err.causes(),
|
||||
});
|
||||
return Err(err);
|
||||
}
|
||||
let duration_ms = elapsed_ms(start);
|
||||
self.emit(SandboxEvent::StopCompleted {
|
||||
provider: "daytona".into(),
|
||||
duration_ms,
|
||||
});
|
||||
Ok(())
|
||||
self.stop_with_deadline(time::Instant::now() + DAYTONA_STATE_CHANGE_TIMEOUT)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete(&self) -> crate::Result<()> {
|
||||
|
|
@ -1548,6 +1768,7 @@ impl Sandbox for DaytonaSandbox {
|
|||
Ok(Some((preview.url, headers)))
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "git_op", skip_all, fields(op = "refresh-credentials"))]
|
||||
async fn refresh_push_credentials(&self) -> crate::Result<RefreshOutcome> {
|
||||
if !self.repo_cloned() {
|
||||
return Ok(RefreshOutcome::none());
|
||||
|
|
@ -1611,17 +1832,12 @@ impl Sandbox for DaytonaSandbox {
|
|||
}
|
||||
}
|
||||
|
||||
let fs_svc = sandbox
|
||||
.fs()
|
||||
.await
|
||||
.map_err(|e| crate::Error::context("Failed to get fs service", e))?;
|
||||
self.upload_file_content(&resolved, content).await
|
||||
}
|
||||
|
||||
fs_svc
|
||||
.upload_file_bytes(&resolved, content.as_bytes())
|
||||
.await
|
||||
.map_err(|e| crate::Error::context(format!("Failed to write file {resolved}"), e))?;
|
||||
|
||||
Ok(())
|
||||
async fn write_existing_file(&self, path: &str, content: &str) -> crate::Result<()> {
|
||||
let resolved = self.resolve_path(path);
|
||||
self.upload_file_content(&resolved, content).await
|
||||
}
|
||||
|
||||
async fn delete_file(&self, path: &str) -> crate::Result<()> {
|
||||
|
|
@ -2751,7 +2967,6 @@ mod tests {
|
|||
config,
|
||||
client,
|
||||
api_key: Some(api_key.to_string()),
|
||||
github_app: None,
|
||||
push_credentials: PushCredentialState::new(None),
|
||||
sandbox: OnceCell::new(),
|
||||
snapshot_name: OnceCell::new(),
|
||||
|
|
@ -2946,6 +3161,10 @@ mod tests {
|
|||
|
||||
assert_eq!(params.ephemeral, Some(false));
|
||||
assert_eq!(params.auto_delete_interval, Some(-1));
|
||||
assert_eq!(
|
||||
params.auto_stop_interval,
|
||||
Some(DEFAULT_AUTO_STOP_INTERVAL_MINUTES)
|
||||
);
|
||||
assert_eq!(
|
||||
params.env_vars,
|
||||
Some(HashMap::from([(BASH_ENV_VAR.to_string(), String::new())]))
|
||||
|
|
@ -2959,6 +3178,27 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn base_params_passes_explicit_auto_stop_through() {
|
||||
for interval in [0, 45] {
|
||||
let sandbox = DaytonaSandbox::new(
|
||||
DaytonaConfig {
|
||||
auto_stop_interval: Some(interval),
|
||||
..DaytonaConfig::default()
|
||||
},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("dtn_test".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("sandbox config should be valid");
|
||||
|
||||
assert_eq!(sandbox.base_params().auto_stop_interval, Some(interval));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activate_skips_start_when_daytona_reports_started() {
|
||||
let server = MockServer::start_async().await;
|
||||
|
|
@ -3060,6 +3300,201 @@ mod tests {
|
|||
start_sandbox.assert_calls_async(0).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activate_waits_out_a_stop_in_progress() {
|
||||
let server = MockServer::start_async().await;
|
||||
let response_count = Arc::new(AtomicU32::new(0));
|
||||
let get_sandbox = server
|
||||
.mock_async({
|
||||
let response_count = Arc::clone(&response_count);
|
||||
move |when, then| {
|
||||
when.method(GET)
|
||||
.path("/sandbox/test-sandbox")
|
||||
.header("authorization", "Bearer dtn_test");
|
||||
then.respond_with(move |_| {
|
||||
let state = if response_count.fetch_add(1, Ordering::Relaxed) == 1 {
|
||||
SandboxState::Stopping
|
||||
} else {
|
||||
SandboxState::Started
|
||||
};
|
||||
HttpMockResponse::builder()
|
||||
.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(sandbox_body("test-sandbox", state).to_string())
|
||||
.build()
|
||||
});
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let start_sandbox = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/sandbox/test-sandbox/start")
|
||||
.header("authorization", "Bearer dtn_test");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(sandbox_body("test-sandbox", SandboxState::Started));
|
||||
})
|
||||
.await;
|
||||
let sandbox = mock_daytona_sandbox(&server, "dtn_test", DaytonaConfig::default()).await;
|
||||
let sdk_sandbox = sandbox
|
||||
.client
|
||||
.get("test-sandbox")
|
||||
.await
|
||||
.expect("test sandbox should load");
|
||||
sandbox
|
||||
.sandbox
|
||||
.set(sdk_sandbox)
|
||||
.expect("test sandbox should initialize once");
|
||||
|
||||
let get_calls_before = get_sandbox.calls_async().await;
|
||||
sandbox
|
||||
.activate()
|
||||
.await
|
||||
.expect("an in-progress stop should be waited out");
|
||||
|
||||
assert_eq!(get_sandbox.calls_async().await, get_calls_before + 2);
|
||||
start_sandbox.assert_calls_async(0).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_succeeds_when_a_pending_auto_stop_finishes_first() {
|
||||
let server = MockServer::start_async().await;
|
||||
let response_count = Arc::new(AtomicU32::new(0));
|
||||
let get_sandbox = server
|
||||
.mock_async({
|
||||
let response_count = Arc::clone(&response_count);
|
||||
move |when, then| {
|
||||
when.method(GET)
|
||||
.path("/sandbox/test-sandbox")
|
||||
.header("authorization", "Bearer dtn_test");
|
||||
then.respond_with(move |_| {
|
||||
let state = if response_count.fetch_add(1, Ordering::Relaxed) == 0 {
|
||||
SandboxState::Started
|
||||
} else {
|
||||
SandboxState::Stopped
|
||||
};
|
||||
HttpMockResponse::builder()
|
||||
.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(sandbox_body("test-sandbox", state).to_string())
|
||||
.build()
|
||||
});
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let stop_sandbox = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/sandbox/test-sandbox/stop")
|
||||
.header("authorization", "Bearer dtn_test");
|
||||
then.status(400)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"message": "Sandbox state change in progress",
|
||||
"statusCode": 400
|
||||
}));
|
||||
})
|
||||
.await;
|
||||
let sandbox = mock_daytona_sandbox(&server, "dtn_test", DaytonaConfig::default()).await;
|
||||
let sdk_sandbox = sandbox
|
||||
.client
|
||||
.get("test-sandbox")
|
||||
.await
|
||||
.expect("test sandbox should load");
|
||||
sandbox
|
||||
.sandbox
|
||||
.set(sdk_sandbox)
|
||||
.expect("test sandbox should initialize once");
|
||||
|
||||
let get_calls_before = get_sandbox.calls_async().await;
|
||||
sandbox
|
||||
.stop()
|
||||
.await
|
||||
.expect("a stop already in flight should count as stopped");
|
||||
|
||||
stop_sandbox.assert_calls_async(1).await;
|
||||
assert_eq!(get_sandbox.calls_async().await, get_calls_before + 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_surfaces_state_change_rejection_after_the_deadline() {
|
||||
let server = MockServer::start_async().await;
|
||||
let _get_sandbox = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/sandbox/test-sandbox")
|
||||
.header("authorization", "Bearer dtn_test");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(sandbox_body("test-sandbox", SandboxState::Stopping));
|
||||
})
|
||||
.await;
|
||||
let start_sandbox = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/sandbox/test-sandbox/start")
|
||||
.header("authorization", "Bearer dtn_test");
|
||||
then.status(400)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"message": "Sandbox state change in progress",
|
||||
"statusCode": 400
|
||||
}));
|
||||
})
|
||||
.await;
|
||||
let sandbox = mock_daytona_sandbox(&server, "dtn_test", DaytonaConfig::default()).await;
|
||||
let sdk_sandbox = sandbox
|
||||
.client
|
||||
.get("test-sandbox")
|
||||
.await
|
||||
.expect("test sandbox should load");
|
||||
sandbox
|
||||
.sandbox
|
||||
.set(sdk_sandbox)
|
||||
.expect("test sandbox should initialize once");
|
||||
|
||||
let err = sandbox
|
||||
.start_with_deadline(time::Instant::now() + Duration::from_millis(1500))
|
||||
.await
|
||||
.expect_err("a state change that outlives the deadline should fail");
|
||||
|
||||
assert_eq!(
|
||||
start_sandbox.calls_async().await,
|
||||
1,
|
||||
"start should not be retried while the current transition is in flight"
|
||||
);
|
||||
assert!(
|
||||
err.causes().iter().any(|cause| cause
|
||||
.to_ascii_lowercase()
|
||||
.contains("state change in progress")),
|
||||
"error should carry the Daytona rejection: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_change_in_progress_matcher_ignores_case_and_context() {
|
||||
assert!(is_state_change_in_progress(&DaytonaError::api(
|
||||
400,
|
||||
"Sandbox state change in progress"
|
||||
)));
|
||||
assert!(is_state_change_in_progress(&DaytonaError::api(
|
||||
400,
|
||||
"State Change In Progress"
|
||||
)));
|
||||
assert!(!is_state_change_in_progress(&DaytonaError::api(
|
||||
400,
|
||||
"Sandbox already started"
|
||||
)));
|
||||
assert!(!is_state_change_in_progress(&DaytonaError::api(
|
||||
500,
|
||||
"Sandbox state change in progress"
|
||||
)));
|
||||
assert!(!is_state_change_in_progress(&DaytonaError::general(
|
||||
"Sandbox state change in progress"
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn base_params_merges_managed_daytona_labels() {
|
||||
let run_id: RunId = "01HY0000000000000000000000".parse().unwrap();
|
||||
|
|
@ -3363,6 +3798,25 @@ mod tests {
|
|||
auth.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn daytona_credential_probe_reports_configured_timeout() {
|
||||
let err = daytona_credential_probe_with_timeout(
|
||||
std::future::pending::<anyhow::Result<DaytonaKeyCheck>>(),
|
||||
Duration::from_millis(1),
|
||||
)
|
||||
.await
|
||||
.expect_err("probe should time out");
|
||||
let timeout = err
|
||||
.downcast_ref::<DaytonaCredentialProbeTimeout>()
|
||||
.expect("timeout should preserve its type");
|
||||
|
||||
assert_eq!(timeout.timeout(), Duration::from_millis(1));
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"Daytona credential probe timed out after 1ms"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn daytona_stdin_file_uploads_exact_bytes_and_is_deleted() {
|
||||
let server = MockServer::start_async().await;
|
||||
|
|
@ -3437,6 +3891,62 @@ mod tests {
|
|||
delete.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_existing_file_skips_parent_directory_creation() {
|
||||
let server = MockServer::start_async().await;
|
||||
let server_url = server.base_url();
|
||||
let sandbox_response = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(GET).path("/sandbox/sandbox-edit");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(sandbox_body("sandbox-edit", SandboxState::Started));
|
||||
})
|
||||
.await;
|
||||
let toolbox_response = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/sandbox/sandbox-edit/toolbox-proxy-url");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(serde_json::json!({"url": server_url}));
|
||||
})
|
||||
.await;
|
||||
let folder = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(POST).path("/sandbox-edit/files/folder");
|
||||
then.status(200);
|
||||
})
|
||||
.await;
|
||||
let upload = server
|
||||
.mock_async(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/sandbox-edit/files/upload")
|
||||
.query_param("path", "/home/daytona/workspace/src/lib.rs")
|
||||
.body_includes("updated contents");
|
||||
then.status(200);
|
||||
})
|
||||
.await;
|
||||
|
||||
let sandbox = mock_daytona_sandbox(&server, "dtn_test", DaytonaConfig::default()).await;
|
||||
let sdk_sandbox = sandbox
|
||||
.client
|
||||
.get("sandbox-edit")
|
||||
.await
|
||||
.expect("get mock sandbox");
|
||||
assert!(sandbox.sandbox.set(sdk_sandbox).is_ok());
|
||||
|
||||
sandbox
|
||||
.write_existing_file("src/lib.rs", "updated contents")
|
||||
.await
|
||||
.expect("write existing file");
|
||||
|
||||
sandbox_response.assert_async().await;
|
||||
toolbox_response.assert_async().await;
|
||||
upload.assert_async().await;
|
||||
folder.assert_calls_async(0).await;
|
||||
}
|
||||
|
||||
/// Recover the inner command a wrapper carries, proving it survives the
|
||||
/// base64 transport byte-for-byte.
|
||||
fn decode_wrapped_command(wrapped: &str) -> String {
|
||||
|
|
|
|||
|
|
@ -134,7 +134,6 @@ impl Default for DockerSandboxOptions {
|
|||
pub struct DockerSandbox {
|
||||
docker: Docker,
|
||||
config: DockerSandboxOptions,
|
||||
github_app: Option<GitHubCredentials>,
|
||||
push_credentials: PushCredentialState,
|
||||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
|
|
@ -165,7 +164,7 @@ enum ContainerStartAction {
|
|||
impl DockerSandbox {
|
||||
pub fn new(
|
||||
config: DockerSandboxOptions,
|
||||
github_app: Option<GitHubCredentials>,
|
||||
github_app: Option<&GitHubCredentials>,
|
||||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
|
|
@ -184,19 +183,18 @@ impl DockerSandbox {
|
|||
fn with_docker_client(
|
||||
docker: Docker,
|
||||
config: DockerSandboxOptions,
|
||||
github_app: Option<GitHubCredentials>,
|
||||
github_app: Option<&GitHubCredentials>,
|
||||
run_id: Option<RunId>,
|
||||
clone_origin_url: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
) -> crate::Result<Self> {
|
||||
let push_credentials = PushCredentialState::new(push_credentials::build_token_source(
|
||||
github_app.as_ref(),
|
||||
github_app,
|
||||
clone_origin_url.as_deref(),
|
||||
)?);
|
||||
Ok(Self {
|
||||
docker,
|
||||
config,
|
||||
github_app,
|
||||
push_credentials,
|
||||
run_id,
|
||||
clone_origin_url,
|
||||
|
|
@ -725,7 +723,7 @@ impl DockerSandbox {
|
|||
) -> crate::Error {
|
||||
let error = result
|
||||
.into_exec_error_with_redactor("git clone", |output| redact_auth_url(output, auth_url));
|
||||
let message = if self.github_app.is_none() {
|
||||
let message = if self.push_credentials.source().is_none() {
|
||||
"Git clone failed. If this is a private repository, configure a GitHub App with \
|
||||
`fabro install` and install it for your organization."
|
||||
} else {
|
||||
|
|
@ -754,10 +752,8 @@ impl DockerSandbox {
|
|||
// the shared source, so the first refresh compares against the clone
|
||||
// token instead of believing nothing was ever embedded.
|
||||
let resolved_token = match self.push_credentials.source() {
|
||||
Some(source) => Some(source.mint_for_clone().await.map_err(|e| {
|
||||
crate::Error::message(format!(
|
||||
"Failed to get GitHub App credentials for clone: {e}"
|
||||
))
|
||||
Some(source) => Some(source.mint_for_clone().await.map_err(|err| {
|
||||
crate::Error::context_anyhow("Failed to get GitHub App credentials for clone", err)
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
|
@ -770,10 +766,11 @@ impl DockerSandbox {
|
|||
let auth_url = match &resolved_token {
|
||||
Some(token) => Some(
|
||||
fabro_github::embed_token_in_url(&origin_url, token.token.expose()).map_err(
|
||||
|e| {
|
||||
crate::Error::message(format!(
|
||||
"Failed to get GitHub App credentials for clone: {e}"
|
||||
))
|
||||
|err| {
|
||||
crate::Error::context_anyhow(
|
||||
"Failed to build authenticated GitHub clone URL",
|
||||
err,
|
||||
)
|
||||
},
|
||||
)?,
|
||||
),
|
||||
|
|
@ -2207,6 +2204,7 @@ impl Sandbox for DockerSandbox {
|
|||
self.origin_url.get().map(String::as_str)
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "git_op", skip_all, fields(op = "refresh-credentials"))]
|
||||
async fn refresh_push_credentials(&self) -> crate::Result<RefreshOutcome> {
|
||||
if !self.repo_cloned() {
|
||||
return Ok(RefreshOutcome::none());
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ pub enum Error {
|
|||
source: Box<dyn std::error::Error + Send + Sync + 'static>,
|
||||
},
|
||||
|
||||
#[error("{message}")]
|
||||
AnyhowContext {
|
||||
message: String,
|
||||
#[source]
|
||||
source: anyhow::Error,
|
||||
},
|
||||
|
||||
#[cfg(feature = "docker")]
|
||||
#[error("Failed to connect to Docker daemon")]
|
||||
DockerConnect {
|
||||
|
|
@ -68,6 +75,13 @@ impl Error {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn context_anyhow(message: impl Into<String>, source: anyhow::Error) -> Self {
|
||||
Self::AnyhowContext {
|
||||
message: message.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exec(label: impl Into<String>, result: ExecResult) -> Self {
|
||||
Self::Exec {
|
||||
label: label.into(),
|
||||
|
|
|
|||
|
|
@ -98,8 +98,13 @@ impl SandboxProvider for DockerSandboxProvider {
|
|||
));
|
||||
};
|
||||
|
||||
let sandbox =
|
||||
DockerSandbox::new(config, github_app, run_id, clone_origin_url, clone_branch)?;
|
||||
let sandbox = DockerSandbox::new(
|
||||
config,
|
||||
github_app.as_ref(),
|
||||
run_id,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
)?;
|
||||
sandbox.initialize().await?;
|
||||
let container_id = sandbox.container_identifier()?.to_string();
|
||||
self.get(&container_id).await?.ok_or_else(|| {
|
||||
|
|
|
|||
|
|
@ -35,18 +35,19 @@ pub(crate) fn build_token_source(
|
|||
return Ok(None);
|
||||
};
|
||||
let normalized = fabro_github::normalize_repo_origin_url(origin_url);
|
||||
if fabro_github::parse_github_owner_repo(&normalized).is_err() {
|
||||
let Ok((owner, repo)) = fabro_github::parse_github_owner_repo(&normalized) else {
|
||||
// Non-GitHub origins never clone in these providers, so there is no
|
||||
// remote to keep credentials fresh for.
|
||||
return Ok(None);
|
||||
}
|
||||
InstallationTokenSource::for_origin(
|
||||
};
|
||||
InstallationTokenSource::for_repository(
|
||||
creds,
|
||||
&normalized,
|
||||
owner,
|
||||
repo,
|
||||
serde_json::json!({ "contents": "write" }),
|
||||
)
|
||||
.map(Some)
|
||||
.map_err(|err| crate::Error::message(format!("Failed to build GitHub token source: {err:#}")))
|
||||
.map_err(|err| crate::Error::context_anyhow("Failed to build GitHub token source", err))
|
||||
}
|
||||
|
||||
/// Push-credential state one provider instance tracks for its `origin`
|
||||
|
|
@ -124,8 +125,9 @@ impl PushCredentialState {
|
|||
"GitHub token refresh failed and no credentials were ever embedded"
|
||||
);
|
||||
}
|
||||
return Err(crate::Error::message(
|
||||
"Failed to refresh push credentials: token_mint_failed",
|
||||
return Err(crate::Error::context_anyhow(
|
||||
"Failed to refresh push credentials",
|
||||
err,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
|
@ -133,22 +135,16 @@ impl PushCredentialState {
|
|||
.as_ref()
|
||||
.is_some_and(|prev| prev.snapshot.generation == resolved.snapshot.generation)
|
||||
{
|
||||
return Ok(RefreshOutcome {
|
||||
action: RemoteCredentialAction::Unchanged,
|
||||
token: Some(resolved.snapshot),
|
||||
});
|
||||
return Ok(RefreshOutcome::unchanged(resolved.snapshot));
|
||||
}
|
||||
let auth_url = fabro_github::embed_token_in_url(origin_url, resolved.token.expose())
|
||||
.map_err(|err| {
|
||||
crate::Error::message(format!("Failed to build authenticated origin URL: {err:#}"))
|
||||
crate::Error::context_anyhow("Failed to build authenticated origin URL", err)
|
||||
})?;
|
||||
set_url(auth_url).await?;
|
||||
let snapshot = resolved.snapshot;
|
||||
*embedded = Some(resolved);
|
||||
Ok(RefreshOutcome {
|
||||
action: RemoteCredentialAction::Embedded,
|
||||
token: Some(snapshot),
|
||||
})
|
||||
Ok(RefreshOutcome::embedded(snapshot))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -179,7 +175,8 @@ pub(crate) struct CredentialLease<'a> {
|
|||
source: Option<&'a InstallationTokenSource>,
|
||||
/// Embed-mutex guard: the last successfully embedded token.
|
||||
embedded: MutexGuard<'a, Option<ResolvedToken>>,
|
||||
/// The operation's single successful resolve.
|
||||
/// The operation's resolved target, including a cached fallback when a
|
||||
/// refresh mint failed.
|
||||
target: Option<ResolvedToken>,
|
||||
/// Skip an immediate duplicate resolve after lease acquisition already
|
||||
/// failed. A later push attempt can retry after backoff.
|
||||
|
|
@ -189,12 +186,10 @@ pub(crate) struct CredentialLease<'a> {
|
|||
impl PushCredentialState {
|
||||
/// Acquire the push-credential lease for one push operation.
|
||||
///
|
||||
/// Resolves the operation's target token up front, pinning one token
|
||||
/// generation for every attempt. A failed resolve still acquires the
|
||||
/// lease when an earlier operation embedded a token (the push falls back
|
||||
/// to it and [`CredentialLease::ensure_embedded`] retries the resolve on
|
||||
/// later attempts); with managed credentials but nothing ever embedded,
|
||||
/// acquisition fails — there is nothing to push with.
|
||||
/// Resolves the operation's target token up front. A failed refresh can
|
||||
/// return a valid cached token; the first attempt uses it, and
|
||||
/// [`CredentialLease::ensure_embedded`] retries the refresh after push
|
||||
/// backoff. A resolve with no cached or embedded token fails acquisition.
|
||||
pub(crate) async fn lease(&self) -> crate::Result<CredentialLease<'_>> {
|
||||
let embedded = self.embedded.lock().await;
|
||||
let Some(source) = self.source.as_deref() else {
|
||||
|
|
@ -206,12 +201,15 @@ impl PushCredentialState {
|
|||
});
|
||||
};
|
||||
match source.resolve().await {
|
||||
Ok(resolved) => Ok(CredentialLease {
|
||||
source: Some(source),
|
||||
embedded,
|
||||
target: Some(resolved),
|
||||
defer_resolve_once: false,
|
||||
}),
|
||||
Ok(resolved) => {
|
||||
let defer_resolve_once = resolved.refresh_failed;
|
||||
Ok(CredentialLease {
|
||||
source: Some(source),
|
||||
embedded,
|
||||
target: Some(resolved),
|
||||
defer_resolve_once,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
if let Some(prev) = embedded.as_ref() {
|
||||
tracing::warn!(
|
||||
|
|
@ -272,9 +270,16 @@ impl CredentialLease<'_> {
|
|||
let mut refresh_error = self.defer_resolve_once.then_some(RefreshErrorKind::Mint);
|
||||
if self.defer_resolve_once {
|
||||
self.defer_resolve_once = false;
|
||||
} else if self.target.is_none() {
|
||||
} else if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_none_or(|resolved| resolved.refresh_failed)
|
||||
{
|
||||
match source.resolve().await {
|
||||
Ok(resolved) => self.target = Some(resolved),
|
||||
Ok(resolved) => {
|
||||
refresh_error = resolved.refresh_failed.then_some(RefreshErrorKind::Mint);
|
||||
self.target = Some(resolved);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
error = %format!("{err:#}"),
|
||||
|
|
@ -391,10 +396,11 @@ mod tests {
|
|||
|
||||
use chrono::Utc;
|
||||
use fabro_github::InstallationToken;
|
||||
use fabro_github::token_source::InstallationTokenMinter;
|
||||
use fabro_github::test_support::{InstallationTokenMinter, installation_token_source};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use super::*;
|
||||
use crate::sandbox::RemoteCredentialAction;
|
||||
|
||||
struct FixedMinter {
|
||||
calls: AtomicUsize,
|
||||
|
|
@ -422,9 +428,9 @@ mod tests {
|
|||
}
|
||||
|
||||
fn minting_state(ttl: chrono::Duration) -> PushCredentialState {
|
||||
PushCredentialState::new(Some(InstallationTokenSource::with_minter(
|
||||
"owner/repo".to_string(),
|
||||
Box::new(FixedMinter {
|
||||
PushCredentialState::new(Some(installation_token_source(
|
||||
"owner/repo",
|
||||
Arc::new(FixedMinter {
|
||||
calls: AtomicUsize::new(0),
|
||||
ttl,
|
||||
}),
|
||||
|
|
@ -465,7 +471,7 @@ mod tests {
|
|||
let outcome = refresh_task.await.expect("refresh task completes");
|
||||
// The lease's resolve minted generation 1; the deferred refresh
|
||||
// reuses it (the operation never embedded, so the refresh embeds).
|
||||
assert_eq!(outcome.token.unwrap().generation, 1);
|
||||
assert_eq!(outcome.token().unwrap().generation, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -475,8 +481,7 @@ mod tests {
|
|||
.refresh(ORIGIN, |_| async { panic!("set-url must not run") })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.action, RemoteCredentialAction::None);
|
||||
assert_eq!(outcome.token, None);
|
||||
assert_eq!(outcome, RefreshOutcome::none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -492,8 +497,8 @@ mod tests {
|
|||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.action, RemoteCredentialAction::Embedded);
|
||||
assert_eq!(first.token.unwrap().generation, 1);
|
||||
assert_eq!(first.action(), RemoteCredentialAction::Embedded);
|
||||
assert_eq!(first.token().unwrap().generation, 1);
|
||||
|
||||
// The cached token is fresh, so the second refresh must skip set-url.
|
||||
let second = state
|
||||
|
|
@ -503,8 +508,8 @@ mod tests {
|
|||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.action, RemoteCredentialAction::Unchanged);
|
||||
assert_eq!(second.token.unwrap().generation, 1);
|
||||
assert_eq!(second.action(), RemoteCredentialAction::Unchanged);
|
||||
assert_eq!(second.token().unwrap().generation, 1);
|
||||
assert_eq!(set_url_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
|
|
@ -529,9 +534,9 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first.token.unwrap().generation, 1);
|
||||
assert_eq!(second.action, RemoteCredentialAction::Embedded);
|
||||
assert_eq!(second.token.unwrap().generation, 2);
|
||||
assert_eq!(first.token().unwrap().generation, 1);
|
||||
assert_eq!(second.action(), RemoteCredentialAction::Embedded);
|
||||
assert_eq!(second.token().unwrap().generation, 2);
|
||||
assert_eq!(set_url_calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
|
|
@ -545,8 +550,8 @@ mod tests {
|
|||
.refresh(ORIGIN, |_| async { panic!("set-url must not run") })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.action, RemoteCredentialAction::Unchanged);
|
||||
assert_eq!(outcome.token.unwrap().generation, 1);
|
||||
assert_eq!(outcome.action(), RemoteCredentialAction::Unchanged);
|
||||
assert_eq!(outcome.token().unwrap().generation, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -564,8 +569,8 @@ mod tests {
|
|||
// The generation was not recorded, so the retry embeds again instead
|
||||
// of wrongly skipping.
|
||||
let retried = state.refresh(ORIGIN, |_| async { Ok(()) }).await.unwrap();
|
||||
assert_eq!(retried.action, RemoteCredentialAction::Embedded);
|
||||
assert_eq!(retried.token.unwrap().generation, 1);
|
||||
assert_eq!(retried.action(), RemoteCredentialAction::Embedded);
|
||||
assert_eq!(retried.token().unwrap().generation, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -584,22 +589,25 @@ mod tests {
|
|||
.refresh(ORIGIN, |_| async { panic!("set-url must not run") })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome.action, RemoteCredentialAction::Unchanged);
|
||||
assert!(outcome.token.unwrap().is_static());
|
||||
assert_eq!(outcome.action(), RemoteCredentialAction::Unchanged);
|
||||
assert!(outcome.token().unwrap().is_static());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mint_failure_maps_to_the_token_mint_failed_error() {
|
||||
let state = PushCredentialState::new(Some(InstallationTokenSource::with_minter(
|
||||
"owner/repo".to_string(),
|
||||
Box::new(FailingMinter),
|
||||
async fn mint_failure_preserves_the_mint_error_chain() {
|
||||
let state = PushCredentialState::new(Some(installation_token_source(
|
||||
"owner/repo",
|
||||
Arc::new(FailingMinter),
|
||||
)));
|
||||
|
||||
let err = state
|
||||
.refresh(ORIGIN, |_| async { panic!("set-url must not run") })
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("token_mint_failed"), "{err}");
|
||||
assert_eq!(err.causes(), vec![
|
||||
"minting GitHub installation access token",
|
||||
"mint failed"
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1032,18 +1032,46 @@ pub struct GrepOptions {
|
|||
/// remote, and the non-secret description of the token embedded in it.
|
||||
/// `token` is `None` only when `action` is [`RemoteCredentialAction::None`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RefreshOutcome {
|
||||
pub action: RemoteCredentialAction,
|
||||
pub token: Option<TokenSnapshot>,
|
||||
pub enum RefreshOutcome {
|
||||
/// No managed credentials exist for this sandbox.
|
||||
None,
|
||||
/// The remote already carried this token generation.
|
||||
Unchanged(TokenSnapshot),
|
||||
/// The remote was updated to carry this token generation.
|
||||
Embedded(TokenSnapshot),
|
||||
}
|
||||
|
||||
impl RefreshOutcome {
|
||||
/// No managed credentials to refresh.
|
||||
#[must_use]
|
||||
pub fn none() -> Self {
|
||||
Self {
|
||||
action: RemoteCredentialAction::None,
|
||||
token: None,
|
||||
pub const fn none() -> Self {
|
||||
Self::None
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn unchanged(token: TokenSnapshot) -> Self {
|
||||
Self::Unchanged(token)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn embedded(token: TokenSnapshot) -> Self {
|
||||
Self::Embedded(token)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn action(self) -> RemoteCredentialAction {
|
||||
match self {
|
||||
Self::None => RemoteCredentialAction::None,
|
||||
Self::Unchanged(_) => RemoteCredentialAction::Unchanged,
|
||||
Self::Embedded(_) => RemoteCredentialAction::Embedded,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn token(self) -> Option<TokenSnapshot> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::Unchanged(token) | Self::Embedded(token) => Some(token),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1071,6 +1099,16 @@ pub trait Sandbox: Send + Sync {
|
|||
}
|
||||
|
||||
async fn write_file(&self, path: &str, content: &str) -> crate::Result<()>;
|
||||
|
||||
/// Write a file that the caller has already confirmed exists.
|
||||
///
|
||||
/// Providers can override this method to skip setup that is only needed
|
||||
/// when creating a new path. The default preserves the behavior of
|
||||
/// [`Sandbox::write_file`].
|
||||
async fn write_existing_file(&self, path: &str, content: &str) -> crate::Result<()> {
|
||||
self.write_file(path, content).await
|
||||
}
|
||||
|
||||
async fn delete_file(&self, path: &str) -> crate::Result<()>;
|
||||
async fn file_exists(&self, path: &str) -> crate::Result<bool>;
|
||||
async fn list_directory(
|
||||
|
|
@ -1496,6 +1534,7 @@ pub async fn setup_git_via_exec(
|
|||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "git_op", skip_all, fields(op = "fetch"))]
|
||||
pub(crate) async fn fetch_source_run_ref(
|
||||
sandbox: &dyn Sandbox,
|
||||
source_run_id: &str,
|
||||
|
|
@ -1613,6 +1652,7 @@ fn push_failure_looks_auth_shaped(error: &crate::Error) -> bool {
|
|||
/// operation. `credentials` is the provider's push-credential state plus the
|
||||
/// origin URL; `None` pushes with whatever the remote already carries (the
|
||||
/// local sandbox, or a workspace without managed credentials).
|
||||
#[tracing::instrument(name = "git_op", skip_all, fields(op = "push"))]
|
||||
pub(crate) async fn git_push_via_exec(
|
||||
sandbox: &dyn Sandbox,
|
||||
credentials: Option<(&PushCredentialState, &str)>,
|
||||
|
|
@ -1799,9 +1839,8 @@ mod push_tests {
|
|||
|
||||
use chrono::Utc;
|
||||
use fabro_github::InstallationToken;
|
||||
use fabro_github::token_source::{
|
||||
InstallationTokenMinter, InstallationTokenSource, REFRESH_MARGIN,
|
||||
};
|
||||
use fabro_github::test_support::{InstallationTokenMinter, installation_token_source};
|
||||
use fabro_github::token_source::{InstallationTokenSource, REFRESH_MARGIN};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use super::*;
|
||||
|
|
@ -2003,13 +2042,11 @@ mod push_tests {
|
|||
}
|
||||
}
|
||||
|
||||
struct SharedMinter(std::sync::Arc<ScriptedMinter>);
|
||||
|
||||
#[async_trait]
|
||||
impl InstallationTokenMinter for SharedMinter {
|
||||
impl InstallationTokenMinter for ScriptedMinter {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
||||
self.0.calls.fetch_add(1, Ordering::SeqCst);
|
||||
match self.0.script.lock().await.pop_front().expect("mint script") {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
match self.script.lock().await.pop_front().expect("mint script") {
|
||||
MintAction::Token(token, ttl) => Ok(InstallationToken {
|
||||
token: token.to_string(),
|
||||
expires_at: Utc::now() + ttl,
|
||||
|
|
@ -2036,9 +2073,9 @@ mod push_tests {
|
|||
script: Vec<MintAction>,
|
||||
) -> (PushCredentialState, std::sync::Arc<ScriptedMinter>) {
|
||||
let minter = ScriptedMinter::new(script);
|
||||
let source = InstallationTokenSource::with_minter(
|
||||
"fabro-testing/repo".to_string(),
|
||||
Box::new(SharedMinter(std::sync::Arc::clone(&minter))),
|
||||
let source = installation_token_source(
|
||||
"fabro-testing/repo",
|
||||
std::sync::Arc::clone(&minter) as std::sync::Arc<dyn InstallationTokenMinter>,
|
||||
);
|
||||
(PushCredentialState::new(Some(source)), minter)
|
||||
}
|
||||
|
|
@ -2446,10 +2483,7 @@ mod push_tests {
|
|||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn retry_deadline_includes_credential_lease_acquisition() {
|
||||
let source = InstallationTokenSource::with_minter(
|
||||
"fabro-testing/repo".to_string(),
|
||||
Box::new(SlowMinter),
|
||||
);
|
||||
let source = installation_token_source("fabro-testing/repo", Arc::new(SlowMinter));
|
||||
let state = PushCredentialState::new(Some(source));
|
||||
let sandbox = ScriptedGitSandbox::new(vec![]);
|
||||
let mut plan = RetryPlan::checkpoint_push();
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ impl SandboxSpec {
|
|||
} => {
|
||||
let mut sandbox = DockerSandbox::new(
|
||||
config.clone(),
|
||||
github_app.clone(),
|
||||
github_app.as_ref(),
|
||||
*run_id,
|
||||
clone_origin_url.clone(),
|
||||
clone_branch.clone(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
|
@ -29,6 +29,8 @@ pub struct MockSandbox {
|
|||
pub os_version_str: String,
|
||||
/// Captures (path, content) pairs from `write_file` calls.
|
||||
pub written_files: Mutex<Vec<(String, String)>>,
|
||||
/// Counts calls to `write_existing_file`.
|
||||
pub existing_file_writes: AtomicUsize,
|
||||
/// Captures the `timeout_ms` argument from `exec_command` calls.
|
||||
pub captured_timeout: Mutex<Option<u64>>,
|
||||
/// Captures the `command` argument from `exec_command` calls (last only).
|
||||
|
|
@ -104,6 +106,10 @@ impl MockSandbox {
|
|||
.expect("delete_calls lock poisoned")
|
||||
}
|
||||
|
||||
pub fn existing_file_write_count(&self) -> usize {
|
||||
self.existing_file_writes.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn set_stdio_process(&self, process: MockStdioProcess) {
|
||||
*self
|
||||
.stdio_process
|
||||
|
|
@ -156,6 +162,7 @@ impl Default for MockSandbox {
|
|||
platform_str: "darwin",
|
||||
os_version_str: "Darwin 24.0.0".into(),
|
||||
written_files: Mutex::new(Vec::new()),
|
||||
existing_file_writes: AtomicUsize::new(0),
|
||||
captured_timeout: Mutex::new(None),
|
||||
captured_command: Mutex::new(None),
|
||||
captured_commands: Mutex::new(Vec::new()),
|
||||
|
|
@ -250,6 +257,11 @@ impl Sandbox for MockSandbox {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_existing_file(&self, path: &str, content: &str) -> crate::Result<()> {
|
||||
self.existing_file_writes.fetch_add(1, Ordering::Relaxed);
|
||||
self.write_file(path, content).await
|
||||
}
|
||||
|
||||
async fn delete_file(&self, _path: &str) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1046,6 +1046,7 @@ fn projection_from_created(event: &EventEnvelope) -> Result<RunProjection> {
|
|||
provenance: props.provenance.clone(),
|
||||
manifest_blob: props.manifest_blob,
|
||||
definition_blob: None,
|
||||
spec_blob: props.spec_blob,
|
||||
git: props.git.clone(),
|
||||
fork_source_ref: props.fork_source_ref.clone(),
|
||||
};
|
||||
|
|
@ -1360,8 +1361,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
|
|||
.conclusion
|
||||
.as_ref()
|
||||
.map(|conclusion| conclusion.timing);
|
||||
let terminal_total = terminal_total_usd_micros(state);
|
||||
let current_total = projected_billing(state).total_usd_micros;
|
||||
let total_usd_micros = projected_billing(state).total_usd_micros;
|
||||
|
||||
Run {
|
||||
id: *run_id,
|
||||
|
|
@ -1405,10 +1405,10 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
|
|||
completed_at,
|
||||
},
|
||||
timing: run_timing,
|
||||
billing: terminal_total.map(|total_usd_micros| RunBillingSummary {
|
||||
billing: total_usd_micros.map(|total_usd_micros| RunBillingSummary {
|
||||
total_usd_micros: Some(total_usd_micros),
|
||||
}),
|
||||
size: RunSize::from_total_usd_micros(current_total),
|
||||
size: RunSize::from_total_usd_micros(total_usd_micros),
|
||||
ask_fabro: AskFabro::default(),
|
||||
diff: diff_summary,
|
||||
pull_request: state.pull_request.clone(),
|
||||
|
|
@ -1421,14 +1421,6 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
|
|||
}
|
||||
}
|
||||
|
||||
fn terminal_total_usd_micros(state: &RunProjection) -> Option<i64> {
|
||||
state
|
||||
.conclusion
|
||||
.as_ref()
|
||||
.and_then(|conclusion| conclusion.billing.as_ref())
|
||||
.and_then(|billing| billing.total_usd_micros)
|
||||
}
|
||||
|
||||
pub(crate) fn projected_billing(state: &RunProjection) -> BilledTokenCounts {
|
||||
if let Some(billing) = state
|
||||
.conclusion
|
||||
|
|
@ -1694,11 +1686,12 @@ mod tests {
|
|||
CommandTermination, EventBody, FailureCategory, FailureDetail, FailureReason, Graph,
|
||||
McpServerStatus, Node, Outcome, ParallelBranchId, PendingReason, PermissionLevel,
|
||||
PullRequestCreationStatus, PullRequestLink, QuestionType, ReasoningEffort,
|
||||
RunApprovalState, RunControlAction, RunDiff, RunEvent, RunSize, RunSpec, RunStatus, Speed,
|
||||
StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod,
|
||||
StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowWarning,
|
||||
StageHandler, StageModelUsage, StageOutcome, StageState, StageTiming, SubAgentStatus,
|
||||
SuccessReason, WorkflowSettings, first_event_seq, fixtures, test_support,
|
||||
RunApprovalState, RunBillingSummary, RunControlAction, RunDiff, RunEvent, RunSize, RunSpec,
|
||||
RunStatus, Speed, StageContextWindowBreakdownItem, StageContextWindowCategory,
|
||||
StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness,
|
||||
StageContextWindowWarning, StageHandler, StageModelUsage, StageOutcome, StageState,
|
||||
StageTiming, SubAgentStatus, SuccessReason, WorkflowSettings, first_event_seq, fixtures,
|
||||
test_support,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -5254,7 +5247,12 @@ mod tests {
|
|||
|
||||
let summary = build_summary(&state, &fixtures::RUN_1);
|
||||
assert_eq!(summary.size, RunSize::S);
|
||||
assert_eq!(summary.billing, None);
|
||||
assert_eq!(
|
||||
summary.billing,
|
||||
Some(RunBillingSummary {
|
||||
total_usd_micros: Some(20_000_001),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -601,6 +601,7 @@ mod tests {
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -593,6 +593,7 @@ mod tests {
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
git: Some(fabro_types::GitContext {
|
||||
origin_url: "https://github.com/fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ toml.workspace = true
|
|||
fabro-vault = { path = "../../foundation/fabro-vault" }
|
||||
[dev-dependencies]
|
||||
fabro-auth = { path = "../../foundation/fabro-auth", features = ["test-support"] }
|
||||
fabro-github = { path = "../fabro-github", features = ["test-support"] }
|
||||
base64.workspace = true
|
||||
fabro-acp = { path = "../fabro-acp", features = ["test-support"] }
|
||||
fabro-workflow = { path = ".", features = ["test-support"] }
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ const TRANSIENT_INFRA_HINTS: &[&str] = &[
|
|||
"cross-device link",
|
||||
"invalid cross-device link",
|
||||
"os error 18",
|
||||
"state change in progress",
|
||||
"sandbox stop still in progress",
|
||||
];
|
||||
|
||||
const BUDGET_EXHAUSTED_HINTS: &[&str] = &[
|
||||
|
|
@ -855,6 +857,18 @@ mod tests {
|
|||
assert_eq!(err.failure_category(), FailureCategory::TransientInfra);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_error_with_sandbox_state_change_cause_classifies_transient() {
|
||||
let source = TestOuterError {
|
||||
message: "Failed to start Daytona sandbox",
|
||||
source: TestCause("Sandbox state change in progress"),
|
||||
};
|
||||
let err = Error::engine_with_source("Pipeline lifecycle operation failed", source);
|
||||
|
||||
assert_eq!(err.failure_category(), FailureCategory::TransientInfra);
|
||||
assert!(err.is_retryable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_error_display() {
|
||||
let err = Error::handler("LLM call failed");
|
||||
|
|
@ -1329,7 +1343,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn transient_infra_hints_count() {
|
||||
assert_eq!(TRANSIENT_INFRA_HINTS.len(), 38);
|
||||
assert_eq!(TRANSIENT_INFRA_HINTS.len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1498,6 +1512,25 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reason_sandbox_state_change_in_progress() {
|
||||
assert_eq!(
|
||||
classify_failure_reason(
|
||||
"Pipeline lifecycle operation failed: failed to activate sandbox after node \
|
||||
attempt survey: Failed to start Daytona sandbox: Sandbox state change in progress"
|
||||
),
|
||||
FailureCategory::TransientInfra
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reason_sandbox_stop_still_in_progress() {
|
||||
assert_eq!(
|
||||
classify_failure_reason("Daytona sandbox stop still in progress after 120s"),
|
||||
FailureCategory::TransientInfra
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reason_500() {
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
|||
automation,
|
||||
provenance,
|
||||
manifest_blob,
|
||||
spec_blob,
|
||||
git,
|
||||
fork_source_ref,
|
||||
retried_from,
|
||||
|
|
@ -92,6 +93,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
|||
automation: automation.clone(),
|
||||
provenance: provenance.clone(),
|
||||
manifest_blob: *manifest_blob,
|
||||
spec_blob: *spec_blob,
|
||||
git: git.clone(),
|
||||
fork_source_ref: fork_source_ref.clone(),
|
||||
retried_from: *retried_from,
|
||||
|
|
@ -2839,6 +2841,7 @@ mod tests {
|
|||
automation: Some(automation.clone()),
|
||||
provenance,
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ pub enum Event {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
manifest_blob: Option<BlobHash>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
spec_blob: Option<BlobHash>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
git: Option<GitContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
fork_source_ref: Option<ForkSourceRef>,
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -364,6 +364,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -1,274 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use fabro_github::{GitHubAppCredentials, InstallationToken};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::warn;
|
||||
|
||||
const REFRESH_THRESHOLD: Duration = Duration::from_mins(15);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait IatMinter: Send + Sync {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken>;
|
||||
}
|
||||
|
||||
pub struct AppIatMinter {
|
||||
creds: GitHubAppCredentials,
|
||||
http: fabro_http::HttpClient,
|
||||
owner: String,
|
||||
repo: String,
|
||||
api_base: String,
|
||||
install_url: Option<String>,
|
||||
permissions: serde_json::Value,
|
||||
}
|
||||
|
||||
impl AppIatMinter {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
creds: GitHubAppCredentials,
|
||||
http: fabro_http::HttpClient,
|
||||
owner: String,
|
||||
repo: String,
|
||||
api_base: String,
|
||||
install_url: Option<String>,
|
||||
permissions: serde_json::Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
creds,
|
||||
http,
|
||||
owner,
|
||||
repo,
|
||||
api_base,
|
||||
install_url,
|
||||
permissions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IatMinter for AppIatMinter {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
||||
self.creds
|
||||
.mint_installation_token(
|
||||
&self.http,
|
||||
&self.owner,
|
||||
&self.repo,
|
||||
&self.api_base,
|
||||
self.permissions.clone(),
|
||||
self.install_url.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GitHubTokenSource {
|
||||
state: SourceState,
|
||||
}
|
||||
|
||||
enum SourceState {
|
||||
Pat(String),
|
||||
StaticIat(InstallationToken),
|
||||
Mintable {
|
||||
minter: Arc<dyn IatMinter>,
|
||||
cache: Mutex<Option<InstallationToken>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl GitHubTokenSource {
|
||||
#[must_use]
|
||||
pub fn pat(token: String) -> Self {
|
||||
Self {
|
||||
state: SourceState::Pat(token),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn static_iat(token: InstallationToken) -> Self {
|
||||
Self {
|
||||
state: SourceState::StaticIat(token),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn mintable(minter: Arc<dyn IatMinter>) -> Self {
|
||||
Self {
|
||||
state: SourceState::Mintable {
|
||||
minter,
|
||||
cache: Mutex::new(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_refreshable(&self) -> bool {
|
||||
matches!(self.state, SourceState::Mintable { .. })
|
||||
}
|
||||
|
||||
pub async fn current_token(&self) -> anyhow::Result<String> {
|
||||
match &self.state {
|
||||
SourceState::Pat(token) => Ok(token.clone()),
|
||||
SourceState::StaticIat(token) => token.valid_token().map(str::to_owned),
|
||||
SourceState::Mintable { minter, cache } => {
|
||||
let mut cache = cache.lock().await;
|
||||
let cached_is_fresh = cache
|
||||
.as_ref()
|
||||
.is_some_and(|token| !token.near_expiry(REFRESH_THRESHOLD));
|
||||
|
||||
if !cached_is_fresh {
|
||||
match minter.mint().await {
|
||||
Ok(token) => *cache = Some(token),
|
||||
Err(err) => {
|
||||
if let Some(token) = cache.as_ref() {
|
||||
if let Ok(value) = token.valid_token() {
|
||||
warn!(
|
||||
error = %err,
|
||||
"GitHub installation token refresh failed; using cached token"
|
||||
);
|
||||
return Ok(value.to_owned());
|
||||
}
|
||||
}
|
||||
return Err(err)
|
||||
.context("failed to mint GitHub installation access token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let token = cache
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("mintable token source has no cached token"))?;
|
||||
token.valid_token().map(str::to_owned)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use anyhow::anyhow;
|
||||
|
||||
use super::*;
|
||||
|
||||
enum MintAction {
|
||||
Token(&'static str, chrono::DateTime<chrono::Utc>),
|
||||
Error(&'static str),
|
||||
}
|
||||
|
||||
struct MockMinter {
|
||||
calls: AtomicUsize,
|
||||
script: Mutex<VecDeque<MintAction>>,
|
||||
}
|
||||
|
||||
impl MockMinter {
|
||||
fn new(script: Vec<MintAction>) -> Self {
|
||||
Self {
|
||||
calls: AtomicUsize::new(0),
|
||||
script: Mutex::new(script.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> usize {
|
||||
self.calls.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IatMinter for MockMinter {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
match self.script.lock().await.pop_front().expect("mint script") {
|
||||
MintAction::Token(token, expires_at) => Ok(InstallationToken {
|
||||
token: token.to_string(),
|
||||
expires_at,
|
||||
}),
|
||||
MintAction::Error(message) => Err(anyhow!(message)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pat_returns_same_token_without_minting() {
|
||||
let source = GitHubTokenSource::pat("ghp_pat".to_string());
|
||||
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghp_pat");
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghp_pat");
|
||||
assert!(!source.is_refreshable());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn static_iat_returns_valid_token_and_rejects_expired_token() {
|
||||
let valid = GitHubTokenSource::static_iat(InstallationToken {
|
||||
token: "ghs_valid".to_string(),
|
||||
expires_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
});
|
||||
assert_eq!(valid.current_token().await.unwrap(), "ghs_valid");
|
||||
assert!(!valid.is_refreshable());
|
||||
|
||||
let expired = GitHubTokenSource::static_iat(InstallationToken {
|
||||
token: "ghs_expired".to_string(),
|
||||
expires_at: chrono::Utc::now() - chrono::Duration::seconds(1),
|
||||
});
|
||||
assert!(expired.current_token().await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mintable_reuses_cached_token_until_refresh_threshold() {
|
||||
let minter = Arc::new(MockMinter::new(vec![MintAction::Token(
|
||||
"ghs_cached",
|
||||
chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
)]));
|
||||
let source = GitHubTokenSource::mintable(minter.clone());
|
||||
|
||||
assert!(source.is_refreshable());
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghs_cached");
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghs_cached");
|
||||
assert_eq!(minter.calls(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mintable_refreshes_cached_token_near_expiry() {
|
||||
let minter = Arc::new(MockMinter::new(vec![
|
||||
MintAction::Token(
|
||||
"ghs_first",
|
||||
chrono::Utc::now() + chrono::Duration::minutes(10),
|
||||
),
|
||||
MintAction::Token(
|
||||
"ghs_second",
|
||||
chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
),
|
||||
]));
|
||||
let source = GitHubTokenSource::mintable(minter.clone());
|
||||
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghs_first");
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghs_second");
|
||||
assert_eq!(minter.calls(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mintable_uses_valid_cached_token_when_refresh_fails() {
|
||||
let minter = Arc::new(MockMinter::new(vec![
|
||||
MintAction::Token(
|
||||
"ghs_cached",
|
||||
chrono::Utc::now() + chrono::Duration::minutes(10),
|
||||
),
|
||||
MintAction::Error("mint failed"),
|
||||
]));
|
||||
let source = GitHubTokenSource::mintable(minter.clone());
|
||||
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghs_cached");
|
||||
assert_eq!(source.current_token().await.unwrap(), "ghs_cached");
|
||||
assert_eq!(minter.calls(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mintable_errors_when_no_cached_token_can_cover_mint_failure() {
|
||||
let minter = Arc::new(MockMinter::new(vec![MintAction::Error("mint failed")]));
|
||||
let source = GitHubTokenSource::mintable(minter);
|
||||
|
||||
let err = format!("{:#}", source.current_token().await.unwrap_err());
|
||||
assert!(err.contains("mint failed"), "got: {err}");
|
||||
}
|
||||
}
|
||||
|
|
@ -501,6 +501,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -374,6 +374,7 @@ mod tests {
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
},
|
||||
|
|
@ -474,6 +475,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
@ -1693,7 +1695,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::github_token_source::IatMinter for RefreshingMinter {
|
||||
impl fabro_github::test_support::InstallationTokenMinter for RefreshingMinter {
|
||||
async fn mint(&self) -> anyhow::Result<fabro_github::InstallationToken> {
|
||||
let call = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
|
||||
Ok(fabro_github::InstallationToken {
|
||||
|
|
@ -1834,8 +1836,9 @@ mod tests {
|
|||
calls: std::sync::atomic::AtomicUsize::new(0),
|
||||
});
|
||||
let mut services = make_sandbox_services(spy.clone());
|
||||
services.github_token = Some(std::sync::Arc::new(
|
||||
crate::github_token_source::GitHubTokenSource::mintable(minter.clone()),
|
||||
services.github_token = Some(fabro_github::test_support::installation_token_source(
|
||||
"owner/repo",
|
||||
minter.clone(),
|
||||
));
|
||||
|
||||
let handler = CommandHandler;
|
||||
|
|
|
|||
|
|
@ -11,8 +11,7 @@ use fabro_acp::{
|
|||
render_stop_reason,
|
||||
};
|
||||
use fabro_agent::{
|
||||
AgentEvent, RefreshOutcome, RemoteCredentialAction, Sandbox, StaticEnvProvider, SteeringItem,
|
||||
ToolEnvProvider,
|
||||
AgentEvent, RefreshOutcome, Sandbox, StaticEnvProvider, SteeringItem, ToolEnvProvider,
|
||||
};
|
||||
use fabro_github::token_source::REFRESH_MARGIN;
|
||||
use fabro_graphviz::graph::Node;
|
||||
|
|
@ -115,12 +114,8 @@ fn push_cred_refresh_interval() -> Option<Duration> {
|
|||
/// tick. Schedule from the token's own `expires_at` instead: wake when the
|
||||
/// cache margin opens, so that tick re-mints. `None` disables the loop —
|
||||
/// static credentials cannot be re-minted by waiting.
|
||||
fn next_refresh_delay(outcome: &RefreshOutcome, fallback: Duration) -> Option<Duration> {
|
||||
let Some(token) = outcome.token else {
|
||||
// No managed credentials to watch; keep the configured cadence in
|
||||
// case a later tick sees them (e.g. after a reconnect).
|
||||
return Some(fallback);
|
||||
};
|
||||
fn next_refresh_delay(outcome: &RefreshOutcome) -> Option<Duration> {
|
||||
let token = outcome.token()?;
|
||||
let expires_at = token.expires_at()?;
|
||||
let margin = chrono::Duration::from_std(REFRESH_MARGIN).unwrap_or(chrono::Duration::MAX);
|
||||
let until_margin = ((expires_at - margin) - chrono::Utc::now())
|
||||
|
|
@ -140,9 +135,10 @@ async fn refresh_ahead_loop(
|
|||
sandbox: Arc<dyn Sandbox>,
|
||||
cancel: CancellationToken,
|
||||
interval: Duration,
|
||||
initial_delay: Duration,
|
||||
) {
|
||||
let retry_delay = interval.min(Duration::from_mins(1));
|
||||
let mut delay = interval;
|
||||
let mut delay = initial_delay;
|
||||
loop {
|
||||
tokio::select! {
|
||||
() = cancel.cancelled() => break,
|
||||
|
|
@ -151,26 +147,26 @@ async fn refresh_ahead_loop(
|
|||
.await
|
||||
{
|
||||
Ok(Ok(outcome)) => {
|
||||
match outcome.action {
|
||||
RemoteCredentialAction::Embedded => {
|
||||
match outcome {
|
||||
RefreshOutcome::Embedded(token) => {
|
||||
tracing::info!(
|
||||
generation = outcome.token.map(|token| token.generation),
|
||||
generation = token.generation,
|
||||
"refresh-ahead re-embedded push credentials mid-turn"
|
||||
);
|
||||
}
|
||||
RemoteCredentialAction::Unchanged => {
|
||||
RefreshOutcome::Unchanged(token) => {
|
||||
tracing::debug!(
|
||||
generation = outcome.token.map(|token| token.generation),
|
||||
generation = token.generation,
|
||||
"refresh-ahead tick: embedded push credentials still fresh"
|
||||
);
|
||||
}
|
||||
RemoteCredentialAction::None => {
|
||||
RefreshOutcome::None => {
|
||||
tracing::debug!(
|
||||
"refresh-ahead tick: no managed push credentials to refresh"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(next) = next_refresh_delay(&outcome, interval) {
|
||||
if let Some(next) = next_refresh_delay(&outcome) {
|
||||
delay = next;
|
||||
} else {
|
||||
tracing::debug!(
|
||||
|
|
@ -312,77 +308,57 @@ impl AgentAcpBackend {
|
|||
}) as Arc<dyn Fn(String, Option<Principal>) + Send + Sync>
|
||||
});
|
||||
|
||||
// Keep the sandbox's push credentials fresh for the duration of this ACP
|
||||
// turn so the agent's own `git push` uses a live token instead of the one
|
||||
// baked into the clone at run start.
|
||||
//
|
||||
// Part 2 (turn-entry): resolve through the cached token source and
|
||||
// rewrite the origin URL before the ACP process spawns, covering a push
|
||||
// early in the turn. A fresh cached token makes this a no-op exec-wise.
|
||||
// Non-fatal and timeout-bounded — a stalled mint must neither fail nor
|
||||
// hang node entry. Part 3 (loop): a background task keeps the embedded
|
||||
// token fresh so a single turn that outlives the ~60-min
|
||||
// installation-token TTL still pushes with a fresh token; ticks
|
||||
// reschedule from the embedded token's expiry, so a normal short turn
|
||||
// never ticks (the drop-guard aborts the task at turn end).
|
||||
//
|
||||
// FABRO_PUSH_CRED_REFRESH_AHEAD=0 (or false/off/no/empty, case-
|
||||
// insensitive) disables the WHOLE feature — turn-entry refresh AND loop —
|
||||
// so an operator who manages `origin` themselves can opt out of all
|
||||
// fabro-side origin rewriting. FABRO_PUSH_CRED_REFRESH_INTERVAL_SECONDS
|
||||
// overrides the loop cadence for ticks without token expiry info; 0
|
||||
// disables just the loop.
|
||||
//
|
||||
// Known limitations tracked as follow-ups (not addressed here): (a)
|
||||
// resumed/parked runs reconnect the sandbox with no GitHub App creds, so
|
||||
// refresh no-ops until those creds are threaded through the reconnect
|
||||
// path; (b) the background `git remote set-url` can contend with the
|
||||
// agent's own git on `.git/config.lock` (skipped entirely while the
|
||||
// cached generation is already embedded); (c) parallel ACP branches each
|
||||
// run their own loop; (d) this refresh lives in the ACP handler only,
|
||||
// though the stale-origin problem is stage-type-agnostic (native/command
|
||||
// stages that push are not covered); (e) refresh failures are logged via
|
||||
// tracing but not surfaced as a RunNotice event on the run stream.
|
||||
// Refresh before launch for early pushes. Schedule later refreshes from
|
||||
// token expiry so the loop cannot sleep past the cache margin.
|
||||
let refresh_enabled = push_cred_refresh_enabled();
|
||||
if refresh_enabled {
|
||||
let refresh_interval = refresh_enabled.then(push_cred_refresh_interval).flatten();
|
||||
let refresh_schedule = if refresh_enabled {
|
||||
match timeout(REFRESH_MINT_TIMEOUT, sandbox.refresh_push_credentials()).await {
|
||||
Ok(Ok(outcome)) => match outcome.action {
|
||||
RemoteCredentialAction::Embedded => {
|
||||
tracing::debug!(
|
||||
generation = outcome.token.map(|token| token.generation),
|
||||
"refreshed sandbox push credentials at ACP turn entry"
|
||||
);
|
||||
Ok(Ok(outcome)) => {
|
||||
match outcome {
|
||||
RefreshOutcome::Embedded(token) => {
|
||||
tracing::debug!(
|
||||
generation = token.generation,
|
||||
"refreshed sandbox push credentials at ACP turn entry"
|
||||
);
|
||||
}
|
||||
RefreshOutcome::Unchanged(token) => {
|
||||
tracing::debug!(
|
||||
generation = token.generation,
|
||||
"sandbox push credentials already fresh at ACP turn entry"
|
||||
);
|
||||
}
|
||||
RefreshOutcome::None => {}
|
||||
}
|
||||
RemoteCredentialAction::Unchanged => {
|
||||
tracing::debug!(
|
||||
generation = outcome.token.map(|token| token.generation),
|
||||
"sandbox push credentials already fresh at ACP turn entry"
|
||||
);
|
||||
}
|
||||
RemoteCredentialAction::None => {}
|
||||
},
|
||||
refresh_interval.zip(next_refresh_delay(&outcome))
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
error = %fabro_sandbox::display_for_log(&e),
|
||||
"node-entry push-credential refresh failed (non-fatal)"
|
||||
);
|
||||
refresh_interval
|
||||
.map(|interval| (interval, interval.min(Duration::from_mins(1))))
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
tracing::warn!(
|
||||
timeout_secs = REFRESH_MINT_TIMEOUT.as_secs(),
|
||||
"node-entry push-credential refresh timed out (non-fatal)"
|
||||
);
|
||||
refresh_interval
|
||||
.map(|interval| (interval, interval.min(Duration::from_mins(1))))
|
||||
}
|
||||
}
|
||||
}
|
||||
let _refresh_ahead_guard: Option<AbortOnDrop> = refresh_enabled
|
||||
.then(push_cred_refresh_interval)
|
||||
.flatten()
|
||||
.map(|interval| {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _refresh_ahead_guard: Option<AbortOnDrop> =
|
||||
refresh_schedule.map(|(interval, initial_delay)| {
|
||||
AbortOnDrop(tokio::spawn(refresh_ahead_loop(
|
||||
Arc::clone(sandbox),
|
||||
cancel_token.child_token(),
|
||||
interval,
|
||||
initial_delay,
|
||||
)))
|
||||
});
|
||||
|
||||
|
|
@ -766,23 +742,22 @@ mod tests {
|
|||
expires_at,
|
||||
}
|
||||
};
|
||||
RefreshOutcome {
|
||||
action,
|
||||
token: Some(TokenSnapshot {
|
||||
generation,
|
||||
provenance,
|
||||
}),
|
||||
let token = TokenSnapshot {
|
||||
generation,
|
||||
provenance,
|
||||
};
|
||||
match action {
|
||||
RemoteCredentialAction::Embedded => RefreshOutcome::embedded(token),
|
||||
RemoteCredentialAction::Unchanged => RefreshOutcome::unchanged(token),
|
||||
RemoteCredentialAction::None => RefreshOutcome::none(),
|
||||
}
|
||||
}
|
||||
|
||||
fn static_outcome() -> RefreshOutcome {
|
||||
RefreshOutcome {
|
||||
action: RemoteCredentialAction::Unchanged,
|
||||
token: Some(TokenSnapshot {
|
||||
generation: 0,
|
||||
provenance: TokenProvenance::Static,
|
||||
}),
|
||||
}
|
||||
RefreshOutcome::unchanged(TokenSnapshot {
|
||||
generation: 0,
|
||||
provenance: TokenProvenance::Static,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -794,7 +769,7 @@ mod tests {
|
|||
chrono::Duration::minutes(60),
|
||||
false,
|
||||
);
|
||||
let delay = next_refresh_delay(&outcome, Duration::from_mins(45)).unwrap();
|
||||
let delay = next_refresh_delay(&outcome).unwrap();
|
||||
// Expiry minus the 10-minute refresh margin: ~50 minutes out.
|
||||
assert!(delay > Duration::from_mins(49), "{delay:?}");
|
||||
assert!(delay <= Duration::from_mins(50), "{delay:?}");
|
||||
|
|
@ -809,26 +784,17 @@ mod tests {
|
|||
chrono::Duration::minutes(5),
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
next_refresh_delay(&outcome, Duration::from_mins(45)),
|
||||
Some(REFRESH_RESCHEDULE_FLOOR)
|
||||
);
|
||||
assert_eq!(next_refresh_delay(&outcome), Some(REFRESH_RESCHEDULE_FLOOR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_refresh_delay_disables_the_loop_for_static_credentials() {
|
||||
assert_eq!(
|
||||
next_refresh_delay(&static_outcome(), Duration::from_mins(45)),
|
||||
None
|
||||
);
|
||||
assert_eq!(next_refresh_delay(&static_outcome()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_refresh_delay_keeps_the_cadence_without_managed_credentials() {
|
||||
assert_eq!(
|
||||
next_refresh_delay(&RefreshOutcome::none(), Duration::from_mins(45)),
|
||||
Some(Duration::from_mins(45))
|
||||
);
|
||||
fn next_refresh_delay_disables_the_loop_without_managed_credentials() {
|
||||
assert_eq!(next_refresh_delay(&RefreshOutcome::none()), None);
|
||||
}
|
||||
|
||||
/// Sandbox stub whose refresh outcomes are scripted, recording when each
|
||||
|
|
@ -989,6 +955,7 @@ mod tests {
|
|||
Arc::clone(&sandbox) as Arc<dyn Sandbox>,
|
||||
cancel.clone(),
|
||||
interval,
|
||||
interval,
|
||||
));
|
||||
|
||||
while sandbox.ticks().len() < 3 {
|
||||
|
|
@ -1011,19 +978,41 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn refresh_ahead_stops_by_itself_for_static_credentials() {
|
||||
let sandbox = ScriptedRefreshSandbox::new(vec![static_outcome()]);
|
||||
async fn refresh_ahead_honors_the_expiry_based_initial_delay() {
|
||||
let interval = Duration::from_mins(45);
|
||||
let entry_outcome = minted_outcome(
|
||||
RemoteCredentialAction::Unchanged,
|
||||
1,
|
||||
chrono::Duration::minutes(45),
|
||||
chrono::Duration::minutes(15),
|
||||
true,
|
||||
);
|
||||
let initial_delay = next_refresh_delay(&entry_outcome).unwrap();
|
||||
let sandbox = ScriptedRefreshSandbox::new(vec![minted_outcome(
|
||||
RemoteCredentialAction::Embedded,
|
||||
2,
|
||||
chrono::Duration::zero(),
|
||||
chrono::Duration::minutes(60),
|
||||
false,
|
||||
)]);
|
||||
let cancel = CancellationToken::new();
|
||||
let start = tokio::time::Instant::now();
|
||||
let loop_task = tokio::spawn(refresh_ahead_loop(
|
||||
Arc::clone(&sandbox) as Arc<dyn Sandbox>,
|
||||
cancel.clone(),
|
||||
Duration::from_mins(45),
|
||||
interval,
|
||||
initial_delay,
|
||||
));
|
||||
|
||||
// The loop exits after the first tick without being cancelled: static
|
||||
// credentials cannot be re-minted, so there is nothing to keep fresh.
|
||||
loop_task.await.expect("refresh loop should stop by itself");
|
||||
assert_eq!(sandbox.ticks().len(), 1);
|
||||
while sandbox.ticks().is_empty() {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
cancel.cancel();
|
||||
loop_task.await.expect("refresh loop should exit cleanly");
|
||||
|
||||
let first_tick = sandbox.ticks()[0] - start;
|
||||
assert!(first_tick <= Duration::from_mins(5), "{first_tick:?}");
|
||||
assert!(first_tick > Duration::from_mins(4), "{first_tick:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -956,6 +956,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -293,7 +293,6 @@ pub mod error;
|
|||
pub mod event;
|
||||
pub mod file_resolver;
|
||||
pub mod git;
|
||||
pub mod github_token_source;
|
||||
pub(crate) mod graph;
|
||||
pub mod handler;
|
||||
mod hook_context;
|
||||
|
|
|
|||
|
|
@ -785,6 +785,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -227,6 +227,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -13,10 +13,11 @@ use std::sync::Arc;
|
|||
use fabro_config::Storage;
|
||||
use fabro_graphviz::graph::{AttrValue, Graph};
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
use fabro_store::Database;
|
||||
use fabro_store::{Database, RunDatabase};
|
||||
use fabro_template::TemplateContext;
|
||||
use fabro_types::{
|
||||
AutomationRef, ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings,
|
||||
AutomationRef, BlobHash, ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance,
|
||||
WorkflowSettings,
|
||||
};
|
||||
use fabro_util::json::normalize_json_value;
|
||||
use tokio::task::spawn_blocking;
|
||||
|
|
@ -470,6 +471,7 @@ pub async fn persist_create_run(
|
|||
provenance,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
git,
|
||||
fork_source_ref,
|
||||
};
|
||||
|
|
@ -516,18 +518,17 @@ async fn persist_created_run(
|
|||
.create_run(&record.run_id)
|
||||
.await
|
||||
.map_err(|err| Error::engine_with_source("failed to create run store", err))?;
|
||||
let manifest_blob = match submitted_manifest_bytes {
|
||||
Some(bytes) => Some(run_store.write_blob(bytes).await.map_err(store_error)?),
|
||||
None => None,
|
||||
};
|
||||
let definition_blob = match accepted_definition {
|
||||
Some(definition) => {
|
||||
let bytes =
|
||||
serde_json::to_vec(definition).map_err(|err| Error::engine(err.to_string()))?;
|
||||
Some(run_store.write_blob(&bytes).await.map_err(store_error)?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let definition_bytes = accepted_definition
|
||||
.map(serde_json::to_vec)
|
||||
.transpose()
|
||||
.map_err(|err| Error::engine_with_source("failed to serialize run definition", err))?;
|
||||
let spec_bytes = serde_json::to_vec(record)
|
||||
.map_err(|err| Error::engine_with_source("failed to serialize run spec", err))?;
|
||||
let (manifest_blob, definition_blob, spec_blob) = tokio::try_join!(
|
||||
write_optional_blob(&run_store, submitted_manifest_bytes),
|
||||
write_optional_blob(&run_store, definition_bytes.as_deref()),
|
||||
async { run_store.write_blob(&spec_bytes).await.map_err(store_error) },
|
||||
)?;
|
||||
|
||||
let title = explicit_title.unwrap_or_else(|| fabro_types::infer_run_title(record.graph.goal()));
|
||||
let stored = to_run_event_at(
|
||||
|
|
@ -554,6 +555,7 @@ async fn persist_created_run(
|
|||
automation: record.automation.clone(),
|
||||
provenance: record.provenance.clone(),
|
||||
manifest_blob,
|
||||
spec_blob: Some(spec_blob),
|
||||
git: record.git.clone(),
|
||||
fork_source_ref: record.fork_source_ref.clone(),
|
||||
retried_from: None,
|
||||
|
|
@ -580,8 +582,22 @@ async fn persist_created_run(
|
|||
.map_err(store_error)
|
||||
}
|
||||
|
||||
fn store_error(err: impl std::fmt::Display) -> Error {
|
||||
Error::engine(err.to_string())
|
||||
async fn write_optional_blob(
|
||||
run_store: &RunDatabase,
|
||||
bytes: Option<&[u8]>,
|
||||
) -> Result<Option<BlobHash>, Error> {
|
||||
match bytes {
|
||||
Some(bytes) => run_store
|
||||
.write_blob(bytes)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(store_error),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn store_error(err: impl Into<anyhow::Error>) -> Error {
|
||||
Error::engine_with_source("run store operation failed", err)
|
||||
}
|
||||
|
||||
/// Parse, transform, and validate `dot_source`.
|
||||
|
|
|
|||
|
|
@ -162,6 +162,9 @@ async fn persist_forked_run(
|
|||
automation: spec.automation.clone(),
|
||||
provenance: spec.provenance.clone(),
|
||||
manifest_blob: spec.manifest_blob,
|
||||
// Content-addressed, so the forked run reads the source run's
|
||||
// unredacted spec bytes through the same id.
|
||||
spec_blob: spec.spec_blob,
|
||||
git: spec.git.clone(),
|
||||
fork_source_ref: spec.fork_source_ref.clone(),
|
||||
retried_from: None,
|
||||
|
|
@ -381,6 +384,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: Some(fabro_types::GitContext {
|
||||
origin_url: "https://github.com/example/repo.git".to_string(),
|
||||
branch: "main".to_string(),
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ pub async fn retry_run(
|
|||
provenance: _,
|
||||
manifest_blob,
|
||||
definition_blob,
|
||||
spec_blob,
|
||||
git,
|
||||
fork_source_ref,
|
||||
} = source.spec;
|
||||
|
|
@ -78,6 +79,9 @@ pub async fn retry_run(
|
|||
automation,
|
||||
provenance: input.provenance.clone(),
|
||||
manifest_blob,
|
||||
// Blobs are content-addressed, so the retried run reads the source
|
||||
// run's unredacted spec bytes through the same id.
|
||||
spec_blob,
|
||||
git,
|
||||
fork_source_ref,
|
||||
retried_from: Some(source_run_id),
|
||||
|
|
@ -185,6 +189,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: provenance("source-user"),
|
||||
manifest_blob,
|
||||
spec_blob: None,
|
||||
git: Some(git_context()),
|
||||
fork_source_ref,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -252,6 +252,7 @@ mod tests {
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
fork_source_ref: None,
|
||||
},
|
||||
)
|
||||
|
|
@ -218,6 +219,7 @@ async fn seed_created_and_starting(
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: run_options.pre_run_git.clone(),
|
||||
fork_source_ref: run_options.fork_source_ref.clone(),
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -798,6 +798,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
@ -916,6 +917,7 @@ mod tests {
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use fabro_agent::{Sandbox, ToolSecrets};
|
|||
use fabro_auth::{
|
||||
CredentialSource, ExtraHeadersCredentialSource, VaultCredentialSource, auth_issue_message,
|
||||
};
|
||||
use fabro_github::token_source::InstallationTokenSource;
|
||||
use fabro_graphviz::graph;
|
||||
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookExecutionContext, HookRunner};
|
||||
use fabro_model::Catalog;
|
||||
|
|
@ -23,7 +24,6 @@ use super::types::{InitOptions, Initialized, LlmSpec, Persisted, SandboxEnvSpec}
|
|||
use crate::error::Error;
|
||||
use crate::event::{Event, RunNoticeCode, RunNoticeLevel};
|
||||
use crate::git::GitAuthor;
|
||||
use crate::github_token_source::{AppIatMinter, GitHubTokenSource};
|
||||
use crate::handler::llm::{AgentAcpBackend, AgentApiBackend, BackendRouter, routing};
|
||||
use crate::handler::{HandlerRegistry, default_registry};
|
||||
#[cfg(test)]
|
||||
|
|
@ -37,7 +37,10 @@ use crate::services::{
|
|||
use crate::stage_execution::{StageExecutionSeed, StageExecutionTracker};
|
||||
use crate::steering_hub::SteeringHub;
|
||||
|
||||
type BuiltSandboxEnv = (HashMap<String, String>, Option<Arc<GitHubTokenSource>>);
|
||||
type BuiltSandboxEnv = (
|
||||
HashMap<String, String>,
|
||||
Option<Arc<InstallationTokenSource>>,
|
||||
);
|
||||
|
||||
async fn run_hooks(
|
||||
hook_runner: Option<&HookRunner>,
|
||||
|
|
@ -99,12 +102,12 @@ fn build_sandbox_env(
|
|||
|
||||
let source = match creds {
|
||||
fabro_github::GitHubCredentials::Pat(token) => {
|
||||
Some(Arc::new(GitHubTokenSource::pat(token.clone())))
|
||||
Some(InstallationTokenSource::pat(token.clone()))
|
||||
}
|
||||
fabro_github::GitHubCredentials::Installation(token) => {
|
||||
Some(Arc::new(GitHubTokenSource::static_iat(token.clone())))
|
||||
Some(InstallationTokenSource::installation(token.clone()))
|
||||
}
|
||||
fabro_github::GitHubCredentials::App(app) => {
|
||||
fabro_github::GitHubCredentials::App(_) => {
|
||||
let Some(origin_url) = spec.origin_url.as_deref() else {
|
||||
return Ok((env, None));
|
||||
};
|
||||
|
|
@ -114,19 +117,11 @@ fn build_sandbox_env(
|
|||
let permissions = serde_json::to_value(permissions).map_err(|err| {
|
||||
Error::engine_with_source("Failed to serialize GitHub permissions", err)
|
||||
})?;
|
||||
let http = fabro_http::http_client()
|
||||
.map_err(|err| Error::engine_with_source("Failed to build HTTP client", err))?;
|
||||
let install_url = app.installation_url(&owner);
|
||||
let minter = AppIatMinter::new(
|
||||
app.clone(),
|
||||
http,
|
||||
owner,
|
||||
repo,
|
||||
fabro_github::github_api_base_url(),
|
||||
install_url,
|
||||
permissions,
|
||||
);
|
||||
Some(Arc::new(GitHubTokenSource::mintable(Arc::new(minter))))
|
||||
Some(
|
||||
InstallationTokenSource::for_repository(creds, owner, repo, permissions).map_err(
|
||||
|err| Error::engine_with_anyhow("Failed to build GitHub token source", err),
|
||||
)?,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -458,7 +453,7 @@ pub async fn initialize(
|
|||
});
|
||||
let github_token_refresh_managed = github_token
|
||||
.as_deref()
|
||||
.is_some_and(GitHubTokenSource::is_refreshable);
|
||||
.is_some_and(InstallationTokenSource::mints_installation_tokens);
|
||||
let (registry, effective_dry_run) = if let Some(registry) = options.registry_override.clone() {
|
||||
// A caller-supplied registry owns execution behavior for its handlers.
|
||||
(registry, options.dry_run)
|
||||
|
|
@ -871,6 +866,7 @@ mod tests {
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
fork_source_ref,
|
||||
},
|
||||
)
|
||||
|
|
@ -1054,6 +1050,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: run_options.fork_source_ref.clone(),
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use std::path::Path;
|
|||
|
||||
use super::types::{PersistOptions, Persisted, Validated};
|
||||
use crate::error::Error;
|
||||
use crate::records::RunSpec;
|
||||
use crate::runtime_store::RunStoreHandle;
|
||||
|
||||
/// PERSIST phase: create the run directory and return durable metadata for
|
||||
|
|
@ -37,7 +38,7 @@ pub(crate) async fn load_from_store(
|
|||
.state()
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
let run_spec = state.spec;
|
||||
let run_spec = executable_run_spec(run_store, state.spec).await?;
|
||||
let graph = run_spec.graph.clone();
|
||||
let source = run_spec.graph_source.clone().unwrap_or_default();
|
||||
|
||||
|
|
@ -50,6 +51,41 @@ pub(crate) async fn load_from_store(
|
|||
))
|
||||
}
|
||||
|
||||
/// Replace the event-folded spec content with the exact bytes from the spec
|
||||
/// blob. Stored events pass through secret redaction, so the folded spec is
|
||||
/// display data; the blob written at creation is what execution must see.
|
||||
/// Runs created before the blob existed fall back to the folded spec.
|
||||
async fn executable_run_spec(
|
||||
run_store: &RunStoreHandle,
|
||||
folded: RunSpec,
|
||||
) -> Result<RunSpec, Error> {
|
||||
let Some(blob_id) = folded.spec_blob else {
|
||||
return Ok(folded);
|
||||
};
|
||||
let bytes = run_store
|
||||
.read_blob(&blob_id)
|
||||
.await
|
||||
.map_err(|err| Error::engine_with_anyhow("failed to read run spec blob", err))?
|
||||
.ok_or_else(|| {
|
||||
Error::engine(format!(
|
||||
"run spec blob is missing from the run store: {blob_id}"
|
||||
))
|
||||
})?;
|
||||
let mut spec: RunSpec = serde_json::from_slice(&bytes)
|
||||
.map_err(|err| Error::engine_with_source("run spec blob was not valid JSON", err))?;
|
||||
// The event stream stays authoritative for run identity, provenance, and
|
||||
// blob ids. Prefer the unredacted graph source from the blob, with the
|
||||
// folded source as a compatibility fallback.
|
||||
spec.run_id = folded.run_id;
|
||||
spec.provenance = folded.provenance;
|
||||
spec.manifest_blob = folded.manifest_blob;
|
||||
spec.definition_blob = folded.definition_blob;
|
||||
spec.spec_blob = folded.spec_blob;
|
||||
spec.fork_source_ref = folded.fork_source_ref;
|
||||
spec.graph_source = spec.graph_source.or(folded.graph_source);
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[expect(clippy::disallowed_methods, reason = "tests stage pipeline fixtures")]
|
||||
mod tests {
|
||||
|
|
@ -150,30 +186,49 @@ mod tests {
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
fork_source_ref: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn seeded_store(record: &RunSpec, source: Option<&str>) -> RunDatabase {
|
||||
seeded_store_with(record, source, Some(record)).await
|
||||
}
|
||||
|
||||
async fn seeded_store_with(
|
||||
record: &RunSpec,
|
||||
source: Option<&str>,
|
||||
blob_record: Option<&RunSpec>,
|
||||
) -> RunDatabase {
|
||||
let store = memory_store();
|
||||
let run_store = store.create_run(&record.run_id).await.unwrap();
|
||||
let spec_blob = match blob_record {
|
||||
Some(blob_record) => Some(
|
||||
run_store
|
||||
.write_blob(&serde_json::to_vec(blob_record).unwrap())
|
||||
.await
|
||||
.unwrap(),
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
append_event(&run_store, &record.run_id, &Event::RunCreated {
|
||||
run_id: record.run_id,
|
||||
title: None,
|
||||
settings: serde_json::to_value(&record.settings).unwrap(),
|
||||
graph: serde_json::to_value(&record.graph).unwrap(),
|
||||
workflow_source: source.map(ToOwned::to_owned),
|
||||
labels: record.labels.clone().into_iter().collect(),
|
||||
run_id: record.run_id,
|
||||
title: None,
|
||||
settings: serde_json::to_value(&record.settings).unwrap(),
|
||||
graph: serde_json::to_value(&record.graph).unwrap(),
|
||||
workflow_source: source.map(ToOwned::to_owned),
|
||||
labels: record.labels.clone().into_iter().collect(),
|
||||
source_directory: record.source_directory.clone(),
|
||||
workflow_slug: record.workflow_slug.clone(),
|
||||
automation: record.automation.clone(),
|
||||
provenance: record.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
git: record.git.clone(),
|
||||
fork_source_ref: record.fork_source_ref.clone(),
|
||||
retried_from: None,
|
||||
parent_id: None,
|
||||
web_url: None,
|
||||
workflow_slug: record.workflow_slug.clone(),
|
||||
automation: record.automation.clone(),
|
||||
provenance: record.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
spec_blob,
|
||||
git: record.git.clone(),
|
||||
fork_source_ref: record.fork_source_ref.clone(),
|
||||
retried_from: None,
|
||||
parent_id: None,
|
||||
web_url: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -272,6 +327,96 @@ mod tests {
|
|||
assert!(loaded.diagnostics().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_from_store_preserves_high_entropy_dockerfile_content() {
|
||||
// The spec the worker executes must survive the store byte-identical.
|
||||
// Event redaction is a storage/display concern; when it reaches the
|
||||
// spec that `load_from_store` rehydrates, the sandbox builds a
|
||||
// corrupted Dockerfile: `ARG NAME=<hex>` pairs come back as
|
||||
// `ARG REDACTED`, the build's `set -eu` step fails on the unset
|
||||
// variable, and the environment's snapshot identity silently changes.
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let run_dir = temp.path().join("run");
|
||||
std::fs::create_dir_all(&run_dir).unwrap();
|
||||
let (graph, source) = graph_and_source();
|
||||
|
||||
// Two shapes that must both survive: the hex pins that triggered the
|
||||
// production failure, and a token high-entropy enough that any
|
||||
// detector will keep flagging it in stored events. The second keeps
|
||||
// this test red until execution stops reading redacted content,
|
||||
// independent of how the entropy heuristic evolves.
|
||||
let dockerfile = "FROM buildpack-deps:noble\n\
|
||||
ARG DOCKER_INSTALL_COMMIT=5ce20f2eef3615d08fea941eda5a109e949e8ebf\n\
|
||||
ARG DOCKER_INSTALL_SHA256=b991f2806186f7287bb9e53362060c382e906d154599b2fb0982f34246bacfd4\n\
|
||||
ENV CACHE_SALT=xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6p\n\
|
||||
RUN install-docker \"${DOCKER_INSTALL_COMMIT}\" \"${DOCKER_INSTALL_SHA256}\"\n";
|
||||
|
||||
let mut record = sample_record(different_graph());
|
||||
record.graph = graph;
|
||||
record.settings.run.environment.image.dockerfile = Some(
|
||||
fabro_types::settings::run::DockerfileSource::Inline(dockerfile.to_string()),
|
||||
);
|
||||
|
||||
let run_store = seeded_store(&record, Some(&source)).await;
|
||||
let loaded = load_from_store(&run_store.clone().into(), &run_dir)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
loaded.run_spec().settings.run.environment.image.dockerfile,
|
||||
Some(fabro_types::settings::run::DockerfileSource::Inline(
|
||||
dockerfile.to_string()
|
||||
)),
|
||||
"the executable run spec must round-trip through the store unredacted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_from_store_falls_back_to_folded_spec_without_spec_blob() {
|
||||
// Runs created before the spec blob existed carry no spec_blob on
|
||||
// run.created; the folded spec is their only copy.
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let run_dir = temp.path().join("run");
|
||||
std::fs::create_dir_all(&run_dir).unwrap();
|
||||
let (graph, source) = graph_and_source();
|
||||
let mut record = sample_record(different_graph());
|
||||
record.graph = graph;
|
||||
|
||||
let run_store = seeded_store_with(&record, Some(&source), None).await;
|
||||
let loaded = load_from_store(&run_store.clone().into(), &run_dir)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(loaded.run_spec().settings, record.settings);
|
||||
assert_eq!(loaded.run_spec().spec_blob, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_from_store_uses_fork_reference_from_event_fold() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let run_dir = temp.path().join("run");
|
||||
std::fs::create_dir_all(&run_dir).unwrap();
|
||||
let (graph, source) = graph_and_source();
|
||||
let source_record = sample_record(graph.clone());
|
||||
let mut fork_record = source_record.clone();
|
||||
fork_record.run_id = fixtures::RUN_7;
|
||||
fork_record.fork_source_ref = Some(fabro_types::ForkSourceRef {
|
||||
source_run_id: source_record.run_id,
|
||||
checkpoint_sha: "checkpoint-sha".to_string(),
|
||||
});
|
||||
|
||||
let run_store = seeded_store_with(&fork_record, Some(&source), Some(&source_record)).await;
|
||||
let loaded = load_from_store(&run_store.clone().into(), &run_dir)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(loaded.run_spec().run_id, fork_record.run_id);
|
||||
assert_eq!(
|
||||
loaded.run_spec().fork_source_ref,
|
||||
fork_record.fork_source_ref
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_returns_error_on_io_failure() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -830,6 +830,7 @@ mod tests {
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
},
|
||||
|
|
@ -1112,6 +1113,7 @@ mod tests {
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
fork_source_ref: None,
|
||||
};
|
||||
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
|
||||
|
|
@ -1126,6 +1128,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: run_spec.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: run_spec.git.clone(),
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
@ -1179,6 +1182,7 @@ mod tests {
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
fork_source_ref: None,
|
||||
};
|
||||
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
|
||||
|
|
@ -1193,6 +1197,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: run_spec.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: run_spec.git.clone(),
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
@ -1596,6 +1601,7 @@ mod tests {
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
fork_source_ref: None,
|
||||
};
|
||||
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
|
||||
|
|
@ -1610,6 +1616,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
@ -1813,6 +1820,7 @@ mod tests {
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
fork_source_ref: None,
|
||||
};
|
||||
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
|
||||
|
|
@ -1827,6 +1835,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -501,6 +501,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: run_spec.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: run_spec.git.clone(),
|
||||
fork_source_ref: run_spec.fork_source_ref.clone(),
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -243,6 +243,7 @@ impl RunMetadataWriterHandle {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "git_op", skip_all, fields(op = "metadata-push"))]
|
||||
pub(crate) async fn write_snapshot(
|
||||
&self,
|
||||
dump: &RunDump,
|
||||
|
|
@ -298,19 +299,16 @@ pub(crate) fn build_metadata_writer(
|
|||
if !normalized_url.starts_with("https://") {
|
||||
return Ok(None);
|
||||
}
|
||||
if fabro_github::parse_github_owner_repo(&normalized_url).is_err() {
|
||||
let Ok((owner, repo)) = fabro_github::parse_github_owner_repo(&normalized_url) else {
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
// Share the sandbox's token source so the metadata writer reuses the
|
||||
// same cached token as every other consumer for this origin. Resumed
|
||||
// runs reconnect the sandbox without one; they build their own cached
|
||||
// source from the run's credentials.
|
||||
let source = match token_source {
|
||||
Some(source) => source,
|
||||
None => InstallationTokenSource::for_origin(
|
||||
None => InstallationTokenSource::for_repository(
|
||||
creds,
|
||||
&normalized_url,
|
||||
owner,
|
||||
repo,
|
||||
serde_json::json!({ "contents": "write" }),
|
||||
)
|
||||
.map_err(RunMetadataError::TokenMint)?,
|
||||
|
|
@ -699,6 +697,7 @@ mod tests {
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
fork_source_ref: None,
|
||||
},
|
||||
chrono::Utc::now(),
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ mod tests {
|
|||
automation: None,
|
||||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ pub async fn git_checkpoint(
|
|||
clippy::too_many_arguments,
|
||||
reason = "Checkpointing needs explicit run metadata, checkpoint settings, and author inputs."
|
||||
)]
|
||||
#[tracing::instrument(name = "git_op", skip_all, fields(op = "checkpoint-commit"))]
|
||||
pub(crate) async fn checked_git_checkpoint(
|
||||
runtime: &SandboxGitRuntime,
|
||||
sandbox: &dyn Sandbox,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use fabro_agent::{Sandbox, ToolEnvProvider};
|
|||
use fabro_auth::CredentialSource;
|
||||
#[cfg(test)]
|
||||
use fabro_auth::ResolvedCredentials;
|
||||
use fabro_github::token_source::InstallationTokenSource;
|
||||
use fabro_hooks::{HookContext, HookDecision, HookExecutionContext, HookRunner};
|
||||
use fabro_interview::Interviewer;
|
||||
use fabro_model::{Catalog, ProviderId};
|
||||
|
|
@ -15,7 +16,6 @@ use fabro_types::{ManifestPath, RunId};
|
|||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::event::Emitter;
|
||||
use crate::github_token_source::GitHubTokenSource;
|
||||
use crate::handler::HandlerRegistry;
|
||||
use crate::interview_runtime::RunInterviewBlocker;
|
||||
use crate::run_metadata::{RunMetadataRuntime, RunMetadataWriterHandle};
|
||||
|
|
@ -238,7 +238,7 @@ pub struct EngineServices {
|
|||
/// Environment variables from `[sandbox.env]` config.
|
||||
pub base_env: HashMap<String, String>,
|
||||
/// GitHub token source used to inject `GITHUB_TOKEN` at the point of use.
|
||||
pub github_token: Option<Arc<GitHubTokenSource>>,
|
||||
pub github_token: Option<Arc<InstallationTokenSource>>,
|
||||
/// Typed values from `[run.inputs]`, available to prompt templates.
|
||||
pub inputs: HashMap<String, toml::Value>,
|
||||
/// When true, handlers should skip real execution and return simulated
|
||||
|
|
@ -342,7 +342,7 @@ impl EngineServices {
|
|||
|
||||
pub struct WorkflowToolEnvProvider {
|
||||
pub base_env: HashMap<String, String>,
|
||||
pub github_token: Option<Arc<GitHubTokenSource>>,
|
||||
pub github_token: Option<Arc<InstallationTokenSource>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
|
@ -354,11 +354,15 @@ impl ToolEnvProvider for WorkflowToolEnvProvider {
|
|||
|
||||
async fn resolve_workflow_env(
|
||||
base_env: &HashMap<String, String>,
|
||||
github_token: Option<&Arc<GitHubTokenSource>>,
|
||||
github_token: Option<&Arc<InstallationTokenSource>>,
|
||||
) -> anyhow::Result<HashMap<String, String>> {
|
||||
let mut env = base_env.clone();
|
||||
if let Some(source) = github_token {
|
||||
env.insert("GITHUB_TOKEN".to_string(), source.current_token().await?);
|
||||
let resolved = source.resolve().await?;
|
||||
env.insert(
|
||||
"GITHUB_TOKEN".to_string(),
|
||||
resolved.token.expose().to_owned(),
|
||||
);
|
||||
}
|
||||
Ok(env)
|
||||
}
|
||||
|
|
@ -371,9 +375,10 @@ mod tests {
|
|||
use anyhow::anyhow;
|
||||
use fabro_agent::ToolEnvProvider as _;
|
||||
use fabro_github::InstallationToken;
|
||||
use fabro_github::test_support::{InstallationTokenMinter, installation_token_source};
|
||||
use fabro_github::token_source::InstallationTokenSource;
|
||||
|
||||
use super::{EngineServices, WorkflowToolEnvProvider};
|
||||
use crate::github_token_source::{GitHubTokenSource, IatMinter};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_default_uses_stub_credential_source() {
|
||||
|
|
@ -406,7 +411,7 @@ mod tests {
|
|||
async fn workflow_tool_env_provider_merges_current_github_token() {
|
||||
let provider = WorkflowToolEnvProvider {
|
||||
base_env: HashMap::from([("FOO".to_string(), "bar".to_string())]),
|
||||
github_token: Some(Arc::new(GitHubTokenSource::pat("ghp_pat".to_string()))),
|
||||
github_token: Some(InstallationTokenSource::pat("ghp_pat".to_string())),
|
||||
};
|
||||
|
||||
let env = provider.resolve().await.unwrap();
|
||||
|
|
@ -418,7 +423,7 @@ mod tests {
|
|||
struct FailingMinter;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IatMinter for FailingMinter {
|
||||
impl InstallationTokenMinter for FailingMinter {
|
||||
async fn mint(&self) -> anyhow::Result<InstallationToken> {
|
||||
Err(anyhow!("GITHUB_TOKEN refresh failed"))
|
||||
}
|
||||
|
|
@ -428,9 +433,10 @@ mod tests {
|
|||
async fn workflow_tool_env_provider_propagates_token_refresh_errors() {
|
||||
let provider = WorkflowToolEnvProvider {
|
||||
base_env: HashMap::new(),
|
||||
github_token: Some(Arc::new(GitHubTokenSource::mintable(Arc::new(
|
||||
FailingMinter,
|
||||
)))),
|
||||
github_token: Some(installation_token_source(
|
||||
"owner/repo",
|
||||
Arc::new(FailingMinter),
|
||||
)),
|
||||
};
|
||||
|
||||
let err = format!("{:#}", provider.resolve().await.unwrap_err());
|
||||
|
|
|
|||
|
|
@ -209,6 +209,7 @@ mod tests {
|
|||
provenance: test_support::test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ async fn initialized(
|
|||
},
|
||||
},
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: run_options.pre_run_git.clone(),
|
||||
fork_source_ref: run_options.fork_source_ref.clone(),
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -179,8 +179,11 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
}
|
||||
}
|
||||
|
||||
// Check visit limits (>= matches fabro-workflow semantics)
|
||||
let visits = state.increment_visits(node.id());
|
||||
// Check visit limits before entry: a node with a limit of N may
|
||||
// execute N times, matching the documented contract. The count
|
||||
// covers previously admitted entries, so the refused visit is
|
||||
// not reported as one.
|
||||
let visits = state.visits(node.id());
|
||||
if let Some(max) = node.max_visits() {
|
||||
if visits >= max {
|
||||
return Err(Error::VisitLimitExceeded {
|
||||
|
|
@ -201,6 +204,7 @@ impl<G: Graph + 'static> Executor<G> {
|
|||
});
|
||||
}
|
||||
}
|
||||
state.increment_visits(node.id());
|
||||
|
||||
// before_node lifecycle
|
||||
let node_result = match self.lifecycle.before_node(&node, &state).await? {
|
||||
|
|
@ -807,7 +811,8 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn executor_visit_limit_per_node() {
|
||||
// Node with max_visits=2, loops back — fails on 2nd visit (>= semantics)
|
||||
// Node with max_visits=2, loops back — executes exactly twice, then
|
||||
// the third entry is refused. The error reports completed visits.
|
||||
let g = TestGraph::new(
|
||||
vec![
|
||||
TestNode::new("loop_node").with_max_visits(2),
|
||||
|
|
@ -821,11 +826,20 @@ mod tests {
|
|||
"loop_node",
|
||||
);
|
||||
let state = ExecutionState::new(&g).unwrap();
|
||||
let handler = Arc::new(CountingHandler::new(vec![]));
|
||||
let executor =
|
||||
ExecutorBuilder::new(Arc::new(AlwaysSucceedHandler) as Arc<dyn NodeHandler<TestGraph>>)
|
||||
.build();
|
||||
ExecutorBuilder::new(Arc::clone(&handler) as Arc<dyn NodeHandler<TestGraph>>).build();
|
||||
let result = executor.run(&g, state).await;
|
||||
assert!(matches!(result, Err(Error::VisitLimitExceeded { .. })));
|
||||
match result {
|
||||
Err(Error::VisitLimitExceeded { visits, limit, .. }) => {
|
||||
assert_eq!(visits, 2);
|
||||
assert_eq!(limit, 2);
|
||||
}
|
||||
Err(other) => panic!("expected VisitLimitExceeded, got {other:?}"),
|
||||
Ok(_) => panic!("expected VisitLimitExceeded, got success"),
|
||||
}
|
||||
// Two full loop_node -> other iterations ran before the refusal.
|
||||
assert_eq!(handler.calls(), 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ impl<M: OutcomeMeta> ExecutionState<M> {
|
|||
graph.get_node(&self.current_node_id)
|
||||
}
|
||||
|
||||
pub fn visits(&self, node_id: &str) -> usize {
|
||||
self.node_visits.get(node_id).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn increment_visits(&mut self, node_id: &str) -> usize {
|
||||
let count = self.node_visits.entry(node_id.to_string()).or_insert(0);
|
||||
*count += 1;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ pub(super) fn shannon_entropy(s: &str) -> f64 {
|
|||
/// Returns regions where tokens match `[A-Za-z0-9+_=-]{10,}` and have
|
||||
/// Shannon entropy above the threshold (4.5 bits). Protects against
|
||||
/// consuming characters from JSON escape sequences.
|
||||
///
|
||||
/// An assignment token (`NAME=value`) is measured and redacted by its
|
||||
/// value alone. Measuring the pair merges the name's charset into the
|
||||
/// value's and pushes innocuous values (a pure-hex git SHA can never
|
||||
/// exceed 4.0 bits by itself) over the threshold, and redacting the
|
||||
/// pair destroys the name that says what was redacted.
|
||||
pub(super) fn find_entropy_regions(s: &str) -> Vec<Region> {
|
||||
let mut regions = Vec::new();
|
||||
for m in SECRET_PATTERN.find_iter(s) {
|
||||
|
|
@ -58,6 +64,10 @@ pub(super) fn find_entropy_regions(s: &str) -> Vec<Region> {
|
|||
}
|
||||
}
|
||||
|
||||
if let Some(offset) = assignment_value_offset(&s[start..end]) {
|
||||
start += offset;
|
||||
}
|
||||
|
||||
if shannon_entropy(&s[start..end]) > ENTROPY_THRESHOLD {
|
||||
regions.push(Region { start, end });
|
||||
}
|
||||
|
|
@ -65,6 +75,28 @@ pub(super) fn find_entropy_regions(s: &str) -> Vec<Region> {
|
|||
regions
|
||||
}
|
||||
|
||||
/// For an assignment token (`NAME=value` with an identifier-shaped name),
|
||||
/// return the byte offset where the value begins. Entropy above the 4.5-bit
|
||||
/// threshold needs at least 23 distinct characters, so a value too short to
|
||||
/// qualify simply measures under the threshold; no length guard is needed.
|
||||
fn assignment_value_offset(token: &str) -> Option<usize> {
|
||||
let eq = token.find('=')?;
|
||||
let value = &token[eq + 1..];
|
||||
if value.is_empty() || value.starts_with('=') {
|
||||
return None;
|
||||
}
|
||||
let name = &token[..eq];
|
||||
let mut chars = name.chars();
|
||||
let first = chars.next()?;
|
||||
if !(first.is_ascii_alphabetic() || first == '_') {
|
||||
return None;
|
||||
}
|
||||
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||
return None;
|
||||
}
|
||||
Some(eq + 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -98,14 +130,28 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn regions_finds_high_entropy_token() {
|
||||
// `=` is in the regex pattern, so "key=xK9..." matches as one token
|
||||
// "key=xK9..." matches as one token, but only the value is
|
||||
// measured and flagged; the name survives redaction.
|
||||
let input = "key=xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6p";
|
||||
let regions = find_entropy_regions(input);
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert_eq!(regions[0].start, 0);
|
||||
assert_eq!(regions[0].start, "key=".len());
|
||||
assert_eq!(regions[0].end, input.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regions_find_padded_base64_tokens() {
|
||||
for input in [
|
||||
"WxFhjC5EAnh30M0JIe0Wa58Xb1BYf8kedTTdKUbbd9Y=",
|
||||
"AbCdEfGhIjKlMnOpQrStUvWxYz0123456789ABCDEF==",
|
||||
] {
|
||||
assert_eq!(find_entropy_regions(input), vec![Region {
|
||||
start: 0,
|
||||
end: input.len(),
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regions_empty_for_json_escape_sequence() {
|
||||
// "controller.go\nmodel.go" — the regex could match across the \n boundary
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ mod tests {
|
|||
let redacted = redact_json_value(input);
|
||||
|
||||
assert_eq!(redacted["name"], "fabro-01KQR3V9D4VPFFWMNTVH09J48G");
|
||||
assert_eq!(redacted["content"], "REDACTED");
|
||||
assert_eq!(redacted["content"], "token=REDACTED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -262,7 +262,7 @@ mod tests {
|
|||
|
||||
let redacted = redact_json_value(input);
|
||||
|
||||
assert_eq!(redacted["content"], "REDACTED");
|
||||
assert_eq!(redacted["content"], "key=REDACTED");
|
||||
assert_eq!(redacted["session_id"], HIGH_ENTROPY_SECRET);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -111,6 +111,27 @@ mod tests {
|
|||
assert_eq!(result, "key=REDACTED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_string_keeps_assignment_with_low_entropy_value() {
|
||||
// A pinned git SHA is pure hex, so the value alone can never exceed
|
||||
// 4.0 bits of entropy. Only the merged NAME=value token crosses the
|
||||
// 4.5-bit threshold, because the uppercase name widens the charset.
|
||||
// Measuring the name together with the value redacts innocuous
|
||||
// pins; the pair must survive.
|
||||
let input = "ARG DOCKER_INSTALL_COMMIT=5ce20f2eef3615d08fea941eda5a109e949e8ebf";
|
||||
assert_eq!(redact_string(input), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_string_keeps_assignment_key_for_high_entropy_value() {
|
||||
// The value alone is above the entropy threshold, so it is
|
||||
// redacted either way — but the name says which setting was
|
||||
// redacted and must survive, as the gitleaks layer already
|
||||
// does for `key=REDACTED`.
|
||||
let result = redact_string("BUILD_STAMP=xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6p");
|
||||
assert_eq!(result, "BUILD_STAMP=REDACTED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_string_overlapping_detections_produce_single_redacted() {
|
||||
// A high-entropy string that also matches a gitleaks pattern
|
||||
|
|
|
|||
|
|
@ -1955,7 +1955,7 @@ pub fn json_snapshot_filters(mut filters: Vec<(String, String)>) -> Vec<(String,
|
|||
r#""id": "[EVENT_ID]""#.to_string(),
|
||||
));
|
||||
filters = json_elapsed_ms_snapshot_filters(filters);
|
||||
for field in ["manifest_blob", "definition_blob"] {
|
||||
for field in ["manifest_blob", "definition_blob", "spec_blob"] {
|
||||
filters.push((
|
||||
format!(r#""{field}":\s*"[0-9a-f]{{64}}""#),
|
||||
format!(r#""{field}": "[BLOB_HASH]""#),
|
||||
|
|
|
|||
|
|
@ -76,6 +76,11 @@ pub struct RunSpec {
|
|||
pub manifest_blob: Option<BlobHash>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub definition_blob: Option<BlobHash>,
|
||||
/// Unredacted copy of this spec in the blob store. Stored events pass
|
||||
/// through secret redaction, so the spec folded from them is display
|
||||
/// data; execution must load the spec from this blob.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub spec_blob: Option<BlobHash>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub git: Option<GitContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ pub struct RunCreatedProps {
|
|||
pub provenance: RunProvenance,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manifest_blob: Option<BlobHash>,
|
||||
/// Unredacted copy of the run spec in the blob store. The settings and
|
||||
/// graph on this event are redacted at the sink; execution loads the
|
||||
/// spec from this blob instead.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub spec_blob: Option<BlobHash>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub git: Option<GitContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ pub fn test_run_spec() -> RunSpec {
|
|||
provenance: test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ fn run_created_props_round_trip_templated_settings() {
|
|||
}),
|
||||
provenance: test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: Some(GitContext {
|
||||
origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
|
||||
branch: "main".to_string(),
|
||||
|
|
@ -93,6 +94,7 @@ fn run_created_props_omits_web_url_when_absent() {
|
|||
automation: None,
|
||||
provenance: test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
spec_blob: None,
|
||||
git: None,
|
||||
fork_source_ref: None,
|
||||
retried_from: None,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ fn run_spec_round_trips_templated_settings() {
|
|||
provenance: test_run_provenance(),
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
spec_blob: None,
|
||||
git: Some(GitContext {
|
||||
origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
|
||||
branch: "main".to_string(),
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export interface RunSpec {
|
|||
'provenance': RunProvenance;
|
||||
'manifest_blob'?: string | null;
|
||||
'definition_blob'?: string | null;
|
||||
'spec_blob'?: string | null;
|
||||
'git'?: GitContext | null;
|
||||
'fork_source_ref'?: ForkSourceRef | null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue