diff --git a/.yoi/tickets/00001KV0TJVN5/artifacts/implementation-report.md b/.yoi/tickets/00001KV0TJVN5/artifacts/implementation-report.md new file mode 100644 index 00000000..3d6f0b26 --- /dev/null +++ b/.yoi/tickets/00001KV0TJVN5/artifacts/implementation-report.md @@ -0,0 +1,33 @@ +Implementation report for Ticket 00001KV0TJVN5 + +Files changed: +- `tests/e2e/src/lib.rs` + - Added a cached e2e binary provider using `OnceLock`. + - Preserves `YOI_E2E_BIN=` as the explicit override and skips the default cargo build provider in that path. + - Default path runs `${CARGO:-cargo} build -p yoi --features e2e-test --bin yoi` from the workspace root, then returns the direct `target/{profile}/yoi` binary path for PTY spawning. + - Writes `target/e2e-artifacts/binary-provider.json` and emits diagnostics with provider, build command, binary path, and tested-subprocess env policy. + - Expanded command-failure diagnostics to include command args. + - Follow-up: isolated tested `yoi` subprocess environments in both `PanelHarness::spawn` and fixture setup `run_yoi_capture` with `env_clear()` plus explicit allowlists only. + - Follow-up: recorded env policy in `run.json`, `binary-provider.json`, and per-fixture `fixture-commands.jsonl` artifacts. + - Follow-up: added a regression assertion that tested-subprocess policies use `env_clear`, do not allow `PATH`, and default-deny provider credentials (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`) and secret-like patterns. + - Follow-up: relative `YOI_E2E_BIN` values are resolved against the workspace root and must exist, so tested subprocess launch does not rely on `PATH` lookup. +- `tests/e2e/tests/panel.rs` + - Updated panel tests to use the fallible cached binary provider. + +Env isolation policy: +- Cargo build provider remains a build-tool command and is not treated as the tested `yoi` subprocess. +- Tested `yoi` fixture setup commands receive only: `HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `YOI_POD_RUNTIME_COMMAND`. +- Tested `yoi panel` commands receive only: fixture `HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `TERM`, `YOI_TUI_TEST_EVENTS`, `YOI_POD_RUNTIME_COMMAND`, and `YOI_TUI_TEST_HOLD_BACKGROUND_TASK` when used. +- `PATH` is intentionally not passed to tested `yoi` subprocesses; the harness launches the already-resolved binary path directly. +- Host provider credentials / token / secret-like environment variables are default-denied. Future provider/LLM E2E should use fixture providers, canned servers, or explicit test env instead of inheriting host credentials. + +Validation: +- `cargo fmt --check` — passed. +- `git diff --check` — passed. +- `cargo check -p yoi-e2e --all-targets --features e2e` — passed. +- `cargo test -p yoi-e2e --features e2e tested_yoi_env_policy_is_env_clear_allowlist -- --nocapture` — passed. +- `unset YOI_E2E_BIN && OPENAI_API_KEY=host-secret ANTHROPIC_API_KEY=host-secret GEMINI_API_KEY=host-secret cargo test -p yoi-e2e --features e2e --test panel -- --nocapture` — passed; default provider built the current `yoi` binary and tested `yoi` subprocesses used isolated env policy artifacts. Host provider env was present for the harness but is not inherited by tested `yoi` subprocesses because `env_clear()` is applied before the allowlist. +- `YOI_E2E_BIN=/home/hare/Projects/yoi/.worktree/e2e-binary-provider/target/debug/yoi OPENAI_API_KEY=host-secret ANTHROPIC_API_KEY=host-secret GEMINI_API_KEY=host-secret cargo test -p yoi-e2e --features e2e --test panel -- --nocapture` — passed; override provider path used without invoking the default cargo-build provider, and tested `yoi` subprocesses still used isolated env policy. + +Remaining gaps: +- None known. diff --git a/.yoi/tickets/00001KV0TJVN5/artifacts/review-2026-06-14-e2e-binary-provider.md b/.yoi/tickets/00001KV0TJVN5/artifacts/review-2026-06-14-e2e-binary-provider.md new file mode 100644 index 00000000..3c804e85 --- /dev/null +++ b/.yoi/tickets/00001KV0TJVN5/artifacts/review-2026-06-14-e2e-binary-provider.md @@ -0,0 +1,19 @@ +## Review: approve + +Decision: approve for Ticket `00001KV0TJVN5`. + +Evidence reviewed: +- Ticket intent/acceptance criteria require default E2E setup to build `yoi` with `cargo build -p yoi --features e2e-test --bin yoi`, then direct-spawn the produced binary, while preserving `YOI_E2E_BIN` override and existing panel E2E behavior. +- `tests/e2e/src/lib.rs` now resolves `yoi_binary()` through a `OnceLock`-cached `BinaryProviderInfo`. The default path runs `${CARGO:-cargo} build -p yoi --features e2e-test --bin yoi` from the workspace root and returns `target/{debug|release}/yoi`; the override path validates and uses `YOI_E2E_BIN` without invoking the cargo-build provider. +- PTY execution remains `Command::new(&config.binary).arg("panel")`; `cargo run` is not in the process-under-test path. +- `PanelHarness::spawn` and fixture `run_yoi_capture` both call `env_clear()` and then set only explicit fixture/test variables. `PATH` and provider credentials are not allowlisted. `YOI_POD_RUNTIME_COMMAND` is set to the resolved binary path, so tested subprocesses do not need host `PATH`. +- Diagnostics/artifacts include provider/build/env policy in `target/e2e-artifacts/binary-provider.json`, panel `run.json`, and fixture `fixture-commands.jsonl`. +- Existing mouse-capture guard (`expect_mouse_capture_enabled` / SGR 1000+1006 tracking), background-task quit barrier assertions, and `e2e-test` production boundary code were not weakened by this diff. + +Validation: +- Reviewer reran `git diff --check a4df9754..HEAD` — passed. +- Reviewer reran `cargo test -p yoi-e2e --features e2e tested_yoi_env_policy_is_env_clear_allowlist -- --nocapture` — passed. +- Also accepted Orchestrator-reported full validation, including fmt/check, `cargo check -p yoi-e2e --all-targets --features e2e`, default panel E2E with host provider env present, and `YOI_E2E_BIN` override panel E2E with host provider env present — all reported passed. + +Risks / follow-up: +- No blocking issues found. The cargo build provider intentionally still uses build-tool environment; tested `yoi` subprocesses are isolated. \ No newline at end of file diff --git a/.yoi/tickets/00001KV0TJVN5/item.md b/.yoi/tickets/00001KV0TJVN5/item.md index 9127db77..720d33a7 100644 --- a/.yoi/tickets/00001KV0TJVN5/item.md +++ b/.yoi/tickets/00001KV0TJVN5/item.md @@ -2,7 +2,7 @@ title: 'E2E harness が最新 yoi binary を自動 build して使うようにする' state: 'inprogress' created_at: '2026-06-13T15:46:07Z' -updated_at: '2026-06-13T15:53:07Z' +updated_at: '2026-06-13T16:06:53Z' assignee: null readiness: 'ready' queued_by: 'yoi ticket' diff --git a/.yoi/tickets/00001KV0TJVN5/thread.md b/.yoi/tickets/00001KV0TJVN5/thread.md index c28d84db..113cceac 100644 --- a/.yoi/tickets/00001KV0TJVN5/thread.md +++ b/.yoi/tickets/00001KV0TJVN5/thread.md @@ -127,4 +127,101 @@ Implication for this Ticket: - Existing `YOI_*` test env should be limited to the feature-gated E2E observability/binary runtime paths and documented as test-only/dev-only surface, not normal runtime configuration. - The cargo build provider should avoid putting Cargo itself in the measured PTY path; any build env inheritance must be considered separately from the tested `yoi` subprocess env boundary. +--- + + + +## Implementation report + +Implementation report for Ticket 00001KV0TJVN5 + +Files changed: +- `tests/e2e/src/lib.rs` + - Added a cached e2e binary provider using `OnceLock`. + - Preserves `YOI_E2E_BIN=` as the explicit override and skips the default cargo build provider in that path. + - Default path runs `${CARGO:-cargo} build -p yoi --features e2e-test --bin yoi` from the workspace root, then returns the direct `target/{profile}/yoi` binary path for PTY spawning. + - Writes `target/e2e-artifacts/binary-provider.json` and emits diagnostics with provider, build command, and binary path. + - Expanded command-failure diagnostics to include command args. +- `tests/e2e/tests/panel.rs` + - Updated panel tests to use the fallible cached binary provider. + +Validation: +- `cargo fmt --check` — passed. +- `git diff --check` — passed. +- `cargo check -p yoi-e2e --all-targets --features e2e` — passed. +- `unset YOI_E2E_BIN && cargo test -p yoi-e2e --features e2e --test panel -- --nocapture` — passed; default provider built the current `yoi` binary and PTY-spawned `target/debug/yoi`. +- `YOI_E2E_BIN=/home/hare/Projects/yoi/.worktree/e2e-binary-provider/target/debug/yoi cargo test -p yoi-e2e --features e2e --test panel -- --nocapture` — passed; override provider path used without invoking the default cargo-build provider. + +Remaining gaps: +- None known. + + +--- + + + +## Implementation report + +Implementation report for Ticket 00001KV0TJVN5 + +Files changed: +- `tests/e2e/src/lib.rs` + - Added a cached e2e binary provider using `OnceLock`. + - Preserves `YOI_E2E_BIN=` as the explicit override and skips the default cargo build provider in that path. + - Default path runs `${CARGO:-cargo} build -p yoi --features e2e-test --bin yoi` from the workspace root, then returns the direct `target/{profile}/yoi` binary path for PTY spawning. + - Writes `target/e2e-artifacts/binary-provider.json` and emits diagnostics with provider, build command, binary path, and tested-subprocess env policy. + - Expanded command-failure diagnostics to include command args. + - Follow-up: isolated tested `yoi` subprocess environments in both `PanelHarness::spawn` and fixture setup `run_yoi_capture` with `env_clear()` plus explicit allowlists only. + - Follow-up: recorded env policy in `run.json`, `binary-provider.json`, and per-fixture `fixture-commands.jsonl` artifacts. + - Follow-up: added a regression assertion that tested-subprocess policies use `env_clear`, do not allow `PATH`, and default-deny provider credentials (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`) and secret-like patterns. + - Follow-up: relative `YOI_E2E_BIN` values are resolved against the workspace root and must exist, so tested subprocess launch does not rely on `PATH` lookup. +- `tests/e2e/tests/panel.rs` + - Updated panel tests to use the fallible cached binary provider. + +Env isolation policy: +- Cargo build provider remains a build-tool command and is not treated as the tested `yoi` subprocess. +- Tested `yoi` fixture setup commands receive only: `HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `YOI_POD_RUNTIME_COMMAND`. +- Tested `yoi panel` commands receive only: fixture `HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, `XDG_CONFIG_HOME`, `TERM`, `YOI_TUI_TEST_EVENTS`, `YOI_POD_RUNTIME_COMMAND`, and `YOI_TUI_TEST_HOLD_BACKGROUND_TASK` when used. +- `PATH` is intentionally not passed to tested `yoi` subprocesses; the harness launches the already-resolved binary path directly. +- Host provider credentials / token / secret-like environment variables are default-denied. Future provider/LLM E2E should use fixture providers, canned servers, or explicit test env instead of inheriting host credentials. + +Validation: +- `cargo fmt --check` — passed. +- `git diff --check` — passed. +- `cargo check -p yoi-e2e --all-targets --features e2e` — passed. +- `cargo test -p yoi-e2e --features e2e tested_yoi_env_policy_is_env_clear_allowlist -- --nocapture` — passed. +- `unset YOI_E2E_BIN && OPENAI_API_KEY=host-secret ANTHROPIC_API_KEY=host-secret GEMINI_API_KEY=host-secret cargo test -p yoi-e2e --features e2e --test panel -- --nocapture` — passed; default provider built the current `yoi` binary and tested `yoi` subprocesses used isolated env policy artifacts. Host provider env was present for the harness but is not inherited by tested `yoi` subprocesses because `env_clear()` is applied before the allowlist. +- `YOI_E2E_BIN=/home/hare/Projects/yoi/.worktree/e2e-binary-provider/target/debug/yoi OPENAI_API_KEY=host-secret ANTHROPIC_API_KEY=host-secret GEMINI_API_KEY=host-secret cargo test -p yoi-e2e --features e2e --test panel -- --nocapture` — passed; override provider path used without invoking the default cargo-build provider, and tested `yoi` subprocesses still used isolated env policy. + +Remaining gaps: +- None known. + + +--- + + + +## Review: approve + +## Review: approve + +Decision: approve for Ticket `00001KV0TJVN5`. + +Evidence reviewed: +- Ticket intent/acceptance criteria require default E2E setup to build `yoi` with `cargo build -p yoi --features e2e-test --bin yoi`, then direct-spawn the produced binary, while preserving `YOI_E2E_BIN` override and existing panel E2E behavior. +- `tests/e2e/src/lib.rs` now resolves `yoi_binary()` through a `OnceLock`-cached `BinaryProviderInfo`. The default path runs `${CARGO:-cargo} build -p yoi --features e2e-test --bin yoi` from the workspace root and returns `target/{debug|release}/yoi`; the override path validates and uses `YOI_E2E_BIN` without invoking the cargo-build provider. +- PTY execution remains `Command::new(&config.binary).arg("panel")`; `cargo run` is not in the process-under-test path. +- `PanelHarness::spawn` and fixture `run_yoi_capture` both call `env_clear()` and then set only explicit fixture/test variables. `PATH` and provider credentials are not allowlisted. `YOI_POD_RUNTIME_COMMAND` is set to the resolved binary path, so tested subprocesses do not need host `PATH`. +- Diagnostics/artifacts include provider/build/env policy in `target/e2e-artifacts/binary-provider.json`, panel `run.json`, and fixture `fixture-commands.jsonl`. +- Existing mouse-capture guard (`expect_mouse_capture_enabled` / SGR 1000+1006 tracking), background-task quit barrier assertions, and `e2e-test` production boundary code were not weakened by this diff. + +Validation: +- Reviewer reran `git diff --check a4df9754..HEAD` — passed. +- Reviewer reran `cargo test -p yoi-e2e --features e2e tested_yoi_env_policy_is_env_clear_allowlist -- --nocapture` — passed. +- Also accepted Orchestrator-reported full validation, including fmt/check, `cargo check -p yoi-e2e --all-targets --features e2e`, default panel E2E with host provider env present, and `YOI_E2E_BIN` override panel E2E with host provider env present — all reported passed. + +Risks / follow-up: +- No blocking issues found. The cargo build provider intentionally still uses build-tool environment; tested `yoi` subprocesses are isolated. + + --- diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index 5c26b6e0..7d866ed9 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -10,7 +10,7 @@ use std::os::fd::{AsRawFd, FromRawFd}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, ExitStatus, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -23,12 +23,118 @@ static FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0); pub type Result = std::result::Result; +#[derive(Clone, Debug, Serialize)] +pub struct BinaryProviderInfo { + pub provider: String, + pub binary: PathBuf, + pub workspace_root: PathBuf, + pub cargo: Option, + pub build_args: Vec, + pub build_command: Option, + pub profile: String, + pub tested_yoi_subprocess_env: TestedYoiEnvPolicy, +} + +#[derive(Clone, Debug, Serialize)] +pub struct EnvPolicy { + pub env_clear: bool, + pub allowlist: Vec, + pub path_allowed: bool, + pub provider_credentials_default_deny: Vec, + pub secret_patterns_default_deny: Vec, + pub note: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct TestedYoiEnvPolicy { + pub fixture_setup: EnvPolicy, + pub panel: EnvPolicy, +} + +impl BinaryProviderInfo { + fn log(&self) { + match &self.build_command { + Some(command) => eprintln!( + "yoi-e2e binary provider={} command={} binary={}", + self.provider, + command, + self.binary.display() + ), + None => eprintln!( + "yoi-e2e binary provider={} binary={}", + self.provider, + self.binary.display() + ), + } + } +} + +fn env_policy(allowlist: &[&str], note: &str) -> EnvPolicy { + EnvPolicy { + env_clear: true, + allowlist: allowlist.iter().map(|name| (*name).to_owned()).collect(), + path_allowed: false, + provider_credentials_default_deny: vec![ + "OPENAI_API_KEY".to_owned(), + "ANTHROPIC_API_KEY".to_owned(), + "GEMINI_API_KEY".to_owned(), + ], + secret_patterns_default_deny: vec![ + "*_API_KEY".to_owned(), + "*_TOKEN".to_owned(), + "*_SECRET".to_owned(), + "*_CREDENTIAL*".to_owned(), + ], + note: note.to_owned(), + } +} + +fn fixture_setup_env_policy() -> EnvPolicy { + env_policy( + &[ + "HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "XDG_CONFIG_HOME", + "YOI_POD_RUNTIME_COMMAND", + ], + "tested yoi fixture setup commands use env_clear and receive only fixture data/config homes plus the explicit runtime binary override", + ) +} + +fn panel_env_policy(include_hold_background_task: bool) -> EnvPolicy { + let mut allowlist = vec![ + "HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "XDG_CONFIG_HOME", + "TERM", + "YOI_TUI_TEST_EVENTS", + "YOI_POD_RUNTIME_COMMAND", + ]; + if include_hold_background_task { + allowlist.push("YOI_TUI_TEST_HOLD_BACKGROUND_TASK"); + } + env_policy( + &allowlist, + "tested yoi panel subprocess uses env_clear and receives only fixture homes, terminal/test-observer variables, and the explicit runtime binary override", + ) +} + +fn tested_yoi_env_policy_overview() -> TestedYoiEnvPolicy { + TestedYoiEnvPolicy { + fixture_setup: fixture_setup_env_policy(), + panel: panel_env_policy(true), + } +} + #[derive(Debug)] pub enum HarnessError { Io(io::Error), Json(serde_json::Error), CommandFailed { program: PathBuf, + args: Vec, status: ExitStatus, stdout: String, stderr: String, @@ -51,13 +157,14 @@ impl std::fmt::Display for HarnessError { Self::Json(err) => write!(f, "json error: {err}"), Self::CommandFailed { program, + args, status, stdout, stderr, } => write!( f, "{} exited with {status}\nstdout:\n{stdout}\nstderr:\n{stderr}", - program.display() + command_display(program, args) ), Self::Timeout { what, artifacts } => write!( f, @@ -66,7 +173,7 @@ impl std::fmt::Display for HarnessError { ), Self::MissingBinary(path) => write!( f, - "missing yoi binary {}; run `cargo build -p yoi --features e2e-test` or set YOI_E2E_BIN", + "missing yoi binary {}; set YOI_E2E_BIN to an existing binary or inspect target/e2e-artifacts/binary-provider.json", path.display() ), Self::MouseCaptureNotEnabled { artifacts } => write!( @@ -187,6 +294,7 @@ impl PanelHarness { fs::write(&artifacts.events_jsonl, "")?; fs::write(&artifacts.input_log, "")?; fs::write(&artifacts.output_log, "")?; + let env_policy = panel_env_policy(config.hold_background_task.is_some()); fs::write( &artifacts.run_json, serde_json::to_vec_pretty(&serde_json::json!({ @@ -201,6 +309,7 @@ impl PanelHarness { "rows": config.terminal_size.1, }, "hold_background_task": config.hold_background_task, + "tested_yoi_env_policy": &env_policy, }))?, )?; @@ -213,6 +322,7 @@ impl PanelHarness { .arg("panel") .arg("--workspace") .arg(&config.workspace) + .env_clear() .env("YOI_TUI_TEST_EVENTS", &artifacts.events_jsonl) .env("YOI_POD_RUNTIME_COMMAND", &config.binary) .env("HOME", &config.home) @@ -496,13 +606,7 @@ pub struct FixtureWorkspace { impl FixtureWorkspace { pub fn new(binary: &Path) -> Result { - let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .ok_or_else(|| { - HarnessError::Protocol("could not resolve workspace root for artifacts".to_owned()) - })? - .to_path_buf(); + let workspace_root = workspace_root()?; let root = workspace_root .join("target") .join("e2e-artifacts") @@ -602,19 +706,146 @@ impl FixtureWorkspace { } } -pub fn yoi_binary() -> PathBuf { - if let Some(path) = std::env::var_os("YOI_E2E_BIN") { - return PathBuf::from(path); +pub fn yoi_binary() -> Result { + Ok(yoi_binary_info()?.binary) +} + +pub fn yoi_binary_info() -> Result { + static BINARY_INFO: OnceLock> = OnceLock::new(); + match BINARY_INFO.get_or_init(|| resolve_yoi_binary().map_err(|err| err.to_string())) { + Ok(info) => Ok(info.clone()), + Err(message) => Err(HarnessError::Protocol(message.clone())), } - let mut path = std::env::current_exe().expect("current executable path"); +} + +fn resolve_yoi_binary() -> Result { + if let Some(path) = std::env::var_os("YOI_E2E_BIN") { + let workspace_root = workspace_root()?; + let binary = PathBuf::from(path); + let binary = if binary.is_absolute() { + binary + } else { + workspace_root.join(binary) + }; + if !binary.exists() { + return Err(HarnessError::MissingBinary(binary)); + } + let info = BinaryProviderInfo { + provider: "YOI_E2E_BIN".to_owned(), + binary, + workspace_root, + cargo: None, + build_args: Vec::new(), + build_command: None, + profile: test_profile(), + tested_yoi_subprocess_env: tested_yoi_env_policy_overview(), + }; + info.log(); + write_binary_provider_artifact(&info)?; + return Ok(info); + } + + let workspace_root = workspace_root()?; + let cargo = PathBuf::from(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())); + let mut args = vec![ + "build".to_owned(), + "-p".to_owned(), + "yoi".to_owned(), + "--features".to_owned(), + "e2e-test".to_owned(), + "--bin".to_owned(), + "yoi".to_owned(), + ]; + if test_profile() == "release" { + args.push("--release".to_owned()); + } + + let command = command_display(&cargo, &args); + eprintln!("yoi-e2e binary provider=cargo-build command={command}"); + let output = Command::new(&cargo) + .args(&args) + .current_dir(&workspace_root) + .output()?; + if !output.status.success() { + return Err(HarnessError::CommandFailed { + program: cargo, + args, + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + let binary = current_target_profile_dir()?.join(binary_name()); + let info = BinaryProviderInfo { + provider: "cargo-build".to_owned(), + binary, + workspace_root, + cargo: Some(cargo), + build_args: args, + build_command: Some(command), + profile: test_profile(), + tested_yoi_subprocess_env: tested_yoi_env_policy_overview(), + }; + info.log(); + write_binary_provider_artifact(&info)?; + if !info.binary.exists() { + return Err(HarnessError::MissingBinary(info.binary)); + } + Ok(info) +} + +fn workspace_root() -> Result { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .map(Path::to_path_buf) + .ok_or_else(|| HarnessError::Protocol("could not resolve workspace root".to_owned())) +} + +fn current_target_profile_dir() -> Result { + let mut path = std::env::current_exe()?; while let Some(name) = path.file_name().and_then(|name| name.to_str()) { if name == "debug" || name == "release" { - path.push("yoi"); - return path; + return Ok(path); } path.pop(); } - PathBuf::from("target/debug/yoi") + Ok(workspace_root()?.join("target").join(test_profile())) +} + +fn test_profile() -> String { + let Ok(mut path) = std::env::current_exe() else { + return "debug".to_owned(); + }; + while let Some(name) = path.file_name().and_then(|name| name.to_str()) { + if name == "debug" || name == "release" { + return name.to_owned(); + } + path.pop(); + } + "debug".to_owned() +} + +fn binary_name() -> String { + format!("yoi{}", std::env::consts::EXE_SUFFIX) +} + +fn write_binary_provider_artifact(info: &BinaryProviderInfo) -> Result<()> { + let dir = info.workspace_root.join("target").join("e2e-artifacts"); + fs::create_dir_all(&dir)?; + fs::write( + dir.join("binary-provider.json"), + serde_json::to_vec_pretty(info)?, + )?; + Ok(()) +} + +fn command_display(program: &Path, args: &[String]) -> String { + std::iter::once(program.display().to_string()) + .chain(args.iter().cloned()) + .collect::>() + .join(" ") } fn open_pty(size: (u16, u16)) -> Result<(File, File)> { @@ -692,18 +923,25 @@ fn run_yoi_capture( config: &Path, args: &[&str], ) -> Result { - let output = Command::new(binary) + let env_policy = fixture_setup_env_policy(); + append_fixture_command_artifact(workspace, binary, args, &env_policy)?; + + let mut command = Command::new(binary); + command .args(args) .current_dir(workspace) + .env_clear() .env("HOME", home) .env("XDG_DATA_HOME", data) .env("XDG_STATE_HOME", state) .env("XDG_CONFIG_HOME", config) - .env("YOI_POD_RUNTIME_COMMAND", binary) - .output()?; + .env("YOI_POD_RUNTIME_COMMAND", binary); + + let output = command.output()?; if !output.status.success() { return Err(HarnessError::CommandFailed { program: binary.to_path_buf(), + args: args.iter().map(|arg| (*arg).to_owned()).collect(), status: output.status, stdout: String::from_utf8_lossy(&output.stdout).into_owned(), stderr: String::from_utf8_lossy(&output.stderr).into_owned(), @@ -714,6 +952,37 @@ fn run_yoi_capture( Ok(text) } +fn append_fixture_command_artifact( + workspace: &Path, + binary: &Path, + args: &[&str], + env_policy: &EnvPolicy, +) -> Result<()> { + let fixture_root = workspace.parent().ok_or_else(|| { + HarnessError::Protocol(format!( + "fixture workspace {} has no parent for artifacts", + workspace.display() + )) + })?; + let artifacts_dir = fixture_root.join("artifacts"); + fs::create_dir_all(&artifacts_dir)?; + let mut file = OpenOptions::new() + .append(true) + .create(true) + .open(artifacts_dir.join("fixture-commands.jsonl"))?; + serde_json::to_writer( + &mut file, + &serde_json::json!({ + "ts_ms": now_ms(), + "binary": binary, + "args": args, + "tested_yoi_env_policy": env_policy, + }), + )?; + writeln!(file)?; + Ok(()) +} + fn write_blocking_pod_metadata(data_home: &Path, pod_name: &str) -> Result<()> { let dir = data_home.join("yoi").join("pods").join(pod_name); fs::create_dir_all(&dir)?; @@ -766,3 +1035,76 @@ fn now_ms() -> u128 { .map(|duration| duration.as_millis()) .unwrap_or_default() } + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_host_credentials_default_denied(policy: &EnvPolicy) { + assert!( + policy.env_clear, + "tested yoi subprocesses must use env_clear" + ); + assert!( + !policy.path_allowed, + "tested yoi subprocesses should not inherit or allow PATH" + ); + assert!( + !policy.allowlist.iter().any(|name| name == "PATH"), + "PATH must not be allowlisted for tested yoi subprocesses" + ); + for name in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"] { + assert!( + !policy.allowlist.iter().any(|allowed| allowed == name), + "{name} must not be allowlisted for tested yoi subprocesses" + ); + assert!( + policy + .provider_credentials_default_deny + .iter() + .any(|denied| denied == name), + "{name} should be recorded as provider credential default-deny" + ); + } + } + + #[test] + fn tested_yoi_env_policy_is_env_clear_allowlist() { + let fixture = fixture_setup_env_policy(); + assert_host_credentials_default_denied(&fixture); + assert_eq!( + fixture.allowlist, + [ + "HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "XDG_CONFIG_HOME", + "YOI_POD_RUNTIME_COMMAND", + ] + ); + + let panel = panel_env_policy(false); + assert_host_credentials_default_denied(&panel); + assert_eq!( + panel.allowlist, + [ + "HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "XDG_CONFIG_HOME", + "TERM", + "YOI_TUI_TEST_EVENTS", + "YOI_POD_RUNTIME_COMMAND", + ] + ); + + let panel_with_hold = panel_env_policy(true); + assert_host_credentials_default_denied(&panel_with_hold); + assert!( + panel_with_hold + .allowlist + .iter() + .any(|name| name == "YOI_TUI_TEST_HOLD_BACKGROUND_TASK") + ); + } +} diff --git a/tests/e2e/tests/panel.rs b/tests/e2e/tests/panel.rs index 87a9638c..bf4d492a 100644 --- a/tests/e2e/tests/panel.rs +++ b/tests/e2e/tests/panel.rs @@ -4,7 +4,7 @@ use yoi_e2e::{FixtureWorkspace, KeyPress, PanelHarness, yoi_binary}; #[test] fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result<()> { - let binary = yoi_binary(); + let binary = yoi_binary()?; let fixture = FixtureWorkspace::new(&binary)?; let mut panel = PanelHarness::spawn(fixture.panel_config(binary))?; @@ -39,7 +39,7 @@ fn panel_mouse_click_selects_row_without_dispatching_action() -> yoi_e2e::Result #[test] fn panel_ctrl_c_exits_promptly_after_background_barrier() -> yoi_e2e::Result<()> { - let binary = yoi_binary(); + let binary = yoi_binary()?; let fixture = FixtureWorkspace::new(&binary)?; let mut panel = PanelHarness::spawn(fixture.panel_config_holding_background_task(binary, "reload"))?;