diff --git a/INSTALL.md b/INSTALL.md index d11a839..136c5ae 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -103,9 +103,8 @@ Install the plugin from the Claude Code marketplace. ### Step 3: Configure API Key -```bash -./scripts/setup.sh -``` +Run `/grok-swarm:setup` inside Claude Code — an OAuth browser flow will +authorize your OpenRouter account without exposing your API key in-context. ### Usage in Claude Code @@ -147,9 +146,9 @@ cp -r src/plugin ~/.openclaw/extensions/grok-swarm/ ### Configure API Key -```bash -./scripts/setup.sh -``` +**For Claude Code:** Run `/grok-swarm:setup` — OAuth browser flow, no key in-context. + +**For OpenClaw/CLI:** Run `./scripts/setup.sh` or set `OPENROUTER_API_KEY` manually. --- @@ -175,9 +174,9 @@ grok-swarm --help ### For Claude Code -```bash -# Set up API key -./scripts/setup.sh +``` +# Authorize via OAuth (no API key in-context) +/grok-swarm:setup # Try it out /grok-swarm:analyze Review my auth module for security issues diff --git a/README.md b/README.md index 5266c16..a278af8 100644 --- a/README.md +++ b/README.md @@ -109,11 +109,11 @@ npm install -g @openclaw/grok-swarm # Install the plugin /plugin install grok-swarm@khaentertainment - -# Set up API key -./scripts/setup.sh ``` +Then run `/grok-swarm:setup` inside Claude Code — an OAuth browser flow will +authorize your OpenRouter account without exposing your API key in-context. + ### Option 3: ClawHub (OpenClaw) ```bash diff --git a/platforms/claude/commands/setup.md b/platforms/claude/commands/setup.md index 68cfc68..b5ff7a8 100644 --- a/platforms/claude/commands/setup.md +++ b/platforms/claude/commands/setup.md @@ -1,6 +1,6 @@ --- name: setup -description: Set up your OpenRouter API key for Grok Swarm. Run this before first use if prompted. +description: Authorize Grok Swarm with your OpenRouter account via OAuth. No API key handling in-context. argument-hint: None allowed-tools: - Bash @@ -8,35 +8,93 @@ allowed-tools: # Setup Grok Swarm -This command guides you through configuring your OpenRouter API key for Grok Swarm. +Follow these steps exactly. Do not ask the user for their API key — the OAuth +flow ensures the key never passes through this conversation. -## Usage +## Step 1 — Check for existing key +Run: +```bash +OAUTH_PATH="$(find ~/.claude/plugins -name 'oauth_setup.py' 2>/dev/null | head -1)" +if [ -n "$OAUTH_PATH" ]; then + python3 "$(dirname "$OAUTH_PATH")/oauth_setup.py" --check 2>/dev/null +else + OAUTH_PATH="$(find /usr /usr/local ~/.local -name 'oauth_setup.py' 2>/dev/null | head -1)" + [ -n "$OAUTH_PATH" ] && python3 "$OAUTH_PATH" --check 2>/dev/null +fi ``` -/grok-swarm:setup + +Alternative (locate bridge relative to this command file): +```bash +OAUTH_PATH="$(find ~/.claude/plugins -name 'oauth_setup.py' 2>/dev/null | head -1)" +if [ -n "$OAUTH_PATH" ]; then + BRIDGE_DIR="$(dirname "$OAUTH_PATH")" +else + OAUTH_PATH="$(find /usr /usr/local ~/.local -name 'oauth_setup.py' 2>/dev/null | head -1)" + [ -n "$OAUTH_PATH" ] && BRIDGE_DIR="$(dirname "$OAUTH_PATH")" +fi +if [ -f "$BRIDGE_DIR/oauth_setup.py" ]; then + python3 "$BRIDGE_DIR/oauth_setup.py" --check +fi ``` -## What it does +If the check exits 0, tell the user their key is already configured and offer +to run a test query. **Stop here.** + +## Step 2 — Run the OAuth flow -1. Checks for existing API key in: - - `~/.claude/grok-swarm.local.md` (plugin settings) - - `~/.config/grok-swarm/config.json` (CLI config) - - `$OPENROUTER_API_KEY` environment variable +Locate `oauth_setup.py` in the plugin's `src/bridge/` directory and run it +with a 240 seconds timeout. The timeout is enforced by the Bash tool invocation +(via the `timeout` parameter in the tool call or CI runner timeout settings): + +```bash +OAUTH_PATH="$(find ~/.claude/plugins -name 'oauth_setup.py' 2>/dev/null | head -1)" +if [ -n "$OAUTH_PATH" ]; then + PLUGIN_ROOT="$(cd "$(dirname "$OAUTH_PATH")/../.." 2>/dev/null && pwd)" +else + OAUTH_PATH="$(find /usr /usr/local ~/.local -name 'oauth_setup.py' 2>/dev/null | head -1)" + [ -n "$OAUTH_PATH" ] && PLUGIN_ROOT="$(cd "$(dirname "$OAUTH_PATH")/../.." 2>/dev/null && pwd)" +fi +timeout 240s python3 "$PLUGIN_ROOT/src/bridge/oauth_setup.py" +``` -2. If no key found, prompts you to enter one +**Note**: The `timeout 240s` wrapper ensures the command terminates if the OAuth +flow exceeds 240 seconds. The script itself has an internal OAUTH_TIMEOUT_SECS +(180s) for the callback phase plus roughly 30s for token exchange, so the 240s +outer limit provides a safe margin. -3. Saves configuration to plugin settings file +The script will: +1. Print an authorization URL +2. Start a local server on `localhost:3000` +3. Wait up to 180 seconds for the browser callback +4. Exchange the auth code for an API key +5. Save the key to `~/.config/grok-swarm/config.json` (chmod 600) -## First-time setup +## Step 3 — Present the auth URL -If you're seeing this for the first time: +Show the user the URL printed by the script and tell them: -1. Get an OpenRouter API key at https://openrouter.ai/keys -2. Run `/grok-swarm:setup` -3. Paste your API key when prompted +> **Click this link to authorize Grok Swarm with your OpenRouter account.** +> After approving in your browser, return here — setup completes automatically. -## Troubleshooting +## Step 4 — Report result -- **Invalid key error**: Make sure you copied the key correctly from OpenRouter -- **Key starts with `sk-`**: That's correct! OpenRouter keys begin with `sk-or-v1-` -- **Permission denied**: The setup script needs to write to `~/.claude/` and `~/.config/` +- If the script exits 0: confirm success and suggest running `/grok-swarm:analyze Hello world` +- If it exits 1 (timeout or port conflict): show the error message from the script + and suggest the manual fallback: + Direct them to https://openrouter.ai/keys for a key. + +## xAI Direct Users + +If the user says they want to use xAI directly (not OpenRouter), run: +```bash +OAUTH_PATH="$(find ~/.claude/plugins -name 'oauth_setup.py' 2>/dev/null | head -1)" +if [ -n "$OAUTH_PATH" ]; then + PLUGIN_ROOT="$(cd "$(dirname "$OAUTH_PATH")/../.." 2>/dev/null && pwd)" +else + OAUTH_PATH="$(find /usr /usr/local ~/.local -name 'oauth_setup.py' 2>/dev/null | head -1)" + [ -n "$OAUTH_PATH" ] && PLUGIN_ROOT="$(cd "$(dirname "$OAUTH_PATH")/../.." 2>/dev/null && pwd)" +fi +python3 "$PLUGIN_ROOT/src/bridge/oauth_setup.py" --provider xai +``` +This prints manual credential instructions without attempting OAuth. \ No newline at end of file diff --git a/platforms/claude/commands/setup.sh b/platforms/claude/commands/setup.sh index f46fbe3..2f21e68 100755 --- a/platforms/claude/commands/setup.sh +++ b/platforms/claude/commands/setup.sh @@ -28,6 +28,16 @@ if api_key=$(get_api_key) && [ -n "$api_key" ]; then exit 0 fi -# No API key - prompt for setup -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -exec "$SCRIPT_DIR/setup.sh" +# No API key found — guide user to the OAuth-based setup command +echo "" +echo "No API key configured." +echo "" +echo "Run '/grok-swarm:setup' inside Claude Code to authorize via OpenRouter's" +echo "OAuth flow. Your key will never pass through Claude's context window." +echo "" +echo "Or set manually:" +echo " export OPENROUTER_API_KEY=sk-or-v1-..." +echo " # or: mkdir -p ~/.config/grok-swarm && echo '{\"api_key\": \"sk-or-v1-...\"}' > ~/.config/grok-swarm/config.json" +echo "" +echo "Get a key at: https://openrouter.ai/keys" +exit 1 diff --git a/skills/grok-refactor/bridge/grok_bridge.py b/skills/grok-refactor/bridge/grok_bridge.py index be2dcbf..808974d 100644 --- a/skills/grok-refactor/bridge/grok_bridge.py +++ b/skills/grok-refactor/bridge/grok_bridge.py @@ -134,6 +134,18 @@ def get_api_key(): except (json.JSONDecodeError, KeyError): pass + # 2b. Claude Code plugin settings file (legacy: written by setup.sh) + claude_settings = Path.home() / ".claude" / "grok-swarm.local.md" + if claude_settings.exists(): + try: + for line in claude_settings.read_text(encoding="utf-8").splitlines(): + if line.startswith("api_key:"): + key = line.split(":", 1)[1].strip() + if key: + return key + except OSError: + pass + # 3. OpenClaw auth profiles (for OpenClaw integration) auth_paths = [ Path.home() / ".openclaw" / "agents" / "coder" / "agent" / "auth-profiles.json", diff --git a/src/agent/grok_agent.py b/src/agent/grok_agent.py index e7b87a1..7e4cf31 100644 --- a/src/agent/grok_agent.py +++ b/src/agent/grok_agent.py @@ -187,8 +187,8 @@ def parse_code_blocks(response_text: str) -> list[dict]: """ blocks = [] - # Pattern 1: lang:/path/to/file (language tag contains path) - lang_path_pattern = re.compile(r'^(\w+):(/[^/\s\n]+(?:/[^/\s\n]+)*)\n', re.MULTILINE) + # Pattern 1: lang:path/to/file (language tag contains path; relative OR absolute) + lang_path_pattern = re.compile(r'^(\w+):([^\s\n]+)\n', re.MULTILINE) # Pattern 2: // FILE: /path or # FILE: /path file_marker_pattern = re.compile( diff --git a/src/bridge/grok_bridge.py b/src/bridge/grok_bridge.py index 517a167..0638886 100644 --- a/src/bridge/grok_bridge.py +++ b/src/bridge/grok_bridge.py @@ -96,6 +96,18 @@ def get_api_key(): except (json.JSONDecodeError, KeyError): pass + # 2b. Claude Code plugin settings file (legacy: written by setup.sh) + claude_settings = Path.home() / ".claude" / "grok-swarm.local.md" + if claude_settings.exists(): + try: + for line in claude_settings.read_text(encoding="utf-8").splitlines(): + if line.startswith("api_key:"): + key = line.split(":", 1)[1].strip() + if key: + return key + except OSError: + pass + # 3. OpenClaw auth profiles (for OpenClaw integration) auth_paths = [ Path.home() / ".openclaw" / "agents" / "coder" / "agent" / "auth-profiles.json", @@ -175,7 +187,9 @@ def _safe_dest(output_path, file_path): """ raw = Path(file_path) if raw.is_absolute(): - raise ValueError(f"Absolute paths are not allowed: {file_path!r}") + rebased = Path(raw.name) + print(f"WARNING: Absolute path rebased to output dir: {file_path!r} → {rebased!r}", file=sys.stderr) + raw = rebased if ".." in raw.parts: raise ValueError(f"Path traversal not allowed: {file_path!r}") dest = (output_path / raw).resolve() @@ -212,8 +226,8 @@ def parse_and_write_files(response_text, output_dir): written = [] output_path = Path(output_dir) - # Pattern 1: lang:/path/to/file (language tag contains path) - lang_path_pattern = re.compile(r'^(\w+):(/[^\s\n]+(?:/[^\s\n]+)*)\n', re.MULTILINE) + # Pattern 1: lang:path/to/file (language tag contains path; relative OR absolute — broad match) + lang_path_pattern = re.compile(r'^(\w+):([^\s\n]+)\n', re.MULTILINE) # Pattern 2: // FILE: /path or # FILE: /path file_marker_pattern = re.compile( diff --git a/src/bridge/oauth_setup.py b/src/bridge/oauth_setup.py new file mode 100644 index 0000000..61f19fb --- /dev/null +++ b/src/bridge/oauth_setup.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +""" +oauth_setup.py — PKCE OAuth helper for Grok Swarm / OpenRouter. + +The API key NEVER passes through Claude's context window. +Flow: browser → OpenRouter → localhost:3000 → ~/.config/grok-swarm/config.json + +Usage: + python3 oauth_setup.py # interactive PKCE OAuth flow + python3 oauth_setup.py --check # exit 0 if key exists, 1 if not (no output) + python3 oauth_setup.py --provider xai # print manual setup instructions + +Requirements: Python 3.8+ stdlib only (no third-party packages). +""" + +import argparse +import base64 +import hashlib +import json +import os +import secrets +import socket +import sys +import tempfile +import urllib.parse +import urllib.request +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import List, Tuple + +# OpenRouter OAuth constants +OPENROUTER_AUTH_URL = "https://openrouter.ai/auth" +OPENROUTER_TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys" +CALLBACK_PORT = 3000 # OpenRouter only allows localhost:3000 +CALLBACK_URL = f"http://localhost:{CALLBACK_PORT}" +APP_NAME = "Grok+Swarm" +OAUTH_TIMEOUT_SECS = 180 + +CONFIG_DIR = Path.home() / ".config" / "grok-swarm" +CONFIG_FILE = CONFIG_DIR / "config.json" + + +# --------------------------------------------------------------------------- +# PKCE helpers +# --------------------------------------------------------------------------- + +def _generate_pkce_pair() -> Tuple[str, str]: + """Return (code_verifier, code_challenge).""" + verifier_bytes = secrets.token_bytes(64) + code_verifier = base64.urlsafe_b64encode(verifier_bytes).rstrip(b"=").decode() + digest = hashlib.sha256(code_verifier.encode()).digest() + code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() + return code_verifier, code_challenge + + +# --------------------------------------------------------------------------- +# Key persistence +# --------------------------------------------------------------------------- + +def _key_exists() -> bool: + """Return True if an API key is already configured.""" + # Check env vars first + if os.environ.get("OPENROUTER_API_KEY") or os.environ.get("XAI_API_KEY"): + return True + if CONFIG_FILE.exists(): + try: + data = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) + return bool(data.get("api_key")) + except (json.JSONDecodeError, OSError): + pass + # Also check ~/.claude/grok-swarm.local.md + claude_settings = Path.home() / ".claude" / "grok-swarm.local.md" + if claude_settings.exists(): + try: + for line in claude_settings.read_text(encoding="utf-8").splitlines(): + if line.startswith("api_key:") and line.split(":", 1)[1].strip(): + return True + except OSError: + pass + return False + + +def _save_key(api_key: str) -> None: + """Write API key to ~/.config/grok-swarm/config.json with mode 600.""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + existing: dict = {} + if CONFIG_FILE.exists(): + try: + existing = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + pass + existing["api_key"] = api_key + + # Write to a temporary file with secure permissions, then atomically rename + json_bytes = (json.dumps(existing, indent=2) + "\n").encode("utf-8") + fd = os.open( + str(CONFIG_DIR / f".config.json.tmp.{os.getpid()}"), + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600 + ) + try: + os.write(fd, json_bytes) + os.fsync(fd) + finally: + os.close(fd) + + # Atomically replace the target file + os.replace(str(CONFIG_DIR / f".config.json.tmp.{os.getpid()}"), str(CONFIG_FILE)) + + +# --------------------------------------------------------------------------- +# OAuth callback server +# --------------------------------------------------------------------------- + +_received_code: List[str] = [] + + +class _CallbackHandler(BaseHTTPRequestHandler): + """Minimal HTTP handler that captures the ?code= query parameter.""" + + def do_GET(self) -> None: + parsed = urllib.parse.urlparse(self.path) + params = urllib.parse.parse_qs(parsed.query) + code = params.get("code", [None])[0] + if code: + _received_code.append(code) + body = b"

Grok Swarm authorized!

You can close this tab.

" + self.send_response(200) + else: + body = b"

Authorization failed.

No code received. Please try again.

" + self.send_response(400) + self.send_header("Content-Type", "text/html") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args) -> None: # type: ignore[override] + pass # suppress request logs + + +def _exchange_code(code: str, code_verifier: str) -> str: + """Exchange auth code for API key via OpenRouter token endpoint.""" + payload = json.dumps({"code": code, "code_verifier": code_verifier}).encode() + req = urllib.request.Request( + OPENROUTER_TOKEN_URL, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode()) + except Exception as exc: + raise RuntimeError(f"Token exchange failed: {exc}") from exc + + key = data.get("key") or data.get("api_key") + if not key: + raise RuntimeError(f"No key in response: {data}") + return key + + +def _check_port_available() -> bool: + """Return True if localhost:3000 is available.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(1) + return s.connect_ex(("localhost", CALLBACK_PORT)) != 0 + + +# --------------------------------------------------------------------------- +# Public entry points +# --------------------------------------------------------------------------- + +def run_oauth_flow() -> int: + """ + Run the PKCE OAuth flow. + + Returns 0 on success, 1 on failure. + """ + # Clear any stale codes from previous runs + _received_code.clear() + + if not _check_port_available(): + print( + f"ERROR: Port {CALLBACK_PORT} is already in use.\n" + f"Stop whatever is using port {CALLBACK_PORT} and retry, or set your key manually:\n" + f" mkdir -p ~/.config/grok-swarm\n" + f" echo '{{\"api_key\": \"sk-or-v1-...\"}}' > ~/.config/grok-swarm/config.json\n" + f" chmod 600 ~/.config/grok-swarm/config.json", + file=sys.stderr, + ) + return 1 + + code_verifier, code_challenge = _generate_pkce_pair() + + auth_params = urllib.parse.urlencode( + { + "callback_url": CALLBACK_URL, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "app_name": APP_NAME, + } + ) + auth_url = f"{OPENROUTER_AUTH_URL}?{auth_params}" + + print("=" * 60) + print("Grok Swarm — OpenRouter Authorization") + print("=" * 60) + print() + print("Click the link below to authorize Grok Swarm with your") + print("OpenRouter account (or paste it into your browser):") + print() + print(f" {auth_url}") + print() + print(f"Waiting up to {OAUTH_TIMEOUT_SECS}s for authorization callback...") + + # Handle bind-time races: another process may have bound between the + # _check_port_available call and this HTTPServer creation. + max_retries = 3 + server = None + for attempt in range(max_retries): + try: + server = HTTPServer(("localhost", CALLBACK_PORT), _CallbackHandler) + server.timeout = OAUTH_TIMEOUT_SECS + break + except OSError as exc: + if attempt < max_retries - 1: + __import__("time").sleep(0.5) + continue + # Final attempt failed + print( + f"\nERROR: Failed to bind to port {CALLBACK_PORT} after {max_retries} attempts: {exc}", + file=sys.stderr, + ) + print("Please try again or set your key manually:", file=sys.stderr) + print(" export OPENROUTER_API_KEY=sk-or-v1-...", file=sys.stderr) + return 1 + + deadline = __import__("time").time() + OAUTH_TIMEOUT_SECS + while not _received_code and __import__("time").time() < deadline: + server.handle_request() + + server.server_close() + + if not _received_code: + print("\nERROR: Timed out waiting for authorization.", file=sys.stderr) + print("Please try again or set your key manually:", file=sys.stderr) + print(" export OPENROUTER_API_KEY=sk-or-v1-...", file=sys.stderr) + return 1 + + code = _received_code[0] + print("\nCallback received. Exchanging code for API key...", end=" ", flush=True) + + try: + api_key = _exchange_code(code, code_verifier) + except RuntimeError as exc: + print(f"FAILED\nERROR: {exc}", file=sys.stderr) + return 1 + + try: + _save_key(api_key) + except OSError as exc: + print(f"FAILED\nERROR: Could not save API key to {CONFIG_FILE}: {exc}", file=sys.stderr) + return 1 + + print(f"OK\n\nSuccess! API key saved to {CONFIG_FILE}") + return 0 + + +def run_check() -> int: + """Exit 0 if key exists, 1 if not. No output.""" + return 0 if _key_exists() else 1 + + +def print_manual_instructions() -> None: + """Print instructions for manually placing xAI / OpenRouter credentials.""" + print("""Grok Swarm — Manual Credential Setup +===================================== + +Option A — OpenRouter (recommended): + 1. Create an account at https://openrouter.ai + 2. Generate an API key at https://openrouter.ai/keys + 3. Run one of: + + export OPENROUTER_API_KEY=sk-or-v1-... + + # or save permanently: + mkdir -p ~/.config/grok-swarm + echo '{"api_key": "sk-or-v1-..."}' > ~/.config/grok-swarm/config.json + chmod 600 ~/.config/grok-swarm/config.json + +Option B — xAI direct (no OAuth available): + 1. Create an account at https://console.x.ai + 2. Generate an API key in your dashboard + 3. Run one of: + + export XAI_API_KEY=xai-... + + # or save permanently (grok_bridge will read the "api_key" field from config.json): + mkdir -p ~/.config/grok-swarm + echo '{"api_key": "xai-..."}' > ~/.config/grok-swarm/config.json + chmod 600 ~/.config/grok-swarm/config.json + + Note: when using xAI direct you must also update grok_bridge.py's + OPENROUTER_BASE to point to https://api.x.ai/v1 +""") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Set up OpenRouter API key for Grok Swarm via PKCE OAuth." + ) + parser.add_argument( + "--check", + action="store_true", + help="Exit 0 if key already configured, 1 otherwise (no output).", + ) + parser.add_argument( + "--provider", + choices=["openrouter", "xai"], + default="openrouter", + help="Credential provider. 'xai' prints manual instructions (no OAuth).", + ) + args = parser.parse_args() + + if args.check: + sys.exit(run_check()) + + if args.provider == "xai": + print_manual_instructions() + sys.exit(0) + + sys.exit(run_oauth_flow()) + + +if __name__ == "__main__": + main() \ No newline at end of file