diff --git a/specs/zeroclaw_dual_hw_orchestration_spec.md b/specs/zeroclaw_dual_hw_orchestration_spec.md index b4676ce..d6f03e8 100644 --- a/specs/zeroclaw_dual_hw_orchestration_spec.md +++ b/specs/zeroclaw_dual_hw_orchestration_spec.md @@ -64,23 +64,31 @@ This keeps `autonomy.workspace_only = true` effective on a per-repo boundary. - `tools/ai/zeroclaw_dual_chat.sh` - target switch by alias (`rtc`, `zacus`) or absolute path, - message mode (`-m`) or interactive mode, + - loads local auth env file `~/.zeroclaw/env` when present, - provider auto-fallback (`copilot` -> `openai-codex` -> `openrouter`), - token sourcing from `gh auth token` at runtime only when `copilot` is selected. - `tools/ai/zeroclaw_stack_up.sh` - starts local gateway and local follow server, - reuses existing listeners when ports are already bound (prevents duplicate-start failures), + - loads local auth env file `~/.zeroclaw/env` when present, + - attempts automatic gateway pairing and token refresh, + - validates bearer token with a malformed webhook probe (`{}` payload) and re-pairs when possible, - generates live follow dashboard at `http://127.0.0.1:8788/`, - dashboard includes live polling panels for `/conversations.jsonl` and `/gateway.log` (1s polling), - preserves direct raw links: `/conversations.jsonl` and `/gateway.log`, - writes `artifacts/zeroclaw/prometheus.yml` scrape config, - - supports local Prometheus startup via `ZEROCLAW_PROM_MODE` (`off`, `auto`, `binary`, `docker`), + - supports local Prometheus startup via `ZEROCLAW_PROM_MODE` (`off`, `auto`, `binary`, `docker`) with `auto` fallback `binary -> docker`, + - on macOS, auto-attempts Docker Desktop startup before Prometheus docker mode, - stores pair token in `artifacts/zeroclaw/pair_token.txt`. - `tools/ai/zeroclaw_stack_down.sh` - stops local gateway/follow processes, - stops local Prometheus process/container if managed by the stack, - confirms logs remain in `artifacts/zeroclaw/`. - `tools/ai/zeroclaw_webhook_send.sh` - - requires `--allow-model-call` before any real webhook send, + - sends webhook by default (no mandatory allow flag), + - keeps `--allow-model-call` as backward-compatible legacy option, + - supports `--dry-run` to validate payload/limits without network send, + - enforces autonomous local quotas with `artifacts/zeroclaw/webhook_budget.json`, - supports `--repo-hint ` metadata tagging, - appends enriched JSONL traces to `artifacts/zeroclaw/conversations.jsonl`. @@ -155,22 +163,43 @@ Compatibility rule: - viewer tolerates legacy lines that only contain `ts`, `message`, `response_raw`. -Credit-protection rule: +Webhook execution and cost controls: -- without `--allow-model-call`, `zeroclaw_webhook_send.sh` exits non-zero and does not send/write logs. +- webhook send is enabled by default. +- `--dry-run` performs validation only (no network call, no execution log append). +- hourly quota and message-size limits are enforced by environment variables: + - `ZEROCLAW_WEBHOOK_MAX_CALLS_PER_HOUR` (default: `40`) + - `ZEROCLAW_WEBHOOK_MAX_CHARS` (default: `1200`) +- quota state is stored in `artifacts/zeroclaw/webhook_budget.json`. ## 9) Prometheus Integration Economical default: -- `ZEROCLAW_PROM_MODE=auto` (start only if local `prometheus` binary exists) +- `ZEROCLAW_PROM_MODE=auto` (try local `prometheus` binary first, then Docker fallback) Optional modes: - `ZEROCLAW_PROM_MODE=off` disables Prometheus startup - `ZEROCLAW_PROM_MODE=binary` requires local `prometheus` binary - `ZEROCLAW_PROM_MODE=docker` runs `prom/prometheus:latest` locally +- `ZEROCLAW_DOCKER_WAIT_SECS` controls Docker daemon wait timeout (default: `90`) +- `ZEROCLAW_PROM_READY_WAIT_SECS` controls Prometheus readiness wait (default: `15`) Default local endpoint when running: - `http://127.0.0.1:9090/targets` + +## 10) Local Auth Bootstrap + +Recommended local secret file: + +- `~/.zeroclaw/env` (permissions `600`) + +Suggested contents: + +- `OPENROUTER_API_KEY=...` + +Behavior: + +- `zeroclaw_dual_chat.sh`, `zeroclaw_stack_up.sh`, and `zeroclaw_webhook_send.sh` auto-load this file if present. diff --git a/tools/ai/zeroclaw_dual_chat.sh b/tools/ai/zeroclaw_dual_chat.sh index 3df69ef..415d0c2 100755 --- a/tools/ai/zeroclaw_dual_chat.sh +++ b/tools/ai/zeroclaw_dual_chat.sh @@ -1,6 +1,18 @@ #!/usr/bin/env bash set -euo pipefail +load_local_env() { + local env_file="${ZEROCLAW_ENV_FILE:-$HOME/.zeroclaw/env}" + [[ -f "$env_file" ]] || return 0 + chmod 600 "$env_file" >/dev/null 2>&1 || true + set -a + # shellcheck disable=SC1090 + source "$env_file" + set +a +} + +load_local_env + ZEROCLAW_BIN="${ZEROCLAW_BIN:-/Users/cils/Documents/Lelectron_rare/Kill_LIFE/zeroclaw/target/release/zeroclaw}" RTC_REPO="/Users/cils/Documents/Lelectron_rare/RTC_BL_PHONE" ZACUS_REPO="/Users/cils/Documents/Lelectron_rare/le-mystere-professeur-zacus" diff --git a/tools/ai/zeroclaw_stack_up.sh b/tools/ai/zeroclaw_stack_up.sh index d681ad1..3296069 100755 --- a/tools/ai/zeroclaw_stack_up.sh +++ b/tools/ai/zeroclaw_stack_up.sh @@ -1,6 +1,18 @@ #!/usr/bin/env bash set -euo pipefail +load_local_env() { + local env_file="${ZEROCLAW_ENV_FILE:-$HOME/.zeroclaw/env}" + [[ -f "$env_file" ]] || return 0 + chmod 600 "$env_file" >/dev/null 2>&1 || true + set -a + # shellcheck disable=SC1090 + source "$env_file" + set +a +} + +load_local_env + ROOT_DIR="/Users/cils/Documents/Lelectron_rare/Kill_LIFE" ART_DIR="${ZEROCLAW_ART_DIR:-$ROOT_DIR/artifacts/zeroclaw}" ZEROCLAW_BIN="${ZEROCLAW_BIN:-$ROOT_DIR/zeroclaw/target/release/zeroclaw}" @@ -13,6 +25,8 @@ PROM_PORT="${ZEROCLAW_PROM_PORT:-9090}" PROM_RETENTION="${ZEROCLAW_PROM_RETENTION:-24h}" PROM_SCRAPE_INTERVAL="${ZEROCLAW_PROM_SCRAPE_INTERVAL:-15s}" PROM_CONTAINER="${ZEROCLAW_PROM_CONTAINER:-zeroclaw-prometheus}" +DOCKER_WAIT_SECS="${ZEROCLAW_DOCKER_WAIT_SECS:-90}" +PROM_READY_WAIT_SECS="${ZEROCLAW_PROM_READY_WAIT_SECS:-15}" GW_PID_FILE="$ART_DIR/gateway.pid" FW_PID_FILE="$ART_DIR/follow.pid" @@ -61,6 +75,41 @@ listener_pid_on_port() { lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | head -n 1 } +ensure_docker_daemon() { + if ! command -v docker >/dev/null 2>&1; then + return 1 + fi + if docker info >/dev/null 2>&1; then + return 0 + fi + + if [[ "$(uname -s)" == "Darwin" ]]; then + open -ga Docker >/dev/null 2>&1 || true + fi + + local waited=0 + while (( waited < DOCKER_WAIT_SECS )); do + if docker info >/dev/null 2>&1; then + return 0 + fi + sleep 2 + waited=$((waited + 2)) + done + return 1 +} + +wait_for_prometheus_ready() { + local waited=0 + while (( waited < PROM_READY_WAIT_SECS )); do + if curl -fsS "http://$PROM_HOST:$PROM_PORT/-/ready" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + waited=$((waited + 1)) + done + return 1 +} + write_prometheus_config() { mkdir -p "$PROM_DATA_DIR" cat >"$PROM_CONFIG_FILE" </dev/null 2>&1; then @@ -94,7 +143,11 @@ start_prometheus_binary() { sleep 0.3 if is_running "$PROM_PID_FILE"; then printf '%s\n' "binary" >"$PROM_MANAGED_FILE" - PROM_STATUS="binary" + if wait_for_prometheus_ready; then + PROM_STATUS="binary(ready)" + else + PROM_STATUS="binary(starting)" + fi return 0 fi rm -f "$PROM_PID_FILE" @@ -105,14 +158,18 @@ start_prometheus_docker() { if ! command -v docker >/dev/null 2>&1; then return 1 fi - if ! docker info >/dev/null 2>&1; then + if ! ensure_docker_daemon; then return 1 fi local running_id running_id="$(docker ps --filter "name=^/${PROM_CONTAINER}$" --format '{{.ID}}' | head -n 1)" if [[ -n "$running_id" ]]; then printf '%s\n' "docker" >"$PROM_MANAGED_FILE" - PROM_STATUS="docker(existing:$PROM_CONTAINER)" + if wait_for_prometheus_ready; then + PROM_STATUS="docker(existing:$PROM_CONTAINER,ready)" + else + PROM_STATUS="docker(existing:$PROM_CONTAINER,starting)" + fi return 0 fi @@ -133,12 +190,100 @@ start_prometheus_docker() { --web.listen-address=:9090 2>>"$PROM_LOG" || true)" if [[ -n "$container_id" ]]; then printf '%s\n' "docker" >"$PROM_MANAGED_FILE" - PROM_STATUS="docker($PROM_CONTAINER)" + if wait_for_prometheus_ready; then + PROM_STATUS="docker($PROM_CONTAINER,ready)" + else + PROM_STATUS="docker($PROM_CONTAINER,starting)" + fi return 0 fi return 1 } +gateway_is_paired() { + curl -fsS "http://$GATEWAY_HOST:$GATEWAY_PORT/health" 2>/dev/null | python3 -c 'import json,sys +raw=sys.stdin.read().strip() +try: + obj=json.loads(raw) if raw else {} +except Exception: + sys.exit(1) +sys.exit(0 if obj.get("paired") else 1)' >/dev/null 2>&1 +} + +read_pair_token() { + [[ -f "$TOKEN_FILE" ]] || return 1 + cat "$TOKEN_FILE" 2>/dev/null +} + +token_is_usable() { + local token="${1:-}" + [[ -n "$token" ]] || return 1 + local http_status + http_status="$(curl -sS -o /dev/null -w "%{http_code}" -X POST "http://$GATEWAY_HOST:$GATEWAY_PORT/webhook" \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -d '{}' || true)" + + # 400/422 typically means auth passed but payload was intentionally malformed. + if [[ "$http_status" == "401" || "$http_status" == "403" || "$http_status" == "000" ]]; then + return 1 + fi + return 0 +} + +extract_pair_code() { + grep -Eo 'X-Pairing-Code: [0-9]{6}' "$GW_LOG" 2>/dev/null | tail -1 | awk '{print $2}' || true +} + +write_pair_token_from_code() { + local pair_code="$1" + local pair_json token + pair_json="$(curl -sS -X POST "http://$GATEWAY_HOST:$GATEWAY_PORT/pair" -H "X-Pairing-Code: $pair_code" || true)" + token="$(printf '%s' "$pair_json" | python3 -c 'import json,sys +raw=sys.stdin.read().strip() +try: + obj=json.loads(raw) if raw else {} +except Exception: + obj={} +print(obj.get("token",""), end="")')" + if [[ -z "$token" ]]; then + return 1 + fi + printf '%s\n' "$token" >"$TOKEN_FILE" + chmod 600 "$TOKEN_FILE" + return 0 +} + +ensure_gateway_pairing() { + local current_token="" + current_token="$(read_pair_token || true)" + if gateway_is_paired && token_is_usable "$current_token"; then + return 0 + fi + + local pair_code + pair_code="$(extract_pair_code)" + if [[ -n "$pair_code" ]]; then + write_pair_token_from_code "$pair_code" || true + fi + + current_token="$(read_pair_token || true)" + if gateway_is_paired && token_is_usable "$current_token"; then + return 0 + fi + + sleep 0.5 + pair_code="$(extract_pair_code)" + if [[ -n "$pair_code" ]]; then + write_pair_token_from_code "$pair_code" || true + fi + + current_token="$(read_pair_token || true)" + if ! token_is_usable "$current_token"; then + rm -f "$TOKEN_FILE" + fi +} + if is_running "$GW_PID_FILE"; then echo "[info] gateway already running (pid $(cat "$GW_PID_FILE"))." elif [[ -n "$(listener_pid_on_port "$GATEWAY_PORT")" ]]; then @@ -172,21 +317,7 @@ for _ in $(seq 1 40); do sleep 0.25 done -PAIR_CODE="$(grep -Eo 'X-Pairing-Code: [0-9]{6}' "$GW_LOG" 2>/dev/null | tail -1 | awk '{print $2}' || true)" -if [[ -n "$PAIR_CODE" ]]; then - PAIR_JSON="$(curl -sS -X POST "http://$GATEWAY_HOST:$GATEWAY_PORT/pair" -H "X-Pairing-Code: $PAIR_CODE" || true)" - TOKEN="$(printf '%s' "$PAIR_JSON" | python3 -c 'import json,sys -raw=sys.stdin.read().strip() -try: - obj=json.loads(raw) if raw else {} -except Exception: - obj={} -print(obj.get("token",""), end="")')" - if [[ -n "$TOKEN" ]]; then - printf '%s\n' "$TOKEN" >"$TOKEN_FILE" - chmod 600 "$TOKEN_FILE" - fi -fi +ensure_gateway_pairing write_prometheus_config case "$PROM_MODE" in @@ -196,8 +327,12 @@ case "$PROM_MODE" in ;; auto) rm -f "$PROM_MANAGED_FILE" - if ! start_prometheus_binary; then - PROM_STATUS="disabled(prometheus missing; use ZEROCLAW_PROM_MODE=docker)" + if start_prometheus_binary; then + : + elif start_prometheus_docker; then + : + else + PROM_STATUS="disabled(no prometheus backend available)" fi ;; binary) @@ -209,7 +344,7 @@ case "$PROM_MODE" in docker) rm -f "$PROM_MANAGED_FILE" if ! start_prometheus_docker; then - PROM_STATUS="disabled(docker unavailable)" + PROM_STATUS="disabled(docker unavailable or daemon not ready)" fi ;; *) @@ -443,5 +578,13 @@ EOF echo "Gateway: http://$GATEWAY_HOST:$GATEWAY_PORT/health" echo "Follow : http://127.0.0.1:$FOLLOW_PORT/" +echo "Prom : http://$PROM_HOST:$PROM_PORT/targets ($PROM_STATUS)" echo "Logs : $GW_LOG" echo "Token : $TOKEN_FILE" +if [[ ! -s "$TOKEN_FILE" ]]; then + if gateway_is_paired; then + echo "[warn] gateway is paired but local bearer token is unavailable; webhook_send may require ZEROCLAW_BEARER or gateway re-pair." + else + echo "[warn] pair token not found. If gateway is waiting for pairing, restart and pair again." + fi +fi diff --git a/tools/ai/zeroclaw_webhook_send.sh b/tools/ai/zeroclaw_webhook_send.sh index 7144caa..65fb78e 100755 --- a/tools/ai/zeroclaw_webhook_send.sh +++ b/tools/ai/zeroclaw_webhook_send.sh @@ -1,26 +1,43 @@ #!/usr/bin/env bash set -euo pipefail +load_local_env() { + local env_file="${ZEROCLAW_ENV_FILE:-$HOME/.zeroclaw/env}" + [[ -f "$env_file" ]] || return 0 + chmod 600 "$env_file" >/dev/null 2>&1 || true + set -a + # shellcheck disable=SC1090 + source "$env_file" + set +a +} + +load_local_env + ART_DIR="${ZEROCLAW_ART_DIR:-/Users/cils/Documents/Lelectron_rare/Kill_LIFE/artifacts/zeroclaw}" HOST="${ZEROCLAW_GATEWAY_HOST:-127.0.0.1}" PORT="${ZEROCLAW_GATEWAY_PORT:-3000}" TOKEN_FILE="$ART_DIR/pair_token.txt" CONVO_FILE="$ART_DIR/conversations.jsonl" +BUDGET_FILE="$ART_DIR/webhook_budget.json" +MAX_CALLS_PER_HOUR="${ZEROCLAW_WEBHOOK_MAX_CALLS_PER_HOUR:-40}" +MAX_CHARS="${ZEROCLAW_WEBHOOK_MAX_CHARS:-1200}" usage() { cat >&2 <] [--allow-model-call] "message" + $(basename "$0") [--repo-hint ] [--allow-model-call] [--dry-run] "message" Options: --repo-hint conversation source hint (e.g. rtc, zacus) - --allow-model-call required to actually send webhook requests + --allow-model-call legacy option (no longer required) + --dry-run validate payload and limits without network send -h, --help show this help USAGE } REPO_HINT="unknown" ALLOW_MODEL_CALL=0 +DRY_RUN=0 MESSAGE="" while [[ $# -gt 0 ]]; do @@ -38,6 +55,10 @@ while [[ $# -gt 0 ]]; do ALLOW_MODEL_CALL=1 shift ;; + --dry-run) + DRY_RUN=1 + shift + ;; -h|--help) usage exit 0 @@ -64,22 +85,79 @@ if [[ -z "$MESSAGE" ]]; then exit 1 fi -if [[ "$ALLOW_MODEL_CALL" -ne 1 ]]; then - echo "[guard] dry guard / credit protection: add --allow-model-call to send." >&2 - exit 10 +if [[ "$ALLOW_MODEL_CALL" -eq 1 ]]; then + echo "[info] --allow-model-call accepted (legacy no-op)." >&2 fi TOKEN="${ZEROCLAW_BEARER:-}" if [[ -z "$TOKEN" && -f "$TOKEN_FILE" ]]; then TOKEN="$(cat "$TOKEN_FILE")" fi + +mkdir -p "$ART_DIR" +touch "$CONVO_FILE" + +MESSAGE_CHARS="$(python3 -c 'import sys;print(len(sys.argv[1]))' "$MESSAGE")" +if (( MESSAGE_CHARS > MAX_CHARS )); then + echo "[budget] message length ${MESSAGE_CHARS} exceeds ZEROCLAW_WEBHOOK_MAX_CHARS=${MAX_CHARS}." >&2 + exit 11 +fi + +if [[ "$DRY_RUN" -eq 1 ]]; then + echo "[dry-run] payload validated; no network call performed." + echo "[dry-run] repo_hint=$REPO_HINT chars=$MESSAGE_CHARS" + exit 0 +fi + if [[ -z "$TOKEN" ]]; then echo "[fail] no bearer token found. Start stack first or set ZEROCLAW_BEARER." >&2 exit 2 fi -mkdir -p "$ART_DIR" -touch "$CONVO_FILE" +if ! python3 - "$BUDGET_FILE" "$MAX_CALLS_PER_HOUR" <<'PY' +import json +import os +import sys +import time + +path = sys.argv[1] +max_calls = int(sys.argv[2]) +now = int(time.time()) +window_start = now - 3600 + +state = {"calls": []} +if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + loaded = json.load(f) + if isinstance(loaded, dict): + state = loaded + except Exception: + state = {"calls": []} + +calls = [] +for item in state.get("calls", []): + try: + ts = int(float(item)) + except Exception: + continue + if ts >= window_start: + calls.append(ts) + +if max_calls > 0 and len(calls) >= max_calls: + print(f"[budget] hourly call limit reached: {len(calls)}/{max_calls}", file=sys.stderr) + sys.exit(1) + +calls.append(now) +tmp_path = path + ".tmp" +with open(tmp_path, "w", encoding="utf-8") as f: + json.dump({"calls": calls}, f, ensure_ascii=False, indent=2) +os.replace(tmp_path, path) +print(f"[budget] calls in last hour: {len(calls)}/{max_calls}", file=sys.stderr) +PY +then + exit 12 +fi PAYLOAD="$(python3 -c 'import json,sys;print(json.dumps({"message":sys.argv[1]}))' "$MESSAGE")" TMP_BODY="$(mktemp)"