feat(server): add variables API (#430)

## Summary

Adds a workflow-visible variables store and HTTP API for managing
non-sensitive run variables, then wires those variables into run config
interpolation before run creation, validation, and preflight.

## What Changed

- Adds `/api/v1/variables` CRUD endpoints backed by a JSON variable
store and generated Rust/TypeScript API types.
- Supports `{{ vars.NAME }}` interpolation alongside existing `{{
env.NAME }}` handling for run-owned config fields, including
environment, MCP, hook, artifact, checkpoint, SCM, and notification
settings.
- Reuses canonical `fabro-types` variable DTOs in `fabro-api` and adds
OpenAPI name patterns so clients see the same env-style variable
contract enforced by the server.
- Keeps variable updates store-owned with `update_existing`, avoiding
duplicated not-found/update semantics in the HTTP handler.
- Shares env-style name validation between variables, interpolation
parsing, and vault token names to avoid grammar drift.

Variables are intentionally non-sensitive: list/get responses include
values, unlike vault secrets.

## Validation

- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo test -p fabro-types`
- `cargo test -p fabro-variable`
- `cargo test -p fabro-api --test variable_round_trip`
- `cargo test -p fabro-server --features test-support --test it
api::variables`
- `cargo +nightly-2026-04-14 clippy -p fabro-types -p fabro-variable -p
fabro-vault --all-targets -- -D warnings`
- `cargo +nightly-2026-04-14 clippy -p fabro-server --features
test-support --all-targets -- -D warnings`

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex/)
This commit is contained in:
Bryan Helmkamp 2026-05-27 11:46:36 -04:00 committed by GitHub
parent 8bca376f35
commit b1bd2f522c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2417 additions and 37 deletions

13
Cargo.lock generated
View file

@ -2299,6 +2299,7 @@ dependencies = [
"fabro-types",
"fabro-util",
"fabro-validate",
"fabro-variable",
"fabro-vault",
"fabro-workflow",
"futures-util",
@ -2554,6 +2555,18 @@ dependencies = [
"toml 0.8.23",
]
[[package]]
name = "fabro-variable"
version = "0.245.0-nightly.1"
dependencies = [
"chrono",
"fabro-types",
"serde",
"serde_json",
"tempfile",
"ulid",
]
[[package]]
name = "fabro-vault"
version = "0.246.0-nightly.0"

View file

@ -4433,6 +4433,176 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
# ── Variables ────────────────────────────────────────────────────────
/api/v1/variables:
get:
operationId: listVariables
tags: [Variables]
summary: List variables
description: Returns non-sensitive variables, including values.
responses:
"200":
description: Variable list
content:
application/json:
schema:
$ref: "#/components/schemas/VariableListResponse"
post:
operationId: createVariable
tags: [Variables]
summary: Store or update a variable
description: Stores a non-sensitive variable for run config interpolation.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateVariableRequest"
responses:
"200":
description: Variable stored
content:
application/json:
schema:
$ref: "#/components/schemas/Variable"
"400":
description: Invalid variable name or request body
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"500":
description: Variable store write failed
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/variables/{name}:
parameters:
- name: name
in: path
required: true
schema:
type: string
pattern: "^[A-Za-z_][A-Za-z0-9_]*$"
description: Variable name.
get:
operationId: getVariable
tags: [Variables]
summary: Get a variable
responses:
"200":
description: Variable
content:
application/json:
schema:
$ref: "#/components/schemas/Variable"
"400":
description: Invalid variable name
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Variable not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
put:
operationId: updateVariable
tags: [Variables]
summary: Replace a variable value
description: Replaces a variable value and preserves the existing description when omitted.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateVariableRequest"
responses:
"200":
description: Variable updated
content:
application/json:
schema:
$ref: "#/components/schemas/Variable"
"400":
description: Invalid variable name or request body
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Variable not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"500":
description: Variable store write failed
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
delete:
operationId: deleteVariable
tags: [Variables]
summary: Delete a variable
responses:
"204":
description: Variable deleted
"400":
description: Invalid variable name
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Variable not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"500":
description: Variable store write failed
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
# ── Repos ────────────────────────────────────────────────────────────
/api/v1/repos/github/{owner}/{name}:
@ -13099,6 +13269,78 @@ components:
items:
$ref: "#/components/schemas/SecretMetadata"
Variable:
description: Non-sensitive variable available for run config interpolation.
type: object
required:
- name
- value
- created_at
- updated_at
properties:
name:
type: string
pattern: "^[A-Za-z_][A-Za-z0-9_]*$"
description: Env-style variable name.
example: DEPLOY_ENV
value:
type: string
description: Variable value.
example: production
description:
type: string
description: Optional operator-facing description of the variable.
created_at:
type: string
format: date-time
description: When the variable was first stored.
updated_at:
type: string
format: date-time
description: When the variable was last updated.
VariableListResponse:
description: List of stored variables.
type: object
required:
- data
properties:
data:
type: array
items:
$ref: "#/components/schemas/Variable"
CreateVariableRequest:
description: Request to store or update a variable.
type: object
required:
- name
- value
properties:
name:
type: string
pattern: "^[A-Za-z_][A-Za-z0-9_]*$"
description: Env-style variable name.
value:
type: string
description: Variable value. Empty values are allowed.
description:
type: string
description: Optional operator-facing description of the variable.
UpdateVariableRequest:
description: Request to update a variable.
type: object
required:
- value
properties:
value:
type: string
description: Replacement value. Empty values are allowed.
description:
type: string
description: Optional operator-facing description. Omitted descriptions preserve the existing value.
RepoCheckResponse:
description: Repository access check result.
type: object

View file

@ -437,6 +437,22 @@ fn main() {
&[],
),
("SecretMetadata", "fabro_types::SecretMetadata", &[]),
("Variable", "fabro_types::Variable", &[]),
(
"VariableListResponse",
"fabro_types::VariableListResponse",
&[],
),
(
"CreateVariableRequest",
"fabro_types::CreateVariableRequest",
&[],
),
(
"UpdateVariableRequest",
"fabro_types::UpdateVariableRequest",
&[],
),
("InterviewOption", "fabro_types::InterviewOption", &[]),
(
"InterviewQuestionRecord",

View file

@ -36,9 +36,9 @@ pub mod types {
pub use fabro_types::{
ActivatedSkill, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary,
AgentToolCategory, AgentToolSource, AgentToolSummary, AgentToolsAvailableProps, AskFabro,
AuthMethod, BilledTokenCounts, CommandTermination, Conclusion, DiffStats, DiffSummary,
DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail,
FailureSignature, GitContext, IdpIdentity, IntegrationConnectionKind,
AuthMethod, BilledTokenCounts, CommandTermination, Conclusion, CreateVariableRequest,
DiffStats, DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory,
FailureDetail, FailureSignature, GitContext, IdpIdentity, IntegrationConnectionKind,
IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider,
IntegrationStatus, InterviewOption, InterviewQuestionRecord, McpServerProjection,
McpServerStatus, PairId, PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord,
@ -59,7 +59,8 @@ pub mod types {
StageContextWindowStaleness, StageContextWindowUnavailableReason,
StageContextWindowWarning, StageHandler, StageModelUsage, StageOutcome, StageProjection,
StageState, SubAgentProjection, SubAgentStatus, SystemActorKind, SystemIntegrationStatus,
SystemIntegrationsResponse, TodoListProjection, TurnId, UserPrincipal, WorkflowSettings,
SystemIntegrationsResponse, TodoListProjection, TurnId, UpdateVariableRequest,
UserPrincipal, Variable, VariableListResponse, WorkflowSettings,
};
pub use crate::generated::types::*;

View file

@ -0,0 +1,79 @@
use std::any::{TypeId, type_name};
use fabro_api::types::{
CreateVariableRequest as ApiCreateVariableRequest,
UpdateVariableRequest as ApiUpdateVariableRequest, Variable as ApiVariable,
VariableListResponse as ApiVariableListResponse,
};
use fabro_types::{CreateVariableRequest, UpdateVariableRequest, Variable, VariableListResponse};
use serde_json::json;
#[test]
fn variable_api_types_reuse_canonical_types() {
assert_same_type::<ApiVariable, Variable>();
assert_same_type::<ApiVariableListResponse, VariableListResponse>();
assert_same_type::<ApiCreateVariableRequest, CreateVariableRequest>();
assert_same_type::<ApiUpdateVariableRequest, UpdateVariableRequest>();
}
#[test]
fn variable_round_trips_representative_json() {
let value = json!({
"name": "DEPLOY_ENV",
"value": "production",
"description": "Deployment target",
"created_at": "2026-05-27T12:34:56Z",
"updated_at": "2026-05-27T12:40:00Z"
});
let variable: Variable = serde_json::from_value(value.clone()).unwrap();
assert_eq!(variable.name, "DEPLOY_ENV");
assert_eq!(variable.value, "production");
assert_eq!(variable.description.as_deref(), Some("Deployment target"));
assert_eq!(serde_json::to_value(variable).unwrap(), value);
}
#[test]
fn variable_requests_round_trip_json() {
let create = json!({
"name": "EMPTY_ALLOWED",
"value": "",
"description": "Intentionally blank"
});
let parsed_create: CreateVariableRequest = serde_json::from_value(create.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed_create).unwrap(), create);
let update = json!({
"value": "updated"
});
let parsed_update: UpdateVariableRequest = serde_json::from_value(update.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed_update).unwrap(), update);
}
#[test]
fn variable_list_response_wraps_data() {
let value = json!({
"data": [{
"name": "DEPLOY_ENV",
"value": "production",
"created_at": "2026-05-27T12:34:56Z",
"updated_at": "2026-05-27T12:40:00Z"
}]
});
let response: VariableListResponse = serde_json::from_value(value.clone()).unwrap();
assert_eq!(response.data.len(), 1);
assert_eq!(serde_json::to_value(response).unwrap(), value);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -47,6 +47,11 @@ impl Storage {
.join("secrets.json")
}
#[must_use]
pub fn variables_path(&self) -> PathBuf {
self.root.join("variables.json")
}
#[must_use]
pub fn runtime_directory(&self) -> RuntimeDirectory {
RuntimeDirectory::new(self.root.clone())
@ -183,6 +188,10 @@ mod tests {
storage.secrets_path(),
std::path::Path::new("/tmp/fabro-data/vaults/default/secrets.json")
);
assert_eq!(
storage.variables_path(),
std::path::Path::new("/tmp/fabro-data/variables.json")
);
assert_eq!(
storage.objects_dir(),
std::path::Path::new("/tmp/fabro-data/objects")

View file

@ -45,6 +45,7 @@ fabro-api = { path = "../fabro-api" }
fabro-client = { path = "../fabro-client" }
fabro-store = { path = "../fabro-store" }
fabro-vault = { path = "../fabro-vault" }
fabro-variable = { path = "../fabro-variable" }
fabro-http.workspace = true
fabro-redact.workspace = true
fabro-static.workspace = true

View file

@ -714,6 +714,7 @@ where
};
let storage = Storage::new(&data_dir);
let vault_path = storage.secrets_path();
let variables_path = storage.variables_path();
let server_env_path = storage.runtime_directory().env_path();
runtime_settings.server_settings = runtime_settings
.server_settings
@ -804,6 +805,7 @@ where
store,
artifact_store,
vault_path,
variables_path,
preloaded_vault: Some(startup_vault),
server_secrets,
env_lookup,
@ -1249,9 +1251,9 @@ mod tests {
apply_effective_log_destination, bind_tcp_host_with_fallback,
build_local_object_store_with_preference, build_object_store_from_settings_with_lookup,
build_slatedb_store, force_exit_after_shutdown, resolve_bind_request_from_server_settings,
resolve_github_webhook_ip_allowlist, resolve_startup_github_webhook_ip_allowlist,
serve_overrides, serve_until_shutdown, server_bind_title, server_title,
spawn_shutdown_orchestrator_inner,
resolve_github_webhook_ip_allowlist, resolve_interp,
resolve_startup_github_webhook_ip_allowlist, serve_overrides, serve_until_shutdown,
server_bind_title, server_title, spawn_shutdown_orchestrator_inner,
};
use crate::server::ResolvedAppStateSettings;
@ -1286,6 +1288,16 @@ mod tests {
.expect("settings should resolve")
}
#[test]
fn server_settings_interpolation_rejects_variables() {
let err = resolve_interp(&InterpString::parse("{{ vars.STORAGE_ROOT }}")).unwrap_err();
let rendered = format!("{err:#}");
assert!(rendered.contains("failed to resolve {{ vars.STORAGE_ROOT }}"));
assert!(rendered.contains("variable \"STORAGE_ROOT\""));
assert!(rendered.contains("not supported in this interpolation context"));
}
fn resolved_runtime_settings(source: &str) -> ResolvedAppStateSettings {
let manifest_run_defaults = manifest_run_defaults(source);
ResolvedAppStateSettings {

View file

@ -30,9 +30,9 @@ pub use fabro_api::types::{
BatchRunLifecycleSummary, BillingByModel, BillingStageRef, CloseRunPullRequestResponse,
CompletionContentPart, CompletionMessage, CompletionMessageRole, CompletionResponse,
CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest,
CreateRunPullRequestRequest, CreateSecretRequest, DeleteRunResponse, DeleteRunSandbox,
DeleteSecretRequest, DenyRunRequest, DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow,
ErrorResponseEntry, ForkRequest, ForkResponse, IntegrationConnectionKind,
CreateRunPullRequestRequest, CreateSecretRequest, CreateVariableRequest, DeleteRunResponse,
DeleteRunSandbox, DeleteSecretRequest, DenyRunRequest, DiskUsageResponse, DiskUsageRunRow,
DiskUsageSummaryRow, ErrorResponseEntry, ForkRequest, ForkResponse, IntegrationConnectionKind,
IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider,
IntegrationStatus, LinkRunPullRequestRequest, MergeRunPullRequestRequest,
MergeRunPullRequestResponse, ModelReference, PaginatedEventList, PaginatedRunList,
@ -46,7 +46,8 @@ pub use fabro_api::types::{
SystemDiskResourceScope, SystemDiskResources, SystemInfoResponse, SystemIntegrationStatus,
SystemIntegrationsResponse, SystemMemoryResourceScope, SystemMemoryResources,
SystemRepairRunIssue, SystemRepairRunsResponse, SystemResourcesResponse, SystemRunCounts,
TimelineEntryResponse, VncPreviewResponse, WriteBlobResponse,
TimelineEntryResponse, UpdateVariableRequest, VariableListResponse, VncPreviewResponse,
WriteBlobResponse,
};
use fabro_auth::{CredentialSource, VaultCredentialSource, auth_issue_message};
#[cfg(test)]
@ -103,6 +104,7 @@ use fabro_util::error::{
SharedError, collect_causes, render_compact_with_causes, render_with_causes,
};
use fabro_util::version::FABRO_VERSION;
use fabro_variable::{Error as VariableError, VariableStore};
use fabro_vault::{Error as VaultError, SecretType, Vault};
use fabro_workflow::artifact_upload::ArtifactSink;
#[cfg(test)]
@ -1019,6 +1021,7 @@ pub struct AppState {
parent_link_lock: AsyncMutex<()>,
pub(crate) vault: Arc<AsyncRwLock<Vault>>,
pub(crate) variables: Arc<AsyncRwLock<VariableStore>>,
pub(super) server_secrets: ServerSecrets,
pub(crate) llm_source: Arc<dyn CredentialSource>,
manifest_run_defaults: RwLock<Arc<RunLayer>>,
@ -1125,6 +1128,7 @@ pub(crate) struct AppStateConfig {
pub(crate) store: Arc<Database>,
pub(crate) artifact_store: ArtifactStore,
pub(crate) vault_path: PathBuf,
pub(crate) variables_path: PathBuf,
pub(crate) preloaded_vault: Option<Vault>,
pub(crate) server_secrets: ServerSecrets,
pub(crate) env_lookup: EnvLookup,
@ -2167,6 +2171,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
store,
artifact_store,
vault_path,
variables_path,
preloaded_vault,
server_secrets,
env_lookup,
@ -2177,6 +2182,8 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
shutdown,
} = config;
let variables = VariableStore::load(variables_path).context("load variables")?;
let variables = Arc::new(AsyncRwLock::new(variables));
let vault = match preloaded_vault {
Some(vault) => vault,
None => load_startup_vault(&vault_path)?,
@ -2266,6 +2273,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
pull_request_create_locks: Arc::new(Mutex::new(HashMap::new())),
parent_link_lock: AsyncMutex::new(()),
vault,
variables,
server_secrets,
llm_source,
manifest_run_defaults: RwLock::new(current_manifest_run_defaults),

View file

@ -21,6 +21,7 @@ mod secrets;
mod sessions;
mod steer;
pub(in crate::server) mod system;
mod variables;
pub(super) use system::{health, openapi_spec};
@ -163,6 +164,7 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> {
.merge(graph::run_routes())
.merge(models::routes())
.merge(secrets::routes())
.merge(variables::routes())
.merge(sessions::routes())
.merge(system::routes())
.merge(completions::routes())

View file

@ -19,10 +19,11 @@ use fabro_api::types::{
use fabro_config::Storage;
use fabro_interview::AnswerSubmission;
use fabro_llm::client::Client as LlmClient;
use fabro_types::settings::ResolveEnvError;
use fabro_types::{
Principal, RunClientProvenance, RunId, RunProvenance, RunServerProvenance, StageContextWindow,
StageContextWindowStaleness, StageContextWindowUnavailableReason, StageHandler,
StageModelUsage, StageProjection, SystemActorKind, parse_blob_ref,
StageModelUsage, StageProjection, SystemActorKind, WorkflowSettings, parse_blob_ref,
};
use fabro_util::version::FABRO_VERSION;
use fabro_workflow::command_log::{command_log_path, read_json_string_blob, read_log_slice};
@ -599,7 +600,7 @@ async fn create_run(
let explicit_title_supplied = req.title.is_some();
let manifest_run_defaults = state.manifest_run_defaults();
let manifest_environment_defaults = state.manifest_environment_defaults();
let prepared = match run_manifest::prepare_manifest_with_environment_defaults(
let mut prepared = match run_manifest::prepare_manifest_with_environment_defaults(
manifest_run_defaults.as_ref(),
manifest_environment_defaults.as_ref(),
&req,
@ -607,6 +608,10 @@ async fn create_run(
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
if let Err(err) = substitute_run_variables(&state, &mut prepared.settings).await {
return ApiError::bad_request(format!("Run config variable interpolation failed: {err}"))
.into_response();
}
let run_id = prepared.run_id.unwrap_or_else(RunId::new);
let provider = run_manifest::effective_sandbox_provider(&prepared.settings.run);
if let Some(error) =
@ -856,7 +861,7 @@ async fn run_preflight(
) -> Response {
let manifest_run_defaults = state.manifest_run_defaults();
let manifest_environment_defaults = state.manifest_environment_defaults();
let prepared = match run_manifest::prepare_manifest_with_environment_defaults(
let mut prepared = match run_manifest::prepare_manifest_with_environment_defaults(
manifest_run_defaults.as_ref(),
manifest_environment_defaults.as_ref(),
&req,
@ -864,6 +869,10 @@ async fn run_preflight(
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
if let Err(err) = substitute_run_variables(&state, &mut prepared.settings).await {
return ApiError::bad_request(format!("Run config variable interpolation failed: {err}"))
.into_response();
}
let mut validated = match run_manifest::validate_prepared_manifest(&prepared, state.catalog()) {
Ok(validated) => validated,
Err(WorkflowError::Parse(_)) => {
@ -889,7 +898,7 @@ async fn validate_run_manifest(
) -> Response {
let manifest_run_defaults = state.manifest_run_defaults();
let manifest_environment_defaults = state.manifest_environment_defaults();
let prepared = match run_manifest::prepare_manifest_with_environment_defaults(
let mut prepared = match run_manifest::prepare_manifest_with_environment_defaults(
manifest_run_defaults.as_ref(),
manifest_environment_defaults.as_ref(),
&req,
@ -897,6 +906,10 @@ async fn validate_run_manifest(
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
if let Err(err) = substitute_run_variables(&state, &mut prepared.settings).await {
return ApiError::bad_request(format!("Run config variable interpolation failed: {err}"))
.into_response();
}
let validated = match run_manifest::validate_prepared_manifest(&prepared, state.catalog()) {
Ok(validated) => validated,
Err(WorkflowError::Parse(_)) => {
@ -911,6 +924,16 @@ async fn validate_run_manifest(
.into_response()
}
async fn substitute_run_variables(
state: &AppState,
settings: &mut WorkflowSettings,
) -> Result<(), ResolveEnvError> {
let variables = state.variables.read().await;
settings
.run
.substitute_variables(|name| variables.get_value(name).map(str::to_string))
}
async fn get_run_status(
RequireRunManagementTarget(id, _actor): RequireRunManagementTarget,
State(state): State<Arc<AppState>>,

View file

@ -0,0 +1,128 @@
use std::sync::Arc;
use fabro_types::Variable;
use tokio::task::JoinError;
use super::super::{
ApiError, AppState, CreateVariableRequest, IntoResponse, Json, Path, RequiredUser, Response,
Router, State, StatusCode, UpdateVariableRequest, VariableError, VariableListResponse,
VariableStore, get, spawn_blocking,
};
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/variables", get(list_variables).post(create_variable))
.route(
"/variables/{name}",
get(get_variable)
.put(update_variable)
.delete(delete_variable),
)
}
async fn list_variables(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response {
let data = state.variables.read().await.list();
(StatusCode::OK, Json(VariableListResponse { data })).into_response()
}
async fn create_variable(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Json(body): Json<CreateVariableRequest>,
) -> Response {
let name = body.name;
let value = body.value;
let description = body.description;
let state_for_write = Arc::clone(&state);
let result = spawn_blocking(move || {
let mut variables = state_for_write.variables.blocking_write();
variables.set(&name, &value, description.as_deref())
})
.await;
variable_write_response(result)
}
async fn get_variable(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> Response {
if let Err(VariableError::InvalidName(_)) = VariableStore::validate_name(&name) {
return ApiError::bad_request("invalid variable name").into_response();
}
match state.variables.read().await.get(&name) {
Some(variable) => (StatusCode::OK, Json(variable)).into_response(),
None => ApiError::not_found(format!("variable not found: {name}")).into_response(),
}
}
async fn update_variable(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
Json(body): Json<UpdateVariableRequest>,
) -> Response {
let value = body.value;
let description = body.description;
let state_for_write = Arc::clone(&state);
let result = spawn_blocking(move || {
let mut variables = state_for_write.variables.blocking_write();
variables.update_existing(&name, &value, description.as_deref())
})
.await;
variable_write_response(result)
}
async fn delete_variable(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> Response {
let state_for_write = Arc::clone(&state);
let result = spawn_blocking(move || {
let mut variables = state_for_write.variables.blocking_write();
variables.remove(&name)
})
.await;
match result {
Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(),
Ok(Err(err)) => variable_error_response(err),
Err(err) => ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("variable delete task failed: {err}"),
)
.into_response(),
}
}
fn variable_write_response(result: Result<Result<Variable, VariableError>, JoinError>) -> Response {
match result {
Ok(Ok(variable)) => (StatusCode::OK, Json(variable)).into_response(),
Ok(Err(err)) => variable_error_response(err),
Err(err) => ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("variable write task failed: {err}"),
)
.into_response(),
}
}
fn variable_error_response(err: VariableError) -> Response {
match err {
VariableError::InvalidName(_) => {
ApiError::bad_request("invalid variable name").into_response()
}
VariableError::NotFound(name) => {
ApiError::not_found(format!("variable not found: {name}")).into_response()
}
VariableError::Io(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
VariableError::Serde(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
}
}

View file

@ -1652,6 +1652,7 @@ fn slack_app_state_with_secret_sources(
max_concurrent_runs: 5,
store,
artifact_store,
variables_path: vault_path.with_file_name("variables.json"),
vault_path,
preloaded_vault: Some(vault),
server_secrets: load_test_server_secrets(server_env_path, server_secret_env),
@ -1747,6 +1748,7 @@ fn slack_service_respects_disabled_server_config_even_with_vault_tokens() {
max_concurrent_runs: 5,
store,
artifact_store,
variables_path: vault_path.with_file_name("variables.json"),
vault_path,
preloaded_vault: Some(vault),
server_secrets: load_test_server_secrets(
@ -2053,6 +2055,7 @@ methods = ["dev-token"]
max_concurrent_runs: 5,
store,
artifact_store,
variables_path: vault_path.with_file_name("variables.json"),
vault_path,
preloaded_vault: None,
server_secrets: ServerSecrets::load(server_env_path, HashMap::new()).unwrap(),
@ -2176,6 +2179,7 @@ fn build_test_app_state_with_vault_path(vault_path: &Path) -> anyhow::Result<Arc
max_concurrent_runs: 5,
store,
artifact_store,
variables_path: vault_path.with_file_name("variables.json"),
vault_path: vault_path.to_path_buf(),
preloaded_vault: None,
server_secrets: load_test_server_secrets(
@ -5272,6 +5276,7 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings(
max_concurrent_runs: 5,
store,
artifact_store,
variables_path: vault_path.with_file_name("variables.json"),
vault_path,
preloaded_vault: None,
server_secrets: load_test_server_secrets(server_env_path, HashMap::new()),

View file

@ -224,6 +224,7 @@ impl TestAppStateBuilder {
max_concurrent_runs: self.max_concurrent_runs,
store,
artifact_store,
variables_path: vault_path.with_file_name("variables.json"),
vault_path,
preloaded_vault: None,
server_secrets: load_test_server_secrets(server_env_path, self.server_secret_env),

View file

@ -12,3 +12,4 @@ mod sessions;
mod settings;
mod system;
mod tcp;
mod variables;

View file

@ -0,0 +1,254 @@
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use tower::ServiceExt;
use crate::helpers::{
MINIMAL_DOT, api, body_json, minimal_manifest_json, response_json, response_status,
test_app_state,
};
fn json_request(method: Method, path: &str, body: &serde_json::Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(api(path))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(body).expect("request body should serialize"),
))
.expect("request should build")
}
fn empty_request(method: Method, path: &str) -> Request<Body> {
Request::builder()
.method(method)
.uri(api(path))
.body(Body::empty())
.expect("request should build")
}
#[tokio::test]
async fn variables_crud_exposes_values() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let list_empty = app
.clone()
.oneshot(empty_request(Method::GET, "/variables"))
.await
.expect("GET /variables should route");
let body = response_json(list_empty, StatusCode::OK, "GET /api/v1/variables").await;
assert_eq!(body, serde_json::json!({ "data": [] }));
let create = app
.clone()
.oneshot(json_request(
Method::POST,
"/variables",
&serde_json::json!({
"name": "DEPLOY_ENV",
"value": "staging",
"description": "Deployment target"
}),
))
.await
.expect("POST /variables should route");
let body = response_json(create, StatusCode::OK, "POST /api/v1/variables").await;
assert_eq!(body["name"], "DEPLOY_ENV");
assert_eq!(body["value"], "staging");
assert_eq!(body["description"], "Deployment target");
assert!(body.get("created_at").is_some());
assert!(body.get("updated_at").is_some());
let post_upsert = app
.clone()
.oneshot(json_request(
Method::POST,
"/variables",
&serde_json::json!({
"name": "DEPLOY_ENV",
"value": "qa"
}),
))
.await
.expect("POST /variables upsert should route");
let body = response_json(post_upsert, StatusCode::OK, "POST /api/v1/variables").await;
assert_eq!(body["value"], "qa");
assert_eq!(body["description"], "Deployment target");
let get = app
.clone()
.oneshot(empty_request(Method::GET, "/variables/DEPLOY_ENV"))
.await
.expect("GET /variables/DEPLOY_ENV should route");
let body = response_json(get, StatusCode::OK, "GET /api/v1/variables/DEPLOY_ENV").await;
assert_eq!(body["value"], "qa");
let update = app
.clone()
.oneshot(json_request(
Method::PUT,
"/variables/DEPLOY_ENV",
&serde_json::json!({ "value": "production" }),
))
.await
.expect("PUT /variables/DEPLOY_ENV should route");
let body = response_json(update, StatusCode::OK, "PUT /api/v1/variables/DEPLOY_ENV").await;
assert_eq!(body["value"], "production");
assert_eq!(body["description"], "Deployment target");
let list = app
.clone()
.oneshot(empty_request(Method::GET, "/variables"))
.await
.expect("GET /variables should route");
let body = response_json(list, StatusCode::OK, "GET /api/v1/variables").await;
assert_eq!(body["data"][0]["name"], "DEPLOY_ENV");
assert_eq!(body["data"][0]["value"], "production");
let delete = app
.clone()
.oneshot(empty_request(Method::DELETE, "/variables/DEPLOY_ENV"))
.await
.expect("DELETE /variables/DEPLOY_ENV should route");
response_status(
delete,
StatusCode::NO_CONTENT,
"DELETE /api/v1/variables/DEPLOY_ENV",
)
.await;
let missing = app
.oneshot(empty_request(Method::GET, "/variables/DEPLOY_ENV"))
.await
.expect("GET /variables/DEPLOY_ENV should route");
response_status(
missing,
StatusCode::NOT_FOUND,
"GET /api/v1/variables/DEPLOY_ENV",
)
.await;
}
#[tokio::test]
async fn variables_validate_names_and_allow_empty_values() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let invalid = app
.clone()
.oneshot(json_request(
Method::POST,
"/variables",
&serde_json::json!({
"name": "1BAD",
"value": "nope"
}),
))
.await
.expect("POST /variables should route");
response_status(invalid, StatusCode::BAD_REQUEST, "POST /api/v1/variables").await;
let invalid_get = app
.clone()
.oneshot(empty_request(Method::GET, "/variables/1BAD"))
.await
.expect("GET /variables/1BAD should route");
response_status(
invalid_get,
StatusCode::BAD_REQUEST,
"GET /api/v1/variables/1BAD",
)
.await;
let empty = app
.clone()
.oneshot(json_request(
Method::POST,
"/variables",
&serde_json::json!({
"name": "EMPTY_ALLOWED",
"value": ""
}),
))
.await
.expect("POST /variables should route");
let body = response_json(empty, StatusCode::OK, "POST /api/v1/variables").await;
assert_eq!(body["value"], "");
let missing_delete = app
.oneshot(empty_request(Method::DELETE, "/variables/MISSING"))
.await
.expect("DELETE /variables/MISSING should route");
response_status(
missing_delete,
StatusCode::NOT_FOUND,
"DELETE /api/v1/variables/MISSING",
)
.await;
}
#[tokio::test]
async fn run_config_substitutes_variables_before_persisting_settings() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let create_variable = app
.clone()
.oneshot(json_request(
Method::POST,
"/variables",
&serde_json::json!({
"name": "RUNTIME_TOKEN",
"value": "token-from-variable"
}),
))
.await
.expect("POST /variables should route");
response_status(create_variable, StatusCode::OK, "POST /api/v1/variables").await;
let mut manifest = minimal_manifest_json(MINIMAL_DOT);
manifest["configs"] = serde_json::json!([{
"type": "project",
"path": ".fabro/project.toml",
"source": r#"
_version = 1
[run.environment]
id = "local"
[environments.local]
provider = "local"
[environments.local.env]
RUNTIME_TOKEN = "{{ vars.RUNTIME_TOKEN }}"
"#
}]);
let create_run = app
.clone()
.oneshot(json_request(Method::POST, "/runs", &manifest))
.await
.expect("POST /runs should route");
let create_status = create_run.status();
let create_body = body_json(create_run.into_body()).await;
assert_eq!(create_status, StatusCode::CREATED, "{create_body}");
let run_id = create_body["id"]
.as_str()
.expect("create run response should include id");
let settings = app
.oneshot(empty_request(
Method::GET,
&format!("/runs/{run_id}/settings"),
))
.await
.expect("GET run settings should route");
let body = response_json(
settings,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/settings"),
)
.await;
assert_eq!(
body["run"]["environment"]["env"]["RUNTIME_TOKEN"],
"token-from-variable"
);
}

View file

@ -47,6 +47,7 @@ pub mod system_integrations;
pub mod timing;
pub mod todo;
pub mod transcript;
pub mod variable;
pub use artifact::ArtifactUpload;
pub use auth::{IdpIdentity, IdpIdentityError};
@ -157,3 +158,6 @@ pub use transcript::{
AudioData, ContentPart, DocumentData, ImageData, MessageId, MessageKind, MessageSource,
PairMessageRef, ThinkingData, ToolCall, ToolResult, TranscriptMessage,
};
pub use variable::{
CreateVariableRequest, UpdateVariableRequest, Variable, VariableListResponse, is_env_style_name,
};

View file

@ -1,8 +1,9 @@
//! Env var interpolation for config strings.
//! Env var and run variable interpolation for config strings.
//!
//! Any string field may use `{{ env.NAME }}` tokens, either as a whole value or
//! as one or more substrings inside a larger string. Resolution happens only
//! when the field is consumed, and provenance tracking lets outward-facing
//! as one or more substrings inside a larger string. Run-scoped settings may
//! additionally use non-sensitive `{{ vars.NAME }}` tokens. Resolution happens
//! only when the field is consumed, and provenance tracking lets outward-facing
//! renderers redact env-sourced values uniformly.
use std::fmt;
@ -10,7 +11,10 @@ use std::fmt;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// A config string that may contain `{{ env.NAME }}` tokens.
use crate::variable::is_env_style_name;
/// A config string that may contain `{{ env.NAME }}` or `{{ vars.NAME }}`
/// tokens.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterpString {
segments: Vec<Segment>,
@ -20,6 +24,7 @@ pub struct InterpString {
enum Segment {
Literal(String),
EnvVar(String),
Variable(String),
}
impl InterpString {
@ -30,7 +35,9 @@ impl InterpString {
match segments.last_mut() {
Some(Segment::Literal(existing)) => existing.push_str(text),
Some(Segment::EnvVar(_)) | None => segments.push(Segment::Literal(text.to_owned())),
Some(Segment::EnvVar(_) | Segment::Variable(_)) | None => {
segments.push(Segment::Literal(text.to_owned()));
}
}
}
@ -47,6 +54,16 @@ impl InterpString {
Some(name.to_owned())
}
fn parse_vars_token(token: &str) -> Option<String> {
let trimmed = token.trim();
let name = trimmed.strip_prefix("vars.")?;
if is_env_style_name(name) {
Some(name.to_owned())
} else {
None
}
}
/// Parse a raw string into its literal/env-var segments.
///
/// The [`From<String>`] and [`From<&str>`] impls delegate here.
@ -66,6 +83,8 @@ impl InterpString {
let token = &after_open[..close];
if let Some(name) = Self::parse_env_token(token) {
segments.push(Segment::EnvVar(name));
} else if let Some(name) = Self::parse_vars_token(token) {
segments.push(Segment::Variable(name));
} else {
Self::push_literal(&mut segments, &rest[start..start + 2 + close + 2]);
}
@ -89,7 +108,7 @@ impl InterpString {
Self { segments }
}
/// True when this string contains no env var tokens.
/// True when this string contains no interpolation tokens.
#[must_use]
pub fn is_literal(&self) -> bool {
self.segments
@ -105,6 +124,14 @@ impl InterpString {
.any(|seg| matches!(seg, Segment::EnvVar(_)))
}
/// True when this string contains at least one run variable token.
#[must_use]
pub fn references_vars(&self) -> bool {
self.segments
.iter()
.any(|seg| matches!(seg, Segment::Variable(_)))
}
/// The env var names referenced by this string, in source order.
#[must_use]
pub fn env_var_names(&self) -> Vec<&str> {
@ -112,7 +139,19 @@ impl InterpString {
.iter()
.filter_map(|seg| match seg {
Segment::EnvVar(name) => Some(name.as_str()),
Segment::Literal(_) => None,
Segment::Literal(_) | Segment::Variable(_) => None,
})
.collect()
}
/// The run variable names referenced by this string, in source order.
#[must_use]
pub fn var_names(&self) -> Vec<&str> {
self.segments
.iter()
.filter_map(|seg| match seg {
Segment::Variable(name) => Some(name.as_str()),
Segment::Literal(_) | Segment::EnvVar(_) => None,
})
.collect()
}
@ -129,6 +168,11 @@ impl InterpString {
out.push_str(name);
out.push_str(" }}");
}
Segment::Variable(name) => {
out.push_str("{{ vars.");
out.push_str(name);
out.push_str(" }}");
}
}
}
out
@ -151,11 +195,14 @@ impl InterpString {
Segment::Literal(text) => value.push_str(text),
Segment::EnvVar(name) => {
let Some(resolved) = lookup(name) else {
return Err(ResolveEnvError { name: name.clone() });
return Err(ResolveEnvError::missing_env(name));
};
value.push_str(&resolved);
used.push(name.clone());
}
Segment::Variable(name) => {
return Err(ResolveEnvError::unsupported_variable(name));
}
}
}
@ -166,6 +213,73 @@ impl InterpString {
};
Ok(Resolved { value, provenance })
}
/// Resolve env and run variable tokens with separate lookup functions.
///
/// Variables are non-sensitive, so variable-only interpolation does not
/// mark the value as env-sourced for redaction.
pub fn resolve_with_variables<F, G>(
&self,
mut env_lookup: F,
mut variable_lookup: G,
) -> Result<Resolved, ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
G: FnMut(&str) -> Option<String>,
{
let mut value = String::new();
let mut used_env = Vec::new();
for seg in &self.segments {
match seg {
Segment::Literal(text) => value.push_str(text),
Segment::EnvVar(name) => {
let Some(resolved) = env_lookup(name) else {
return Err(ResolveEnvError::missing_env(name));
};
value.push_str(&resolved);
used_env.push(name.clone());
}
Segment::Variable(name) => {
let Some(resolved) = variable_lookup(name) else {
return Err(ResolveEnvError::missing_variable(name));
};
value.push_str(&resolved);
}
}
}
let provenance = if used_env.is_empty() {
Provenance::Literal
} else {
Provenance::EnvSourced { names: used_env }
};
Ok(Resolved { value, provenance })
}
/// Substitute only `{{ vars.* }}` tokens while preserving `{{ env.* }}`
/// tokens for their existing consumption-time env lookup.
pub fn substitute_variables<F>(&self, mut lookup: F) -> Result<Self, ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
let mut segments = Vec::new();
for seg in &self.segments {
match seg {
Segment::Literal(text) => Self::push_literal(&mut segments, text),
Segment::EnvVar(name) => segments.push(Segment::EnvVar(name.clone())),
Segment::Variable(name) => {
let Some(resolved) = lookup(name) else {
return Err(ResolveEnvError::missing_variable(name));
};
Self::push_literal(&mut segments, &resolved);
}
}
}
if segments.is_empty() {
segments.push(Segment::Literal(String::new()));
}
Ok(Self { segments })
}
}
impl From<String> for InterpString {
@ -201,15 +315,59 @@ pub enum Provenance {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolveEnvError {
pub name: String,
pub kind: ResolveEnvErrorKind,
}
impl ResolveEnvError {
fn missing_env(name: &str) -> Self {
Self {
name: name.to_string(),
kind: ResolveEnvErrorKind::MissingEnv,
}
}
fn missing_variable(name: &str) -> Self {
Self {
name: name.to_string(),
kind: ResolveEnvErrorKind::MissingVariable,
}
}
fn unsupported_variable(name: &str) -> Self {
Self {
name: name.to_string(),
kind: ResolveEnvErrorKind::UnsupportedVariable,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolveEnvErrorKind {
MissingEnv,
MissingVariable,
UnsupportedVariable,
}
impl fmt::Display for ResolveEnvError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"environment variable {:?} referenced by {{{{ env.{} }}}} is not set",
self.name, self.name
)
match self.kind {
ResolveEnvErrorKind::MissingEnv => write!(
f,
"environment variable {:?} referenced by {{{{ env.{} }}}} is not set",
self.name, self.name
),
ResolveEnvErrorKind::MissingVariable => write!(
f,
"variable {:?} referenced by {{{{ vars.{} }}}} is not set",
self.name, self.name
),
ResolveEnvErrorKind::UnsupportedVariable => write!(
f,
"variable {:?} referenced by {{{{ vars.{} }}}} is not supported in this \
interpolation context",
self.name, self.name
),
}
}
}
@ -354,4 +512,51 @@ mod tests {
let rendered = serde_json::to_string(&parsed).unwrap();
assert_eq!(rendered, input);
}
#[test]
fn vars_reference_round_trips_source() {
let s = InterpString::parse("{{ vars.RUNTIME_TOKEN }}");
assert_eq!(s.var_names(), vec!["RUNTIME_TOKEN"]);
assert_eq!(s.as_source(), "{{ vars.RUNTIME_TOKEN }}");
}
#[test]
fn resolve_with_variables_substitutes_env_and_var_tokens() {
let s = InterpString::parse("https://{{ env.REGION }}.{{ vars.DOMAIN }}");
let resolved = s
.resolve_with_variables(
lookup_from(&[("REGION", "us-east-1")]),
lookup_from(&[("DOMAIN", "example.com")]),
)
.unwrap();
assert_eq!(resolved.value, "https://us-east-1.example.com");
assert_eq!(resolved.provenance, Provenance::EnvSourced {
names: vec!["REGION".into()],
});
}
#[test]
fn resolve_with_variables_reports_missing_variable() {
let s = InterpString::parse("{{ vars.MISSING }}");
let err = s
.resolve_with_variables(lookup_from(&[]), lookup_from(&[]))
.unwrap_err();
assert_eq!(err.name, "MISSING");
assert_eq!(err.kind, ResolveEnvErrorKind::MissingVariable);
}
#[test]
fn env_only_resolution_rejects_vars_reference() {
let s = InterpString::parse("{{ vars.RUNTIME_TOKEN }}");
let err = s.resolve(lookup_from(&[])).unwrap_err();
assert_eq!(err.name, "RUNTIME_TOKEN");
assert_eq!(err.kind, ResolveEnvErrorKind::UnsupportedVariable);
}
}

View file

@ -14,7 +14,7 @@ use serde::ser::SerializeStruct;
use serde::{Deserialize, Serialize};
use super::duration::Duration;
use super::interp::InterpString;
use super::interp::{InterpString, ResolveEnvError};
use super::model_ref::ModelRef;
use super::size::Size;
@ -76,6 +76,404 @@ impl Default for RunNamespace {
}
}
impl RunNamespace {
pub fn substitute_variables<F>(&mut self, mut lookup: F) -> Result<(), ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
substitute_goal(&mut self.goal, &mut lookup)?;
substitute_option(&mut self.working_dir, &mut lookup)?;
substitute_string_map(&mut self.metadata, &mut lookup)?;
substitute_option(&mut self.model.provider, &mut lookup)?;
substitute_option(&mut self.model.name, &mut lookup)?;
substitute_option_string(&mut self.model.controls.reasoning_effort, &mut lookup)?;
substitute_option_string(&mut self.model.controls.speed, &mut lookup)?;
if let Some(author) = &mut self.git.author {
substitute_option(&mut author.name, &mut lookup)?;
substitute_option(&mut author.email, &mut lookup)?;
}
substitute_string_vec(&mut self.checkpoint.exclude_globs, &mut lookup)?;
substitute_environment(&mut self.environment, &mut lookup)?;
substitute_map(&mut self.environment.env, &mut lookup)?;
for route in self.notifications.values_mut() {
substitute_option_string(&mut route.provider, &mut lookup)?;
substitute_string_vec(&mut route.events, &mut lookup)?;
if let Some(slack) = &mut route.slack {
substitute_option(&mut slack.channel, &mut lookup)?;
}
}
substitute_option_string(&mut self.interviews.provider, &mut lookup)?;
if let Some(slack) = &mut self.interviews.slack {
substitute_option(&mut slack.channel, &mut lookup)?;
}
substitute_map(&mut self.integrations.github.permissions, &mut lookup)?;
substitute_option(&mut self.scm.owner, &mut lookup)?;
substitute_option(&mut self.scm.repository, &mut lookup)?;
substitute_string_vec(&mut self.prepare.commands, &mut lookup)?;
for mcp in self.agent.mcps.values_mut() {
substitute_string(&mut mcp.name, &mut lookup)?;
substitute_mcp_transport(&mut mcp.transport, &mut lookup)?;
}
for hook in &mut self.hooks {
substitute_option_string(&mut hook.name, &mut lookup)?;
substitute_option_string(&mut hook.command, &mut lookup)?;
substitute_option_string(&mut hook.matcher, &mut lookup)?;
if let Some(hook_type) = &mut hook.hook_type {
substitute_hook_type(hook_type, &mut lookup)?;
}
}
substitute_option_string(&mut self.scm.provider, &mut lookup)?;
substitute_string_vec(&mut self.artifacts.include, &mut lookup)?;
Ok(())
}
}
fn substitute_goal<F>(goal: &mut Option<RunGoal>, lookup: &mut F) -> Result<(), ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
match goal {
Some(RunGoal::Inline(value) | RunGoal::File(value)) => substitute(value, lookup),
None => Ok(()),
}
}
fn substitute_option<F>(
value: &mut Option<InterpString>,
lookup: &mut F,
) -> Result<(), ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
match value {
Some(value) => substitute(value, lookup),
None => Ok(()),
}
}
fn substitute_map<F>(
values: &mut HashMap<String, InterpString>,
lookup: &mut F,
) -> Result<(), ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
for value in values.values_mut() {
substitute(value, lookup)?;
}
Ok(())
}
fn substitute<F>(value: &mut InterpString, lookup: &mut F) -> Result<(), ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
if !value.references_vars() {
return Ok(());
}
*value = value.substitute_variables(lookup)?;
Ok(())
}
fn substitute_string<F>(value: &mut String, lookup: &mut F) -> Result<(), ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
if !may_reference_variable(value) {
return Ok(());
}
let parsed = InterpString::parse(value);
if parsed.references_vars() {
*value = parsed.substitute_variables(lookup)?.as_source();
}
Ok(())
}
fn may_reference_variable(value: &str) -> bool {
value.contains("{{") && value.contains("vars.")
}
fn substitute_option_string<F>(
value: &mut Option<String>,
lookup: &mut F,
) -> Result<(), ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
match value {
Some(value) => substitute_string(value, lookup),
None => Ok(()),
}
}
fn substitute_string_vec<F>(values: &mut [String], lookup: &mut F) -> Result<(), ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
for value in values {
substitute_string(value, lookup)?;
}
Ok(())
}
fn substitute_string_map<F>(
values: &mut HashMap<String, String>,
lookup: &mut F,
) -> Result<(), ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
for value in values.values_mut() {
substitute_string(value, lookup)?;
}
Ok(())
}
fn substitute_mcp_transport<F>(
transport: &mut McpTransport,
lookup: &mut F,
) -> Result<(), ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
match transport {
McpTransport::Stdio { command, env } | McpTransport::Sandbox { command, env, .. } => {
substitute_string_vec(command, lookup)?;
substitute_string_map(env, lookup)
}
McpTransport::Http { url, headers, .. } => {
substitute_string(url, lookup)?;
substitute_string_map(headers, lookup)
}
}
}
fn substitute_environment<F>(
environment: &mut RunEnvironmentSettings,
lookup: &mut F,
) -> Result<(), ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
substitute_string(&mut environment.id, lookup)?;
substitute_option_string(&mut environment.image.reference, lookup)?;
substitute_dockerfile_source(&mut environment.image.dockerfile, lookup)?;
substitute_string_vec(&mut environment.network.allow, lookup)?;
substitute_string_map(&mut environment.labels, lookup)?;
for volume in &mut environment.volumes {
substitute_string(&mut volume.id, lookup)?;
substitute_string(&mut volume.mount_path, lookup)?;
substitute_option_string(&mut volume.subpath, lookup)?;
}
Ok(())
}
fn substitute_dockerfile_source<F>(
source: &mut Option<DockerfileSource>,
lookup: &mut F,
) -> Result<(), ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
match source {
Some(DockerfileSource::Inline(value) | DockerfileSource::Path { path: value }) => {
substitute_string(value, lookup)
}
None => Ok(()),
}
}
fn substitute_hook_type<F>(hook_type: &mut HookType, lookup: &mut F) -> Result<(), ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
match hook_type {
HookType::Command { command } => substitute_string(command, lookup),
HookType::Http { url, headers, .. } => {
substitute_string(url, lookup)?;
if let Some(headers) = headers {
substitute_string_map(headers, lookup)?;
}
Ok(())
}
HookType::Prompt { prompt, model } | HookType::Agent { prompt, model, .. } => {
substitute_string(prompt, lookup)?;
substitute_option_string(model, lookup)
}
}
}
#[cfg(test)]
mod run_namespace_variable_substitution_tests {
use std::collections::HashMap;
use super::{
ArtifactsSettings, DockerfileSource, EnvironmentImageSettings, EnvironmentNetworkMode,
EnvironmentNetworkSettings, EnvironmentVolumeSettings, HookDefinition, HookEvent, HookType,
InterpString, McpHttpProtocol, McpServerSettings, McpTransport, RunCheckpointSettings,
RunEnvironmentSettings, RunGoal, RunNamespace, RunPrepareSettings,
};
#[test]
fn substitutes_variables_in_interp_and_late_bound_run_strings() {
let mut run = RunNamespace {
goal: Some(RunGoal::Inline(InterpString::parse(
"deploy {{ vars.ENV }} in {{ env.REGION }}",
))),
working_dir: Some(InterpString::parse("/workspace/{{ vars.ENV }}")),
prepare: RunPrepareSettings {
commands: vec!["echo {{ vars.ENV }} {{ env.REGION }}".to_string()],
timeout_ms: 1_000,
},
agent: super::RunAgentSettings {
mcps: HashMap::from([("http".to_string(), McpServerSettings {
name: "http".to_string(),
transport: McpTransport::Http {
protocol: McpHttpProtocol::default(),
url: "https://{{ vars.HOST }}/mcp".to_string(),
headers: HashMap::from([(
"X-Env".to_string(),
"{{ vars.ENV }}".to_string(),
)]),
},
current_dir: None,
clear_env: false,
startup_timeout_secs: 10,
tool_timeout_secs: 60,
})]),
..super::RunAgentSettings::default()
},
hooks: vec![HookDefinition {
name: Some("notify".to_string()),
event: HookEvent::RunComplete,
command: None,
hook_type: Some(HookType::Http {
url: "https://hooks.example/{{ vars.ENV }}".to_string(),
headers: Some(HashMap::from([(
"X-Env".to_string(),
"{{ vars.ENV }}".to_string(),
)])),
allowed_env_vars: Vec::new(),
tls: super::TlsMode::Verify,
}),
matcher: None,
blocking: None,
timeout_ms: None,
sandbox: None,
}],
..RunNamespace::default()
};
run.substitute_variables(|name| match name {
"ENV" => Some("prod".to_string()),
"HOST" => Some("mcp.example".to_string()),
_ => None,
})
.unwrap();
let goal_source = match run.goal.as_ref() {
Some(RunGoal::Inline(value) | RunGoal::File(value)) => Some(value.as_source()),
None => None,
};
assert_eq!(
goal_source,
Some("deploy prod in {{ env.REGION }}".to_string())
);
assert_eq!(
run.working_dir.as_ref().map(InterpString::as_source),
Some("/workspace/prod".to_string())
);
assert_eq!(run.prepare.commands, vec![
"echo prod {{ env.REGION }}".to_string()
]);
let mcp = &run.agent.mcps["http"];
match &mcp.transport {
McpTransport::Http { url, headers, .. } => {
assert_eq!(url, "https://mcp.example/mcp");
assert_eq!(headers.get("X-Env").map(String::as_str), Some("prod"));
}
other => panic!("expected http mcp transport, got {other:?}"),
}
match run.hooks[0].hook_type.as_ref().unwrap() {
HookType::Http { url, headers, .. } => {
assert_eq!(url, "https://hooks.example/prod");
assert_eq!(
headers
.as_ref()
.and_then(|headers| headers.get("X-Env"))
.map(String::as_str),
Some("prod")
);
}
other => panic!("expected http hook type, got {other:?}"),
}
}
#[test]
fn substitutes_variables_in_string_backed_settings_families() {
let mut run = RunNamespace {
checkpoint: RunCheckpointSettings {
exclude_globs: vec!["tmp/{{ vars.ENV }}/**".to_string()],
skip_git_hooks: false,
},
environment: RunEnvironmentSettings {
image: EnvironmentImageSettings {
reference: Some("registry.example/{{ vars.ENV }}:latest".to_string()),
dockerfile: Some(DockerfileSource::Inline(
"FROM registry.example/base:{{ vars.ENV }}".to_string(),
)),
},
network: EnvironmentNetworkSettings {
mode: EnvironmentNetworkMode::CidrAllowList,
allow: vec!["{{ vars.CIDR }}".to_string()],
},
labels: HashMap::from([("deploy-env".to_string(), "{{ vars.ENV }}".to_string())]),
volumes: vec![EnvironmentVolumeSettings {
id: "vol_{{ vars.ENV }}".to_string(),
mount_path: "/mnt/{{ vars.ENV }}".to_string(),
subpath: Some("cache/{{ vars.ENV }}".to_string()),
}],
..RunEnvironmentSettings::default()
},
artifacts: ArtifactsSettings {
include: vec!["reports/{{ vars.ENV }}/**".to_string()],
},
..RunNamespace::default()
};
run.substitute_variables(|name| match name {
"CIDR" => Some("10.0.0.0/8".to_string()),
"ENV" => Some("prod".to_string()),
_ => None,
})
.unwrap();
assert_eq!(run.checkpoint.exclude_globs, vec!["tmp/prod/**"]);
assert_eq!(
run.environment.image.reference.as_deref(),
Some("registry.example/prod:latest")
);
assert_eq!(
run.environment.image.dockerfile,
Some(DockerfileSource::Inline(
"FROM registry.example/base:prod".to_string()
))
);
assert_eq!(run.environment.network.allow, vec!["10.0.0.0/8"]);
assert_eq!(
run.environment.labels.get("deploy-env").map(String::as_str),
Some("prod")
);
assert_eq!(run.environment.volumes[0].id, "vol_prod");
assert_eq!(run.environment.volumes[0].mount_path, "/mnt/prod");
assert_eq!(
run.environment.volumes[0].subpath.as_deref(),
Some("cache/prod")
);
assert_eq!(run.artifacts.include, vec!["reports/prod/**"]);
}
}
/// `[run.integrations]` — run-level integration knobs.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RunIntegrationsSettings {

View file

@ -0,0 +1,59 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[must_use]
pub fn is_env_style_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
_ => return false,
}
chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Variable {
pub name: String,
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct VariableListResponse {
pub data: Vec<Variable>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CreateVariableRequest {
pub name: String,
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UpdateVariableRequest {
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[cfg(test)]
mod tests {
use super::is_env_style_name;
#[test]
fn env_style_names_match_variable_store_contract() {
for valid in ["A", "_A", "A_123"] {
assert!(is_env_style_name(valid), "{valid} should be accepted");
}
for invalid in ["", "1BAD", "bad-name", "BAD.NAME"] {
assert!(!is_env_style_name(invalid), "{invalid} should be rejected");
}
}
}

View file

@ -0,0 +1,23 @@
[package]
name = "fabro-variable"
edition.workspace = true
version.workspace = true
publish = false
license.workspace = true
description = "Workflow-visible non-sensitive variables for Fabro"
[lib]
doctest = false
[lints]
workspace = true
[dependencies]
chrono.workspace = true
fabro-types = { path = "../fabro-types" }
serde.workspace = true
serde_json.workspace = true
ulid.workspace = true
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,218 @@
#![expect(
clippy::disallowed_methods,
reason = "fabro-variable: sync JSON-file storage; not used on a Tokio hot path"
)]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::{fmt, io};
use chrono::{DateTime, Utc};
use fabro_types::{Variable, is_env_style_name};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct VariableEntry {
value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
#[derive(Debug)]
pub enum Error {
InvalidName(String),
NotFound(String),
Io(std::io::Error),
Serde(serde_json::Error),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidName(name) => write!(f, "invalid variable name: {name}"),
Self::NotFound(name) => write!(f, "variable not found: {name}"),
Self::Io(err) => write!(f, "{err}"),
Self::Serde(err) => write!(f, "{err}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
Self::Serde(err) => Some(err),
Self::InvalidName(_) | Self::NotFound(_) => None,
}
}
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<serde_json::Error> for Error {
fn from(value: serde_json::Error) -> Self {
Self::Serde(value)
}
}
#[derive(Debug)]
pub struct VariableStore {
path: PathBuf,
entries: HashMap<String, VariableEntry>,
}
impl VariableStore {
pub fn load(path: PathBuf) -> Result<Self, Error> {
let entries = match std::fs::read_to_string(&path) {
Ok(contents) => serde_json::from_str(&contents)?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => HashMap::new(),
Err(err) => return Err(io_context("read variables", &path, &err).into()),
};
Ok(Self { path, entries })
}
pub fn set(
&mut self,
name: &str,
value: &str,
description: Option<&str>,
) -> Result<Variable, Error> {
self.set_with_policy(name, value, description, false)
}
pub fn update_existing(
&mut self,
name: &str,
value: &str,
description: Option<&str>,
) -> Result<Variable, Error> {
self.set_with_policy(name, value, description, true)
}
fn set_with_policy(
&mut self,
name: &str,
value: &str,
description: Option<&str>,
require_existing: bool,
) -> Result<Variable, Error> {
Self::validate_name(name)?;
let now = Utc::now();
let existing = self.entries.get(name);
if require_existing && existing.is_none() {
return Err(Error::NotFound(name.to_string()));
}
let (created_at, description) = existing.map_or_else(
|| (now, description.map(str::to_string)),
|entry| {
(
entry.created_at,
description
.map(str::to_string)
.or_else(|| entry.description.clone()),
)
},
);
let entry = VariableEntry {
value: value.to_string(),
description: description.clone(),
created_at,
updated_at: now,
};
self.entries.insert(name.to_string(), entry);
self.write_atomic()?;
Ok(Variable {
name: name.to_string(),
value: value.to_string(),
description,
created_at,
updated_at: now,
})
}
pub fn get(&self, name: &str) -> Option<Variable> {
self.entries
.get(name)
.map(|entry| variable_from_entry(name, entry))
}
pub fn get_value(&self, name: &str) -> Option<&str> {
self.entries.get(name).map(|entry| entry.value.as_str())
}
pub fn list(&self) -> Vec<Variable> {
let mut data = self
.entries
.iter()
.map(|(name, entry)| variable_from_entry(name, entry))
.collect::<Vec<_>>();
data.sort_by(|a, b| a.name.cmp(&b.name));
data
}
pub fn remove(&mut self, name: &str) -> Result<(), Error> {
Self::validate_name(name)?;
if self.entries.remove(name).is_none() {
return Err(Error::NotFound(name.to_string()));
}
self.write_atomic()?;
Ok(())
}
pub fn validate_name(name: &str) -> Result<(), Error> {
if is_env_style_name(name) {
Ok(())
} else {
Err(Error::InvalidName(name.to_string()))
}
}
fn write_atomic(&self) -> Result<(), Error> {
let parent = self
.path
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
std::fs::create_dir_all(&parent)
.map_err(|err| io_context("create variables directory", &parent, &err))?;
let file_name = self
.path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("variables.json");
let tmp_path = parent.join(format!(".{file_name}.tmp-{}", ulid::Ulid::new()));
let json = serde_json::to_vec_pretty(&self.entries)?;
std::fs::write(&tmp_path, json)
.map_err(|err| io_context("write variables temp file", &tmp_path, &err))?;
std::fs::rename(&tmp_path, &self.path).map_err(|err| {
io_context(
&format!("rename variables temp file to {}", self.path.display()),
&tmp_path,
&err,
)
})?;
Ok(())
}
}
fn variable_from_entry(name: &str, entry: &VariableEntry) -> Variable {
Variable {
name: name.to_string(),
value: entry.value.clone(),
description: entry.description.clone(),
created_at: entry.created_at,
updated_at: entry.updated_at,
}
}
fn io_context(op: &str, path: &Path, source: &io::Error) -> io::Error {
io::Error::new(source.kind(), format!("{op} {}: {source}", path.display()))
}

View file

@ -0,0 +1,111 @@
use fabro_variable::{Error, VariableStore};
#[test]
fn load_missing_file_returns_empty_store() {
let dir = tempfile::tempdir().unwrap();
let store = VariableStore::load(dir.path().join("variables.json")).unwrap();
assert!(store.list().is_empty());
}
#[test]
fn set_get_list_and_reload_variables() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("variables.json");
let mut store = VariableStore::load(path.clone()).unwrap();
let first = store.set("ZETA", "last", Some("Last variable")).unwrap();
let second = store.set("ALPHA", "", None).unwrap();
assert_eq!(first.name, "ZETA");
assert_eq!(first.value, "last");
assert_eq!(first.description.as_deref(), Some("Last variable"));
assert_eq!(second.value, "");
assert_eq!(store.get("ZETA").unwrap().value, "last");
assert_eq!(
store
.list()
.into_iter()
.map(|variable| variable.name)
.collect::<Vec<_>>(),
vec!["ALPHA", "ZETA"]
);
let reloaded = VariableStore::load(path).unwrap();
assert_eq!(reloaded.get("ALPHA").unwrap().value, "");
assert_eq!(
reloaded.get("ZETA").unwrap().description.as_deref(),
Some("Last variable")
);
}
#[test]
fn upsert_preserves_description_when_omitted_and_updates_when_present() {
let dir = tempfile::tempdir().unwrap();
let mut store = VariableStore::load(dir.path().join("variables.json")).unwrap();
let created = store
.set("DEPLOY_ENV", "staging", Some("Deployment target"))
.unwrap();
let preserved = store.set("DEPLOY_ENV", "production", None).unwrap();
let updated = store
.set("DEPLOY_ENV", "preview", Some("Preview target"))
.unwrap();
assert_eq!(preserved.created_at, created.created_at);
assert_eq!(preserved.description.as_deref(), Some("Deployment target"));
assert_eq!(updated.description.as_deref(), Some("Preview target"));
assert!(updated.updated_at >= preserved.updated_at);
}
#[test]
fn update_existing_preserves_description_and_reports_missing() {
let dir = tempfile::tempdir().unwrap();
let mut store = VariableStore::load(dir.path().join("variables.json")).unwrap();
let created = store
.set("DEPLOY_ENV", "staging", Some("Deployment target"))
.unwrap();
let updated = store
.update_existing("DEPLOY_ENV", "production", None)
.unwrap();
assert_eq!(updated.created_at, created.created_at);
assert_eq!(updated.value, "production");
assert_eq!(updated.description.as_deref(), Some("Deployment target"));
assert!(matches!(
store.update_existing("MISSING", "value", None),
Err(Error::NotFound(name)) if name == "MISSING"
));
}
#[test]
fn remove_deletes_variable_and_reports_missing() {
let dir = tempfile::tempdir().unwrap();
let mut store = VariableStore::load(dir.path().join("variables.json")).unwrap();
store.set("DEPLOY_ENV", "staging", None).unwrap();
store.remove("DEPLOY_ENV").unwrap();
assert!(store.get("DEPLOY_ENV").is_none());
assert!(matches!(
store.remove("DEPLOY_ENV"),
Err(Error::NotFound(name)) if name == "DEPLOY_ENV"
));
}
#[test]
fn env_style_names_are_required() {
let dir = tempfile::tempdir().unwrap();
let mut store = VariableStore::load(dir.path().join("variables.json")).unwrap();
for invalid in ["", "1BAD", "bad-name", "BAD.NAME"] {
assert!(matches!(
store.set(invalid, "value", None),
Err(Error::InvalidName(name)) if name == invalid
));
}
store.set("_OK", "value", None).unwrap();
store.set("OK_123", "value", None).unwrap();
}

View file

@ -9,8 +9,8 @@ use std::{fmt, io};
use chrono::{DateTime, Utc};
use fabro_static::EnvVars;
use fabro_types::SecretMetadata;
pub use fabro_types::SecretType;
use fabro_types::{SecretMetadata, is_env_style_name};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SecretEntry {
@ -164,13 +164,7 @@ impl Vault {
}
fn validate_env_name(name: &str) -> Result<(), Error> {
let mut chars = name.chars();
match chars.next() {
Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
_ => return Err(Error::InvalidName(name.to_string())),
}
if chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
if is_env_style_name(name) {
Ok(())
} else {
Err(Error::InvalidName(name.to_string()))

View file

@ -17,6 +17,7 @@ api/secrets-api.ts
api/sessions-api.ts
api/settings-api.ts
api/system-api.ts
api/variables-api.ts
api/workflows-api.ts
base.ts
common.ts
@ -87,6 +88,7 @@ models/create-completion-request.ts
models/create-run-pull-request-request.ts
models/create-run-session-request.ts
models/create-secret-request.ts
models/create-variable-request.ts
models/delete-run-response.ts
models/delete-run-sandbox.ts
models/delete-secret-request.ts
@ -482,8 +484,11 @@ models/todo-projection.ts
models/todo-status.ts
models/update-run-parent-request.ts
models/update-run-request.ts
models/update-variable-request.ts
models/user-response.ts
models/validate-response.ts
models/variable-list-response.ts
models/variable.ts
models/vnc-preview-response.ts
models/webhook-strategy.ts
models/workflow-detail-response.ts

View file

@ -32,4 +32,5 @@ export * from './api/secrets-api';
export * from './api/sessions-api';
export * from './api/settings-api';
export * from './api/system-api';
export * from './api/variables-api';
export * from './api/workflows-api';

View file

@ -0,0 +1,435 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from '../configuration';
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
// Some imports not used depending on template conditions
// @ts-ignore
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common';
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
// @ts-ignore
import type { CreateVariableRequest } from '../models';
// @ts-ignore
import type { ErrorResponse } from '../models';
// @ts-ignore
import type { UpdateVariableRequest } from '../models';
// @ts-ignore
import type { Variable } from '../models';
// @ts-ignore
import type { VariableListResponse } from '../models';
/**
* VariablesApi - axios parameter creator
*/
export const VariablesApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Stores a non-sensitive variable for run config interpolation.
* @summary Store or update a variable
* @param {CreateVariableRequest} createVariableRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createVariable: async (createVariableRequest: CreateVariableRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'createVariableRequest' is not null or undefined
assertParamExists('createVariable', 'createVariableRequest', createVariableRequest)
const localVarPath = `/api/v1/variables`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(createVariableRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Delete a variable
* @param {string} name Variable name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteVariable: async (name: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('deleteVariable', 'name', name)
const localVarPath = `/api/v1/variables/{name}`
.replace(`{${"name"}}`, encodeURIComponent(String(name)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Get a variable
* @param {string} name Variable name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getVariable: async (name: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('getVariable', 'name', name)
const localVarPath = `/api/v1/variables/{name}`
.replace(`{${"name"}}`, encodeURIComponent(String(name)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Returns non-sensitive variables, including values.
* @summary List variables
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listVariables: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/v1/variables`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Replaces a variable value and preserves the existing description when omitted.
* @summary Replace a variable value
* @param {string} name Variable name.
* @param {UpdateVariableRequest} updateVariableRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateVariable: async (name: string, updateVariableRequest: UpdateVariableRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('updateVariable', 'name', name)
// verify required parameter 'updateVariableRequest' is not null or undefined
assertParamExists('updateVariable', 'updateVariableRequest', updateVariableRequest)
const localVarPath = `/api/v1/variables/{name}`
.replace(`{${"name"}}`, encodeURIComponent(String(name)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(updateVariableRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* VariablesApi - functional programming interface
*/
export const VariablesApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = VariablesApiAxiosParamCreator(configuration)
return {
/**
* Stores a non-sensitive variable for run config interpolation.
* @summary Store or update a variable
* @param {CreateVariableRequest} createVariableRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createVariable(createVariableRequest: CreateVariableRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Variable>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createVariable(createVariableRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['VariablesApi.createVariable']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
*
* @summary Delete a variable
* @param {string} name Variable name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deleteVariable(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteVariable(name, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['VariablesApi.deleteVariable']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
*
* @summary Get a variable
* @param {string} name Variable name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getVariable(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Variable>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getVariable(name, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['VariablesApi.getVariable']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Returns non-sensitive variables, including values.
* @summary List variables
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listVariables(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<VariableListResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listVariables(options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['VariablesApi.listVariables']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Replaces a variable value and preserves the existing description when omitted.
* @summary Replace a variable value
* @param {string} name Variable name.
* @param {UpdateVariableRequest} updateVariableRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updateVariable(name: string, updateVariableRequest: UpdateVariableRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Variable>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateVariable(name, updateVariableRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['VariablesApi.updateVariable']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
/**
* VariablesApi - factory interface
*/
export const VariablesApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = VariablesApiFp(configuration)
return {
/**
* Stores a non-sensitive variable for run config interpolation.
* @summary Store or update a variable
* @param {CreateVariableRequest} createVariableRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createVariable(createVariableRequest: CreateVariableRequest, options?: RawAxiosRequestConfig): AxiosPromise<Variable> {
return localVarFp.createVariable(createVariableRequest, options).then((request) => request(axios, basePath));
},
/**
*
* @summary Delete a variable
* @param {string} name Variable name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteVariable(name: string, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.deleteVariable(name, options).then((request) => request(axios, basePath));
},
/**
*
* @summary Get a variable
* @param {string} name Variable name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getVariable(name: string, options?: RawAxiosRequestConfig): AxiosPromise<Variable> {
return localVarFp.getVariable(name, options).then((request) => request(axios, basePath));
},
/**
* Returns non-sensitive variables, including values.
* @summary List variables
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listVariables(options?: RawAxiosRequestConfig): AxiosPromise<VariableListResponse> {
return localVarFp.listVariables(options).then((request) => request(axios, basePath));
},
/**
* Replaces a variable value and preserves the existing description when omitted.
* @summary Replace a variable value
* @param {string} name Variable name.
* @param {UpdateVariableRequest} updateVariableRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateVariable(name: string, updateVariableRequest: UpdateVariableRequest, options?: RawAxiosRequestConfig): AxiosPromise<Variable> {
return localVarFp.updateVariable(name, updateVariableRequest, options).then((request) => request(axios, basePath));
},
};
};
/**
* VariablesApi - object-oriented interface
*/
export class VariablesApi extends BaseAPI {
/**
* Stores a non-sensitive variable for run config interpolation.
* @summary Store or update a variable
* @param {CreateVariableRequest} createVariableRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public createVariable(createVariableRequest: CreateVariableRequest, options?: RawAxiosRequestConfig) {
return VariablesApiFp(this.configuration).createVariable(createVariableRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Delete a variable
* @param {string} name Variable name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public deleteVariable(name: string, options?: RawAxiosRequestConfig) {
return VariablesApiFp(this.configuration).deleteVariable(name, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Get a variable
* @param {string} name Variable name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public getVariable(name: string, options?: RawAxiosRequestConfig) {
return VariablesApiFp(this.configuration).getVariable(name, options).then((request) => request(this.axios, this.basePath));
}
/**
* Returns non-sensitive variables, including values.
* @summary List variables
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public listVariables(options?: RawAxiosRequestConfig) {
return VariablesApiFp(this.configuration).listVariables(options).then((request) => request(this.axios, this.basePath));
}
/**
* Replaces a variable value and preserves the existing description when omitted.
* @summary Replace a variable value
* @param {string} name Variable name.
* @param {UpdateVariableRequest} updateVariableRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public updateVariable(name: string, updateVariableRequest: UpdateVariableRequest, options?: RawAxiosRequestConfig) {
return VariablesApiFp(this.configuration).updateVariable(name, updateVariableRequest, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -0,0 +1,33 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Request to store or update a variable.
*/
export interface CreateVariableRequest {
/**
* Env-style variable name.
*/
'name': string;
/**
* Variable value. Empty values are allowed.
*/
'value': string;
/**
* Optional operator-facing description of the variable.
*/
'description'?: string;
}

View file

@ -63,6 +63,7 @@ export * from './create-completion-request';
export * from './create-run-pull-request-request';
export * from './create-run-session-request';
export * from './create-secret-request';
export * from './create-variable-request';
export * from './delete-run-response';
export * from './delete-run-sandbox';
export * from './delete-secret-request';
@ -457,8 +458,11 @@ export * from './todo-projection';
export * from './todo-status';
export * from './update-run-parent-request';
export * from './update-run-request';
export * from './update-variable-request';
export * from './user-response';
export * from './validate-response';
export * from './variable';
export * from './variable-list-response';
export * from './vnc-preview-response';
export * from './webhook-strategy';
export * from './workflow-detail-response';

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Request to update a variable.
*/
export interface UpdateVariableRequest {
/**
* Replacement value. Empty values are allowed.
*/
'value': string;
/**
* Optional operator-facing description. Omitted descriptions preserve the existing value.
*/
'description'?: string;
}

View file

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { Variable } from './variable';
/**
* List of stored variables.
*/
export interface VariableListResponse {
'data': Array<Variable>;
}

View file

@ -0,0 +1,41 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Non-sensitive variable available for run config interpolation.
*/
export interface Variable {
/**
* Env-style variable name.
*/
'name': string;
/**
* Variable value.
*/
'value': string;
/**
* Optional operator-facing description of the variable.
*/
'description'?: string;
/**
* When the variable was first stored.
*/
'created_at': string;
/**
* When the variable was last updated.
*/
'updated_at': string;
}