diff --git a/.claude/rules/shell-scripts.md b/.claude/rules/shell-scripts.md index 51e9e7a0..82124a50 100644 --- a/.claude/rules/shell-scripts.md +++ b/.claude/rules/shell-scripts.md @@ -25,10 +25,10 @@ macOS ships bash 3.2. All scripts MUST work on it: ## Use Bun + TypeScript for Inline Scripting — NEVER python/python3 When shell scripts need JSON processing, HTTP calls, crypto, or any non-trivial logic: -- **ALWAYS** use `bun eval '...'` or write a temp `.ts` file and `bun run` it +- **ALWAYS** use `bun -e '...'` or write a temp `.ts` file and `bun run` it - **NEVER** use `python3 -c` or `python -c` for inline scripting — python is not a project dependency -- Prefer `jq` for simple JSON extraction; fall back to `bun eval` when jq is unavailable -- Pass data to bun via environment variables (e.g., `_DATA="${var}" bun eval "..."`) or temp files — never interpolate untrusted values into JS strings +- Prefer `jq` for simple JSON extraction; fall back to `bun -e` when jq is unavailable +- Pass data to bun via environment variables (e.g., `_DATA="${var}" bun -e "..."`) or temp files — never interpolate untrusted values into JS strings - For complex operations (SigV4 signing, API calls with retries), write a heredoc `.ts` file and `bun run` it ## ESM Only — NEVER use require() or CommonJS diff --git a/.claude/skills/setup-agent-team/qa.sh b/.claude/skills/setup-agent-team/qa.sh index d4373299..47d95fad 100644 --- a/.claude/skills/setup-agent-team/qa.sh +++ b/.claude/skills/setup-agent-team/qa.sh @@ -226,6 +226,29 @@ if [[ "${RUN_MODE}" == "e2e" ]]; then fi fi +# --- Load Telegram credentials for soak mode --- +if [[ "${RUN_MODE}" == "soak" ]]; then + if [[ -f /etc/spawn-qa-auth.env ]]; then + while IFS='=' read -r _tkey _tval || [[ -n "${_tkey}" ]]; do + _tkey="${_tkey#"${_tkey%%[! ]*}"}" + _tkey="${_tkey%"${_tkey##*[! ]}"}" + [[ -z "${_tkey}" || "${_tkey}" == \#* ]] && continue + case "${_tkey}" in + TELEGRAM_BOT_TOKEN|TELEGRAM_TEST_CHAT_ID|SOAK_CLOUD) + export "${_tkey}=${_tval}" + ;; + esac + done < /etc/spawn-qa-auth.env + if [[ -n "${TELEGRAM_BOT_TOKEN:-}" ]] && [[ -n "${TELEGRAM_TEST_CHAT_ID:-}" ]]; then + log "Telegram credentials loaded for soak test (cloud: ${SOAK_CLOUD:-sprite})" + else + log "WARNING: TELEGRAM_BOT_TOKEN or TELEGRAM_TEST_CHAT_ID missing from /etc/spawn-qa-auth.env — soak test will fail" + fi + else + log "WARNING: /etc/spawn-qa-auth.env not found — soak test requires TELEGRAM_BOT_TOKEN and TELEGRAM_TEST_CHAT_ID" + fi +fi + # Launch Claude Code with mode-specific prompt # Enable agent teams (required for team-based workflows) export CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 04ebfce4..9092268d 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -1,7 +1,8 @@ name: QA on: schedule: - - cron: '0 */4 * * *' + - cron: '0 */4 * * *' # Every 4 hours — quality sweep + - cron: '0 3 * * 1' # Every Monday 3am UTC — Telegram soak test (OpenClaw on DigitalOcean) workflow_dispatch: inputs: reason: @@ -24,7 +25,11 @@ jobs: SPRITE_URL: ${{ secrets.QA_SPRITE_URL }} TRIGGER_SECRET: ${{ secrets.QA_TRIGGER_SECRET }} run: | - REASON="${{ github.event.inputs.reason || 'schedule' }}" + if [ "${{ github.event_name }}" = "schedule" ] && [ "${{ github.event.schedule }}" = "0 3 * * 1" ]; then + REASON="soak" + else + REASON="${{ github.event.inputs.reason || 'schedule' }}" + fi curl -sS --fail-with-body -X POST \ "${SPRITE_URL}/trigger?reason=${REASON}" \ -H "Authorization: Bearer ${TRIGGER_SECRET}" diff --git a/sh/e2e/lib/soak.sh b/sh/e2e/lib/soak.sh index 80e8cf9e..b9c1906e 100644 --- a/sh/e2e/lib/soak.sh +++ b/sh/e2e/lib/soak.sh @@ -19,6 +19,7 @@ set -eo pipefail # --------------------------------------------------------------------------- SOAK_WAIT_SECONDS="${SOAK_WAIT_SECONDS:-3600}" SOAK_CRON_DELAY_SECONDS="${SOAK_CRON_DELAY_SECONDS:-3300}" +SOAK_CLOUD="${SOAK_CLOUD:-sprite}" SOAK_HEARTBEAT_INTERVAL=300 # 5 minutes SOAK_GATEWAY_PORT=18789 TELEGRAM_API_BASE="https://api.telegram.org" @@ -146,14 +147,15 @@ soak_inject_telegram_config() { log_step "Patching ~/.openclaw/openclaw.json with Telegram bot token..." - # Use bun eval on the remote to JSON-patch the config file + # Use bun -e on the remote to JSON-patch the config file. + # _TOKEN is passed via env var prefix so process.env._TOKEN is available in bun. cloud_exec "${app}" "source ~/.spawnrc 2>/dev/null; \ export PATH=\$HOME/.npm-global/bin:\$HOME/.bun/bin:\$HOME/.local/bin:\$PATH; \ _TOKEN=\$(printf '%s' '${encoded_token}' | base64 -d); \ - bun eval ' \ + _TOKEN=\${_TOKEN} bun -e ' \ import { mkdirSync, readFileSync, writeFileSync } from \"node:fs\"; \ import { dirname } from \"node:path\"; \ - const configPath = process.env.HOME + \"/.openclaw/openclaw.json\"; \ + const configPath = (process.env.HOME ?? \"\") + \"/.openclaw/openclaw.json\"; \ let config = {}; \ try { config = JSON.parse(readFileSync(configPath, \"utf-8\")); } catch {} \ if (!config.channels) config.channels = {}; \ @@ -162,7 +164,7 @@ soak_inject_telegram_config() { mkdirSync(dirname(configPath), { recursive: true }); \ writeFileSync(configPath, JSON.stringify(config, null, 2)); \ console.log(\"Telegram config injected\"); \ - '" >/dev/null 2>&1 + '" 2>&1 if [ $? -ne 0 ]; then log_err "Failed to inject Telegram config" @@ -477,7 +479,7 @@ soak_run_telegram_tests() { # --------------------------------------------------------------------------- # run_soak_test [LOG_DIR] # -# Orchestrator: validate env → load sprite driver → provision openclaw → +# Orchestrator: validate env → load cloud driver (SOAK_CLOUD) → provision openclaw → # verify → inject telegram config → schedule openclaw cron reminder → # soak wait → run tests (including openclaw cron verification) → teardown. # --------------------------------------------------------------------------- @@ -488,6 +490,7 @@ run_soak_test() { fi log_header "Spawn Soak Test: OpenClaw + Telegram (with cron reminder)" + log_info "Cloud: ${SOAK_CLOUD}" log_info "Soak wait: ${SOAK_WAIT_SECONDS}s" log_info "Cron delay: ${SOAK_CRON_DELAY_SECONDS}s" @@ -497,8 +500,8 @@ run_soak_test() { return 1 fi - # Load sprite cloud driver - load_cloud_driver "sprite" + # Load cloud driver (configurable via SOAK_CLOUD, default: sprite) + load_cloud_driver "${SOAK_CLOUD}" # Validate cloud environment if ! require_env; then diff --git a/sh/shared/github-auth.sh b/sh/shared/github-auth.sh index 57b940e1..23d65d18 100755 --- a/sh/shared/github-auth.sh +++ b/sh/shared/github-auth.sh @@ -136,11 +136,11 @@ _fetch_gh_latest_version() { } local latest_version="" - # Prefer jq for safe JSON parsing; fall back to bun eval (never python) + # Prefer jq for safe JSON parsing; fall back to bun -e (never python) if command -v jq &>/dev/null; then latest_version=$(printf '%s' "${api_response}" | jq -r '.tag_name // empty' 2>/dev/null) || true elif command -v bun &>/dev/null; then - latest_version=$(_GH_API_RESPONSE="${api_response}" bun eval " + latest_version=$(_GH_API_RESPONSE="${api_response}" bun -e " const data = JSON.parse(process.env._GH_API_RESPONSE || '{}'); const tag = typeof data.tag_name === 'string' ? data.tag_name : ''; process.stdout.write(tag); diff --git a/sh/shared/key-request.sh b/sh/shared/key-request.sh index e0549739..218e0bfd 100644 --- a/sh/shared/key-request.sh +++ b/sh/shared/key-request.sh @@ -29,7 +29,7 @@ _check_cli_auth_clouds() { if command -v jq &>/dev/null; then cli_clouds=$(jq -r '.clouds | to_entries[] | select(.value.auth != null) | select(.value.auth | test("\\b(login|configure|setup)\\b"; "i")) | "\(.key)|\(.value.auth)"' "${manifest_path}" 2>/dev/null) else - cli_clouds=$(_MANIFEST="${manifest_path}" bun eval " + cli_clouds=$(_MANIFEST="${manifest_path}" bun -e " import fs from 'fs'; const m = JSON.parse(fs.readFileSync(process.env._MANIFEST, 'utf8')); for (const [key, cloud] of Object.entries(m.clouds || {})) { @@ -58,7 +58,7 @@ for (const [key, cloud] of Object.entries(m.clouds || {})) { if command -v jq &>/dev/null; then project=$(jq -r '.GCP_PROJECT // .project // "" | select(. != null)' "${gcp_config}" 2>/dev/null) else - project=$(_FILE="${gcp_config}" bun eval " + project=$(_FILE="${gcp_config}" bun -e " import fs from 'fs'; const d = JSON.parse(fs.readFileSync(process.env._FILE, 'utf8')); process.stdout.write(d.GCP_PROJECT || d.project || ''); @@ -95,7 +95,7 @@ _parse_cloud_auths() { if command -v jq &>/dev/null; then jq -r '.clouds | to_entries[] | select(.value.auth != null and .value.auth != "") | select(.value.key_request != false) | select(.value.auth | test("\\b(login|configure|setup)\\b"; "i") | not) | "\(.key)|\(.value.auth)"' "${manifest_path}" 2>/dev/null else - _MANIFEST="${manifest_path}" bun eval " + _MANIFEST="${manifest_path}" bun -e " import fs from 'fs'; const m = JSON.parse(fs.readFileSync(process.env._MANIFEST, 'utf8')); for (const [key, cloud] of Object.entries(m.clouds || {})) { @@ -134,7 +134,7 @@ _try_load_env_var() { if command -v jq &>/dev/null; then val=$(jq -r --arg v "${var_name}" '(.[$v] // .api_key // .token) // "" | select(. != null)' "${config_file}" 2>/dev/null) else - val=$(_FILE="${config_file}" _VAR="${var_name}" bun eval " + val=$(_FILE="${config_file}" _VAR="${var_name}" bun -e " import fs from 'fs'; const d = JSON.parse(fs.readFileSync(process.env._FILE, 'utf8')); process.stdout.write(d[process.env._VAR] || d.api_key || d.token || ''); @@ -268,7 +268,7 @@ request_missing_cloud_keys() { if command -v jq &>/dev/null; then providers_json=$(printf '%s\n' ${MISSING_KEY_PROVIDERS} | jq -Rn '[inputs | select(. != "")]' 2>/dev/null) || return 0 elif command -v bun &>/dev/null; then - providers_json=$(_PROVIDERS="${MISSING_KEY_PROVIDERS}" bun eval " + providers_json=$(_PROVIDERS="${MISSING_KEY_PROVIDERS}" bun -e " const providers = process.env._PROVIDERS.trim().split(/\s+/).filter(Boolean); process.stdout.write(JSON.stringify(providers)); " 2>/dev/null) || return 0