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,
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
+8 -7
View File
@@ -6,15 +6,14 @@ promises.
## Status
Phases 03 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 04 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 <query> --provider steam
kiln scan --provider steam
kiln info steam:<app-id>
kiln launch steam:<app-id> --dry-run
kiln launch steam:<app-id>
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/`.
+91 -34
View File
@@ -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<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
///
/// 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<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
.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<LaunchOutcome, Co
}
writeln!(log, "launch {}", plan.game_id())
.map_err(|error| unavailable(format!("write native launch log: {error}")))?;
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("native process exited unsuccessfully; see launch log"),
))
}
Ok(log)
}
fn spawn(
@@ -412,7 +460,7 @@ fn spawn(
) -> 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(&current) {
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<PathBuf, Con
///
/// Returns a structured error when no absolute XDG state or home path is available.
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 home = env::var_os("HOME");
xdg.as_deref()
@@ -827,7 +884,7 @@ pub fn current_log_path() -> Result<PathBuf, ContractErrorV1> {
.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"))
}
+168 -12
View File
@@ -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<LaunchPlanV1, ContractErrorV1> {
Err(ContractErrorV1::unsupported_capability(
text("Steam launch planning begins in Phase 4"),
name("launch"),
))
fn plan_launch(&self, game_id: &GameId) -> Result<LaunchPlanV1, ContractErrorV1> {
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<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> {
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::<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"
]
);
}
+60 -24
View File
@@ -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 <query> [--provider native|steam] [--json]
kiln info <id-or-name> [--provider native|steam] [--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 validate [--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 {
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<String>, json_output: bool) -> ExitCode {
log.display()
);
}
Ok(())
})
}
Ok(())
}
fn with_adapter(
+59 -1
View File
@@ -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");
}
+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-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
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
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
| **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 |
| **Document type** | Living project specification |
| **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.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
+1 -1
View File
@@ -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)