Skill: ugurinanc12/codex-profile-failover (version 1.0.2)

--- name: codex-profile-failover description: OpenClaw içinde birden fazla openai-codex OAuth profili varken otomatik ve manuel profil taklası sağlar. Aktif profilin kalan kullanım oranı yüzde 10 gibi bir eşik altına düştüğünde, 401 veya eksik token gibi usage/auth hataları oluştuğunda ya da operatör manuel tetikleme istediğinde session `authProfileOverride` alanını daha sağlıklı profile geçirmen gerektiğinde kullan. --- # Codex Profile Failover Bu skill, OpenClaw auth profilleri üstüne hafif bir Codex profil takla katmanı ekler. ## Bu skill ne sağlar - Aktif profili canlı usage ile izleyen watchdog - Cron veya tek seferlik kullanım için threshold guard - Operatörün elle çalıştırabileceği manuel tetikleme scripti - Session için doğrudan `authProfileOverride` yazan helper - Genel amaçlı config şablonları ve kurulum notları ## Temel davranış Bir profil şu koşullardan biri varsa değiştirilmeye aday sayılır: - efektif kalan kullanım oranı eşik değerin altına düşmüşse - usage kontrolü auth veya transport hatası döndürüyorsa ve `switchOnUsageError` açıksa - token yerelde eksik veya süresi geçmiş görünüyorsa Geçişte, eşik üstünde kalan en sağlıklı alternatif profil tercih edilir. Tüm adaylarda canlı usage okunamıyorsa, yerelde hâlâ geçerli görünen token için sınırlı bir fallback uygulanabilir. ## Dahili scriptler - `scripts/codex_profile_runtime.py` auth profile okuma ve canlı usage yardımcıları - `scripts/codex_profile_watchdog.py` uzun süreli arka plan watchdog - `scripts/codex_profile_threshold_guard.py` tek seferlik eşik kontrolü - `scripts/assign_codex_profile_to_session.py` session `authProfileOverride` alanını yazar - `scripts/trigger_profile_failover.py` operatör için manuel takla tetikleyicisi - `scripts/install_codex_profile_failover.py` hedef workspace içine generic config yazar ## Hızlı başlangıç 1. `references/setup.md` dosyasını oku. 2. Installer helper'ı çalıştır veya örnek configleri elle kopyala. 3. Önce dry-run ile kontrol et. 4. Sonra bir kez apply ile gerçek geçiş dene. 5. Her şey doğruysa watchdog'u arka planda başlat. ## Önerilen kurulum akışı ### Configleri yaz ```bash python3 scripts/install_codex_profile_failover.py --workspace /path/to/workspace --session-key agent:main:main ``` ### Dry-run kontrolü ```bash python3 scripts/codex_profile_threshold_guard.py --config /path/to/workspace/config/codex-profile-rotation.json --json ``` ### Gerçek bir geçiş uygula ```bash python3 scripts/codex_profile_threshold_guard.py --config /path/to/workspace/config/codex-profile-rotation.json --apply --json ``` ### Watchdog'u başlat ```bash nohup python3 scripts/codex_profile_watchdog.py --config /path/to/workspace/config/codex-profile-watchdog.json --apply >> /path/to/workspace/state/codex-profile-watchdog.log 2>&1 & ``` ### Manuel tetikleme ```bash python3 scripts/trigger_profile_failover.py --config /path/to/workspace/config/codex-profile-watchdog.json ``` ## Tasarım kuralları - Profil id'lerini sabit ve generic tut. - Mümkünse email veya account başına tek profil yaklaşımını tercih et. - Workspace snapshot mantığını otomatik yan etki değil, açık uyumluluk katmanı olarak tut. - Skill içine kişisel email, user id veya kanal id koyma. - Ortama özel değerleri scriptlerin içine gömme, kurulum sırasında yazılan configlerde tut. ## Önemli not Bu skill, install edildiği anda tek başına tamamen aktif olmaz. Skill dosyalarını getirir, sonra installer helper ile config yazılması ve watchdog'un ayrıca başlatılması gerekir. ## Doğrulama listesi - `auth-profiles.json` içinde birden fazla `openai-codex:*` profil var - hedef session `sessions.json` içinde mevcut - dry-run beklenen aktif profili ve adayı gösteriyor - apply sonrası `authProfileOverride` ve `authProfileOverrideSource` yazılıyor - event log ve backup dosyaları workspace state dizininde oluşuyor - watchdog tekrar eden çalışmalarda sağlıklı kalıyor ## Referanslar - Kurulum ve publish öncesi temizlik için `references/setup.md` - Generic config örnekleri için `references/watchdog-config.example.json` ve `references/threshold-config.example.json`

path: references/setup.md
content: # Setup

## Goal

Install a generic Codex profile failover layer for OpenClaw without bundling any personal emails, account ids, or chat ids.

## Prerequisites

- OpenClaw state directory exists, usually `/home/node/.openclaw`
- `auth-profiles.json` contains multiple `openai-codex:*` profiles
- target session already exists in `sessions.json`
- Python 3 is available

## Docker içinde kullanım

OpenClaw bir container içinde çalışıyorsa komutları container içinde çalıştır.

Örnek shell erişimi:

```bash
docker exec -it <container-name> bash
```

Tek komut installer örneği:

```bash
docker exec -it <container-name> python3 /workspace/skills/codex-profile-failover/scripts/install_codex_profile_failover.py --workspace /workspace --session-key agent:main:main
```

Not: gerçek workspace yolu container içindeki kurulumuna göre değişebilir. Sık görülen yollar `/workspace`, `/app`, veya OpenClaw workspace mount noktasıdır.

## Codex login mantığı

Bu skill yeni bir Codex login akışı sağlamaz. Önce OpenClaw tarafında Codex hesaplarının normal giriş akışıyla eklenmiş olması gerekir.

Beklenen durum:

- `auth-profiles.json` içinde birden fazla `openai-codex:*` profil vardır
- skill bu mevcut profiller arasında geçiş yapar
- aktif session için `authProfileOverride` alanını yönetir

Yani sıralama şöyledir:

1. önce Codex hesaplarını OpenClaw içine bağla
2. sonra failover configlerini yaz
3. sonra watchdog veya manuel trigger ile profil taklasını çalıştır

## Recommended install path

Run the installer helper from the skill directory:

```bash
python3 scripts/install_codex_profile_failover.py --workspace /path/to/workspace --session-key agent:main:main
```

This writes two workspace-local config files:

- `config/codex-profile-watchdog.json`
- `config/codex-profile-rotation.json`

## Dry run first

```bash
python3 scripts/codex_profile_threshold_guard.py \
  --config /path/to/workspace/config/codex-profile-rotation.json \
  --state-dir /home/node/.openclaw \
  --json
```

Confirm that:

- the current profile is detected correctly
- a healthy alternative candidate appears when expected
- no personal identifiers were hardcoded into the config

## Apply one switch

```bash
python3 scripts/codex_profile_threshold_guard.py \
  --config /path/to/workspace/config/codex-profile-rotation.json \
  --state-dir /home/node/.openclaw \
  --apply --json
```

## Start background watchdog

```bash
nohup python3 scripts/codex_profile_watchdog.py \
  --config /path/to/workspace/config/codex-profile-watchdog.json \
  --state-dir /home/node/.openclaw \
  --apply >> /path/to/workspace/state/codex-profile-watchdog.log 2>&1 &
```

## Manual trigger

Use the manual trigger helper when the operator wants an explicit rotation attempt:

```bash
python3 scripts/trigger_profile_failover.py \
  --config /path/to/workspace/config/codex-profile-watchdog.json \
  --state-dir /home/node/.openclaw
```

## Session patch helper

Use this only when you already know the destination profile id:

```bash
python3 scripts/assign_codex_profile_to_session.py \
  agent:main:main \
  openai-codex:secondary \
  --config /path/to/workspace/config/codex-profile-watchdog.json \
  --state-dir /home/node/.openclaw
```

## Privacy rules before publishing

- remove local logs and backups from the publish folder
- keep only generic session ids in examples, such as `agent:main:main`
- do not include real emails, tokens, account ids, or workspace names in docs or scripts
- keep event log paths generic and workspace-local


path: references/threshold-config.example.json
content: {
  "agent": "main",
  "thresholdPercent": 10,
  "switchOnUsageError": true,
  "sessionTargets": [
    "agent:main:main"
  ],
  "switchSource": "auto-threshold",
  "eventLogPath": "/path/to/workspace/state/codex-profile-switch-events.jsonl"
}


path: references/watchdog-config.example.json
content: {
  "agent": "main",
  "thresholdPercent": 10,
  "switchOnUsageError": true,
  "sessionTargets": [
    "agent:main:main"
  ],
  "intervalSeconds": 60,
  "usageProbeEverySeconds": 60,
  "switchSource": "auto-watchdog",
  "eventLogPath": "/path/to/workspace/state/codex-profile-watchdog-events.jsonl",
  "logPath": "/path/to/workspace/state/codex-profile-watchdog.log"
}


path: scripts/assign_codex_profile_to_session.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import time
from pathlib import Path

from codex_profile_runtime import append_event, backup_file, load_json, write_json

DEFAULT_STATE_DIR = Path("/home/node/.openclaw")


def main() -> int:
    parser = argparse.ArgumentParser(description="Assign a Codex authProfileOverride to a session")
    parser.add_argument("session_key", help="Session key to patch")
    parser.add_argument("profile_id", help="Codex profile id to assign")
    parser.add_argument("--config", required=True, help="Config path used to resolve agent and event log")
    parser.add_argument("--state-dir", default=str(DEFAULT_STATE_DIR), help="OpenClaw state directory")
    parser.add_argument("--source", help="Override authProfileOverrideSource")
    parser.add_argument("--json", action="store_true", help="Print JSON output")
    args = parser.parse_args()

    config = load_json(Path(args.config))
    state_dir = Path(args.state_dir)
    agent = config.get("agent", "main")
    source = args.source or config.get("switchSource", "manual")
    sessions_path = state_dir / "agents" / agent / "sessions" / "sessions.json"
    event_log_path = Path(config.get("eventLogPath", "state/codex-profile-broker-events.jsonl"))

    sessions = load_json(sessions_path)
    entry = sessions.get(args.session_key)
    if not isinstance(entry, dict):
        raise SystemExit(f"Session not found: {args.session_key}")

    old_profile_id = entry.get("authProfileOverride")
    backup_path = backup_file(sessions_path)
    entry["authProfileOverride"] = args.profile_id
    entry["authProfileOverrideSource"] = source
    entry["updatedAt"] = int(time.time() * 1000)
    write_json(sessions_path, sessions)

    event = {
        "timestamp": int(time.time()),
        "sessionKey": args.session_key,
        "oldProfileId": old_profile_id,
        "newProfileId": args.profile_id,
        "source": source,
        "backup": str(backup_path),
    }
    append_event(event_log_path, event)

    payload = {
        "sessionKey": args.session_key,
        "oldProfileId": old_profile_id,
        "newProfileId": args.profile_id,
        "source": source,
        "backup": str(backup_path),
    }
    print(json.dumps(payload, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/codex_profile_runtime.py
content: #!/usr/bin/env python3
from __future__ import annotations

import base64
import json
import shutil
import socket
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any

USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"


def load_json(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8"))


def write_json(path: Path, payload: dict[str, Any]) -> None:
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def decode_email(profile: dict[str, Any]) -> str | None:
    access = profile.get("access") or profile.get("token")
    if not isinstance(access, str) or access.count(".") < 2:
        return None
    try:
        payload = access.split(".")[1]
        payload += "=" * (-len(payload) % 4)
        decoded = json.loads(base64.urlsafe_b64decode(payload.encode()).decode())
        return ((decoded.get("https://api.openai.com/profile") or {}).get("email"))
    except Exception:
        return None


def collect_profiles(auth: dict[str, Any]) -> list[dict[str, Any]]:
    profiles = []
    for profile_id, profile in (auth.get("profiles") or {}).items():
        if not str(profile_id).startswith("openai-codex:") or not isinstance(profile, dict):
            continue
        profiles.append(
            {
                "profileId": profile_id,
                "accountId": profile.get("accountId"),
                "email": profile.get("email") or decode_email(profile),
                "access": profile.get("access") or profile.get("token"),
                "expires": profile.get("expires"),
                "lastGood": profile.get("lastGood"),
            }
        )
    profiles.sort(key=lambda item: item["profileId"])
    return profiles


def fetch_usage(profile: dict[str, Any], timeout_seconds: int) -> dict[str, Any]:
    [REDACTED_SECRET]"access")
    if not [REDACTED_SECRET] {"error": "missing_token"}
    headers = {
        "Authorization": f"Bearer {token}",
        "User-Agent": "OpenClaw/1.0",
        "Accept": "application/json",
    }
    if profile.get("accountId"):
        headers["ChatGPT-Account-Id"] = profile["accountId"]
    req = urllib.request.Request(USAGE_URL, headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=timeout_seconds) as response:
            return json.loads(response.read().decode("utf-8"))
    except urllib.error.HTTPError as exc:
        return {"error": f"http_{exc.code}"}
    except socket.timeout:
        return {"error": "timeout"}
    except Exception as exc:
        return {"error": exc.__class__.__name__}


def remaining_percent(window: dict[str, Any] | None) -> float | None:
    if not isinstance(window, dict) or window.get("used_percent") is None:
        return None
    return max(0.0, 100.0 - float(window.get("used_percent") or 0))


def effective_remaining(usage: dict[str, Any]) -> float | None:
    if usage.get("error"):
        return None
    rate_limit = usage.get("rate_limit") or {}
    candidates: list[float] = []
    for key in ("primary_window", "secondary_window"):
        value = remaining_percent(rate_limit.get(key))
        if value is not None:
            candidates.append(value)
    review_limit = usage.get("code_review_rate_limit") or {}
    for key in ("primary_window", "secondary_window"):
        value = remaining_percent(review_limit.get(key))
        if value is not None:
            candidates.append(value)
    if not candidates:
        return None
    return min(candidates)


def build_usage_rows(profiles: list[dict[str, Any]], timeout_seconds: int) -> list[dict[str, Any]]:
    rows = []
    for profile in profiles:
        usage = fetch_usage(profile, timeout_seconds)
        rows.append(
            {
                "profileId": profile["profileId"],
                "email": profile.get("email"),
                "accountId": profile.get("accountId"),
                "usage": usage,
                "effectiveRemaining": effective_remaining(usage),
            }
        )
    return rows


def choose_best_candidate(
    rows: list[dict[str, Any]],
    current_profile_id: str | None,
    threshold: float,
    excluded_profile_ids: set[str] | None = None,
) -> dict[str, Any] | None:
    excluded_profile_ids = excluded_profile_ids or set()
    eligible = [
        row
        for row in rows
        if row["profileId"] != current_profile_id
        and row["profileId"] not in excluded_profile_ids
        and row.get("effectiveRemaining") is not None
        and row["effectiveRemaining"] > threshold
    ]
    if not eligible:
        return None
    eligible.sort(key=lambda item: (item["effectiveRemaining"], item["profileId"]), reverse=True)
    return eligible[0]


def append_event(path: Path, event: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as handle:
        handle.write(json.dumps(event, ensure_ascii=False) + "\n")


def backup_file(path: Path) -> Path:
    backup_path = path.with_suffix(path.suffix + f".bak-{int(time.time())}")
    shutil.copy2(path, backup_path)
    return backup_path


path: scripts/codex_profile_threshold_guard.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import time
from pathlib import Path
from typing import Any

from codex_profile_runtime import append_event, backup_file, build_usage_rows, choose_best_candidate, collect_profiles, load_json, write_json

DEFAULT_STATE_DIR = Path("/home/node/.openclaw")


def main() -> int:
    parser = argparse.ArgumentParser(description="Switch Codex profile when usage remaining drops below threshold")
    parser.add_argument("--config", required=True, help="Rotation config path")
    parser.add_argument("--state-dir", default=str(DEFAULT_STATE_DIR), help="OpenClaw state directory")
    parser.add_argument("--session-key", help="Override a single target session key")
    parser.add_argument("--threshold", type=float, help="Override threshold percent")
    parser.add_argument("--timeout", type=int, default=15, help="Per-profile usage timeout")
    parser.add_argument("--apply", action="store_true", help="Apply profile switch when threshold is breached")
    parser.add_argument("--json", action="store_true", help="Print JSON output")
    args = parser.parse_args()

    config = load_json(Path(args.config))
    state_dir = Path(args.state_dir)
    agent = config.get("agent", "main")
    threshold = float(args.threshold if args.threshold is not None else config.get("thresholdPercent", 10))
    auth_path = state_dir / "agents" / agent / "agent" / "auth-profiles.json"
    sessions_path = state_dir / "agents" / agent / "sessions" / "sessions.json"
    event_log_path = Path(config.get("eventLogPath", "state/codex-profile-switch-events.jsonl"))
    session_targets = [args.session_key] if args.session_key else list(config.get("sessionTargets") or [])
    switch_source = config.get("switchSource", "auto-threshold")
    switch_on_usage_error = bool(config.get("switchOnUsageError", True))

    auth = load_json(auth_path)
    sessions = load_json(sessions_path)
    profiles = collect_profiles(auth)
    usage_rows = build_usage_rows(profiles, args.timeout)

    session_results: list[dict[str, Any]] = []
    changed = 0
    for session_key in session_targets:
        entry = sessions.get(session_key)
        if not isinstance(entry, dict):
            session_results.append({"sessionKey": session_key, "status": "missing_session"})
            continue
        current_profile_id = entry.get("authProfileOverride")
        current_row = next((row for row in usage_rows if row["profileId"] == current_profile_id), None)
        current_remaining = current_row.get("effectiveRemaining") if current_row else None
        current_error = current_row.get("usage", {}).get("error") if current_row else None if current_row else None
        result = {
            "sessionKey": session_key,
            "currentProfileId": current_profile_id,
            "currentRemaining": current_remaining,
            "currentError": current_error,
            "threshold": threshold,
            "status": "ok",
        }
        switch_reason = None
        if current_error and switch_on_usage_error:
            switch_reason = "current_profile_usage_error"
        elif current_remaining is None:
            result["status"] = "unknown_current_usage"
            session_results.append(result)
            continue
        elif current_remaining > threshold:
            result["status"] = "within_threshold"
            session_results.append(result)
            continue
        else:
            switch_reason = "remaining_below_threshold"

        candidate = choose_best_candidate(usage_rows, current_profile_id, threshold)
        if not candidate:
            result["status"] = "no_candidate_available"
            session_results.append(result)
            continue

        result["status"] = "switch_recommended"
        result["nextProfileId"] = candidate["profileId"]
        result["nextRemaining"] = candidate["effectiveRemaining"]
        result["reason"] = switch_reason

        if args.apply:
            backup_path = backup_file(sessions_path)
            entry["authProfileOverride"] = candidate["profileId"]
            entry["authProfileOverrideSource"] = switch_source
            entry["updatedAt"] = int(time.time() * 1000)
            write_json(sessions_path, sessions)
            event = {
                "timestamp": int(time.time()),
                "sessionKey": session_key,
                "oldProfileId": current_profile_id,
                "oldRemaining": current_remaining,
                "newProfileId": candidate["profileId"],
                "newRemaining": candidate["effectiveRemaining"],
                "threshold": threshold,
                "reason": switch_reason,
                "backup": str(backup_path),
            }
            append_event(event_log_path, event)
            result["status"] = "switched"
            result["backup"] = str(backup_path)
            changed += 1
        session_results.append(result)

    payload = {
        "agent": agent,
        "threshold": threshold,
        "profiles": usage_rows,
        "sessions": session_results,
        "changed": changed,
        "apply": args.apply,
    }
    print(json.dumps(payload, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/codex_profile_watchdog.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import time
from pathlib import Path
from typing import Any

from codex_profile_runtime import append_event, backup_file, build_usage_rows, choose_best_candidate, collect_profiles, load_json, write_json

DEFAULT_STATE_DIR = Path("/home/node/.openclaw")


def local_profile_issue(profile: dict[str, Any] | None, now_ms: int) -> str | None:
    if not isinstance(profile, dict):
        return "missing_profile_entry"
    [REDACTED_SECRET]"access") or profile.get("token")
    if not [REDACTED_SECRET] "missing_token"
    expires = profile.get("expires")
    if isinstance(expires, (int, float)) and expires <= now_ms:
        return "token_expired"
    return None


def choose_local_fallback(profiles: list[dict[str, Any]], current_profile_id: str | None, now_ms: int, excluded: set[str] | None = None) -> dict[str, Any] | None:
    excluded = excluded or set()
    eligible = []
    for profile in profiles:
        if profile["profileId"] == current_profile_id or profile["profileId"] in excluded:
            continue
        if local_profile_issue(profile, now_ms) is None:
            eligible.append(profile)
    if not eligible:
        return None
    eligible.sort(key=lambda item: ((item.get("expires") or 0), item["profileId"]), reverse=True)
    chosen = eligible[0]
    return {
        "profileId": chosen["profileId"],
        "email": chosen.get("email"),
        "accountId": chosen.get("accountId"),
        "usage": {"note": "local_fallback_only"},
        "effectiveRemaining": None,
    }


def evaluate_session(entry: dict[str, Any], usage_rows: list[dict[str, Any]], profiles: list[dict[str, Any]], threshold: float, switch_on_usage_error: bool, now_ms: int) -> dict[str, Any]:
    current_profile_id = entry.get("authProfileOverride")
    current_profile = next((profile for profile in profiles if profile["profileId"] == current_profile_id), None)
    current_local_issue = local_profile_issue(current_profile, now_ms)
    current_row = next((row for row in usage_rows if row["profileId"] == current_profile_id), None)
    current_remaining = current_row.get("effectiveRemaining") if current_row else None
    current_usage_error = current_row.get("usage", {}).get("error") if current_row else None

    result: dict[str, Any] = {
        "currentProfileId": current_profile_id,
        "currentRemaining": current_remaining,
        "currentUsageError": current_usage_error,
        "currentLocalIssue": current_local_issue,
        "status": "healthy",
        "reason": None,
    }

    reason = None
    if current_local_issue:
        reason = current_local_issue
    elif current_usage_error and switch_on_usage_error:
        reason = "current_profile_usage_error"
    elif current_remaining is not None and current_remaining <= threshold:
        reason = "remaining_below_threshold"
    else:
        return result

    candidate = choose_best_candidate(usage_rows, current_profile_id, threshold)
    if not candidate:
        candidate = choose_local_fallback(profiles, current_profile_id, now_ms)
    if candidate:
        result["status"] = "switch_recommended"
        result["reason"] = reason
        result["nextProfileId"] = candidate["profileId"]
        result["nextRemaining"] = candidate.get("effectiveRemaining")
    else:
        result["status"] = "no_candidate_available"
        result["reason"] = reason
    return result


def run_check(config_path: Path, state_dir: Path, usage_rows: list[dict[str, Any]] | None, force_refresh: bool, timeout: int, apply: bool) -> tuple[list[dict[str, Any]], list[dict[str, Any]], int]:
    config = load_json(config_path)
    agent = config.get("agent", "main")
    threshold = float(config.get("thresholdPercent", 10))
    switch_on_usage_error = bool(config.get("switchOnUsageError", True))
    sessions_path = state_dir / "agents" / agent / "sessions" / "sessions.json"
    auth_path = state_dir / "agents" / agent / "agent" / "auth-profiles.json"
    event_log_path = Path(config.get("eventLogPath", "state/codex-profile-watchdog-events.jsonl"))
    switch_source = config.get("switchSource", "auto-watchdog")

    sessions = load_json(sessions_path)
    auth = load_json(auth_path)
    profiles = collect_profiles(auth)
    if usage_rows is None or force_refresh:
        usage_rows = build_usage_rows(profiles, timeout)

    changed = 0
    results: list[dict[str, Any]] = []
    now_ms = int(time.time() * 1000)
    for session_key in config.get("sessionTargets") or []:
        entry = sessions.get(session_key)
        if not isinstance(entry, dict):
            results.append({"sessionKey": session_key, "status": "missing_session"})
            continue

        evaluation = evaluate_session(entry, usage_rows, profiles, threshold, switch_on_usage_error, now_ms)
        evaluation["sessionKey"] = session_key
        evaluation["threshold"] = threshold
        if apply and evaluation.get("status") == "switch_recommended":
            backup_path = backup_file(sessions_path)
            old_profile_id = entry.get("authProfileOverride")
            entry["authProfileOverride"] = evaluation["nextProfileId"]
            entry["authProfileOverrideSource"] = switch_source
            entry["updatedAt"] = now_ms
            write_json(sessions_path, sessions)
            append_event(
                event_log_path,
                {
                    "timestamp": int(time.time()),
                    "sessionKey": session_key,
                    "oldProfileId": old_profile_id,
                    "newProfileId": evaluation["nextProfileId"],
                    "reason": evaluation["reason"],
                    "currentRemaining": evaluation.get("currentRemaining"),
                    "nextRemaining": evaluation.get("nextRemaining"),
                    "backup": str(backup_path),
                },
            )
            evaluation["status"] = "switched"
            evaluation["backup"] = str(backup_path)
            changed += 1
        results.append(evaluation)
    return usage_rows, results, changed


def main() -> int:
    parser = argparse.ArgumentParser(description="Periodic Codex profile health watchdog")
    parser.add_argument("--config", required=True, help="Watchdog config path")
    parser.add_argument("--state-dir", default=str(DEFAULT_STATE_DIR), help="OpenClaw state directory")
    parser.add_argument("--timeout", type=int, default=15, help="Per-profile usage timeout")
    parser.add_argument("--once", action="store_true", help="Run one check and exit")
    parser.add_argument("--apply", action="store_true", help="Apply profile switches")
    parser.add_argument("--json", action="store_true", help="Print JSON output in once mode")
    args = parser.parse_args()

    config_path = Path(args.config)
    config = load_json(config_path)
    state_dir = Path(args.state_dir)
    interval_seconds = int(config.get("intervalSeconds", 60))
    usage_probe_every_seconds = int(config.get("usageProbeEverySeconds", 60))
    usage_rows: list[dict[str, Any]] | None = None
    last_usage_probe_at = 0.0

    if args.once:
        usage_rows, results, changed = run_check(config_path, state_dir, usage_rows, True, args.timeout, args.apply)
        payload = {
            "mode": "once",
            "usageProbeEverySeconds": usage_probe_every_seconds,
            "intervalSeconds": interval_seconds,
            "profiles": usage_rows,
            "sessions": results,
            "changed": changed,
            "apply": args.apply,
        }
        print(json.dumps(payload, ensure_ascii=False, indent=2))
        return 0

    while True:
        now = time.time()
        force_refresh = usage_rows is None or (now - last_usage_probe_at) >= usage_probe_every_seconds
        usage_rows, results, changed = run_check(config_path, state_dir, usage_rows, force_refresh, args.timeout, args.apply)
        if force_refresh:
            last_usage_probe_at = now
        log_line = {
            "timestamp": int(now),
            "changed": changed,
            "sessions": results,
        }
        print(json.dumps(log_line, ensure_ascii=False), flush=True)
        time.sleep(interval_seconds)


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/install_codex_profile_failover.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
from pathlib import Path

SKILL_DIR = Path(__file__).resolve().parent.parent
DEFAULT_STATE_DIR = "/home/node/.openclaw"


def main() -> int:
    parser = argparse.ArgumentParser(description="Install generic Codex profile failover configs into an OpenClaw workspace")
    parser.add_argument("--workspace", required=True, help="OpenClaw workspace root")
    parser.add_argument("--session-key", default="agent:main:main", help="Session key to watch")
    parser.add_argument("--threshold", type=float, default=10.0, help="Switch threshold percent")
    parser.add_argument("--interval", type=int, default=60, help="Watchdog interval seconds")
    parser.add_argument("--probe-interval", type=int, default=60, help="Usage probe interval seconds")
    parser.add_argument("--state-dir", default=DEFAULT_STATE_DIR, help="OpenClaw state directory")
    args = parser.parse_args()

    workspace = Path(args.workspace).resolve()
    config_dir = workspace / "config"
    state_dir = workspace / "state"
    config_dir.mkdir(parents=True, exist_ok=True)
    state_dir.mkdir(parents=True, exist_ok=True)

    watchdog = {
        "agent": "main",
        "thresholdPercent": args.threshold,
        "switchOnUsageError": True,
        "sessionTargets": [args.session_key],
        "intervalSeconds": args.interval,
        "usageProbeEverySeconds": args.probe_interval,
        "switchSource": "auto-watchdog",
        "eventLogPath": str((workspace / "state" / "codex-profile-watchdog-events.jsonl").resolve()),
        "logPath": str((workspace / "state" / "codex-profile-watchdog.log").resolve()),
    }
    threshold = {
        "agent": "main",
        "thresholdPercent": args.threshold,
        "switchOnUsageError": True,
        "sessionTargets": [args.session_key],
        "switchSource": "auto-threshold",
        "eventLogPath": str((workspace / "state" / "codex-profile-switch-events.jsonl").resolve()),
    }

    watchdog_path = config_dir / "codex-profile-watchdog.json"
    threshold_path = config_dir / "codex-profile-rotation.json"
    watchdog_path.write_text(json.dumps(watchdog, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    threshold_path.write_text(json.dumps(threshold, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

    payload = {
        "workspace": str(workspace),
        "stateDir": args.state_dir,
        "watchdogConfig": str(watchdog_path),
        "thresholdConfig": str(threshold_path),
        "nextCommands": {
            "dryRun": f"python3 {SKILL_DIR / 'scripts' / 'codex_profile_threshold_guard.py'} --config {threshold_path} --state-dir {args.state_dir} --json",
            "applyOnce": f"python3 {SKILL_DIR / 'scripts' / 'codex_profile_threshold_guard.py'} --config {threshold_path} --state-dir {args.state_dir} --apply --json",
            "startWatchdog": f"nohup python3 {SKILL_DIR / 'scripts' / 'codex_profile_watchdog.py'} --config {watchdog_path} --state-dir {args.state_dir} --apply >> {workspace / 'state' / 'codex-profile-watchdog.log'} 2>&1 &",
            "manualTrigger": f"python3 {SKILL_DIR / 'scripts' / 'trigger_profile_failover.py'} --config {watchdog_path} --state-dir {args.state_dir}",
        },
    }
    print(json.dumps(payload, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/trigger_profile_failover.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import subprocess
import sys
from pathlib import Path

SKILL_DIR = Path(__file__).resolve().parent.parent
DEFAULT_SESSION_KEY = "agent:main:main"


def main() -> int:
    parser = argparse.ArgumentParser(description="Manually trigger Codex profile failover for a session")
    parser.add_argument("--config", required=True, help="Watchdog config path")
    parser.add_argument("--session-key", default=DEFAULT_SESSION_KEY, help=f"Session key to evaluate (default: {DEFAULT_SESSION_KEY})")
    parser.add_argument("--state-dir", default="/home/node/.openclaw", help="OpenClaw state directory")
    parser.add_argument("--timeout", type=int, default=15, help="Per-profile usage timeout seconds")
    parser.add_argument("--dry-run", action="store_true", help="Only inspect, do not apply a switch")
    args = parser.parse_args()

    command = [
        sys.executable,
        str(SKILL_DIR / "scripts" / "codex_profile_watchdog.py"),
        "--once",
        "--json",
        "--config",
        args.config,
        "--state-dir",
        args.state_dir,
        "--timeout",
        str(args.timeout),
    ]
    if not args.dry_run:
        command.append("--apply")

    completed = subprocess.run(command, capture_output=True, text=True, check=False)
    if completed.returncode != 0:
        sys.stderr.write(completed.stderr)
        return completed.returncode

    payload = json.loads(completed.stdout)
    payload["sessions"] = [item for item in (payload.get("sessions") or []) if item.get("sessionKey") == args.session_key]
    print(json.dumps(payload, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)