diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f0a870..2551bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ relevant schema version is advanced. - Read-only Steam library-folder and app-manifest discovery with stable App ID identity, normalized records, provider-scoped CLI list/search/info/scan commands, hostile fixtures, and local installation acceptance evidence. +- Typed native and Flatpak Steam launch plans, shell-free client submission, private logs, + structured missing-client failures, and real native/Proton launch acceptance evidence. ### Corrected diff --git a/README.md b/README.md index 68154b2..9fbc5c1 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,14 @@ promises. ## Status -Phases 0–3 are complete: repository gates, shared contracts, native discovery/execution, -and read-only Steam discovery. Phase 4 adds Steam launch planning and representative -native/Proton execution. The Steam commands below currently read metadata only; they do -not start Steam or launch games. +Phases 0–4 are complete: repository gates, shared contracts, native discovery/execution, +Steam discovery, typed Steam launch planning, and representative native/Proton execution. +Phase 5 proves the minimal graphical session and recovery path. ## Current proof -The workspace contains the shared Rust core, native and read-only Steam adapters, and -CLI. Local development, verification, and the minimum supported compiler are pinned to +The workspace contains the shared Rust core, native and Steam adapters, and CLI. Local +development, verification, and the minimum supported compiler are pinned to Rust 1.97.1: ```text @@ -28,6 +27,8 @@ kiln list --provider steam kiln search --provider steam kiln scan --provider steam kiln info steam: +kiln launch steam: --dry-run +kiln launch steam: kiln config defaults kiln config validate kiln config explain --json @@ -47,4 +48,4 @@ The active specification is under `docs/spec/`. Start with Local Codex CLI work starts with `AGENTS.md`. Native manifest setup is documented in `docs/phase-2/native-manifests.md`; completed Steam discovery evidence is under -`docs/phase-3/`. +`docs/phase-3/`; completed Steam execution evidence is under `docs/phase-4/`. diff --git a/crates/kiln-adapter-native/src/lib.rs b/crates/kiln-adapter-native/src/lib.rs index 6ad22ae..4a2f4e5 100644 --- a/crates/kiln-adapter-native/src/lib.rs +++ b/crates/kiln-adapter-native/src/lib.rs @@ -347,27 +347,91 @@ pub struct LaunchOutcome { pub success: bool, } -/// Executes a validated native launch plan and captures stdout/stderr in a private log. +/// Result of submitting work to a provider-owned client. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SubmissionOutcome { + /// Process ID of the submitted client command. + pub process_id: u32, +} + +/// Submits a validated launch plan without equating the client process with the game. +/// +/// # Errors +/// +/// Returns a structured error when the log cannot be secured or the command cannot start. +pub fn submit(plan: &LaunchPlanV1, log_path: &Path) -> Result { + if !plan.wrappers().is_empty() { + return Err(ContractErrorV1::unsupported_capability( + text("wrapper execution is not implemented"), + name("wrappers"), + )); + } + let mut log = open_log(plan, log_path)?; + let executable = executable_path(&plan.command().executable); + let child = Command::new(executable) + .args(plan.command().arguments.iter().map(CommandArgument::as_str)) + .env_clear() + .envs( + plan.environment() + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())), + ) + .stdin(Stdio::null()) + .current_dir(plan.working_directory().as_path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| launch_failed(format!("submit process: {error}")))?; + let outcome = SubmissionOutcome { + process_id: child.id(), + }; + writeln!(log, "submitted {}", outcome.process_id) + .map_err(|error| unavailable(format!("write launch log: {error}")))?; + Ok(outcome) +} + +/// Executes a validated launch plan and captures stdout/stderr in a private log. /// /// # Errors /// /// Returns a structured error when the log cannot be secured, the command cannot start, /// or the child exits unsuccessfully. pub fn execute(plan: &LaunchPlanV1, log_path: &Path) -> Result { + let mut log = open_log(plan, log_path)?; + let (status, stdout, stderr) = spawn(plan)?; + write_redacted_output(&mut log, plan, "stdout", &stdout)?; + write_redacted_output(&mut log, plan, "stderr", &stderr)?; + writeln!(log, "exit {:?}", status.code()) + .map_err(|error| unavailable(format!("write native launch log: {error}")))?; + let outcome = LaunchOutcome { + exit_code: status.code(), + success: status.success(), + }; + if outcome.success { + Ok(outcome) + } else { + Err(ContractErrorV1::new( + ContractErrorCode::LaunchFailed, + text("process exited unsuccessfully; see launch log"), + )) + } +} + +fn open_log(plan: &LaunchPlanV1, log_path: &Path) -> Result { let parent = log_path .parent() - .ok_or_else(|| invalid("native log path has no parent"))?; + .ok_or_else(|| invalid("launch log path has no parent"))?; if !log_path.is_absolute() || fs::symlink_metadata(log_path).is_ok_and(|m| m.file_type().is_symlink()) { - return Err(invalid("native log path is unsafe")); + return Err(invalid("launch log path is unsafe")); } reject_symlink_components(parent)?; fs::create_dir_all(parent) - .map_err(|error| unavailable(format!("create native log directory: {error}")))?; + .map_err(|error| unavailable(format!("create launch log directory: {error}")))?; reject_symlink_components(parent)?; fs::set_permissions(parent, fs::Permissions::from_mode(DIRECTORY_MODE)) - .map_err(|error| unavailable(format!("secure native log directory: {error}")))?; + .map_err(|error| unavailable(format!("secure launch log directory: {error}")))?; let mut log = OpenOptions::new() .create(true) .append(true) @@ -388,23 +452,7 @@ pub fn execute(plan: &LaunchPlanV1, log_path: &Path) -> Result Result<(ExitStatus, CapturedOutput, CapturedOutput), ContractErrorV1> { if !plan.wrappers().is_empty() { return Err(ContractErrorV1::unsupported_capability( - text("native wrapper execution is not implemented"), + text("wrapper execution is not implemented"), name("wrappers"), )); } @@ -432,28 +480,28 @@ fn spawn( .stderr(Stdio::piped()); let mut child = command .spawn() - .map_err(|error| launch_failed(format!("start native process: {error}")))?; + .map_err(|error| launch_failed(format!("start process: {error}")))?; let stdout = child .stdout .take() - .ok_or_else(|| launch_failed("capture native stdout"))?; + .ok_or_else(|| launch_failed("capture process stdout"))?; let stderr = child .stderr .take() - .ok_or_else(|| launch_failed("capture native stderr"))?; + .ok_or_else(|| launch_failed("capture process stderr"))?; let stdout_reader = thread::spawn(move || capture_bounded(stdout)); let stderr_reader = thread::spawn(move || capture_bounded(stderr)); let status = child .wait() - .map_err(|error| launch_failed(format!("wait for native process: {error}")))?; + .map_err(|error| launch_failed(format!("wait for process: {error}")))?; let stdout = stdout_reader .join() - .map_err(|_| launch_failed("native stdout capture failed"))? - .map_err(|error| launch_failed(format!("read native stdout: {error}")))?; + .map_err(|_| launch_failed("stdout capture failed"))? + .map_err(|error| launch_failed(format!("read stdout: {error}")))?; let stderr = stderr_reader .join() - .map_err(|_| launch_failed("native stderr capture failed"))? - .map_err(|error| launch_failed(format!("read native stderr: {error}")))?; + .map_err(|_| launch_failed("stderr capture failed"))? + .map_err(|error| launch_failed(format!("read stderr: {error}")))?; Ok((status, stdout, stderr)) } @@ -527,11 +575,11 @@ fn reject_symlink_components(path: &Path) -> Result<(), ContractErrorV1> { current.push(component); match fs::symlink_metadata(¤t) { Ok(metadata) if metadata.file_type().is_symlink() => { - return Err(invalid("native log path contains a symlink")); + return Err(invalid("launch log path contains a symlink")); } Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(unavailable(format!("inspect native log path: {error}"))), + Err(error) => return Err(unavailable(format!("inspect launch log path: {error}"))), } } Ok(()) @@ -815,6 +863,15 @@ fn config_home(xdg: Option<&OsStr>, home: Option<&OsStr>) -> Result Result { + current_log_path_for(&name("native")) +} + +/// Resolves a provider-named private per-user launch log. +/// +/// # Errors +/// +/// Returns a structured error when no absolute XDG state or home path is available. +pub fn current_log_path_for(provider: &ContractName) -> Result { let xdg = env::var_os("XDG_STATE_HOME"); let home = env::var_os("HOME"); xdg.as_deref() @@ -827,7 +884,7 @@ pub fn current_log_path() -> Result { .filter(|path| path.is_absolute()) .map(|path| path.join(".local/state")) }) - .map(|path| path.join("kiln/logs/native.log")) + .map(|path| path.join(format!("kiln/logs/{}.log", provider.as_str()))) .ok_or_else(|| unavailable("no absolute XDG state or home directory is available")) } diff --git a/crates/kiln-adapter-steam/src/lib.rs b/crates/kiln-adapter-steam/src/lib.rs index a611262..2843184 100644 --- a/crates/kiln-adapter-steam/src/lib.rs +++ b/crates/kiln-adapter-steam/src/lib.rs @@ -8,8 +8,15 @@ use std::io::Read; use std::path::{Path, PathBuf}; use kiln_core::adapter::ProviderAdapter; +use kiln_core::config::{ + ConfigurationLayerV1, ConfigurationSource, ConfigurationTier, built_in_configuration, + configuration_path_for_current_user, load_configuration, resolve_layers, +}; use kiln_core::contract_error::{AmbiguousGameCandidate, ContractErrorCode, ContractErrorV1}; -use kiln_core::launch::LaunchPlanV1; +use kiln_core::launch::{ + CommandArgument, CommandSpec, ControllerState, EnvironmentName, EnvironmentValue, Executable, + LaunchPlanInput, LaunchPlanV1, LifecycleConfidence, +}; use kiln_core::{ Capability, ContractName, ContractText, GameId, GameRecordInput, GameRecordV1, InstallPath, InstallationId, @@ -19,10 +26,11 @@ use uuid::Uuid; const MAX_VDF_BYTES: u64 = 1024 * 1024; const MAX_LIBRARIES: usize = 128; const MAX_MANIFESTS: usize = 8192; -const CAPABILITIES: &[Capability] = &[Capability::Discover, Capability::Info]; +const CAPABILITIES: &[Capability] = &[Capability::Discover, Capability::Info, Capability::Launch]; const RECORD_CAPABILITIES: &[Capability] = &[ Capability::Discover, Capability::Info, + Capability::Launch, Capability::ClientRequired, ]; const INSTALLATION_NAMESPACE: u128 = 0x72d8_017f_93a9_47b7_0000_0000_0000_0000; @@ -32,6 +40,13 @@ const INSTALLATION_NAMESPACE: u128 = 0x72d8_017f_93a9_47b7_0000_0000_0000_0000; pub struct SteamAdapter { name: ContractName, root: PathBuf, + client: SteamClient, +} + +#[derive(Clone, Copy, Debug)] +enum SteamClient { + Native, + Flatpak, } impl SteamAdapter { @@ -41,6 +56,7 @@ impl SteamAdapter { Self { name: name("steam"), root: root.into(), + client: SteamClient::Native, } } @@ -56,8 +72,15 @@ impl SteamAdapter { .ok_or_else(|| unavailable("no absolute home directory is available"))?; let native = home.join(".local/share/Steam"); let flatpak = home.join(".var/app/com.valvesoftware.Steam/data/Steam"); - let root = if native.is_dir() { native } else { flatpak }; - Ok(Self::at(root)) + if native.is_dir() { + Ok(Self::at(native)) + } else { + Ok(Self { + name: name("steam"), + root: flatpak, + client: SteamClient::Flatpak, + }) + } } /// Returns matching records in deterministic App ID order. @@ -223,14 +246,110 @@ impl ProviderAdapter for SteamAdapter { } } - fn plan_launch(&self, _game_id: &GameId) -> Result { - Err(ContractErrorV1::unsupported_capability( - text("Steam launch planning begins in Phase 4"), - name("launch"), - )) + fn plan_launch(&self, game_id: &GameId) -> Result { + let record = self + .records()? + .into_iter() + .find(|record| record.id() == game_id) + .ok_or_else(|| not_found("Steam game was not found"))?; + let configuration_path = configuration_path_for_current_user() + .map_err(|error| invalid(format!("resolve configuration path: {error}")))?; + let mut layers = vec![built_in_configuration()]; + if let Some(user) = load_configuration( + &configuration_path, + ConfigurationSource { + tier: ConfigurationTier::GlobalUser, + label: text(&configuration_path.to_string_lossy()), + }, + ) + .map_err(|error| invalid(format!("invalid user configuration: {error}")))? + { + layers.push(user); + } + layers.push( + ConfigurationLayerV1::parse_toml( + ConfigurationSource { + tier: ConfigurationTier::Provider, + label: text("Steam-managed runtime"), + }, + "schema_version = 1\n[launch]\nruntime = \"steam-managed\"\n", + ) + .expect("static Steam configuration"), + ); + let resolved = resolve_layers(&layers) + .map_err(|error| invalid(format!("resolve Steam configuration: {error}")))?; + let mut arguments = match self.client { + SteamClient::Native => Vec::new(), + SteamClient::Flatpak => vec![argument("run"), argument("com.valvesoftware.Steam")], + }; + arguments.extend([ + argument("-silent"), + argument("-applaunch"), + argument(game_id.value()), + ]); + let executable = match self.client { + SteamClient::Native => "steam", + SteamClient::Flatpak => "flatpak", + }; + let mut environment = minimal_environment(); + environment.extend( + resolved + .environment + .iter() + .map(|(key, value)| (key.clone(), value.value.clone())), + ); + LaunchPlanV1::new(LaunchPlanInput { + game_id: record.id().clone(), + installation_id: record.installation_id(), + store: name("steam"), + adapter: name("steam"), + configuration_owner: name("steam"), + runtime: resolved.runtime.value, + session_backend: resolved.session_backend.value, + display_mode: resolved.display_mode.map(|mode| mode.value), + controller_state: ControllerState::Unknown, + working_directory: record.install_path().clone(), + environment, + wrappers: resolved + .wrappers + .into_iter() + .map(|wrapper| wrapper.value) + .collect(), + provenance: resolved.provenance, + lifecycle_confidence: LifecycleConfidence::Unknown, + command: CommandSpec { + executable: Executable::Program(name(executable)), + arguments, + }, + }) + .map_err(|_| invalid("Steam launch plan violated its contract")) } } +fn argument(raw: &str) -> CommandArgument { + raw.to_owned().try_into().expect("validated Steam argument") +} + +fn minimal_environment() -> BTreeMap { + [ + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", + "HOME", + "LANG", + "LC_ALL", + "PATH", + "WAYLAND_DISPLAY", + "XAUTHORITY", + "XDG_RUNTIME_DIR", + ] + .into_iter() + .filter_map(|key| { + let value = env::var(key).ok()?; + Some((key.parse().ok()?, value.try_into().ok()?)) + }) + .collect() +} + fn load_manifest(path: &Path, steamapps: &Path) -> Result, ContractErrorV1> { let input = read_text(path, "app manifest")?; let document = parse_vdf(&input)?; @@ -518,9 +637,10 @@ mod tests { use kiln_core::Capability; use kiln_core::adapter::ProviderAdapter; use kiln_core::contract_error::ContractErrorCode; + use kiln_core::launch::{CommandArgument, Executable}; use uuid::Uuid; - use super::SteamAdapter; + use super::{SteamAdapter, SteamClient}; struct TestDirectory(PathBuf); @@ -597,14 +717,50 @@ mod tests { [ Capability::Discover, Capability::Info, + Capability::Launch, Capability::ClientRequired ] ); assert_eq!(adapter.search("portal").unwrap().len(), 1); assert_eq!(adapter.resolve("Portal 2").unwrap().id(), record.id()); + let plan = adapter.plan_launch(record.id()).unwrap(); assert_eq!( - adapter.plan_launch(record.id()).unwrap_err().code(), - ContractErrorCode::UnsupportedCapability + plan.lifecycle_confidence(), + kiln_core::launch::LifecycleConfidence::Unknown + ); + assert!(matches!( + &plan.command().executable, + Executable::Program(program) if program.as_str() == "steam" + )); + assert_eq!( + plan.command() + .arguments + .iter() + .map(CommandArgument::as_str) + .collect::>(), + ["-silent", "-applaunch", "620"] + ); + + let mut flatpak = adapter.clone(); + flatpak.client = SteamClient::Flatpak; + let plan = flatpak.plan_launch(record.id()).unwrap(); + assert!(matches!( + &plan.command().executable, + Executable::Program(program) if program.as_str() == "flatpak" + )); + assert_eq!( + plan.command() + .arguments + .iter() + .map(CommandArgument::as_str) + .collect::>(), + [ + "run", + "com.valvesoftware.Steam", + "-silent", + "-applaunch", + "620" + ] ); } diff --git a/crates/kiln-cli/src/main.rs b/crates/kiln-cli/src/main.rs index d9559c3..0930bdc 100644 --- a/crates/kiln-cli/src/main.rs +++ b/crates/kiln-cli/src/main.rs @@ -3,7 +3,7 @@ use std::env; use std::process::ExitCode; -use kiln_adapter_native::{NativeAdapter, current_log_path, execute}; +use kiln_adapter_native::{NativeAdapter, current_log_path_for, execute, submit}; use kiln_adapter_steam::SteamAdapter; use kiln_core::adapter::ProviderAdapter; use kiln_core::config::{ @@ -27,7 +27,7 @@ Usage: kiln search [--provider native|steam] [--json] kiln info [--provider native|steam] [--json] kiln scan [--provider native|steam] [--no-cache] [--json] - kiln launch [--dry-run] [--json] + kiln launch [--provider native|steam] [--dry-run] [--json] kiln config defaults kiln config validate [--json] kiln config explain [--json] @@ -332,30 +332,66 @@ fn scan_steam(json_output: bool) -> ExitCode { fn launch(mut arguments: Vec, json_output: bool) -> ExitCode { let dry_run = take_flag(&mut arguments, "--dry-run"); let _ = take_flag(&mut arguments, "--no-cache"); - let [command, selector] = arguments.as_slice() else { - return invalid_arguments(json_output); + let (selector, provider) = match arguments.as_slice() { + [command, selector] if command == "launch" => (selector, None), + [command, selector, flag, provider] if command == "launch" && flag == "--provider" => { + (selector, Some(provider.as_str())) + } + _ => return invalid_arguments(json_output), }; - if command != "launch" { + let steam = provider == Some("steam") || provider.is_none() && selector.starts_with("steam:"); + if provider.is_some_and(|value| !matches!(value, "native" | "steam")) + || provider == Some("native") && selector.starts_with("steam:") + { return invalid_arguments(json_output); } - with_adapter(json_output, |adapter| { - let record = adapter.resolve(selector)?; - let plan = adapter.plan_launch(record.id())?; - if dry_run { - if json_output { - println!( - "{}", - serde_json::to_string(&plan).expect("launch plan serializes") - ); - } else { - println!( - "{}", - serde_json::to_string_pretty(&plan).expect("launch plan serializes") - ); - } - return Ok(()); + if steam { + with_steam(json_output, |adapter| { + launch_with(adapter, selector, "steam", dry_run, json_output) + }) + } else { + with_adapter(json_output, |adapter| { + launch_with(adapter, selector, "native", dry_run, json_output) + }) + } +} + +fn launch_with( + adapter: &impl ProviderAdapter, + selector: &str, + provider: &str, + dry_run: bool, + json_output: bool, +) -> Result<(), ContractErrorV1> { + let record = adapter.resolve(selector)?; + let plan = adapter.plan_launch(record.id())?; + if dry_run { + let output = if json_output { + serde_json::to_string(&plan) + } else { + serde_json::to_string_pretty(&plan) } - let log = current_log_path()?; + .expect("launch plan serializes"); + println!("{output}"); + return Ok(()); + } + let provider_name = name(provider); + let log = current_log_path_for(&provider_name)?; + if provider == "steam" { + let outcome = submit(&plan, &log)?; + if json_output { + println!( + "{}", + json!({"game_id": record.id(), "status": "submitted", "process_id": outcome.process_id, "log": log}) + ); + } else { + println!( + "{} submitted successfully; log: {}", + record.id(), + log.display() + ); + } + } else { let outcome = execute(&plan, &log)?; if json_output { println!( @@ -369,8 +405,8 @@ fn launch(mut arguments: Vec, json_output: bool) -> ExitCode { log.display() ); } - Ok(()) - }) + } + Ok(()) } fn with_adapter( diff --git a/crates/kiln-cli/tests/cli.rs b/crates/kiln-cli/tests/cli.rs index f03c4c5..969ece0 100644 --- a/crates/kiln-cli/tests/cli.rs +++ b/crates/kiln-cli/tests/cli.rs @@ -1,6 +1,7 @@ //! Isolated CLI contract tests for Phase 1 machine output. use std::fs; +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; @@ -28,11 +29,23 @@ impl TestDirectory { } fn run(&self, arguments: &[&str]) -> std::process::Output { + self.run_with_path( + arguments, + &format!( + "{}:{}", + self.0.join("bin").display(), + std::env::var("PATH").unwrap_or_default() + ), + ) + } + + fn run_with_path(&self, arguments: &[&str], path: &str) -> std::process::Output { Command::new(env!("CARGO_BIN_EXE_kiln")) .args(arguments) .env("XDG_CONFIG_HOME", self.0.join("config")) .env("XDG_STATE_HOME", self.0.join("state")) .env("HOME", self.0.join("home")) + .env("PATH", path) .output() .expect("kiln runs") } @@ -66,6 +79,11 @@ impl TestDirectory { ), ) .unwrap(); + let bin = self.0.join("bin"); + fs::create_dir_all(&bin).unwrap(); + let steam = bin.join("steam"); + fs::write(&steam, "#!/bin/sh\nprintf 'steam %s\\n' \"$*\"\n").unwrap(); + fs::set_permissions(steam, fs::Permissions::from_mode(0o755)).unwrap(); fs::write( steamapps.join(format!("appmanifest_{app_id}.acf")), format!( @@ -230,7 +248,7 @@ fn invalid_configuration_and_failed_launch_are_structured() { } #[test] -fn steam_provider_cli_discovers_searches_and_resolves_without_launching() { +fn steam_provider_cli_discovers_plans_and_submits_without_a_shell() { let directory = TestDirectory::new(); directory.add_steam_game("620", "Portal 2"); @@ -240,6 +258,12 @@ fn steam_provider_cli_discovers_searches_and_resolves_without_launching() { assert_eq!(records[0]["id"], "steam:620"); assert_eq!(records[0]["runtime"], "steam-managed"); assert_eq!(records[0]["client_required"], true); + assert!( + records[0]["capabilities"] + .as_array() + .unwrap() + .contains(&Value::String("launch".into())) + ); let search = directory.run(&["search", "portal", "--provider", "steam", "--json"]); assert!(search.status.success()); @@ -260,4 +284,38 @@ fn steam_provider_cli_discovers_searches_and_resolves_without_launching() { let result: Value = serde_json::from_slice(&scan.stdout).unwrap(); assert_eq!(result["provider"], "steam"); assert_eq!(result["discovered"], 1); + + let dry_run = directory.run(&[ + "launch", + "Portal 2", + "--provider", + "steam", + "--dry-run", + "--json", + ]); + assert!(dry_run.status.success()); + let plan: Value = serde_json::from_slice(&dry_run.stdout).unwrap(); + assert_eq!(plan["runtime"], "steam-managed"); + assert_eq!(plan["lifecycle_confidence"], "unknown"); + assert_eq!(plan["command"]["arguments"][2], "620"); + + let launch = directory.run(&["launch", "steam:620", "--json"]); + assert!(launch.status.success()); + let outcome: Value = serde_json::from_slice(&launch.stdout).unwrap(); + assert_eq!(outcome["status"], "submitted"); + assert!(outcome["process_id"].as_u64().unwrap() > 0); + assert!( + fs::read_to_string(directory.0.join("state/kiln/logs/steam.log")) + .unwrap() + .contains("submitted") + ); + + fs::remove_file(directory.0.join("bin/steam")).unwrap(); + let failed = directory.run_with_path( + &["launch", "steam:620", "--json"], + directory.0.join("bin").to_str().unwrap(), + ); + assert_eq!(failed.status.code(), Some(2)); + let error: Value = serde_json::from_slice(&failed.stderr).unwrap(); + assert_eq!(error["code"], "launch-failed"); } diff --git a/docs/phase-0/traceability.csv b/docs/phase-0/traceability.csv index a223ff3..5d17557 100644 --- a/docs/phase-0/traceability.csv +++ b/docs/phase-0/traceability.csv @@ -16,7 +16,7 @@ R-014,12,Pegasus adapter tests,post-v1 R-015,8,Lean and Ready policy tests,planned R-016,7,Phase 1 contracts Phase 2 native and Phase 3 Steam capability tests,in-progress R-017,8,window-switching acceptance tests,planned -R-018,4,Phase 1 policy and Phase 2 owned native-process lifecycle tests,in-progress +R-018,4,Phase 1 policy Phase 2 owned native-process lifecycle tests and Phase 4 provider-handoff evidence,in-progress R-019,15,vendor launcher tests,post-v1 R-020,6,Bluetooth CLI and TUI tests,planned R-021,11,installer destructive VM tests,planned diff --git a/docs/phase-4/evidence-2026-07-20.md b/docs/phase-4/evidence-2026-07-20.md new file mode 100644 index 0000000..f4982ed --- /dev/null +++ b/docs/phase-4/evidence-2026-07-20.md @@ -0,0 +1,28 @@ +# Phase 4 Steam execution evidence + +- Date: 2026-07-20 +- Requirement IDs: R-005, R-016, R-018, R-024 +- Phase/gate: phase 4 / Steam execution +- Scope: typed native and Flatpak Steam launch plans, configuration provenance, + argument-safe client submission, private logs, structured errors, and representative + native plus Proton execution +- Exclusions: direct executable launch, provider-client ownership, strong game-lifetime + tracking, automatic Steam shutdown, and minimal-session behavior +- Host: Linux 7.1.4-arch1-1; NVIDIA GeForce RTX 5070 using the `nvidia` driver; + Steam 1.0.0.87-1 +- Commands: `kiln launch steam:105600 --dry-run --json`; `kiln launch steam:105600 + --json`; `kiln launch steam:12810 --dry-run --json`; `kiln launch steam:12810 + --json`; `make check` +- Expected result: Kiln emits a typed `steam -silent -applaunch ` plan, submits + it without shell interpolation, returns promptly with unknown lifecycle confidence, + launches one native and one Proton title through Steam, and reports missing clients + as structured failures +- Observed result: Terraria App ID 105600 loaded through Steam Linux Runtime and its + native `Terraria.bin.x86_64`; Overlord II App ID 12810 loaded through + `GE-Proton11-1` and `Overlord2.exe`; both returned cleanly after user exit +- Safety and cleanup: Steam was stopped before the temporary compatibility change and + final restore. The original `config.vdf` was restored byte-for-byte with SHA-256 + `9251c6dbf08602857399916808208bd58dd415b5d5b000acc01f1c9044d89c37`, mode and + ownership unchanged, and Steam restarted +- Automated result: 61 tests passed with the complete local gate +- Result: pass diff --git a/docs/phase-4/status.md b/docs/phase-4/status.md new file mode 100644 index 0000000..15186ea --- /dev/null +++ b/docs/phase-4/status.md @@ -0,0 +1,16 @@ +# Phase 4 status + +Phase 4 is complete. + +| Exit condition | Evidence | Status | +|---|---|---| +| Steam launch plans | Typed native and Flatpak unit tests | Passed | +| Safe client submission | Shell-free CLI integration test with private logs | Passed | +| Failure handling | Structured missing-client integration assertion | Passed | +| Native representative | Terraria App ID 105600 through Steam Linux Runtime | Passed | +| Proton representative | Overlord II App ID 12810 through GE-Proton11-1 | Passed | +| Host cleanup | Original Steam config restored byte-for-byte; Steam restarted | Passed | +| Repository gates | `make check` | Passed | + +Full provider-client lifecycle tracking remains later session-hardening work. Phase 5 +can now prove the minimal compositor, terminal, login/session, and recovery path. diff --git a/docs/spec/00_SPEC_INDEX.md b/docs/spec/00_SPEC_INDEX.md index bcd26b7..d3c8513 100644 --- a/docs/spec/00_SPEC_INDEX.md +++ b/docs/spec/00_SPEC_INDEX.md @@ -33,4 +33,4 @@ This directory is the starting point for project planning and later implementati ## Integrity note -The numbered topic files are the current v0.22 source of truth. `99_FULL_SPEC_ARCHIVE.md` preserves the complete monolithic v0.4 specification for historical and integrity checks, so it no longer matches the evolving numbered files byte-for-byte. +The numbered topic files are the current v0.23 source of truth. `99_FULL_SPEC_ARCHIVE.md` preserves the complete monolithic v0.4 specification for historical and integrity checks, so it no longer matches the evolving numbered files byte-for-byte. diff --git a/docs/spec/01_PROJECT_FOUNDATION.md b/docs/spec/01_PROJECT_FOUNDATION.md index 56669c5..e374969 100644 --- a/docs/spec/01_PROJECT_FOUNDATION.md +++ b/docs/spec/01_PROJECT_FOUNDATION.md @@ -2,9 +2,9 @@ > Performance-first Linux gaming appliance with a unified launcher backend -| **Status** | Implementation — phases 0–3 complete; phase 4 ready | +| **Status** | Implementation — phases 0–4 complete; phase 5 ready | |--------------------|----------------------------------------------------| -| **Version** | 0.22 | +| **Version** | 0.23 | | **Created** | July 17, 2026 | | **Document type** | Living project specification | | **Current target** | Purpose-built, performance-focused gaming distribution | diff --git a/docs/spec/07_RISKS_DECISIONS_AND_HISTORY.md b/docs/spec/07_RISKS_DECISIONS_AND_HISTORY.md index eb71bfe..30a4da1 100644 --- a/docs/spec/07_RISKS_DECISIONS_AND_HISTORY.md +++ b/docs/spec/07_RISKS_DECISIONS_AND_HISTORY.md @@ -145,6 +145,7 @@ | 0.20 | 2026-07-18 | Replaced the unused hosted-CI plan with mandatory local verification, made Rust 1.97.1 the minimum and reviewed compiler, and scheduled closure of the remaining configuration and adversarial-test gaps before phase 3. | | 0.21 | 2026-07-18 | Closed the pre-phase-3 gaps with local configuration defaults, validation, explanation and safe edits; native launch-plan configuration integration; removal of the obsolete file-lock dependency; bounded redacted process logs; and expanded adversarial tests. | | 0.22 | 2026-07-18 | Completed Phase 3 with bounded read-only Steam library and app-manifest parsing, stable App ID identities, provider-scoped CLI discovery, adversarial fixtures, and a non-launching check against the local Steam installation. | +| 0.23 | 2026-07-20 | Completed Phase 4 with typed native and Flatpak Steam launch plans, shell-free client submission, structured failures, and real native Terraria plus GE-Proton Overlord II acceptance tests. | ## 21. Reference notes diff --git a/scripts/verify_repo.py b/scripts/verify_repo.py index 127ac1f..d2b00f7 100755 --- a/scripts/verify_repo.py +++ b/scripts/verify_repo.py @@ -43,7 +43,7 @@ def check_spec() -> set[str]: if duplicates: fail(f"duplicate {label} IDs: {duplicates}") versions = re.findall(r"^\| \*\*Version\*\*\s+\| ([0-9.]+)", combined, re.MULTILINE) - if versions != ["0.22"]: + if versions != ["0.23"]: fail(f"active specification version markers are stale or ambiguous: {versions}") return set(requirements)