feat: complete Steam execution

This commit is contained in:
2026-07-20 15:51:11 -04:00
parent bc0090c63d
commit 7d1077c0cb
13 changed files with 438 additions and 83 deletions
+2
View File
@@ -32,6 +32,8 @@ relevant schema version is advanced.
- Read-only Steam library-folder and app-manifest discovery with stable App ID identity, - 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, normalized records, provider-scoped CLI list/search/info/scan commands, hostile fixtures,
and local installation acceptance evidence. 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 ### Corrected
+8 -7
View File
@@ -6,15 +6,14 @@ promises.
## Status ## Status
Phases 03 are complete: repository gates, shared contracts, native discovery/execution, Phases 04 are complete: repository gates, shared contracts, native discovery/execution,
and read-only Steam discovery. Phase 4 adds Steam launch planning and representative Steam discovery, typed Steam launch planning, and representative native/Proton execution.
native/Proton execution. The Steam commands below currently read metadata only; they do Phase 5 proves the minimal graphical session and recovery path.
not start Steam or launch games.
## Current proof ## Current proof
The workspace contains the shared Rust core, native and read-only Steam adapters, and The workspace contains the shared Rust core, native and Steam adapters, and CLI. Local
CLI. Local development, verification, and the minimum supported compiler are pinned to development, verification, and the minimum supported compiler are pinned to
Rust 1.97.1: Rust 1.97.1:
```text ```text
@@ -28,6 +27,8 @@ kiln list --provider steam
kiln search <query> --provider steam kiln search <query> --provider steam
kiln scan --provider steam kiln scan --provider steam
kiln info steam:<app-id> kiln info steam:<app-id>
kiln launch steam:<app-id> --dry-run
kiln launch steam:<app-id>
kiln config defaults kiln config defaults
kiln config validate kiln config validate
kiln config explain --json 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 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-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/`.
+91 -34
View File
@@ -347,27 +347,91 @@ pub struct LaunchOutcome {
pub success: bool, 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<SubmissionOutcome, ContractErrorV1> {
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 /// # Errors
/// ///
/// Returns a structured error when the log cannot be secured, the command cannot start, /// Returns a structured error when the log cannot be secured, the command cannot start,
/// or the child exits unsuccessfully. /// or the child exits unsuccessfully.
pub fn execute(plan: &LaunchPlanV1, log_path: &Path) -> Result<LaunchOutcome, ContractErrorV1> { pub fn execute(plan: &LaunchPlanV1, log_path: &Path) -> Result<LaunchOutcome, ContractErrorV1> {
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<File, ContractErrorV1> {
let parent = log_path let parent = log_path
.parent() .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() if !log_path.is_absolute()
|| fs::symlink_metadata(log_path).is_ok_and(|m| m.file_type().is_symlink()) || 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)?; reject_symlink_components(parent)?;
fs::create_dir_all(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)?; reject_symlink_components(parent)?;
fs::set_permissions(parent, fs::Permissions::from_mode(DIRECTORY_MODE)) 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() let mut log = OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
@@ -388,23 +452,7 @@ pub fn execute(plan: &LaunchPlanV1, log_path: &Path) -> Result<LaunchOutcome, Co
} }
writeln!(log, "launch {}", plan.game_id()) writeln!(log, "launch {}", plan.game_id())
.map_err(|error| unavailable(format!("write native launch log: {error}")))?; .map_err(|error| unavailable(format!("write native launch log: {error}")))?;
let (status, stdout, stderr) = spawn(plan)?; Ok(log)
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("native process exited unsuccessfully; see launch log"),
))
}
} }
fn spawn( fn spawn(
@@ -412,7 +460,7 @@ fn spawn(
) -> Result<(ExitStatus, CapturedOutput, CapturedOutput), ContractErrorV1> { ) -> Result<(ExitStatus, CapturedOutput, CapturedOutput), ContractErrorV1> {
if !plan.wrappers().is_empty() { if !plan.wrappers().is_empty() {
return Err(ContractErrorV1::unsupported_capability( return Err(ContractErrorV1::unsupported_capability(
text("native wrapper execution is not implemented"), text("wrapper execution is not implemented"),
name("wrappers"), name("wrappers"),
)); ));
} }
@@ -432,28 +480,28 @@ fn spawn(
.stderr(Stdio::piped()); .stderr(Stdio::piped());
let mut child = command let mut child = command
.spawn() .spawn()
.map_err(|error| launch_failed(format!("start native process: {error}")))?; .map_err(|error| launch_failed(format!("start process: {error}")))?;
let stdout = child let stdout = child
.stdout .stdout
.take() .take()
.ok_or_else(|| launch_failed("capture native stdout"))?; .ok_or_else(|| launch_failed("capture process stdout"))?;
let stderr = child let stderr = child
.stderr .stderr
.take() .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 stdout_reader = thread::spawn(move || capture_bounded(stdout));
let stderr_reader = thread::spawn(move || capture_bounded(stderr)); let stderr_reader = thread::spawn(move || capture_bounded(stderr));
let status = child let status = child
.wait() .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 let stdout = stdout_reader
.join() .join()
.map_err(|_| launch_failed("native stdout capture failed"))? .map_err(|_| launch_failed("stdout capture failed"))?
.map_err(|error| launch_failed(format!("read native stdout: {error}")))?; .map_err(|error| launch_failed(format!("read stdout: {error}")))?;
let stderr = stderr_reader let stderr = stderr_reader
.join() .join()
.map_err(|_| launch_failed("native stderr capture failed"))? .map_err(|_| launch_failed("stderr capture failed"))?
.map_err(|error| launch_failed(format!("read native stderr: {error}")))?; .map_err(|error| launch_failed(format!("read stderr: {error}")))?;
Ok((status, stdout, stderr)) Ok((status, stdout, stderr))
} }
@@ -527,11 +575,11 @@ fn reject_symlink_components(path: &Path) -> Result<(), ContractErrorV1> {
current.push(component); current.push(component);
match fs::symlink_metadata(&current) { match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_symlink() => { 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(_) => {} Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} 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(()) Ok(())
@@ -815,6 +863,15 @@ fn config_home(xdg: Option<&OsStr>, home: Option<&OsStr>) -> Result<PathBuf, Con
/// ///
/// Returns a structured error when no absolute XDG state or home path is available. /// Returns a structured error when no absolute XDG state or home path is available.
pub fn current_log_path() -> Result<PathBuf, ContractErrorV1> { pub fn current_log_path() -> Result<PathBuf, ContractErrorV1> {
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<PathBuf, ContractErrorV1> {
let xdg = env::var_os("XDG_STATE_HOME"); let xdg = env::var_os("XDG_STATE_HOME");
let home = env::var_os("HOME"); let home = env::var_os("HOME");
xdg.as_deref() xdg.as_deref()
@@ -827,7 +884,7 @@ pub fn current_log_path() -> Result<PathBuf, ContractErrorV1> {
.filter(|path| path.is_absolute()) .filter(|path| path.is_absolute())
.map(|path| path.join(".local/state")) .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")) .ok_or_else(|| unavailable("no absolute XDG state or home directory is available"))
} }
+168 -12
View File
@@ -8,8 +8,15 @@ use std::io::Read;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use kiln_core::adapter::ProviderAdapter; 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::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::{ use kiln_core::{
Capability, ContractName, ContractText, GameId, GameRecordInput, GameRecordV1, InstallPath, Capability, ContractName, ContractText, GameId, GameRecordInput, GameRecordV1, InstallPath,
InstallationId, InstallationId,
@@ -19,10 +26,11 @@ use uuid::Uuid;
const MAX_VDF_BYTES: u64 = 1024 * 1024; const MAX_VDF_BYTES: u64 = 1024 * 1024;
const MAX_LIBRARIES: usize = 128; const MAX_LIBRARIES: usize = 128;
const MAX_MANIFESTS: usize = 8192; 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] = &[ const RECORD_CAPABILITIES: &[Capability] = &[
Capability::Discover, Capability::Discover,
Capability::Info, Capability::Info,
Capability::Launch,
Capability::ClientRequired, Capability::ClientRequired,
]; ];
const INSTALLATION_NAMESPACE: u128 = 0x72d8_017f_93a9_47b7_0000_0000_0000_0000; 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 { pub struct SteamAdapter {
name: ContractName, name: ContractName,
root: PathBuf, root: PathBuf,
client: SteamClient,
}
#[derive(Clone, Copy, Debug)]
enum SteamClient {
Native,
Flatpak,
} }
impl SteamAdapter { impl SteamAdapter {
@@ -41,6 +56,7 @@ impl SteamAdapter {
Self { Self {
name: name("steam"), name: name("steam"),
root: root.into(), root: root.into(),
client: SteamClient::Native,
} }
} }
@@ -56,8 +72,15 @@ impl SteamAdapter {
.ok_or_else(|| unavailable("no absolute home directory is available"))?; .ok_or_else(|| unavailable("no absolute home directory is available"))?;
let native = home.join(".local/share/Steam"); let native = home.join(".local/share/Steam");
let flatpak = home.join(".var/app/com.valvesoftware.Steam/data/Steam"); let flatpak = home.join(".var/app/com.valvesoftware.Steam/data/Steam");
let root = if native.is_dir() { native } else { flatpak }; if native.is_dir() {
Ok(Self::at(root)) Ok(Self::at(native))
} else {
Ok(Self {
name: name("steam"),
root: flatpak,
client: SteamClient::Flatpak,
})
}
} }
/// Returns matching records in deterministic App ID order. /// Returns matching records in deterministic App ID order.
@@ -223,12 +246,108 @@ impl ProviderAdapter for SteamAdapter {
} }
} }
fn plan_launch(&self, _game_id: &GameId) -> Result<LaunchPlanV1, ContractErrorV1> { fn plan_launch(&self, game_id: &GameId) -> Result<LaunchPlanV1, ContractErrorV1> {
Err(ContractErrorV1::unsupported_capability( let record = self
text("Steam launch planning begins in Phase 4"), .records()?
name("launch"), .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<EnvironmentName, EnvironmentValue> {
[
"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<Option<GameRecordV1>, ContractErrorV1> { fn load_manifest(path: &Path, steamapps: &Path) -> Result<Option<GameRecordV1>, ContractErrorV1> {
@@ -518,9 +637,10 @@ mod tests {
use kiln_core::Capability; use kiln_core::Capability;
use kiln_core::adapter::ProviderAdapter; use kiln_core::adapter::ProviderAdapter;
use kiln_core::contract_error::ContractErrorCode; use kiln_core::contract_error::ContractErrorCode;
use kiln_core::launch::{CommandArgument, Executable};
use uuid::Uuid; use uuid::Uuid;
use super::SteamAdapter; use super::{SteamAdapter, SteamClient};
struct TestDirectory(PathBuf); struct TestDirectory(PathBuf);
@@ -597,14 +717,50 @@ mod tests {
[ [
Capability::Discover, Capability::Discover,
Capability::Info, Capability::Info,
Capability::Launch,
Capability::ClientRequired Capability::ClientRequired
] ]
); );
assert_eq!(adapter.search("portal").unwrap().len(), 1); assert_eq!(adapter.search("portal").unwrap().len(), 1);
assert_eq!(adapter.resolve("Portal 2").unwrap().id(), record.id()); assert_eq!(adapter.resolve("Portal 2").unwrap().id(), record.id());
let plan = adapter.plan_launch(record.id()).unwrap();
assert_eq!( assert_eq!(
adapter.plan_launch(record.id()).unwrap_err().code(), plan.lifecycle_confidence(),
ContractErrorCode::UnsupportedCapability 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::<Vec<_>>(),
["-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::<Vec<_>>(),
[
"run",
"com.valvesoftware.Steam",
"-silent",
"-applaunch",
"620"
]
); );
} }
+48 -12
View File
@@ -3,7 +3,7 @@
use std::env; use std::env;
use std::process::ExitCode; 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_adapter_steam::SteamAdapter;
use kiln_core::adapter::ProviderAdapter; use kiln_core::adapter::ProviderAdapter;
use kiln_core::config::{ use kiln_core::config::{
@@ -27,7 +27,7 @@ Usage:
kiln search <query> [--provider native|steam] [--json] kiln search <query> [--provider native|steam] [--json]
kiln info <id-or-name> [--provider native|steam] [--json] kiln info <id-or-name> [--provider native|steam] [--json]
kiln scan [--provider native|steam] [--no-cache] [--json] kiln scan [--provider native|steam] [--no-cache] [--json]
kiln launch <id-or-name> [--dry-run] [--json] kiln launch <id-or-name> [--provider native|steam] [--dry-run] [--json]
kiln config defaults kiln config defaults
kiln config validate [--json] kiln config validate [--json]
kiln config explain [--json] kiln config explain [--json]
@@ -332,30 +332,66 @@ fn scan_steam(json_output: bool) -> ExitCode {
fn launch(mut arguments: Vec<String>, json_output: bool) -> ExitCode { fn launch(mut arguments: Vec<String>, json_output: bool) -> ExitCode {
let dry_run = take_flag(&mut arguments, "--dry-run"); let dry_run = take_flag(&mut arguments, "--dry-run");
let _ = take_flag(&mut arguments, "--no-cache"); let _ = take_flag(&mut arguments, "--no-cache");
let [command, selector] = arguments.as_slice() else { let (selector, provider) = match arguments.as_slice() {
return invalid_arguments(json_output); [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); return invalid_arguments(json_output);
} }
if steam {
with_steam(json_output, |adapter| {
launch_with(adapter, selector, "steam", dry_run, json_output)
})
} else {
with_adapter(json_output, |adapter| { 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 record = adapter.resolve(selector)?;
let plan = adapter.plan_launch(record.id())?; let plan = adapter.plan_launch(record.id())?;
if dry_run { if dry_run {
let output = if json_output {
serde_json::to_string(&plan)
} else {
serde_json::to_string_pretty(&plan)
}
.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 { if json_output {
println!( println!(
"{}", "{}",
serde_json::to_string(&plan).expect("launch plan serializes") json!({"game_id": record.id(), "status": "submitted", "process_id": outcome.process_id, "log": log})
); );
} else { } else {
println!( println!(
"{}", "{} submitted successfully; log: {}",
serde_json::to_string_pretty(&plan).expect("launch plan serializes") record.id(),
log.display()
); );
} }
return Ok(()); } else {
}
let log = current_log_path()?;
let outcome = execute(&plan, &log)?; let outcome = execute(&plan, &log)?;
if json_output { if json_output {
println!( println!(
@@ -369,8 +405,8 @@ fn launch(mut arguments: Vec<String>, json_output: bool) -> ExitCode {
log.display() log.display()
); );
} }
}
Ok(()) Ok(())
})
} }
fn with_adapter( fn with_adapter(
+59 -1
View File
@@ -1,6 +1,7 @@
//! Isolated CLI contract tests for Phase 1 machine output. //! Isolated CLI contract tests for Phase 1 machine output.
use std::fs; use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
@@ -28,11 +29,23 @@ impl TestDirectory {
} }
fn run(&self, arguments: &[&str]) -> std::process::Output { 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")) Command::new(env!("CARGO_BIN_EXE_kiln"))
.args(arguments) .args(arguments)
.env("XDG_CONFIG_HOME", self.0.join("config")) .env("XDG_CONFIG_HOME", self.0.join("config"))
.env("XDG_STATE_HOME", self.0.join("state")) .env("XDG_STATE_HOME", self.0.join("state"))
.env("HOME", self.0.join("home")) .env("HOME", self.0.join("home"))
.env("PATH", path)
.output() .output()
.expect("kiln runs") .expect("kiln runs")
} }
@@ -66,6 +79,11 @@ impl TestDirectory {
), ),
) )
.unwrap(); .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( fs::write(
steamapps.join(format!("appmanifest_{app_id}.acf")), steamapps.join(format!("appmanifest_{app_id}.acf")),
format!( format!(
@@ -230,7 +248,7 @@ fn invalid_configuration_and_failed_launch_are_structured() {
} }
#[test] #[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(); let directory = TestDirectory::new();
directory.add_steam_game("620", "Portal 2"); 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]["id"], "steam:620");
assert_eq!(records[0]["runtime"], "steam-managed"); assert_eq!(records[0]["runtime"], "steam-managed");
assert_eq!(records[0]["client_required"], true); 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"]); let search = directory.run(&["search", "portal", "--provider", "steam", "--json"]);
assert!(search.status.success()); 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(); let result: Value = serde_json::from_slice(&scan.stdout).unwrap();
assert_eq!(result["provider"], "steam"); assert_eq!(result["provider"], "steam");
assert_eq!(result["discovered"], 1); 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");
} }
+1 -1
View File
@@ -16,7 +16,7 @@ R-014,12,Pegasus adapter tests,post-v1
R-015,8,Lean and Ready policy tests,planned 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-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-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-019,15,vendor launcher tests,post-v1
R-020,6,Bluetooth CLI and TUI tests,planned R-020,6,Bluetooth CLI and TUI tests,planned
R-021,11,installer destructive VM tests,planned R-021,11,installer destructive VM tests,planned
1 requirement_id owner_phase initial_evidence status
16 R-015 8 Lean and Ready policy tests planned
17 R-016 7 Phase 1 contracts Phase 2 native and Phase 3 Steam capability tests in-progress
18 R-017 8 window-switching acceptance tests planned
19 R-018 4 Phase 1 policy and Phase 2 owned native-process lifecycle tests Phase 1 policy Phase 2 owned native-process lifecycle tests and Phase 4 provider-handoff evidence in-progress
20 R-019 15 vendor launcher tests post-v1
21 R-020 6 Bluetooth CLI and TUI tests planned
22 R-021 11 installer destructive VM tests planned
+28
View File
@@ -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 <appid>` 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
+16
View File
@@ -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.
+1 -1
View File
@@ -33,4 +33,4 @@ This directory is the starting point for project planning and later implementati
## Integrity note ## 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.
+2 -2
View File
@@ -2,9 +2,9 @@
> Performance-first Linux gaming appliance with a unified launcher backend > Performance-first Linux gaming appliance with a unified launcher backend
| **Status** | Implementation — phases 03 complete; phase 4 ready | | **Status** | Implementation — phases 04 complete; phase 5 ready |
|--------------------|----------------------------------------------------| |--------------------|----------------------------------------------------|
| **Version** | 0.22 | | **Version** | 0.23 |
| **Created** | July 17, 2026 | | **Created** | July 17, 2026 |
| **Document type** | Living project specification | | **Document type** | Living project specification |
| **Current target** | Purpose-built, performance-focused gaming distribution | | **Current target** | Purpose-built, performance-focused gaming distribution |
@@ -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.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.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.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 ## 21. Reference notes
+1 -1
View File
@@ -43,7 +43,7 @@ def check_spec() -> set[str]:
if duplicates: if duplicates:
fail(f"duplicate {label} IDs: {duplicates}") fail(f"duplicate {label} IDs: {duplicates}")
versions = re.findall(r"^\| \*\*Version\*\*\s+\| ([0-9.]+)", combined, re.MULTILINE) 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}") fail(f"active specification version markers are stale or ambiguous: {versions}")
return set(requirements) return set(requirements)