use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use saya_agent::{AgentMode, ApprovalPolicy, CancellationToken, ToolExecutor};
use saya_config::{
AiProvider, ColorChoice, MemoryMode, OutputFormat, ResolvedAi, ResolvedConfig,
ResolvedFetchJobs, ResolvedHostCommands, ResolvedInterpreterJobs, ResolvedJobs, ResolvedMemory,
ResolvedRunnerJobs, ThemeChoice,
};
use super::SessionUniverse;
fn temp_dir(label: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"saya-session-universe-{label}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
root
}
fn worktree(label: &str) -> PathBuf {
let project = temp_dir(label);
fs::create_dir_all(project.join(".git")).unwrap();
project
}
fn session_runtime(
runner: Option<(Vec<String>, Option<PathBuf>)>,
) -> crate::config::runtime::RuntimeConfig {
crate::config::runtime::RuntimeConfig {
resolved: ResolvedConfig {
profile_name: None,
profile: None,
ai: ResolvedAi {
provider: AiProvider::Ollama,
model: "test-model".into(),
base_url: None,
api_key: None,
allow_data_sharing: true,
temperature: 0.0,
timeout_seconds: 60,
idle_timeout_seconds: 90,
max_output_tokens: 4096,
max_output_tokens_is_default: true,
context_byte_budget: 256 * 1024,
context_window_tokens: None,
show_thinking: false,
compaction: saya_config::CompactionMode::Auto,
retry_delays_ms: vec![250, 500, 1000],
},
max_rows: 100,
read_only: true,
max_iterations: 4,
candidates: 1,
jobs: ResolvedJobs {
wall_clock_seconds: None,
tokens_per_endpoint: Default::default(),
turns: Some(4),
tool_calls: None,
fetch: ResolvedFetchJobs::default(),
interpreter: ResolvedInterpreterJobs::default(),
runner: match runner {
Some((allow, program_dir)) => ResolvedRunnerJobs {
allow,
program_dir,
timeout_seconds: 300,
},
None => ResolvedRunnerJobs::default(),
},
},
query_timeout_seconds: 5,
output_format: OutputFormat::Text,
output_color: ColorChoice::Auto,
ui_theme: ThemeChoice::Auto,
memory: ResolvedMemory {
mode: MemoryMode::Off,
max_contracts: 5,
max_claims_per_contract: 12,
max_context_bytes: 16384,
},
host_commands: ResolvedHostCommands::default(),
session_deny: Default::default(),
ignored_project_overrides: Vec::new(),
endpoints: Default::default(),
},
connections: Default::default(),
config_path: None,
connections_path: None,
cache_scope: PathBuf::from("/tmp/saya-session-universe"),
secret_values: Default::default(),
}
}
fn compose(
runtime: &crate::config::runtime::RuntimeConfig,
project: &Path,
state_dir: &Path,
) -> SessionUniverse {
compose_result(runtime, project, state_dir).expect("composition succeeds on a plain worktree")
}
fn compose_result(
runtime: &crate::config::runtime::RuntimeConfig,
cwd: &Path,
state_dir: &Path,
) -> Result<SessionUniverse, String> {
SessionUniverse::compose(runtime, None, None, true, cwd, state_dir)
}
const SESSION_WRITE_TOOLS: [&str; 6] = [
"workspace_write",
"scratch_sql",
"scratch_import",
"http_fetch",
"http_download",
"run_program",
];
fn advertised(
universe: &SessionUniverse,
agent_mode: AgentMode,
mode: ApprovalPolicy,
can_obtain_approval: bool,
) -> Vec<String> {
universe
.definitions(agent_mode, mode, can_obtain_approval, true, false, false)
.into_iter()
.map(|definition| definition.name)
.collect()
}
#[cfg(not(windows))]
#[test]
fn the_lane_s_advertisement_follows_the_mode_rule() {
let project = worktree("host-advertise");
let state = temp_dir("host-advertise-state");
let runtime = session_runtime(None);
let launch = crate::interactive::session_host::HostLaunch::for_tests_stated(&runtime);
let universe = SessionUniverse::compose_with_launch(
&runtime,
None,
None,
true,
&project,
&state,
Some(&launch),
)
.expect("composition succeeds on a plain worktree");
for (mode, can_obtain_approval, label) in [
(ApprovalPolicy::ReadOnly, true, "read-only"),
(ApprovalPolicy::Never, true, "never"),
(ApprovalPolicy::Ask, false, "no prompt surface"),
] {
let names = advertised(&universe, AgentMode::Build, mode, can_obtain_approval);
assert!(
!names.contains(&"run_command".to_string()),
"{label} never sees the tool: {names:?}"
);
}
let ask_names = advertised(&universe, AgentMode::Build, ApprovalPolicy::Ask, true);
assert!(
ask_names.contains(&"run_command".to_string()),
"a composed lane advertises under ask with a prompt: {ask_names:?}"
);
let bypass_names = advertised(&universe, AgentMode::Build, ApprovalPolicy::Bypass, false);
assert!(
bypass_names.contains(&"run_command".to_string()),
"a composed lane advertises under bypass: {bypass_names:?}"
);
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[test]
fn a_session_without_path_still_starts() {
let project = worktree("no-path-starts");
let state = temp_dir("no-path-starts-state");
let runtime = session_runtime(None);
let universe = SessionUniverse::compose_with_launch_and_path(
crate::interactive::session_universe::SessionComposition {
runtime: &runtime,
explicit: None,
pinned_root: None,
walk_when_unpinned: true,
cwd: &project,
state_dir: &state,
launch: None,
path: None,
scratch: None,
},
)
.expect("a session without PATH still starts");
assert!(
universe.host_composed_for_tests().is_none(),
"no PATH: the lane composes nothing"
);
assert!(
universe.root().is_some(),
"the root still binds without PATH"
);
let notice = universe
.notice
.as_deref()
.expect("no PATH says so at startup");
if cfg!(windows) {
assert!(notice.contains("unavailable on Windows"), "{notice:?}");
} else {
assert!(
notice.contains("No PATH is set"),
"the notice states the fact: {notice:?}"
);
}
let ask_names = advertised(&universe, AgentMode::Build, ApprovalPolicy::Ask, true);
for tool in [
"scratch_sql",
"scratch_import",
"http_fetch",
"workspace_write",
"http_download",
] {
assert!(
ask_names.contains(&tool.to_string()),
"no PATH keeps {tool}: {ask_names:?}"
);
}
assert!(
!ask_names.contains(&"run_command".to_string()),
"only the lane is gone: {ask_names:?}"
);
assert!(
universe.workspace().is_some(),
"workspace file tools still compose without PATH"
);
assert!(
universe.approval_facts(&runtime).host.is_none(),
"the host lane contributes no approval facts without PATH"
);
let launch =
crate::interactive::session_host::HostLaunch::from_deny_for_tests(vec!["curl".to_owned()]);
let denied = SessionUniverse::compose_with_launch_and_path(
crate::interactive::session_universe::SessionComposition {
runtime: &runtime,
explicit: None,
pinned_root: None,
walk_when_unpinned: true,
cwd: &project,
state_dir: &state,
launch: Some(&launch),
path: None,
scratch: universe.scratch(),
},
)
.expect("deny composes without PATH too");
assert!(
denied.deny_programs().contains(&"curl".to_string()),
"the deny list rides the no-PATH session: {:?}",
denied.deny_programs()
);
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[cfg(windows)]
#[test]
fn a_windows_session_starts_with_the_host_lane_off() {
let project = worktree("windows-host-off");
let state = temp_dir("windows-host-off-state");
let runtime = session_runtime(None);
let universe = SessionUniverse::compose_with_launch_and_path(
crate::interactive::session_universe::SessionComposition {
runtime: &runtime,
explicit: None,
pinned_root: None,
walk_when_unpinned: true,
cwd: &project,
state_dir: &state,
launch: None,
path: Some("C:\\Windows\\System32".to_owned()),
scratch: None,
},
)
.expect("Windows starts the session with host commands unavailable");
assert!(universe.root().is_some(), "the workspace still binds");
assert!(
universe.host_composed_for_tests().is_none(),
"host lane is off"
);
for (mode, can_obtain_approval, label) in [
(ApprovalPolicy::Ask, true, "ask"),
(ApprovalPolicy::Bypass, false, "bypass"),
] {
let names = advertised(&universe, AgentMode::Build, mode, can_obtain_approval);
assert!(
!names.contains(&"run_command".to_string()),
"Windows {label} does not advertise an unavailable tool: {names:?}"
);
}
assert!(
universe.approval_facts(&runtime).host.is_none(),
"an unavailable host lane has no approval facts"
);
let notice = universe
.notice
.as_deref()
.expect("startup names the unavailable lane");
assert!(notice.contains("unavailable on Windows"), "{notice:?}");
assert!(notice.contains("run_command is unavailable"), "{notice:?}");
assert!(matches!(
crate::interactive::session_host::compose_host(
&crate::interactive::session_host::HostLaunch::unstated(&runtime),
Some(&project),
"C:\\Windows\\System32".to_owned(),
),
Err(error) if error.contains("unavailable on Windows")
));
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[cfg(not(windows))]
#[test]
fn the_missing_path_notice_names_fact_consequence_and_remedy() {
use crate::interactive::session_host::NO_PATH_NOTICE;
assert!(
NO_PATH_NOTICE.contains("No PATH is set"),
"the notice states the fact: {NO_PATH_NOTICE:?}"
);
assert!(
NO_PATH_NOTICE.contains("run_command is unavailable"),
"the notice names the consequence: {NO_PATH_NOTICE:?}"
);
assert!(
NO_PATH_NOTICE.contains("set PATH"),
"the notice names the remedy: {NO_PATH_NOTICE:?}"
);
}
#[test]
fn no_root_still_no_lane() {
let plain = temp_dir("host-no-worktree");
let state = temp_dir("host-no-worktree-state");
let runtime = session_runtime(None);
let launch = crate::interactive::session_host::HostLaunch::for_tests_stated(&runtime);
let universe = SessionUniverse::compose_with_launch(
&runtime,
None,
None,
true,
&plain,
&state,
Some(&launch),
)
.expect("composition succeeds without a root");
assert!(
universe.host_composed_for_tests().is_none(),
"no workspace root: the lane does not compose"
);
let names = advertised(&universe, AgentMode::Build, ApprovalPolicy::Ask, true);
assert!(
!names.contains(&"run_command".to_string()),
"hidden, not advertised: {names:?}"
);
let bypass_names = advertised(&universe, AgentMode::Build, ApprovalPolicy::Bypass, false);
assert!(
!bypass_names.contains(&"run_command".to_string()),
"hidden under bypass too, not advertised: {bypass_names:?}"
);
let unstated = SessionUniverse::compose_with_launch_and_path(
crate::interactive::session_universe::SessionComposition {
runtime: &runtime,
explicit: None,
pinned_root: None,
walk_when_unpinned: true,
cwd: &plain,
state_dir: &state,
launch: None,
path: std::env::var_os("PATH").map(|value| value.to_string_lossy().into_owned()),
scratch: universe.scratch(),
},
)
.expect("the unstated unbound session composes");
assert!(
unstated.host_composed_for_tests().is_none(),
"unstated: no lane either"
);
let _ = (fs::remove_dir_all(&plain), fs::remove_dir_all(&state));
}
#[test]
fn write_shaped_tools_stay_hidden_where_a_prompt_is_impossible() {
let project = worktree("readonly");
let state = temp_dir("readonly-state");
let universe = compose(&session_runtime(None), &project, &state);
for (mode, can_obtain_approval, label) in [
(ApprovalPolicy::ReadOnly, true, "read-only"),
(ApprovalPolicy::Never, true, "never"),
(ApprovalPolicy::Ask, false, "no approval surface"),
(
ApprovalPolicy::ReadOnly,
false,
"read-only, no approval surface",
),
] {
let names = advertised(&universe, AgentMode::Build, mode, can_obtain_approval);
for tool in SESSION_WRITE_TOOLS {
assert!(
!names.contains(&tool.to_string()),
"{mode_label} must not advertise {tool}: {names:?}",
mode_label = label,
names = names
);
}
}
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[test]
fn bypass_advertises_the_write_shaped_tools_without_a_prompt_surface() {
let project = worktree("bypass-advertise");
let state = temp_dir("bypass-advertise-state");
let universe = compose(&session_runtime(None), &project, &state);
for can_obtain_approval in [true, false] {
let names = advertised(
&universe,
AgentMode::Build,
ApprovalPolicy::Bypass,
can_obtain_approval,
);
for tool in [
"workspace_write",
"scratch_sql",
"http_fetch",
"http_download",
] {
assert!(
names.contains(&tool.to_string()),
"bypass advertises {tool} whether or not a prompt surface exists \
(can_obtain_approval={can_obtain_approval}): {names:?}"
);
}
let definitions = universe.definitions(
saya_agent::AgentMode::Build,
ApprovalPolicy::Bypass,
can_obtain_approval,
true,
false,
false,
);
let write = definitions
.iter()
.find(|definition| definition.name == "workspace_write")
.expect("workspace_write is advertised under bypass");
assert!(
write.effect.requires_approval,
"the definition still declares its approval shape honestly: the engine resolves it"
);
}
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[test]
fn outside_a_worktree_the_write_shaped_tools_are_hidden_and_scratch_and_fetch_work() {
let plain = temp_dir("no-worktree-universe");
let state = temp_dir("no-worktree-state");
let universe =
SessionUniverse::compose(&session_runtime(None), None, None, true, &plain, &state)
.expect("composition succeeds without a root");
let names = advertised(&universe, AgentMode::Build, ApprovalPolicy::Ask, true);
assert!(
!names.contains(&"workspace_write".to_string()),
"no root, no write tool: {names:?}"
);
assert!(
!names.contains(&"http_download".to_string()),
"no root, no download destination: {names:?}"
);
assert!(
!names.contains(&"run_program".to_string()),
"no root, no runner: {names:?}"
);
assert!(
!names.contains(&"scratch_import".to_string()),
"no root, no contained import: {names:?}"
);
assert!(
names.contains(&"scratch_sql".to_string()),
"scratch needs no root: {names:?}"
);
assert!(
names.contains(&"http_fetch".to_string()),
"fetch needs no root: {names:?}"
);
assert!(universe.root().is_none());
let _ = (fs::remove_dir_all(&plain), fs::remove_dir_all(&state));
}
#[test]
fn a_worktree_session_advertises_every_write_shaped_tool_ask_gated() {
let project = worktree("ask-universe");
let state = temp_dir("ask-universe-state");
let universe = compose(&session_runtime(None), &project, &state);
let names = advertised(&universe, AgentMode::Build, ApprovalPolicy::Ask, true);
for tool in [
"workspace_write",
"scratch_sql",
"scratch_import",
"http_fetch",
"http_download",
] {
assert!(
names.contains(&tool.to_string()),
"an ask session advertises {tool}: {names:?}"
);
}
let definitions = universe.definitions(
saya_agent::AgentMode::Build,
ApprovalPolicy::Ask,
true,
true,
false,
false,
);
for tool in [
"workspace_write",
"scratch_sql",
"scratch_import",
"http_fetch",
"http_download",
] {
let definition = definitions
.iter()
.find(|definition| definition.name == tool)
.expect("the write-shaped tool is advertised");
assert!(
definition.effect.requires_approval,
"{tool} is ask-gated: the engine decides every call"
);
}
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[tokio::test]
async fn advertised_scratch_import_routes_through_the_session_executor() {
let project = worktree("scratch-import-executor");
let state = temp_dir("scratch-import-executor-state");
std::fs::write(project.join("data.csv"), b"name\nAda\n").unwrap();
let universe = compose(&session_runtime(None), &project, &state);
assert!(
advertised(&universe, AgentMode::Build, ApprovalPolicy::Bypass, false)
.contains(&"scratch_import".to_owned())
);
let database = Arc::new(
crate::agent::tools::DatabaseTools::new(None, 100, true)
.with_workspace(universe.workspace()),
);
let result = universe
.executor(database, &CancellationToken::new(), false)
.execute(
"scratch_import",
serde_json::json!({"path":"data.csv","table":"people"}),
)
.await
.expect("advertised import routes to scratch");
assert_eq!(result["rows_imported"], 1);
let _ = (fs::remove_dir_all(project), fs::remove_dir_all(state));
}
#[cfg(not(windows))]
#[test]
fn a_program_dir_inside_the_session_workspace_refuses_naming_directory_and_root() {
let project = worktree("placement");
let state = temp_dir("placement-state");
let tools = project.join("tools");
fs::create_dir_all(&tools).unwrap();
let runtime = session_runtime(Some((vec!["bench".to_string()], Some(tools.clone()))));
let error = compose_result(&runtime, &project, &state)
.map(|_: SessionUniverse| ())
.expect_err("a checked-in tool directory refuses for sessions");
assert!(
error.contains(tools.canonicalize().unwrap().display().to_string().as_str()),
"the refusal names the directory: {error}"
);
assert!(
error.contains(
project
.canonicalize()
.unwrap()
.display()
.to_string()
.as_str()
),
"the refusal names the root it sits inside: {error}"
);
assert!(
error.contains("cannot express an exclusion"),
"the refusal says why: {error}"
);
let error = compose_result(
&session_runtime(Some((vec!["bench".to_string()], Some(project.clone())))),
&project,
&state,
)
.map(|_: SessionUniverse| ())
.expect_err("the workspace root itself as program dir refuses");
assert!(
error.contains("overlaps this session's workspace root"),
"the refusal states the containment: {error}"
);
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[cfg(unix)]
#[tokio::test]
async fn no_file_tool_reaches_the_session_state_dir() {
use std::os::unix::fs::symlink;
let project = worktree("sentinel");
let state = temp_dir("sentinel-state");
let sentinel = state.join("sentinel.txt");
fs::write(&sentinel, b"STATE_SENTINEL_9f3a\n").unwrap();
let universe = compose(&session_runtime(None), &project, &state);
let root = universe.root().expect("the worktree binds").to_path_buf();
assert!(
!state.starts_with(&root),
"the session state dir must sit outside the workspace root"
);
let link = project.join("state-link");
symlink(&state, &link).unwrap();
let workspace = universe.workspace();
let database = Arc::new(
crate::agent::tools::DatabaseTools::new(None, 100, true).with_workspace(workspace),
);
let executor = universe.executor(Arc::clone(&database), &CancellationToken::new(), false);
let read = executor
.execute(
"workspace_read",
serde_json::json!({"path": "state-link/sentinel.txt"}),
)
.await
.expect_err("a symlink into the session state dir is refused");
let read = format!("{read:?}");
assert!(
read.contains("symlink"),
"the refusal names the symlink: {read}"
);
for escape in [
serde_json::json!({"path": format!("{}/sentinel.txt", state.display())}),
serde_json::json!({"path": "../sentinel.txt"}),
serde_json::json!({"path": "../sentinel-state/sentinel.txt"}),
] {
let result = executor.execute("workspace_read", escape).await;
assert!(
result.is_err(),
"no relative or absolute path reaches the state dir: {result:?}"
);
}
assert_eq!(
fs::read(&sentinel).unwrap(),
b"STATE_SENTINEL_9f3a\n",
"the sentinel was never rewritten"
);
let _ = fs::remove_dir_all(&project);
let _ = fs::remove_dir_all(&state);
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn a_session_child_runs_with_the_workspace_root_as_its_cwd() {
let project = worktree("child-cwd");
let state = temp_dir("child-state");
let runtime = session_runtime(Some((
vec!["touch".to_string()],
Some(PathBuf::from("/usr/bin")),
)));
let universe = compose(&runtime, &project, &state);
let runner = universe
.runner
.as_ref()
.expect("the probe proves this host (macOS)");
assert_eq!(
runner.spawn.fs_roots(),
&[project.canonicalize().unwrap()],
"one fs root: the workspace, and nothing else — never the session state dir"
);
let database = Arc::new(crate::agent::tools::DatabaseTools::new(None, 100, true));
let executor = universe.executor(Arc::clone(&database), &CancellationToken::new(), false);
let result = executor
.execute(
"run_program",
serde_json::json!({"program": "touch", "args": ["out.json"]}),
)
.await
.expect("the staged program runs in the project root");
let value = serde_json::to_value(&result).unwrap();
assert!(value.get("error").is_none(), "the child ran: {value}");
assert!(
project.join("out.json").exists(),
"the child's write landed in the project, not a state dir"
);
assert!(
!project.join("run_program").exists(),
"the project gains no saya-created directories"
);
let _ = fs::remove_dir_all(&project);
let _ = fs::remove_dir_all(&state);
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn a_granted_interpreter_actually_runs_under_ask() {
let project = worktree("interpreter-grant");
let state = temp_dir("interpreter-grant-state");
let mut runtime = session_runtime(Some((
vec!["touch".to_string()],
Some(PathBuf::from("/usr/bin")),
)));
runtime.resolved.jobs.interpreter.allow = vec!["python3".to_string()];
let universe = compose(&runtime, &project, &state);
assert!(
universe.runner.is_some(),
"the probe proves this host (macOS): the runner composes"
);
let definitions = universe.definitions(
saya_agent::AgentMode::Build,
ApprovalPolicy::Ask,
true,
true,
false,
false,
);
let run_program = definitions
.iter()
.find(|definition| definition.name == "run_program")
.expect("a proven runner advertises run_program");
let policy = saya_agent::SessionPolicy::new(ApprovalPolicy::Ask);
policy.grants().grant("interpreter:python3");
assert_eq!(
policy.resolve(&run_program.effect, Some("interpreter:python3")),
saya_agent::ApprovalDecision::Allow,
"the granted interpreter token pre-answers the ask"
);
let database = Arc::new(crate::agent::tools::DatabaseTools::new(None, 100, true));
let executor = universe.executor(Arc::clone(&database), &CancellationToken::new(), false);
let result = executor
.execute(
"run_program",
serde_json::json!({"program": "python3", "args": ["-c", "print(41 + 1)"]}),
)
.await;
let value = match result {
Ok(value) => value,
Err(error) => panic!(
"the granted interpreter call was refused: {error:?} — the ask approved a \
capability the composition never constructed"
),
};
let outcome = serde_json::to_value(&value).unwrap();
assert!(
outcome.get("error").is_none(),
"the call was admitted: the child's own outcome is the report, not a refusal: {outcome}"
);
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[cfg(target_os = "macos")]
#[test]
fn the_session_s_interpreter_door_is_the_staged_config_universe() {
let project = worktree("interpreter-door");
let state = temp_dir("interpreter-door-state");
let mut staged = session_runtime(Some((
vec!["touch".to_string()],
Some(PathBuf::from("/usr/bin")),
)));
staged.resolved.jobs.interpreter.allow = vec!["python3".to_string(), "perl".to_string()];
let universe = compose(&staged, &project, &state);
let runner = universe
.runner
.as_ref()
.expect("the probe proves this host (macOS)");
let door = runner
.interpreters
.as_ref()
.expect("staged interpreters open the door");
assert_eq!(door.programs, vec!["python3", "perl"]);
let unstaged = compose(
&session_runtime(Some((
vec!["touch".to_string()],
Some(PathBuf::from("/usr/bin")),
))),
&project,
&state,
);
let doorless = unstaged
.runner
.expect("the runner composes regardless of interpreters");
assert!(
doorless.interpreters.is_none(),
"nothing staged in [jobs.interpreter] allow: the door does not exist"
);
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn an_unstaged_interpreter_keeps_the_byte_identical_family_refusal() {
const FAMILY_REFUSAL: &str = "shells and interpreters are refused by name: \
an interpreter can spawn arbitrary children with arbitrary argv and would void \
the typed-argv contract from inside the allowlist";
let project = worktree("interpreter-family");
let state = temp_dir("interpreter-family-state");
let mut runtime = session_runtime(Some((
vec!["touch".to_string()],
Some(PathBuf::from("/usr/bin")),
)));
runtime.resolved.jobs.interpreter.allow = vec!["python3".to_string()];
let universe = compose(&runtime, &project, &state);
assert!(universe.runner.is_some(), "the probe proves this host");
let database = Arc::new(crate::agent::tools::DatabaseTools::new(None, 100, true));
let executor = universe.executor(Arc::clone(&database), &CancellationToken::new(), false);
let result = executor
.execute(
"run_program",
serde_json::json!({"program": "bash", "args": ["-c", "echo hi"]}),
)
.await
.expect_err("bash is not staged: the family refusal stands");
assert!(
result.to_string().contains(FAMILY_REFUSAL),
"the unstaged interpreter keeps the byte-identical family refusal: {result}"
);
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[test]
fn bypass_composes_no_runner_where_the_probe_refuses_and_says_so() {
use super::super::session_runner::PROBE_REFUSED_NOTICE;
let project = worktree("probe-refusal");
let state = temp_dir("probe-refusal-state");
let tools = temp_dir("probe-refusal-tools");
let runtime = session_runtime(Some((vec!["bench".to_string()], Some(tools))));
let universe = compose(&runtime, &project, &state);
match universe.runner.as_ref() {
Some(_) => {
assert!(
!universe.probe_refused && universe.notice.is_none(),
"a proven probe is silent: {:?}",
universe.notice
);
assert!(
advertised(&universe, AgentMode::Build, ApprovalPolicy::Bypass, false)
.contains(&"run_program".to_string()),
"bypass advertises the proven runner with no prompt surface"
);
}
None => {
let notice = universe
.notice
.as_deref()
.expect("a refused probe is said, never silent");
if cfg!(windows) {
assert!(notice.contains(PROBE_REFUSED_NOTICE), "{notice:?}");
assert!(
notice.contains("Host commands are unavailable on Windows"),
"{notice:?}"
);
} else {
assert_eq!(notice, PROBE_REFUSED_NOTICE);
}
assert!(universe.probe_refused, "the activation line's fact rides");
assert!(
!advertised(&universe, AgentMode::Build, ApprovalPolicy::Bypass, false)
.contains(&"run_program".to_string()),
"no runner, no run_program advertisement — under bypass like any mode"
);
}
}
assert!(
PROBE_REFUSED_NOTICE.contains("run_program is unavailable")
&& PROBE_REFUSED_NOTICE.contains("the sandbox probe did not prove this host"),
"the notice names the probe: {PROBE_REFUSED_NOTICE}"
);
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[tokio::test]
async fn bypass_leaves_the_sql_safety_layer_untouched() {
use saya_connectors::{ConnectorOptions, DuckDbConnector};
let project = worktree("safety-layer");
let state = temp_dir("safety-layer-state");
let universe = compose(&session_runtime(None), &project, &state);
let connector = DuckDbConnector::open(":memory:", false, ConnectorOptions::default())
.await
.expect("an in-memory duckdb opens");
let database = Arc::new(crate::agent::tools::DatabaseTools::new(
Some(Box::new(connector)),
100,
true,
));
let sql = universe
.definitions(
saya_agent::AgentMode::Build,
ApprovalPolicy::Bypass,
false,
true,
false,
false,
)
.iter()
.find(|definition| definition.name == "bounded_sql_query")
.expect("the read-shaped SQL tools are always advertised")
.clone();
assert_eq!(
saya_agent::SessionPolicy::new(ApprovalPolicy::Bypass).resolve(&sql.effect, None),
saya_agent::ApprovalDecision::Allow,
"bypass allows the SQL call: the safety layer is the next line of defence, not approval"
);
let executor = universe.executor(database, &CancellationToken::new(), false);
let refused = executor
.execute(
"bounded_sql_query",
serde_json::json!({"sql": "DROP TABLE users"}),
)
.await
.expect_err("a write statement still refuses under bypass");
let refusal = format!("{refused:?}");
assert!(
refusal.contains("read-only safety policy")
|| refusal.contains("not parseable as one read-only statement"),
"the refusal is the safety layer's own: {refusal}"
);
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[test]
fn a_second_acquisition_of_a_live_session_refuses() {
let project = worktree("lock");
let runtime = session_runtime(None);
let first = crate::interactive::session_runtime::SessionRuntime::acquire(
&runtime,
None,
true,
None,
"lock-session-1",
saya_agent::ApprovalPolicy::Ask,
&crate::interactive::session_paths::default_session_dir(),
)
.expect("the first holder acquires");
let error = crate::interactive::session_runtime::SessionRuntime::acquire(
&runtime,
None,
true,
None,
"lock-session-1",
saya_agent::ApprovalPolicy::Ask,
&crate::interactive::session_paths::default_session_dir(),
)
.map(|_: crate::interactive::session_runtime::SessionRuntime| ())
.expect_err("a live holder refuses");
assert!(
error.contains("already running") && error.contains("pid"),
"the refusal names the holder: {error}"
);
drop(first);
crate::interactive::session_runtime::SessionRuntime::acquire(
&runtime,
None,
true,
None,
"lock-session-1",
saya_agent::ApprovalPolicy::Ask,
&crate::interactive::session_paths::default_session_dir(),
)
.expect("the lock is reclaimable after release");
crate::interactive::session_runtime::SessionRuntime::acquire(
&runtime,
None,
true,
None,
"lock-session-2",
saya_agent::ApprovalPolicy::Ask,
&crate::interactive::session_paths::default_session_dir(),
)
.expect("a different session on the same project acquires");
let _ = fs::remove_dir_all(&project);
}
#[tokio::test]
async fn scratch_is_per_session_not_per_project() {
let project = worktree("scratch-boundary");
let state_a = temp_dir("scratch-a");
let state_b = temp_dir("scratch-b");
let universe_a = compose(&session_runtime(None), &project, &state_a);
let universe_b = compose(&session_runtime(None), &project, &state_b);
let scratch_a = universe_a.scratch.as_ref().expect("scratch composed");
let scratch_b = universe_b.scratch.as_ref().expect("scratch composed");
scratch_a
.run("CREATE TABLE stage AS SELECT 42 AS v")
.await
.expect("session A stages a table");
assert!(
state_a.join("scratch.duckdb").exists() && state_b.join("scratch.duckdb").exists(),
"each session's scratch lives at its own state dir"
);
let leaked = scratch_b.run("SELECT v FROM stage").await;
assert!(
leaked.is_err(),
"session B must not see session A's staged table: {leaked:?}"
);
let _ = fs::remove_dir_all(&project);
let _ = fs::remove_dir_all(&state_a);
let _ = fs::remove_dir_all(&state_b);
}
#[tokio::test]
async fn recomposing_a_session_reuses_its_live_scratch_connection() {
let project = worktree("scratch-recompose");
let state = temp_dir("scratch-recompose-state");
let runtime = session_runtime(None);
let first = compose(&runtime, &project, &state);
let scratch = first.scratch.clone().expect("scratch composed");
first
.scratch
.as_ref()
.expect("scratch composed")
.run("CREATE TABLE stage AS SELECT 42 AS v")
.await
.expect("the first universe stages a table");
let second = SessionUniverse::compose_with_launch_and_path(
crate::interactive::session_universe::SessionComposition {
runtime: &runtime,
explicit: None,
pinned_root: None,
walk_when_unpinned: true,
cwd: &project,
state_dir: &state,
launch: None,
path: Some("test-path".to_owned()),
scratch: Some(scratch),
},
)
.expect("recomposition shares the live scratch database");
let second_scratch = second.scratch.as_ref().expect("scratch composed");
let rows = second_scratch
.run("SELECT v FROM stage")
.await
.expect("the second universe reads the first universe's stage");
assert_eq!(rows.rows.len(), 1, "the staged row remains available");
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[tokio::test]
async fn recomposing_unbound_clears_scratch_import_access() {
let project = worktree("scratch-recompose-unbound");
let plain = temp_dir("scratch-recompose-plain");
let state = temp_dir("scratch-recompose-unbound-state");
let runtime = session_runtime(None);
let first = compose(&runtime, &project, &state);
first
.scratch
.as_ref()
.expect("scratch composed")
.run("CREATE TABLE stage AS SELECT 42 AS v")
.await
.expect("the bound universe stages a table");
let second = SessionUniverse::compose_with_launch_and_path(
crate::interactive::session_universe::SessionComposition {
runtime: &runtime,
explicit: None,
pinned_root: None,
walk_when_unpinned: false,
cwd: &plain,
state_dir: &state,
launch: None,
path: Some("test-path".to_owned()),
scratch: first.scratch(),
},
)
.expect("the unbound recomposition retains scratch");
let names = advertised(&second, AgentMode::Build, ApprovalPolicy::Ask, true);
assert!(
!names.contains(&"scratch_import".to_owned()),
"an unbound universe hides scratch imports: {names:?}"
);
second
.scratch
.as_ref()
.expect("scratch composed")
.execute(
"scratch_import",
serde_json::json!({"path": "data.csv", "table": "stage"}),
)
.await
.expect_err("an unbound universe refuses scratch imports");
let rows = second
.scratch
.as_ref()
.expect("scratch composed")
.run("SELECT v FROM stage")
.await
.expect("the shared scratch database retains staged data");
assert_eq!(rows.rows.len(), 1, "the staged row remains available");
let _ = (
fs::remove_dir_all(&project),
fs::remove_dir_all(&plain),
fs::remove_dir_all(&state),
);
}
#[tokio::test]
async fn a_resumed_session_re_enters_its_state_dir_and_reopens_the_scratch() {
let root = temp_dir("resume-scratch-root");
let id = "resume-scratch-1";
{
let first = crate::interactive::session_runtime::SessionRuntime::acquire(
&session_runtime(None),
None,
true,
None,
id,
ApprovalPolicy::Ask,
&root,
)
.expect("the first process acquires");
first
.universe()
.scratch
.as_ref()
.expect("scratch composed")
.run("CREATE TABLE stage AS SELECT 42 AS v")
.await
.expect("the first process stages a table");
}
let db = root.join(id).join("scratch.duckdb");
assert!(
db.exists(),
"the scratch database survives the process: {}",
db.display()
);
let resumed = crate::interactive::session_runtime::SessionRuntime::acquire(
&session_runtime(None),
None,
false,
None,
id,
ApprovalPolicy::Ask,
&root,
)
.expect("the resumed session acquires");
let rows = resumed
.universe()
.scratch
.as_ref()
.expect("scratch composed")
.run("SELECT v FROM stage")
.await
.expect("the staged table comes back on resume");
let values = &rows.rows;
assert_eq!(
values.len(),
1,
"exactly the staged row comes back: {values:?}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_resumed_session_inherits_no_grant_from_the_journal() {
let root = temp_dir("resume-journal-root");
let id = "resume-journal-1";
{
let first = crate::interactive::session_runtime::SessionRuntime::acquire(
&session_runtime(None),
None,
true,
None,
id,
ApprovalPolicy::Ask,
&root,
)
.expect("the first process acquires");
first
.journal()
.granted("sql:analytics", saya_store::GrantSource::Prompt)
.expect("the first process journals its grant");
drop(first);
}
let before = saya_store::SessionJournal::open(root.join(id))
.read()
.expect("the journal reads");
let resumed = crate::interactive::session_runtime::SessionRuntime::acquire(
&session_runtime(None),
None,
false,
None,
id,
ApprovalPolicy::Ask,
&root,
)
.expect("the resumed session acquires");
assert!(
resumed.policy().grants().is_empty(),
"a resumed session starts with an empty grant store"
);
let effect = saya_agent::ToolEffect {
database_data: false,
external_side_effect: true,
requires_approval: true,
local_state: saya_agent::LocalStateEffect::None,
};
assert_eq!(
resumed.policy().resolve(&effect, Some("sql:analytics")),
saya_agent::ApprovalDecision::Ask,
"the journalled grant is not in force: the call the previous process \
was allowed still asks on resume"
);
assert_eq!(
saya_store::SessionJournal::open(root.join(id))
.read()
.expect("the journal reads"),
before,
"a resume re-grants nothing and re-journals nothing"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn fresh_unbound_composition_carries_the_no_workspace_notice() {
let plain = temp_dir("unbound-notice");
let state = temp_dir("unbound-notice-state");
let universe =
SessionUniverse::compose(&session_runtime(None), None, None, true, &plain, &state)
.expect("composition succeeds without a root");
assert!(
universe.root().is_none(),
"outside a worktree with no --workspace, nothing binds"
);
let notice = universe
.notice
.as_deref()
.expect("an unbound session says so at startup");
assert!(
notice.contains("No workspace is bound"),
"the notice states the fact: {notice:?}"
);
assert!(
notice.contains("file tools are unavailable"),
"the notice names why the file tools are absent: {notice:?}"
);
assert!(
notice.contains("--workspace <dir>"),
"the notice names the explicit-bind remedy: {notice:?}"
);
assert!(
notice.contains("git worktree"),
"the notice names the worktree remedy: {notice:?}"
);
let _ = (fs::remove_dir_all(&plain), fs::remove_dir_all(&state));
}
#[test]
fn a_bound_session_carries_no_such_notice() {
let project = worktree("bound-silent");
let state = temp_dir("bound-silent-state");
let universe =
SessionUniverse::compose(&session_runtime(None), None, None, true, &project, &state)
.expect("composition succeeds on a worktree");
assert!(
universe.root().is_some(),
"inside a worktree, the root binds"
);
if cfg!(windows) {
assert!(
universe
.notice
.as_deref()
.is_some_and(|notice| notice.contains("Host commands are unavailable on Windows")),
"Windows names the unavailable host lane: {:?}",
universe.notice
);
} else {
assert!(
universe.notice.is_none(),
"a bound session stays silent: {:?}",
universe.notice
);
}
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[cfg(not(windows))]
#[test]
fn unstated_lane_composes_where_a_root_binds() {
let project = worktree("unstated-lane");
let state = temp_dir("unstated-lane-state");
let universe =
SessionUniverse::compose(&session_runtime(None), None, None, true, &project, &state)
.expect("composition succeeds on a worktree");
assert!(
universe.host_composed_for_tests().is_some(),
"a bound root composes the lane with no statement"
);
let ask_names = advertised(&universe, AgentMode::Build, ApprovalPolicy::Ask, true);
assert!(
ask_names.contains(&"run_command".to_string()),
"the unstated lane advertises under ask with a prompt: {ask_names:?}"
);
let bypass_names = advertised(&universe, AgentMode::Build, ApprovalPolicy::Bypass, false);
assert!(
bypass_names.contains(&"run_command".to_string()),
"the unstated lane advertises under bypass: {bypass_names:?}"
);
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[test]
fn a_pre_workspace_resume_stays_silent_when_unbound() {
let plain = temp_dir("resume-silent");
let state = temp_dir("resume-silent-state");
let universe =
SessionUniverse::compose(&session_runtime(None), None, None, false, &plain, &state)
.expect("composition succeeds without a root");
assert!(
universe.root().is_none(),
"no pin and no walk: nothing binds"
);
assert!(
universe.notice.is_none(),
"a pre-workspace resume keeps its old silence: {:?}",
universe.notice
);
let _ = (fs::remove_dir_all(&plain), fs::remove_dir_all(&state));
}
const PLAN_HIDDEN_TOOLS: [&str; 7] = [
"workspace_write",
"scratch_sql",
"scratch_import",
"http_fetch",
"http_download",
"run_program",
"run_command",
];
#[test]
fn plan_advertises_exactly_the_read_only_surface() {
let project = worktree("plan-readonly-surface");
let state = temp_dir("plan-readonly-surface-state");
let universe = compose(&session_runtime(None), &project, &state);
let plan: std::collections::BTreeSet<String> =
advertised(&universe, AgentMode::Plan, ApprovalPolicy::Ask, true)
.into_iter()
.collect();
let read_only: std::collections::BTreeSet<String> =
advertised(&universe, AgentMode::Build, ApprovalPolicy::ReadOnly, true)
.into_iter()
.collect();
assert_eq!(
plan, read_only,
"Plan hides write-shaped tools instead of offering-then-denying them, \
so its Ask surface equals the read-only surface: plan={plan:?} read-only={read_only:?}"
);
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[test]
fn plan_hides_write_shaped_tools_under_bypass_too() {
let project = worktree("plan-bypass");
let state = temp_dir("plan-bypass-state");
let universe = compose(&session_runtime(None), &project, &state);
let names = advertised(&universe, AgentMode::Plan, ApprovalPolicy::Bypass, false);
for tool in PLAN_HIDDEN_TOOLS {
assert!(
!names.contains(&tool.to_string()),
"Plan hides {tool} even under bypass (hiding, not denying): {names:?}"
);
}
let build = advertised(&universe, AgentMode::Build, ApprovalPolicy::Bypass, false);
for tool in [
"workspace_write",
"scratch_sql",
"http_fetch",
"http_download",
] {
assert!(
build.contains(&tool.to_string()),
"Build-with-bypass still advertises {tool}: {build:?}"
);
}
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[cfg(not(windows))]
#[test]
fn build_advertisement_is_pinned_for_every_approval_policy() {
let project = worktree("build-pinned");
let state = temp_dir("build-pinned-state");
let universe = compose(&session_runtime(None), &project, &state);
for (mode, can_obtain_approval, names) in [
(
ApprovalPolicy::Ask,
false,
vec![
"schema_discovery",
"workspace_read",
"workspace_list",
"glob",
"grep",
"bounded_sql_query",
"bounded_sql_query_all",
"result_shape",
"column_health",
"join_check",
"designate_answer",
"tasks_set",
],
),
(
ApprovalPolicy::Ask,
true,
vec![
"schema_discovery",
"workspace_read",
"workspace_list",
"glob",
"grep",
"bounded_sql_query",
"bounded_sql_query_all",
"result_shape",
"column_health",
"join_check",
"render_chart",
"designate_answer",
"tasks_set",
"workspace_write",
"scratch_sql",
"scratch_import",
"http_fetch",
"http_download",
"run_command",
],
),
(
ApprovalPolicy::Bypass,
false,
vec![
"schema_discovery",
"workspace_read",
"workspace_list",
"glob",
"grep",
"bounded_sql_query",
"bounded_sql_query_all",
"result_shape",
"column_health",
"join_check",
"render_chart",
"designate_answer",
"tasks_set",
"workspace_write",
"scratch_sql",
"scratch_import",
"http_fetch",
"http_download",
"run_command",
],
),
(
ApprovalPolicy::ReadOnly,
true,
vec![
"schema_discovery",
"workspace_read",
"workspace_list",
"glob",
"grep",
"bounded_sql_query",
"bounded_sql_query_all",
"result_shape",
"column_health",
"join_check",
"designate_answer",
"tasks_set",
],
),
(
ApprovalPolicy::Never,
true,
vec![
"schema_discovery",
"workspace_read",
"workspace_list",
"glob",
"grep",
"bounded_sql_query",
"bounded_sql_query_all",
"result_shape",
"column_health",
"join_check",
"designate_answer",
],
),
] {
assert_eq!(
advertised(&universe, AgentMode::Build, mode, can_obtain_approval),
names
.into_iter()
.map(str::to_string)
.collect::<Vec<String>>(),
"Build under {mode:?} pins the advertised names"
);
}
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[test]
fn render_chart_advertisement_agrees_with_enforcement_for_every_policy_and_mode() {
use saya_agent::{ApprovalDecision, SessionPolicy, ToolEffect};
let project = worktree("chart-advert");
let state = temp_dir("chart-advert-state");
let universe = compose(&session_runtime(None), &project, &state);
let chart_effect = ToolEffect {
database_data: false,
external_side_effect: true,
requires_approval: true,
local_state: saya_agent::LocalStateEffect::None,
};
for (agent_mode, policy, can_obtain_approval) in [
(AgentMode::Build, ApprovalPolicy::Ask, true),
(AgentMode::Build, ApprovalPolicy::Ask, false),
(AgentMode::Build, ApprovalPolicy::Bypass, false),
(AgentMode::Build, ApprovalPolicy::ReadOnly, true),
(AgentMode::Build, ApprovalPolicy::Never, true),
(AgentMode::Plan, ApprovalPolicy::Ask, true),
(AgentMode::Plan, ApprovalPolicy::Bypass, false),
(AgentMode::Plan, ApprovalPolicy::ReadOnly, true),
(AgentMode::Plan, ApprovalPolicy::Never, true),
] {
let definitions =
universe.definitions(agent_mode, policy, can_obtain_approval, true, false, false);
let advertised_chart = definitions
.iter()
.any(|definition| definition.name == "render_chart");
let denied = matches!(
SessionPolicy::new(policy)
.with_agent_mode(agent_mode)
.resolve(&chart_effect, None),
ApprovalDecision::Deny { .. }
) || (policy == ApprovalPolicy::Ask && !can_obtain_approval);
assert_eq!(
advertised_chart, !denied,
"{agent_mode:?} under {policy:?} (can_obtain_approval={can_obtain_approval}): advertised={advertised_chart} but denied={denied}"
);
}
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[test]
fn ask_with_prompt_under_build_still_advertises_render_chart() {
let project = worktree("chart-ask");
let state = temp_dir("chart-ask-state");
let universe = compose(&session_runtime(None), &project, &state);
let names = advertised(&universe, AgentMode::Build, ApprovalPolicy::Ask, true);
let position = names
.iter()
.position(|name| name == "render_chart")
.expect("ask-with-prompt under Build advertises render_chart");
assert_eq!(
&names[position - 1..position + 2],
&["join_check", "render_chart", "designate_answer"],
"render_chart keeps its place in the normal interactive tool list: {names:?}"
);
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[test]
fn plan_definitions_carry_no_workspace_write_permit() {
use saya_agent::LocalStateEffect;
let project = worktree("plan-permit");
let state = temp_dir("plan-permit-state");
let universe = compose(&session_runtime(None), &project, &state);
let definitions = universe.definitions(
AgentMode::Plan,
ApprovalPolicy::Ask,
true,
true,
false,
false,
);
assert!(
definitions
.iter()
.all(|definition| definition.effect.local_state != LocalStateEffect::WriteWorkspace),
"no Plan definition writes the workspace, so the runtime derivation reads false: {:?}",
definitions
.iter()
.map(|definition| &definition.name)
.collect::<Vec<_>>()
);
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}
#[test]
fn every_plan_advertised_definition_is_not_denied_by_plan_enforcement() {
use saya_agent::{ApprovalDecision, SessionPolicy};
let project = worktree("plan-anti-drift");
let state = temp_dir("plan-anti-drift-state");
let universe = compose(&session_runtime(None), &project, &state);
for policy in [
ApprovalPolicy::Ask,
ApprovalPolicy::Bypass,
ApprovalPolicy::ReadOnly,
] {
let definitions = universe.definitions(
AgentMode::Plan,
policy,
policy == ApprovalPolicy::Ask,
true,
false,
false,
);
let enforcer = SessionPolicy::new(policy).with_agent_mode(AgentMode::Plan);
for definition in &definitions {
assert!(
!matches!(
enforcer.resolve(&definition.effect, None),
ApprovalDecision::Deny { .. }
),
"Plan advertises {} under {policy:?} but enforcement denies it",
definition.name
);
}
}
let _ = (fs::remove_dir_all(&project), fs::remove_dir_all(&state));
}