From bf1d7ac9287856b025e1d0191e09b77475c123f2 Mon Sep 17 00:00:00 2001 From: Chummy Date: Tue, 24 Feb 2026 13:04:29 +0000 Subject: [PATCH] supersede: file-replay changes from #1317 Automated conflict recovery via changed-file replay on latest dev. --- Dockerfile | 13 +- scripts/bootstrap.sh | 652 +++++++++++++++++++++++++-- src/onboard/wizard.rs | 923 +++++++++++++++++++++++++------------- src/tools/browser_open.rs | 120 ++--- src/tools/http_request.rs | 57 +-- 5 files changed, 1302 insertions(+), 463 deletions(-) diff --git a/Dockerfile b/Dockerfile index d9ced9042..ed25483e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ FROM rust:1.93-slim@sha256:9663b80a1621253d30b146454f903de48f0af925c967be48c84745537cd35d8b AS builder WORKDIR /app +ARG ZEROCLAW_CARGO_FEATURES="" # Install build dependencies RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ @@ -23,7 +24,11 @@ RUN mkdir -p src benches crates/robot-kit/src \ RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ --mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \ --mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \ - cargo build --release --locked + if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \ + cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \ + else \ + cargo build --release --locked; \ + fi RUN rm -rf src benches crates/robot-kit/src # 2. Copy only build-relevant source paths (avoid cache-busting on docs/tests/scripts) @@ -52,7 +57,11 @@ RUN mkdir -p web/dist && \ RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ --mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \ --mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \ - cargo build --release --locked && \ + if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \ + cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \ + else \ + cargo build --release --locked; \ + fi && \ cp target/release/zeroclaw /app/zeroclaw && \ strip /app/zeroclaw diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index a1b5b924d..8af9ff139 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -30,6 +30,10 @@ Options: --guided Run interactive guided installer --no-guided Disable guided installer --docker Run bootstrap in Docker-compatible mode and launch onboarding inside the container + --docker-reset Reset existing ZeroClaw Docker containers/networks/volumes and data dir before --docker bootstrap + --docker-config Seed Docker config.toml from host path (skips default onboarding unless explicitly requested) + --docker-secret-key Seed Docker .secret_key from host path (used with --docker-config encrypted secrets) + --docker-daemon Start persistent Docker daemon container directly (requires --docker) --install-system-deps Install build dependencies (Linux/macOS) --install-rust Install Rust via rustup if missing --prefer-prebuilt Try latest release binary first; fallback to source build on miss @@ -53,6 +57,7 @@ Examples: ./zeroclaw_install.sh --prebuilt-only ./zeroclaw_install.sh --onboard --api-key "sk-..." --provider openrouter [--model "openrouter/auto"] ./zeroclaw_install.sh --interactive-onboard + ./zeroclaw_install.sh --docker --docker-config ./config.toml --docker-daemon # Compatibility entrypoint: ./bootstrap.sh --docker @@ -64,6 +69,23 @@ Environment: ZEROCLAW_CONTAINER_CLI Container CLI command (default: docker; auto-fallback: podman) ZEROCLAW_DOCKER_DATA_DIR Host path for Docker config/workspace persistence ZEROCLAW_DOCKER_IMAGE Docker image tag to build/run (default: zeroclaw-bootstrap:local) + ZEROCLAW_DOCKER_BROWSER_RUNTIME + Browser runtime provisioning mode for --docker: "auto" (prompt), "on", or "off" + ZEROCLAW_DOCKER_BROWSER_SIDECAR_IMAGE + Browser WebDriver sidecar image (default: selenium/standalone-chromium:latest) + ZEROCLAW_DOCKER_BROWSER_SIDECAR_NAME + Browser WebDriver sidecar container name (default: zeroclaw-browser-webdriver) + ZEROCLAW_DOCKER_NETWORK Docker network for ZeroClaw + sidecars (default: zeroclaw-bootstrap-net) + ZEROCLAW_DOCKER_CARGO_FEATURES + Extra Cargo features for Docker builds (comma-separated) + ZEROCLAW_DOCKER_DAEMON_NAME + Daemon container name for --docker-daemon (default: zeroclaw-daemon) + ZEROCLAW_DOCKER_DAEMON_BIND_HOST + Host bind address for daemon port publish (default: 127.0.0.1) + ZEROCLAW_DOCKER_DAEMON_HOST_PORT + Host port to publish daemon gateway (default: same as gateway.port) + ZEROCLAW_DOCKER_SECRET_KEY_FILE + Host path to .secret_key used when seeding encrypted config.toml ZEROCLAW_API_KEY Used when --api-key is not provided ZEROCLAW_PROVIDER Used when --provider is not provided (default: openrouter) ZEROCLAW_MODEL Used when --model is not provided @@ -313,6 +335,22 @@ bool_to_word() { fi } +string_to_bool() { + local value + value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" + case "$value" in + 1|true|yes|on) + echo "true" + ;; + 0|false|no|off) + echo "false" + ;; + *) + echo "invalid" + ;; + esac +} + guided_input_stream() { if [[ -t 0 ]]; then echo "/dev/stdin" @@ -624,12 +662,323 @@ ensure_docker_ready() { fi } +is_zeroclaw_container() { + local name="$1" + local image="$2" + local command="$3" + local name_lc image_lc command_lc + + name_lc="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" + image_lc="$(printf '%s' "$image" | tr '[:upper:]' '[:lower:]')" + command_lc="$(printf '%s' "$command" | tr '[:upper:]' '[:lower:]')" + + [[ "$name_lc" == *"zeroclaw"* || "$image_lc" == *"zeroclaw"* || "$command_lc" == *"zeroclaw"* ]] +} + +is_zeroclaw_resource_name() { + local name="$1" + local name_lc + name_lc="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" + [[ "$name_lc" == *"zeroclaw"* ]] +} + +maybe_stop_running_zeroclaw_containers() { + local -a running_ids running_rows + local id name image command row + + while IFS=$'\t' read -r id name image command; do + if [[ -z "$id" ]]; then + continue + fi + if is_zeroclaw_container "$name" "$image" "$command"; then + running_ids+=("$id") + running_rows+=("$id"$'\t'"$name"$'\t'"$image"$'\t'"$command") + fi + done < <("$CONTAINER_CLI" ps --format '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Command}}') + + if [[ ${#running_ids[@]} -eq 0 ]]; then + return 0 + fi + + warn "Detected running ZeroClaw container(s):" + for row in "${running_rows[@]}"; do + IFS=$'\t' read -r id name image command <<<"$row" + echo " - $name ($id) image=$image cmd=$command" + done + + if ! guided_input_stream >/dev/null 2>&1; then + warn "Non-interactive mode detected; leaving existing ZeroClaw containers running." + return 0 + fi + + if prompt_yes_no "Stop these running ZeroClaw containers before continuing?" "yes"; then + info "Stopping ${#running_ids[@]} ZeroClaw container(s)" + "$CONTAINER_CLI" stop "${running_ids[@]}" >/dev/null + else + warn "Continuing with existing ZeroClaw containers still running." + fi +} + +reset_docker_zeroclaw_resources() { + local docker_data_dir="$1" + local -a container_ids container_rows network_names volume_names + local id name image command row resource_name + + container_ids=() + container_rows=() + network_names=() + volume_names=() + + info "Resetting ZeroClaw Docker resources" + + while IFS=$'\t' read -r id name image command; do + if [[ -z "$id" ]]; then + continue + fi + if is_zeroclaw_container "$name" "$image" "$command"; then + container_ids+=("$id") + container_rows+=("$id"$'\t'"$name"$'\t'"$image"$'\t'"$command") + fi + done < <("$CONTAINER_CLI" ps -a --format '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Command}}') + + if [[ ${#container_ids[@]} -gt 0 ]]; then + info "Removing ${#container_ids[@]} ZeroClaw container(s)" + for row in "${container_rows[@]}"; do + IFS=$'\t' read -r id name image command <<<"$row" + echo " - $name ($id) image=$image cmd=$command" + done + "$CONTAINER_CLI" rm -f "${container_ids[@]}" >/dev/null + else + info "No existing ZeroClaw containers found" + fi + + while IFS= read -r resource_name; do + if [[ -z "$resource_name" ]]; then + continue + fi + if is_zeroclaw_resource_name "$resource_name"; then + network_names+=("$resource_name") + fi + done < <("$CONTAINER_CLI" network ls --format '{{.Name}}') + + if [[ ${#network_names[@]} -gt 0 ]]; then + info "Removing ${#network_names[@]} ZeroClaw network(s)" + for resource_name in "${network_names[@]}"; do + echo " - $resource_name" + if ! "$CONTAINER_CLI" network rm "$resource_name" >/dev/null 2>&1; then + warn "Could not remove network '$resource_name' (it may still be in use)." + fi + done + else + info "No existing ZeroClaw networks found" + fi + + while IFS= read -r resource_name; do + if [[ -z "$resource_name" ]]; then + continue + fi + if is_zeroclaw_resource_name "$resource_name"; then + volume_names+=("$resource_name") + fi + done < <("$CONTAINER_CLI" volume ls --format '{{.Name}}') + + if [[ ${#volume_names[@]} -gt 0 ]]; then + info "Removing ${#volume_names[@]} ZeroClaw volume(s)" + for resource_name in "${volume_names[@]}"; do + echo " - $resource_name" + if ! "$CONTAINER_CLI" volume rm "$resource_name" >/dev/null 2>&1; then + warn "Could not remove volume '$resource_name' (it may still be in use)." + fi + done + else + info "No existing ZeroClaw volumes found" + fi + + if [[ -d "$docker_data_dir" ]]; then + info "Removing Docker data directory ($docker_data_dir)" + rm -rf "$docker_data_dir" + else + info "No Docker data directory to remove ($docker_data_dir)" + fi +} + +ensure_docker_network() { + local network_name="$1" + if "$CONTAINER_CLI" network inspect "$network_name" >/dev/null 2>&1; then + return 0 + fi + info "Creating Docker network ($network_name)" + "$CONTAINER_CLI" network create "$network_name" >/dev/null +} + +toml_section_value() { + local file_path="$1" + local section_name="$2" + local key_name="$3" + awk -v target_section="[$section_name]" -v target_key="$key_name" ' + function trim(s) { + sub(/^[[:space:]]+/, "", s); + sub(/[[:space:]]+$/, "", s); + return s; + } + { + line = $0; + sub(/[[:space:]]*#.*/, "", line); + line = trim(line); + if (line == "") { + next; + } + if (line ~ /^\[[^]]+\]$/) { + section = line; + next; + } + if (section != target_section) { + next; + } + + split_pos = index(line, "="); + if (split_pos == 0) { + next; + } + key = trim(substr(line, 1, split_pos - 1)); + if (key != target_key) { + next; + } + value = trim(substr(line, split_pos + 1)); + print value; + exit; + } + ' "$file_path" +} + +strip_toml_quotes() { + local value="$1" + value="$(printf '%s' "$value" | tr -d '\r')" + if [[ "$value" == \"*\" ]]; then + value="${value#\"}" + value="${value%\"}" + fi + printf '%s' "$value" +} + +config_requests_webdriver_sidecar() { + local config_path="$1" + local enabled_raw backend_raw enabled backend + + enabled_raw="$(toml_section_value "$config_path" "browser" "enabled" || true)" + backend_raw="$(toml_section_value "$config_path" "browser" "backend" || true)" + + enabled="$(printf '%s' "$enabled_raw" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')" + backend="$(printf '%s' "$backend_raw" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')" + backend="$(strip_toml_quotes "$backend")" + + if [[ "$enabled" != "true" ]]; then + return 1 + fi + + case "$backend" in + rust_native|auto) + return 0 + ;; + *) + return 1 + ;; + esac +} + +config_gateway_port() { + local config_path="$1" + local raw port + raw="$(toml_section_value "$config_path" "gateway" "port" || true)" + port="$(printf '%s' "$raw" | tr -cd '0-9')" + if [[ "$port" =~ ^[0-9]+$ ]] && ((port >= 1 && port <= 65535)); then + printf '%s' "$port" + fi +} + +config_has_encrypted_secrets() { + local config_path="$1" + grep -Eq "enc2:|enc:" "$config_path" +} + +seed_docker_secret_key_for_config() { + local source_config_path="$1" + local target_config_dir="$2" + local source_secret_key_override="${3:-}" + local source_config_dir source_secret_key target_secret_key + + source_config_dir="$(dirname "$source_config_path")" + if [[ -n "$source_secret_key_override" ]]; then + source_secret_key="$source_secret_key_override" + else + source_secret_key="$source_config_dir/.secret_key" + fi + target_secret_key="$target_config_dir/.secret_key" + + if [[ -f "$source_secret_key" ]]; then + info "Importing secret key from $source_secret_key" + install -m 600 "$source_secret_key" "$target_secret_key" + return 0 + fi + + if config_has_encrypted_secrets "$source_config_path"; then + error "Encrypted secrets detected in $source_config_path, but key file was not found at:" + error " $source_secret_key" + error "Provide the matching .secret_key next to config.toml (or via --docker-secret-key), or decrypt/remove encrypted values before bootstrap." + exit 1 + fi +} + +ensure_browser_webdriver_sidecar() { + local sidecar_name="$1" + local sidecar_image="$2" + local network_name="$3" + + if "$CONTAINER_CLI" ps --format '{{.Names}}' | grep -Fxq "$sidecar_name"; then + info "Browser WebDriver sidecar already running ($sidecar_name)" + return 0 + fi + + if "$CONTAINER_CLI" ps -a --format '{{.Names}}' | grep -Fxq "$sidecar_name"; then + info "Starting existing browser WebDriver sidecar ($sidecar_name)" + "$CONTAINER_CLI" start "$sidecar_name" >/dev/null + return 0 + fi + + info "Starting browser WebDriver sidecar ($sidecar_name)" + "$CONTAINER_CLI" run -d \ + --name "$sidecar_name" \ + --network "$network_name" \ + --shm-size=2g \ + "$sidecar_image" >/dev/null +} + run_docker_bootstrap() { local docker_image docker_data_dir default_data_dir fallback_image + local seed_config_path + local seed_secret_key_path local config_mount workspace_mount + local docker_build_features docker_browser_runtime_mode docker_browser_runtime_bool + local docker_browser_sidecar_name docker_browser_sidecar_image docker_network + local container_network_name docker_browser_webdriver_url + local docker_daemon_name docker_daemon_bind_host docker_daemon_host_port docker_daemon_port + local config_gateway_port_value local -a container_run_user_args container_run_namespace_args + local -a container_extra_run_args container_extra_env_args docker_build_args daemon_cmd docker_image="${ZEROCLAW_DOCKER_IMAGE:-zeroclaw-bootstrap:local}" fallback_image="ghcr.io/zeroclaw-labs/zeroclaw:latest" + docker_build_features="${ZEROCLAW_DOCKER_CARGO_FEATURES:-}" + docker_browser_runtime_mode="${ZEROCLAW_DOCKER_BROWSER_RUNTIME:-auto}" + docker_browser_sidecar_name="${ZEROCLAW_DOCKER_BROWSER_SIDECAR_NAME:-zeroclaw-browser-webdriver}" + docker_browser_sidecar_image="${ZEROCLAW_DOCKER_BROWSER_SIDECAR_IMAGE:-selenium/standalone-chromium:latest}" + docker_network="${ZEROCLAW_DOCKER_NETWORK:-zeroclaw-bootstrap-net}" + docker_daemon_name="${ZEROCLAW_DOCKER_DAEMON_NAME:-zeroclaw-daemon}" + docker_daemon_bind_host="${ZEROCLAW_DOCKER_DAEMON_BIND_HOST:-127.0.0.1}" + docker_daemon_host_port="${ZEROCLAW_DOCKER_DAEMON_HOST_PORT:-}" + seed_config_path="${DOCKER_CONFIG_FILE:-}" + seed_secret_key_path="${DOCKER_SECRET_KEY_FILE:-${ZEROCLAW_DOCKER_SECRET_KEY_FILE:-}}" + container_network_name="" + docker_browser_webdriver_url="" if [[ "$TEMP_CLONE" == true ]]; then default_data_dir="$HOME/.zeroclaw-docker" else @@ -638,15 +987,85 @@ run_docker_bootstrap() { docker_data_dir="${ZEROCLAW_DOCKER_DATA_DIR:-$default_data_dir}" DOCKER_DATA_DIR="$docker_data_dir" + if [[ "$DOCKER_RESET" == true ]]; then + reset_docker_zeroclaw_resources "$docker_data_dir" + fi + mkdir -p "$docker_data_dir/.zeroclaw" "$docker_data_dir/workspace" + if [[ -n "$seed_config_path" ]]; then + if [[ ! -f "$seed_config_path" ]]; then + error "--docker-config file was not found: $seed_config_path" + exit 1 + fi + info "Seeding Docker config from $seed_config_path" + install -m 600 "$seed_config_path" "$docker_data_dir/.zeroclaw/config.toml" + seed_docker_secret_key_for_config "$seed_config_path" "$docker_data_dir/.zeroclaw" "$seed_secret_key_path" + fi + if [[ "$SKIP_INSTALL" == true ]]; then warn "--skip-install has no effect with --docker." fi + maybe_stop_running_zeroclaw_containers + + docker_browser_runtime_bool="false" + case "$(printf '%s' "$docker_browser_runtime_mode" | tr '[:upper:]' '[:lower:]')" in + ""|auto) + if [[ -n "$seed_config_path" ]]; then + if config_requests_webdriver_sidecar "$seed_config_path"; then + docker_browser_runtime_bool="true" + info "Browser WebDriver sidecar enabled from seeded config ([browser] backend=rust_native/auto)." + else + docker_browser_runtime_bool="false" + info "Browser WebDriver sidecar disabled from seeded config." + fi + elif guided_input_stream >/dev/null 2>&1; then + echo + if prompt_yes_no "Provision browser WebDriver sidecar for Docker bootstrap?" "yes"; then + docker_browser_runtime_bool="true" + fi + fi + ;; + *) + docker_browser_runtime_bool="$(string_to_bool "$docker_browser_runtime_mode")" + if [[ "$docker_browser_runtime_bool" == "invalid" ]]; then + warn "Invalid ZEROCLAW_DOCKER_BROWSER_RUNTIME='$docker_browser_runtime_mode' (expected auto/on/off). Defaulting to off." + docker_browser_runtime_bool="false" + fi + ;; + esac + + if [[ "$docker_browser_runtime_bool" == "true" ]]; then + if [[ ",${docker_build_features// /,}," != *,browser-native,* ]]; then + if [[ -n "$docker_build_features" ]]; then + docker_build_features+=",browser-native" + else + docker_build_features="browser-native" + fi + fi + ensure_docker_network "$docker_network" + ensure_browser_webdriver_sidecar \ + "$docker_browser_sidecar_name" \ + "$docker_browser_sidecar_image" \ + "$docker_network" + container_network_name="$docker_network" + docker_browser_webdriver_url="http://${docker_browser_sidecar_name}:4444" + info "Browser runtime sidecar: $docker_browser_sidecar_name ($docker_browser_webdriver_url)" + if [[ "$SKIP_BUILD" == true ]]; then + warn "--skip-build enabled: existing image must already include browser-native feature for rust_native backend." + fi + fi + if [[ "$SKIP_BUILD" == false ]]; then info "Building Docker image ($docker_image)" - "$CONTAINER_CLI" build --target release -t "$docker_image" "$WORK_DIR" + docker_build_args=(build --target release -t "$docker_image") + if [[ -n "$docker_build_features" ]]; then + info "Docker build features: $docker_build_features" + docker_build_args+=(--build-arg "ZEROCLAW_CARGO_FEATURES=$docker_build_features") + fi + docker_build_args+=("$WORK_DIR") + "$CONTAINER_CLI" "${docker_build_args[@]}" else info "Skipping Docker image build" if ! "$CONTAINER_CLI" image inspect "$docker_image" >/dev/null 2>&1; then @@ -676,16 +1095,74 @@ run_docker_bootstrap() { container_run_user_args=(--user "$(id -u):$(id -g)") fi + container_extra_run_args=() + container_extra_env_args=() + if [[ -n "$container_network_name" ]]; then + container_extra_run_args+=(--network "$container_network_name") + fi + if [[ -n "$docker_browser_webdriver_url" ]]; then + container_extra_env_args+=(-e "ZEROCLAW_DOCKER_WEBDRIVER_URL=$docker_browser_webdriver_url") + fi + info "Docker data directory: $docker_data_dir" info "Container CLI: $CONTAINER_CLI" - local onboard_cmd=() - if [[ "$INTERACTIVE_ONBOARD" == true ]]; then - info "Launching interactive onboarding in container" - onboard_cmd=(onboard --interactive) - else - if [[ -z "$API_KEY" ]]; then - cat <<'MSG' + if [[ "$DOCKER_DAEMON_MODE" == true ]]; then + if "$CONTAINER_CLI" ps -a --format '{{.Names}}' | grep -Fxq "$docker_daemon_name"; then + error "Daemon container '$docker_daemon_name' already exists." + error "Use --docker-reset, or remove it manually: $CONTAINER_CLI rm -f $docker_daemon_name" + exit 1 + fi + + config_gateway_port_value="" + if [[ -f "$docker_data_dir/.zeroclaw/config.toml" ]]; then + config_gateway_port_value="$(config_gateway_port "$docker_data_dir/.zeroclaw/config.toml" || true)" + fi + docker_daemon_port="${config_gateway_port_value:-42617}" + if [[ -z "$docker_daemon_host_port" ]]; then + docker_daemon_host_port="$docker_daemon_port" + fi + + daemon_cmd=(run -d --name "$docker_daemon_name" --restart unless-stopped) + if [[ "$CONTAINER_CLI" == "podman" ]]; then + daemon_cmd+=("${container_run_namespace_args[@]}") + fi + daemon_cmd+=("${container_run_user_args[@]}") + if [[ ${#container_extra_run_args[@]} -gt 0 ]]; then + daemon_cmd+=("${container_extra_run_args[@]}") + fi + daemon_cmd+=( + -p "${docker_daemon_bind_host}:${docker_daemon_host_port}:${docker_daemon_port}" + -e HOME=/zeroclaw-data + -e ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace + -e ZEROCLAW_DOCKER_BOOTSTRAP=1 + ) + if [[ ${#container_extra_env_args[@]} -gt 0 ]]; then + daemon_cmd+=("${container_extra_env_args[@]}") + fi + daemon_cmd+=( + -v "$config_mount" + -v "$workspace_mount" + "$docker_image" + daemon + --port "$docker_daemon_port" + ) + + info "Starting daemon container ($docker_daemon_name)" + "$CONTAINER_CLI" "${daemon_cmd[@]}" >/dev/null + info "Daemon running: $docker_daemon_name (gateway: http://${docker_daemon_bind_host}:${docker_daemon_host_port})" + info "Follow logs: $CONTAINER_CLI logs -f $docker_daemon_name" + return 0 + fi + + if [[ "$RUN_ONBOARD" == true ]]; then + local onboard_cmd=() + if [[ "$INTERACTIVE_ONBOARD" == true ]]; then + info "Launching interactive onboarding in container" + onboard_cmd=(onboard --interactive) + else + if [[ -z "$API_KEY" ]]; then + cat <<'MSG' ==> Onboarding requested, but API key not provided. Use either: --api-key "sk-..." @@ -694,28 +1171,48 @@ or: or run interactive: ./zeroclaw_install.sh --docker --interactive-onboard MSG - exit 1 + exit 1 + fi + if [[ -n "$MODEL" ]]; then + info "Launching quick onboarding in container (provider: $PROVIDER, model: $MODEL)" + else + info "Launching quick onboarding in container (provider: $PROVIDER)" + fi + onboard_cmd=(onboard --api-key "$API_KEY" --provider "$PROVIDER") + if [[ -n "$MODEL" ]]; then + onboard_cmd+=(--model "$MODEL") + fi fi - if [[ -n "$MODEL" ]]; then - info "Launching quick onboarding in container (provider: $PROVIDER, model: $MODEL)" - else - info "Launching quick onboarding in container (provider: $PROVIDER)" - fi - onboard_cmd=(onboard --api-key "$API_KEY" --provider "$PROVIDER") - if [[ -n "$MODEL" ]]; then - onboard_cmd+=(--model "$MODEL") - fi - fi - "$CONTAINER_CLI" run --rm -it \ - "${container_run_namespace_args[@]}" \ - "${container_run_user_args[@]}" \ - -e HOME=/zeroclaw-data \ - -e ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace \ - -v "$config_mount" \ - -v "$workspace_mount" \ - "$docker_image" \ - "${onboard_cmd[@]}" + if [[ "$CONTAINER_CLI" == "podman" ]]; then + "$CONTAINER_CLI" run --rm -it \ + "${container_run_namespace_args[@]}" \ + "${container_run_user_args[@]}" \ + "${container_extra_run_args[@]+${container_extra_run_args[@]}}" \ + -e HOME=/zeroclaw-data \ + -e ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace \ + -e ZEROCLAW_DOCKER_BOOTSTRAP=1 \ + "${container_extra_env_args[@]+${container_extra_env_args[@]}}" \ + -v "$config_mount" \ + -v "$workspace_mount" \ + "$docker_image" \ + "${onboard_cmd[@]}" + else + "$CONTAINER_CLI" run --rm -it \ + "${container_run_user_args[@]}" \ + "${container_extra_run_args[@]+${container_extra_run_args[@]}}" \ + -e HOME=/zeroclaw-data \ + -e ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace \ + -e ZEROCLAW_DOCKER_BOOTSTRAP=1 \ + "${container_extra_env_args[@]+${container_extra_env_args[@]}}" \ + -v "$config_mount" \ + -v "$workspace_mount" \ + "$docker_image" \ + "${onboard_cmd[@]}" + fi + else + info "Skipping onboarding container run (--onboard not requested)." + fi } SCRIPT_PATH="${BASH_SOURCE[0]:-$0}" @@ -726,6 +1223,10 @@ ORIGINAL_ARG_COUNT=$# GUIDED_MODE="auto" DOCKER_MODE=false +DOCKER_RESET=false +DOCKER_DAEMON_MODE=false +DOCKER_CONFIG_FILE="" +DOCKER_SECRET_KEY_FILE="" INSTALL_SYSTEM_DEPS=false INSTALL_RUST=false PREFER_PREBUILT=false @@ -755,6 +1256,30 @@ while [[ $# -gt 0 ]]; do DOCKER_MODE=true shift ;; + --docker-reset) + DOCKER_RESET=true + shift + ;; + --docker-config) + DOCKER_CONFIG_FILE="${2:-}" + [[ -n "$DOCKER_CONFIG_FILE" ]] || { + error "--docker-config requires a value" + exit 1 + } + shift 2 + ;; + --docker-secret-key) + DOCKER_SECRET_KEY_FILE="${2:-}" + [[ -n "$DOCKER_SECRET_KEY_FILE" ]] || { + error "--docker-secret-key requires a value" + exit 1 + } + shift 2 + ;; + --docker-daemon) + DOCKER_DAEMON_MODE=true + shift + ;; --install-system-deps) INSTALL_SYSTEM_DEPS=true shift @@ -847,6 +1372,36 @@ if [[ "$DOCKER_MODE" == true && "$GUIDED_MODE" == "on" ]]; then GUIDED_MODE="off" fi +if [[ "$DOCKER_RESET" == true && "$DOCKER_MODE" == false ]]; then + error "--docker-reset requires --docker." + exit 1 +fi + +if [[ -n "$DOCKER_CONFIG_FILE" && "$DOCKER_MODE" == false ]]; then + error "--docker-config requires --docker." + exit 1 +fi + +if [[ -n "$DOCKER_SECRET_KEY_FILE" && "$DOCKER_MODE" == false ]]; then + error "--docker-secret-key requires --docker." + exit 1 +fi + +if [[ -n "$DOCKER_SECRET_KEY_FILE" && -z "$DOCKER_CONFIG_FILE" ]]; then + error "--docker-secret-key requires --docker-config." + exit 1 +fi + +if [[ "$DOCKER_DAEMON_MODE" == true && "$DOCKER_MODE" == false ]]; then + error "--docker-daemon requires --docker." + exit 1 +fi + +if [[ "$DOCKER_DAEMON_MODE" == true && "$RUN_ONBOARD" == true ]]; then + error "--docker-daemon cannot be combined with --onboard/--interactive-onboard." + exit 1 +fi + if [[ "$GUIDED_MODE" == "on" ]]; then run_guided_installer "$OS_NAME" fi @@ -929,25 +1484,46 @@ fi if [[ "$DOCKER_MODE" == true ]]; then ensure_docker_ready if [[ "$RUN_ONBOARD" == false ]]; then - RUN_ONBOARD=true - if [[ -z "$API_KEY" ]]; then - INTERACTIVE_ONBOARD=true + if [[ -n "$DOCKER_CONFIG_FILE" || "$DOCKER_DAEMON_MODE" == true ]]; then + RUN_ONBOARD=false + else + RUN_ONBOARD=true + if [[ -z "$API_KEY" ]]; then + INTERACTIVE_ONBOARD=true + fi fi fi run_docker_bootstrap - cat <<'DONE' - -✅ Docker bootstrap complete. - -Your containerized ZeroClaw data is persisted under: -DONE + echo + echo "✅ Docker bootstrap complete." + echo + echo "Your containerized ZeroClaw data is persisted under:" echo " $DOCKER_DATA_DIR" - cat <<'DONE' + echo + if [[ "$DOCKER_DAEMON_MODE" == true ]]; then + daemon_name="${ZEROCLAW_DOCKER_DAEMON_NAME:-zeroclaw-daemon}" + echo "Daemon mode is active; onboarding was intentionally skipped." + echo " container: $daemon_name" + echo " logs: $CONTAINER_CLI logs -f $daemon_name" + echo " stop: $CONTAINER_CLI rm -f $daemon_name" + echo + echo "Optional next steps:" + echo " ./zeroclaw_install.sh --docker --interactive-onboard" + elif [[ "$RUN_ONBOARD" == false ]]; then + echo "Onboarding was intentionally skipped (pre-seeded config mode)." + echo + echo "Next steps:" + echo " ./zeroclaw_install.sh --docker --docker-config ./config.toml --docker-daemon" + echo " ./zeroclaw_install.sh --docker --interactive-onboard" + else + cat <<'DONE' Next steps: ./zeroclaw_install.sh --docker --interactive-onboard ./zeroclaw_install.sh --docker --api-key "sk-..." --provider openrouter + ./zeroclaw_install.sh --docker --docker-config ./config.toml --docker-daemon DONE + fi exit 0 fi diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index cf04c337b..de6cf7e70 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -1,12 +1,12 @@ use crate::config::schema::{ default_nostr_relays, DingTalkConfig, IrcConfig, LarkReceiveMode, LinqConfig, - NextcloudTalkConfig, NostrConfig, QQConfig, QQReceiveMode, SignalConfig, StreamMode, - WhatsAppConfig, + NextcloudTalkConfig, NostrConfig, QQConfig, SignalConfig, StreamMode, WhatsAppConfig, }; use crate::config::{ AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, - HeartbeatConfig, IMessageConfig, LarkConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, - RuntimeConfig, SecretsConfig, SlackConfig, StorageConfig, TelegramConfig, WebhookConfig, + FeishuConfig, HeartbeatConfig, HttpRequestConfig, IMessageConfig, LarkConfig, MatrixConfig, + MemoryConfig, ObservabilityConfig, RuntimeConfig, SecretsConfig, SlackConfig, StorageConfig, + TelegramConfig, WebSearchConfig, WebhookConfig, }; use crate::hardware::{self, HardwareConfig}; use crate::memory::{ @@ -22,8 +22,9 @@ use console::style; use dialoguer::{Confirm, Input, Select}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::io::IsTerminal; +use std::net::{TcpStream, ToSocketAddrs}; use std::path::{Path, PathBuf}; use std::time::Duration; use tokio::fs; @@ -108,7 +109,8 @@ pub async fn run_wizard(force: bool) -> Result { let tunnel_config = setup_tunnel()?; print_step(5, 9, "Tool Mode & Security"); - let (composio_config, secrets_config) = setup_tool_mode()?; + let (composio_config, secrets_config, browser_config, http_request_config, web_search_config) = + setup_tool_mode()?; print_step(6, 9, "Hardware (Physical World)"); let hardware_config = setup_hardware()?; @@ -134,9 +136,7 @@ pub async fn run_wizard(force: bool) -> Result { }, api_url: provider_api_url, default_provider: Some(provider), - provider_api: None, default_model: Some(model), - model_providers: std::collections::HashMap::new(), default_temperature: 0.7, observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), @@ -157,11 +157,10 @@ pub async fn run_wizard(force: bool) -> Result { gateway: crate::config::GatewayConfig::default(), composio: composio_config, secrets: secrets_config, - browser: BrowserConfig::default(), - http_request: crate::config::HttpRequestConfig::default(), + browser: browser_config, + http_request: http_request_config, multimodal: crate::config::MultimodalConfig::default(), - web_fetch: crate::config::WebFetchConfig::default(), - web_search: crate::config::WebSearchConfig::default(), + web_search: web_search_config, proxy: crate::config::ProxyConfig::default(), identity: crate::config::IdentityConfig::default(), cost: crate::config::CostConfig::default(), @@ -390,7 +389,6 @@ fn memory_config_defaults_for_backend(backend: &str) -> MemoryConfig { snapshot_on_hygiene: false, auto_hydrate: true, sqlite_open_timeout_secs: None, - qdrant: crate::config::QdrantConfig::default(), } } @@ -486,9 +484,7 @@ async fn run_quick_setup_with_home( }), api_url: None, default_provider: Some(provider_name.clone()), - provider_api: None, default_model: Some(model.clone()), - model_providers: std::collections::HashMap::new(), default_temperature: 0.7, observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), @@ -512,7 +508,6 @@ async fn run_quick_setup_with_home( browser: BrowserConfig::default(), http_request: crate::config::HttpRequestConfig::default(), multimodal: crate::config::MultimodalConfig::default(), - web_fetch: crate::config::WebFetchConfig::default(), web_search: crate::config::WebSearchConfig::default(), proxy: crate::config::ProxyConfig::default(), identity: crate::config::IdentityConfig::default(), @@ -607,32 +602,10 @@ async fn run_quick_setup_with_home( println!(); println!(" {}", style("Next steps:").white().bold()); if credential_override.is_none() { - if provider_supports_keyless_local_usage(&provider_name) { - println!(" 1. Chat: zeroclaw agent -m \"Hello!\""); - println!(" 2. Gateway: zeroclaw gateway"); - println!(" 3. Status: zeroclaw status"); - } else if provider_supports_device_flow(&provider_name) { - if canonical_provider_name(&provider_name) == "copilot" { - println!(" 1. Chat: zeroclaw agent -m \"Hello!\""); - println!(" (device / OAuth auth will prompt on first run)"); - println!(" 2. Gateway: zeroclaw gateway"); - println!(" 3. Status: zeroclaw status"); - } else { - println!( - " 1. Login: zeroclaw auth login --provider {}", - provider_name - ); - println!(" 2. Chat: zeroclaw agent -m \"Hello!\""); - println!(" 3. Gateway: zeroclaw gateway"); - println!(" 4. Status: zeroclaw status"); - } - } else { - let env_var = provider_env_var(&provider_name); - println!(" 1. Set your API key: export {env_var}=\"sk-...\""); - println!(" 2. Or edit: ~/.zeroclaw/config.toml"); - println!(" 3. Chat: zeroclaw agent -m \"Hello!\""); - println!(" 4. Gateway: zeroclaw gateway"); - } + println!(" 1. Set your API key: export OPENROUTER_API_KEY=\"sk-...\""); + println!(" 2. Or edit: ~/.zeroclaw/config.toml"); + println!(" 3. Chat: zeroclaw agent -m \"Hello!\""); + println!(" 4. Gateway: zeroclaw gateway"); } else { println!(" 1. Chat: zeroclaw agent -m \"Hello!\""); println!(" 2. Gateway: zeroclaw gateway"); @@ -656,8 +629,6 @@ fn canonical_provider_name(provider_name: &str) -> &str { "grok" => "xai", "together" => "together-ai", "google" | "google-gemini" => "gemini", - "github-copilot" => "copilot", - "openai_codex" | "codex" => "openai-codex", "kimi_coding" | "kimi_for_coding" => "kimi-code", "nvidia-nim" | "build.nvidia.com" => "nvidia", "aws-bedrock" => "bedrock", @@ -702,7 +673,6 @@ fn default_model_for_provider(provider: &str) -> String { "xai" => "grok-4-1-fast-reasoning".into(), "perplexity" => "sonar-pro".into(), "fireworks" => "accounts/fireworks/models/llama-v3p3-70b-instruct".into(), - "novita" => "minimax/minimax-m2.5".into(), "together-ai" => "meta-llama/Llama-3.3-70B-Instruct-Turbo".into(), "cohere" => "command-a-03-2025".into(), "moonshot" => "kimi-k2.5".into(), @@ -896,10 +866,6 @@ fn curated_models_for_provider(provider_name: &str) -> Vec<(String, String)> { "Mixtral 8x22B".to_string(), ), ], - "novita" => vec![( - "minimax/minimax-m2.5".to_string(), - "MiniMax M2.5".to_string(), - )], "together-ai" => vec![ ( "meta-llama/Llama-3.3-70B-Instruct-Turbo".to_string(), @@ -1133,14 +1099,9 @@ fn curated_models_for_provider(provider_name: &str) -> Vec<(String, String)> { } fn supports_live_model_fetch(provider_name: &str) -> bool { - if provider_name.trim().starts_with("custom:") { - return true; - } - matches!( canonical_provider_name(provider_name), "openrouter" - | "openai-codex" | "openai" | "anthropic" | "groq" @@ -1157,7 +1118,6 @@ fn supports_live_model_fetch(provider_name: &str) -> bool { | "astrai" | "venice" | "fireworks" - | "novita" | "cohere" | "moonshot" | "glm" @@ -1175,7 +1135,6 @@ fn models_endpoint_for_provider(provider_name: &str) -> Option<&'static str> { "glm-cn" | "bigmodel" => Some("https://open.bigmodel.cn/api/paas/v4/models"), "zai-cn" | "z.ai-cn" => Some("https://open.bigmodel.cn/api/coding/paas/v4/models"), _ => match canonical_provider_name(provider_name) { - "openai-codex" => Some("https://api.openai.com/v1/models"), "openai" => Some("https://api.openai.com/v1/models"), "venice" => Some("https://api.venice.ai/api/v1/models"), "groq" => Some("https://api.groq.com/openai/v1/models"), @@ -1184,7 +1143,6 @@ fn models_endpoint_for_provider(provider_name: &str) -> Option<&'static str> { "xai" => Some("https://api.x.ai/v1/models"), "together-ai" => Some("https://api.together.xyz/v1/models"), "fireworks" => Some("https://api.fireworks.ai/inference/v1/models"), - "novita" => Some("https://api.novita.ai/openai/v1/models"), "cohere" => Some("https://api.cohere.com/compatibility/v1/models"), "moonshot" => Some("https://api.moonshot.ai/v1/models"), "glm" => Some("https://api.z.ai/api/paas/v4/models"), @@ -1210,16 +1168,14 @@ fn build_model_fetch_client() -> Result { } fn normalize_model_ids(ids: Vec) -> Vec { - let mut unique = BTreeMap::new(); + let mut unique = BTreeSet::new(); for id in ids { let trimmed = id.trim(); if !trimmed.is_empty() { - unique - .entry(trimmed.to_ascii_lowercase()) - .or_insert_with(|| trimmed.to_string()); + unique.insert(trimmed.to_string()); } } - unique.into_values().collect() + unique.into_iter().collect() } fn parse_openai_compatible_model_ids(payload: &Value) -> Vec { @@ -1428,17 +1384,6 @@ fn resolve_live_models_endpoint( provider_name: &str, provider_api_url: Option<&str>, ) -> Option { - if let Some(raw_base) = provider_name.strip_prefix("custom:") { - let normalized = raw_base.trim().trim_end_matches('/'); - if normalized.is_empty() { - return None; - } - if normalized.ends_with("/models") { - return Some(normalized.to_string()); - } - return Some(format!("{normalized}/models")); - } - if matches!( canonical_provider_name(provider_name), "llamacpp" | "sglang" | "vllm" | "osaurus" @@ -1455,19 +1400,6 @@ fn resolve_live_models_endpoint( } } - if canonical_provider_name(provider_name) == "openai-codex" { - if let Some(url) = provider_api_url - .map(str::trim) - .filter(|url| !url.is_empty()) - { - let normalized = url.trim_end_matches('/'); - if normalized.ends_with("/models") { - return Some(normalized.to_string()); - } - return Some(format!("{normalized}/models")); - } - } - models_endpoint_for_provider(provider_name).map(str::to_string) } @@ -1812,151 +1744,6 @@ pub async fn run_models_refresh( } } -pub async fn run_models_list(config: &Config, provider_override: Option<&str>) -> Result<()> { - let provider_name = provider_override - .or(config.default_provider.as_deref()) - .unwrap_or("openrouter"); - - let cached = load_any_cached_models_for_provider(&config.workspace_dir, provider_name).await?; - - let Some(cached) = cached else { - println!(); - println!( - " No cached models for '{provider_name}'. Run: zeroclaw models refresh --provider {provider_name}" - ); - println!(); - return Ok(()); - }; - - println!(); - println!( - " {} models for '{}' (cached {} ago):", - cached.models.len(), - provider_name, - humanize_age(cached.age_secs) - ); - println!(); - for model in &cached.models { - let marker = if config.default_model.as_deref() == Some(model.as_str()) { - "* " - } else { - " " - }; - println!(" {marker}{model}"); - } - println!(); - Ok(()) -} - -pub async fn run_models_set(config: &Config, model: &str) -> Result<()> { - let model = model.trim(); - if model.is_empty() { - anyhow::bail!("Model name cannot be empty"); - } - - let mut updated = config.clone(); - updated.default_model = Some(model.to_string()); - updated.save().await?; - - println!(); - println!(" Default model set to '{}'.", style(model).green().bold()); - println!(); - Ok(()) -} - -pub async fn run_models_status(config: &Config) -> Result<()> { - let provider = config.default_provider.as_deref().unwrap_or("openrouter"); - let model = config.default_model.as_deref().unwrap_or("(not set)"); - - println!(); - println!(" Provider: {}", style(provider).cyan()); - println!(" Model: {}", style(model).cyan()); - println!( - " Temp: {}", - style(format!("{:.1}", config.default_temperature)).cyan() - ); - - match load_any_cached_models_for_provider(&config.workspace_dir, provider).await? { - Some(cached) => { - println!( - " Cache: {} models (updated {} ago)", - cached.models.len(), - humanize_age(cached.age_secs) - ); - let fresh = cached.age_secs < MODEL_CACHE_TTL_SECS; - if fresh { - println!(" Freshness: {}", style("fresh").green()); - } else { - println!(" Freshness: {}", style("stale").yellow()); - } - } - None => { - println!(" Cache: {}", style("none").yellow()); - } - } - - println!(); - Ok(()) -} - -pub async fn cached_model_catalog_stats( - config: &Config, - provider_name: &str, -) -> Result> { - let Some(cached) = - load_any_cached_models_for_provider(&config.workspace_dir, provider_name).await? - else { - return Ok(None); - }; - Ok(Some((cached.models.len(), cached.age_secs))) -} - -pub async fn run_models_refresh_all(config: &Config, force: bool) -> Result<()> { - let mut targets: Vec = crate::providers::list_providers() - .into_iter() - .map(|provider| provider.name.to_string()) - .filter(|name| supports_live_model_fetch(name)) - .collect(); - - targets.sort(); - targets.dedup(); - - if targets.is_empty() { - anyhow::bail!("No providers support live model discovery"); - } - - println!( - "Refreshing model catalogs for {} providers (force: {})", - targets.len(), - if force { "yes" } else { "no" } - ); - println!(); - - let mut ok_count = 0usize; - let mut fail_count = 0usize; - - for provider_name in &targets { - println!("== {} ==", provider_name); - match run_models_refresh(config, Some(provider_name), force).await { - Ok(()) => { - ok_count += 1; - } - Err(error) => { - fail_count += 1; - println!(" failed: {error}"); - } - } - println!(); - } - - println!("Summary: {} succeeded, {} failed", ok_count, fail_count); - - if ok_count == 0 { - anyhow::bail!("Model refresh failed for all providers") - } - Ok(()) -} - // ── Step helpers ───────────────────────────────────────────────── fn print_step(current: u8, total: u8, title: &str) { @@ -2155,7 +1942,6 @@ async fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, 1 => vec![ ("groq", "Groq — ultra-fast LPU inference"), ("fireworks", "Fireworks AI — fast open-source inference"), - ("novita", "Novita AI — affordable open-source inference"), ("together-ai", "Together AI — open-source model hosting"), ("nvidia", "NVIDIA NIM — DeepSeek, Llama, & more"), ], @@ -2579,7 +2365,6 @@ async fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, "deepseek" => "https://platform.deepseek.com/api_keys", "together-ai" => "https://api.together.xyz/settings/api-keys", "fireworks" => "https://fireworks.ai/account/api-keys", - "novita" => "https://novita.ai/settings/key-management", "perplexity" => "https://www.perplexity.ai/settings/api", "xai" => "https://console.x.ai", "cohere" => "https://dashboard.cohere.com/api-keys", @@ -2856,7 +2641,6 @@ fn provider_env_var(name: &str) -> &'static str { match canonical_provider_name(name) { "openrouter" => "OPENROUTER_API_KEY", "anthropic" => "ANTHROPIC_API_KEY", - "openai-codex" => "OPENAI_API_KEY", "openai" => "OPENAI_API_KEY", "ollama" => "OLLAMA_API_KEY", "llamacpp" => "LLAMACPP_API_KEY", @@ -2870,7 +2654,6 @@ fn provider_env_var(name: &str) -> &'static str { "xai" => "XAI_API_KEY", "together-ai" => "TOGETHER_API_KEY", "fireworks" | "fireworks-ai" => "FIREWORKS_API_KEY", - "novita" => "NOVITA_API_KEY", "perplexity" => "PERPLEXITY_API_KEY", "cohere" => "COHERE_API_KEY", "kimi-code" => "KIMI_CODE_API_KEY", @@ -2899,16 +2682,15 @@ fn provider_supports_keyless_local_usage(provider_name: &str) -> bool { ) } -fn provider_supports_device_flow(provider_name: &str) -> bool { - matches!( - canonical_provider_name(provider_name), - "copilot" | "gemini" | "openai-codex" - ) -} - // ── Step 5: Tool Mode & Security ──────────────────────────────── -fn setup_tool_mode() -> Result<(ComposioConfig, SecretsConfig)> { +fn setup_tool_mode() -> Result<( + ComposioConfig, + SecretsConfig, + BrowserConfig, + HttpRequestConfig, + WebSearchConfig, +)> { print_bullet("Choose how ZeroClaw connects to external apps."); print_bullet("You can always change this later in config.toml."); println!(); @@ -2967,6 +2749,283 @@ fn setup_tool_mode() -> Result<(ComposioConfig, SecretsConfig)> { ComposioConfig::default() }; + // ── Tool selection ── + println!(); + print_bullet("Choose optional network tools to enable."); + print_bullet("Core local tools stay enabled (shell, file_read/file_write, memory_*)."); + + let browser_runtime = detect_browser_runtime_availability(); + let rust_native_backend_ready = + browser_runtime.rust_native_compiled && browser_runtime.docker_webdriver_available; + if browser_runtime.in_container + && !browser_runtime.brave_available + && !browser_runtime.agent_browser_available + && !browser_runtime.local_computer_use_available + && !rust_native_backend_ready + { + println!(); + print_bullet("Browser preflight: no local browser backend detected in this container."); + print_bullet("browser_open requires Brave/Brave Browser binary."); + print_bullet( + "browser backend requires agent-browser CLI or reachable computer_use sidecar.", + ); + if browser_runtime.docker_webdriver_url.is_some() && !browser_runtime.rust_native_compiled { + print_bullet( + "This binary was not built with browser-native, so WebDriver cannot be used.", + ); + } + print_bullet( + "You can still enable browser now, but calls will fail until backend setup is done.", + ); + } + + let mut browser_config = BrowserConfig::default(); + let enable_browser = Confirm::new() + .with_prompt(" Enable browser tools (browser_open + browser)?") + .default(false) + .interact()?; + + if enable_browser { + browser_config.enabled = true; + browser_config.allowed_domains = prompt_allowed_domains_for_tool("browser")?; + println!( + " {} browser.allowed_domains = [{}]", + style("✓").green().bold(), + style(browser_config.allowed_domains.join(", ")).green() + ); + + let mut backend_configured = false; + if let Some(webdriver_url) = browser_runtime.docker_webdriver_url.as_deref() { + if browser_runtime.rust_native_compiled { + let use_rust_native = Confirm::new() + .with_prompt(format!( + " Use rust_native backend with provisioned WebDriver ({webdriver_url})?" + )) + .default(browser_runtime.docker_webdriver_available) + .interact()?; + if use_rust_native { + browser_config.backend = "rust_native".to_string(); + browser_config.native_webdriver_url = webdriver_url.to_string(); + backend_configured = true; + println!( + " {} browser backend: {}", + style("✓").green().bold(), + style("rust_native").green() + ); + if browser_runtime.docker_webdriver_available { + println!( + " {} rust-native webdriver endpoint: {}", + style("✓").green().bold(), + style("reachable").green() + ); + } else { + println!( + " {} rust-native webdriver endpoint: {}", + style("!").yellow().bold(), + style("unreachable right now").yellow() + ); + print_bullet( + "Browser automation will fail until the configured WebDriver endpoint is reachable.", + ); + } + } + } else { + println!( + " {} browser-native build feature: {}", + style("!").yellow().bold(), + style("not enabled in this binary").yellow() + ); + print_bullet( + "A Docker WebDriver sidecar is configured, but this build cannot use rust_native backend.", + ); + } + } + + if !backend_configured { + let enable_computer_use = Confirm::new() + .with_prompt(" Enable browser.computer_use backend?") + .default(false) + .interact()?; + + if enable_computer_use { + browser_config.backend = "computer_use".to_string(); + let endpoint: String = Input::new() + .with_prompt(" browser.computer_use endpoint") + .default(browser_config.computer_use.endpoint.clone()) + .interact_text()?; + let endpoint = endpoint.trim(); + if !endpoint.is_empty() { + browser_config.computer_use.endpoint = endpoint.to_string(); + } + let allow_remote_endpoint = Confirm::new() + .with_prompt(" Allow remote computer_use endpoint?") + .default(browser_config.computer_use.allow_remote_endpoint) + .interact()?; + browser_config.computer_use.allow_remote_endpoint = allow_remote_endpoint; + println!( + " {} browser.computer_use: {}", + style("✓").green().bold(), + style("enabled").green() + ); + print_bullet(&format!( + "computer_use endpoint: {}", + browser_config.computer_use.endpoint + )); + if endpoint_is_reachable( + &browser_config.computer_use.endpoint, + Duration::from_millis(600), + ) { + println!( + " {} computer_use sidecar endpoint: {}", + style("✓").green().bold(), + style("reachable").green() + ); + } else { + println!( + " {} computer_use sidecar endpoint: {}", + style("!").yellow().bold(), + style("unreachable right now").yellow() + ); + print_bullet( + "Browser automation will fail until the configured computer_use sidecar is reachable.", + ); + } + } else { + println!( + " {} browser backend: {}", + style("✓").green().bold(), + style(browser_config.backend.as_str()).green() + ); + if !browser_runtime.agent_browser_available { + println!( + " {} agent-browser CLI: {}", + style("!").yellow().bold(), + style("not detected").yellow() + ); + print_bullet( + "browser backend is set to agent_browser, but the CLI is unavailable in this environment.", + ); + } + } + } + + if !browser_runtime.brave_available { + println!( + " {} browser_open executable: {}", + style("!").yellow().bold(), + style("Brave not detected").yellow() + ); + print_bullet("browser_open requires Brave Browser in PATH."); + } + + let browser_backend_ready = + browser_backend_looks_available(&browser_config, &browser_runtime); + let browser_open_ready = browser_runtime.brave_available; + if !browser_backend_ready && !browser_open_ready { + println!( + " {} Browser tools enabled but no working backend detected in this environment.", + style("!").yellow().bold() + ); + let keep_enabled = Confirm::new() + .with_prompt(" Keep browser tools enabled anyway?") + .default(false) + .interact()?; + if !keep_enabled { + browser_config = BrowserConfig::default(); + println!( + " {} browser tools: {}", + style("✓").green().bold(), + style("disabled").dim() + ); + } + } + } else { + println!( + " {} browser tools: {}", + style("✓").green().bold(), + style("disabled").dim() + ); + } + + let mut http_request_config = HttpRequestConfig::default(); + let enable_http_request = Confirm::new() + .with_prompt(" Enable http_request tool for API calls?") + .default(false) + .interact()?; + + if enable_http_request { + http_request_config.enabled = true; + http_request_config.allowed_domains = prompt_allowed_domains_for_tool("http_request")?; + println!( + " {} http_request.allowed_domains = [{}]", + style("✓").green().bold(), + style(http_request_config.allowed_domains.join(", ")).green() + ); + } else { + println!( + " {} http_request: {}", + style("✓").green().bold(), + style("disabled").dim() + ); + } + + let mut web_search_config = WebSearchConfig::default(); + let enable_web_search = Confirm::new() + .with_prompt(" Enable web_search_tool?") + .default(false) + .interact()?; + + if enable_web_search { + web_search_config.enabled = true; + + let provider_options = vec![ + "DuckDuckGo (free, no API key)", + "Brave Search (requires API key)", + ]; + let provider_choice = Select::new() + .with_prompt(" web_search provider") + .items(&provider_options) + .default(0) + .interact()?; + + if provider_choice == 1 { + web_search_config.provider = "brave".to_string(); + let brave_api_key: String = Input::new() + .with_prompt(" Brave API key (or Enter to skip)") + .allow_empty(true) + .interact_text()?; + if brave_api_key.trim().is_empty() { + println!( + " {} Brave key skipped — set web_search.brave_api_key in config.toml later", + style("→").dim() + ); + } else { + web_search_config.brave_api_key = Some(brave_api_key); + println!( + " {} Brave API key: {}", + style("✓").green().bold(), + style("configured").green() + ); + } + } else { + web_search_config.provider = "duckduckgo".to_string(); + } + + web_search_config.max_results = prompt_optional_max_results(web_search_config.max_results)?; + println!( + " {} web_search: {} (max_results: {})", + style("✓").green().bold(), + style(web_search_config.provider.as_str()).green(), + web_search_config.max_results + ); + } else { + println!( + " {} web_search_tool: {}", + style("✓").green().bold(), + style("disabled").dim() + ); + } + // ── Encrypted secrets ── println!(); print_bullet("ZeroClaw can encrypt API keys stored in config.toml."); @@ -2993,7 +3052,239 @@ fn setup_tool_mode() -> Result<(ComposioConfig, SecretsConfig)> { ); } - Ok((composio_config, secrets_config)) + Ok(( + composio_config, + secrets_config, + browser_config, + http_request_config, + web_search_config, + )) +} + +#[derive(Debug, Clone)] +struct BrowserRuntimeAvailability { + brave_available: bool, + agent_browser_available: bool, + local_computer_use_available: bool, + rust_native_compiled: bool, + docker_webdriver_url: Option, + docker_webdriver_available: bool, + in_container: bool, +} + +fn detect_browser_runtime_availability() -> BrowserRuntimeAvailability { + let docker_webdriver_url = docker_webdriver_url_from_env(); + let docker_webdriver_available = docker_webdriver_url + .as_deref() + .is_some_and(|url| endpoint_is_reachable(url, Duration::from_millis(600))); + + BrowserRuntimeAvailability { + brave_available: command_available("brave-browser", &["--version"]) + || command_available("brave", &["--version"]), + agent_browser_available: command_available("agent-browser", &["--version"]), + local_computer_use_available: endpoint_is_reachable( + "http://127.0.0.1:8787/v1/actions", + Duration::from_millis(600), + ), + rust_native_compiled: cfg!(feature = "browser-native"), + docker_webdriver_url, + docker_webdriver_available, + in_container: Path::new("/.dockerenv").exists() + || std::env::var_os("ZEROCLAW_DOCKER_BOOTSTRAP").is_some(), + } +} + +fn docker_webdriver_url_from_env() -> Option { + std::env::var("ZEROCLAW_DOCKER_WEBDRIVER_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn command_available(command: &str, args: &[&str]) -> bool { + std::process::Command::new(command) + .args(args) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +fn endpoint_is_reachable(endpoint: &str, timeout: Duration) -> bool { + let parsed = match reqwest::Url::parse(endpoint) { + Ok(url) => url, + Err(_) => return false, + }; + + if !matches!(parsed.scheme(), "http" | "https") { + return false; + } + + let host = match parsed.host_str() { + Some(host) if !host.is_empty() => host.to_string(), + _ => return false, + }; + + let port = match parsed.port_or_known_default() { + Some(port) => port, + None => return false, + }; + + let addrs = match (host.as_str(), port).to_socket_addrs() { + Ok(addrs) => addrs, + Err(_) => return false, + }; + + for addr in addrs { + if TcpStream::connect_timeout(&addr, timeout).is_ok() { + return true; + } + } + + false +} + +fn browser_backend_looks_available( + browser_config: &BrowserConfig, + runtime: &BrowserRuntimeAvailability, +) -> bool { + let rust_native_ready = runtime.rust_native_compiled + && endpoint_is_reachable( + &browser_config.native_webdriver_url, + Duration::from_millis(600), + ); + + match browser_config.backend.trim().to_ascii_lowercase().as_str() { + "computer_use" | "computeruse" => endpoint_is_reachable( + &browser_config.computer_use.endpoint, + Duration::from_millis(600), + ), + "agent_browser" | "agentbrowser" => runtime.agent_browser_available, + "auto" => { + runtime.agent_browser_available + || endpoint_is_reachable( + &browser_config.computer_use.endpoint, + Duration::from_millis(600), + ) + || rust_native_ready + } + "rust_native" | "native" => rust_native_ready, + _ => false, + } +} + +fn prompt_allowed_domains_for_tool(tool_name: &str) -> Result> { + let options = vec![ + "Restricted allowlist (recommended)", + "Allow ANY public domain (*)", + ]; + + let choice = Select::new() + .with_prompt(format!(" {tool_name}: domain access policy")) + .items(&options) + .default(0) + .interact()?; + + if choice == 1 { + print_bullet("ANY-domain mode still blocks localhost and private network targets."); + return Ok(vec!["*".to_string()]); + } + + prompt_domain_list(&format!( + " {tool_name}: allowed domains (comma-separated, e.g. docs.rs, github.com)" + )) +} + +fn prompt_domain_list(prompt: &str) -> Result> { + loop { + let raw: String = Input::new() + .with_prompt(prompt) + .allow_empty(true) + .interact_text()?; + + let domains = parse_domain_list(&raw); + if !domains.is_empty() { + return Ok(domains); + } + + println!( + " {} {}", + style("✗").red().bold(), + style("Enter at least one domain, or choose ANY domain.").yellow() + ); + } +} + +fn parse_domain_list(raw: &str) -> Vec { + let mut domains: Vec = raw.split(',').filter_map(normalize_domain_entry).collect(); + domains.sort_unstable(); + domains.dedup(); + domains +} + +fn normalize_domain_entry(raw: &str) -> Option { + let mut domain = raw.trim().to_lowercase(); + if domain.is_empty() { + return None; + } + + if domain == "*" { + return Some(domain); + } + + if let Some(stripped) = domain.strip_prefix("https://") { + domain = stripped.to_string(); + } else if let Some(stripped) = domain.strip_prefix("http://") { + domain = stripped.to_string(); + } + + if let Some((host, _)) = domain.split_once('/') { + domain = host.to_string(); + } + + domain = domain + .trim_start_matches('.') + .trim_end_matches('.') + .to_string(); + + if let Some((host, _)) = domain.split_once(':') { + domain = host.to_string(); + } + + if domain.is_empty() || domain.chars().any(char::is_whitespace) { + return None; + } + + Some(domain) +} + +fn prompt_optional_max_results(default_value: usize) -> Result { + loop { + let raw: String = Input::new() + .with_prompt(format!( + " web_search max results (1-10, Enter for default {default_value})" + )) + .allow_empty(true) + .interact_text()?; + + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(default_value); + } + + if let Ok(parsed) = trimmed.parse::() { + if (1..=10).contains(&parsed) { + return Ok(parsed); + } + } + + println!( + " {} {}", + style("✗").red().bold(), + style("Enter a number between 1 and 10.").yellow() + ); + } } // ── Step 6: Hardware (Physical World) ─────────────────────────── @@ -4753,22 +5044,10 @@ fn setup_channels() -> Result { .filter(|s| !s.is_empty()) .collect(); - let receive_mode_choice = Select::new() - .with_prompt(" Receive mode") - .items(["Webhook (recommended)", "WebSocket (legacy fallback)"]) - .default(0) - .interact()?; - let receive_mode = if receive_mode_choice == 0 { - QQReceiveMode::Webhook - } else { - QQReceiveMode::Websocket - }; - config.qq = Some(QQConfig { app_id, app_secret, allowed_users, - receive_mode, }); } ChannelMenuChoice::Lark | ChannelMenuChoice::Feishu => { @@ -4948,17 +5227,28 @@ fn setup_channels() -> Result { ); } - config.lark = Some(LarkConfig { - app_id, - app_secret, - verification_token, - encrypt_key: None, - allowed_users, - mention_only: false, - use_feishu: is_feishu, - receive_mode, - port, - }); + if is_feishu { + config.feishu = Some(FeishuConfig { + app_id, + app_secret, + verification_token, + encrypt_key: None, + allowed_users, + receive_mode, + port, + }); + } else { + config.lark = Some(LarkConfig { + app_id, + app_secret, + verification_token, + encrypt_key: None, + allowed_users, + use_feishu: false, + receive_mode, + port, + }); + } } ChannelMenuChoice::Nostr => { // ── Nostr ── @@ -5599,6 +5889,51 @@ fn print_summary(config: &Config) { } ); + println!( + " {} Browser: {}", + style("🌍").cyan(), + if config.browser.enabled { + let domains = if config.browser.allowed_domains.is_empty() { + "(none configured)".to_string() + } else { + config.browser.allowed_domains.join(", ") + }; + format!("enabled ({}, domains: {})", config.browser.backend, domains) + } else { + "disabled".to_string() + } + ); + + println!( + " {} HTTP request: {}", + style("🌐").cyan(), + if config.http_request.enabled { + if config.http_request.allowed_domains.is_empty() { + "enabled (domains not configured)".to_string() + } else { + format!( + "enabled (domains: {})", + config.http_request.allowed_domains.join(", ") + ) + } + } else { + "disabled".to_string() + } + ); + + println!( + " {} Web search: {}", + style("🔎").cyan(), + if config.web_search.enabled { + format!( + "enabled ({}, max_results: {})", + config.web_search.provider, config.web_search.max_results + ) + } else { + "disabled".to_string() + } + ); + // Secrets println!(" {} Secrets: configured", style("🔒").cyan()); @@ -5823,6 +6158,19 @@ mod tests { assert!(!config.channels_config.cli); } + #[test] + fn parse_domain_list_normalizes_and_deduplicates_entries() { + let parsed = + parse_domain_list(" https://Docs.Example.com/path , docs.example.com, example.com "); + assert_eq!(parsed, vec!["docs.example.com", "example.com"]); + } + + #[test] + fn parse_domain_list_preserves_wildcard_entries() { + let parsed = parse_domain_list("*,*.example.com"); + assert_eq!(parsed, vec!["*", "*.example.com"]); + } + #[test] fn apply_provider_update_clears_api_key_when_empty() { let mut config = Config::default(); @@ -6514,8 +6862,6 @@ mod tests { assert_eq!(canonical_provider_name("dashscope-us"), "qwen"); assert_eq!(canonical_provider_name("qwen-code"), "qwen-code"); assert_eq!(canonical_provider_name("qwen-oauth"), "qwen-code"); - assert_eq!(canonical_provider_name("codex"), "openai-codex"); - assert_eq!(canonical_provider_name("openai_codex"), "openai-codex"); assert_eq!(canonical_provider_name("moonshot-intl"), "moonshot"); assert_eq!(canonical_provider_name("kimi-cn"), "moonshot"); assert_eq!(canonical_provider_name("kimi_coding"), "kimi-code"); @@ -6749,10 +7095,6 @@ mod tests { #[test] fn models_endpoint_for_provider_supports_additional_openai_compatible_providers() { - assert_eq!( - models_endpoint_for_provider("openai-codex"), - Some("https://api.openai.com/v1/models") - ); assert_eq!( models_endpoint_for_provider("venice"), Some("https://api.venice.ai/api/v1/models") @@ -6822,18 +7164,6 @@ mod tests { assert_eq!(resolve_live_models_endpoint("unknown-provider", None), None); } - #[test] - fn resolve_live_models_endpoint_supports_custom_provider_urls() { - assert_eq!( - resolve_live_models_endpoint("custom:https://proxy.example.com/v1", None), - Some("https://proxy.example.com/v1/models".to_string()) - ); - assert_eq!( - resolve_live_models_endpoint("custom:https://proxy.example.com/v1/models", None), - Some("https://proxy.example.com/v1/models".to_string()) - ); - } - #[test] fn normalize_ollama_endpoint_url_strips_api_suffix_and_trailing_slash() { assert_eq!( @@ -6897,17 +7227,6 @@ mod tests { assert_eq!(ids, vec!["alpha".to_string(), "beta".to_string()]); } - #[test] - fn normalize_model_ids_deduplicates_case_insensitively() { - let ids = normalize_model_ids(vec![ - "GPT-5".to_string(), - "gpt-5".to_string(), - "gpt-5-mini".to_string(), - " GPT-5-MINI ".to_string(), - ]); - assert_eq!(ids, vec!["GPT-5".to_string(), "gpt-5-mini".to_string()]); - } - #[test] fn parse_gemini_model_ids_filters_for_generate_content() { let payload = json!({ @@ -7034,7 +7353,6 @@ mod tests { fn provider_env_var_known_providers() { assert_eq!(provider_env_var("openrouter"), "OPENROUTER_API_KEY"); assert_eq!(provider_env_var("anthropic"), "ANTHROPIC_API_KEY"); - assert_eq!(provider_env_var("openai-codex"), "OPENAI_API_KEY"); assert_eq!(provider_env_var("openai"), "OPENAI_API_KEY"); assert_eq!(provider_env_var("ollama"), "OLLAMA_API_KEY"); assert_eq!(provider_env_var("llamacpp"), "LLAMACPP_API_KEY"); @@ -7078,16 +7396,6 @@ mod tests { assert!(!provider_supports_keyless_local_usage("openai")); } - #[test] - fn provider_supports_device_flow_copilot() { - assert!(provider_supports_device_flow("copilot")); - assert!(provider_supports_device_flow("github-copilot")); - assert!(provider_supports_device_flow("gemini")); - assert!(provider_supports_device_flow("openai-codex")); - assert!(!provider_supports_device_flow("openai")); - assert!(!provider_supports_device_flow("openrouter")); - } - #[test] fn local_provider_choices_include_sglang() { let choices = local_provider_choices(); @@ -7190,7 +7498,6 @@ mod tests { app_id: "app-id".into(), app_secret: "app-secret".into(), allowed_users: vec!["*".into()], - receive_mode: crate::config::schema::QQReceiveMode::Websocket, }); assert!(has_launchable_channels(&channels)); diff --git a/src/tools/browser_open.rs b/src/tools/browser_open.rs index 2c31fa1c6..38256badb 100644 --- a/src/tools/browser_open.rs +++ b/src/tools/browser_open.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use serde_json::json; use std::sync::Arc; -/// Open approved HTTPS URLs in the system default browser (no scraping, no DOM automation). +/// Open approved HTTPS URLs in Brave Browser (no scraping, no DOM automation). pub struct BrowserOpenTool { security: Arc, allowed_domains: Vec, @@ -60,7 +60,7 @@ impl Tool for BrowserOpenTool { } fn description(&self) -> &str { - "Open an approved HTTPS URL in the system browser. Security constraints: allowlist-only domains, no local/private hosts, no scraping." + "Open an approved HTTPS URL in Brave Browser. Security constraints: allowlist-only domains, no local/private hosts, no scraping." } fn parameters_schema(&self) -> serde_json::Value { @@ -69,7 +69,7 @@ impl Tool for BrowserOpenTool { "properties": { "url": { "type": "string", - "description": "HTTPS URL to open in the system browser" + "description": "HTTPS URL to open in Brave Browser" } }, "required": ["url"] @@ -109,113 +109,72 @@ impl Tool for BrowserOpenTool { } }; - match open_in_system_browser(&url).await { + match open_in_brave(&url).await { Ok(()) => Ok(ToolResult { success: true, - output: format!("Opened in system browser: {url}"), + output: format!("Opened in Brave: {url}"), error: None, }), Err(e) => Ok(ToolResult { success: false, output: String::new(), - error: Some(format!("Failed to open system browser: {e}")), + error: Some(format!("Failed to open Brave Browser: {e}")), }), } } } -async fn open_in_system_browser(url: &str) -> anyhow::Result<()> { +async fn open_in_brave(url: &str) -> anyhow::Result<()> { #[cfg(target_os = "macos")] { - let primary_error = match tokio::process::Command::new("open").arg(url).status().await { - Ok(status) if status.success() => return Ok(()), - Ok(status) => format!("open exited with status {status}"), - Err(error) => format!("open not runnable: {error}"), - }; - - // TODO(compat): remove Brave fallback after default-browser launch has been stable across macOS environments. - let mut brave_error = String::new(); for app in ["Brave Browser", "Brave"] { - match tokio::process::Command::new("open") + let status = tokio::process::Command::new("open") .arg("-a") .arg(app) .arg(url) .status() - .await - { - Ok(status) if status.success() => return Ok(()), - Ok(status) => { - brave_error = format!("open -a '{app}' exited with status {status}"); - } - Err(error) => { - brave_error = format!("open -a '{app}' not runnable: {error}"); + .await; + + if let Ok(s) = status { + if s.success() { + return Ok(()); } } } - anyhow::bail!( - "Failed to open URL with default browser launcher: {primary_error}. Brave compatibility fallback also failed: {brave_error}" + "Brave Browser was not found (tried macOS app names 'Brave Browser' and 'Brave')" ); } #[cfg(target_os = "linux")] { let mut last_error = String::new(); - for cmd in [ - "xdg-open", - "gio", - "sensible-browser", - "brave-browser", - "brave", - ] { - let mut command = tokio::process::Command::new(cmd); - if cmd == "gio" { - command.arg("open"); - } - command.arg(url); - match command.status().await { + for cmd in ["brave-browser", "brave"] { + match tokio::process::Command::new(cmd).arg(url).status().await { Ok(status) if status.success() => return Ok(()), Ok(status) => { last_error = format!("{cmd} exited with status {status}"); } - Err(error) => { - last_error = format!("{cmd} not runnable: {error}"); + Err(e) => { + last_error = format!("{cmd} not runnable: {e}"); } } } - - // TODO(compat): remove Brave fallback commands (brave-browser/brave) once default launcher coverage is validated. - anyhow::bail!( - "Failed to open URL with default browser launchers; Brave compatibility fallback also failed. Last error: {last_error}" - ); + anyhow::bail!("{last_error}"); } #[cfg(target_os = "windows")] { - let primary_error = match tokio::process::Command::new("cmd") - .args(["/C", "start", "", url]) - .status() - .await - { - Ok(status) if status.success() => return Ok(()), - Ok(status) => format!("cmd start default-browser exited with status {status}"), - Err(error) => format!("cmd start default-browser not runnable: {error}"), - }; - - // TODO(compat): remove Brave fallback after default-browser launch has been stable across Windows environments. - let brave_error = match tokio::process::Command::new("cmd") + let status = tokio::process::Command::new("cmd") .args(["/C", "start", "", "brave", url]) .status() - .await - { - Ok(status) if status.success() => return Ok(()), - Ok(status) => format!("cmd start brave exited with status {status}"), - Err(error) => format!("cmd start brave not runnable: {error}"), - }; + .await?; - anyhow::bail!( - "Failed to open URL with default browser launcher: {primary_error}. Brave compatibility fallback also failed: {brave_error}" - ); + if status.success() { + return Ok(()); + } + + anyhow::bail!("cmd start brave exited with status {status}"); } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] @@ -302,15 +261,16 @@ fn extract_host(url: &str) -> anyhow::Result { } fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> bool { - if allowed_domains.iter().any(|domain| domain == "*") { - return true; - } - - allowed_domains.iter().any(|domain| { - host == domain - || host - .strip_suffix(domain) - .is_some_and(|prefix| prefix.ends_with('.')) + allowed_domains.iter().any(|pattern| { + if pattern == "*" { + return true; + } + if pattern.starts_with("*.") { + let suffix = &pattern[1..]; // ".example.com" + host.ends_with(suffix) || host == &pattern[2..] + } else { + host == pattern || host.ends_with(&format!(".{pattern}")) + } }) } @@ -411,6 +371,14 @@ mod tests { assert!(err.contains("local/private")); } + #[test] + fn validate_accepts_wildcard_subdomain_pattern() { + let tool = test_tool(vec!["*.example.com"]); + assert!(tool.validate_url("https://example.com").is_ok()); + assert!(tool.validate_url("https://sub.example.com").is_ok()); + assert!(tool.validate_url("https://other.com").is_err()); + } + #[test] fn validate_rejects_http() { let tool = test_tool(vec!["example.com"]); diff --git a/src/tools/http_request.rs b/src/tools/http_request.rs index 513ba554b..731a14f78 100644 --- a/src/tools/http_request.rs +++ b/src/tools/http_request.rs @@ -141,10 +141,6 @@ impl HttpRequestTool { } fn truncate_response(&self, text: &str) -> String { - // 0 means unlimited — no truncation. - if self.max_response_size == 0 { - return text.to_string(); - } if text.len() > self.max_response_size { let mut truncated = text .chars() @@ -381,15 +377,16 @@ fn extract_host(url: &str) -> anyhow::Result { } fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> bool { - if allowed_domains.iter().any(|domain| domain == "*") { - return true; - } - - allowed_domains.iter().any(|domain| { - host == domain - || host - .strip_suffix(domain) - .is_some_and(|prefix| prefix.ends_with('.')) + allowed_domains.iter().any(|pattern| { + if pattern == "*" { + return true; + } + if pattern.starts_with("*.") { + let suffix = &pattern[1..]; // ".example.com" + host.ends_with(suffix) || host == &pattern[2..] + } else { + host == pattern || host.ends_with(&format!(".{pattern}")) + } }) } @@ -517,6 +514,14 @@ mod tests { assert!(err.contains("local/private")); } + #[test] + fn validate_accepts_wildcard_subdomain_pattern() { + let tool = test_tool(vec!["*.example.com"]); + assert!(tool.validate_url("https://example.com").is_ok()); + assert!(tool.validate_url("https://sub.example.com").is_ok()); + assert!(tool.validate_url("https://other.com").is_err()); + } + #[test] fn validate_rejects_allowlist_miss() { let tool = test_tool(vec!["example.com"]); @@ -731,32 +736,6 @@ mod tests { assert!(truncated.contains("[Response truncated")); } - #[test] - fn truncate_response_zero_means_unlimited() { - let tool = HttpRequestTool::new( - Arc::new(SecurityPolicy::default()), - vec!["example.com".into()], - 0, // max_response_size = 0 means no limit - 30, - ); - let text = "a".repeat(10_000_000); - assert_eq!(tool.truncate_response(&text), text); - } - - #[test] - fn truncate_response_nonzero_still_truncates() { - let tool = HttpRequestTool::new( - Arc::new(SecurityPolicy::default()), - vec!["example.com".into()], - 5, - 30, - ); - let text = "hello world"; - let truncated = tool.truncate_response(text); - assert!(truncated.starts_with("hello")); - assert!(truncated.contains("[Response truncated")); - } - #[test] fn parse_headers_preserves_original_values() { let tool = test_tool(vec!["example.com"]);