mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -73,3 +73,4 @@ workflow
|
||||
overlay/src-tauri/target/
|
||||
.claude/*.lock
|
||||
app/.claude/scheduled_tasks.lock
|
||||
target-test-run
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user