2026-08-09 14:53:46 -04:00
#!/usr/bin/env bash
# Probes Cloudflare API auth state for the Turnstile Spin agent.
#
# Reads:
# $CLOUDFLARE_API_TOKEN (required)
# $CLOUDFLARE_ACCOUNT_ID (optional; if set, must be one of the token's accounts)
#
2026-08-13 21:04:57 -04:00
# Requires: bash, curl, python3. Optional: a user-approved WRANGLER_BIN for account enumeration.
2026-08-09 14:53:46 -04:00
#
2026-08-13 21:04:57 -04:00
# Outputs JSON to stdout, always exits 0. The agent reads `status`:
# "ok" ; selected account passed the Turnstile Edit-scope probe
# "missing_token" ; no token set, python3 unavailable, or account enumeration failed
# "missing_scope" ; token lacks Account.Turnstile:Edit on the selected account
# "multiple_accounts" ; token covers >1 accounts and $CLOUDFLARE_ACCOUNT_ID is unset
# "account_mismatch" ; $CLOUDFLARE_ACCOUNT_ID is set but is not in the token's accounts list
# "network_failure" ; the Edit-scope probe could not reach the Cloudflare API
# "upstream_failure" ; the Edit-scope probe returned an unexpected upstream response
#
# Account enumeration uses `WRANGLER_BIN whoami --json` only when WRANGLER_BIN is
# an approved canonical absolute path outside PROJECT_ROOT and WRANGLER_VERSION
# matches it exactly. Otherwise the caller must supply $CLOUDFLARE_ACCOUNT_ID.
#
# Human-readable diagnostics go to stderr.
2026-08-09 14:53:46 -04:00
2026-08-13 21:04:57 -04:00
set +x
2026-08-09 14:53:46 -04:00
set -uo pipefail
emit() {
echo " $1 "
exit 0
}
2026-08-13 21:04:57 -04:00
if ! command -v python3 >/dev/null 2>& 1; then
echo "auth-probe: python3 is required but not found in PATH." >& 2
emit '{"status":"missing_token","reason":"python3_not_available"}'
fi
2026-08-09 14:53:46 -04:00
token = " ${ CLOUDFLARE_API_TOKEN :- } "
2026-08-13 21:04:57 -04:00
unset CLOUDFLARE_API_TOKEN
2026-08-09 14:53:46 -04:00
declared_account = " ${ CLOUDFLARE_ACCOUNT_ID :- } "
if [ -z " $token " ] ; then
echo "auth-probe: \$CLOUDFLARE_API_TOKEN is not set." >& 2
emit '{"status":"missing_token","reason":"no_env_var"}'
fi
2026-08-13 21:04:57 -04:00
if [[ ! " $token " = ~ ^[ A-Za-z0-9_-] +$ ]] ; then
echo "auth-probe: CLOUDFLARE_API_TOKEN has an invalid format." >& 2
emit '{"status":"missing_token","reason":"invalid_token_format"}'
2026-08-09 14:53:46 -04:00
fi
2026-08-13 21:04:57 -04:00
accounts_json = ""
account_count = 0
2026-08-09 14:53:46 -04:00
2026-08-13 21:04:57 -04:00
if [ -n " ${ WRANGLER_BIN :- } " ] ; then
if [[ " $WRANGLER_BIN " != /* || ! -x " $WRANGLER_BIN " ]] ; then
echo "auth-probe: WRANGLER_BIN must be an executable absolute path." >& 2
emit '{"status":"missing_token","reason":"invalid_wrangler_path"}'
fi
wrangler_bin = $( python3 -I -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' " $WRANGLER_BIN " )
if [ " $wrangler_bin " != " $WRANGLER_BIN " ] ; then
echo "auth-probe: WRANGLER_BIN must be canonical, without symlinks." >& 2
emit '{"status":"missing_token","reason":"noncanonical_wrangler_path"}'
fi
if [ -n " ${ PROJECT_ROOT :- } " ] ; then
project_root = $( python3 -I -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' " $PROJECT_ROOT " )
if [[ " $wrangler_bin " == " $project_root " || " $wrangler_bin " == " $project_root /" * ]] ; then
echo "auth-probe: WRANGLER_BIN must be outside PROJECT_ROOT." >& 2
emit '{"status":"missing_token","reason":"project_local_wrangler"}'
fi
fi
if [ -z " ${ WRANGLER_VERSION :- } " ] ; then
echo "auth-probe: WRANGLER_VERSION is required with WRANGLER_BIN." >& 2
emit '{"status":"missing_token","reason":"missing_wrangler_version"}'
fi
actual_version = $(
" $wrangler_bin " --version 2>/dev/null |
python3 -I -c 'import re,sys; m=re.search(r"\b(\d+\.\d+\.\d+)\b", sys.stdin.read()); print(m.group(1) if m else "")'
)
if [ " $actual_version " != " $WRANGLER_VERSION " ] ; then
echo "auth-probe: WRANGLER_BIN version does not match WRANGLER_VERSION." >& 2
emit '{"status":"missing_token","reason":"wrangler_version_mismatch"}'
fi
whoami_json = $( CLOUDFLARE_API_TOKEN = " $token " " $wrangler_bin " whoami --json 2>/dev/null || true )
if [ -n " $whoami_json " ] && [ " $( printf '%s' " $whoami_json " | head -c 1) " = "{" ] ; then
accounts_json = $( printf '%s' " $whoami_json " | python3 -I -c '
import json, sys
try:
d = json.load(sys.stdin)
print(json.dumps(d.get("accounts") or []))
except Exception:
print("[]")
' )
account_count = $( printf '%s' " $accounts_json " | python3 -I -c '
import json, sys
try:
print(len(json.load(sys.stdin)))
except Exception:
print(0)
' )
fi
fi
if [ " $account_count " = "0" ] && [ -n " $declared_account " ] ; then
# No wrangler, but user gave us an account. Trust it and skip enumeration.
accounts_json = "[{\"id\": $( python3 -I -c 'import json, sys; print(json.dumps(sys.argv[1]))' " $declared_account " ) }]"
account_count = 1
fi
if [ " $account_count " = "0" ] ; then
echo "auth-probe: could not enumerate accounts. Export CLOUDFLARE_ACCOUNT_ID or provide an approved WRANGLER_BIN and WRANGLER_VERSION." >& 2
2026-08-09 14:53:46 -04:00
emit '{"status":"missing_token","reason":"no_accounts"}'
fi
if [ -n " $declared_account " ] ; then
2026-08-13 21:04:57 -04:00
in_list = $( printf '%s' " $accounts_json " | python3 -I -c '
import json, sys
target = sys.argv[1]
try:
accounts = json.load(sys.stdin)
except Exception:
print("false"); sys.exit(0)
print("true" if any((a or {}).get("id") == target for a in accounts) else "false")
' " $declared_account " )
2026-08-09 14:53:46 -04:00
if [ " $in_list " != "true" ] ; then
echo "auth-probe: \$CLOUDFLARE_ACCOUNT_ID ( $declared_account ) is not one of the token's accounts." >& 2
2026-08-13 21:04:57 -04:00
emit " $( python3 -I -c '
import json, sys
declared, accounts_raw = sys.argv[1], sys.argv[2]
try:
accounts = json.loads(accounts_raw)
except Exception:
accounts = []
print(json.dumps({"status":"account_mismatch","declared":declared,"accounts":accounts}))
' " $declared_account " " $accounts_json " ) "
2026-08-09 14:53:46 -04:00
fi
account_id = " $declared_account "
elif [ " $account_count " = "1" ] ; then
2026-08-13 21:04:57 -04:00
account_id = $( printf '%s' " $accounts_json " | python3 -I -c '
import json, sys
try:
print(json.load(sys.stdin)[0]["id"])
except Exception:
print("")
' )
if [ -z " $account_id " ] ; then
echo "auth-probe: accounts list had one entry but no id field." >& 2
emit '{"status":"missing_token","reason":"malformed_accounts"}'
fi
2026-08-09 14:53:46 -04:00
else
echo "auth-probe: token covers $account_count accounts; ask the user to pick one, then export \$CLOUDFLARE_ACCOUNT_ID and re-run." >& 2
2026-08-13 21:04:57 -04:00
emit " $( python3 -I -c '
import json, sys
try:
accounts = json.loads(sys.argv[1])
except Exception:
accounts = []
print(json.dumps({"status":"multiple_accounts","accounts":accounts}))
' " $accounts_json " ) "
2026-08-09 14:53:46 -04:00
fi
2026-08-13 21:04:57 -04:00
# Edit-scope probe. A GET /challenges/widgets would authorize a Read-only
# token; to verify Edit specifically, POST with an intentionally invalid
# payload and interpret the response:
# 401 or 403 → token lacks Edit
# 200 with success:false, errors[0].code=10000 → token lacks Edit
# 400/422 or 200 with validation error codes → Edit scope OK
#
# The API rejects the empty-name/empty-domains payload with 400 today, so
# no widget is created. If validation ever loosens and the probe accidentally
# creates one, we detect the returned sitekey and DELETE it as a safety net
# so the probe stays side-effect-free.
account_enc = $( python3 -I -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' " $account_id " )
2026-08-09 14:53:46 -04:00
2026-08-13 21:04:57 -04:00
if ! probe_response = " $(
printf 'header = "Authorization: Bearer %s"\n' " $token " |
curl --disable --config - --silent --show-error --write-out $'\n%{http_code}' -X POST \
"https://api.cloudflare.com/client/v4/accounts/ $account_enc /challenges/widgets" \
-H "Content-Type: application/json" \
--data '{"name":"","domains":[]}'
) " ; then
echo "auth-probe: network failure probing Edit scope on account $account_id ." >& 2
emit '{"status":"network_failure","account_id":"' " $account_id " '"}'
2026-08-09 14:53:46 -04:00
fi
2026-08-13 21:04:57 -04:00
edit_code = " ${ probe_response ##* $'\n' } "
probe_body = " ${ probe_response % $'\n' * } "
probe_output = $( printf '%s' " $probe_body " | python3 -I -c '
import json, sys
http_code = sys.argv[1]
verdict = "unknown"
created_sitekey = ""
try:
raw = sys.stdin.read()
data = json.loads(raw) if raw else {}
except Exception:
data = None
if isinstance(data, dict):
errors = data.get("errors") or []
if not isinstance(errors, list):
errors = []
first = (errors[0] or {}) if errors else {}
if not isinstance(first, dict):
first = {}
first_code = first.get("code", 0)
if http_code in ("401", "403"):
verdict = "missing_scope"
elif http_code == "200" and data.get("success") is False and first_code == 10000:
verdict = "missing_scope"
elif http_code in ("400", "422"):
verdict = "scope_ok"
elif http_code == "200":
# Any 200 that got past auth means scope is fine (whether success or not).
verdict = "scope_ok"
else:
verdict = f"unexpected_{http_code}"
# Detect accidental widget creation (safety net if API validation ever
# accepts the empty-name/empty-domains probe payload).
result = data.get("result")
if isinstance(result, dict) and data.get("success") is True:
sk = result.get("sitekey", "")
if isinstance(sk, str) and sk:
created_sitekey = sk
print(f"{verdict}|{created_sitekey}")
' " $edit_code " )
unset probe_body probe_response
verdict = " ${ probe_output %%|* } "
created_sitekey = " ${ probe_output #*| } "
[ " $created_sitekey " = " $probe_output " ] && created_sitekey = ""
2026-08-09 14:53:46 -04:00
2026-08-13 21:04:57 -04:00
# If the probe unexpectedly created a widget (API validation loosened),
# DELETE it so the probe stays side-effect-free.
if [ -n " $created_sitekey " ] ; then
echo "auth-probe: probe unexpectedly created widget $created_sitekey ; cleaning up..." >& 2
sk_enc = $( python3 -I -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' " $created_sitekey " )
cleanup_code = $(
printf 'header = "Authorization: Bearer %s"\n' " $token " |
curl --disable --config - --silent --show-error --output /dev/null --write-out "%{http_code}" -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/ $account_enc /challenges/widgets/ $sk_enc " || echo "000"
)
case " $cleanup_code " in
2*) echo "auth-probe: cleanup DELETE for widget $created_sitekey succeeded (HTTP $cleanup_code )." >& 2 ;;
*) echo "auth-probe: cleanup DELETE for widget $created_sitekey FAILED (HTTP $cleanup_code ). Please remove it from the Turnstile dashboard manually." >& 2 ;;
esac
2026-08-09 14:53:46 -04:00
fi
2026-08-13 21:04:57 -04:00
case " $verdict " in
scope_ok)
emit " $( python3 -I -c '
import json, sys
account_id, accounts_raw = sys.argv[1], sys.argv[2]
try:
accounts = json.loads(accounts_raw)
except Exception:
accounts = []
print(json.dumps({"status":"ok","account_id":account_id,"accounts":accounts}))
' " $account_id " " $accounts_json " ) "
;;
missing_scope)
echo "auth-probe: token cannot write /challenges/widgets on account $account_id (HTTP $edit_code ). Missing Account.Turnstile:Edit." >& 2
emit " $( python3 -I -c '
import json, sys
account_id, http_code = sys.argv[1], sys.argv[2]
try:
code_num = int(http_code)
except ValueError:
code_num = 0
print(json.dumps({"status":"missing_scope","account_id":account_id,"http_code":code_num}))
' " $account_id " " $edit_code " ) "
;;
*)
echo "auth-probe: unexpected response probing Edit scope on account $account_id (HTTP $edit_code )." >& 2
emit " $( python3 -I -c '
import json, sys
account_id, http_code = sys.argv[1], sys.argv[2]
try:
code_num = int(http_code)
except ValueError:
code_num = 0
print(json.dumps({"status":"upstream_failure","account_id":account_id,"http_code":code_num}))
' " $account_id " " $edit_code " ) "
;;
esac