chore(dev): add Windows dev runner and align lockfile versions (#1015)

Co-authored-by: oxoxDev <164490987+oxoxDev@users.noreply.github.com>
This commit is contained in:
Mega Mind
2026-04-29 11:07:00 -07:00
committed by GitHub
co-authored by oxoxDev
parent b11b8f333f
commit 40fddf5bfa
20 changed files with 353 additions and 70 deletions
+1
View File
@@ -73,3 +73,4 @@ workflow
overlay/src-tauri/target/
.claude/*.lock
app/.claude/scheduled_tasks.lock
target-test-run
+51
View File
@@ -1,5 +1,56 @@
#!/usr/bin/env sh
# Windows Git Bash can miss Node/Pnpm in PATH when hooks run.
# Recover from common PATH drift by hydrating from where.exe.
has_node() {
command -v node >/dev/null 2>&1 || command -v node.exe >/dev/null 2>&1
}
has_pnpm() {
command -v pnpm >/dev/null 2>&1 || command -v pnpm.exe >/dev/null 2>&1
}
prepend_windows_exe_dir() {
EXE_NAME="$1"
WIN_EXE="$(where.exe "$EXE_NAME" 2>/dev/null | tr -d '\r' | head -n 1)"
if [ -z "$WIN_EXE" ]; then
return
fi
if command -v cygpath >/dev/null 2>&1; then
EXE_PATH="$(cygpath -u "$WIN_EXE" 2>/dev/null)"
else
EXE_PATH="$(printf '%s' "$WIN_EXE" | sed 's#\\#/#g')"
fi
if [ -n "$EXE_PATH" ]; then
PATH="$(dirname "$EXE_PATH"):$PATH"
export PATH
fi
}
if [ "${OS:-}" = "Windows_NT" ]; then
if ! has_node; then
prepend_windows_exe_dir node
fi
if ! command -v pnpm >/dev/null 2>&1; then
prepend_windows_exe_dir pnpm
fi
fi
if ! has_node; then
echo "Pre-push checks require Node.js, but 'node' is not available on PATH."
echo "Install Node.js or expose node.exe in PATH, then retry git push."
exit 1
fi
if ! has_pnpm; then
echo "Pre-push checks require pnpm, but 'pnpm' is not available on PATH."
echo "Install pnpm (https://pnpm.io/installation) or expose pnpm.exe in PATH, then retry git push."
exit 1
fi
# Run format check first (capture exit code without breaking script)
set +e
pnpm format:check
+1
View File
@@ -10,3 +10,4 @@ skills
tsconfig.tsbuildinfo
yarn.lock
package-lock.json
target-test-run
@@ -116,20 +116,22 @@ const ScreenIntelligencePanel = () => {
/>
<div className="max-w-2xl mx-auto w-full p-4 space-y-4">
<PermissionsSection
screenRecording={status?.permissions.screen_recording ?? 'unknown'}
accessibility={status?.permissions.accessibility ?? 'unknown'}
inputMonitoring={status?.permissions.input_monitoring ?? 'unknown'}
anyPermissionDenied={anyPermissionDenied ?? false}
lastRestartSummary={lastRestartSummary}
permissionCheckProcessPath={status?.permission_check_process_path}
isRequestingPermissions={isRequestingPermissions}
isRestartingCore={isRestartingCore}
isLoading={isLoading}
requestPermission={requestPermission}
refreshPermissionsWithRestart={refreshPermissionsWithRestart}
refreshStatus={refreshStatus}
/>
{(status?.platform_supported ?? true) && (
<PermissionsSection
screenRecording={status?.permissions.screen_recording ?? 'unknown'}
accessibility={status?.permissions.accessibility ?? 'unknown'}
inputMonitoring={status?.permissions.input_monitoring ?? 'unknown'}
anyPermissionDenied={anyPermissionDenied ?? false}
lastRestartSummary={lastRestartSummary}
permissionCheckProcessPath={status?.permission_check_process_path}
isRequestingPermissions={isRequestingPermissions}
isRestartingCore={isRestartingCore}
isLoading={isLoading}
requestPermission={requestPermission}
refreshPermissionsWithRestart={refreshPermissionsWithRestart}
refreshStatus={refreshStatus}
/>
)}
<section className="space-y-3">
<h3 className="text-sm font-semibold text-stone-900">Screen Awareness</h3>
@@ -224,7 +226,8 @@ const ScreenIntelligencePanel = () => {
{status !== null && !status.platform_supported && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-3 text-sm text-amber-700">
Screen Awareness is currently supported on macOS only.
Screen Awareness desktop capture and permission controls are currently supported on
macOS only.
</div>
)}
@@ -155,7 +155,7 @@ describe('ScreenIntelligencePanel', () => {
expect(baseState.refreshStatus).toHaveBeenCalledTimes(1);
});
it('shows permission restart guidance and unsupported-platform messaging', async () => {
it('hides permissions section and shows unsupported-platform messaging', async () => {
renderPanel({
...baseState,
status: {
@@ -169,14 +169,18 @@ describe('ScreenIntelligencePanel', () => {
},
});
expect(await screen.findByText('Permissions')).toBeInTheDocument();
expect(screen.getByText(/After granting in System Settings, click/i)).toBeInTheDocument();
await waitFor(() => {
expect(
screen.getByText(
'Screen Awareness desktop capture and permission controls are currently supported on macOS only.'
)
).toBeInTheDocument();
});
expect(screen.queryByText('Permissions')).not.toBeInTheDocument();
expect(screen.queryByText(/After granting in System Settings, click/i)).not.toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Restart & Refresh Permissions' })
).toBeInTheDocument();
expect(
screen.getByText('Screen Awareness is currently supported on macOS only.')
).toBeInTheDocument();
screen.queryByRole('button', { name: 'Restart & Refresh Permissions' })
).not.toBeInTheDocument();
});
it('shows the last successful restart summary', async () => {
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
APP_DIR="$REPO_ROOT/app"
cd "$APP_DIR"
# Load .env first so project env vars are available, but before we compute
# Windows-specific paths so tailored values (CEF_PATH, PATH, etc.) are set
# after .env is applied and cannot be clobbered by it.
# shellcheck source=../scripts/load-dotenv.sh
source "$REPO_ROOT/scripts/load-dotenv.sh"
if ! command -v cygpath >/dev/null 2>&1; then
echo "[run-dev-win] cygpath not found. Run this script from Git Bash or MSYS2."
exit 1
fi
if [[ -z "${LOCALAPPDATA:-}" ]]; then
echo "[run-dev-win] LOCALAPPDATA is unset; cannot resolve the CEF cache directory." >&2
exit 1
fi
export LIBCLANG_PATH="/c/Program Files/LLVM/bin"
# CEF runtime lives under LOCALAPPDATA on Windows.
# ensure-tauri-cli.sh stages it here; fall back to a default if unset.
CEF_PATH="${CEF_PATH:-$(cygpath -u "$LOCALAPPDATA")/tauri-cef}"
export CEF_PATH
to_unix_path() {
if [[ -z "${1:-}" ]]; then
return 1
fi
cygpath -u "$1"
}
# Resolve a WinGet-installed executable.
# Usage: find_winget_exe <package-glob> <exe-name>
# Prints the full path to the exe and returns 0, or returns 1 if not found.
find_winget_exe() {
local pkg_glob="$1"
local exe_name="$2"
local local_appdata_unix
local_appdata_unix="$(to_unix_path "${LOCALAPPDATA:-}")" || return 1
local candidate
# Sort by version (lexicographic on directory name) and pick the newest.
candidate="$(ls -d "$local_appdata_unix"/Microsoft/WinGet/Packages/${pkg_glob}_* 2>/dev/null \
| sort -V | tail -n1 || true)"
if [[ -n "$candidate" && -x "$candidate/$exe_name" ]]; then
printf '%s\n' "$candidate/$exe_name"
return 0
fi
return 1
}
find_pnpm() {
if command -v pnpm >/dev/null 2>&1; then
command -v pnpm
return 0
fi
find_winget_exe "pnpm.pnpm" "pnpm.exe"
}
find_ninja() {
if command -v ninja >/dev/null 2>&1; then
command -v ninja
return 0
fi
find_winget_exe "Ninja-build.Ninja" "ninja.exe"
}
PNPM_EXE="$(find_pnpm || true)"
if [[ -z "$PNPM_EXE" ]]; then
echo "[run-dev-win] pnpm not found. Install pnpm and retry."
exit 1
fi
NINJA_EXE="$(find_ninja || true)"
if [[ -z "$NINJA_EXE" ]]; then
echo "[run-dev-win] ninja not found. Install ninja and retry."
exit 1
fi
export CMAKE_MAKE_PROGRAM="$NINJA_EXE"
CEF_RUNTIME_PATH="$(ls -d "$CEF_PATH"/*/cef_windows_x86_64 2>/dev/null | sort -Vr | head -n1 || true)"
if [[ -n "$CEF_RUNTIME_PATH" ]]; then
export CEF_RUNTIME_PATH
fi
PATH_PREFIX="/c/Program Files/CMake/bin:$(dirname "$NINJA_EXE")"
if [[ -n "${CEF_RUNTIME_PATH:-}" ]]; then
PATH_PREFIX="$PATH_PREFIX:$CEF_RUNTIME_PATH"
fi
export PATH="$PATH_PREFIX:$PATH"
"$PNPM_EXE" tauri:ensure
"$PNPM_EXE" core:stage
# Use the vendored tauri-cef CLI (via the pnpm tauri script) so the
# CEF runtime is correctly bundled. APPLE_SIGNING_IDENTITY is macOS-only
# and is intentionally omitted here.
"$PNPM_EXE" tauri dev
+4 -3
View File
@@ -244,9 +244,10 @@ mod tests {
fn tool_maker_prompt_includes_command() {
let interceptor = SelfHealingInterceptor::new(Path::new("/workspace"), true);
let prompt = interceptor.tool_maker_prompt("jq", "parse json output");
assert!(prompt.contains("jq"));
assert!(prompt.contains("/workspace/polyfills/jq"));
assert!(prompt.contains("parse json output"));
let normalized = prompt.replace('\\', "/");
assert!(normalized.contains("jq"));
assert!(normalized.contains("/workspace/polyfills/jq"));
assert!(normalized.contains("parse json output"));
}
#[test]
+7 -4
View File
@@ -280,10 +280,13 @@ mod tests {
// Note: this uses the same `load_config_with_timeout()` call path
// that real startup uses. If some other test has written a session
// profile to disk, this test accepts either outcome (Ok) gracefully.
let result = run_one_tick().await;
// Either Ok (no client, skipped) or Ok (backend unreachable handled
// gracefully). The `Err` branch only fires on config-load failure.
let _ = result;
let inner = tokio::time::timeout(Duration::from_secs(5), run_one_tick())
.await
.expect("run_one_tick should not hang indefinitely during tests");
assert!(
inner.is_ok(),
"run_one_tick should return Ok when no client is available: {inner:?}"
);
}
#[tokio::test]
+23 -1
View File
@@ -6,7 +6,7 @@
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use std::sync::{Arc, Mutex, OnceLock};
use chrono::Utc;
use fs2::FileExt;
@@ -15,6 +15,12 @@ use super::types::{ComposioTriggerHistoryEntry, ComposioTriggerHistoryResult};
static GLOBAL_TRIGGER_HISTORY: OnceLock<Arc<ComposioTriggerHistoryStore>> = OnceLock::new();
/// Process-local write serializer for Windows, where `fs2::FileExt::lock_exclusive`
/// is unavailable. This ensures concurrent `record_trigger` calls do not race and
/// produce malformed JSONL lines.
#[cfg(windows)]
static WINDOWS_TRIGGER_WRITE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
const TRIGGER_ARCHIVE_DIR: &str = "triggers";
pub fn init_global(workspace_dir: PathBuf) -> Result<(), String> {
@@ -113,12 +119,25 @@ impl ComposioTriggerHistoryStore {
)
})?;
#[cfg(windows)]
let _windows_guard = WINDOWS_TRIGGER_WRITE_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.map_err(|_| {
format!(
"[composio][history] windows write mutex poisoned for archive file {}",
path.display()
)
})?;
#[cfg(not(windows))]
file.lock_exclusive().map_err(|error| {
format!(
"[composio][history] failed to lock archive file {}: {error}",
path.display()
)
})?;
let write_result = writeln!(file, "{line}")
.and_then(|_| file.flush())
.map_err(|error| {
@@ -127,6 +146,8 @@ impl ComposioTriggerHistoryStore {
path.display()
)
});
#[cfg(not(windows))]
let unlock_result = file.unlock().map_err(|error| {
format!(
"[composio][history] failed to unlock archive file {}: {error}",
@@ -135,6 +156,7 @@ impl ComposioTriggerHistoryStore {
});
write_result?;
#[cfg(not(windows))]
unlock_result?;
tracing::debug!(
+5
View File
@@ -45,6 +45,7 @@ fn test_job(command: &str) -> CronJob {
}
}
#[cfg(not(windows))]
#[tokio::test]
async fn run_job_command_success() {
let tmp = TempDir::new().unwrap();
@@ -58,6 +59,7 @@ async fn run_job_command_success() {
assert!(output.contains("status=exit status: 0"));
}
#[cfg(not(windows))]
#[tokio::test]
async fn run_job_command_failure() {
let tmp = TempDir::new().unwrap();
@@ -77,6 +79,7 @@ async fn run_job_command_failure() {
assert!(output.contains("status=exit status:"));
}
#[cfg(not(windows))]
#[tokio::test]
async fn run_job_command_times_out() {
let tmp = TempDir::new().unwrap();
@@ -149,6 +152,7 @@ async fn run_job_command_blocks_rate_limited() {
assert!(output.contains("rate limit exceeded"));
}
#[cfg(not(windows))]
#[tokio::test]
async fn execute_job_with_retry_recovers_after_first_failure() {
let tmp = TempDir::new().unwrap();
@@ -174,6 +178,7 @@ async fn execute_job_with_retry_recovers_after_first_failure() {
assert!(output.contains("recovered"));
}
#[cfg(not(windows))]
#[tokio::test]
async fn execute_job_with_retry_exhausts_attempts() {
let tmp = TempDir::new().unwrap();
+14 -10
View File
@@ -226,17 +226,21 @@ mod tests {
#[test]
fn target_paths_preserve_absolute_overrides() {
let (_tmp, mut config) = temp_config();
config.local_ai.stt_model_id = "/tmp/stt-model.bin".to_string();
config.local_ai.tts_voice_id = "/tmp/voice.onnx".to_string();
let stt = if cfg!(windows) {
"C:\\tmp\\stt-model.bin"
} else {
"/tmp/stt-model.bin"
};
let tts = if cfg!(windows) {
"C:\\tmp\\voice.onnx"
} else {
"/tmp/voice.onnx"
};
config.local_ai.stt_model_id = stt.to_string();
config.local_ai.tts_voice_id = tts.to_string();
assert_eq!(
stt_model_target_path(&config),
PathBuf::from("/tmp/stt-model.bin")
);
assert_eq!(
tts_model_target_path(&config),
PathBuf::from("/tmp/voice.onnx")
);
assert_eq!(stt_model_target_path(&config), PathBuf::from(stt));
assert_eq!(tts_model_target_path(&config), PathBuf::from(tts));
}
#[test]
+12 -3
View File
@@ -731,8 +731,13 @@ fn from_config_creates_fresh_tracker() {
#[test]
fn checklist_root_path_blocked() {
let p = default_policy();
assert!(!p.is_path_allowed("/"));
assert!(!p.is_path_allowed("/anything"));
if cfg!(windows) {
assert!(!p.is_path_allowed("C:\\"));
assert!(!p.is_path_allowed("C:\\anything"));
} else {
assert!(!p.is_path_allowed("/"));
assert!(!p.is_path_allowed("/anything"));
}
}
#[test]
@@ -789,7 +794,11 @@ fn checklist_workspace_only_blocks_all_absolute() {
workspace_only: true,
..SecurityPolicy::default()
};
assert!(!p.is_path_allowed("/any/absolute/path"));
if cfg!(windows) {
assert!(!p.is_path_allowed("C:\\any\\absolute\\path"));
} else {
assert!(!p.is_path_allowed("/any/absolute/path"));
}
assert!(p.is_path_allowed("relative/path.txt"));
}
+7 -2
View File
@@ -409,8 +409,13 @@ fn read_skill_resource_rejects_absolute_paths() {
let ws = dir.path();
make_legacy_skill(ws, "demo");
let err = read_skill_resource(ws, "demo", Path::new("/etc/passwd"))
.expect_err("absolute path must be rejected");
let absolute = if cfg!(windows) {
Path::new("C:\\Windows\\System32\\drivers\\etc\\hosts")
} else {
Path::new("/etc/passwd")
};
let err =
read_skill_resource(ws, "demo", absolute).expect_err("absolute path must be rejected");
assert!(
err.to_lowercase().contains("absolute"),
"unexpected error: {err}"
@@ -284,6 +284,9 @@ mod tests {
#[test]
fn screenshot_command_contains_output_path() {
if !matches!(std::env::consts::OS, "macos" | "linux") {
return;
}
let cmd = ScreenshotTool::screenshot_command("/tmp/my_screenshot.png").unwrap();
let joined = cmd.join(" ");
assert!(
@@ -352,7 +355,12 @@ mod tests {
#[tokio::test]
async fn screenshot_rejects_all_unsafe_chars() {
let tool = ScreenshotTool::new(test_security());
for ch in ['\'', '"', '`', '$', '\\', ';', '|', '&', '(', ')'] {
// Backslash is a path separator on Windows, not a shell-injection risk there.
let mut chars = vec!['\'', '"', '`', '$', ';', '|', '&', '(', ')'];
if matches!(std::env::consts::OS, "macos" | "linux") {
chars.push('\\');
}
for ch in chars {
let filename = format!("test{ch}name.png");
let result = tool
.execute(serde_json::json!({"filename": filename}))
+19 -2
View File
@@ -114,10 +114,27 @@ mod tests {
let tool = CronRunTool::new(cfg.clone());
let result = tool.execute(json!({ "job_id": job.id })).await.unwrap();
assert!(!result.is_error, "{:?}", result.output());
if cfg!(windows) {
// `echo` may not be available as a standalone executable on Windows;
// verify the expected failure mode without short-circuiting the test.
assert!(result.is_error);
assert!(
result.output().contains("spawn error"),
"{:?}",
result.output()
);
} else {
assert!(!result.is_error, "{:?}", result.output());
}
// History persistence should be verified on all platforms.
let runs = cron::list_runs(&cfg, &job.id, 10).unwrap();
assert_eq!(runs.len(), 1);
if cfg!(windows) {
// On Windows the job fails to spawn, so no run record is expected.
assert_eq!(runs.len(), 0);
} else {
assert_eq!(runs.len(), 1);
}
}
#[tokio::test]
+12 -3
View File
@@ -361,6 +361,9 @@ mod tests {
use super::*;
use crate::openhuman::security::SecurityPolicy;
use tempfile::TempDir;
fn slash_norm(s: String) -> String {
s.replace('\\', "/")
}
fn tool(tmp: &TempDir, allow: Vec<&str>) -> CurlTool {
CurlTool::new(
@@ -375,14 +378,17 @@ mod tests {
#[test]
fn sanitize_dest_subdir_strips_absolute_paths() {
assert_eq!(sanitize_dest_subdir("/etc/passwd"), "etc/passwd");
assert_eq!(
slash_norm(sanitize_dest_subdir("/etc/passwd")),
"etc/passwd"
);
assert_eq!(sanitize_dest_subdir("//foo"), "foo");
}
#[test]
fn sanitize_dest_subdir_strips_parent_segments() {
assert_eq!(sanitize_dest_subdir("../../etc"), "etc");
assert_eq!(sanitize_dest_subdir("a/../b"), "a/b");
assert_eq!(slash_norm(sanitize_dest_subdir("a/../b")), "a/b");
}
#[test]
@@ -396,7 +402,10 @@ mod tests {
#[test]
fn sanitize_dest_subdir_keeps_normal_paths() {
assert_eq!(sanitize_dest_subdir("downloads"), "downloads");
assert_eq!(sanitize_dest_subdir("artifacts/build"), "artifacts/build");
assert_eq!(
slash_norm(sanitize_dest_subdir("artifacts/build")),
"artifacts/build"
);
}
#[test]
+8 -1
View File
@@ -293,6 +293,13 @@ fn resolve_script_path(
#[cfg(test)]
mod tests {
use super::*;
fn absolute_sample() -> &'static str {
if cfg!(windows) {
"C:\\Windows\\System32\\drivers\\etc\\hosts"
} else {
"/etc/passwd"
}
}
#[test]
fn shell_quote_wraps_plain_strings() {
@@ -326,7 +333,7 @@ mod tests {
#[test]
fn resolve_script_path_rejects_absolute() {
let ws = std::path::Path::new("/ws");
assert!(resolve_script_path(ws, "/etc/passwd").is_err());
assert!(resolve_script_path(ws, absolute_sample()).is_err());
}
#[test]
+8 -1
View File
@@ -315,6 +315,13 @@ fn resolve_cwd(
#[cfg(test)]
mod tests {
use super::*;
fn absolute_sample() -> &'static str {
if cfg!(windows) {
"C:\\Windows\\System32"
} else {
"/etc"
}
}
#[test]
fn is_sane_subcommand_accepts_common_npm_verbs() {
@@ -349,7 +356,7 @@ mod tests {
#[test]
fn resolve_cwd_rejects_absolute_and_parent() {
let ws = std::path::Path::new("/tmp/ws");
assert!(resolve_cwd(ws, Some("/etc")).is_err());
assert!(resolve_cwd(ws, Some(absolute_sample())).is_err());
assert!(resolve_cwd(ws, Some("../other")).is_err());
assert!(resolve_cwd(ws, Some("sub/../../../etc")).is_err());
}
+26 -15
View File
@@ -236,6 +236,7 @@ mod tests {
assert!(schema["properties"]["approved"].is_object());
}
#[cfg(not(windows))]
#[tokio::test]
async fn shell_executes_allowed_command() {
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised), test_runtime());
@@ -243,7 +244,7 @@ mod tests {
.execute(json!({"command": "echo hello"}))
.await
.unwrap();
assert!(!result.is_error);
assert!(!result.is_error, "{}", result.output());
assert!(result.output().trim().contains("hello"));
assert!(!result.is_error);
}
@@ -294,7 +295,7 @@ mod tests {
Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Supervised,
workspace_dir: std::env::temp_dir(),
allowed_commands: vec!["env".into(), "echo".into()],
allowed_commands: vec!["env".into(), "echo".into(), "set".into(), "mkdir".into()],
..SecurityPolicy::default()
})
}
@@ -323,19 +324,22 @@ mod tests {
}
}
#[cfg(not(windows))]
#[tokio::test(flavor = "current_thread")]
async fn shell_does_not_leak_api_key() {
let _g1 = EnvGuard::set("API_KEY", "sk-test-secret-12345");
let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime());
let result = tool.execute(json!({"command": "env"})).await.unwrap();
assert!(!result.is_error);
let cmd = if cfg!(windows) { "set" } else { "env" };
let result = tool.execute(json!({"command": cmd})).await.unwrap();
assert!(!result.is_error, "{}", result.output());
assert!(
!result.output().contains("sk-test-secret-12345"),
"API_KEY leaked to shell command output"
);
}
#[cfg(not(windows))]
#[tokio::test]
async fn shell_preserves_path_and_home() {
let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime());
@@ -344,7 +348,7 @@ mod tests {
.execute(json!({"command": "echo $HOME"}))
.await
.unwrap();
assert!(!result.is_error);
assert!(!result.is_error, "{}", result.output());
assert!(
!result.output().trim().is_empty(),
"HOME should be available in shell"
@@ -354,41 +358,48 @@ mod tests {
.execute(json!({"command": "echo $PATH"}))
.await
.unwrap();
assert!(!result.is_error);
assert!(!result.is_error, "{}", result.output());
assert!(
!result.output().trim().is_empty(),
"PATH should be available in shell"
);
}
#[cfg(not(windows))]
#[tokio::test]
async fn shell_requires_approval_for_medium_risk_command() {
let security = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Supervised,
allowed_commands: vec!["touch".into()],
allowed_commands: vec!["touch".into(), "mkdir".into()],
workspace_dir: std::env::temp_dir(),
..SecurityPolicy::default()
});
let tool = ShellTool::new(security.clone(), test_runtime());
let denied = tool
.execute(json!({"command": "touch openhuman_shell_approval_test"}))
.await
.unwrap();
let command = if cfg!(windows) {
"mkdir openhuman_shell_approval_test"
} else {
"touch openhuman_shell_approval_test"
};
let denied = tool.execute(json!({"command": command})).await.unwrap();
assert!(denied.is_error);
assert!(denied.output().contains("explicit approval"));
let allowed = tool
.execute(json!({
"command": "touch openhuman_shell_approval_test",
"command": command,
"approved": true
}))
.await
.unwrap();
assert!(!allowed.is_error);
assert!(!allowed.is_error, "{}", allowed.output());
let _ = tokio::fs::remove_file(std::env::temp_dir().join("openhuman_shell_approval_test"))
.await;
let cleanup = std::env::temp_dir().join("openhuman_shell_approval_test");
if cfg!(windows) {
let _ = tokio::fs::remove_dir_all(&cleanup).await;
} else {
let _ = tokio::fs::remove_file(&cleanup).await;
}
}
// ── §5.2 Shell timeout enforcement tests ─────────────────
+13 -1
View File
@@ -43,7 +43,7 @@ fn env_lock() -> std::sync::MutexGuard<'static, ()> {
ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock poisoned")
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
// ── Tests ────────────────────────────────────────────────────────────
@@ -54,6 +54,9 @@ async fn accepted_completions_stored_and_retrievable() {
let _lock = env_lock();
let tmp = tempdir().expect("tempdir");
let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
if let Err(e) = history::clear_history().await {
eprintln!("[test] best-effort clear_history failed: {e}");
}
// Write three completions with different contexts.
history::save_accepted_completion("fn main() { let x =", "42;", Some("VSCode")).await;
@@ -103,6 +106,9 @@ async fn completions_improve_future_suggestions_via_merge() {
let _lock = env_lock();
let tmp = tempdir().expect("tempdir");
let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
if let Err(e) = history::clear_history().await {
eprintln!("[test] best-effort clear_history failed: {e}");
}
// Populate with several completions.
for i in 0..5 {
@@ -153,6 +159,9 @@ async fn clear_history_removes_kv_and_docs() {
let _lock = env_lock();
let tmp = tempdir().expect("tempdir");
let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
if let Err(e) = history::clear_history().await {
eprintln!("[test] best-effort clear_history failed: {e}");
}
// Insert completions into both layers.
for i in 0..3 {
@@ -194,6 +203,9 @@ async fn kv_history_trims_beyond_max() {
let _lock = env_lock();
let tmp = tempdir().expect("tempdir");
let _home = EnvVarGuard::set_to_path("HOME", tmp.path());
if let Err(e) = history::clear_history().await {
eprintln!("[test] best-effort clear_history failed: {e}");
}
// Insert 55 completions (MAX_HISTORY_ENTRIES = 50).
for i in 0..55 {