supersede: file-replay changes from #1317

Automated conflict recovery via changed-file replay on latest dev.
This commit is contained in:
Chummy 2026-02-24 13:04:29 +00:00 committed by Chum Yin
parent 040bd95d84
commit bf1d7ac928
5 changed files with 1302 additions and 463 deletions

View File

@ -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

View File

@ -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 <path> Seed Docker config.toml from host path (skips default onboarding unless explicitly requested)
--docker-secret-key <path> 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

File diff suppressed because it is too large Load Diff

View File

@ -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<SecurityPolicy>,
allowed_domains: Vec<String>,
@ -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<String> {
}
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"]);

View File

@ -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<String> {
}
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"]);