feat: make local a reserved, in-memory environment

`local` no longer makes sense as a configurable environment — the local
sandbox provider ignores image/resources/network/lifecycle/volumes. Stop
seeding it as an editable TOML file and stop offering it as a creatable
provider in the UI form.

Instead the server synthesizes a reserved, in-memory `local` environment
only when `server.sandbox.providers.local.enabled`. It is never persisted
and cannot be created, replaced, or deleted (409). Provider-enablement is
already enforced at run create/preflight/start, so the synthetic `local`
just needs to exist in the store catalog when enabled.

- fabro-environment: drop `local` from disk seeds; thread `local_enabled`
  into load_or_seed and inject a synthetic `local`; ignore any stale
  `local.toml`; reject create/replace/delete of `local` with a new
  Reserved error. seeded_catalog_layer keeps `local` for CLI validation.
- fabro-server: pass local.enabled into the store; map Reserved -> 409.
- web: form provider dropdown excludes local; the `local` row is
  read-only (no Edit/Delete) with a reserved tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-31 11:24:14 -04:00
parent c4dbbb4c27
commit a163c16cda
No known key found for this signature in database
9 changed files with 283 additions and 38 deletions

View file

@ -250,11 +250,13 @@ export function EnvironmentFormFields({
onChange={(e) => patch({ provider: parseProvider(e.target.value) })}
className={INPUT_CLASS}
>
{Object.values(EnvironmentProvider).map((provider) => (
<option key={provider} value={provider}>
{provider}
</option>
))}
{Object.values(EnvironmentProvider)
.filter((provider) => provider !== EnvironmentProvider.LOCAL)
.map((provider) => (
<option key={provider} value={provider}>
{provider}
</option>
))}
</select>
</Row>
</Panel>

View file

@ -23,6 +23,10 @@ import { useToast } from "../components/toast";
// in the UI instead of letting the delete fail with a 409.
const PROTECTED_ID = "default";
// `local` is a reserved, in-memory environment (present only when the local
// sandbox provider is enabled). It cannot be edited or deleted.
const RESERVED_ID = "local";
const MENU_ITEM_CLASS =
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-3 transition-colors data-focus:bg-overlay data-focus:text-fg data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60";
@ -153,6 +157,11 @@ function EnvironmentRow({
{environment.id}
</span>
<Badge>{environment.provider}</Badge>
{environment.id === RESERVED_ID ? (
<StatusTag>reserved</StatusTag>
) : environment.id === PROTECTED_ID ? (
<StatusTag>protected</StatusTag>
) : null}
</div>
<div className="mt-0.5 truncate text-xs/5 text-fg-3">
{resourcesSummary(environment)} · network {environment.network.mode}
@ -184,6 +193,14 @@ function resourcesSummary(environment: Environment): string {
return parts.length > 0 ? parts.join(" · ") : "Default resources";
}
function StatusTag({ children }: { children: string }) {
return (
<span className="rounded-sm bg-overlay px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
{children}
</span>
);
}
function RowMenu({
environment,
disabled,
@ -193,6 +210,7 @@ function RowMenu({
disabled: boolean;
onDelete: () => void;
}) {
const reserved = environment.id === RESERVED_ID;
const protectedFromDelete = environment.id === PROTECTED_ID;
return (
<Menu as="div" className="relative inline-block">
@ -211,23 +229,40 @@ function RowMenu({
className="z-30 w-36 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem>
<Link
to={`/settings/environments/${encodeURIComponent(environment.id)}/edit`}
className={MENU_ITEM_CLASS}
>
Edit
</Link>
{reserved ? (
<button
type="button"
disabled
title="The local environment is reserved and cannot be edited"
className={MENU_ITEM_CLASS}
>
Edit
</button>
) : (
<Link
to={`/settings/environments/${encodeURIComponent(environment.id)}/edit`}
className={MENU_ITEM_CLASS}
>
Edit
</Link>
)}
</MenuItem>
<hr className="my-1 h-px border-0 bg-line" />
<MenuItem>
<button
type="button"
onClick={onDelete}
disabled={disabled || protectedFromDelete}
title={protectedFromDelete ? "The default environment is protected" : undefined}
disabled={disabled || protectedFromDelete || reserved}
title={
reserved
? "The local environment is reserved"
: protectedFromDelete
? "The default environment is protected"
: undefined
}
className={MENU_ITEM_DANGER_CLASS}
>
{protectedFromDelete ? "Protected" : "Delete"}
{reserved ? "Reserved" : protectedFromDelete ? "Protected" : "Delete"}
</button>
</MenuItem>
</MenuItems>

View file

@ -33,6 +33,8 @@ pub enum EnvironmentStoreError {
},
#[error("environment is protected and cannot be deleted: {id}")]
Protected { id: EnvironmentId },
#[error("environment is reserved and cannot be modified: {id}")]
Reserved { id: EnvironmentId },
#[error("environment validation failed: {source}")]
Validation {
#[from]
@ -94,6 +96,7 @@ impl EnvironmentStoreError {
Self::AlreadyExists { .. } => "already_exists",
Self::StaleRevision { .. } => "stale_revision",
Self::Protected { .. } => "protected",
Self::Reserved { .. } => "reserved",
Self::Validation { .. } => "validation",
Self::InvalidFilename { .. } => "invalid_filename",
Self::Parse { .. } | Self::InvalidUtf8 { .. } => "parse",

View file

@ -65,6 +65,25 @@ impl Environment {
))
}
/// Builds an in-memory environment from settings without touching the
/// filesystem. Unlike [`from_settings`], this never inlines Dockerfile
/// paths, so it stays synchronous — suitable for reserved environments
/// (e.g. `local`) that carry no Dockerfile and are never persisted.
pub(crate) fn synthetic(
id: EnvironmentId,
settings: &EnvironmentSettings,
) -> Result<Self, EnvironmentStoreError> {
let persisted = environment_settings_to_layer(settings);
let settings = resolve_environment(&persisted)?;
let bytes = canonical_bytes(&persisted).into_bytes();
let revision = EnvironmentRevision::from_bytes(&bytes);
Ok(Self {
id,
revision,
settings,
})
}
pub(crate) fn to_layer(&self) -> EnvironmentLayer {
environment_settings_to_layer(&self.settings)
}

View file

@ -5,7 +5,7 @@ use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use fabro_config::{EnvironmentLayer, MergeMap};
use fabro_types::settings::run::EnvironmentSettings;
use fabro_types::settings::run::{EnvironmentProvider, EnvironmentSettings};
use tokio::fs;
use tokio::io::AsyncWriteExt as _;
use tokio::sync::Mutex;
@ -14,16 +14,24 @@ use crate::{
Environment, EnvironmentDraft, EnvironmentId, EnvironmentRevision, EnvironmentStoreError,
};
/// Environments written to disk on first startup. `local` is intentionally
/// absent: it is a reserved, in-memory environment (see [`RESERVED_LOCAL_ID`]).
const SEEDS: &[(&str, &str)] = &[
("default", DEFAULT_ENVIRONMENT_TOML),
("local", LOCAL_ENVIRONMENT_TOML),
("docker", DOCKER_ENVIRONMENT_TOML),
("daytona", DAYTONA_ENVIRONMENT_TOML),
];
/// `local` is a reserved environment: it is synthesized in memory only when the
/// local sandbox provider is enabled, is never persisted to disk, and cannot be
/// created, replaced, or deleted through the store.
const RESERVED_LOCAL_ID: &str = "local";
/// Returns the built-in seeded environment catalog as a `MergeMap` of
/// `EnvironmentLayer`s. Useful for client-side manifest validation where no
/// live `EnvironmentStore` is available.
/// live `EnvironmentStore` is available. Includes the reserved `local` entry so
/// manifests selecting `id = "local"` validate; server-side provider-enablement
/// policy decides whether such a run may actually execute.
pub fn seeded_catalog_layer() -> MergeMap<EnvironmentLayer> {
let mut catalog: HashMap<String, EnvironmentLayer> = HashMap::new();
for (id, body) in SEEDS {
@ -31,6 +39,9 @@ pub fn seeded_catalog_layer() -> MergeMap<EnvironmentLayer> {
toml::from_str(body).expect("built-in environment seed should parse");
catalog.insert((*id).to_string(), layer);
}
let local: EnvironmentLayer = toml::from_str(LOCAL_ENVIRONMENT_TOML)
.expect("built-in local environment seed should parse");
catalog.insert(RESERVED_LOCAL_ID.to_string(), local);
MergeMap::from(catalog)
}
@ -96,6 +107,18 @@ impl CatalogState {
}
}
/// Builds the reserved, in-memory `local` environment. It carries only
/// `provider = "local"`; image/resources/network/etc. are irrelevant to the
/// local sandbox and stay at their defaults.
fn synthetic_local_environment() -> Result<Environment, EnvironmentStoreError> {
let id = EnvironmentId::new(RESERVED_LOCAL_ID).expect("reserved local id is valid");
let settings = EnvironmentSettings {
provider: EnvironmentProvider::Local,
..EnvironmentSettings::default()
};
Environment::synthetic(id, &settings)
}
fn build_catalog_layer(
environments: &HashMap<EnvironmentId, Environment>,
) -> MergeMap<EnvironmentLayer> {
@ -110,10 +133,17 @@ impl EnvironmentStore {
/// Synchronously seed missing built-in environment files and load all
/// persisted environments. The synchronous file access runs during server
/// startup before request handling begins.
pub fn load_or_seed(dir: impl Into<PathBuf>) -> Result<Self, EnvironmentStoreError> {
pub fn load_or_seed(
dir: impl Into<PathBuf>,
local_enabled: bool,
) -> Result<Self, EnvironmentStoreError> {
let dir = dir.into();
seed_missing_environments(&dir)?;
let environments = load_environments(&dir)?;
let mut environments = load_environments(&dir)?;
if local_enabled {
let local = synthetic_local_environment()?;
environments.insert(local.id.clone(), local);
}
let request_base_dir = dir.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
Ok(Self {
dir,
@ -147,6 +177,9 @@ impl EnvironmentStore {
draft: EnvironmentDraft,
) -> Result<Environment, EnvironmentStoreError> {
let EnvironmentDraft { id, settings } = draft;
if id.as_str() == RESERVED_LOCAL_ID {
return Err(EnvironmentStoreError::Reserved { id });
}
let (environment, bytes) =
Environment::from_settings(id.clone(), settings, &self.request_base_dir).await?;
let _mutation = self.mutations.lock().await;
@ -171,6 +204,9 @@ impl EnvironmentStore {
expected: &EnvironmentRevision,
settings: EnvironmentSettings,
) -> Result<Environment, EnvironmentStoreError> {
if id.as_str() == RESERVED_LOCAL_ID {
return Err(EnvironmentStoreError::Reserved { id: id.clone() });
}
let (environment, bytes) =
Environment::from_settings(id.clone(), settings, &self.request_base_dir).await?;
let _mutation = self.mutations.lock().await;
@ -191,6 +227,9 @@ impl EnvironmentStore {
if id.as_str() == "default" {
return Err(EnvironmentStoreError::Protected { id: id.clone() });
}
if id.as_str() == RESERVED_LOCAL_ID {
return Err(EnvironmentStoreError::Reserved { id: id.clone() });
}
let _mutation = self.mutations.lock().await;
check_revision(&self.read_state().environments, id, expected)?;
@ -279,6 +318,11 @@ fn load_environments(
if !file_type.is_file() || !is_toml_file(&path) {
continue;
}
// `local` is reserved and synthesized in memory; never load a stale
// `local.toml` left behind by an earlier build that seeded it.
if id_from_path(&path).is_ok_and(|id| id.as_str() == RESERVED_LOCAL_ID) {
continue;
}
let environment = load_environment_file(&path)?;
environments.insert(environment.id.clone(), environment);
}
@ -448,9 +492,11 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
let store = EnvironmentStore::load_or_seed(&environment_dir).unwrap();
let store = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap();
let environments = store.list();
// `local` is present in memory (local provider enabled) but the other
// three are the on-disk seeds.
assert_eq!(
environments
.iter()
@ -458,9 +504,73 @@ mod tests {
.collect::<Vec<_>>(),
vec!["daytona", "default", "docker", "local"]
);
for id in ["default", "local", "docker", "daytona"] {
for id in ["default", "docker", "daytona"] {
assert!(environment_dir.join(format!("{id}.toml")).exists());
}
// `local` is reserved and in-memory; it is never written to disk.
assert!(!environment_dir.join("local.toml").exists());
}
#[tokio::test]
async fn local_present_only_when_enabled() {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
let enabled = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap();
assert!(enabled.get(&EnvironmentId::new("local").unwrap()).is_some());
let disabled = EnvironmentStore::load_or_seed(&environment_dir, false).unwrap();
assert!(
disabled
.get(&EnvironmentId::new("local").unwrap())
.is_none()
);
}
#[tokio::test]
async fn on_disk_local_is_ignored_in_favor_of_synthetic() {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
fs::create_dir_all(&environment_dir).await.unwrap();
// A stale `local.toml` left by an earlier build that seeded it.
fs::write(
environment_dir.join("local.toml"),
"provider = \"local\"\n[resources]\ncpu = 99\n",
)
.await
.unwrap();
let store = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap();
let local = store.get(&EnvironmentId::new("local").unwrap()).unwrap();
// The synthetic local carries no resources; the stale file was ignored.
assert_eq!(local.settings.resources.cpu, None);
}
#[tokio::test]
async fn local_mutations_are_reserved() {
let dir = tempfile::tempdir().unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments"), true).unwrap();
let local = EnvironmentId::new("local").unwrap();
let revision = store.get(&local).unwrap().revision;
let create_err = store
.create(draft("local", EnvironmentProvider::Local))
.await
.unwrap_err();
assert!(matches!(create_err, EnvironmentStoreError::Reserved { .. }));
let replace_err = store
.replace(&local, &revision, settings(EnvironmentProvider::Local))
.await
.unwrap_err();
assert!(matches!(
replace_err,
EnvironmentStoreError::Reserved { .. }
));
let delete_err = store.delete(&local, &revision).await.unwrap_err();
assert!(matches!(delete_err, EnvironmentStoreError::Reserved { .. }));
}
#[tokio::test]
@ -475,7 +585,7 @@ mod tests {
.await
.unwrap();
let store = EnvironmentStore::load_or_seed(&environment_dir).unwrap();
let store = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap();
assert_eq!(
store
@ -494,7 +604,7 @@ mod tests {
std::fs::create_dir_all(&environment_dir).unwrap();
std::fs::write(environment_dir.join("Bad.toml"), r#"provider = "local""#).unwrap();
let err = EnvironmentStore::load_or_seed(&environment_dir).unwrap_err();
let err = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap_err();
assert!(matches!(err, EnvironmentStoreError::InvalidFilename { .. }));
}
@ -506,7 +616,7 @@ mod tests {
std::fs::create_dir_all(&environment_dir).unwrap();
std::fs::write(environment_dir.join("bad.toml"), r#"provider = "bogus""#).unwrap();
let err = EnvironmentStore::load_or_seed(&environment_dir).unwrap_err();
let err = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap_err();
assert!(matches!(err, EnvironmentStoreError::Validation { .. }));
assert!(err.to_string().contains("unknown environment provider"));
@ -528,7 +638,7 @@ mode = "cidr_allow_list"
)
.unwrap();
let err = EnvironmentStore::load_or_seed(&environment_dir).unwrap_err();
let err = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap_err();
assert!(matches!(err, EnvironmentStoreError::Validation { .. }));
assert!(
@ -553,7 +663,7 @@ path = "Dockerfile"
)
.unwrap();
let err = EnvironmentStore::load_or_seed(&environment_dir).unwrap_err();
let err = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap_err();
assert!(matches!(err, EnvironmentStoreError::Validation { .. }));
assert!(err.to_string().contains("Dockerfile"));
@ -562,10 +672,10 @@ path = "Dockerfile"
#[tokio::test]
async fn create_conflict_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments"), true).unwrap();
let err = store
.create(draft("local", EnvironmentProvider::Local))
.create(draft("docker", EnvironmentProvider::Docker))
.await
.unwrap_err();
@ -575,7 +685,7 @@ path = "Dockerfile"
#[tokio::test]
async fn create_invalid_settings_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments"), true).unwrap();
let mut settings = settings(EnvironmentProvider::Local);
settings.network.mode = EnvironmentNetworkMode::Block;
@ -597,8 +707,8 @@ path = "Dockerfile"
#[tokio::test]
async fn replace_stale_revision_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap();
let current = store.get(&EnvironmentId::new("local").unwrap()).unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments"), true).unwrap();
let current = store.get(&EnvironmentId::new("docker").unwrap()).unwrap();
let stale = EnvironmentRevision::from_bytes(b"stale");
let err = store
@ -612,7 +722,7 @@ path = "Dockerfile"
#[tokio::test]
async fn default_delete_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments"), true).unwrap();
let default = store.get(&EnvironmentId::new("default").unwrap()).unwrap();
let err = store
@ -627,7 +737,7 @@ path = "Dockerfile"
async fn delete_success_removes_file_and_memory_entry() {
let dir = tempfile::tempdir().unwrap();
let environment_dir = dir.path().join("environments");
let store = EnvironmentStore::load_or_seed(&environment_dir).unwrap();
let store = EnvironmentStore::load_or_seed(&environment_dir, true).unwrap();
let created = store
.create(draft("tmp", EnvironmentProvider::Local))
.await
@ -642,7 +752,7 @@ path = "Dockerfile"
#[tokio::test]
async fn canonical_revision_changes_when_persisted_bytes_change() {
let dir = tempfile::tempdir().unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments"), true).unwrap();
let created = store
.create(draft("rev", EnvironmentProvider::Local))
.await
@ -667,7 +777,7 @@ path = "Dockerfile"
fs::write(dir.path().join("Dockerfile"), "FROM alpine\n")
.await
.unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments")).unwrap();
let store = EnvironmentStore::load_or_seed(dir.path().join("environments"), true).unwrap();
let mut settings = settings(EnvironmentProvider::Docker);
settings.image.dockerfile = Some(DockerfileSource::Path {
path: "Dockerfile".to_string(),

View file

@ -2319,8 +2319,15 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
.context("load automations")?,
);
let environment_dir = environment_dir_for_active_config(&active_config_path);
let local_provider_enabled = resolved_settings
.server_settings
.server
.sandbox
.providers
.local
.enabled;
let environment_store = Arc::new(
EnvironmentStore::load_or_seed(environment_dir)
EnvironmentStore::load_or_seed(environment_dir, local_provider_enabled)
.map_err(anyhow::Error::new)
.context("load environments")?,
);

View file

@ -244,6 +244,10 @@ impl From<EnvironmentStoreError> for ApiError {
StatusCode::CONFLICT,
format!("environment is protected and cannot be deleted: {id}"),
),
EnvironmentStoreError::Reserved { id } => Self::new(
StatusCode::CONFLICT,
format!("environment is reserved and cannot be modified: {id}"),
),
EnvironmentStoreError::Validation { source } => {
Self::new(StatusCode::UNPROCESSABLE_ENTITY, source.to_string())
}

View file

@ -1222,7 +1222,7 @@ id = "missing"
#[test]
fn system_sandbox_provider_uses_manifest_defaults() {
let temp = tempfile::tempdir().unwrap();
let environment_store = EnvironmentStore::load_or_seed(temp.path().join("environments"))
let environment_store = EnvironmentStore::load_or_seed(temp.path().join("environments"), true)
.expect("environment store should seed");
let source = r#"
_version = 1
@ -1241,7 +1241,7 @@ id = "daytona"
#[test]
fn system_sandbox_provider_defaults_when_manifest_run_settings_do_not_resolve() {
let temp = tempfile::tempdir().unwrap();
let environment_store = EnvironmentStore::load_or_seed(temp.path().join("environments"))
let environment_store = EnvironmentStore::load_or_seed(temp.path().join("environments"), true)
.expect("environment store should seed");
let source = r#"
_version = 1

View file

@ -407,6 +407,71 @@ async fn duplicate_environment_create_returns_conflict() {
.await;
}
#[tokio::test]
async fn reserved_local_environment_cannot_be_created_or_modified() {
let (app, _temp_dir, _environment_dir) = environment_app();
// `local` is reserved: creating it is rejected with a conflict.
let created = app
.clone()
.oneshot(json_request(
Method::POST,
"/environments",
&environment_body("local", "local"),
))
.await
.expect("reserved create should respond");
response_status(
created,
StatusCode::CONFLICT,
"POST /api/v1/environments local",
)
.await;
// It is synthesized in memory (local provider enabled) and readable.
let local = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/local"))
.await
.expect("get local should respond");
let local = response_json(local, StatusCode::OK, "GET /api/v1/environments/local").await;
let revision = revision_from(&local);
// Replace and delete are rejected even with a valid If-Match.
let replaced = app
.clone()
.oneshot(request_with_if_match(
Method::PUT,
"/environments/local",
&format!("\"{revision}\""),
Some(environment_settings("local")),
))
.await
.expect("reserved replace should respond");
response_status(
replaced,
StatusCode::CONFLICT,
"PUT /api/v1/environments/local",
)
.await;
let deleted = app
.oneshot(request_with_if_match(
Method::DELETE,
"/environments/local",
&format!("\"{revision}\""),
None,
))
.await
.expect("reserved delete should respond");
response_status(
deleted,
StatusCode::CONFLICT,
"DELETE /api/v1/environments/local",
)
.await;
}
#[tokio::test]
async fn invalid_environment_id_and_if_match_return_bad_request() {
let (app, _temp_dir, _environment_dir) = environment_app();