From 516c723811fe9afb07bef15ab127c7d0bef787f8 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:19:37 -0500 Subject: [PATCH 1/2] fix(native): run stock image processes in wrappers like Docker, naming refusals Project-wide host aliases put a host-dependency release barrier around every service, and private environment values put a launcher around their services. Both refused stock images whose entrypoint or health-check executable is a bare name (docker-entrypoint.sh, CMD mysqladmin), one service per run, without naming the service. Runtime: - The environment launcher and the relay release barrier exec the selected process like Docker: a name with "/" runs as given and a bare name is resolved through the container's own PATH (Docker's default when unset), never a host PATH and never a shell. - The selected process is materialized from the pinned image for host-dependency services too, not only for private environment delivery, so the barrier runs the image's ENTRYPOINT and CMD. A command-only image becomes the same argv, and a CMD-SHELL health check becomes the exec form Docker runs (the image SHELL, /bin/sh -c by default). - Every wrapped service is checked before any runtime state is reserved. A refusal keeps its code and lists each service and closed field (entrypoint, healthcheck, user, stop_signal, mounts) in a new value-free process_refusals diagnostic. CLI: - Native up refuses Compose-declared settings no wrapper can run (empty process, a health test without an executable, a named user, an unsupported stop signal, a reserved volume target) before the VM starts, naming every service and field. Restart preflight checks the same before stopping the current graph. - Runtime process_refusals are parsed in their closed form and named in the startup failure report. - Native doctor adds a "native process wrappers" check that predicts these cases for every service native may wrap. Refs HACK-1939 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 19 + packages/relay-guest/README.md | 11 +- packages/relay-guest/src/barrier.rs | 11 +- packages/relay-guest/src/main.rs | 2 +- packages/runtime-core/README.md | 12 +- .../guest/environment-launcher.zig | 15 +- packages/runtime-core/src/error.rs | 38 ++ .../runtime-core/src/provider/environment.rs | 2 +- .../src/provider/graph/image_process.rs | 222 +++++++-- .../src/provider/graph/launcher.rs | 132 +++-- .../runtime-core/src/provider/graph/mod.rs | 81 +++- .../src/provider/graph/native/runtime.rs | 6 +- .../src/provider/graph/process_refusals.rs | 145 ++++++ .../src/provider/graph/restore.rs | 11 +- .../src/provider/graph/startup.rs | 6 + .../src/provider/graph/startup/runtime.rs | 10 +- src/backends/native-project-process.ts | 5 + .../native-project-restart-preflight.ts | 9 + src/backends/native-project-start.ts | 36 +- src/backends/native-runtime-client.ts | 18 + src/commands/doctor.ts | 54 ++- src/lib/native-process-wrappers.ts | 458 ++++++++++++++++++ tests/native-process-wrappers.test.ts | 210 ++++++++ tests/native-project-process.test.ts | 67 +++ tests/native-project-start.test.ts | 54 +++ 25 files changed, 1531 insertions(+), 103 deletions(-) create mode 100644 packages/runtime-core/src/provider/graph/process_refusals.rs create mode 100644 src/lib/native-process-wrappers.ts create mode 100644 tests/native-process-wrappers.test.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 25cc9811a..03ba8d575 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -2538,6 +2538,25 @@ checks. Named users/groups remain unsupported for private delivery and refuse instead of silently becoming root. An image with no user retains Docker's normal root default; an explicit Compose numeric user takes precedence. +Services with private environment values and services with host dependency +bindings (including every service when project-wide host aliases exist) run their +process through a wrapper. The wrapper runs exactly what Docker would: the image +or Compose `entrypoint` followed by `command` (with no entrypoint, `command` +alone), and a bare executable name such as `docker-entrypoint.sh` or the +`mysqladmin` of `CMD mysqladmin ping` is resolved through the container's own +PATH after the environment is applied. A shell-form health check (`CMD-SHELL` +or a string `test`) runs through the image's shell (`/bin/sh -c` by default), as +Docker runs it. Stock images therefore need no absolute paths. What a wrapper +still cannot run is an empty process, a health test that names no executable, a +named (non-numeric) user, a stop signal other than SIGTERM, SIGINT, SIGQUIT or +SIGKILL, and a volume target at `/`, `/run` or a reserved wrapper path. Native +`up` refuses the Compose-declared cases before the VM starts, and the runtime +checks settings inherited from the pinned image before reserving anything. Both +name every refused service and field at once (for example +`service db user (use a numeric UID or UID:GID)`). `hack doctor` on the native +backend reports the Compose-declared cases in its `native process wrappers` +check, for every service native may wrap. + ### Approved-host DNS on pool restart The pinned Smol provider re-resolves approved hosts at each boot and appends the diff --git a/packages/relay-guest/README.md b/packages/relay-guest/README.md index d3a78ea81..0553b549c 100644 --- a/packages/relay-guest/README.md +++ b/packages/relay-guest/README.md @@ -136,15 +136,18 @@ shutdown and absence of surviving workers or descriptors. The Linux-only gate modes do not read stdin or install signal handlers: ```text -hack-relay-guest --await-release <32 lowercase hex generation> -- [args...] -hack-relay-guest --check-release <32 lowercase hex generation> -- [args...] +hack-relay-guest --await-release <32 lowercase hex generation> -- [args...] +hack-relay-guest --check-release <32 lowercase hex generation> -- [args...] ``` Await mode watches `/run/hack-startup` with inotify before checking for `release`, then waits on events with one anchored 120-second monotonic deadline. Check mode checks once and refuses immediately while the marker is absent. A valid release -executes the supplied absolute executable in place, preserving arguments (including -non-UTF-8 arguments), environment, uid/gid and standard descriptors. Observation +executes the supplied executable in place, preserving arguments (including +non-UTF-8 arguments), environment, uid/gid and standard descriptors. Like Docker, +a name containing `/` runs as given and a bare name (such as +`docker-entrypoint.sh`) is resolved through the container's own PATH; no shell +runs and no host PATH is consulted. Observation FDs close before exec. Graph launch must use Docker `Init=true` so normal init signal forwarding applies while waiting and after replacement. diff --git a/packages/relay-guest/src/barrier.rs b/packages/relay-guest/src/barrier.rs index 7333c9b5c..13211c745 100644 --- a/packages/relay-guest/src/barrier.rs +++ b/packages/relay-guest/src/barrier.rs @@ -1,6 +1,6 @@ //! A public generation fence, not a credential or authentication boundary. use crate::CandidateError; -use std::{ffi::OsString, os::unix::process::CommandExt, path::Path, process::Command}; +use std::{ffi::OsString, os::unix::process::CommandExt, process::Command}; fn refused() -> CandidateError { CandidateError::new("relay_guest_barrier", "Guest startup release refused.") @@ -18,7 +18,14 @@ pub(super) fn valid(args: &[OsString]) -> bool { .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) }) && args[2] == "--" - && Path::new(&args[3]).is_absolute() + && executable(&args[3]) +} +/// Docker accepts an absolute path, a path relative to the working directory, +/// or a bare name it resolves through the container's PATH. `Command` performs +/// that same lookup with this process's (the container's) PATH; no shell runs. +fn executable(name: &OsString) -> bool { + let bytes = name.as_encoded_bytes(); + !bytes.is_empty() && bytes != b"/" && !bytes.contains(&0) } pub(super) fn run(args: &[OsString]) -> Result<(), CandidateError> { if !valid(args) { diff --git a/packages/relay-guest/src/main.rs b/packages/relay-guest/src/main.rs index 147a38f23..9df4262d5 100644 --- a/packages/relay-guest/src/main.rs +++ b/packages/relay-guest/src/main.rs @@ -30,7 +30,7 @@ use std::{ time::Duration, }; -const USAGE: &str = "hack-relay-guest --slot <0..31> --application-fd <3..2147483647>\nhack-relay-guest --slot <0..31> --listen-port <1..65535> [--listen-address ]\nhack-relay-guest --await-release -- [args...]\nhack-relay-guest --check-release -- [args...]\nRelay credential: one private stdin descriptor. Listener address is 127.0.0.1 or 127.0.0.(slot+2).\n"; +const USAGE: &str = "hack-relay-guest --slot <0..31> --application-fd <3..2147483647>\nhack-relay-guest --slot <0..31> --listen-port <1..65535> [--listen-address ]\nhack-relay-guest --await-release -- [args...]\nhack-relay-guest --check-release -- [args...]\nRelay credential: one private stdin descriptor. Listener address is 127.0.0.1 or 127.0.0.(slot+2).\n"; mod options; use options::{Mode, Options, options}; fn credential() -> Result { diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index e7abf4bcd..87dfbe345 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -996,8 +996,16 @@ bound read-only at their targets. A compiled mount or tmpfs target that equals, contains or sits under a member target refuses before effects. Members carry no separate deadline: they live as long as the allocation, which is the container's, and are retired with it by the existing allocation cleanup. A service with values keeps the -launcher contract (numeric user, absolute entrypoint, `CMD` health with an absolute -executable). A files-only service keeps its own process and needs only a numeric image +launcher contract: a numeric user, a non-empty selected process and an exec-form +`CMD` health test. The selected process is the image or Compose `Entrypoint ++ Cmd` +(with no entrypoint, `Cmd` alone), and a `CMD-SHELL` test becomes the exec form +Docker runs (the image `SHELL`, default `/bin/sh -c`). Executables may be bare +names such as `docker-entrypoint.sh`: the launcher resolves them through the +container's PATH after applying the environment, as Docker does. Host-dependency +services get the same materialized process behind their release barrier. Every +wrapped service is checked before any runtime state is reserved; a refusal keeps +its code and lists each service and closed field (`entrypoint`, `healthcheck`, +`user`, `stop_signal`, `mounts`) in `process_refusals`. A files-only service keeps its own process and needs only a numeric image or Compose user. Live-owner restore and normalized serve-restore deliver fresh members into new allocations. `graph exec` and one-off runs refuse envelopes with files; files already in a running container are never re-delivered or renewed. diff --git a/packages/runtime-core/guest/environment-launcher.zig b/packages/runtime-core/guest/environment-launcher.zig index b63b04e89..64f0008db 100644 --- a/packages/runtime-core/guest/environment-launcher.zig +++ b/packages/runtime-core/guest/environment-launcher.zig @@ -37,16 +37,18 @@ fn freshValues(value: std.json.Value, started: u64, now: u64) !std.json.ObjectMa } return values.object; } -/// Resolve a user exec in this container after applying its environment. Never -/// consult a host PATH or start a shell. Startup and health still require an -/// absolute executable and do not call this function. +/// Exec the selected process in this container after applying its environment, +/// as Docker does: a name containing `/` is executed as given (relative to the +/// working directory), and a bare name is resolved through the container's PATH +/// (Docker's default PATH when none is set). Never consult a host PATH or start +/// a shell. Startup, health and exec all use this. fn execCommand(args: []const [:0]u8, env: *const std.process.EnvMap) !void { if (args.len == 0 or args[0].len == 0) return error.Arguments; const command = args[0]; if (std.mem.indexOfScalar(u8, command, '/') != null) { return std.process.execve(allocator, args, env); } - const path = env.get("PATH") orelse "/usr/local/bin:/bin:/usr/bin"; + const path = env.get("PATH") orelse "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; if (path.len > 8192) return error.Arguments; const argv = try allocator.alloc([]const u8, args.len); defer allocator.free(argv); @@ -120,7 +122,7 @@ fn run() !void { try std.posix.dup2(null_fd, 2); } const args = if (health or mounted_exec) raw[1..] else raw; - if (args.len < 4 or args.len > 4100 or (!mounted_exec and !std.fs.path.isAbsolute(args[3]))) return error.Arguments; + if (args.len < 4 or args.len > 4100 or args[3].len == 0) return error.Arguments; const payload = try privateFile(args[1], 32768); const expiry = try privateFile(args[2], 32); const deadline = try std.fmt.parseInt(u64, expiry, 10); @@ -135,8 +137,7 @@ fn run() !void { const now = try std.posix.clock_gettime(.BOOTTIME); if (now.sec < 0 or @as(u64, @intCast(now.sec)) >= deadline) return error.Expired; // Successful exec replaces this process: no resident wrapper and no signal forwarding shim. - if (mounted_exec) return execCommand(args[3..], &env); - return std.process.execve(allocator, args[3..], &env); + return execCommand(args[3..], &env); } pub fn main() void { run() catch { diff --git a/packages/runtime-core/src/error.rs b/packages/runtime-core/src/error.rs index 2e9f3132b..ad15b8dc9 100644 --- a/packages/runtime-core/src/error.rs +++ b/packages/runtime-core/src/error.rs @@ -53,6 +53,39 @@ pub struct StartupLogsDiagnostic { pub services: Vec, } +/// The container setting a private process wrapper could not run, named so a +/// refusal points at the Compose or image field to change. A closed vocabulary. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProcessField { + /// The selected process: Compose `entrypoint`/`command` or the image's. + Entrypoint, + /// The selected health check test. + Healthcheck, + /// The selected user (Compose `user` or the image's). + User, + /// Compose `stop_signal`. + StopSignal, + /// A mount target overlapping reserved wrapper paths. + Mounts, +} + +/// Value-free: admitted service names and closed fields only, never argv, +/// paths, users or other configuration values. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProcessRefusal { + pub service: String, + pub field: ProcessField, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProcessRefusalsDiagnostic { + pub version: u8, + pub refusals: Vec, +} + #[derive(Debug, Serialize)] pub struct CandidateError { pub code: &'static str, @@ -66,6 +99,10 @@ pub struct CandidateError { /// [`StartupLogTail`]. Never persist an error carrying it. #[serde(skip_serializing_if = "Option::is_none")] pub startup_logs: Option>, + /// Every service whose selected process a private wrapper refused, found in + /// one pass before any runtime state is reserved. + #[serde(skip_serializing_if = "Option::is_none")] + pub process_refusals: Option>, } impl CandidateError { @@ -76,6 +113,7 @@ impl CandidateError { cause_code: None, stop_failures: None, startup_logs: None, + process_refusals: None, } } diff --git a/packages/runtime-core/src/provider/environment.rs b/packages/runtime-core/src/provider/environment.rs index 5f565a1f2..9ca05fde2 100644 --- a/packages/runtime-core/src/provider/environment.rs +++ b/packages/runtime-core/src/provider/environment.rs @@ -197,7 +197,7 @@ impl PendingEnvironment { pub(super) fn service(&self) -> &str { &self.service } - /// Whether launcher admission (absolute entrypoint, CMD health) applies. + /// Whether launcher admission (selected process, exec-form CMD health) applies. pub(super) fn wraps_process(&self) -> bool { self.wraps } diff --git a/packages/runtime-core/src/provider/graph/image_process.rs b/packages/runtime-core/src/provider/graph/image_process.rs index 4e9fd6c25..31c10fdb4 100644 --- a/packages/runtime-core/src/provider/graph/image_process.rs +++ b/packages/runtime-core/src/provider/graph/image_process.rs @@ -1,8 +1,12 @@ //! Process defaults from an already verified immutable image, before wrappers. //! Compose null inherits; explicit entrypoint (including empty) suppresses image -//! CMD. No shell tokenization, PATH lookup or image selection occurs here. +//! CMD. No shell tokenization, PATH lookup or image selection occurs here: a +//! relative executable name stays relative and the wrapper resolves it through +//! the container's own PATH at exec time, as Docker does. //! See https://docs.docker.com/reference/compose-file/services/#entrypoint. +use super::process_refusals::Refused; use super::{CandidateError, error}; +use crate::error::ProcessField; use serde_json::{Value, json}; const MAX_ARGUMENTS: usize = 4096; @@ -78,47 +82,122 @@ pub(super) fn resolve(image: &Value, create: &Value) -> Result Result { +fn selected_user(image: &Value, create: &Value) -> Result { fn nonempty(value: Option<&Value>) -> Option<&Value> { value.filter(|value| !value.is_null() && value.as_str() != Some("")) } let user = nonempty(create.get("User")).or_else(|| nonempty(image.get("User"))); let user = match user { None => "", - Some(value) => value.as_str().ok_or_else(malformed)?, + Some(value) => value.as_str().ok_or_else(|| Refused { + field: ProcessField::User, + error: malformed(), + })?, }; - super::launcher::identity(&json!({"User":user}))?; + super::launcher::identity(&json!({"User":user})).map_err(|error| Refused { + field: ProcessField::User, + error, + })?; Ok(user.into()) } + +/// Docker runs a `CMD-SHELL` health check as the image's `SHELL` (default +/// `/bin/sh -c`) followed by the test text. Express exactly that as an exec-form +/// `CMD` test, which a wrapper can then prefix. Other tests are unchanged. +fn exec_form_health(create: &Value, image: &Value) -> Result, Refused> { + let refused = || { + Refused::new( + ProcessField::Healthcheck, + "environment_health", + "Private delivery requires a bounded health check test; values omitted.", + ) + }; + let Some(test) = create["Healthcheck"]["Test"].as_array() else { + return Ok(None); + }; + if test.first().and_then(Value::as_str) != Some("CMD-SHELL") { + return Ok(None); + } + let [_, Value::String(command)] = test.as_slice() else { + return Err(refused()); + }; + if command.is_empty() || command.contains('\0') || command.len() > MAX_BYTES { + return Err(refused()); + } + let shell: Vec = match image.get("Shell") { + None | Some(Value::Null) => vec!["/bin/sh".into(), "-c".into()], + Some(Value::Array(values)) + if !values.is_empty() + && values.len() <= 16 + && values.iter().all(|value| { + value + .as_str() + .is_some_and(|text| !text.is_empty() && !text.contains('\0')) + }) => + { + values + .iter() + .filter_map(|value| value.as_str().map(str::to_owned)) + .collect() + } + Some(_) => return Err(refused()), + }; + let mut exec = vec![json!("CMD")]; + exec.extend(shell.into_iter().map(Value::String)); + exec.push(json!(command)); + Ok(Some(json!(exec))) +} /// Materialize exactly the selected process for a private environment/dependency -/// wrapper. All validation precedes mutation. Relative executable names remain -/// unsupported here; private launch must never add ambient PATH resolution. -/// An empty entrypoint is preserved by `resolve` but refused by this adapter, -/// even when Cmd alone names an absolute executable (a separate argv-only gate). -pub(super) fn apply_private(create: &mut Value, image: &Value) -> Result<(), CandidateError> { - let process = resolve(image, create)?; - if !process - .entrypoint - .first() - .is_some_and(|name| name.starts_with('/') && name != "/") - { - return Err(error( +/// wrapper. All validation precedes mutation. +/// +/// Docker runs `Entrypoint ++ Cmd`; with no entrypoint, `Cmd` alone is the +/// process, so it becomes the materialized entrypoint (the same argv). An +/// executable name without `/` (such as `docker-entrypoint.sh`) is kept as is: +/// the wrapper resolves it through the container's PATH at exec time exactly as +/// Docker does, never through a host or wrapper PATH. A `CMD-SHELL` health check +/// becomes the equivalent exec-form test. Only an empty process refuses. +pub(super) fn apply_private(create: &mut Value, image: &Value) -> Result<(), Refused> { + let process = resolve(image, create).map_err(|error| Refused { + field: ProcessField::Entrypoint, + error, + })?; + let (entrypoint, cmd) = if process.entrypoint.is_empty() { + (process.cmd, Vec::new()) + } else { + (process.entrypoint, process.cmd) + }; + if !entrypoint.first().is_some_and(|name| executable(name)) { + return Err(Refused::new( + ProcessField::Entrypoint, "environment_entrypoint", - "Private delivery requires a pinned absolute entrypoint; values omitted.", + "Private delivery requires an entrypoint or command that names an executable; values omitted.", )); } let user = selected_user(image, create)?; + let health = exec_form_health(create, image)?; create["User"] = json!(user); - create["Entrypoint"] = json!(process.entrypoint); - create["Cmd"] = json!(process.cmd); + create["Entrypoint"] = json!(entrypoint); + create["Cmd"] = json!(cmd); + if let Some(test) = health { + create["Healthcheck"]["Test"] = test; + } Ok(()) } +/// An exec target Docker would accept: absolute, relative to the working +/// directory, or a bare name resolved through the container's PATH. +pub(super) fn executable(name: &str) -> bool { + !name.is_empty() && name != "/" && !name.contains('\0') +} + /// Files-only private delivery keeps the selected process unchanged and only pins the /// numeric owner that staged mode-0400 members must match. Validation precedes mutation. -pub(super) fn apply_file_owner(create: &mut Value, image: &Value) -> Result<(), CandidateError> { +pub(super) fn apply_file_owner(create: &mut Value, image: &Value) -> Result<(), Refused> { if !image.is_object() || !create.is_object() { - return Err(malformed()); + return Err(Refused { + field: ProcessField::User, + error: malformed(), + }); } let user = selected_user(image, create)?; create["User"] = json!(user); @@ -219,23 +298,98 @@ mod tests { } #[test] - fn refuses_path_lookup_or_empty_entry_without_partial_mutation() { - for image in [ - json!({"Entrypoint":["relative"],"Cmd":[]}), - json!({"Entrypoint":null,"Cmd":["/absolute"]}), + fn relative_stock_entrypoints_stay_relative_for_container_path_lookup() { + // A stock image such as `ENTRYPOINT ["docker-entrypoint.sh"]` keeps its + // exact argv; the wrapper resolves the name through the container PATH. + let image = json!({"Entrypoint":["docker-entrypoint.sh"],"Cmd":["mysqld"]}); + let mut create = json!({}); + apply_private(&mut create, &image).unwrap(); + assert_eq!(create["Entrypoint"], json!(["docker-entrypoint.sh"])); + assert_eq!(create["Cmd"], json!(["mysqld"])); + let mut relative = json!({"Entrypoint":["./bin/start"],"Cmd":null}); + apply_private(&mut relative, &json!({})).unwrap(); + assert_eq!(relative["Entrypoint"], json!(["./bin/start"])); + } + + #[test] + fn command_only_process_is_the_same_argv_as_docker_runs() { + for (image, create, argv) in [ + ( + json!({"Cmd":["python3","-m","http.server"]}), + json!({}), + json!(["python3", "-m", "http.server"]), + ), + ( + json!({"Entrypoint":["/image"]}), + json!({"Entrypoint":[],"Cmd":["/explicit","a"]}), + json!(["/explicit", "a"]), + ), + ( + json!({"Entrypoint":null,"Cmd":["/absolute"]}), + json!({}), + json!(["/absolute"]), + ), ] { - let mut create = json!({}); + let mut create = create; + apply_private(&mut create, &image).unwrap(); + assert_eq!(create["Entrypoint"], argv); + assert_eq!(create["Cmd"], json!([])); + } + } + + #[test] + fn refuses_an_empty_process_without_partial_mutation() { + for (image, create) in [ + (json!({"Entrypoint":null,"Cmd":null}), json!({})), + (json!({"Entrypoint":["/image"]}), json!({"Entrypoint":[]})), + (json!({"Entrypoint":[""],"Cmd":["x"]}), json!({})), + (json!({"Entrypoint":["/"]}), json!({})), + ] { + let mut create = create; let before = create.clone(); - assert_eq!( - apply_private(&mut create, &image).unwrap_err().code, - "environment_entrypoint" - ); + let refused = apply_private(&mut create, &image).unwrap_err(); + assert_eq!(refused.error.code, "environment_entrypoint"); + assert_eq!(refused.field, ProcessField::Entrypoint); + assert_eq!(create, before); + } + } + + #[test] + fn shell_form_health_becomes_the_exec_form_docker_runs() { + let image = json!({"Entrypoint":["docker-entrypoint.sh"]}); + let mut create = json!({"Healthcheck":{"Test":["CMD-SHELL","mysqladmin ping -h localhost"],"Retries":3}}); + apply_private(&mut create, &image).unwrap(); + assert_eq!( + create["Healthcheck"], + json!({"Test":["CMD","/bin/sh","-c","mysqladmin ping -h localhost"],"Retries":3}) + ); + let shell = json!({"Entrypoint":["/e"],"Shell":["/bin/bash","-o","pipefail","-c"]}); + let mut create = json!({"Healthcheck":{"Test":["CMD-SHELL","true"]}}); + apply_private(&mut create, &shell).unwrap(); + assert_eq!( + create["Healthcheck"]["Test"], + json!(["CMD", "/bin/bash", "-o", "pipefail", "-c", "true"]) + ); + // Exec-form tests, including relative names, are unchanged. + let mut create = json!({"Healthcheck":{"Test":["CMD","mysqladmin","ping"]}}); + apply_private(&mut create, &image).unwrap(); + assert_eq!( + create["Healthcheck"]["Test"], + json!(["CMD", "mysqladmin", "ping"]) + ); + for test in [ + json!(["CMD-SHELL"]), + json!(["CMD-SHELL", ""]), + json!(["CMD-SHELL", "a", "b"]), + ] { + let mut create = json!({"Healthcheck":{"Test":test}}); + let before = create.clone(); + let refused = apply_private(&mut create, &image).unwrap_err(); + assert_eq!(refused.field, ProcessField::Healthcheck); assert_eq!(create, before); } - let mut create = json!({"Entrypoint":[],"Cmd":["/explicit"]}); - let before = create.clone(); - assert!(apply_private(&mut create, &json!({"Entrypoint":["/image"]})).is_err()); - assert_eq!(create, before); + let mut create = json!({"Healthcheck":{"Test":["CMD-SHELL","true"]}}); + assert!(apply_private(&mut create, &json!({"Entrypoint":["/e"],"Shell":"sh"})).is_err()); } #[test] diff --git a/packages/runtime-core/src/provider/graph/launcher.rs b/packages/runtime-core/src/provider/graph/launcher.rs index 9d6a5c104..1baff55c7 100644 --- a/packages/runtime-core/src/provider/graph/launcher.rs +++ b/packages/runtime-core/src/provider/graph/launcher.rs @@ -1,5 +1,7 @@ //! Feature-gated static launcher; no resident wrapper or runtime credential provider. +use super::process_refusals::Refused; use super::*; +use crate::error::ProcessField; #[cfg(feature = "environment-launcher")] use base64::Engine as _; #[cfg(feature = "environment-launcher")] @@ -99,25 +101,40 @@ pub(super) fn identity(config: &Value) -> Result<(u32, u32), CandidateError> { Ok((parse(uid)?, parse(gid)?)) } -pub(super) fn validate(config: &Value) -> Result<(), CandidateError> { - identity(config)?; - let entry = config["Entrypoint"] +/// Wrapper admission for one materialized process (see +/// `image_process::apply_private`). Executables may be absolute, relative to +/// the working directory, or bare names: the wrapper resolves a bare name +/// through the container's PATH at exec time, as Docker does. Each refusal +/// names the field it concerns. +pub(super) fn validate(config: &Value) -> Result<(), Refused> { + identity(config).map_err(|error| Refused { + field: ProcessField::User, + error, + })?; + let executable = config["Entrypoint"] .as_array() - .filter(|a| !a.is_empty()) - .ok_or_else(|| { - error( - "environment_entrypoint", - "Environment delivery requires an explicit absolute entrypoint.", - ) - })?; - if !entry[0].as_str().is_some_and(|s| s.starts_with('/')) - || config["StopSignal"] - .as_str() - .is_some_and(|s| !["SIGTERM", "SIGINT", "SIGQUIT", "SIGKILL"].contains(&s)) + .and_then(|entry| entry.first()) + .and_then(Value::as_str) + .is_some_and(super::image_process::executable); + if !executable + || !config["Entrypoint"] + .as_array() + .is_some_and(|entry| entry.iter().all(Value::is_string)) { - return Err(error( + return Err(Refused::new( + ProcessField::Entrypoint, "environment_entrypoint", - "Environment delivery requires an absolute entrypoint ; unsupported stop signals remain gated.", + "Environment delivery requires an explicit entrypoint that names an executable.", + )); + } + if config["StopSignal"] + .as_str() + .is_some_and(|s| !["SIGTERM", "SIGINT", "SIGQUIT", "SIGKILL"].contains(&s)) + { + return Err(Refused::new( + ProcessField::StopSignal, + "environment_entrypoint", + "Environment delivery supports only SIGTERM, SIGINT, SIGQUIT or SIGKILL stop signals.", )); } validate_health(config)?; @@ -128,7 +145,8 @@ pub(super) fn validate(config: &Value) -> Result<(), CandidateError> { { let target = mount["Target"].as_str().unwrap_or(""); if ["/run", "/"].contains(&target) || target.starts_with("/run/hack-environment") { - return Err(error( + return Err(Refused::new( + ProcessField::Mounts, "environment_mount", "A mount overlaps reserved environment paths.", )); @@ -136,12 +154,13 @@ pub(super) fn validate(config: &Value) -> Result<(), CandidateError> { } Ok(()) } -fn validate_health(config: &Value) -> Result<(), CandidateError> { +fn validate_health(config: &Value) -> Result<(), Refused> { let Some(health) = config.get("Healthcheck") else { return Ok(()); }; let test = health["Test"].as_array().ok_or_else(|| { - error( + Refused::new( + ProcessField::Healthcheck, "environment_health", "An explicit CMD health check is required.", ) @@ -151,18 +170,21 @@ fn validate_health(config: &Value) -> Result<(), CandidateError> { } if test.len() < 2 || test[0] != "CMD" - || !test[1].as_str().is_some_and(|s| s.starts_with('/')) + || !test[1] + .as_str() + .is_some_and(super::image_process::executable) || !test.iter().all(Value::is_string) { - return Err(error( + return Err(Refused::new( + ProcessField::Healthcheck, "environment_health", - "Environment health delivery requires CMD with an absolute executable.", + "Environment health delivery requires an exec-form CMD test that names an executable.", )); } Ok(()) } pub(super) fn attach(config: &mut Value, path: &str, launcher: &str) -> Result<(), CandidateError> { - validate(config)?; + validate(config).map_err(Refused::into_error)?; // Preserve Docker's numeric UID-only group lookup and supplemental groups. identity(config)?; if config["Healthcheck"]["Test"][0] == "CMD" { @@ -339,16 +361,50 @@ mod tests { assert_eq!(value["Healthcheck"]["Interval"], 1000000000); assert_eq!(value["Healthcheck"]["Timeout"], 2000000000); assert_eq!(value["Healthcheck"]["Retries"], 3); + // apply_private turns CMD-SHELL into the exec form Docker runs first. for test in [ json!(["CMD-SHELL", "echo ok"]), json!(["CMD"]), json!(["CMD", "/bin/check", 5]), + json!(["CMD", ""]), ] { value["Healthcheck"]["Test"] = test; - assert!(validate(&value).is_err()); + let refused = validate(&value).unwrap_err(); + assert_eq!(refused.field, ProcessField::Healthcheck); + assert_eq!(refused.error.code, "environment_health"); } } #[test] + fn relative_entrypoint_and_health_names_are_kept_for_container_path_lookup() { + let mut value = config(); + value["Entrypoint"] = json!(["docker-entrypoint.sh"]); + value["Cmd"] = json!(["mysqld"]); + value["Healthcheck"] = json!({"Test":["CMD","mysqladmin","ping"]}); + attach(&mut value, "/run/slot/values.json", "/storage/launcher").unwrap(); + assert_eq!( + value["Entrypoint"], + json!([ + "/run/hack-environment-launcher", + "/run/hack-environment.json", + "/run/hack-environment.expires", + "docker-entrypoint.sh", + "mysqld" + ]) + ); + assert_eq!( + value["Healthcheck"]["Test"], + json!([ + "CMD", + "/run/hack-environment-launcher", + "--health", + "/run/hack-environment.json", + "/run/hack-environment.expires", + "mysqladmin", + "ping" + ]) + ); + } + #[test] fn uid_only_keeps_engine_group_resolution_while_private_files_use_owner_uid() { let mut value = config(); value["User"] = json!("1001"); @@ -429,12 +485,34 @@ mod tests { value["HostConfig"]["Mounts"] = json!([{"Target":target}]); assert!(validate(&value).is_err()); } + for (field, entry) in [ + (ProcessField::Entrypoint, json!([])), + (ProcessField::Entrypoint, json!([""])), + (ProcessField::Entrypoint, json!(["/"])), + (ProcessField::Entrypoint, json!(["app", 5])), + (ProcessField::Entrypoint, json!(null)), + ] { + let mut value = config(); + value["Entrypoint"] = entry; + assert_eq!(validate(&value).unwrap_err().field, field); + } let mut value = config(); - value["Healthcheck"] = json!({"Test":["CMD","true"]}); - assert!(validate(&value).is_err()); + value["StopSignal"] = json!("SIGUSR1"); + assert_eq!( + validate(&value).unwrap_err().field, + ProcessField::StopSignal + ); + let mut value = config(); + value["User"] = json!("bun"); + assert_eq!(validate(&value).unwrap_err().field, ProcessField::User); + let mut value = config(); + value["HostConfig"]["Mounts"] = json!([{"Target":"/run"}]); + assert_eq!(validate(&value).unwrap_err().field, ProcessField::Mounts); + // Relative names are accepted for container PATH lookup. let mut value = config(); value["Entrypoint"] = json!(["app"]); - assert!(validate(&value).is_err()); + value["Healthcheck"] = json!({"Test":["CMD","true"]}); + assert!(validate(&value).is_ok()); } } diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index 41017a492..befa300bc 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -123,6 +123,7 @@ pub use endpoints::GuestEndpoint; mod environment_crash_test; mod launcher; mod probes; +mod process_refusals; #[cfg(all(test, feature = "environment-launcher"))] mod startup_test; pub use environment::stage_environment; @@ -1329,17 +1330,44 @@ pub(super) fn validate_private_delivery( configs: &BTreeMap, environments: &BTreeMap, ) -> Result<(), CandidateError> { + let mut refusals = process_refusals::Collector::default(); for (name, pending) in environments { let config = &configs[name]; if pending.wraps_process() { - launcher::validate(config)?; + refusals.check(name, launcher::validate(config)); } else { - launcher::identity(config)?; + refusals.check( + name, + launcher::identity(config) + .map(|_| ()) + .map_err(|error| process_refusals::Refused { + field: crate::error::ProcessField::User, + error, + }), + ); } - launcher::validate_files(config, &pending.file_targets())?; + } + refusals.finish()?; + for (name, pending) in environments { + launcher::validate_files(&configs[name], &pending.file_targets())?; } Ok(()) } + +/// Wrapper admission for host-dependency services, before any runtime state is +/// reserved: their release barrier runs the same materialized process. +pub(super) fn validate_dependency_processes( + configs: &BTreeMap, + services: &BTreeSet, +) -> Result<(), CandidateError> { + let mut refusals = process_refusals::Collector::default(); + for name in services { + if let Some(config) = configs.get(name) { + refusals.check(name, launcher::validate(config)); + } + } + refusals.finish() +} /// Services whose process the private launcher wraps, and files-only services whose /// image user must still be materialized for exact file ownership. pub(super) fn private_services( @@ -1453,8 +1481,17 @@ fn run_inputs( super::environment::preflight_capacity(engine.guest(), environments.len())?; } // Resolve inherited process defaults only from the exact verified image, - // before the private launcher takes ownership of Entrypoint and Cmd. - let (wrapped, file_owners) = private_services(&environments); + // before the private launcher or the dependency release barrier takes + // ownership of Entrypoint and Cmd. + let (mut wrapped, mut file_owners) = private_services(&environments); + let dependency_processes = startup + .as_ref() + .map(|driver| driver.process_services()) + .unwrap_or_default(); + for name in &dependency_processes { + file_owners.remove(name); + wrapped.insert(name.clone()); + } let expected_environment = verify_images( &engine, &prepared.resources, @@ -1463,6 +1500,7 @@ fn run_inputs( &file_owners, )?; validate_private_delivery(&prepared.configs, &environments)?; + validate_dependency_processes(&prepared.configs, &dependency_processes)?; if let Some(driver) = startup.as_ref() { driver.check_cancelled()?; } @@ -2358,6 +2396,7 @@ fn verify_images( file_owners: &BTreeSet, ) -> Result>, CandidateError> { let mut expected = BTreeMap::new(); + let mut refusals = process_refusals::Collector::default(); for resource in resources.values().filter(|r| r.kind == Kind::Container) { let image = engine.request( Method::GET, @@ -2384,24 +2423,32 @@ fn verify_images( resources, )?; if private_services.contains(&resource.key) { - image_process::apply_private( - configs - .get_mut(&resource.key) - .expect("compiled service config"), - &image["Config"], - )?; + refusals.check( + &resource.key, + image_process::apply_private( + configs + .get_mut(&resource.key) + .expect("compiled service config"), + &image["Config"], + ), + ); } else if file_owners.contains(&resource.key) { - image_process::apply_file_owner( - configs - .get_mut(&resource.key) - .expect("compiled service config"), - &image["Config"], - )?; + refusals.check( + &resource.key, + image_process::apply_file_owner( + configs + .get_mut(&resource.key) + .expect("compiled service config"), + &image["Config"], + ), + ); } expected.insert( resource.key.clone(), image_environment::compose(&image["Config"]["Env"], &configs[&resource.key]["Env"])?, ); } + // Every wrapped service is checked before the first refusal is reported. + refusals.finish()?; Ok(expected) } diff --git a/packages/runtime-core/src/provider/graph/native/runtime.rs b/packages/runtime-core/src/provider/graph/native/runtime.rs index a0786804c..3aa30105a 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime.rs @@ -1088,7 +1088,8 @@ pub(super) fn run_guarded( verify_native_images(&engine, &config.resources, &mut config.configs, &private)? }; for name in &private { - launcher::validate(&config.configs[name])?; + launcher::validate(&config.configs[name]) + .map_err(super::super::process_refusals::Refused::into_error)?; } check_startup(startup_guard)?; selected.assert_fresh(candidate)?; @@ -1793,7 +1794,8 @@ fn verify_native_images( } } if private.contains(&resource.key) { - image_process::apply_private(config, &image["Config"])?; + image_process::apply_private(config, &image["Config"]) + .map_err(super::super::process_refusals::Refused::into_error)?; } expected.insert( resource.key.clone(), diff --git a/packages/runtime-core/src/provider/graph/process_refusals.rs b/packages/runtime-core/src/provider/graph/process_refusals.rs new file mode 100644 index 000000000..30d0017fe --- /dev/null +++ b/packages/runtime-core/src/provider/graph/process_refusals.rs @@ -0,0 +1,145 @@ +//! Named, collected refusals of a private process wrapper's selected process. +//! +//! A wrapper (private environment delivery or the host-dependency release +//! barrier) runs the exact process Docker would run, so every wrapped service is +//! checked before any runtime state is reserved. Each refusal keeps its fixed +//! code and value-free message and also names the service and the closed field +//! to change. All wrapped services are checked in one pass so a project with +//! several affected stock images learns about all of them from one start. +use crate::error::{CandidateError, ProcessField, ProcessRefusal, ProcessRefusalsDiagnostic}; + +/// One wrapper refusal before it is attributed to a service. +#[derive(Debug)] +pub(super) struct Refused { + pub(super) field: ProcessField, + pub(super) error: CandidateError, +} + +impl Refused { + pub(super) fn new(field: ProcessField, code: &'static str, message: &str) -> Self { + Self { + field, + error: CandidateError::new(code, message), + } + } + + /// The plain refusal, for a caller that checks one process only. + pub(super) fn into_error(self) -> CandidateError { + self.error + } +} + +/// Bounded like other value-free per-service diagnostics. +const MAX_REFUSALS: usize = 32; + +/// Collects refusals across services; the first refusal's code is reported. +#[derive(Default)] +pub(super) struct Collector { + first: Option, + refusals: Vec, +} + +impl Collector { + pub(super) fn check(&mut self, service: &str, result: Result<(), Refused>) { + let Err(refused) = result else { + return; + }; + if self.refusals.len() < MAX_REFUSALS + && !self + .refusals + .iter() + .any(|known| known.service == service && known.field == refused.field) + { + self.refusals.push(ProcessRefusal { + service: service.into(), + field: refused.field, + }); + } + self.first.get_or_insert(refused.error); + } + + pub(super) fn finish(self) -> Result<(), CandidateError> { + match self.first { + None => Ok(()), + Some(mut error) => { + error.message = format!( + "{} Refused process settings are named in process_refusals.", + error.message + ); + error.process_refusals = Some(Box::new(ProcessRefusalsDiagnostic { + version: 1, + refusals: self.refusals, + })); + Err(error) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collects_every_service_and_field_and_keeps_the_first_code() { + let mut collector = Collector::default(); + collector.check("web", Ok(())); + collector.check( + "db", + Err(Refused::new( + ProcessField::User, + "environment_user", + "Invalid.", + )), + ); + collector.check( + "api", + Err(Refused::new( + ProcessField::Healthcheck, + "environment_health", + "Invalid.", + )), + ); + collector.check( + "db", + Err(Refused::new( + ProcessField::User, + "environment_user", + "Again.", + )), + ); + let error = collector.finish().unwrap_err(); + assert_eq!(error.code, "environment_user"); + let detail = error.process_refusals.expect("named refusals"); + assert_eq!(detail.version, 1); + assert_eq!( + detail.refusals, + vec![ + ProcessRefusal { + service: "db".into(), + field: ProcessField::User + }, + ProcessRefusal { + service: "api".into(), + field: ProcessField::Healthcheck + }, + ] + ); + let reply = serde_json::to_value(CandidateError { + process_refusals: Some(Box::new(ProcessRefusalsDiagnostic { + version: 1, + refusals: detail.refusals, + })), + ..CandidateError::new("environment_user", "Invalid.") + }) + .unwrap(); + assert_eq!( + reply["process_refusals"], + serde_json::json!({"version":1,"refusals":[ + {"service":"db","field":"user"}, + {"service":"api","field":"healthcheck"} + ]}) + ); + assert!(Collector::default().finish().is_ok()); + } +} diff --git a/packages/runtime-core/src/provider/graph/restore.rs b/packages/runtime-core/src/provider/graph/restore.rs index 756427a92..c254a456a 100644 --- a/packages/runtime-core/src/provider/graph/restore.rs +++ b/packages/runtime-core/src/provider/graph/restore.rs @@ -477,7 +477,15 @@ fn restore_inputs( if !environments.is_empty() { super::super::environment::preflight_capacity(engine.guest(), environments.len())?; } - let (wrapped, file_owners) = private_services(&environments); + let (mut wrapped, mut file_owners) = private_services(&environments); + let dependency_processes = startup + .as_ref() + .map(|driver| driver.process_services()) + .unwrap_or_default(); + for name in &dependency_processes { + file_owners.remove(name); + wrapped.insert(name.clone()); + } let expected_environment = verify_images( &engine, &prepared.resources, @@ -485,6 +493,7 @@ fn restore_inputs( &wrapped, &file_owners, )?; + super::validate_dependency_processes(&prepared.configs, &dependency_processes)?; for resource in receipt.resources.values() { let present = inspect_resource(&engine, &receipt, resource)?.is_some(); if resource.kind == Kind::Volume { diff --git a/packages/runtime-core/src/provider/graph/startup.rs b/packages/runtime-core/src/provider/graph/startup.rs index 2321ccfe1..c2bcf0605 100644 --- a/packages/runtime-core/src/provider/graph/startup.rs +++ b/packages/runtime-core/src/provider/graph/startup.rs @@ -188,6 +188,12 @@ pub(super) trait Driver { fn check_cancelled(&self) -> Result<(), CandidateError> { Ok(()) } + /// Services whose process this driver wraps behind its release barrier. + /// Their inherited image process is materialized before admission, exactly + /// as for private environment delivery, so the wrapper runs what Docker would. + fn process_services(&self) -> std::collections::BTreeSet { + std::collections::BTreeSet::new() + } fn validate_inputs( &self, inputs: &project::inputs::ExecutionInputs, diff --git a/packages/runtime-core/src/provider/graph/startup/runtime.rs b/packages/runtime-core/src/provider/graph/startup/runtime.rs index 139aad584..3a7f5be5a 100644 --- a/packages/runtime-core/src/provider/graph/startup/runtime.rs +++ b/packages/runtime-core/src/provider/graph/startup/runtime.rs @@ -656,6 +656,13 @@ impl Driver for HostRelayRuntime { self.run.is_some() } + fn process_services(&self) -> std::collections::BTreeSet { + self.dependencies + .keys() + .map(|(service, _)| service.clone()) + .collect() + } + fn check_cancelled(&self) -> Result<(), CandidateError> { if self.startup_cancelled.is_some_and(|check| check()) { return Err(error( @@ -789,7 +796,8 @@ impl Driver for HostRelayRuntime { let config = configs .get(name) .ok_or_else(|| stage_refused("graph_startup_service_config"))?; - launcher::validate(config)?; + launcher::validate(config) + .map_err(super::super::process_refusals::Refused::into_error)?; if config["HostConfig"]["Init"] != true { return Err(stage_refused("graph_startup_init")); } diff --git a/src/backends/native-project-process.ts b/src/backends/native-project-process.ts index 54670e94b..0737909c7 100644 --- a/src/backends/native-project-process.ts +++ b/src/backends/native-project-process.ts @@ -1,6 +1,7 @@ import { isAbsolute } from "node:path"; import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; +import type { NativeProcessRefusal } from "../lib/native-process-wrappers.ts"; import { type NativeAuthoredReceipt, type NativeAuthoredReview, @@ -29,6 +30,8 @@ export type NativeExitDiagnostic = { readonly nativeCode?: string; /** Bounded cause the runtime allows with `nativeCode`, such as a wrapped stopped runtime. */ readonly nativeCauseCode?: string; + /** Every service and closed field a process wrapper refused; value-free. */ + readonly processRefusals?: readonly NativeProcessRefusal[]; }; type ProcessOptions = { @@ -482,6 +485,7 @@ async function serveGraphProcess( const failure = await failureCodes; const nativeCode = failure?.code; const nativeCauseCode = failure?.causeCode; + const processRefusals = failure?.processRefusals; clearTimeout(drainTimer); drain.abort(); try { @@ -489,6 +493,7 @@ async function serveGraphProcess( exitCode, ...(nativeCode ? { nativeCode } : {}), ...(nativeCauseCode ? { nativeCauseCode } : {}), + ...(processRefusals ? { processRefusals } : {}), }); } catch { // Observation must never replace the startup result or cleanup authority. diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index a06616231..9be223cd8 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; +import { requireWrappableNativeProcesses } from "../lib/native-process-wrappers.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; import { adaptNativeGcloudEnvironment } from "./native-gcloud-environment.ts"; import { prepareNativeProjectAdaptation } from "./native-project-adaptation.ts"; @@ -453,6 +454,14 @@ export async function preflightNativeRestart(opts: { sources: opts.internalExtraHostSources, }); prepareNativeDependencyServices({ dependencies, services: specs }); + // The replacement startup would refuse these before its VM work; refuse + // before the current graph is stopped. + requireWrappableNativeProcesses({ + services: specs, + managedEnvironment: input.managedEnvironment, + managedFiles: input.managedFiles, + dependencyServices: dependencies.map((binding) => binding.service), + }); const activeImages = stoppedRetry ? undefined : await deps.activeImages({ diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 6debd0d44..67bf37985 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -8,6 +8,11 @@ import { withComposeServiceEnvFingerprints, } from "../lib/compose-env-fingerprint.ts"; import { isRecord } from "../lib/guards.ts"; +import { + describeNativeProcessRefusals, + type NativeProcessRefusal, + requireWrappableNativeProcesses, +} from "../lib/native-process-wrappers.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; import { scheduleNativeAwsExpiryWarnings } from "./native-aws-expiry.ts"; import { @@ -872,13 +877,21 @@ const NATIVE_CODE = /^[A-Za-z0-9_]{1,80}$/; * inspection reports the run's journal exactly absent. Nothing was created, so * no cleanup is pending and the startup diagnostic is the whole story. */ -function nothingReserved(startupFailure: unknown, nativeCode?: string): Error { +function nothingReserved( + startupFailure: unknown, + nativeCode?: string, + processRefusals?: readonly NativeProcessRefusal[] +): Error { const message = startupFailure instanceof Error ? startupFailure.message : ""; const diagnostic = SAFE_STARTUP_DIAGNOSTIC.exec(message); const code = nativeCode && NATIVE_CODE.test(nativeCode) ? nativeCode : diagnostic?.[2]; + const refused = + processRefusals && processRefusals.length > 0 + ? ` Native cannot wrap these services' processes: ${describeNativeProcessRefusals(processRefusals)}.` + : ""; return new ClosedCliError( - `Native graph startup failed${code ? ` (${code})` : ""} before any runtime state was reserved; nothing was created and no cleanup is needed.` + `Native graph startup failed${code ? ` (${code})` : ""} before any runtime state was reserved; nothing was created and no cleanup is needed.${refused}` ); } /** @@ -894,6 +907,7 @@ function uninspectableStartupFailure(opts: { readonly restored: boolean; readonly mappingAttempted: boolean; readonly nativeCode: string | undefined; + readonly processRefusals?: readonly NativeProcessRefusal[]; }): { readonly error: Error; readonly nothingReserved: boolean } { if ( opts.serveFailure === undefined || @@ -911,7 +925,11 @@ function uninspectableStartupFailure(opts: { }; } return { - error: nothingReserved(opts.serveFailure, opts.nativeCode), + error: nothingReserved( + opts.serveFailure, + opts.nativeCode, + opts.processRefusals + ), nothingReserved: true, }; } @@ -1559,6 +1577,13 @@ export async function startNativeProject(opts: { dependencies: hostDependencies, services: specs, }); + // Refuse Compose settings no process wrapper can run before the VM starts. + requireWrappableNativeProcesses({ + services: specs, + managedEnvironment: input.managedEnvironment, + managedFiles: input.managedFiles, + dependencyServices: hostDependencies.map((binding) => binding.service), + }); await verifyNativeRetainedMapping({ run: retained, scope: opts.scope, @@ -1765,6 +1790,9 @@ export async function startNativeProject(opts: { let code = 1; let serveFailure: unknown; let nativeExitCode: string | undefined; + let nativeProcessRefusals: + | readonly NativeProcessRefusal[] + | undefined; // Redacted at receipt; the raw tail is never retained or persisted. let startupLogReport = ""; try { @@ -1803,6 +1831,7 @@ export async function startNativeProject(opts: { ...stop?.serve, onExitDiagnostic: (diagnostic) => { nativeExitCode = diagnostic.nativeCode; + nativeProcessRefusals = diagnostic.processRefusals; }, onStartupLogs: (logs) => { startupLogReport = formatNativeStartupLogReport({ @@ -1937,6 +1966,7 @@ export async function startNativeProject(opts: { restored: restore !== undefined, mappingAttempted, nativeCode: nativeExitCode, + processRefusals: nativeProcessRefusals, }); graphCleanupConfirmed = settled.nothingReserved; throw settled.error; diff --git a/src/backends/native-runtime-client.ts b/src/backends/native-runtime-client.ts index 67d52f89d..cfb124b88 100644 --- a/src/backends/native-runtime-client.ts +++ b/src/backends/native-runtime-client.ts @@ -1,6 +1,10 @@ import { isAbsolute } from "node:path"; import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; +import { + type NativeProcessRefusal, + readNativeProcessRefusals, +} from "../lib/native-process-wrappers.ts"; import { nativeAuthoredExecOptions } from "./native-authored-exec-options.ts"; import { NATIVE_STARTUP_FAILURE_REPLY_BYTES, @@ -23,6 +27,8 @@ interface NativeFailure { readonly causeCode?: string; readonly stopFailures?: readonly NativeStopFailure[]; readonly startupLogs?: readonly NativeStartupLogTail[]; + /** Services and closed fields a process wrapper refused; value-free. */ + readonly processRefusals?: readonly NativeProcessRefusal[]; } /** @@ -642,8 +648,16 @@ function nativeFailureFromReply( ...(startupLogs && value.startup_logs !== undefined ? { startupLogs: readNativeStartupLogs(value.startup_logs) } : {}), + ...processRefusalsOf(value.process_refusals), }; } +function processRefusalsOf(value: unknown): { + readonly processRefusals?: readonly NativeProcessRefusal[]; +} { + const processRefusals = + value === undefined ? undefined : readNativeProcessRefusals(value); + return processRefusals ? { processRefusals } : {}; +} /** Only bounded native error codes leave this boundary, never stderr messages. */ async function readNativeFailure( @@ -722,6 +736,7 @@ export async function readNativeStartupFailure( readonly code: string; readonly causeCode?: string; readonly startupLogs?: readonly NativeStartupLogTail[]; + readonly processRefusals?: readonly NativeProcessRefusal[]; } | undefined > { @@ -736,6 +751,9 @@ export async function readNativeStartupFailure( code: failure.code, ...(failure.causeCode ? { causeCode: failure.causeCode } : {}), ...(failure.startupLogs ? { startupLogs: failure.startupLogs } : {}), + ...(failure.processRefusals + ? { processRefusals: failure.processRefusals } + : {}), }; } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 81f57aee6..6274d8a8f 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -129,6 +129,7 @@ import { confirmSafe, requireInteractive, } from "../lib/interactivity.ts"; +import { resolveInternalExtraHosts } from "../lib/internal-extra-hosts.ts"; import { findHackLocalCaCertPath } from "../lib/local-ca.ts"; import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts"; import { @@ -148,7 +149,10 @@ import { repairNativeCaTrust, } from "../lib/native-ca-doctor.ts"; import { selectLegacyComposeAdoptedRoot } from "../lib/native-compose-adoption-command.ts"; -import { readComposeBackendReading } from "../lib/native-compose-backend-features.ts"; +import { + readComposeBackendReading, + readNativeComposeDocument, +} from "../lib/native-compose-backend-features.ts"; import { NATIVE_DATA_CHECK, type NativeDataReport, @@ -166,6 +170,13 @@ import { reportHackLocalRoute, } from "../lib/native-hack-local-route.ts"; import { createNativeHackLocalRouteDependencies } from "../lib/native-hack-local-route-host.ts"; +import { + NATIVE_PROCESS_CHECK, + type NativeProcessReport, + nativeProcessCheckResult, + nativeWrappedServiceCandidates, + predictNativeProcessRefusals, +} from "../lib/native-process-wrappers.ts"; import { isMac } from "../lib/os.ts"; import { defaultProjectSlugFromPath, @@ -251,6 +262,8 @@ interface CheckResult { readonly composeBackend?: ComposeBackendReport; /** Closed native data advisory codes and service names only. */ readonly nativeData?: NativeDataReport; + /** Service names and closed process fields only. */ + readonly nativeProcess?: NativeProcessReport; } const HACK_LOCAL_ROUTE_CHECK = "native hack.local route"; @@ -1155,6 +1168,11 @@ async function handleNativeDoctor(opts: { checkNativeDataAdvisories({ startDir: opts.startDir }) ) ); + results.push( + await runCheck(progress, NATIVE_PROCESS_CHECK, () => + checkNativeProcessWrappers({ startDir: opts.startDir }) + ) + ); } progress.stop(`Native doctor checks complete (${results.length} checks)`); const hasError = results.some((result) => result.status === "error"); @@ -1344,6 +1362,8 @@ export type DoctorJsonCheck = { readonly composeBackend?: ComposeBackendReport; /** Present only on the native data-service check: closed codes and service names. */ readonly nativeData?: NativeDataReport; + /** Present only on the native process-wrapper check: service names and closed fields. */ + readonly nativeProcess?: NativeProcessReport; }; export type DoctorJsonData = { @@ -1383,6 +1403,7 @@ export function buildDoctorJsonData(opts: { ? { composeBackend: result.composeBackend } : {}), ...(result.nativeData ? { nativeData: result.nativeData } : {}), + ...(result.nativeProcess ? { nativeProcess: result.nativeProcess } : {}), })), summary: buildDoctorSummaryLines({ results: opts.results }), counts, @@ -3043,6 +3064,37 @@ async function checkNativeDataAdvisories(opts: { return nativeDataCheckResult(await readNativeDataAdvisories(ctx.composeFile)); } +/** + * Predicts the Compose-declared settings native up's process wrappers refuse + * (HACK-1939), for the services it may wrap: every service when project-wide + * host aliases exist, services with host-gateway extra hosts, and services + * with bare environment keys. Read-only and value-free; image-inherited + * settings are checked by native up against the pinned image. + */ +async function checkNativeProcessWrappers(opts: { + readonly startDir: string; +}): Promise { + const ctx = await findProjectContext(opts.startDir); + if (!ctx) { + return { + name: NATIVE_PROCESS_CHECK, + status: "warn", + message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`, + }; + } + const cfg = await readProjectConfig(ctx); + const aliases = await resolveInternalExtraHosts({ + projectRoot: ctx.projectRoot, + projectDir: ctx.projectDir, + staticHosts: cfg.internal?.extraHosts, + }).catch(() => ({ hosts: {} })); + const candidates = nativeWrappedServiceCandidates({ + compose: await readNativeComposeDocument(ctx.composeFile), + projectWideAliases: Object.keys(aliases.hosts).length > 0, + }); + return nativeProcessCheckResult(predictNativeProcessRefusals(candidates)); +} + /** * Read-only key-strength and exposure audit (HACK-1127). `--fix` never acts on * it; each finding suggests the exact command instead. It also catches a key diff --git a/src/lib/native-process-wrappers.ts b/src/lib/native-process-wrappers.ts new file mode 100644 index 000000000..d01758f6f --- /dev/null +++ b/src/lib/native-process-wrappers.ts @@ -0,0 +1,458 @@ +import { resolveNativeComposeText } from "../backends/native-compose-interpolation.ts"; +import { HackCliError } from "./cli-result.ts"; +import { isRecord } from "./guards.ts"; + +/** + * Native wraps some services' processes: private environment delivery runs + * the selected process through a launcher, and host-dependency services run it + * behind a release barrier. Both run exactly what Docker would (image or + * Compose `entrypoint` + `command`, bare executable names resolved through the + * container's PATH, shell-form health checks through `/bin/sh -c`), but a few + * settings still cannot be wrapped. Native up checks every wrapped service + * before any runtime state is reserved and names each refused service and + * field; this module predicts the Compose-declared cases before `up` starts + * anything, and parses and describes the runtime's named refusals. + * + * Settings inherited from an image (its ENTRYPOINT, CMD or USER) are not read + * here; native up checks those against the pinned image and reports every + * refused service at once. + */ +export const NATIVE_PROCESS_FIELDS = [ + "entrypoint", + "healthcheck", + "user", + "stop_signal", + "mounts", +] as const; + +export type NativeProcessField = (typeof NATIVE_PROCESS_FIELDS)[number]; + +/** One refused (service, field) pair; value-free. */ +export type NativeProcessRefusal = { + readonly service: string; + readonly field: NativeProcessField; +}; + +const SERVICE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const MAX_REFUSALS = 32; + +/** + * Accept only the runtime's versioned, bounded `process_refusals` contract; + * anything else is dropped whole. + */ +export function readNativeProcessRefusals( + value: unknown +): readonly NativeProcessRefusal[] | undefined { + if ( + !isRecord(value) || + Object.keys(value).length !== 2 || + value.version !== 1 || + !Array.isArray(value.refusals) || + value.refusals.length < 1 || + value.refusals.length > MAX_REFUSALS + ) { + return; + } + const result: NativeProcessRefusal[] = []; + for (const entry of value.refusals) { + if ( + !isRecord(entry) || + Object.keys(entry).length !== 2 || + typeof entry.service !== "string" || + !SERVICE.test(entry.service) || + !NATIVE_PROCESS_FIELDS.includes(entry.field as NativeProcessField) + ) { + return; + } + result.push({ + service: entry.service, + field: entry.field as NativeProcessField, + }); + } + return result; +} + +const FIELD_LABELS: Readonly> = { + entrypoint: "entrypoint or command", + healthcheck: "healthcheck test", + user: "user", + stop_signal: "stop_signal", + mounts: "volume target", +}; + +const FIELD_ADVICE: Readonly> = { + entrypoint: "the process must name an executable", + healthcheck: "use an exec-form CMD test that names an executable", + user: "use a numeric UID or UID:GID", + stop_signal: "use SIGTERM, SIGINT, SIGQUIT or SIGKILL", + mounts: "a volume target overlaps a path the wrapper reserves", +}; + +/** `service web (user: use a numeric UID or UID:GID); ...`, fields in a fixed order. */ +export function describeNativeProcessRefusals( + refusals: readonly NativeProcessRefusal[] +): string { + return refusals + .map( + (refusal) => + `service ${refusal.service} ${FIELD_LABELS[refusal.field]} (${FIELD_ADVICE[refusal.field]})` + ) + .join("; "); +} + +const SIGNALS = new Set(["SIGTERM", "SIGINT", "SIGQUIT", "SIGKILL"]); +const NUMERIC_USER = /^(\d{1,10})(?::(\d{1,10}))?$/; +const MAX_ID = 2_147_483_647; +const ENVIRONMENT_RESERVED = ["/run/hack-environment"]; +const DEPENDENCY_RESERVED = [ + "/run/hack-relay-guest", + "/run/hack-startup", + "/run/hack-dependencies", +]; + +/** A Compose string as native execution resolves it; null when not knowable. */ +function compiled(value: string): string | null { + return resolveNativeComposeText({ text: value }); +} + +function numericUser(value: unknown): boolean | undefined { + if (value === undefined || value === null) { + return true; + } + if (typeof value === "number") { + return Number.isInteger(value) && value >= 0 && value <= MAX_ID; + } + if (typeof value !== "string") { + return false; + } + const user = compiled(value); + if (user === null) { + return; + } + if (user === "") { + return true; + } + const match = NUMERIC_USER.exec(user); + return ( + match !== null && + Number(match[1]) <= MAX_ID && + (match[2] === undefined || Number(match[2]) <= MAX_ID) + ); +} + +function emptyArgv(value: unknown): boolean { + if (Array.isArray(value)) { + return value.length === 0; + } + return typeof value === "string" && value.trim() === ""; +} + +/** True when the declared entrypoint and command select no executable. */ +function emptyProcess(service: Readonly>): boolean { + const entrypoint = service.entrypoint; + if (Array.isArray(entrypoint) && entrypoint.length > 0) { + const first = entrypoint[0]; + return typeof first === "string" && (first === "" || first === "/"); + } + if (!emptyArgv(entrypoint)) { + return false; + } + // An explicit empty entrypoint suppresses the image's CMD as well. + const command = service.command; + if (Array.isArray(command) && command.length > 0) { + const first = command[0]; + return typeof first === "string" && (first === "" || first === "/"); + } + return command === undefined || command === null || emptyArgv(command); +} + +function unwrappableHealth(health: unknown): boolean { + // A native HTTP probe replaces the container health check entirely. + if ( + !isRecord(health) || + health.disable === true || + Object.hasOwn(health, "x-hack-http") + ) { + return false; + } + const test = health.test; + if (test === undefined || test === null) { + return false; + } + if (typeof test === "string") { + return test.trim() === ""; + } + if (!Array.isArray(test) || test.length === 0) { + return true; + } + const [kind, ...rest] = test; + if (kind === "NONE") { + return false; + } + if (kind === "CMD") { + return !( + typeof rest[0] === "string" && + rest[0] !== "" && + rest.every((part) => typeof part === "string") + ); + } + if (kind === "CMD-SHELL") { + return !( + rest.length === 1 && + typeof rest[0] === "string" && + rest[0] !== "" + ); + } + return true; +} + +function volumeTargets(volumes: unknown): string[] { + if (!Array.isArray(volumes)) { + return []; + } + const targets: string[] = []; + for (const volume of volumes) { + if (typeof volume === "string") { + const parts = volume.split(":"); + const target = parts.length >= 2 ? parts[1] : parts[0]; + if (target) { + targets.push(target); + } + } else if (isRecord(volume) && typeof volume.target === "string") { + targets.push(volume.target); + } + } + return targets; +} + +/** The same reserved targets the environment launcher and release barrier refuse. */ +function reservedTarget(target: string, dependency: boolean): boolean { + const normalized = + target.length > 1 && target.endsWith("/") ? target.slice(0, -1) : target; + return ( + normalized === "/" || + normalized === "/run" || + ENVIRONMENT_RESERVED.some((prefix) => normalized.startsWith(prefix)) || + (dependency && + DEPENDENCY_RESERVED.some( + (prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`) + )) + ); +} + +/** + * Compose-declared settings native cannot wrap, for every wrapped service: + * `wrapped` services have their process wrapped, `fileOwners` receive private + * files only (their user must be numeric). `dependencies` are the wrapped + * services that also run behind the host-dependency release barrier. + * Interpolated values are compiled like native execution; unknown ones are + * left to the runtime check. + */ +export function predictNativeProcessRefusals(opts: { + readonly services: Readonly< + Record>> + >; + readonly wrapped: ReadonlySet; + readonly dependencies?: ReadonlySet; + readonly fileOwners?: ReadonlySet; +}): NativeProcessRefusal[] { + const refusals: NativeProcessRefusal[] = []; + for (const name of Object.keys(opts.services).sort()) { + const service = opts.services[name]; + const wrapped = opts.wrapped.has(name); + if ( + !( + service && + SERVICE.test(name) && + (wrapped || opts.fileOwners?.has(name)) + ) + ) { + continue; + } + const add = (field: NativeProcessField) => { + if (refusals.length < MAX_REFUSALS) { + refusals.push({ service: name, field }); + } + }; + if (wrapped && emptyProcess(service)) { + add("entrypoint"); + } + if (wrapped && unwrappableHealth(service.healthcheck)) { + add("healthcheck"); + } + if (numericUser(service.user) === false) { + add("user"); + } + if ( + wrapped && + typeof service.stop_signal === "string" && + !SIGNALS.has(service.stop_signal) + ) { + add("stop_signal"); + } + const dependency = opts.dependencies?.has(name) === true; + if ( + wrapped && + volumeTargets(service.volumes).some((target) => + reservedTarget(target, dependency) + ) + ) { + add("mounts"); + } + } + return refusals; +} + +/** Leading text of the refusal for Compose settings no process wrapper can run. */ +export const NATIVE_PROCESS_WRAPPER_REFUSAL = + "Native up cannot wrap these services' processes; nothing was started."; + +/** + * Refuse, before the VM starts, the Compose-declared settings the runtime's + * process wrappers refuse. Services with private environment values or host + * dependency bindings have their process wrapped, and services with private + * files only need a numeric user. Settings inherited from images are checked + * by the runtime against the pinned image before any state is reserved. + */ +export function requireWrappableNativeProcesses(opts: { + readonly services: Readonly< + Record>> + >; + readonly managedEnvironment: Readonly< + Record>> + >; + readonly managedFiles?: Readonly>; + readonly dependencyServices: readonly string[]; +}): void { + const dependencies = new Set(opts.dependencyServices); + const wrapped = new Set(dependencies); + for (const [service, values] of Object.entries(opts.managedEnvironment)) { + if (Object.keys(values).length > 0) { + wrapped.add(service); + } + } + const fileOwners = new Set( + Object.entries(opts.managedFiles ?? {}) + .filter(([service, files]) => files.length > 0 && !wrapped.has(service)) + .map(([service]) => service) + ); + const refusals = predictNativeProcessRefusals({ + services: opts.services, + wrapped, + dependencies, + fileOwners, + }); + if (refusals.length > 0) { + throw new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: `${NATIVE_PROCESS_WRAPPER_REFUSAL} Refused: ${describeNativeProcessRefusals(refusals)}.`, + }); + } +} + +/** Doctor check id for the process-wrapper prediction. */ +export const NATIVE_PROCESS_CHECK = "native process wrappers"; + +/** Value-free JSON payload: service names and closed fields only. */ +export type NativeProcessReport = { + readonly refusals: readonly NativeProcessRefusal[]; +}; + +function declaresHostGateway(extraHosts: unknown): boolean { + if (isRecord(extraHosts)) { + return Object.values(extraHosts).some( + (target) => target === "host-gateway" + ); + } + return ( + Array.isArray(extraHosts) && + extraHosts.some( + (entry) => typeof entry === "string" && entry.endsWith("host-gateway") + ) + ); +} + +/** Bare or null environment keys are where Hack delivers managed values. */ +function declaresManagedKeys(environment: unknown): boolean { + if (isRecord(environment)) { + return Object.values(environment).some((value) => value === null); + } + return ( + Array.isArray(environment) && + environment.some( + (entry) => typeof entry === "string" && !entry.includes("=") + ) + ); +} + +/** + * The services native up may wrap, from the Compose file alone: every service + * when project-wide host aliases exist (each then needs a host dependency + * binding), services that declare a host-gateway extra host, and services + * that declare bare environment keys Hack can deliver privately. + */ +export function nativeWrappedServiceCandidates(opts: { + readonly compose: unknown; + readonly projectWideAliases: boolean; +}): { + readonly services: Record>>; + readonly wrapped: ReadonlySet; + readonly dependencies: ReadonlySet; +} { + const services: Record>> = {}; + const wrapped = new Set(); + const dependencies = new Set(); + if (!(isRecord(opts.compose) && isRecord(opts.compose.services))) { + return { services, wrapped, dependencies }; + } + for (const [name, service] of Object.entries(opts.compose.services)) { + if (!(isRecord(service) && SERVICE.test(name))) { + continue; + } + Object.defineProperty(services, name, { + value: service, + enumerable: true, + writable: true, + configurable: true, + }); + if (opts.projectWideAliases || declaresHostGateway(service.extra_hosts)) { + dependencies.add(name); + wrapped.add(name); + } + if (declaresManagedKeys(service.environment)) { + wrapped.add(name); + } + } + return { services, wrapped, dependencies }; +} + +/** + * Doctor result for the process-wrapper prediction. Warning when a + * Compose-declared setting would be refused, otherwise ok. Read-only and + * value-free apart from service names. + */ +export function nativeProcessCheckResult( + refusals: readonly NativeProcessRefusal[] +): { + readonly name: string; + readonly status: "ok" | "warn"; + readonly message: string; + readonly nativeProcess: NativeProcessReport; +} { + const nativeProcess = { refusals: refusals.map((entry) => ({ ...entry })) }; + if (refusals.length === 0) { + return { + name: NATIVE_PROCESS_CHECK, + status: "ok", + message: + "No Compose setting blocks native process wrapping; native up checks image entrypoints, commands and users before it reserves anything and names every refused service at once.", + nativeProcess, + }; + } + return { + name: NATIVE_PROCESS_CHECK, + status: "warn", + message: `Native up would refuse to wrap these services' processes: ${describeNativeProcessRefusals(refusals)}.`, + nativeProcess, + }; +} diff --git a/tests/native-process-wrappers.test.ts b/tests/native-process-wrappers.test.ts new file mode 100644 index 000000000..9ef82a3cd --- /dev/null +++ b/tests/native-process-wrappers.test.ts @@ -0,0 +1,210 @@ +/* biome-ignore-all lint/suspicious/noTemplateCurlyInString: literal Compose interpolation fixtures. */ +import { expect, test } from "bun:test"; +import { HackCliError } from "../src/lib/cli-result.ts"; +import { + describeNativeProcessRefusals, + NATIVE_PROCESS_CHECK, + NATIVE_PROCESS_WRAPPER_REFUSAL, + nativeProcessCheckResult, + nativeWrappedServiceCandidates, + predictNativeProcessRefusals, + readNativeProcessRefusals, + requireWrappableNativeProcesses, +} from "../src/lib/native-process-wrappers.ts"; + +function predict( + services: Record>, + dependencies: string[] = [] +) { + return predictNativeProcessRefusals({ + services, + wrapped: new Set(Object.keys(services)), + dependencies: new Set(dependencies), + }); +} + +test("stock images with relative entrypoints and health checks are wrappable", () => { + expect( + predict({ + db: { + image: "mysql:8.0", + healthcheck: { test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] }, + }, + shell: { + image: "postgres:16", + healthcheck: { test: "pg_isready -U postgres" }, + }, + shellform: { + image: "redis:7", + healthcheck: { test: ["CMD-SHELL", "redis-cli ping"] }, + }, + relative: { + image: "alpine:3.21", + entrypoint: ["docker-entrypoint.sh"], + command: ["serve"], + }, + commandOnly: { image: "python:3", entrypoint: [], command: ["python3"] }, + numeric: { image: "node:22", user: "1000:1000" }, + root: { image: "node:22", user: 0 }, + probe: { + image: "nginx", + healthcheck: { test: ["CMD"], "x-hack-http": { path: "/" } }, + }, + disabled: { image: "nginx", healthcheck: { disable: true, test: [] } }, + interpolated: { image: "nginx", user: "${APP_UID:-1000}" }, + }) + ).toEqual([]); +}); + +test("Compose settings no wrapper can run are named by service and field", () => { + expect( + predict( + { + empty: { image: "alpine", entrypoint: [] }, + blank: { image: "alpine", entrypoint: "", command: [] }, + slash: { image: "alpine", entrypoint: ["/"] }, + health: { image: "alpine", healthcheck: { test: ["CMD"] } }, + shell: { image: "alpine", healthcheck: { test: "" } }, + named: { image: "postgres", user: "postgres" }, + group: { image: "postgres", user: "999:postgres" }, + signal: { image: "nginx", stop_signal: "SIGUSR1" }, + run: { image: "nginx", volumes: ["data:/run"] }, + launcher: { + image: "nginx", + volumes: [{ target: "/run/hack-environment.json" }], + }, + relay: { image: "nginx", volumes: ["./x:/run/hack-startup/x:ro"] }, + }, + ["relay"] + ) + ).toEqual([ + { service: "blank", field: "entrypoint" }, + { service: "empty", field: "entrypoint" }, + { service: "group", field: "user" }, + { service: "health", field: "healthcheck" }, + { service: "launcher", field: "mounts" }, + { service: "named", field: "user" }, + { service: "relay", field: "mounts" }, + { service: "run", field: "mounts" }, + { service: "shell", field: "healthcheck" }, + { service: "signal", field: "stop_signal" }, + { service: "slash", field: "entrypoint" }, + ]); + // Release-barrier paths are reserved only for dependency services. + expect( + predict({ app: { image: "nginx", volumes: ["./x:/run/hack-startup/x"] } }) + ).toEqual([]); +}); + +test("only wrapped services and private-file owners are checked", () => { + const services = { + plain: { image: "postgres", user: "postgres", entrypoint: [] }, + files: { image: "postgres", user: "postgres", entrypoint: [] }, + }; + expect( + predictNativeProcessRefusals({ + services, + wrapped: new Set(), + fileOwners: new Set(["files"]), + }) + ).toEqual([{ service: "files", field: "user" }]); +}); + +test("the up preflight refuses before runtime work, naming every service and field", () => { + let thrown: unknown; + try { + requireWrappableNativeProcesses({ + services: { + db: { image: "postgres", user: "postgres" }, + web: { image: "nginx", healthcheck: { test: ["CMD"] } }, + idle: { image: "nginx", user: "nginx" }, + }, + managedEnvironment: { db: { PASSWORD: "synthetic" }, idle: {} }, + dependencyServices: ["web"], + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(HackCliError); + expect(thrown).toHaveProperty("code", "E_NATIVE_PROJECT_UNSUPPORTED"); + expect((thrown as Error).message).toBe( + `${NATIVE_PROCESS_WRAPPER_REFUSAL} Refused: service db user (use a numeric UID or UID:GID); service web healthcheck test (use an exec-form CMD test that names an executable).` + ); + expect((thrown as Error).message).not.toContain("synthetic"); + expect((thrown as Error).message).not.toContain("postgres"); +}); + +test("the runtime refusal list is accepted only in its closed, bounded form", () => { + expect( + readNativeProcessRefusals({ + version: 1, + refusals: [{ service: "db", field: "stop_signal" }], + }) + ).toEqual([{ service: "db", field: "stop_signal" }]); + for (const value of [ + null, + { version: 1 }, + { version: 1, refusals: [{ service: "db", field: "env" }] }, + { version: 1, refusals: [{ service: "", field: "user" }] }, + { + version: 1, + refusals: Array.from({ length: 33 }, (_, i) => ({ + service: `s${i}`, + field: "user", + })), + }, + ]) { + expect(readNativeProcessRefusals(value)).toBeUndefined(); + } + expect( + describeNativeProcessRefusals([{ service: "db", field: "mounts" }]) + ).toBe( + "service db volume target (a volume target overlaps a path the wrapper reserves)" + ); +}); + +test("doctor predicts for every service when project-wide aliases exist", () => { + const compose = { + services: { + api: { image: "node", user: "node" }, + gateway: { + image: "nginx", + user: "nginx", + extra_hosts: ["api.example.test:host-gateway"], + }, + secret: { + image: "postgres", + user: "postgres", + environment: ["PASSWORD"], + }, + literal: { + image: "postgres", + user: "postgres", + environment: { MODE: "dev" }, + }, + }, + }; + const local = nativeWrappedServiceCandidates({ + compose, + projectWideAliases: false, + }); + expect([...local.wrapped].sort()).toEqual(["gateway", "secret"]); + expect([...local.dependencies]).toEqual(["gateway"]); + const wide = nativeWrappedServiceCandidates({ + compose, + projectWideAliases: true, + }); + const result = nativeProcessCheckResult(predictNativeProcessRefusals(wide)); + expect(result.name).toBe(NATIVE_PROCESS_CHECK); + expect(result.status).toBe("warn"); + expect(result.nativeProcess.refusals.map((entry) => entry.service)).toEqual([ + "api", + "gateway", + "literal", + "secret", + ]); + expect(result.message).toContain( + "service api user (use a numeric UID or UID:GID)" + ); + expect(nativeProcessCheckResult([]).status).toBe("ok"); +}); diff --git a/tests/native-project-process.test.ts b/tests/native-project-process.test.ts index 7f6c030cc..1b837edaa 100644 --- a/tests/native-project-process.test.ts +++ b/tests/native-project-process.test.ts @@ -662,3 +662,70 @@ test("a second stop request during beforeStop forces the owner to stop", async ( ); expect(canceled).toBe(true); }, 15_000); + +test("HACK-1939: a startup failure reports every named process refusal to the exit diagnostic", async () => { + const reply = JSON.stringify({ + code: "environment_user", + message: "synthetic-private-detail", + process_refusals: { + version: 1, + refusals: [ + { service: "db", field: "user" }, + { service: "web", field: "healthcheck" }, + ], + }, + }); + const opts = await fixture( + `console.error(${JSON.stringify(reply)}); process.exit(2);` + ); + const diagnostics: unknown[] = []; + const failure = await serveNativeProjectGraph({ + ...opts, + onExitDiagnostic: (diagnostic) => { + diagnostics.push(diagnostic); + }, + onReady: async () => { + throw new Error("must not become ready"); + }, + }).catch((error: unknown) => String(error)); + expect(failure).toContain("environment_user"); + expect(failure).not.toContain("synthetic-private-detail"); + expect(diagnostics).toEqual([ + { + exitCode: 2, + nativeCode: "environment_user", + processRefusals: [ + { service: "db", field: "user" }, + { service: "web", field: "healthcheck" }, + ], + }, + ]); +}); + +test("a malformed process refusal list is dropped whole", async () => { + for (const process_refusals of [ + { version: 2, refusals: [{ service: "db", field: "user" }] }, + { version: 1, refusals: [{ service: "db", field: "argv" }] }, + { version: 1, refusals: [{ service: "../db", field: "user" }] }, + { version: 1, refusals: [] }, + { version: 1, refusals: [{ service: "db", field: "user", value: "x" }] }, + ]) { + const reply = JSON.stringify({ + code: "environment_user", + process_refusals, + }); + const opts = await fixture( + `console.error(${JSON.stringify(reply)}); process.exit(2);` + ); + const diagnostics: Record[] = []; + await serveNativeProjectGraph({ + ...opts, + onExitDiagnostic: (diagnostic) => { + diagnostics.push(diagnostic); + }, + onReady: async () => {}, + }).catch(() => undefined); + expect(diagnostics[0]?.processRefusals).toBeUndefined(); + expect(diagnostics[0]?.nativeCode).toBe("environment_user"); + } +}); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 7ddc106dc..061f3010e 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -4461,3 +4461,57 @@ test("the confirmed-stop line reflects whether data was retained", () => { nativeStoppedNotice({ receipt: { phase: "stopped-data-retained" } }) ).toContain("Named volumes and the stopped run mapping are kept"); }); + +test("HACK-1939: a wrapped service whose Compose user cannot be wrapped refuses before the VM starts", async () => { + const { opts, events } = await fixture(); + const prepare = opts.dependencies.prepare!; + opts.dependencies.prepare = async (request) => { + const input = await prepare(request); + const compose = JSON.parse(input.normalizedComposeJson); + compose.services.web.user = "postgres"; + return { ...input, normalizedComposeJson: JSON.stringify(compose) }; + }; + const error = await startNativeProject(opts).catch( + (caught: unknown) => caught + ); + expect(error).toBeInstanceOf(HackCliError); + expect(error).toHaveProperty("code", "E_NATIVE_PROJECT_UNSUPPORTED"); + expect((error as Error).message).toBe( + "Native up cannot wrap these services' processes; nothing was started. Refused: service web user (use a numeric UID or UID:GID)." + ); + expect(events).not.toContain("runtime up"); + expect(events.at(-1)).toBe("cleanup"); +}); + +test("HACK-1939: runtime process refusals are named in the nothing-reserved report", async () => { + const { opts } = await fixture(); + const invoke = opts.dependencies.invoke!; + opts.dependencies.serve = async (request) => { + request.onExitDiagnostic?.({ + exitCode: 2, + nativeCode: "environment_entrypoint", + processRefusals: [ + { service: "web", field: "entrypoint" }, + { service: "worker", field: "user" }, + ], + }); + throw new Error( + "Native graph startup was interrupted or failed (environment_entrypoint); inspect owned state before retrying." + ); + }; + opts.dependencies.invoke = async (request) => { + if (request.args[1] === "inspect") { + throw new NativeRuntimeRequestError({ + message: "Native graph run is not reserved.", + nativeCode: NATIVE_GRAPH_UNRESERVED, + }); + } + return await invoke(request); + }; + const error = await startNativeProject(opts).catch( + (caught: unknown) => caught + ); + expect((error as Error).message).toBe( + "Native graph startup failed (environment_entrypoint) before any runtime state was reserved; nothing was created and no cleanup is needed. Native cannot wrap these services' processes: service web entrypoint or command (the process must name an executable); service worker user (use a numeric UID or UID:GID)." + ); +}); From 08f2d8c3eb207a7393fe13326153cb7c85824b57 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy <344842+roodboi@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:39:36 -0500 Subject: [PATCH 2/2] fix(native): check process wrappers for profile-active services only The wrapper preflight checked every authored service, so a service behind an unselected profile with an unsupported wrapper setting blocked the whole up and restart even though it never runs. Apply Compose profile activation before the check in the start and restart preflights. Doctor reports profile-gated services as information only and never warns on them. Refs HACK-1939 Co-Authored-By: Claude Opus 5.5 --- docs/guides/native-candidate.md | 4 +- .../native-project-restart-preflight.ts | 1 + src/backends/native-project-start.ts | 1 + src/commands/doctor.ts | 7 +- src/lib/native-process-wrappers.ts | 87 +++++++++++++++++-- tests/native-process-wrappers.test.ts | 69 +++++++++++++++ tests/native-project-start.test.ts | 70 +++++++++++++++ 7 files changed, 227 insertions(+), 12 deletions(-) diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 03ba8d575..f4c88b67f 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -2555,7 +2555,9 @@ checks settings inherited from the pinned image before reserving anything. Both name every refused service and field at once (for example `service db user (use a numeric UID or UID:GID)`). `hack doctor` on the native backend reports the Compose-declared cases in its `native process wrappers` -check, for every service native may wrap. +check, for every service native may wrap. Only profile-active services are +checked by `up` and `restart`; a service behind an unselected profile never runs +and cannot block them, so `doctor` lists such services as information only. ### Approved-host DNS on pool restart diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index 9be223cd8..bf63a9c46 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -458,6 +458,7 @@ export async function preflightNativeRestart(opts: { // before the current graph is stopped. requireWrappableNativeProcesses({ services: specs, + profiles: selected.profiles, managedEnvironment: input.managedEnvironment, managedFiles: input.managedFiles, dependencyServices: dependencies.map((binding) => binding.service), diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 67bf37985..e7b73a3ca 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -1580,6 +1580,7 @@ export async function startNativeProject(opts: { // Refuse Compose settings no process wrapper can run before the VM starts. requireWrappableNativeProcesses({ services: specs, + profiles, managedEnvironment: input.managedEnvironment, managedFiles: input.managedFiles, dependencyServices: hostDependencies.map((binding) => binding.service), diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 6274d8a8f..14739e145 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -173,9 +173,7 @@ import { createNativeHackLocalRouteDependencies } from "../lib/native-hack-local import { NATIVE_PROCESS_CHECK, type NativeProcessReport, - nativeProcessCheckResult, - nativeWrappedServiceCandidates, - predictNativeProcessRefusals, + nativeProcessCheckFromCompose, } from "../lib/native-process-wrappers.ts"; import { isMac } from "../lib/os.ts"; import { @@ -3088,11 +3086,10 @@ async function checkNativeProcessWrappers(opts: { projectDir: ctx.projectDir, staticHosts: cfg.internal?.extraHosts, }).catch(() => ({ hosts: {} })); - const candidates = nativeWrappedServiceCandidates({ + return nativeProcessCheckFromCompose({ compose: await readNativeComposeDocument(ctx.composeFile), projectWideAliases: Object.keys(aliases.hosts).length > 0, }); - return nativeProcessCheckResult(predictNativeProcessRefusals(candidates)); } /** diff --git a/src/lib/native-process-wrappers.ts b/src/lib/native-process-wrappers.ts index d01758f6f..5d4c7087c 100644 --- a/src/lib/native-process-wrappers.ts +++ b/src/lib/native-process-wrappers.ts @@ -303,6 +303,46 @@ export function predictNativeProcessRefusals(opts: { return refusals; } +/** + * Compose profile activation: a service without `profiles` (or with an empty + * list) always runs; otherwise it runs only when one of its profiles is + * selected. Inactive services are never scheduled, so nothing wraps them. + */ +export function activeNativeService( + service: Readonly>, + profiles: readonly string[] +): boolean { + const declared = service.profiles; + if (declared === undefined || declared === null) { + return true; + } + return ( + Array.isArray(declared) && + (declared.length === 0 || + declared.some( + (profile) => typeof profile === "string" && profiles.includes(profile) + )) + ); +} + +function activeServices( + services: Readonly>>>, + profiles: readonly string[] +): Record>> { + const result: Record>> = {}; + for (const [name, service] of Object.entries(services)) { + if (activeNativeService(service, profiles)) { + Object.defineProperty(result, name, { + value: service, + enumerable: true, + writable: true, + configurable: true, + }); + } + } + return result; +} + /** Leading text of the refusal for Compose settings no process wrapper can run. */ export const NATIVE_PROCESS_WRAPPER_REFUSAL = "Native up cannot wrap these services' processes; nothing was started."; @@ -318,6 +358,8 @@ export function requireWrappableNativeProcesses(opts: { readonly services: Readonly< Record>> >; + /** Selected profiles: services outside them never run and are not checked. */ + readonly profiles: readonly string[]; readonly managedEnvironment: Readonly< Record>> >; @@ -337,7 +379,7 @@ export function requireWrappableNativeProcesses(opts: { .map(([service]) => service) ); const refusals = predictNativeProcessRefusals({ - services: opts.services, + services: activeServices(opts.services, opts.profiles), wrapped, dependencies, fileOwners, @@ -356,6 +398,8 @@ export const NATIVE_PROCESS_CHECK = "native process wrappers"; /** Value-free JSON payload: service names and closed fields only. */ export type NativeProcessReport = { readonly refusals: readonly NativeProcessRefusal[]; + /** Services behind a profile: refused only when that profile is selected. */ + readonly profiled?: readonly NativeProcessRefusal[]; }; function declaresHostGateway(extraHosts: unknown): boolean { @@ -432,27 +476,58 @@ export function nativeWrappedServiceCandidates(opts: { * value-free apart from service names. */ export function nativeProcessCheckResult( - refusals: readonly NativeProcessRefusal[] + refusals: readonly NativeProcessRefusal[], + /** Refusals of services that run only when one of their profiles is selected. */ + profiled: readonly NativeProcessRefusal[] = [] ): { readonly name: string; readonly status: "ok" | "warn"; readonly message: string; readonly nativeProcess: NativeProcessReport; } { - const nativeProcess = { refusals: refusals.map((entry) => ({ ...entry })) }; + const nativeProcess = { + refusals: refusals.map((entry) => ({ ...entry })), + ...(profiled.length > 0 + ? { profiled: profiled.map((entry) => ({ ...entry })) } + : {}), + }; + const note = + profiled.length > 0 + ? ` Only when their profile is selected: ${describeNativeProcessRefusals(profiled)}.` + : ""; if (refusals.length === 0) { return { name: NATIVE_PROCESS_CHECK, status: "ok", - message: - "No Compose setting blocks native process wrapping; native up checks image entrypoints, commands and users before it reserves anything and names every refused service at once.", + message: `No Compose setting blocks native process wrapping for the services that always run; native up checks image entrypoints, commands and users before it reserves anything and names every refused service at once.${note}`, nativeProcess, }; } return { name: NATIVE_PROCESS_CHECK, status: "warn", - message: `Native up would refuse to wrap these services' processes: ${describeNativeProcessRefusals(refusals)}.`, + message: `Native up would refuse to wrap these services' processes: ${describeNativeProcessRefusals(refusals)}.${note}`, nativeProcess, }; } + +/** + * Doctor's prediction, split by profile activation: services that always run + * can block `up` (warning); services behind a profile block it only when that + * profile is selected, so they are reported as information only. + */ +export function nativeProcessCheckFromCompose(opts: { + readonly compose: unknown; + readonly projectWideAliases: boolean; +}): ReturnType { + const candidates = nativeWrappedServiceCandidates(opts); + const refusals = predictNativeProcessRefusals(candidates); + const always = refusals.filter((refusal) => { + const service = candidates.services[refusal.service]; + return service !== undefined && activeNativeService(service, []); + }); + return nativeProcessCheckResult( + always, + refusals.filter((refusal) => !always.includes(refusal)) + ); +} diff --git a/tests/native-process-wrappers.test.ts b/tests/native-process-wrappers.test.ts index 9ef82a3cd..81c48a8ed 100644 --- a/tests/native-process-wrappers.test.ts +++ b/tests/native-process-wrappers.test.ts @@ -5,6 +5,7 @@ import { describeNativeProcessRefusals, NATIVE_PROCESS_CHECK, NATIVE_PROCESS_WRAPPER_REFUSAL, + nativeProcessCheckFromCompose, nativeProcessCheckResult, nativeWrappedServiceCandidates, predictNativeProcessRefusals, @@ -119,6 +120,7 @@ test("the up preflight refuses before runtime work, naming every service and fie web: { image: "nginx", healthcheck: { test: ["CMD"] } }, idle: { image: "nginx", user: "nginx" }, }, + profiles: [], managedEnvironment: { db: { PASSWORD: "synthetic" }, idle: {} }, dependencyServices: ["web"], }); @@ -208,3 +210,70 @@ test("doctor predicts for every service when project-wide aliases exist", () => ); expect(nativeProcessCheckResult([]).status).toBe("ok"); }); + +test("only profile-active services can block the up preflight", () => { + const services = { + web: { image: "nginx" }, + optional: { image: "postgres", user: "postgres", profiles: ["extra"] }, + }; + const opts = { + services, + managedEnvironment: { optional: { PASSWORD: "synthetic" } }, + dependencyServices: [], + }; + requireWrappableNativeProcesses({ ...opts, profiles: [] }); + requireWrappableNativeProcesses({ ...opts, profiles: ["other"] }); + expect(() => + requireWrappableNativeProcesses({ ...opts, profiles: ["extra"] }) + ).toThrow("Refused: service optional user (use a numeric UID or UID:GID)."); + // An empty profiles list always runs, like Compose. + expect(() => + requireWrappableNativeProcesses({ + ...opts, + services: { optional: { ...services.optional, profiles: [] } }, + profiles: [], + }) + ).toThrow("service optional user"); +}); + +test("doctor reports profile-gated services as information, never as a warning", () => { + const compose = { + services: { + web: { image: "nginx", environment: ["TOKEN"] }, + optional: { + image: "postgres", + user: "postgres", + environment: ["PASSWORD"], + profiles: ["extra"], + }, + }, + }; + const gated = nativeProcessCheckFromCompose({ + compose, + projectWideAliases: false, + }); + expect(gated.status).toBe("ok"); + expect(gated.nativeProcess).toEqual({ + refusals: [], + profiled: [{ service: "optional", field: "user" }], + }); + expect(gated.message).toContain( + "Only when their profile is selected: service optional user" + ); + const always = nativeProcessCheckFromCompose({ + compose: { + services: { + ...compose.services, + web: { ...compose.services.web, user: "nginx" }, + }, + }, + projectWideAliases: false, + }); + expect(always.status).toBe("warn"); + expect(always.nativeProcess.refusals).toEqual([ + { service: "web", field: "user" }, + ]); + expect(always.nativeProcess.profiled).toEqual([ + { service: "optional", field: "user" }, + ]); +}); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 061f3010e..43d64f11a 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -4515,3 +4515,73 @@ test("HACK-1939: runtime process refusals are named in the nothing-reserved repo "Native graph startup failed (environment_entrypoint) before any runtime state was reserved; nothing was created and no cleanup is needed. Native cannot wrap these services' processes: service web entrypoint or command (the process must name an executable); service worker user (use a numeric UID or UID:GID)." ); }); + +/** A fixture whose inactive-by-default `optional` service cannot be wrapped. */ +async function optionalProfileFixture() { + const fixtureResult = await fixture(); + const prepare = fixtureResult.opts.dependencies.prepare!; + fixtureResult.opts.dependencies.prepare = async (request) => { + const input = await prepare(request); + const compose = JSON.parse(input.normalizedComposeJson); + compose.services.optional = { + image: `sha256:${"e".repeat(64)}`, + user: "postgres", + profiles: ["extra"], + }; + return { + ...input, + normalizedComposeJson: JSON.stringify(compose), + managedEnvironment: { + ...input.managedEnvironment, + optional: { PASSWORD: "synthetic-secret" }, + }, + serviceNames: [...input.serviceNames, "optional"], + }; + }; + return fixtureResult; +} + +test("an inactive-profile service a wrapper cannot run does not block up; an active one does", async () => { + const inactive = await optionalProfileFixture(); + expect(await startNativeProject(inactive.opts)).toBe(0); + const active = await optionalProfileFixture(); + const error = await startNativeProject({ + ...active.opts, + profiles: ["extra"], + }).catch((caught: unknown) => caught); + expect((error as Error).message).toBe( + "Native up cannot wrap these services' processes; nothing was started. Refused: service optional user (use a numeric UID or UID:GID)." + ); + expect(active.events).not.toContain("runtime up"); +}); + +test("restart preflight ignores inactive-profile services a wrapper cannot run", async () => { + const { opts } = await optionalProfileFixture(); + const preflight = (profiles: string[]) => + preflightNativeRestart({ + runtime: opts.runtime, + scope: opts.scope, + composeFile: opts.composeFile, + run: { + run: "1".repeat(32), + owner: "c".repeat(32), + namespace: "b".repeat(64), + planId: "a".repeat(64), + effectiveEnvName: null, + profiles, + aws: null, + }, + dependencies: { + prepare: opts.dependencies.prepare, + review: opts.dependencies.review, + dependencies: async () => [], + invoke: restartPreflightInvoke, + }, + }); + const refusal = await preflight(["extra"]).catch((caught: unknown) => caught); + expect((refusal as Error).message).toContain( + "Refused: service optional user (use a numeric UID or UID:GID)." + ); + // The same preflight without the profile completes. + await preflight([]); +});