Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ test('preserves rich MCP, error, reconnect, and cancellation semantics', async (
await rich.locator(':scope > summary').click()
const output = rich.locator('.tool-output')
await expect(output).toContainText('fixture rich text')
await expect(output).toContainText('data:image/png;base64,iVBORw0KGgo=')
await expect(output).toContainText('[MCP binary content denied]')
await expect(output).not.toContainText('data:image/png;base64,iVBORw0KGgo=')
await expect(output).toContainText('[resource fixture://embedded]')
await expect(output).toContainText('[resource: fixture://linked]')
await ready(page)
Expand Down
3 changes: 2 additions & 1 deletion build_defs/rust/desktop_test.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ def desktop_serve_test():
crate_root = "tests/e2e/desktop/serve.rs",
edition = "2024",
data = [":loopal"],
env = {"LOOPAL_BINARY": "$(rootpath :loopal)"},
env = {"LOOPAL_BINARY": "$(rlocationpath :loopal)"},
local = True,
# The tests launch real Desktop/Hub processes and enforce startup
# deadlines, so their timing must not include unrelated //... load.
tags = ["exclusive"],
deps = [
"//crates/loopal-agent-client",
"//crates/loopal-ipc",
"@crates//:serde_json",
"@crates//:tempfile",
Expand Down
18 changes: 10 additions & 8 deletions build_defs/rust/root_tests.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ def _binary_e2e(name, src, deps, extra_srcs = [], tags = []):
crate_root = src,
data = [":loopal"],
edition = "2024",
env = {"LOOPAL_BINARY": "$(rootpath :loopal)"},
env = {"LOOPAL_BINARY": "$(rlocationpath :loopal)"},
local = True,
tags = ["exclusive"] + tags,
deps = deps,
deps = ["//crates/loopal-agent-client"] + deps,
)

def loopal_root_tests():
Expand Down Expand Up @@ -76,7 +76,7 @@ def loopal_root_tests():
crate_root = "tests/e2e/cli_llm/suite.rs",
data = [":loopal"],
edition = "2024",
env = {"LOOPAL_BINARY": "$(rootpath :loopal)"},
env = {"LOOPAL_BINARY": "$(rlocationpath :loopal)"},
local = True,
# reason: excluded from `//...` wildcards (three-OS CI matrix) and run
# by the dedicated Agent E2E gate job, mirroring the desktop e2e setup.
Expand All @@ -86,6 +86,7 @@ def loopal_root_tests():
"manual",
],
deps = [
"//crates/loopal-agent-client",
"//crates/loopal-ipc",
"//crates/loopal-protocol",
"//crates/loopal-test-support:mock-llm-server",
Expand Down Expand Up @@ -124,9 +125,9 @@ def loopal_root_tests():
],
edition = "2024",
env = {
"LOOPAL_BINARY": "$(rootpath :loopal)",
"LOOPAL_MOCK_MCP_BINARY": "$(rootpath :mock_mcp_server)",
"LOOPAL_MOCK_WORKFLOW_WORKER_BINARY": "$(rootpath :mock_workflow_worker)",
"LOOPAL_BINARY": "$(rlocationpath :loopal)",
"LOOPAL_MOCK_MCP_BINARY": "$(rlocationpath :mock_mcp_server)",
"LOOPAL_MOCK_WORKFLOW_WORKER_BINARY": "$(rlocationpath :mock_workflow_worker)",
},
local = True,
tags = [
Expand All @@ -135,6 +136,7 @@ def loopal_root_tests():
"manual",
],
deps = [
"//crates/loopal-agent-client",
"//crates/loopal-ipc",
"//crates/loopal-protocol",
"//crates/loopal-storage",
Expand Down Expand Up @@ -211,9 +213,9 @@ def loopal_root_tests():
],
edition = "2024",
env = {
"LOOPAL_BINARY": "$(rootpath :loopal)",
"LOOPAL_BINARY": "$(rlocationpath :loopal)",
"LOOPAL_OTEL_ENABLED": "0",
"LOOPAL_TEST_PROVIDER": "$(rootpath tests/fixtures/bootstrap_mock_provider.json)",
"LOOPAL_TEST_PROVIDER": "$(rlocationpath tests/fixtures/bootstrap_mock_provider.json)",
},
local = True,
tags = ["exclusive"],
Expand Down
2 changes: 2 additions & 0 deletions crates/loopal-agent-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod bridge;
mod client;
mod process;
mod process_command;
mod runfiles;
mod start_params;
pub(crate) mod stderr_drain;

Expand All @@ -18,4 +19,5 @@ pub mod test_support {
pub use bridge::{BridgeHandles, start_bridge};
pub use client::{AgentClient, AgentClientEvent};
pub use process::AgentProcess;
pub use runfiles::{require_runfile_env, resolve_runfile_env};
pub use start_params::{StartAgentParams, encode};
15 changes: 5 additions & 10 deletions crates/loopal-agent-client/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,11 @@ impl AgentProcess {
}

fn resolve_executable(name: &str) -> anyhow::Result<PathBuf> {
let override_path = std::env::var("LOOPAL_BINARY").ok().map(PathBuf::from);
if let Some(path) = crate::resolve_runfile_env("LOOPAL_BINARY")? {
return Ok(path);
}
let current = std::env::current_exe().ok();
let selected = Self::select_executable(name, override_path, current);
let selected = Self::select_executable(name, current);
if selected.exists() {
return std::fs::canonicalize(&selected).map_err(|error| {
anyhow::anyhow!(
Expand All @@ -182,14 +184,7 @@ impl AgentProcess {
Ok(selected)
}

fn select_executable(
name: &str,
override_path: Option<PathBuf>,
current: Option<PathBuf>,
) -> PathBuf {
if let Some(path) = override_path.filter(|path| path.exists()) {
return path;
}
fn select_executable(name: &str, current: Option<PathBuf>) -> PathBuf {
let explicit = PathBuf::from(name);
if explicit.is_absolute() && explicit.exists() {
return explicit;
Expand Down
32 changes: 20 additions & 12 deletions crates/loopal-agent-client/src/process/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,39 @@ mod support;

#[test]
fn executable_resolver_obeys_precedence() {
let override_path = script("exit 0");
let explicit = script("exit 0");
let current = script("exit 0");
assert_eq!(
AgentProcess::select_executable(
explicit.to_str().unwrap(),
Some(override_path.clone()),
Some(current.clone()),
),
override_path
);
assert_eq!(
AgentProcess::select_executable(explicit.to_str().unwrap(), None, Some(current.clone())),
AgentProcess::select_executable(explicit.to_str().unwrap(), Some(current.clone())),
explicit
);
assert_eq!(
AgentProcess::select_executable("loopal", Some(missing_path()), Some(current.clone())),
AgentProcess::select_executable("loopal", Some(current.clone())),
current
);
assert_eq!(
AgentProcess::select_executable("loopal", Some(missing_path()), None),
AgentProcess::select_executable("loopal", None),
Path::new("loopal")
);
}

#[tokio::test]
async fn invalid_explicit_override_is_an_error() {
let _lock = env_lock().await;
let old = std::env::var_os("LOOPAL_BINARY");
let missing = missing_path();
unsafe { std::env::set_var("LOOPAL_BINARY", &missing) };

let error = AgentProcess::spawn_now(None).err().unwrap().to_string();

assert!(error.contains("LOOPAL_BINARY"), "{error}");
assert!(error.contains(&missing.display().to_string()), "{error}");
match old {
Some(value) => unsafe { std::env::set_var("LOOPAL_BINARY", value) },
None => unsafe { std::env::remove_var("LOOPAL_BINARY") },
}
}

#[tokio::test]
async fn spawn_now_some_succeeds_without_test_binary() {
let _lock = env_lock().await;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ fn missing_absolute_executable_falls_through_without_path_rewrite() {
let missing = support::missing_path();
assert!(missing.is_absolute());
assert_eq!(
AgentProcess::select_executable(missing.to_str().unwrap(), None, None),
AgentProcess::select_executable(missing.to_str().unwrap(), None),
missing
);
}
146 changes: 146 additions & 0 deletions crates/loopal-agent-client/src/runfiles.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
use std::ffi::OsStr;
use std::path::{Path, PathBuf};

/// Resolve a file-valued environment variable from Bazel runfiles.
///
/// A configured value is authoritative: callers receive an error when it
/// cannot be resolved. `Ok(None)` is returned only when the variable is absent.
pub fn resolve_runfile_env(variable: &str) -> anyhow::Result<Option<PathBuf>> {
let Some(configured) = std::env::var_os(variable) else {
return Ok(None);
};
resolve_configured_file(variable, &configured, &RunfilesLocations::from_env()).map(Some)
}

/// Resolve a required file-valued environment variable from Bazel runfiles.
pub fn require_runfile_env(variable: &str) -> anyhow::Result<PathBuf> {
resolve_runfile_env(variable)?
.ok_or_else(|| anyhow::anyhow!("{variable} must be set to a file path"))
}

#[derive(Default)]
struct RunfilesLocations {
runfiles_dir: Option<PathBuf>,
test_srcdir: Option<PathBuf>,
manifest: Option<PathBuf>,
}

impl RunfilesLocations {
fn from_env() -> Self {
Self {
runfiles_dir: std::env::var_os("RUNFILES_DIR").map(PathBuf::from),
test_srcdir: std::env::var_os("TEST_SRCDIR").map(PathBuf::from),
manifest: std::env::var_os("RUNFILES_MANIFEST_FILE").map(PathBuf::from),
}
}
}

fn resolve_configured_file(
variable: &str,
configured: &OsStr,
locations: &RunfilesLocations,
) -> anyhow::Result<PathBuf> {
let logical = PathBuf::from(configured);
if logical.as_os_str().is_empty() {
return Err(unresolved(variable, &logical));
}
if logical.is_absolute() {
return existing_file(variable, &logical, &logical);
}
if logical.is_file() {
return canonicalize(variable, &logical, &logical);
}
for root in [&locations.runfiles_dir, &locations.test_srcdir]
.into_iter()
.flatten()
{
let candidate = root.join(&logical);
if candidate.is_file() {
return canonicalize(variable, &logical, &candidate);
}
}
if let (Some(manifest), Some(key)) = (&locations.manifest, logical.to_str())
&& let Some(candidate) = manifest_entry(manifest, key).map_err(|error| {
anyhow::anyhow!(
"failed to resolve {variable} runfile '{}': could not read manifest '{}': {error}",
logical.display(),
manifest.display()
)
})?
{
return existing_file(variable, &logical, &candidate);
}
Err(unresolved(variable, &logical))
}

fn manifest_entry(manifest: &Path, key: &str) -> std::io::Result<Option<PathBuf>> {
let contents = std::fs::read_to_string(manifest)?;
Ok(contents.lines().find_map(|line| {
let (logical, physical) = parse_manifest_entry(line.trim_end_matches('\r'))?;
(logical == key).then(|| PathBuf::from(physical))
}))
}

fn parse_manifest_entry(line: &str) -> Option<(String, String)> {
let (escaped, entry) = match line.strip_prefix(' ') {
Some(entry) => (true, entry),
None => (false, line),
};
let (logical, physical) = entry.split_once(' ')?;
if !escaped {
return Some((logical.to_owned(), physical.to_owned()));
}
Some((
decode_manifest_field(logical),
decode_manifest_field(physical),
))
}

fn decode_manifest_field(value: &str) -> String {
let mut decoded = String::with_capacity(value.len());
let mut chars = value.chars();
while let Some(character) = chars.next() {
if character != '\\' {
decoded.push(character);
continue;
}
match chars.next() {
Some('s') => decoded.push(' '),
Some('n') => decoded.push('\n'),
Some('b') => decoded.push('\\'),
Some(other) => {
decoded.push('\\');
decoded.push(other);
}
None => decoded.push('\\'),
}
}
decoded
}

fn existing_file(variable: &str, logical: &Path, candidate: &Path) -> anyhow::Result<PathBuf> {
if !candidate.is_file() {
return Err(unresolved(variable, logical));
}
canonicalize(variable, logical, candidate)
}

fn canonicalize(variable: &str, logical: &Path, candidate: &Path) -> anyhow::Result<PathBuf> {
std::fs::canonicalize(candidate).map_err(|error| {
anyhow::anyhow!(
"failed to resolve {variable} runfile '{}': {error}",
logical.display()
)
})
}

fn unresolved(variable: &str, logical: &Path) -> anyhow::Error {
anyhow::anyhow!(
"failed to resolve {variable} runfile '{}': file not found",
logical.display()
)
}

#[cfg(test)]
#[path = "runfiles/tests.rs"]
mod tests;
Loading
Loading