1 Commits

Author SHA1 Message Date
Clément SAILLANT e630ac08e0 add autonomous zeroclaw auth/bootstrap and dual prometheus fallback 2026-02-21 02:26:07 +01:00
4 changed files with 297 additions and 35 deletions
+34 -5
View File
@@ -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 <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.
+12
View File
@@ -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"
+166 -23
View File
@@ -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" <<EOF
@@ -78,7 +127,7 @@ EOF
start_prometheus_binary() {
if is_running "$PROM_PID_FILE"; then
PROM_STATUS="binary(pid $(cat "$PROM_PID_FILE"))"
PROM_STATUS="binary(existing pid $(cat "$PROM_PID_FILE"))"
return 0
fi
if ! command -v prometheus >/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
+85 -7
View File
@@ -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 <<USAGE
Usage:
$(basename "$0") [--repo-hint <hint>] [--allow-model-call] "message"
$(basename "$0") [--repo-hint <hint>] [--allow-model-call] [--dry-run] "message"
Options:
--repo-hint <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)"