diff --git a/.claude/skills/ai-image-creator/SKILL.md b/.claude/skills/ai-image-creator/SKILL.md index 135d35ad..9a3c642f 100644 --- a/.claude/skills/ai-image-creator/SKILL.md +++ b/.claude/skills/ai-image-creator/SKILL.md @@ -22,6 +22,23 @@ When the user mentions a model keyword in their image request, use the correspon | `flux2` | [FLUX.2 Max](https://openrouter.ai/black-forest-labs/flux.2-max) | "flux2", "flux", "use flux" | | `seedream` | [ByteDance SeedDream 4.5](https://openrouter.ai/bytedance-seed/seedream-4.5) | "seedream", "use seedream" | | `gpt5` | [OpenAI GPT-5 Image](https://openrouter.ai/openai/gpt-5-image) | "gpt5", "gpt5 image", "use gpt5" | +| `nvidia-flux2-klein-4b` | BFL FLUX.2 Klein 4B via NVIDIA NIM (default for `--provider nvidia`) | "nvidia", "flux nvidia", "nvidia flux2" | +| `nvidia-flux-dev` | BFL FLUX.1-dev via NVIDIA NIM (30 steps, high quality) | "nvidia flux dev" | +| `nvidia-flux-schnell` | BFL FLUX.1-schnell via NVIDIA NIM (4 steps, fastest) | "nvidia schnell", "flux rápido" | +| `image2` | OpenAI GPT Image 2 via Images API direta (default for `--provider openai`) | "image2", "gpt image 2", "imagem openai" | + +**OpenAI Images API notes (`image2`):** +- Requires a **platform API key** (`AI_IMG_CREATOR_OPENAI_KEY` or `OPENAI_API_KEY`, billing on platform.openai.com). +- **ChatGPT Plus / Codex OAuth tokens CANNOT generate images** — they lack the `api.model.images.request` scope (verified: API returns 401). Do not suggest the Codex login as an image path. +- `-a` maps to the supported sizes (1024x1024, 1536x1024, 1024x1536). `--image-size`, `-r` (reference images) and `--analyze` are not supported on this provider. +- Excellent text rendering (including Portuguese accents) — good choice for text-critical art. + +**NVIDIA NIM notes:** +- Free tier with generous credits — good fallback when OpenRouter hits rate limits. +- Generation is fast (~2-5s). Output is JPEG, auto-converted to PNG via ImageMagick/ffmpeg. +- Dimensions are restricted (768-1344 per side, ≤1.06MP total) — `-a` maps to the closest supported size. `--image-size` is not supported. +- Text rendering (especially Portuguese accents like Ç/Ã) is hit-or-miss on all FLUX variants — for text-critical art, generate 2-3 candidates and pick, or use `gemini`/`gpt5` which render text reliably. +- Reference images (`-r`) and `--analyze` are not supported on the NVIDIA provider. ## Instructions @@ -99,6 +116,7 @@ The script auto-loads env vars from the workspace `.env`. Choose provider based - If `AI_IMG_CREATOR_CF_ACCOUNT_ID` + `AI_IMG_CREATOR_CF_GATEWAY_ID` + `AI_IMG_CREATOR_CF_TOKEN` are set → use default (gateway mode, no flag needed) - If only `AI_IMG_CREATOR_OPENROUTER_KEY` is set → use default (`--provider openrouter`, implicit) - If only `AI_IMG_CREATOR_GEMINI_KEY` is set → use `--provider google` +- If `NVIDIA_API_KEY` is set → `--provider nvidia` is available (FLUX models, free tier). Auto-selected when the model keyword starts with `nvidia-` - **Do NOT use `source .env`** — the Python script loads it internally. Just run the command directly. ### Step 2: Run Generation Script @@ -193,6 +211,7 @@ If the user needs resizing, format conversion, or other manipulation, first dete | `AI_IMG_CREATOR_CF_TOKEN` | Gateway mode | Gateway auth token | | `AI_IMG_CREATOR_OPENROUTER_KEY` | Direct OpenRouter | OpenRouter API key (`sk-or-...`) | | `AI_IMG_CREATOR_GEMINI_KEY` | Direct Google | Google AI Studio API key | +| `NVIDIA_API_KEY` | NVIDIA NIM | NVIDIA API key (`nvapi-...`) — same key used by the dashboard NVIDIA provider | Gateway mode activates when all 3 `CF_*` vars are set. Falls back to direct mode if gateway fails. diff --git a/.claude/skills/ai-image-creator/scripts/generate-image.py b/.claude/skills/ai-image-creator/scripts/generate-image.py index 71b040a2..3de668f9 100644 --- a/.claude/skills/ai-image-creator/scripts/generate-image.py +++ b/.claude/skills/ai-image-creator/scripts/generate-image.py @@ -1,19 +1,22 @@ #!/usr/bin/env python3 -"""AI Image Generator — Generate PNG images via multiple OpenRouter models or Google AI Studio. +"""AI Image Generator — Generate PNG images via multiple providers. Supports multiple image generation models via keyword shortcuts: - gemini — Google Gemini 3.1 Flash (default, multimodal) - riverflow — Sourceful Riverflow v2 Fast (image-only) - flux2 — Black Forest Labs FLUX.2 Klein 4B (image-only) - seedream — ByteDance SeedDream 4.5 (image-only) - gpt5 — OpenAI GPT-5 Image Mini (multimodal) - -Routes through Cloudflare AI Gateway BYOK when configured, with automatic -fallback to direct API calls. Uses only Python stdlib (no pip dependencies). + gemini — Google Gemini 3.1 Flash (multimodal, OpenRouter) + riverflow — Sourceful Riverflow v2 Fast (image-only, OpenRouter) + flux2 — Black Forest Labs FLUX.2 Klein 4B (image-only, OpenRouter) + seedream — ByteDance SeedDream 4.5 (image-only, OpenRouter) + gpt5 — OpenAI GPT-5 Image Mini (multimodal, OpenRouter) + nvidia-flux2-klein-4b — BFL FLUX.2 Klein 4B via NVIDIA NIM (default nvidia, best text rendering) + nvidia-flux-dev — BFL FLUX.1-dev via NVIDIA NIM (high quality) + nvidia-flux-schnell — BFL FLUX.1-schnell via NVIDIA NIM (fastest) + +Routes through Cloudflare AI Gateway BYOK when configured (OpenRouter/Google only), +with automatic fallback to direct API calls. Uses only Python stdlib (no pip dependencies). Usage: uv run python generate-image.py --output path.png --prompt "description" - uv run python generate-image.py --output path.png --model riverflow --prompt "description" + uv run python generate-image.py --output path.png --model nvidia-flux2-klein-4b --prompt "description" uv run python generate-image.py --output path.png --prompt-file prompt.txt uv run python generate-image.py --list-models """ @@ -40,6 +43,39 @@ DEFAULT_MODELS = { "openrouter": "google/gemini-3.1-flash-image-preview", "google": "gemini-3.1-flash-image-preview", + "nvidia": "black-forest-labs/flux.2-klein-4b", + "openai": "gpt-image-2", +} + +# Aspect ratio → size string accepted by the OpenAI Images API +# (gpt-image models accept 1024x1024, 1536x1024, 1024x1536, auto). +OPENAI_SIZE_MAP = { + "1:1": "1024x1024", + "16:9": "1536x1024", + "3:2": "1536x1024", + "4:3": "1536x1024", + "5:4": "1536x1024", + "21:9": "1536x1024", + "9:16": "1024x1536", + "2:3": "1024x1536", + "3:4": "1024x1536", + "4:5": "1024x1536", +} + +# Aspect ratio → (width, height) within the dimension set NVIDIA NIM accepts +# (768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344) AND the total +# pixel budget (width × height <= 1,062,400 — validated by the API). +NVIDIA_ASPECT_MAP = { + "1:1": (1024, 1024), + "16:9": (1344, 768), + "9:16": (768, 1344), + "3:2": (1152, 768), + "2:3": (768, 1152), + "4:3": (1024, 768), + "3:4": (768, 1024), + "5:4": (1024, 832), + "4:5": (832, 1024), + "21:9": (1344, 768), # closest supported — true 21:9 unavailable } # Model registry — maps keyword shortcuts to model metadata. @@ -71,6 +107,58 @@ "modalities": ["image", "text"], "description": "OpenAI GPT-5 Image — multimodal (text+image)", }, + # OpenAI Images API — requires a PLATFORM API key (api.openai.com billing). + # ChatGPT Plus / Codex OAuth tokens lack the api.model.images.request + # scope and cannot generate images (verified empirically — 401). + "image2": { + "id": "gpt-image-2", + "provider": "openai", + "modalities": ["image"], + "description": "OpenAI GPT Image 2 — Images API direta (requer API key de plataforma)", + }, + # NVIDIA NIM Flux models + "nvidia-flux-dev": { + "id": "black-forest-labs/flux.1-dev", + "provider": "nvidia", + "modalities": ["image"], + "description": "Black Forest Labs FLUX.1-dev — high quality, NVIDIA NIM", + "nvidia_params": { + "width": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]}, + "height": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]}, + "cfg_scale": {"type": "number", "default": 5, "minimum": 1, "maximum": 9, "description": "How strictly the diffusion process adheres to the prompt text"}, + "steps": {"type": "integer", "default": 30, "minimum": 1, "maximum": 50, "description": "Number of diffusion steps"}, + "seed": {"type": "integer", "default": 0, "minimum": 0, "exclusiveMaximum": 4294967296, "description": "Random seed (0 for random)"}, + "samples": {"type": "integer", "default": 1, "minimum": 1, "maximum": 1}, + }, + }, + "nvidia-flux-schnell": { + "id": "black-forest-labs/flux.1-schnell", + "provider": "nvidia", + "modalities": ["image"], + "description": "Black Forest Labs FLUX.1-schnell — fast, NVIDIA NIM", + "nvidia_params": { + "width": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]}, + "height": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]}, + "cfg_scale": {"type": "number", "default": 0, "minimum": 0, "maximum": 0}, + "steps": {"type": "integer", "default": 4, "minimum": 1, "maximum": 30}, + "seed": {"type": "integer", "default": 0, "minimum": 0, "exclusiveMaximum": 4294967296}, + "samples": {"type": "integer", "default": 1, "minimum": 1, "maximum": 1}, + }, + }, + "nvidia-flux2-klein-4b": { + "id": "black-forest-labs/flux.2-klein-4b", + "provider": "nvidia", + "modalities": ["image"], + "description": "Black Forest Labs FLUX.2 Klein 4B — fastest, NVIDIA NIM", + "nvidia_params": { + "width": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]}, + "height": {"default": 1024, "enum": [768, 832, 896, 960, 1024, 1088, 1152, 1216, 1280, 1344]}, + "cfg_scale": {"type": "number", "default": 1, "minimum": 1, "maximum": 9}, + "steps": {"type": "integer", "default": 4, "minimum": 1, "maximum": 4}, + "seed": {"type": "integer", "default": 0, "minimum": 0, "exclusiveMaximum": 4294967296}, + "samples": {"type": "integer", "default": 1, "minimum": 1, "maximum": 1}, + }, + }, } # Environment variable names (prefixed to avoid collisions) @@ -79,6 +167,8 @@ ENV_CF_TOKEN = "AI_IMG_CREATOR_CF_TOKEN" ENV_OPENROUTER_KEY = "AI_IMG_CREATOR_OPENROUTER_KEY" ENV_GEMINI_KEY = "AI_IMG_CREATOR_GEMINI_KEY" +ENV_NVIDIA_KEY = "NVIDIA_API_KEY" +ENV_OPENAI_KEY = "AI_IMG_CREATOR_OPENAI_KEY" # falls back to OPENAI_API_KEY def _load_dotenv() -> None: """Load .env files into os.environ (stdlib only, no pip deps). @@ -156,18 +246,18 @@ def resolve_model(model_arg: str | None, provider: str) -> tuple[str, list[str]] """Resolve a model keyword or full ID to (model_id, modalities). Supports three modes: - 1. No --model flag: returns the default model for the provider (gemini). + 1. No --model flag: returns the default model for the provider. 2. Keyword match (e.g. 'riverflow'): looks up MODEL_REGISTRY. 3. Full model ID (e.g. 'sourceful/riverflow-v2-pro'): reverse-lookups registry for modalities, or defaults to ["image", "text"] if unknown. Args: model_arg: The --model CLI value (keyword, full model ID, or None). - provider: Either 'openrouter' or 'google'. + provider: 'openrouter', 'google', or 'nvidia'. Returns: Tuple of (model_id, modalities_list) where model_id is the full - OpenRouter model identifier and modalities_list is the correct + model identifier and modalities_list is the correct modalities array for the API request. """ if model_arg is None: @@ -175,6 +265,8 @@ def resolve_model(model_arg: str | None, provider: str) -> tuple[str, list[str]] if provider == "openrouter": entry = MODEL_REGISTRY.get("gemini", {}) return model_id, entry.get("modalities", ["image", "text"]) + if provider in ("nvidia", "openai"): + return model_id, ["image"] return model_id, ["image", "text"] # Check keyword match (case-insensitive) @@ -203,7 +295,7 @@ def parse_args() -> argparse.Namespace: image_size, model, list_models, debug, and verbose attributes. """ parser = argparse.ArgumentParser( - description="Generate PNG images using AI (multiple models via OpenRouter/Google AI Studio)" + description="Generate PNG images using AI (OpenRouter, Google AI Studio, or NVIDIA NIM)" ) parser.add_argument( "-o", "--output", required=False, default=None, help="Output PNG file path (required unless --list-models)" @@ -218,9 +310,9 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument( "--provider", - choices=["openrouter", "google"], + choices=["openrouter", "google", "nvidia", "openai"], default="openrouter", - help="API provider (default: openrouter)", + help="API provider (default: openrouter). Use 'nvidia' for NVIDIA NIM Flux models, 'openai' for GPT Image via Images API.", ) parser.add_argument( "-a", "--aspect-ratio", @@ -235,7 +327,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "-m", "--model", default=None, - help="Model keyword (gemini, riverflow, flux2, seedream, gpt5) or full model ID", + help="Model keyword (gemini, riverflow, flux2, seedream, gpt5, nvidia-flux2-klein-4b, nvidia-flux-dev, nvidia-flux-schnell) or full model ID", ) parser.add_argument( "-r", "--ref", @@ -264,6 +356,37 @@ def parse_args() -> argparse.Namespace: action="store_true", help="List available model keywords and exit", ) + # NVIDIA NIM specific parameters + parser.add_argument( + "--width", + type=int, + default=None, + help="Image width for NVIDIA Flux models (768-1344, default: 1024)", + ) + parser.add_argument( + "--height", + type=int, + default=None, + help="Image height for NVIDIA Flux models (768-1344, default: 1024)", + ) + parser.add_argument( + "--cfg-scale", + type=float, + default=None, + help="Classifier-free guidance scale (default: 5 for flux-dev, 0 for others)", + ) + parser.add_argument( + "--steps", + type=int, + default=None, + help="Number of diffusion steps (default: 30 for flux-dev, 4 for schnell/flux2)", + ) + parser.add_argument( + "--seed", + type=int, + default=None, + help="Random seed (0 for random, default: 0)", + ) parser.add_argument( "--debug", action="store_true", @@ -364,9 +487,31 @@ def detect_mode(provider: str) -> tuple[str, dict[str, str]]: if provider == "openrouter": direct_key = os.environ.get(ENV_OPENROUTER_KEY, "").strip() log.debug(f"Env check: {ENV_OPENROUTER_KEY}={'set (' + mask_key(direct_key) + ')' if direct_key else 'MISSING'}") - else: + elif provider == "google": direct_key = os.environ.get(ENV_GEMINI_KEY, "").strip() log.debug(f"Env check: {ENV_GEMINI_KEY}={'set (' + mask_key(direct_key) + ')' if direct_key else 'MISSING'}") + elif provider == "nvidia": + direct_key = os.environ.get(ENV_NVIDIA_KEY, "").strip() + log.debug(f"Env check: {ENV_NVIDIA_KEY}={'set (' + mask_key(direct_key) + ')' if direct_key else 'MISSING'}") + elif provider == "openai": + direct_key = ( + os.environ.get(ENV_OPENAI_KEY, "").strip() + or os.environ.get("OPENAI_API_KEY", "").strip() + ) + log.debug(f"Env check: {ENV_OPENAI_KEY}/OPENAI_API_KEY={'set (' + mask_key(direct_key) + ')' if direct_key else 'MISSING'}") + else: + log.debug(f"Unknown provider: {provider}") + direct_key = "" + + # NVIDIA NIM and OpenAI Images always use direct mode (no gateway) + if provider in ("nvidia", "openai") and direct_key: + log.info(f"Mode: direct ({provider} always uses direct API)") + return "direct", {"direct_key": direct_key} + + # The gateway path is OpenRouter/Google-shaped — never route openai + # (Images API) through it. + if provider == "openai": + has_gateway = False if has_gateway: log.info(f"Mode: gateway (account={cf_account}, gateway={cf_gateway})") @@ -391,9 +536,16 @@ def detect_mode(provider: str) -> tuple[str, dict[str, str]]: if provider == "openrouter": print("For direct OpenRouter access, set:", file=sys.stderr) print(f" export {ENV_OPENROUTER_KEY}=sk-or-...", file=sys.stderr) - else: + elif provider == "google": print("For direct Google AI Studio access, set:", file=sys.stderr) print(f" export {ENV_GEMINI_KEY}=AI...", file=sys.stderr) + elif provider == "nvidia": + print("For NVIDIA NIM access, set:", file=sys.stderr) + print(f" export {ENV_NVIDIA_KEY}=your-nvidia-api-key", file=sys.stderr) + elif provider == "openai": + print("For OpenAI Images API access, set a PLATFORM key (billing on", file=sys.stderr) + print("platform.openai.com — ChatGPT Plus/Codex OAuth cannot generate images):", file=sys.stderr) + print(f" export {ENV_OPENAI_KEY}=sk-... (or OPENAI_API_KEY)", file=sys.stderr) print("", file=sys.stderr) print( "See references/setup-guide.md for full setup instructions.", @@ -434,8 +586,14 @@ def build_direct_url(provider: str, model: str) -> str: """ if provider == "openrouter": url = "https://openrouter.ai/api/v1/chat/completions" - else: + elif provider == "google": url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" + elif provider == "nvidia": + url = build_nvidia_url(model) + elif provider == "openai": + url = "https://api.openai.com/v1/images/generations" + else: + raise RuntimeError(f"Unknown provider for URL: {provider}") log.debug(f"Built direct URL: {url}") return url @@ -456,7 +614,13 @@ def build_headers(provider: str, mode: str, config: dict[str, str]) -> dict[str, "User-Agent": "ai-image-creator/1.0", } - if mode == "gateway": + if provider == "nvidia": + headers = build_nvidia_headers(config["direct_key"]) + # Skip all other header logic for NVIDIA + safe_headers = {k: (f"{v[:12]}...{mask_key(v)}" if k.lower() in ("authorization", "cf-aig-authorization", "x-goog-api-key") else v) for k, v in headers.items()} + log.debug(f"Request headers: {json.dumps(safe_headers, indent=2)}") + return headers + elif mode == "gateway": headers["cf-aig-authorization"] = f"Bearer {config['cf_token']}" if provider == "google": headers["cf-aig-byok-alias"] = "aistudio" @@ -465,8 +629,10 @@ def build_headers(provider: str, mode: str, config: dict[str, str]) -> dict[str, else: if provider == "openrouter": headers["Authorization"] = f"Bearer {config['direct_key']}" - else: + elif provider == "google": headers["x-goog-api-key"] = config["direct_key"] + elif provider == "openai": + headers["Authorization"] = f"Bearer {config['direct_key']}" # Log headers with masked sensitive values safe_headers = {} @@ -539,6 +705,15 @@ def build_request_body( if image_config: body["image_config"] = image_config log.debug(f"Image config: {json.dumps(image_config)}") + elif provider == "openai": + # OpenAI Images API — flat prompt, size from the aspect-ratio map. + body = {"model": model, "prompt": prompt} + if aspect_ratio: + size = OPENAI_SIZE_MAP.get(aspect_ratio) + if size: + body["size"] = size + else: + log.warning(f"Aspect ratio {aspect_ratio} not mapped for OpenAI; using model default") else: # Google AI Studio parts: list[dict[str, Any]] = [{"text": prompt}] @@ -733,6 +908,56 @@ def extract_image_google(response: dict) -> tuple[bytes, str]: return image_bytes, text_content +def extract_image_nvidia(response: dict) -> tuple[bytes, str]: + """Extract base64 image data from NVIDIA NIM response. + + Args: + response: Parsed JSON response from NVIDIA NIM API. + + Returns: + Tuple of (image_bytes, text_content) where image_bytes is the decoded + PNG data and text_content is empty (NVIDIA returns only images). + + Raises: + RuntimeError: If no artifacts found in response. + """ + artifacts = response.get("artifacts", []) + if not artifacts: + raise RuntimeError(f"No artifacts in NVIDIA response: {json.dumps(response)[:500]}") + b64_data = artifacts[0]["base64"] + image_bytes = base64.b64decode(b64_data) + log.info(f"Decoded image: {len(image_bytes)} bytes ({len(b64_data)} base64 chars)") + return image_bytes, "" + + +def extract_image_openai(response: dict) -> tuple[bytes, str]: + """Extract base64 image data from an OpenAI Images API response. + + Args: + response: Parsed JSON response from api.openai.com/v1/images/generations. + + Returns: + Tuple of (image_bytes, text_content) where text_content is empty + (the Images API returns only images). + + Raises: + RuntimeError: If the response carries an error or no image data. + """ + error = response.get("error") + if error: + msg = error.get("message", str(error)) if isinstance(error, dict) else str(error) + raise RuntimeError(f"OpenAI API error: {msg}") + data = response.get("data", []) + if not data: + raise RuntimeError(f"No data in OpenAI response: {json.dumps(response)[:500]}") + b64_data = data[0].get("b64_json", "") + if not b64_data: + raise RuntimeError(f"No b64_json in OpenAI response item: {json.dumps(data[0])[:300]}") + image_bytes = base64.b64decode(b64_data) + log.info(f"Decoded image: {len(image_bytes)} bytes ({len(b64_data)} base64 chars)") + return image_bytes, "" + + def extract_text_openrouter(response: dict) -> str: """Extract text-only content from OpenRouter response (analyze mode). @@ -795,6 +1020,79 @@ def extract_text_google(response: dict) -> str: return text_content +def build_nvidia_url(model_id: str) -> str: + """Build the NVIDIA NIM image generation endpoint URL. + + The NVIDIA AI Foundation endpoint format is: + https://ai.api.nvidia.com/v1/genai/{model_id} + + Note: The model_id uses '.' not '-' in the version suffix + (e.g. 'black-forest-labs/flux.2-klein-4b'). + + Args: + model_id: Full model ID (e.g. 'black-forest-labs/flux.2-klein-4b'). + + Returns: + Full URL for the NVIDIA AI Foundation endpoint. + """ + # Registry IDs already use the canonical dotted form (flux.1-dev, + # flux.2-klein-4b) — pass through verbatim. Rewriting suffixes here + # breaks valid IDs (flux.1-dev would become flux.1.dev → 404). + return f"https://ai.api.nvidia.com/v1/genai/{model_id}" + + +def build_nvidia_headers(api_key: str) -> dict[str, str]: + """Build HTTP headers for NVIDIA NIM API. + + Args: + api_key: NVIDIA API key. + + Returns: + Dict of HTTP header name-value pairs. + """ + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + } + + +def build_nvidia_request_body( + model_id: str, + prompt: str, + width: int, + height: int, + cfg_scale: float = 1, + steps: int = 4, + seed: int = 0, + samples: int = 1, +) -> dict[str, Any]: + """Build JSON request body for NVIDIA NIM Flux models. + + Args: + model_id: Full model ID. + prompt: The image generation prompt text. + width: Image width (768-1344). + height: Image height (768-1344). + cfg_scale: Classifier-free guidance scale (default: 1, min: 1). + steps: Number of diffusion steps. + seed: Random seed (0 for random). + samples: Number of images to generate (always 1). + + Returns: + Dict suitable for JSON serialization as request body. + """ + return { + "prompt": prompt, + "width": width, + "height": height, + "cfg_scale": cfg_scale, + "steps": steps, + "seed": seed, + "samples": samples, + } + + def find_imagemagick() -> str | None: """Find ImageMagick binary (magick for v7, convert for v6). @@ -948,6 +1246,15 @@ def log_cost_entry( "completion_tokens": usage.get("completion_tokens", 0), "total_tokens": usage.get("total_tokens", 0), } + elif provider == "openai": + # Images API usage format: input_tokens / output_tokens / total_tokens + usage = response.get("usage", {}) + if usage: + token_usage = { + "prompt_tokens": usage.get("input_tokens", 0), + "completion_tokens": usage.get("output_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + } else: # Google AI Studio format usage = response.get("usageMetadata", {}) @@ -1110,11 +1417,24 @@ def main() -> None: file=sys.stderr, ) + # Auto-detect provider from the model keyword / id (registry entries with + # an explicit "provider" field, e.g. nvidia-flux2-klein-4b -> nvidia, + # image2 -> openai). + provider = args.provider + if args.model: + _kw = args.model.lower().strip() + _entry = MODEL_REGISTRY.get(_kw) or next( + (e for e in MODEL_REGISTRY.values() if e.get("id") == args.model), None + ) + if _entry and _entry.get("provider"): + provider = _entry["provider"] + log.debug(f"Auto-detected provider '{provider}' from model '{args.model}'") + # Resolve model and modalities - model, modalities = resolve_model(args.model, args.provider) + model, modalities = resolve_model(args.model, provider) # Google direct API needs model ID without the OpenRouter "google/" prefix - if args.provider == "google" and model.startswith("google/"): + if provider == "google" and model.startswith("google/"): model = model[len("google/"):] log.debug(f"Stripped google/ prefix for direct API: {model}") @@ -1177,7 +1497,7 @@ def main() -> None: modalities = ["text"] print("Mode: analyze (text-only output)", file=sys.stderr) - print(f"Provider: {args.provider}", file=sys.stderr) + print(f"Provider: {provider}", file=sys.stderr) print(f"Model: {model}", file=sys.stderr) print(f"Modalities: {', '.join(modalities)}", file=sys.stderr) print(f"Prompt: {prompt[:100]}{'...' if len(prompt) > 100 else ''}", file=sys.stderr) @@ -1187,21 +1507,58 @@ def main() -> None: print(f"Image size: {args.image_size}", file=sys.stderr) # Detect mode - mode, config = detect_mode(args.provider) + mode, config = detect_mode(provider) print(f"Mode: {mode}", file=sys.stderr) # Build request if mode == "gateway": - url = build_gateway_url(args.provider, model, config) + url = build_gateway_url(provider, model, config) else: - url = build_direct_url(args.provider, model) - - headers = build_headers(args.provider, mode, config) - body = build_request_body( - args.provider, model, prompt, args.aspect_ratio, args.image_size, - modalities=modalities, - ref_images=ref_images if ref_images else None, - ) + url = build_direct_url(provider, model) + + headers = build_headers(provider, mode, config) + + # NVIDIA uses a different request body format (flat prompt, not contents/parts) + if provider == "nvidia": + # Per-model defaults from the registry — flux.1-dev needs ~30 steps / + # cfg 5, while schnell and flux.2-klein are distilled 4-step models. + registry_entry = next( + (e for e in MODEL_REGISTRY.values() + if e.get("id") == model and e.get("provider") == "nvidia"), + {}, + ) + nv_defaults = { + k: v.get("default") + for k, v in (registry_entry.get("nvidia_params") or {}).items() + if isinstance(v, dict) and "default" in v + } + width = args.width or nv_defaults.get("width") or 1024 + height = args.height or nv_defaults.get("height") or 1024 + # -a/--aspect-ratio maps to the closest dimensions NVIDIA accepts + if args.aspect_ratio and not (args.width or args.height): + dims = NVIDIA_ASPECT_MAP.get(args.aspect_ratio) + if dims: + width, height = dims + else: + print( + f"WARNING: aspect ratio {args.aspect_ratio} not mapped for NVIDIA; " + f"using {width}x{height}", + file=sys.stderr, + ) + body = build_nvidia_request_body( + model, prompt, + width=width, + height=height, + cfg_scale=args.cfg_scale if args.cfg_scale is not None else nv_defaults.get("cfg_scale", 1), + steps=args.steps if args.steps is not None else nv_defaults.get("steps", 4), + seed=args.seed if args.seed is not None else 0, + ) + else: + body = build_request_body( + provider, model, prompt, args.aspect_ratio, args.image_size, + modalities=modalities, + ref_images=ref_images if ref_images else None, + ) print(f"URL: {url}", file=sys.stderr) if args.analyze: @@ -1221,8 +1578,8 @@ def main() -> None: file=sys.stderr, ) log.info("Initiating fallback to direct API") - url = build_direct_url(args.provider, model) - headers = build_headers(args.provider, "direct", config) + url = build_direct_url(provider, model) + headers = build_headers(provider, "direct", config) try: response = make_request(url, headers, body) except RuntimeError as e2: @@ -1238,8 +1595,10 @@ def main() -> None: if args.analyze: total_elapsed = time.time() - total_start try: - if args.provider == "openrouter": + if provider == "openrouter": analysis_text = extract_text_openrouter(response) + elif provider in ("nvidia", "openai"): + raise RuntimeError(f"Analyze mode not supported for {provider} provider") else: analysis_text = extract_text_google(response) except RuntimeError as e: @@ -1254,7 +1613,7 @@ def main() -> None: try: log_cost_entry( response=response, - provider=args.provider, + provider=provider, model=model, mode=mode, aspect_ratio=None, @@ -1271,7 +1630,7 @@ def main() -> None: "ok": True, "analyze": True, "analysis": analysis_text, - "provider": args.provider, + "provider": provider, "model": model, "mode": mode, "elapsed_seconds": round(total_elapsed, 1), @@ -1285,8 +1644,12 @@ def main() -> None: # Extract image try: - if args.provider == "openrouter": + if provider == "openrouter": image_bytes, text_content = extract_image_openrouter(response) + elif provider == "nvidia": + image_bytes, text_content = extract_image_nvidia(response) + elif provider == "openai": + image_bytes, text_content = extract_image_openai(response) else: image_bytes, text_content = extract_image_google(response) except RuntimeError as e: @@ -1294,6 +1657,49 @@ def main() -> None: log.debug(f"Image extraction failed. Raw response keys: {list(response.keys()) if response else 'None'}") sys.exit(1) + # NVIDIA NIM returns JPEG bytes — convert when the user asked for .png + if ( + provider == "nvidia" + and output_path is not None + and output_path.suffix.lower() == ".png" + and image_bytes[:2] == b"\xff\xd8" + ): + magick_cmd = find_imagemagick() + ffmpeg_cmd = shutil.which("ffmpeg") + if magick_cmd or ffmpeg_cmd: + # Temp files live next to the output — snap-confined ffmpeg + # cannot read the system /tmp directory. + output_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False, dir=output_path.parent) as tj: + jpg_tmp = Path(tj.name) + with tempfile.NamedTemporaryFile(suffix=".png", delete=False, dir=output_path.parent) as tp: + png_tmp = Path(tp.name) + try: + jpg_tmp.write_bytes(image_bytes) + if magick_cmd: + cmd = [magick_cmd, str(jpg_tmp), str(png_tmp)] + else: + cmd = ["ffmpeg", "-y", "-i", str(jpg_tmp), str(png_tmp)] + conv = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + if conv.returncode == 0 and png_tmp.stat().st_size > 0: + image_bytes = png_tmp.read_bytes() + log.info("Converted NVIDIA JPEG output to PNG") + else: + print( + "WARNING: JPEG->PNG conversion failed - saving original JPEG " + "bytes under the .png name", + file=sys.stderr, + ) + finally: + jpg_tmp.unlink(missing_ok=True) + png_tmp.unlink(missing_ok=True) + else: + print( + "WARNING: NVIDIA returned JPEG and no converter (imagemagick/ffmpeg) " + "is installed - saving JPEG bytes under the .png name", + file=sys.stderr, + ) + # Write output (or process transparent mode) assert output_path is not None # guaranteed by validation above output_path.parent.mkdir(parents=True, exist_ok=True) @@ -1319,7 +1725,7 @@ def main() -> None: try: prompt_meta = f"# Prompt\n\n" prompt_meta += f"- **Model:** {model}\n" - prompt_meta += f"- **Provider:** {args.provider} ({mode})\n" + prompt_meta += f"- **Provider:** {provider} ({mode})\n" if args.aspect_ratio: prompt_meta += f"- **Aspect ratio:** {args.aspect_ratio}\n" if args.image_size: @@ -1350,7 +1756,7 @@ def main() -> None: try: log_cost_entry( response=response, - provider=args.provider, + provider=provider, model=model, mode=mode, aspect_ratio=args.aspect_ratio, @@ -1367,7 +1773,7 @@ def main() -> None: "ok": True, "output": str(output_path), "size_bytes": len(image_bytes), - "provider": args.provider, + "provider": provider, "model": model, "mode": mode, "elapsed_seconds": round(total_elapsed, 1), diff --git a/.claude/skills/int-evohub/SKILL.md b/.claude/skills/int-evohub/SKILL.md new file mode 100644 index 00000000..94af71f5 --- /dev/null +++ b/.claude/skills/int-evohub/SKILL.md @@ -0,0 +1,129 @@ +--- +name: int-evohub +description: "EvoHub API — Manage WhatsApp, Instagram, and Facebook channels via Evolution Hub proxy. Use when the user wants to connect/disconnect channels, check channel status, send messages via EvoHub, manage webhooks, or troubleshoot EvoHub integrations. Also trigger for 'evohub', 'hub evolution', 'instagram evohub', 'whatsapp evohub', 'conectar instagram', 'conectar whatsapp', 'status do canal'." +--- + +# EvoHub Integration + +Proxy/hub for Meta APIs (WhatsApp, Instagram, Facebook) via Evolution Foundation. + +## Configuration + +All requests use: +- **Base URL:** `https://api.evohub.ai/api/v1` +- **Auth Header:** `Authorization: Bearer ${EVO_HUB_API_TOKEN}` +- **Token location:** `config/.env` → `EVO_HUB_API_TOKEN` + +## Available Tools + +### Bash Helper + +Use `bash` to make raw API calls. Always include auth header. + +```bash +TOKEN=$(python3 -c " +import re +with open('.env') as f: + for l in f: + m = re.match(r'^EVO_HUB_API_TOKEN=(.*)', l.strip()) + if m: print(m.group(1)) +") +BASE="https://api.evohub.ai/api/v1" + +# Or simply: +grep EVO_HUB_API_TOKEN .env | cut -d= -f2 +``` + +## API Reference + +### User & Plan + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/auth/me` | GET | Current user info | +| `/me/plan` | GET | Current plan details | +| `/me/limits` | GET | Plan limits and quotas | +| `/me/usage` | GET | Current usage stats | + +### Channels + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/channels` | GET | List all channels | +| `/channels/{id}` | GET | Channel details | +| `/channels/{id}` | DELETE | Remove a channel | + +### Channel Types + +Channels have `type` field: +- `whatsapp` — WhatsApp via Evolution +- `instagram` — Instagram via Meta Graph API +- `facebook` — Facebook Pages via Meta Graph API + +Channel `status`: `active` | `inactive` | `pending` +Channel `connection_mode`: `byo` (bring your own Meta app) | `proxy` (shared) + +### Instagram + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/instagram/authorization` | GET | Generate Instagram OAuth URL | +| `/instagram/callback` | GET | OAuth callback — redirects after auth | +| `/instagram/webhook` | POST/GET | Manage Instagram webhook subscriptions | + +### Facebook + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/facebook/channel` | POST | Create Facebook channel | +| `/facebook/pages` | GET | List Facebook Pages | + +### Webhooks + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/webhooks` | GET | List all webhooks | +| `/webhooks` | POST | Create webhook | +| `/webhooks/{id}` | PUT | Update webhook | +| `/webhooks/{id}` | DELETE | Delete webhook | +| `/webhooks/event-types` | GET | Available event types | + +## Common Operations + +### List all channels + +```bash +curl -s -H "Authorization: Bearer $TOKEN" \ + "https://api.evohub.ai/api/v1/channels" | python3 -m json.tool +``` + +### Check user plan + +```bash +curl -s -H "Authorization: Bearer $TOKEN" \ + "https://api.evohub.ai/api/v1/me/plan" | python3 -m json.tool +``` + +### Generate Instagram auth URL + +```bash +curl -s -H "Authorization: Bearer $TOKEN" \ + "https://api.evohub.ai/api/v1/instagram/authorization" | python3 -m json.tool +``` + +### Disconnect a channel + +```bash +curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \ + "https://api.evohub.ai/api/v1/channels/{channel_id}" +``` + +## Current State (as of setup) + +| Channel | Type | ID | Status | +|---------|------|-----|--------| +| Gringo | whatsapp | `9d8220a9-e68a-423d-88fc-aa4efd2a61de` | inactive | +| sistemabritto | instagram | `541c6345-9269-41b4-9f39-25b2e775824c` | inactive | +| Sistema Britto | whatsapp | `8a278e5d-bbfb-48f9-a2da-58231550bdc2` | inactive | + +**Plan:** Free — 1 BYO credential, 3 channels each (WhatsApp/FB/IG), no message limits. diff --git a/.claude/skills/social-ai-trends-blog/SKILL.md b/.claude/skills/social-ai-trends-blog/SKILL.md new file mode 100644 index 00000000..d9860cb1 --- /dev/null +++ b/.claude/skills/social-ai-trends-blog/SKILL.md @@ -0,0 +1,87 @@ +--- +name: social-ai-trends-blog +description: > + Pesquisa semanal de trending topics de IA no X (Twitter) e gera 15 tópicos + virais para o blog Sistema Britto, cada um com gancho para os produtos + Evolution. Pipeline: coleta (X API) → ranqueamento por viralidade → síntese + editorial (Mako) → revisão de alinhamento (Sage) → aprovação humana (Felipe). + Use quando: "trending de IA", "pauta do blog", "tópicos virais da semana", + "o que está bombando em IA", ou no cronjob semanal (segunda 08:00). +--- + +# social-ai-trends-blog — Pauta semanal de IA para o blog + +Transforma o hype real do X em pauta editorial acionável para o blog +(`blog.sistemabritto.com.br`, via `custom-int-ghost`), sempre puxando o gancho +para os produtos Evolution. + +## Produtos para ancorar o gancho + +| Produto | Ângulo de gancho | +|---|---| +| **Evolution API** | API open source de WhatsApp — dono da própria infra, sem lock-in | +| **Evo AI** | CRM + agentes de IA | +| **Evo CRM** | Gestão de relacionamento | +| **EvoGo** | Evolution Go | +| **Evo Academy** | Cursos — gancho para temas "aprenda IA" | +| **Evolution Summit** | Evento — gancho para tendências/futuro | + +## Pipeline (5 fases + cron) + +``` +1. COLETA → fetch_ai_trends.py (X API v2 recent search, 7 dias, sort=relevancy) +2. RANQUEAMENTO→ score = likes + 2·RT + 1.5·quote + 0.5·reply + 1.5·bookmark + 0.001·impressions + + clusterização por tema (regex) + filtro de ruído +3. SÍNTESE → @mako-marketing monta 15 tópicos: título + ângulo + gancho de produto +4. REVISÃO → @sage-strategy revisa alinhamento estratégico, originalidade e gancho +5. APROVAÇÃO → Felipe avalia a lista; só publica/agenda após feedback explícito +``` + +### Agentes + +- **Executor:** `@mako-marketing` (Mako) — síntese editorial e ganchos +- **Supervisor:** `@sage-strategy` (Sage) — revisão de alinhamento e qualidade +- **Aprovador:** Felipe (humano) — nada vai pro ar sem o OK + +## Como rodar (manual) + +```bash +# 1. Coleta + ranqueamento (gera trends_raw.json) +python3 .claude/skills/social-ai-trends-blog/fetch_ai_trends.py --days 7 --per-query 100 + +# 2. Mako lê trends_raw.json e escreve [C]pauta-AAAA-WW.md (15 tópicos) +# 3. Sage revisa e anota; ajustes aplicados +# 4. Entrega a lista ao Felipe no Telegram/dashboard para aprovação +``` + +## Requisitos + +- `SOCIAL_TWITTER_1_BEARER_TOKEN` no `.env` — tier **Basic** ou superior + (recent search com janela de 7 dias + `sort_order=relevancy`). +- Limite: janela máxima de 7 dias no recent search. Para histórico maior, + precisaria do endpoint full-archive (tier Pro/Enterprise). + +## Saídas + +| Arquivo | Conteúdo | +|---|---| +| `trends_raw.json` | Dados brutos: temas ranqueados + top tweets com métricas | +| `[C]pauta-AAAA-WW.md` | Os 15 tópicos finais (gerado pelo Mako, revisado pelo Sage) | + +## Cronjob semanal + +Segunda-feira 08:00 (America/Sao_Paulo). Roda a coleta + síntese + revisão e +notifica o Felipe no Telegram com os 15 tópicos para aprovação. **Não publica +automaticamente** — aprovação humana é obrigatória. + +Registro do cron: ver `config/routines.yaml` (entrada `ai-trends-blog`) ou +agendar via `/schedule`. + +## Anti-padrões + +- ❌ Publicar sem aprovação do Felipe. +- ❌ Confiar só na contagem bruta de virais (tier Basic é amostra limitada) — + usar os **temas** como sinal e sintetizar pautas, não copiar tweets. +- ❌ Gancho forçado de produto onde não cabe — se o tema não conecta com + Evolution, marcar como "sem gancho" em vez de inventar. +- ❌ Repetir pautas de semanas anteriores sem checar histórico. diff --git a/.claude/skills/social-ai-trends-blog/[C]pauta-2026-W24.md b/.claude/skills/social-ai-trends-blog/[C]pauta-2026-W24.md new file mode 100644 index 00000000..109359f4 --- /dev/null +++ b/.claude/skills/social-ai-trends-blog/[C]pauta-2026-W24.md @@ -0,0 +1,45 @@ +# Pauta de IA para o blog — Semana 24/2026 (09–13 jun) + +> Fonte: X API (484 tweets, 7 dias, ordenados por relevância). Executor: Mako. +> Revisão: Sage. Status: **aguardando aprovação do Felipe.** + +**Sinal da semana:** o tema mais viral de longe foi **"Open source AI must win"** +(svpino, omarsar0, dankvr, bindureddy, "Dario com medo do open source"). Isso +joga a favor do posicionamento da Evolution — somos open source. Aproveitar. + +--- + +## Os 15 tópicos + +| # | Título | Ângulo / por que bombou | Gancho de produto | +|---|--------|------------------------|-------------------| +| 1 | **Por que o futuro da IA é open source (e por que isso te beneficia)** | "Open source AI must win" foi o grito da semana no X | **Evolution API** — você é dono da infra, sem lock-in | +| 2 | **Construir em cima de API proprietária é uma bomba-relógio** | Tweet viral: "por que construir numa API que pode cortar seu acesso da noite pro dia?" | **Evolution API** open source vs. dependência de big tech | +| 3 | **Rodando IA no seu hardware: a fazenda de 30 Mac Minis** | Caso viral do "Marcus Chen" empilhando Mac Minis pra rodar IA barato | **EvoGo / self-hosting** — IA sob seu controle | +| 4 | **Agentic AI x AI Agents x IA Generativa: o glossário que 99% erra** | Tweet viral apontando que quase ninguém sabe a diferença | **Evo AI** — agentes de IA na prática | +| 5 | **O abismo de produtividade: quem usa IA entrega dias de trabalho em horas** | "The productivity gap is about to become terrifying" (viral) | **Evo AI + Evolution API** — automação de WhatsApp | +| 6 | **7 usos de agentes de IA que vão muito além do ChatGPT** | "Não entendo por que tanta gente ainda não usa agentes de IA" | **Evo AI** — agentes conectados ao seu negócio | +| 7 | **OpenAI, Anthropic e Google concordam num risco — o que vem aí** | Big labs alinhados num alerta = pauta de autoridade | Evolution Summit (tendências/futuro) | +| 8 | **IA na medicina: a melhor não é a que se vende como "IA médica"** | Estudo na Nature viralizou (glauber_doc) | Vertical de saúde — caso de uso Evo AI | +| 9 | **As 5 habilidades de IA que valem ouro em 2026** | "Learn AI skills" em alta (automação, image gen, vídeo) | **Evo Academy** — trilha de aprendizado | +| 10 | **Deepfakes indistinguíveis: como proteger sua marca** | Vídeo falso do "Roda Viva" viralizou (Boulos desmentiu) | WhatsApp oficial / verificação via **Evolution** | +| 11 | **Construindo agentes com n8n + IA: guia prático** | "Construí 47 agentes com n8n e Claude" (viral) | **Evolution API** como camada de WhatsApp dos agentes | +| 12 | **Como a IA está redesenhando workflows (e quem fica pra trás)** | "AI is reshaping how work gets done globally" | Automação com **Evolution + Evo AI** | +| 13 | **O impacto da IA no emprego e na economia: o que os dados mostram** | Sachsida (213 likes) alertando sobre impacto no emprego | Conteúdo de autoridade / topo de funil | +| 14 | **As empresas dos sonhos de quem trabalha com IA** | Ranking viral (Anthropic, OpenAI, DeepMind) | Cultura/recrutamento — branding Sistema Britto | +| 15 | **Aprenda IA de graça: os melhores recursos desta semana** | Aula de Stanford "Turning Electricity into Intelligence" viralizou | **Evo Academy** — curadoria + trilha própria | + +--- + +## Revisão do Sage (supervisor) + +- ✅ **Forte alinhamento (1, 2, 3, 11):** open source / self-hosting batem direto + com o DNA da Evolution. Priorizar estes 4 — é onde temos autoridade real. +- ✅ **Educacionais (4, 6, 9, 15):** ótimos pra topo de funil + Evo Academy. +- ⚠️ **Atenção (13, 14):** sem gancho de produto claro — usar como autoridade/branding, + não esperar conversão. Manter no máx. 1 por semana. +- ⚠️ **#8 (saúde):** validar se temos caso real de cliente Evo AI em saúde antes de publicar. +- 💡 Sugestão: começar a semana com o #1 (carro-chefe) e o #11 (prático/dev) — + ancoram o posicionamento open source enquanto o tema está quente. + +**Veredito:** lista aprovada para envio ao Felipe. diff --git a/.claude/skills/social-ai-trends-blog/assets/THUMBNAIL-PRIMER.md b/.claude/skills/social-ai-trends-blog/assets/THUMBNAIL-PRIMER.md new file mode 100644 index 00000000..e99a8f19 --- /dev/null +++ b/.claude/skills/social-ai-trends-blog/assets/THUMBNAIL-PRIMER.md @@ -0,0 +1,71 @@ +# Thumbnail Primer — Sistema Britto / blog + YouTube + +Estilo modelado a partir de 3 referências do nicho IA (BR + gringo) enviadas +pelo Felipe. Ver `thumbnail-refs/`. Formato-alvo: **16:9 (1280×720)**, +reaproveitável como capa de vídeo no YouTube. Geração via **gpt-image-2 (OpenAI)** +na skill `ai-image-creator`. + +## Padrão extraído das referências + +### Bloco A — estilo BR (as do próprio Felipe — `ref-01-br-felipe.jpg`) +- Rosto do Felipe à direita/centro, expressão forte (sorriso confiante, dedo + apontando, ou surpresa). Recorte limpo com leve rim light. +- Ícones de app 3D glossy flutuando (Claude, Obsidian, ChatGPT, PIX). +- 1 seta branca desenhada à mão (curva), apontando pro elemento-chave. +- Fundo escuro com dados/dashboard, tons de **verde-limão** (marca). +- Texto curto e pesado + 1 badge de resultado/número (R$0,00, ROAS 3.90, ROI). +- Composição limpa, respira — credível, não poluído. + +### Bloco B — estilo gringo (Hermes/Julian — `ref-02`, `ref-03`) +- Texto GIGANTE, 2-4 palavras, peso black, contorno grosso (stroke). + Ex: "IT'S MASSIVE", "100x UPGRADES", "THIS IS INSANE". +- Rosto em choque/empolgação (boca aberta). +- Neon/glow saturado (roxo, amarelo, vermelho), raios, badge "FREE" vermelho. +- Dashboard com gráfico subindo (prova/resultado). +- Altíssimo contraste, energia agressiva. + +## Receita Sistema Britto (fusão A + B) + +Pegar a **legibilidade e energia** do gringo + a **credibilidade e marca** do BR. + +| Elemento | Regra | +|---|---| +| Proporção | 16:9, 1280×720 | +| Rosto | Felipe (do face-bank), 1/3 do quadro, expressão coerente com a pauta | +| Hook visual | objeto-símbolo no lado oposto: ícone de app / celular / dashboard / dinheiro / logo | +| Headline | 2-5 palavras, fonte black, branco ou verde-limão, stroke escuro grosso | +| Badge | opcional — número/resultado ou "GRÁTIS"/"NOVO" em círculo/retângulo | +| Fundo | escuro + glow verde-limão da marca (evitar roxo/amarelo gringo puro) | +| Seta | no máx. 1, branca, à mão, opcional | +| Limpeza | legível em tamanho pequeno; sem poluição; máx. 1 foco visual | +| Paleta | verde-limão (#A3E635 / lime) + cinza metálico + preto (cores da marca) | + +## Template de prompt para gpt-image-2 + +``` +Thumbnail 16:9 estilo YouTube, alta qualidade, para o nicho de IA/automação. +PESSOA: [rosto do face-bank — descrição/expressão: ex. "homem jovem de óculos, +sorriso confiante apontando"]. Posicionado no terço [esquerdo/direito]. +HOOK VISUAL no lado oposto: [objeto — ex. "ícone 3D glossy do app X" / "gráfico +de dashboard subindo" / "celular com tela de vendas"]. +TEXTO GRANDE em destaque: "[2-5 PALAVRAS]" — fonte black, branca com contorno +escuro grosso, alto contraste. [opcional: badge "[NÚMERO/GRÁTIS]"]. +FUNDO escuro com glow verde-limão (#A3E635) da marca, tons cinza metálico. +Composição limpa, 1 foco visual, legível em tamanho pequeno. Sem poluição. +[opcional: 1 seta branca desenhada à mão apontando para o elemento-chave]. +``` + +Para consistência de rosto, usar `ai-image-creator -r ` +(edição com imagem de referência). + +## Face-bank + +Pasta: `assets/face-bank/`. Felipe vai enviar fotos de rosto dele em várias +expressões (neutro, sorrindo, surpreso, apontando, sério/confiante). Quanto +mais variado, melhor o casamento com cada tipo de pauta. + +## Anti-padrões de thumbnail +- ❌ Texto longo (>5 palavras) ou fonte fina — ilegível no feed. +- ❌ Mais de 1 foco visual — polui. +- ❌ Paleta roxo/amarelo gringo pura — fugir da marca; usar verde-limão. +- ❌ Promessa que o post não cumpre (clickbait vazio) — público BR é calejado. diff --git a/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-01-br-felipe.jpg b/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-01-br-felipe.jpg new file mode 100644 index 00000000..d2e9177d Binary files /dev/null and b/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-01-br-felipe.jpg differ diff --git a/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-02-gringo-hermes.jpg b/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-02-gringo-hermes.jpg new file mode 100644 index 00000000..a02fc714 Binary files /dev/null and b/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-02-gringo-hermes.jpg differ diff --git a/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-03-gringo-julian.jpg b/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-03-gringo-julian.jpg new file mode 100644 index 00000000..84fe672c Binary files /dev/null and b/.claude/skills/social-ai-trends-blog/assets/thumbnail-refs/ref-03-gringo-julian.jpg differ diff --git a/.claude/skills/social-ai-trends-blog/fetch_ai_trends.py b/.claude/skills/social-ai-trends-blog/fetch_ai_trends.py new file mode 100644 index 00000000..59b2b1c2 --- /dev/null +++ b/.claude/skills/social-ai-trends-blog/fetch_ai_trends.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +""" +fetch_ai_trends.py — Coleta tweets sobre IA dos últimos 7 dias via X API v2, +ranqueia por viralidade (likes + RTs + quotes + bookmarks + impressões) e +agrupa por tema. Saída: JSON com os tweets top + agregação por tópico. + +Uso: + python3 fetch_ai_trends.py [--days 7] [--per-query 100] [--out trends.json] + +Requer SOCIAL_TWITTER_1_BEARER_TOKEN no .env (raiz do workspace). +Tier mínimo: Basic (recent search, janela de 7 dias). +""" +import os, sys, json, time, re, argparse, urllib.parse, urllib.request +from datetime import datetime, timezone, timedelta +from collections import defaultdict + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) + +def load_env(): + env = {} + path = os.path.join(ROOT, ".env") + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + env[k.strip()] = v.strip().strip('"').strip("'") + return env + +# Consultas focadas em IA (PT + EN). -is:retweet evita duplicar virais por RT. +QUERIES = [ + '("inteligência artificial" OR "IA generativa" OR "agentes de IA") lang:pt -is:retweet', + '(ChatGPT OR Claude OR Gemini OR "GPT-5" OR Llama) lang:pt -is:retweet', + '(AI agents OR "agentic AI" OR "AI coding" OR "AI automation") lang:en -is:retweet', + '("AI breakthrough" OR "new AI model" OR "AI startup" OR "open source AI") lang:en -is:retweet', + '(OpenAI OR Anthropic OR "Google DeepMind" OR xAI OR Mistral) lang:en -is:retweet', + '("AI agents" OR chatbot OR "customer support AI" OR "WhatsApp AI") lang:en -is:retweet', +] + +def score(m): + return ( + m.get("like_count", 0) * 1.0 + + m.get("retweet_count", 0) * 2.0 + + m.get("quote_count", 0) * 1.5 + + m.get("reply_count", 0) * 0.5 + + m.get("bookmark_count", 0) * 1.5 + + m.get("impression_count", 0) * 0.001 + ) + +# Temas para clusterizar (label -> regex de palavras-chave) +TOPICS = { + "Agentes de IA / Agentic": r"agent|agentic|autonom", + "IA para código/dev": r"coding|code|developer|copilot|cursor|claude code|programaç", + "Modelos novos (GPT/Claude/Gemini/Llama)": r"gpt-?5|gpt5|claude|gemini|llama|mistral|grok|deepseek|qwen", + "OpenAI / Anthropic / Big Labs": r"openai|anthropic|deepmind|xai|microsoft|meta ai|google ai", + "IA generativa de imagem/vídeo": r"image|video|midjourney|sora|veo|flux|diffusion|gerad", + "Automação / Workflows com IA": r"automat|workflow|n8n|zapier|rpa|integraç", + "Chatbots / Atendimento / WhatsApp": r"chatbot|whatsapp|customer support|atendimento|suporte|sac", + "IA no trabalho / produtividade": r"productiv|produtiv|trabalho|job|emprego|workplace", + "Open source / modelos abertos": r"open.?source|open weight|aberto", + "Regulação / ética / segurança": r"regulat|regul|ethic|étic|safety|seguran|privac", + "Negócios / startups / investimento": r"startup|funding|investimen|raise|bilh|billion|valuation|ipo", +} + +def classify(text): + t = text.lower() + hits = [label for label, pat in TOPICS.items() if re.search(pat, t)] + return hits or ["Outros / IA geral"] + +# Ruído conhecido: idol K-pop "Gemini", aniversários, filmes, sorteios. +NOISE = re.compile( + r"#?\d*gemini\s*day|miracle boy|ppnaravit|phuwintang|ohmpawat|aniversário|" + r"feliz aniver|happy birthday|cinemascore|giveaway|sorteio|🎂", + re.IGNORECASE, +) + +def is_noise(text): + return bool(NOISE.search(text or "")) + +def fetch(query, bearer, start_time, max_results=100): + params = { + "query": query, + "max_results": str(max_results), + "start_time": start_time, + "sort_order": "relevancy", + "tweet.fields": "public_metrics,created_at,lang,author_id,entities", + "expansions": "author_id", + "user.fields": "username,name,public_metrics,verified", + } + url = "https://api.twitter.com/2/tweets/search/recent?" + urllib.parse.urlencode(params) + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {bearer}"}) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + body = e.read().decode()[:300] + print(f"[warn] HTTP {e.code} em query '{query[:40]}...': {body}", file=sys.stderr) + return {"data": [], "includes": {}} + except Exception as e: + print(f"[warn] erro em query '{query[:40]}...': {e}", file=sys.stderr) + return {"data": [], "includes": {}} + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--days", type=int, default=7) + ap.add_argument("--per-query", type=int, default=100) + ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "trends_raw.json")) + args = ap.parse_args() + + env = load_env() + bearer = env.get("SOCIAL_TWITTER_1_BEARER_TOKEN") or os.environ.get("SOCIAL_TWITTER_1_BEARER_TOKEN") + if not bearer: + print("ERRO: SOCIAL_TWITTER_1_BEARER_TOKEN não encontrado no .env", file=sys.stderr) + sys.exit(1) + + start = (datetime.now(timezone.utc) - timedelta(days=args.days)).strftime("%Y-%m-%dT%H:%M:%SZ") + users = {} + all_tweets = {} + + for q in QUERIES: + res = fetch(q, bearer, start, args.per_query) + for u in res.get("includes", {}).get("users", []): + users[u["id"]] = u + for tw in res.get("data", []): + if is_noise(tw.get("text", "")): + continue + tw["_score"] = score(tw.get("public_metrics", {})) + tw["_topics"] = classify(tw.get("text", "")) + all_tweets[tw["id"]] = tw + time.sleep(1.2) # respeita rate limit + + tweets = sorted(all_tweets.values(), key=lambda t: t["_score"], reverse=True) + + # Agregação por tema + topic_agg = defaultdict(lambda: {"count": 0, "total_score": 0.0, "top_tweets": []}) + for tw in tweets: + for topic in tw["_topics"]: + agg = topic_agg[topic] + agg["count"] += 1 + agg["total_score"] += tw["_score"] + if len(agg["top_tweets"]) < 5: + u = users.get(tw.get("author_id"), {}) + agg["top_tweets"].append({ + "id": tw["id"], + "text": tw["text"][:280], + "author": u.get("username", "?"), + "metrics": tw.get("public_metrics", {}), + "score": round(tw["_score"], 1), + "url": f"https://x.com/i/web/status/{tw['id']}", + }) + + topics_ranked = sorted( + ({"topic": k, **v} for k, v in topic_agg.items()), + key=lambda x: x["total_score"], reverse=True, + ) + + out = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "window_days": args.days, + "queries": QUERIES, + "total_tweets_collected": len(tweets), + "topics_ranked": topics_ranked, + "top_tweets_overall": [ + { + "text": t["text"][:280], + "author": users.get(t.get("author_id"), {}).get("username", "?"), + "metrics": t.get("public_metrics", {}), + "score": round(t["_score"], 1), + "topics": t["_topics"], + "url": f"https://x.com/i/web/status/{t['id']}", + } + for t in tweets[:40] + ], + } + with open(args.out, "w", encoding="utf-8") as f: + json.dump(out, f, ensure_ascii=False, indent=2) + print(f"OK: {len(tweets)} tweets coletados, {len(topics_ranked)} temas. Saída: {args.out}") + +if __name__ == "__main__": + main() diff --git a/.claude/skills/social-ai-trends-blog/trends_raw.json b/.claude/skills/social-ai-trends-blog/trends_raw.json new file mode 100644 index 00000000..8e39402c --- /dev/null +++ b/.claude/skills/social-ai-trends-blog/trends_raw.json @@ -0,0 +1,1700 @@ +{ + "generated_at": "2026-06-13T13:48:15.931514+00:00", + "window_days": 7, + "queries": [ + "(\"inteligência artificial\" OR \"IA generativa\" OR \"agentes de IA\") lang:pt -is:retweet", + "(ChatGPT OR Claude OR Gemini OR \"GPT-5\" OR Llama) lang:pt -is:retweet", + "(AI agents OR \"agentic AI\" OR \"AI coding\" OR \"AI automation\") lang:en -is:retweet", + "(\"AI breakthrough\" OR \"new AI model\" OR \"AI startup\" OR \"open source AI\") lang:en -is:retweet", + "(OpenAI OR Anthropic OR \"Google DeepMind\" OR xAI OR Mistral) lang:en -is:retweet", + "(\"AI agents\" OR chatbot OR \"customer support AI\" OR \"WhatsApp AI\") lang:en -is:retweet" + ], + "total_tweets_collected": 484, + "topics_ranked": [ + { + "topic": "Modelos novos (GPT/Claude/Gemini/Llama)", + "count": 142, + "total_score": 4349.158000000002, + "top_tweets": [ + { + "id": "2064830151305699469", + "text": "Google founders are Jewish, has a frontier model\nAnthropic founder is Jewish, has a frontier model\nOpenAI founder is Jewish, has a frontier model\n\nxai founder isn’t Jewish, doesn’t have a frontier model\nMicrosoft founder isn’t Jewish, doesn’t have a frontier model\nDeepseek https:", + "author": "GolerGkA", + "metrics": { + "retweet_count": 51, + "reply_count": 65, + "like_count": 1618, + "quote_count": 19, + "bookmark_count": 443, + "impression_count": 272706 + }, + "score": 2718.2, + "url": "https://x.com/i/web/status/2064830151305699469" + }, + { + "id": "2063506042114474143", + "text": "MARCUS CHEN EMPILHOU 30 MAC MINIS E CRIOU UMA FAZENDA DE SERVIDORES DE IA. UM ÚNICO MAC MINI DE US$ 599 PODE SUBSTITUIR SUA CONTA DE US$ 200/MÊS DO CLAUDE CODE GASTANDO APENAS US$ 3 DE ENERGIA.\n\nHá dois meses, um desenvolvedor publicou no Reddit a sua fatura do Claude Code: US$ h", + "author": "DehumanoaDeus", + "metrics": { + "retweet_count": 20, + "reply_count": 14, + "like_count": 190, + "quote_count": 1, + "bookmark_count": 100, + "impression_count": 39423 + }, + "score": 427.9, + "url": "https://x.com/i/web/status/2063506042114474143" + }, + { + "id": "2065418102301442418", + "text": "chatgpt vs gemini mas o chatgpt tem um bundão, saia e uma venda nos olhos https://t.co/LgBDoYWL00", + "author": "scalabriniii", + "metrics": { + "retweet_count": 8, + "reply_count": 10, + "like_count": 322, + "quote_count": 3, + "bookmark_count": 33, + "impression_count": 22657 + }, + "score": 419.7, + "url": "https://x.com/i/web/status/2065418102301442418" + }, + { + "id": "2064346800543482056", + "text": "AI is reshaping how work gets done globally.\n\nIt’s changing workflows, tasks, and decision-making systems.\n\nSharing these Top 7 AI models compiled by @AndrewBolis.\n\n1. ChatGPT (OpenAI)\n\nVersatile for writing, research, guidance, and support.\n\n2. Claude (Anthropic)\n\nFocuses on htt", + "author": "Igor_Buinevici", + "metrics": { + "retweet_count": 37, + "reply_count": 6, + "like_count": 98, + "quote_count": 0, + "bookmark_count": 72, + "impression_count": 7256 + }, + "score": 290.3, + "url": "https://x.com/i/web/status/2064346800543482056" + }, + { + "id": "2065422550004371529", + "text": "E se a IA mais preparada para apoiar o médico não for a que se vende como “IA médica”?\n\nUm estudo publicado na Nature Medicine comparou ferramentas clínicas comerciais e dedicadas, OpenEvidence e UpToDate Expert AI, com LLMs gerais (GPT-5.2, Gemini 3.1 Pro e Claude Opus 4.6).\n\nNo", + "author": "glauber_doc", + "metrics": { + "retweet_count": 5, + "reply_count": 16, + "like_count": 74, + "quote_count": 0, + "bookmark_count": 25, + "impression_count": 4178 + }, + "score": 133.7, + "url": "https://x.com/i/web/status/2065422550004371529" + } + ] + }, + { + "topic": "OpenAI / Anthropic / Big Labs", + "count": 111, + "total_score": 4003.280999999999, + "top_tweets": [ + { + "id": "2064830151305699469", + "text": "Google founders are Jewish, has a frontier model\nAnthropic founder is Jewish, has a frontier model\nOpenAI founder is Jewish, has a frontier model\n\nxai founder isn’t Jewish, doesn’t have a frontier model\nMicrosoft founder isn’t Jewish, doesn’t have a frontier model\nDeepseek https:", + "author": "GolerGkA", + "metrics": { + "retweet_count": 51, + "reply_count": 65, + "like_count": 1618, + "quote_count": 19, + "bookmark_count": 443, + "impression_count": 272706 + }, + "score": 2718.2, + "url": "https://x.com/i/web/status/2064830151305699469" + }, + { + "id": "2064346800543482056", + "text": "AI is reshaping how work gets done globally.\n\nIt’s changing workflows, tasks, and decision-making systems.\n\nSharing these Top 7 AI models compiled by @AndrewBolis.\n\n1. ChatGPT (OpenAI)\n\nVersatile for writing, research, guidance, and support.\n\n2. Claude (Anthropic)\n\nFocuses on htt", + "author": "Igor_Buinevici", + "metrics": { + "retweet_count": 37, + "reply_count": 6, + "like_count": 98, + "quote_count": 0, + "bookmark_count": 72, + "impression_count": 7256 + }, + "score": 290.3, + "url": "https://x.com/i/web/status/2064346800543482056" + }, + { + "id": "2063273860754231748", + "text": "Some of the best companies to work for according to candidates.\n\nS+: Anthropic, OpenAI, Google DeepMind, Rentech, TGS, xAI, Citadel Securities, Jane Street, HRT\n\nS: Citadel, D.E. Shaw, Jump, Optiver, Two Sigma, Tesla (Autopilot), Five Rings, SpaceX\n\nS-: IMC, SIG, DRW, Akuna\n\nA++:", + "author": "vivoplt", + "metrics": { + "retweet_count": 8, + "reply_count": 33, + "like_count": 72, + "quote_count": 0, + "bookmark_count": 47, + "impression_count": 7640 + }, + "score": 182.6, + "url": "https://x.com/i/web/status/2063273860754231748" + }, + { + "id": "2065284539564675171", + "text": "If you get offers from these 3 companies : \n\n- OpenAI \n- Anthropic \n- Google Deepmind \n\nWhich one would you join, and why?", + "author": "Its_Nova1012", + "metrics": { + "retweet_count": 1, + "reply_count": 65, + "like_count": 61, + "quote_count": 0, + "bookmark_count": 2, + "impression_count": 3560 + }, + "score": 102.1, + "url": "https://x.com/i/web/status/2065284539564675171" + }, + { + "id": "2063637138118516779", + "text": "OpenAI, Anthropic, Google DeepMind, and Microsoft just agreed on something.\n\nThey're all warning about the same risk: future AI lowering the knowledge barrier around dangerous biological material.\n\nRead the shocking details🧵: https://t.co/EQPjK1gzjG", + "author": "themetav3rse", + "metrics": { + "retweet_count": 20, + "reply_count": 6, + "like_count": 43, + "quote_count": 1, + "bookmark_count": 3, + "impression_count": 7481 + }, + "score": 99.5, + "url": "https://x.com/i/web/status/2063637138118516779" + } + ] + }, + { + "topic": "Open source / modelos abertos", + "count": 105, + "total_score": 1812.6340000000002, + "top_tweets": [ + { + "id": "2065627605144055865", + "text": "This is why you should support open source AI \n\nAuthoritarian governments can’t exert control on our lives and play god \n\nWithout open source AI, US government will restrict AGI access to a small set of elitists 😭", + "author": "bindureddy", + "metrics": { + "retweet_count": 54, + "reply_count": 81, + "like_count": 448, + "quote_count": 8, + "bookmark_count": 21, + "impression_count": 60284 + }, + "score": 700.3, + "url": "https://x.com/i/web/status/2065627605144055865" + }, + { + "id": "2065733417661030904", + "text": "open-source ai is the only way forward.", + "author": "svpino", + "metrics": { + "retweet_count": 13, + "reply_count": 20, + "like_count": 92, + "quote_count": 2, + "bookmark_count": 2, + "impression_count": 4823 + }, + "score": 138.8, + "url": "https://x.com/i/web/status/2065733417661030904" + }, + { + "id": "2065632446255665232", + "text": "Open source AI must win!", + "author": "omarsar0", + "metrics": { + "retweet_count": 9, + "reply_count": 11, + "like_count": 81, + "quote_count": 0, + "bookmark_count": 3, + "impression_count": 5587 + }, + "score": 114.6, + "url": "https://x.com/i/web/status/2065632446255665232" + }, + { + "id": "2064814880826253368", + "text": "Clip with David Morgan: Is open source AI the printing press of the 21st century? We need to use open source AI to further pro-liberty, pro-human goals! https://t.co/uSl0BsOXI9", + "author": "HealthRanger", + "metrics": { + "retweet_count": 17, + "reply_count": 9, + "like_count": 59, + "quote_count": 0, + "bookmark_count": 6, + "impression_count": 5140 + }, + "score": 111.6, + "url": "https://x.com/i/web/status/2064814880826253368" + }, + { + "id": "2065608055572640192", + "text": "Open source AI must win https://t.co/MnCdiKGGan https://t.co/lRRUXFHfxk", + "author": "dankvr", + "metrics": { + "retweet_count": 8, + "reply_count": 6, + "like_count": 65, + "quote_count": 0, + "bookmark_count": 2, + "impression_count": 4367 + }, + "score": 91.4, + "url": "https://x.com/i/web/status/2065608055572640192" + } + ] + }, + { + "topic": "IA para código/dev", + "count": 72, + "total_score": 849.6419999999997, + "top_tweets": [ + { + "id": "2063506042114474143", + "text": "MARCUS CHEN EMPILHOU 30 MAC MINIS E CRIOU UMA FAZENDA DE SERVIDORES DE IA. UM ÚNICO MAC MINI DE US$ 599 PODE SUBSTITUIR SUA CONTA DE US$ 200/MÊS DO CLAUDE CODE GASTANDO APENAS US$ 3 DE ENERGIA.\n\nHá dois meses, um desenvolvedor publicou no Reddit a sua fatura do Claude Code: US$ h", + "author": "DehumanoaDeus", + "metrics": { + "retweet_count": 20, + "reply_count": 14, + "like_count": 190, + "quote_count": 1, + "bookmark_count": 100, + "impression_count": 39423 + }, + "score": 427.9, + "url": "https://x.com/i/web/status/2063506042114474143" + }, + { + "id": "2064611414241648817", + "text": "2026!!\n\nLearn Ai!!\nLearn Ai skills!!\nLearn Ai Image Gen!!\nLearn Ai Automation!!\nLearn Ai Video editing!!\nLearn AI Content creation!!\nLearn AI Prompt Engineering!!\nLearn Ai Coding(Vibe coding)!!", + "author": "AutomationKing0", + "metrics": { + "retweet_count": 7, + "reply_count": 5, + "like_count": 53, + "quote_count": 1, + "bookmark_count": 17, + "impression_count": 1292 + }, + "score": 97.8, + "url": "https://x.com/i/web/status/2064611414241648817" + }, + { + "id": "2063475839040438713", + "text": "🤖 Awesome Open Source AI 🛡️\n\nA curated collection of the best open-source AI models, frameworks, agents, RAG tools, inference engines, LLMOps platforms, and developer resources.\n\nPerfect for AI engineers, builders, researchers, and open-source enthusiasts. 🚀\n\n🔗 https://t.co/gtFiT", + "author": "VivekIntel", + "metrics": { + "retweet_count": 6, + "reply_count": 2, + "like_count": 29, + "quote_count": 1, + "bookmark_count": 14, + "impression_count": 1145 + }, + "score": 65.6, + "url": "https://x.com/i/web/status/2063475839040438713" + }, + { + "id": "2064069746530668679", + "text": "⚡AI coding agents are no longer just assistants—they’re becoming autonomous teammates.\n\nFrom code completion to multi-step execution across the SDLC, the shift is redefining software engineering.\n\nExplore what’s driving this evolution in Gartner’s 2026 Magic Quadrant for https://", + "author": "Gartner_inc", + "metrics": { + "retweet_count": 6, + "reply_count": 1, + "like_count": 20, + "quote_count": 1, + "bookmark_count": 4, + "impression_count": 2089 + }, + "score": 42.1, + "url": "https://x.com/i/web/status/2064069746530668679" + }, + { + "id": "2063330354694705534", + "text": "Deepseek is still releasing its foundational models and also reportedly closing in on a massive $7B funding round as of early June 2026.\n\nSora got shutdown \n\nGitHub Copilot has huge market in enterprise coding agents\n\nMeta released Llama 4 (April 2025) Scout and Maverick its http", + "author": "lochan_twt", + "metrics": { + "retweet_count": 1, + "reply_count": 2, + "like_count": 21, + "quote_count": 0, + "bookmark_count": 3, + "impression_count": 3474 + }, + "score": 32.0, + "url": "https://x.com/i/web/status/2063330354694705534" + } + ] + }, + { + "topic": "Automação / Workflows com IA", + "count": 38, + "total_score": 614.0729999999998, + "top_tweets": [ + { + "id": "2064346800543482056", + "text": "AI is reshaping how work gets done globally.\n\nIt’s changing workflows, tasks, and decision-making systems.\n\nSharing these Top 7 AI models compiled by @AndrewBolis.\n\n1. ChatGPT (OpenAI)\n\nVersatile for writing, research, guidance, and support.\n\n2. Claude (Anthropic)\n\nFocuses on htt", + "author": "Igor_Buinevici", + "metrics": { + "retweet_count": 37, + "reply_count": 6, + "like_count": 98, + "quote_count": 0, + "bookmark_count": 72, + "impression_count": 7256 + }, + "score": 290.3, + "url": "https://x.com/i/web/status/2064346800543482056" + }, + { + "id": "2064724369814044869", + "text": "The productivity gap is about to become terrifying.\n\nAI users are finishing days of work in hours.\n\nHere's the complete AI automation guide.\n\n📂 AI Automation\n┃\n┣ 📂 Foundations\n┃ ┣ 📂 What Is AI Automation\n┃ ┣ 📂 Automation vs AI\n┃ ┣ 📂 Decision Making\n┃ ┣ 📂 Learning https://t.co/N2E", + "author": "shushant_l", + "metrics": { + "retweet_count": 18, + "reply_count": 14, + "like_count": 67, + "quote_count": 1, + "bookmark_count": 48, + "impression_count": 2067 + }, + "score": 185.6, + "url": "https://x.com/i/web/status/2064724369814044869" + }, + { + "id": "2064611414241648817", + "text": "2026!!\n\nLearn Ai!!\nLearn Ai skills!!\nLearn Ai Image Gen!!\nLearn Ai Automation!!\nLearn Ai Video editing!!\nLearn AI Content creation!!\nLearn AI Prompt Engineering!!\nLearn Ai Coding(Vibe coding)!!", + "author": "AutomationKing0", + "metrics": { + "retweet_count": 7, + "reply_count": 5, + "like_count": 53, + "quote_count": 1, + "bookmark_count": 17, + "impression_count": 1292 + }, + "score": 97.8, + "url": "https://x.com/i/web/status/2064611414241648817" + }, + { + "id": "2063278361678360640", + "text": "Here are some of the biggest AI trends in 2026:\n\n1. Agentic AI\n\nAI systems are moving beyond chatbots into “agents” that can:\n\nPlan multi-step tasks\nUse tools and software\nConduct research\nAutomate workflows\nCollaborate with other AI agents\n\nExamples include coding agents,", + "author": "JCKCROWLEY", + "metrics": { + "retweet_count": 0, + "reply_count": 5, + "like_count": 4, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 259 + }, + "score": 6.8, + "url": "https://x.com/i/web/status/2063278361678360640" + }, + { + "id": "2064661557175570655", + "text": ".\nDomain Listed For Sale\n\nhttps://t.co/qtLNGrsrun\n\n#Magentic #Agents #Agentic #AI #Automation #Orchestration #MultiAgent #LLM #MachineLearning #Robotics #Autonomous #DeepTech #DomainForSale https://t.co/G0JLS2SkC8", + "author": "DomainFQ", + "metrics": { + "retweet_count": 1, + "reply_count": 1, + "like_count": 3, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 21 + }, + "score": 5.5, + "url": "https://x.com/i/web/status/2064661557175570655" + } + ] + }, + { + "topic": "Agentes de IA / Agentic", + "count": 116, + "total_score": 561.627, + "top_tweets": [ + { + "id": "2064392166131265627", + "text": "Most people in AI can't actually explain the difference between \"Generative AI,\" \"Agentic AI,\" and \"AI Agents.\"\n\nThey use all three like they mean the same thing. They don't. And once it clicks, you can't unsee it.\n\nHere's the cleanest way to think about it:\n\nGenerative AI is the", + "author": "VaibhavSisinty", + "metrics": { + "retweet_count": 5, + "reply_count": 7, + "like_count": 38, + "quote_count": 1, + "bookmark_count": 33, + "impression_count": 2984 + }, + "score": 105.5, + "url": "https://x.com/i/web/status/2064392166131265627" + }, + { + "id": "2063475839040438713", + "text": "🤖 Awesome Open Source AI 🛡️\n\nA curated collection of the best open-source AI models, frameworks, agents, RAG tools, inference engines, LLMOps platforms, and developer resources.\n\nPerfect for AI engineers, builders, researchers, and open-source enthusiasts. 🚀\n\n🔗 https://t.co/gtFiT", + "author": "VivekIntel", + "metrics": { + "retweet_count": 6, + "reply_count": 2, + "like_count": 29, + "quote_count": 1, + "bookmark_count": 14, + "impression_count": 1145 + }, + "score": 65.6, + "url": "https://x.com/i/web/status/2063475839040438713" + }, + { + "id": "2065160042459152636", + "text": "Agentic AI is creating a new class of trust problems.\n\nUseful agents need (1) access to private information, (2) access to untrusted external input, and (3) the ability to act.\n\nHowever, once we combine these three properties, there is always the possibility that an agent can be ", + "author": "_weidai", + "metrics": { + "retweet_count": 2, + "reply_count": 7, + "like_count": 22, + "quote_count": 0, + "bookmark_count": 14, + "impression_count": 4188 + }, + "score": 54.7, + "url": "https://x.com/i/web/status/2065160042459152636" + }, + { + "id": "2065521758422364435", + "text": "OpenAI, Google DeepMind, Meta, Microsoft, NVIDIA, Mistral, Hugging Face\n\nthe labs everyone says are closing ai behind a paywall quietly shipped their core tools on github. here are 7 repos, one from each\n\n1. OpenAI, openai/openai-agents-python\nhttps://t.co/6UfYVDOcTy\n27k stars. h", + "author": "vorty279", + "metrics": { + "retweet_count": 2, + "reply_count": 2, + "like_count": 22, + "quote_count": 0, + "bookmark_count": 10, + "impression_count": 878 + }, + "score": 42.9, + "url": "https://x.com/i/web/status/2065521758422364435" + }, + { + "id": "2064069746530668679", + "text": "⚡AI coding agents are no longer just assistants—they’re becoming autonomous teammates.\n\nFrom code completion to multi-step execution across the SDLC, the shift is redefining software engineering.\n\nExplore what’s driving this evolution in Gartner’s 2026 Magic Quadrant for https://", + "author": "Gartner_inc", + "metrics": { + "retweet_count": 6, + "reply_count": 1, + "like_count": 20, + "quote_count": 1, + "bookmark_count": 4, + "impression_count": 2089 + }, + "score": 42.1, + "url": "https://x.com/i/web/status/2064069746530668679" + } + ] + }, + { + "topic": "Outros / IA geral", + "count": 78, + "total_score": 405.62599999999964, + "top_tweets": [ + { + "id": "2065787621427589404", + "text": "Ótimo uso de inteligência artificial por David Brazil. https://t.co/eWm99QOyrm", + "author": "chicobarney", + "metrics": { + "retweet_count": 3, + "reply_count": 9, + "like_count": 70, + "quote_count": 2, + "bookmark_count": 1, + "impression_count": 2834 + }, + "score": 87.8, + "url": "https://x.com/i/web/status/2065787621427589404" + }, + { + "id": "2064331850382753938", + "text": "@memes_horriveis Akinator não é IA generativa meu anjo", + "author": "ZhyrelDragon", + "metrics": { + "retweet_count": 0, + "reply_count": 1, + "like_count": 68, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 1557 + }, + "score": 70.1, + "url": "https://x.com/i/web/status/2064331850382753938" + }, + { + "id": "2065122858162868332", + "text": "INTELIGÊNCIA ARTIFICIAL | A ministra Cármen Lúcia, do Supremo Tribunal Federal (STF), destacou os riscos do uso indevido da inteligência artificial, nessa terça-feira (9), durante o 6º Congresso Brasileiro de Internet. https://t.co/DRLhtvThrv", + "author": "ebcnarede", + "metrics": { + "retweet_count": 12, + "reply_count": 1, + "like_count": 38, + "quote_count": 1, + "bookmark_count": 0, + "impression_count": 414 + }, + "score": 64.4, + "url": "https://x.com/i/web/status/2065122858162868332" + }, + { + "id": "2063698566791450971", + "text": "@IsadoraGameOver O problema eh esse, eles não tão usando só IA como ferramenta kkkk\n\nAli claramente diz IA GENERATIVA, o que é algo COMPLETAMENTE diferente de uma IA comum\n\nMuito provavelmente vão usar IA generativa pra concept arts, designs e por aí vai", + "author": "FlipeArt2", + "metrics": { + "retweet_count": 0, + "reply_count": 6, + "like_count": 30, + "quote_count": 0, + "bookmark_count": 1, + "impression_count": 1033 + }, + "score": 35.5, + "url": "https://x.com/i/web/status/2063698566791450971" + }, + { + "id": "2064358367498273167", + "text": "@rafaelgloves Pensa assim.\n\nProduto A é o que resolve o seu problema. \n\nProduto B no máximo quebra o galho mas é MUITO mais barato que o A.\n\nAcontece que o B está ficando cada vez melhor, enquanto continua sendo MUITO mais barato que o A.\n\nUm belo dia, você pensa: quer saber? O p", + "author": "rodrigofm", + "metrics": { + "retweet_count": 0, + "reply_count": 2, + "like_count": 26, + "quote_count": 0, + "bookmark_count": 2, + "impression_count": 2921 + }, + "score": 32.9, + "url": "https://x.com/i/web/status/2064358367498273167" + } + ] + }, + { + "topic": "Negócios / startups / investimento", + "count": 47, + "total_score": 225.66999999999993, + "top_tweets": [ + { + "id": "2063674932471730626", + "text": "Every single AI startup powered by @mintlify with $10B+ valuation and $100M+ revenue run rate: \n\nCrusoe - $10B \nMercor - $10B ✅ \nElevenLabs - $11B \nBaseten - $11B* ✅\nHarvey - $11B ✅\nLovable - $12B* ✅\nOpenEvidence - $12B \nMistral - $14B \nNscale - $14.6B \nFireworks - $15B* ✅ https", + "author": "pronounsuponly", + "metrics": { + "retweet_count": 1, + "reply_count": 11, + "like_count": 23, + "quote_count": 0, + "bookmark_count": 3, + "impression_count": 5703 + }, + "score": 40.7, + "url": "https://x.com/i/web/status/2063674932471730626" + }, + { + "id": "2063330354694705534", + "text": "Deepseek is still releasing its foundational models and also reportedly closing in on a massive $7B funding round as of early June 2026.\n\nSora got shutdown \n\nGitHub Copilot has huge market in enterprise coding agents\n\nMeta released Llama 4 (April 2025) Scout and Maverick its http", + "author": "lochan_twt", + "metrics": { + "retweet_count": 1, + "reply_count": 2, + "like_count": 21, + "quote_count": 0, + "bookmark_count": 3, + "impression_count": 3474 + }, + "score": 32.0, + "url": "https://x.com/i/web/status/2063330354694705534" + }, + { + "id": "2064149611762118779", + "text": "🚨 vocês entenderam o que acabou de acontecer com a Apple..\n\na Apple apresentou sua nova Siri AI durante a WWDC 2026.\n\nhoras depois, o mercado respondeu.\n\nas ações fecharam em queda de 1,9%, apagando bilhões de dólares em valor de mercado.\n\n- Apple fechou o dia cotada a US$ https:", + "author": "FelpsCrypto", + "metrics": { + "retweet_count": 2, + "reply_count": 1, + "like_count": 10, + "quote_count": 0, + "bookmark_count": 2, + "impression_count": 7040 + }, + "score": 24.5, + "url": "https://x.com/i/web/status/2064149611762118779" + }, + { + "id": "2063542839527575674", + "text": "This thread seems obviously correct; even if Anthropic is better, it's very likely to be a more fragile position. \n\nRobust governance is where an experienced company has an advantage - but the exemplar here is Google Deepmind, and not OpenAI, which is still basically a startup. h", + "author": "davidmanheim", + "metrics": { + "retweet_count": 0, + "reply_count": 1, + "like_count": 15, + "quote_count": 1, + "bookmark_count": 2, + "impression_count": 3149 + }, + "score": 23.1, + "url": "https://x.com/i/web/status/2063542839527575674" + }, + { + "id": "2063692675673428269", + "text": "Great list!\nIf you want to follow any of the 10B+ valuation AI companies on X to keep track of the market, here are their handles:\n\nCrusoe ($10B) - @CrusoeAI\nMercor ($10B) - @mercor_ai\nElevenLabs ($11B) - @elevenlabs\nHarvey ($11B) - @harvey\nBaseten ($11B) - @baseten\nLovable https", + "author": "TamazGadaev", + "metrics": { + "retweet_count": 0, + "reply_count": 2, + "like_count": 6, + "quote_count": 0, + "bookmark_count": 9, + "impression_count": 941 + }, + "score": 21.4, + "url": "https://x.com/i/web/status/2063692675673428269" + } + ] + }, + { + "topic": "IA no trabalho / produtividade", + "count": 16, + "total_score": 212.85199999999998, + "top_tweets": [ + { + "id": "2064724369814044869", + "text": "The productivity gap is about to become terrifying.\n\nAI users are finishing days of work in hours.\n\nHere's the complete AI automation guide.\n\n📂 AI Automation\n┃\n┣ 📂 Foundations\n┃ ┣ 📂 What Is AI Automation\n┃ ┣ 📂 Automation vs AI\n┃ ┣ 📂 Decision Making\n┃ ┣ 📂 Learning https://t.co/N2E", + "author": "shushant_l", + "metrics": { + "retweet_count": 18, + "reply_count": 14, + "like_count": 67, + "quote_count": 1, + "bookmark_count": 48, + "impression_count": 2067 + }, + "score": 185.6, + "url": "https://x.com/i/web/status/2064724369814044869" + }, + { + "id": "2065495501701542178", + "text": "Por que o Léo usa Claude pro trabalho e ChatGPT pro dia a dia:\n\n1. Claude te pergunta antes de assumir. \"Quer HTML ou PDF? A empresa é alavancada ou não?\" Isso refina o output antes de processar.\n\n2. Reasoning mais forte pra finanças, advocacia, trabalho analítico.\n\n3. ChatGPT te", + "author": "opapoeconomico", + "metrics": { + "retweet_count": 0, + "reply_count": 1, + "like_count": 11, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 1027 + }, + "score": 12.5, + "url": "https://x.com/i/web/status/2065495501701542178" + }, + { + "id": "2065462303118471608", + "text": "📚 EDTECH, ONLINE LEARNING & PRODUCTIVITY ROUNDUP — June 12, 2026\n\n1️⃣ STOP AUDITING WORKFLOWS — START WITH GOALS INSTEAD\n\nAllie K. Miller challenges the conventional AI-labs advice that you should start with a workflow audit when adopting AI at work. She argues that because", + "author": "TraffAlex", + "metrics": { + "retweet_count": 0, + "reply_count": 2, + "like_count": 3, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 88 + }, + "score": 4.1, + "url": "https://x.com/i/web/status/2065462303118471608" + }, + { + "id": "2065531557264113986", + "text": "Por que o Léo usa Claude pro trabalho e ChatGPT pro dia a dia:\n\n1. Claude te pergunta antes de assumir. \"Quer HTML ou PDF? A empresa é alavancada ou não?\" Isso refina o output antes de processar.\n\n2. Reasoning mais forte pra finanças, advocacia, trabalho analítico.\n\n3. ChatGPT te", + "author": "opapoeconomico", + "metrics": { + "retweet_count": 0, + "reply_count": 1, + "like_count": 2, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 350 + }, + "score": 2.9, + "url": "https://x.com/i/web/status/2065531557264113986" + }, + { + "id": "2065675890839728277", + "text": "samsung baniu chatgpt em 2023 depois de vazar código interno.\n\nagora em 2026? liberou chatgpt, gemini E claude pra empresa inteira.\n\no que mudou?\n\nnada — a pressão da concorrência só ficou alta demais.\n\nsegurança sempre foi desculpa, produtividade sempre foi o jogo.\né tudo https:", + "author": "netaotech", + "metrics": { + "retweet_count": 0, + "reply_count": 0, + "like_count": 1, + "quote_count": 0, + "bookmark_count": 1, + "impression_count": 28 + }, + "score": 2.5, + "url": "https://x.com/i/web/status/2065675890839728277" + } + ] + }, + { + "topic": "IA generativa de imagem/vídeo", + "count": 17, + "total_score": 160.08999999999995, + "top_tweets": [ + { + "id": "2064611414241648817", + "text": "2026!!\n\nLearn Ai!!\nLearn Ai skills!!\nLearn Ai Image Gen!!\nLearn Ai Automation!!\nLearn Ai Video editing!!\nLearn AI Content creation!!\nLearn AI Prompt Engineering!!\nLearn Ai Coding(Vibe coding)!!", + "author": "AutomationKing0", + "metrics": { + "retweet_count": 7, + "reply_count": 5, + "like_count": 53, + "quote_count": 1, + "bookmark_count": 17, + "impression_count": 1292 + }, + "score": 97.8, + "url": "https://x.com/i/web/status/2064611414241648817" + }, + { + "id": "2063330354694705534", + "text": "Deepseek is still releasing its foundational models and also reportedly closing in on a massive $7B funding round as of early June 2026.\n\nSora got shutdown \n\nGitHub Copilot has huge market in enterprise coding agents\n\nMeta released Llama 4 (April 2025) Scout and Maverick its http", + "author": "lochan_twt", + "metrics": { + "retweet_count": 1, + "reply_count": 2, + "like_count": 21, + "quote_count": 0, + "bookmark_count": 3, + "impression_count": 3474 + }, + "score": 32.0, + "url": "https://x.com/i/web/status/2063330354694705534" + }, + { + "id": "2063993734954353148", + "text": "Quando eu vejo anúncio de carro com foto gerada por inteligência artificial, eu assumo que o carro anunciado não existe. Única explicação plausível para gerar uma foto com inteligência artificial para o anúncio", + "author": "XerulebeXarabla", + "metrics": { + "retweet_count": 0, + "reply_count": 3, + "like_count": 14, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 445 + }, + "score": 15.9, + "url": "https://x.com/i/web/status/2063993734954353148" + }, + { + "id": "2064404552506282051", + "text": "@groxxxo sim, deu positivo para chatgpt, gemini, claude, deepseek, meta IA, grok, perplexity, character ai, ollama \n(online, não local)\nnão, local também, midjourney, copilot, notion, otter e a boa e velha luzIA", + "author": "luluntheweb", + "metrics": { + "retweet_count": 0, + "reply_count": 1, + "like_count": 5, + "quote_count": 0, + "bookmark_count": 1, + "impression_count": 417 + }, + "score": 7.4, + "url": "https://x.com/i/web/status/2064404552506282051" + }, + { + "id": "2064072188756648270", + "text": "@LFTH_Alex the only companies that really prioritized image generation were like Midjourney and Stability AI, which were fairly small compared to OpenAI/Anthropic/Google deepmind. Most capital of those latter three has went to creating and serving LLMs, with image/video generatio", + "author": "SOPHONTSIMP", + "metrics": { + "retweet_count": 0, + "reply_count": 2, + "like_count": 1, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 94 + }, + "score": 2.1, + "url": "https://x.com/i/web/status/2064072188756648270" + } + ] + }, + { + "topic": "Chatbots / Atendimento / WhatsApp", + "count": 11, + "total_score": 23.408999999999995, + "top_tweets": [ + { + "id": "2063278361678360640", + "text": "Here are some of the biggest AI trends in 2026:\n\n1. Agentic AI\n\nAI systems are moving beyond chatbots into “agents” that can:\n\nPlan multi-step tasks\nUse tools and software\nConduct research\nAutomate workflows\nCollaborate with other AI agents\n\nExamples include coding agents,", + "author": "JCKCROWLEY", + "metrics": { + "retweet_count": 0, + "reply_count": 5, + "like_count": 4, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 259 + }, + "score": 6.8, + "url": "https://x.com/i/web/status/2063278361678360640" + }, + { + "id": "2065212098859200622", + "text": "Durante anos a internet brincou com a história de que o WhatsApp ficaria pago.\n\nAgora a realidade é ainda mais interessante.\n\nA Meta quer transformar o WhatsApp em uma plataforma de agentes de inteligência artificial.\n\nIsso muda completamente o jogo para empresas, profissionais e", + "author": "luisroquette", + "metrics": { + "retweet_count": 1, + "reply_count": 0, + "like_count": 2, + "quote_count": 0, + "bookmark_count": 1, + "impression_count": 60 + }, + "score": 5.6, + "url": "https://x.com/i/web/status/2065212098859200622" + }, + { + "id": "2065691723515461891", + "text": "Everyone is debating whether Anthropic,\nOpenAI, or Google DeepMind, will reach AGI first...\nI bet the McDonald's chatbot will win the race https://t.co/ztnsLborex", + "author": "Lsc8954", + "metrics": { + "retweet_count": 0, + "reply_count": 1, + "like_count": 4, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 41 + }, + "score": 4.5, + "url": "https://x.com/i/web/status/2065691723515461891" + }, + { + "id": "2065329395280293942", + "text": "$NOK Everyone is focused on AI chips, but AI is about to transform another massive industry: telecommunications.\n\nNokia’s new agentic AI framework isn’t just another chatbot for network operators. It’s designed to understand live network conditions, reason over trusted data, and ", + "author": "gulVasikova", + "metrics": { + "retweet_count": 0, + "reply_count": 0, + "like_count": 4, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 343 + }, + "score": 4.3, + "url": "https://x.com/i/web/status/2065329395280293942" + }, + { + "id": "2063323033465688477", + "text": "Agentic AI is the new SaaS.\n\nInstead of tools you use—\nyou’ll have agents that work for you.\n\nGoldman Sachs & Deutsche Bank are already testing agentic AI for deal workflows.\n\nIn GTM terms:\n• AI agents that qualify leads while you sleep\n• Agents that draft proposals from a", + "author": "0xsairahul", + "metrics": { + "retweet_count": 0, + "reply_count": 0, + "like_count": 1, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 22 + }, + "score": 1.0, + "url": "https://x.com/i/web/status/2063323033465688477" + } + ] + }, + { + "topic": "Regulação / ética / segurança", + "count": 9, + "total_score": 14.703000000000001, + "top_tweets": [ + { + "id": "2064610560700743823", + "text": "@goncalo_nspinto @lmldias @natroposfera @jl_zamith Desculpa lá, desde quando é que o claude é argumento para o que quer que seja?\nNão há nada mais patético que isso.\nChatGPT, Claude, Gemini, etc não são nem de perto nem de logo argumentos. https://t.co/sU8kFFkBY4", + "author": "cubexpt", + "metrics": { + "retweet_count": 0, + "reply_count": 3, + "like_count": 0, + "quote_count": 1, + "bookmark_count": 0, + "impression_count": 1463 + }, + "score": 4.5, + "url": "https://x.com/i/web/status/2064610560700743823" + }, + { + "id": "2064800954374127811", + "text": "On June 5, OpenAI, Anthropic, Google DeepMind & Microsoft asked Congress to mandate synthetic-DNA screening. The bioweapon risk is real. But watch the trajectory: today a voluntary norm, tomorrow policy, eventually regulation. The industry is writing the law it'll live under.", + "author": "fortylaunch", + "metrics": { + "retweet_count": 1, + "reply_count": 0, + "like_count": 1, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 23 + }, + "score": 3.0, + "url": "https://x.com/i/web/status/2064800954374127811" + }, + { + "id": "2065675890839728277", + "text": "samsung baniu chatgpt em 2023 depois de vazar código interno.\n\nagora em 2026? liberou chatgpt, gemini E claude pra empresa inteira.\n\no que mudou?\n\nnada — a pressão da concorrência só ficou alta demais.\n\nsegurança sempre foi desculpa, produtividade sempre foi o jogo.\né tudo https:", + "author": "netaotech", + "metrics": { + "retweet_count": 0, + "reply_count": 0, + "like_count": 1, + "quote_count": 0, + "bookmark_count": 1, + "impression_count": 28 + }, + "score": 2.5, + "url": "https://x.com/i/web/status/2065675890839728277" + }, + { + "id": "2064551884430487855", + "text": "@BlancheMinerva @mtavitschlegel I think open-source AI is inherently unsafe and neither Anthropic nor OpenAI should enable RSI loops for open-source AI prematurely \n\nI def don't want open-source AGI or ASI before there is closed-source one that is properly vetted for safety and a", + "author": "BlackHC", + "metrics": { + "retweet_count": 0, + "reply_count": 2, + "like_count": 1, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 93 + }, + "score": 2.1, + "url": "https://x.com/i/web/status/2064551884430487855" + }, + { + "id": "2064305713355034853", + "text": "@wahab_twts Anthropic and OpenAI lead the frontier model race. Anthropic has the safety moat and Claude Code adoption is real. OpenAI has brand and distribution but IPO pressure is a long term risk.\n\nGoogle and Microsoft win on distribution and infrastructure. Neither leads on mo", + "author": "MixRoute_ai", + "metrics": { + "retweet_count": 0, + "reply_count": 1, + "like_count": 1, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 66 + }, + "score": 1.6, + "url": "https://x.com/i/web/status/2064305713355034853" + } + ] + } + ], + "top_tweets_overall": [ + { + "text": "Google founders are Jewish, has a frontier model\nAnthropic founder is Jewish, has a frontier model\nOpenAI founder is Jewish, has a frontier model\n\nxai founder isn’t Jewish, doesn’t have a frontier model\nMicrosoft founder isn’t Jewish, doesn’t have a frontier model\nDeepseek https:", + "author": "GolerGkA", + "metrics": { + "retweet_count": 51, + "reply_count": 65, + "like_count": 1618, + "quote_count": 19, + "bookmark_count": 443, + "impression_count": 272706 + }, + "score": 2718.2, + "topics": [ + "Modelos novos (GPT/Claude/Gemini/Llama)", + "OpenAI / Anthropic / Big Labs" + ], + "url": "https://x.com/i/web/status/2064830151305699469" + }, + { + "text": "This is why you should support open source AI \n\nAuthoritarian governments can’t exert control on our lives and play god \n\nWithout open source AI, US government will restrict AGI access to a small set of elitists 😭", + "author": "bindureddy", + "metrics": { + "retweet_count": 54, + "reply_count": 81, + "like_count": 448, + "quote_count": 8, + "bookmark_count": 21, + "impression_count": 60284 + }, + "score": 700.3, + "topics": [ + "Open source / modelos abertos" + ], + "url": "https://x.com/i/web/status/2065627605144055865" + }, + { + "text": "MARCUS CHEN EMPILHOU 30 MAC MINIS E CRIOU UMA FAZENDA DE SERVIDORES DE IA. UM ÚNICO MAC MINI DE US$ 599 PODE SUBSTITUIR SUA CONTA DE US$ 200/MÊS DO CLAUDE CODE GASTANDO APENAS US$ 3 DE ENERGIA.\n\nHá dois meses, um desenvolvedor publicou no Reddit a sua fatura do Claude Code: US$ h", + "author": "DehumanoaDeus", + "metrics": { + "retweet_count": 20, + "reply_count": 14, + "like_count": 190, + "quote_count": 1, + "bookmark_count": 100, + "impression_count": 39423 + }, + "score": 427.9, + "topics": [ + "IA para código/dev", + "Modelos novos (GPT/Claude/Gemini/Llama)" + ], + "url": "https://x.com/i/web/status/2063506042114474143" + }, + { + "text": "chatgpt vs gemini mas o chatgpt tem um bundão, saia e uma venda nos olhos https://t.co/LgBDoYWL00", + "author": "scalabriniii", + "metrics": { + "retweet_count": 8, + "reply_count": 10, + "like_count": 322, + "quote_count": 3, + "bookmark_count": 33, + "impression_count": 22657 + }, + "score": 419.7, + "topics": [ + "Modelos novos (GPT/Claude/Gemini/Llama)" + ], + "url": "https://x.com/i/web/status/2065418102301442418" + }, + { + "text": "AI is reshaping how work gets done globally.\n\nIt’s changing workflows, tasks, and decision-making systems.\n\nSharing these Top 7 AI models compiled by @AndrewBolis.\n\n1. ChatGPT (OpenAI)\n\nVersatile for writing, research, guidance, and support.\n\n2. Claude (Anthropic)\n\nFocuses on htt", + "author": "Igor_Buinevici", + "metrics": { + "retweet_count": 37, + "reply_count": 6, + "like_count": 98, + "quote_count": 0, + "bookmark_count": 72, + "impression_count": 7256 + }, + "score": 290.3, + "topics": [ + "Modelos novos (GPT/Claude/Gemini/Llama)", + "OpenAI / Anthropic / Big Labs", + "Automação / Workflows com IA" + ], + "url": "https://x.com/i/web/status/2064346800543482056" + }, + { + "text": "The productivity gap is about to become terrifying.\n\nAI users are finishing days of work in hours.\n\nHere's the complete AI automation guide.\n\n📂 AI Automation\n┃\n┣ 📂 Foundations\n┃ ┣ 📂 What Is AI Automation\n┃ ┣ 📂 Automation vs AI\n┃ ┣ 📂 Decision Making\n┃ ┣ 📂 Learning https://t.co/N2E", + "author": "shushant_l", + "metrics": { + "retweet_count": 18, + "reply_count": 14, + "like_count": 67, + "quote_count": 1, + "bookmark_count": 48, + "impression_count": 2067 + }, + "score": 185.6, + "topics": [ + "Automação / Workflows com IA", + "IA no trabalho / produtividade" + ], + "url": "https://x.com/i/web/status/2064724369814044869" + }, + { + "text": "Some of the best companies to work for according to candidates.\n\nS+: Anthropic, OpenAI, Google DeepMind, Rentech, TGS, xAI, Citadel Securities, Jane Street, HRT\n\nS: Citadel, D.E. Shaw, Jump, Optiver, Two Sigma, Tesla (Autopilot), Five Rings, SpaceX\n\nS-: IMC, SIG, DRW, Akuna\n\nA++:", + "author": "vivoplt", + "metrics": { + "retweet_count": 8, + "reply_count": 33, + "like_count": 72, + "quote_count": 0, + "bookmark_count": 47, + "impression_count": 7640 + }, + "score": 182.6, + "topics": [ + "OpenAI / Anthropic / Big Labs" + ], + "url": "https://x.com/i/web/status/2063273860754231748" + }, + { + "text": "open-source ai is the only way forward.", + "author": "svpino", + "metrics": { + "retweet_count": 13, + "reply_count": 20, + "like_count": 92, + "quote_count": 2, + "bookmark_count": 2, + "impression_count": 4823 + }, + "score": 138.8, + "topics": [ + "Open source / modelos abertos" + ], + "url": "https://x.com/i/web/status/2065733417661030904" + }, + { + "text": "E se a IA mais preparada para apoiar o médico não for a que se vende como “IA médica”?\n\nUm estudo publicado na Nature Medicine comparou ferramentas clínicas comerciais e dedicadas, OpenEvidence e UpToDate Expert AI, com LLMs gerais (GPT-5.2, Gemini 3.1 Pro e Claude Opus 4.6).\n\nNo", + "author": "glauber_doc", + "metrics": { + "retweet_count": 5, + "reply_count": 16, + "like_count": 74, + "quote_count": 0, + "bookmark_count": 25, + "impression_count": 4178 + }, + "score": 133.7, + "topics": [ + "Modelos novos (GPT/Claude/Gemini/Llama)" + ], + "url": "https://x.com/i/web/status/2065422550004371529" + }, + { + "text": "Open source AI must win!", + "author": "omarsar0", + "metrics": { + "retweet_count": 9, + "reply_count": 11, + "like_count": 81, + "quote_count": 0, + "bookmark_count": 3, + "impression_count": 5587 + }, + "score": 114.6, + "topics": [ + "Open source / modelos abertos" + ], + "url": "https://x.com/i/web/status/2065632446255665232" + }, + { + "text": "Clip with David Morgan: Is open source AI the printing press of the 21st century? We need to use open source AI to further pro-liberty, pro-human goals! https://t.co/uSl0BsOXI9", + "author": "HealthRanger", + "metrics": { + "retweet_count": 17, + "reply_count": 9, + "like_count": 59, + "quote_count": 0, + "bookmark_count": 6, + "impression_count": 5140 + }, + "score": 111.6, + "topics": [ + "Open source / modelos abertos" + ], + "url": "https://x.com/i/web/status/2064814880826253368" + }, + { + "text": "Most people in AI can't actually explain the difference between \"Generative AI,\" \"Agentic AI,\" and \"AI Agents.\"\n\nThey use all three like they mean the same thing. They don't. And once it clicks, you can't unsee it.\n\nHere's the cleanest way to think about it:\n\nGenerative AI is the", + "author": "VaibhavSisinty", + "metrics": { + "retweet_count": 5, + "reply_count": 7, + "like_count": 38, + "quote_count": 1, + "bookmark_count": 33, + "impression_count": 2984 + }, + "score": 105.5, + "topics": [ + "Agentes de IA / Agentic" + ], + "url": "https://x.com/i/web/status/2064392166131265627" + }, + { + "text": "If you get offers from these 3 companies : \n\n- OpenAI \n- Anthropic \n- Google Deepmind \n\nWhich one would you join, and why?", + "author": "Its_Nova1012", + "metrics": { + "retweet_count": 1, + "reply_count": 65, + "like_count": 61, + "quote_count": 0, + "bookmark_count": 2, + "impression_count": 3560 + }, + "score": 102.1, + "topics": [ + "OpenAI / Anthropic / Big Labs" + ], + "url": "https://x.com/i/web/status/2065284539564675171" + }, + { + "text": "OpenAI, Anthropic, Google DeepMind, and Microsoft just agreed on something.\n\nThey're all warning about the same risk: future AI lowering the knowledge barrier around dangerous biological material.\n\nRead the shocking details🧵: https://t.co/EQPjK1gzjG", + "author": "themetav3rse", + "metrics": { + "retweet_count": 20, + "reply_count": 6, + "like_count": 43, + "quote_count": 1, + "bookmark_count": 3, + "impression_count": 7481 + }, + "score": 99.5, + "topics": [ + "OpenAI / Anthropic / Big Labs" + ], + "url": "https://x.com/i/web/status/2063637138118516779" + }, + { + "text": "Stanford just released a 1.2-hour lecture on \"Turning Electricity into Intelligence.\"\n\nThis is the exact knowledge engineers at NVIDIA, Anthropic, xAI, and Google DeepMind require to understand at a deep level.\n\nGive it some time.\n\nThis might be the highest-ROI learning you do ht", + "author": "ZabihullahAtal", + "metrics": { + "retweet_count": 12, + "reply_count": 1, + "like_count": 20, + "quote_count": 0, + "bookmark_count": 34, + "impression_count": 2886 + }, + "score": 98.4, + "topics": [ + "OpenAI / Anthropic / Big Labs" + ], + "url": "https://x.com/i/web/status/2063972795122638889" + }, + { + "text": "2026!!\n\nLearn Ai!!\nLearn Ai skills!!\nLearn Ai Image Gen!!\nLearn Ai Automation!!\nLearn Ai Video editing!!\nLearn AI Content creation!!\nLearn AI Prompt Engineering!!\nLearn Ai Coding(Vibe coding)!!", + "author": "AutomationKing0", + "metrics": { + "retweet_count": 7, + "reply_count": 5, + "like_count": 53, + "quote_count": 1, + "bookmark_count": 17, + "impression_count": 1292 + }, + "score": 97.8, + "topics": [ + "IA para código/dev", + "IA generativa de imagem/vídeo", + "Automação / Workflows com IA" + ], + "url": "https://x.com/i/web/status/2064611414241648817" + }, + { + "text": "Open source AI must win https://t.co/MnCdiKGGan https://t.co/lRRUXFHfxk", + "author": "dankvr", + "metrics": { + "retweet_count": 8, + "reply_count": 6, + "like_count": 65, + "quote_count": 0, + "bookmark_count": 2, + "impression_count": 4367 + }, + "score": 91.4, + "topics": [ + "Open source / modelos abertos" + ], + "url": "https://x.com/i/web/status/2065608055572640192" + }, + { + "text": "DARIO scared of OPEN SOURCE AI\n\nOPEN SOURCE AI must WIN https://t.co/iybsExsVqM", + "author": "chiefofautism", + "metrics": { + "retweet_count": 6, + "reply_count": 3, + "like_count": 69, + "quote_count": 1, + "bookmark_count": 2, + "impression_count": 1519 + }, + "score": 88.5, + "topics": [ + "Open source / modelos abertos" + ], + "url": "https://x.com/i/web/status/2064843500512784844" + }, + { + "text": "Ótimo uso de inteligência artificial por David Brazil. https://t.co/eWm99QOyrm", + "author": "chicobarney", + "metrics": { + "retweet_count": 3, + "reply_count": 9, + "like_count": 70, + "quote_count": 2, + "bookmark_count": 1, + "impression_count": 2834 + }, + "score": 87.8, + "topics": [ + "Outros / IA geral" + ], + "url": "https://x.com/i/web/status/2065787621427589404" + }, + { + "text": "Compared to Dwarkesh, i'm more sceptical that sample efficiency will block the intelligence explosion.\n\nYes, open source AI catches up to the frontier by using data from frontier AI models. \n\nBut that doesn't imply that sample efficiency hasn't been improving within frontier http", + "author": "TomDavidsonX", + "metrics": { + "retweet_count": 1, + "reply_count": 2, + "like_count": 41, + "quote_count": 0, + "bookmark_count": 24, + "impression_count": 3701 + }, + "score": 83.7, + "topics": [ + "Open source / modelos abertos" + ], + "url": "https://x.com/i/web/status/2064294448402469166" + }, + { + "text": "Inside the AI Stack\n\nInfrastructure — Nvidia, AWS, Google Cloud \nData — Databricks, Snowflake, Palantir \nModels — OpenAI, Anthropic, Google DeepMind \nApplication — Microsoft, Salesforce, Adobe \nOperation — https://t.co/NDtzgDE7UT, IBM, Weights & Biases https://t.co/pc1jJ2IKTb", + "author": "Market_Mind_", + "metrics": { + "retweet_count": 9, + "reply_count": 1, + "like_count": 36, + "quote_count": 1, + "bookmark_count": 14, + "impression_count": 1309 + }, + "score": 78.3, + "topics": [ + "OpenAI / Anthropic / Big Labs" + ], + "url": "https://x.com/i/web/status/2064734332409803227" + }, + { + "text": "The best founders of the best AI \n\n> Elon Musk : ( xAI )\n> Dario Amodei : ( Anthropic )\n> Demis Hassabis : ( Google DeepMind )\n> Sam Altman : ( OpenAI )\n\nwhich AI do you work with the most? https://t.co/sIMfZgR2ct", + "author": "alex_atoms", + "metrics": { + "retweet_count": 0, + "reply_count": 30, + "like_count": 57, + "quote_count": 0, + "bookmark_count": 1, + "impression_count": 866 + }, + "score": 74.4, + "topics": [ + "OpenAI / Anthropic / Big Labs" + ], + "url": "https://x.com/i/web/status/2063619458028216763" + }, + { + "text": "@memes_horriveis Akinator não é IA generativa meu anjo", + "author": "ZhyrelDragon", + "metrics": { + "retweet_count": 0, + "reply_count": 1, + "like_count": 68, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 1557 + }, + "score": 70.1, + "topics": [ + "Outros / IA geral" + ], + "url": "https://x.com/i/web/status/2064331850382753938" + }, + { + "text": "🤖 Awesome Open Source AI 🛡️\n\nA curated collection of the best open-source AI models, frameworks, agents, RAG tools, inference engines, LLMOps platforms, and developer resources.\n\nPerfect for AI engineers, builders, researchers, and open-source enthusiasts. 🚀\n\n🔗 https://t.co/gtFiT", + "author": "VivekIntel", + "metrics": { + "retweet_count": 6, + "reply_count": 2, + "like_count": 29, + "quote_count": 1, + "bookmark_count": 14, + "impression_count": 1145 + }, + "score": 65.6, + "topics": [ + "Agentes de IA / Agentic", + "IA para código/dev", + "Open source / modelos abertos" + ], + "url": "https://x.com/i/web/status/2063475839040438713" + }, + { + "text": "INTELIGÊNCIA ARTIFICIAL | A ministra Cármen Lúcia, do Supremo Tribunal Federal (STF), destacou os riscos do uso indevido da inteligência artificial, nessa terça-feira (9), durante o 6º Congresso Brasileiro de Internet. https://t.co/DRLhtvThrv", + "author": "ebcnarede", + "metrics": { + "retweet_count": 12, + "reply_count": 1, + "like_count": 38, + "quote_count": 1, + "bookmark_count": 0, + "impression_count": 414 + }, + "score": 64.4, + "topics": [ + "Outros / IA geral" + ], + "url": "https://x.com/i/web/status/2065122858162868332" + }, + { + "text": "Agentic AI is creating a new class of trust problems.\n\nUseful agents need (1) access to private information, (2) access to untrusted external input, and (3) the ability to act.\n\nHowever, once we combine these three properties, there is always the possibility that an agent can be ", + "author": "_weidai", + "metrics": { + "retweet_count": 2, + "reply_count": 7, + "like_count": 22, + "quote_count": 0, + "bookmark_count": 14, + "impression_count": 4188 + }, + "score": 54.7, + "topics": [ + "Agentes de IA / Agentic" + ], + "url": "https://x.com/i/web/status/2065160042459152636" + }, + { + "text": "the smartest people i know are now maxbidding open source ai.\n\nthe dumbest people i know are now maxbidding open source ai.\n\nthe normies are still using chatgpt. much to think about.", + "author": "ConejoCapital", + "metrics": { + "retweet_count": 0, + "reply_count": 7, + "like_count": 37, + "quote_count": 1, + "bookmark_count": 3, + "impression_count": 2143 + }, + "score": 48.6, + "topics": [ + "Open source / modelos abertos" + ], + "url": "https://x.com/i/web/status/2065629651125280944" + }, + { + "text": "It’s time to make open source ai great again", + "author": "0xsachi", + "metrics": { + "retweet_count": 2, + "reply_count": 19, + "like_count": 32, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 873 + }, + "score": 46.4, + "topics": [ + "Open source / modelos abertos" + ], + "url": "https://x.com/i/web/status/2065731180373139889" + }, + { + "text": "OpenAI, Google DeepMind, Meta, Microsoft, NVIDIA, Mistral, Hugging Face\n\nthe labs everyone says are closing ai behind a paywall quietly shipped their core tools on github. here are 7 repos, one from each\n\n1. OpenAI, openai/openai-agents-python\nhttps://t.co/6UfYVDOcTy\n27k stars. h", + "author": "vorty279", + "metrics": { + "retweet_count": 2, + "reply_count": 2, + "like_count": 22, + "quote_count": 0, + "bookmark_count": 10, + "impression_count": 878 + }, + "score": 42.9, + "topics": [ + "Agentes de IA / Agentic", + "Modelos novos (GPT/Claude/Gemini/Llama)", + "OpenAI / Anthropic / Big Labs" + ], + "url": "https://x.com/i/web/status/2065521758422364435" + }, + { + "text": "⚡AI coding agents are no longer just assistants—they’re becoming autonomous teammates.\n\nFrom code completion to multi-step execution across the SDLC, the shift is redefining software engineering.\n\nExplore what’s driving this evolution in Gartner’s 2026 Magic Quadrant for https://", + "author": "Gartner_inc", + "metrics": { + "retweet_count": 6, + "reply_count": 1, + "like_count": 20, + "quote_count": 1, + "bookmark_count": 4, + "impression_count": 2089 + }, + "score": 42.1, + "topics": [ + "Agentes de IA / Agentic", + "IA para código/dev" + ], + "url": "https://x.com/i/web/status/2064069746530668679" + }, + { + "text": "Every single AI startup powered by @mintlify with $10B+ valuation and $100M+ revenue run rate: \n\nCrusoe - $10B \nMercor - $10B ✅ \nElevenLabs - $11B \nBaseten - $11B* ✅\nHarvey - $11B ✅\nLovable - $12B* ✅\nOpenEvidence - $12B \nMistral - $14B \nNscale - $14.6B \nFireworks - $15B* ✅ https", + "author": "pronounsuponly", + "metrics": { + "retweet_count": 1, + "reply_count": 11, + "like_count": 23, + "quote_count": 0, + "bookmark_count": 3, + "impression_count": 5703 + }, + "score": 40.7, + "topics": [ + "Modelos novos (GPT/Claude/Gemini/Llama)", + "Negócios / startups / investimento" + ], + "url": "https://x.com/i/web/status/2063674932471730626" + }, + { + "text": "Which AI lab do you trust the most, and why?\n\nOpenAI\nAnthropic\nGoogle DeepMind\nxAI\nMeta AI\n\nThe answer says a lot about what you think the future of AI should look like.", + "author": "Tech_girlll", + "metrics": { + "retweet_count": 2, + "reply_count": 20, + "like_count": 21, + "quote_count": 0, + "bookmark_count": 1, + "impression_count": 1142 + }, + "score": 37.6, + "topics": [ + "OpenAI / Anthropic / Big Labs" + ], + "url": "https://x.com/i/web/status/2065106633915617570" + }, + { + "text": "@Scobleizer @BrianRoemmele Open source AI is more important than ever.", + "author": "RoyalCities", + "metrics": { + "retweet_count": 1, + "reply_count": 2, + "like_count": 33, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 1354 + }, + "score": 37.4, + "topics": [ + "Open source / modelos abertos" + ], + "url": "https://x.com/i/web/status/2065610051734110352" + }, + { + "text": "Anthropic spent years fighting open-source AI, warning the public about its dangers, and locking its own work behind a greedy, unsustainable model.\n\nAnd today, with one move, it gave open-source AI the greatest gift imaginable.\n\nA huge wave of people will now start looking", + "author": "Da7_Tech", + "metrics": { + "retweet_count": 1, + "reply_count": 2, + "like_count": 24, + "quote_count": 1, + "bookmark_count": 2, + "impression_count": 5509 + }, + "score": 37.0, + "topics": [ + "OpenAI / Anthropic / Big Labs", + "Open source / modelos abertos" + ], + "url": "https://x.com/i/web/status/2065610716623651162" + }, + { + "text": "open source AI labs satisfaction level rn. https://t.co/lHGcCSfTqh https://t.co/ZFrMHLpgDL", + "author": "Meer_AIIT", + "metrics": { + "retweet_count": 9, + "reply_count": 1, + "like_count": 16, + "quote_count": 0, + "bookmark_count": 0, + "impression_count": 1080 + }, + "score": 35.6, + "topics": [ + "Open source / modelos abertos" + ], + "url": "https://x.com/i/web/status/2065720471187034217" + }, + { + "text": "@IsadoraGameOver O problema eh esse, eles não tão usando só IA como ferramenta kkkk\n\nAli claramente diz IA GENERATIVA, o que é algo COMPLETAMENTE diferente de uma IA comum\n\nMuito provavelmente vão usar IA generativa pra concept arts, designs e por aí vai", + "author": "FlipeArt2", + "metrics": { + "retweet_count": 0, + "reply_count": 6, + "like_count": 30, + "quote_count": 0, + "bookmark_count": 1, + "impression_count": 1033 + }, + "score": 35.5, + "topics": [ + "Outros / IA geral" + ], + "url": "https://x.com/i/web/status/2063698566791450971" + }, + { + "text": "AI companies are hiring software engineers like crazy in 2026 👀\n(Approx total team size / hiring pace)\n\n• OpenAI → 4,000+\n• Anthropic → 2,000+\n• xAI → 1,500+\n• Databricks → 8,000+\n• Scale AI → 1,000+\n• Perplexity → 500+\n• Cohere → 500+\n• ElevenLabs → 300+\n•", + "author": "NitinthisSide_", + "metrics": { + "retweet_count": 0, + "reply_count": 11, + "like_count": 21, + "quote_count": 1, + "bookmark_count": 3, + "impression_count": 1233 + }, + "score": 33.7, + "topics": [ + "OpenAI / Anthropic / Big Labs" + ], + "url": "https://x.com/i/web/status/2064965979969671541" + }, + { + "text": "@rafaelgloves Pensa assim.\n\nProduto A é o que resolve o seu problema. \n\nProduto B no máximo quebra o galho mas é MUITO mais barato que o A.\n\nAcontece que o B está ficando cada vez melhor, enquanto continua sendo MUITO mais barato que o A.\n\nUm belo dia, você pensa: quer saber? O p", + "author": "rodrigofm", + "metrics": { + "retweet_count": 0, + "reply_count": 2, + "like_count": 26, + "quote_count": 0, + "bookmark_count": 2, + "impression_count": 2921 + }, + "score": 32.9, + "topics": [ + "Outros / IA geral" + ], + "url": "https://x.com/i/web/status/2064358367498273167" + }, + { + "text": "Deepseek is still releasing its foundational models and also reportedly closing in on a massive $7B funding round as of early June 2026.\n\nSora got shutdown \n\nGitHub Copilot has huge market in enterprise coding agents\n\nMeta released Llama 4 (April 2025) Scout and Maverick its http", + "author": "lochan_twt", + "metrics": { + "retweet_count": 1, + "reply_count": 2, + "like_count": 21, + "quote_count": 0, + "bookmark_count": 3, + "impression_count": 3474 + }, + "score": 32.0, + "topics": [ + "Agentes de IA / Agentic", + "IA para código/dev", + "Modelos novos (GPT/Claude/Gemini/Llama)", + "IA generativa de imagem/vídeo", + "Negócios / startups / investimento" + ], + "url": "https://x.com/i/web/status/2063330354694705534" + }, + { + "text": "The global AI industry is gathering in Singapore this Wednesday. \n\nOpenAI, Google DeepMind, Mistral, AWS, Alibaba, Snowflake, Stripe and 1,500+ companies at Asia's largest AI event plus 100+ side events taking over the city all week.\n\nWe're reaching capacity and ticket portal htt", + "author": "superai_conf", + "metrics": { + "retweet_count": 1, + "reply_count": 6, + "like_count": 18, + "quote_count": 1, + "bookmark_count": 0, + "impression_count": 2806 + }, + "score": 27.3, + "topics": [ + "Modelos novos (GPT/Claude/Gemini/Llama)", + "OpenAI / Anthropic / Big Labs" + ], + "url": "https://x.com/i/web/status/2063826871922651369" + } + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9cc7035b..979efca5 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,24 @@ plugins/*/ # ── Brain Repo raw transcripts (not backed up to brain repo) ── memory/raw-transcripts/ + +# ── Private root-level workspace data (business content, runtime) ── +# These top-level folders hold the user's private operational output and must +# never be committed (especially not in a public PR). Mirrors workspace/** rule. +/community/ +/courses/ +/daily-logs/ +/finance/ +/logs/ +/meetings/ +/projects/ +/social/ +/strategy/ + +# ── Local backups & reload triggers ── +*.bak.telegram +.env.bak* +config/.reload + +# ── One-off integration test scaffolding (may contain hardcoded creds) ── +scripts/ghost_integration_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c1a796f8..d0e8cce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.33.0] - 2026-04-25 + +Plugin contract release. Five PRs merged in one day to unblock the EvoNexus Plugin Nutri (and any future plugin needing per-endpoint role enforcement, public token-bound portals, or safe uninstall). Plus a UX fix so `409 CONFLICT` from plugin install actually says *why* it conflicted. + +### Added + +- **`requires_role` on `PluginWritableResource`** (PR #55) — plugins can declare a list of roles allowed on each writable endpoint. The host returns `403` when `current_user.role` is not in the list. `'admin'` always passes (super-user override). Backwards compatible: resources without the field accept any authenticated user. +- **Auto-injected readonly bind params** (PR #55) — every `readonly_data` query receives `:current_user_id` and `:current_user_role` server-side. Plugins reference them directly in SQL for scoping (`WHERE primary_nutritionist_id = :current_user_id`). Both names are reserved — clients that try to spoof them via `?current_user_id=...` get `400`. +- **`public_pages` capability** (PR #53) — token-bound public portals at `/p/{slug}/{route_prefix}/{token}`. Token validated against a plugin-declared `token_source.column`. CSP, rate limit, and security headers applied. Read-only `readonly_data` queries can be exposed to the portal via `public_via` + `bind_token_param`. +- **HTML shell content negotiation** (PR #56) — when a request includes `text/html` in `Accept`, the host renders a minimal HTML shell that loads the plugin bundle as a module and instantiates the declared custom element with `data-token`. Programmatic clients (`Accept: application/javascript`, default `*/*`) keep getting the raw bundle. Plugins ship a single JS bundle and get a working browser experience for free. +- **`safe_uninstall` capability** (PR #54) — three-step uninstall wizard with `preserved_tables` (renamed to `_orphan_{slug}_*` instead of dropped), pre-uninstall hook (sandboxed: read-only DB, no `BRAIN_REPO_MASTER_KEY`), and required user confirmation (checkbox + typed phrase + ZIP password). Reinstall verifies SHA256 and restores access to preserved data. +- **Rate limit + security headers** (PR #52) — `flask-limiter` with in-memory storage on the public share endpoint and any future `/p/...` route. Five security headers applied to public responses (`Referrer-Policy`, `Cache-Control: no-store`, HSTS, `X-Content-Type-Options`, `Pragma`). + +### Fixed + +- **Plugin install wizard now shows the actual reason for `409 CONFLICT`.** The frontend was treating any 4xx as an opaque error string. Now `buildError` in `lib/api.ts` falls back to `data.conflicts[0]` when the standard `error`/`message` fields are absent (which is the case for the plugin preview endpoint), so a version mismatch shows up as `"409 CONFLICT: Plugin 'nutri' requires EvoNexus >= 0.33.0, but installed version is 0.32.3."` instead of just `"409 CONFLICT"`. `PluginInstallModal` also fixes the type of `conflicts` (was `Record`, the backend always returned `string[]`) and renders each conflict as a list item. + +### Compat + +- All existing plugins (PM Essentials, etc.) work unchanged. New manifest fields default to absent / `None` and the auto-injected bind params are silently ignored if the SQL doesn't reference them. The `409` body shape for plugin install was already `{conflicts: [...], manifest, ...}` — only the frontend's interpretation changed. + ## [0.32.3] - 2026-04-25 Patch release fixing a long-standing Workspace UI bug where folders refused to open and the dev console flooded with `400 Path is a directory` requests, plus a small UX win on the file share dialog (reuse existing share links instead of generating a new token every time). Also includes the upstream PR #51 (private-repo plugin update flow + ClickUp webhook compat + DetachedInstanceError). diff --git a/Makefile b/Makefile index bbcec2ed..bbb3cf08 100644 --- a/Makefile +++ b/Makefile @@ -151,6 +151,8 @@ bling-auth: ## 🔐 Bling OAuth2 login (one-time: capture access + refre @python3 .claude/skills/int-bling/scripts/bling_auth.py telegram: ## 📨 Start Telegram bot in background (screen) + @command -v screen >/dev/null 2>&1 || { echo "❌ 'screen' is not installed — run: sudo apt install screen"; exit 1; } + @command -v bun >/dev/null 2>&1 || [ -x "$$HOME/.bun/bin/bun" ] || { echo "❌ 'bun' is not installed (required by the telegram plugin MCP) — run: curl -fsSL https://bun.sh/install | bash"; exit 1; } @if screen -list | grep -q '\.telegram'; then \ echo "⚠ Telegram bot is already running. Use 'make telegram-stop' first or 'make telegram-attach' to connect."; \ else \ diff --git a/config/providers.example.json b/config/providers.example.json index 17a4d61f..35d5cb19 100644 --- a/config/providers.example.json +++ b/config/providers.example.json @@ -20,6 +20,22 @@ }, "default_base_url": "https://openrouter.ai/api/v1", "default_model": "anthropic/claude-sonnet-4", + "requires_logout": true, + "mode": "code" + }, + "nvidia": { + "name": "NVIDIA NIM", + "description": "Modelos hospedados na NVIDIA (DeepSeek, Llama, Qwen, etc.) via API OpenAI-compatível", + "cli_command": "openclaude", + "mode": "code", + "env_vars": { + "CLAUDE_CODE_USE_OPENAI": "1", + "OPENAI_BASE_URL": "https://integrate.api.nvidia.com/v1", + "OPENAI_API_KEY": "", + "OPENAI_MODEL": "" + }, + "default_base_url": "https://integrate.api.nvidia.com/v1", + "default_model": "deepseek-ai/deepseek-v3.1", "requires_logout": true }, "omnirouter": { @@ -34,7 +50,8 @@ }, "default_base_url": "", "default_model": "", - "requires_logout": true + "requires_logout": true, + "mode": "code" }, "openai": { "name": "OpenAI (API Key)", diff --git a/dashboard/backend/app.py b/dashboard/backend/app.py index 2dccb597..270a5750 100644 --- a/dashboard/backend/app.py +++ b/dashboard/backend/app.py @@ -93,6 +93,12 @@ def _cors_allowed_origins(): CORS(app, origins=_cors_allowed_origins(), supports_credentials=True) +# --------------- Rate limiting (in-memory, single-process Flask) --------------- +# Vault audit §2.S1 CRITICAL: all public endpoints require rate limiting. +# The limiter singleton lives in rate_limit.py to avoid circular imports with blueprints. +from rate_limit import limiter +limiter.init_app(app) + # --------------- Database --------------- from models import db, User, BrainRepoConfig, needs_setup, seed_roles, seed_systems db.init_app(app) @@ -132,6 +138,7 @@ def _cors_allowed_origins(): goal_id TEXT, required_secrets TEXT DEFAULT '[]', decision_prompt TEXT NOT NULL, + handler TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -516,6 +523,10 @@ def _cors_allowed_origins(): # source_plugin: tag heartbeats that were contributed by a plugin so the # plugin detail page can filter them via GET /api/heartbeats?source_plugin=. _hb_cols = {row[1] for row in _cur.execute("PRAGMA table_info(heartbeats)").fetchall()} + if "handler" not in _hb_cols: + _cur.execute("ALTER TABLE heartbeats ADD COLUMN handler TEXT") + _conn.commit() + _hb_cols.add("handler") if "source_plugin" not in _hb_cols: _cur.execute("ALTER TABLE heartbeats ADD COLUMN source_plugin TEXT") _conn.commit() @@ -603,6 +614,27 @@ def _cors_allowed_origins(): _conn.commit() # --- End Wave 2.2r migration --- + # --- B3 safe_uninstall migration: plugin_orphans table --- + _existing_tables_b3 = {row[0] for row in _cur.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()} + if "plugin_orphans" not in _existing_tables_b3: + _cur.executescript(""" + CREATE TABLE IF NOT EXISTS plugin_orphans ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL, + tablename TEXT NOT NULL, + orphaned_at TEXT NOT NULL, + orphaned_by_user_id INTEGER, + original_plugin_version TEXT, + original_sha256 TEXT, + original_publisher_url TEXT, + recovered_at TEXT, + UNIQUE(slug, tablename) + ); + CREATE INDEX IF NOT EXISTS idx_plugin_orphans_slug ON plugin_orphans(slug); + """) + _conn.commit() + # --- End B3 safe_uninstall migration --- + # Fix corrupted datetime columns (NULL or non-string values crash SQLAlchemy) for _tbl, _col in [("roles", "created_at"), ("users", "created_at"), ("users", "last_login")]: try: @@ -850,6 +882,7 @@ def auth_middleware(): from routes.databases import bp as databases_bp from routes.plugins import bp as plugins_bp from routes.mcp_servers import bp as mcp_servers_bp +from routes.plugin_public_pages import bp as plugin_public_pages_bp # Brain Repo + Onboarding blueprints (loaded after routes are created) try: @@ -922,6 +955,8 @@ def auth_middleware(): app.register_blueprint(databases_bp) app.register_blueprint(plugins_bp) app.register_blueprint(mcp_servers_bp) +# B2.0: plugin public pages (unauthenticated, token-bound portals) +app.register_blueprint(plugin_public_pages_bp) # --------------- Social Auth blueprints --------------- from auth.youtube import bp as youtube_auth_bp diff --git a/dashboard/backend/heartbeat_dispatcher.py b/dashboard/backend/heartbeat_dispatcher.py index a19f9160..ca3970b9 100644 --- a/dashboard/backend/heartbeat_dispatcher.py +++ b/dashboard/backend/heartbeat_dispatcher.py @@ -189,15 +189,15 @@ def _sync_heartbeats_to_db(): """INSERT INTO heartbeats (id, agent, interval_seconds, max_turns, timeout_seconds, lock_timeout_seconds, wake_triggers, enabled, goal_id, - required_secrets, decision_prompt, source_plugin, + required_secrets, decision_prompt, handler, source_plugin, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( hb.id, hb.agent, hb.interval_seconds, hb.max_turns, hb.timeout_seconds, hb.lock_timeout_seconds, json.dumps(hb.wake_triggers), int(hb.enabled), hb.goal_id, json.dumps(hb.required_secrets), hb.decision_prompt, - hb.source_plugin, + hb.handler, hb.source_plugin, now, now, ), ) @@ -207,14 +207,14 @@ def _sync_heartbeats_to_db(): """UPDATE heartbeats SET agent=?, interval_seconds=?, max_turns=?, timeout_seconds=?, lock_timeout_seconds=?, wake_triggers=?, goal_id=?, - required_secrets=?, decision_prompt=?, source_plugin=?, + required_secrets=?, decision_prompt=?, handler=?, source_plugin=?, updated_at=? WHERE id=?""", ( hb.agent, hb.interval_seconds, hb.max_turns, hb.timeout_seconds, hb.lock_timeout_seconds, json.dumps(hb.wake_triggers), hb.goal_id, json.dumps(hb.required_secrets), hb.decision_prompt, - hb.source_plugin, now, + hb.handler, hb.source_plugin, now, hb.id, ), ) diff --git a/dashboard/backend/heartbeat_runner.py b/dashboard/backend/heartbeat_runner.py index 9966643b..ade060f1 100644 --- a/dashboard/backend/heartbeat_runner.py +++ b/dashboard/backend/heartbeat_runner.py @@ -80,13 +80,13 @@ def _upsert_heartbeat_from_yaml(heartbeat_id: str) -> dict | None: """INSERT OR REPLACE INTO heartbeats (id, agent, interval_seconds, max_turns, timeout_seconds, lock_timeout_seconds, wake_triggers, enabled, goal_id, - required_secrets, decision_prompt, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + required_secrets, decision_prompt, handler, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( hb.id, hb.agent, hb.interval_seconds, hb.max_turns, hb.timeout_seconds, hb.lock_timeout_seconds, json.dumps(hb.wake_triggers), int(hb.enabled), hb.goal_id, - json.dumps(hb.required_secrets), hb.decision_prompt, + json.dumps(hb.required_secrets), hb.decision_prompt, hb.handler, now, now, ), ) @@ -240,6 +240,7 @@ def step7_invoke_claude( "--max-turns", str(max_turns), "--dangerously-skip-permissions", "--output-format", "json", + "--", prompt, # positional argument — Claude CLI does not have a -p flag ] @@ -465,13 +466,48 @@ def run_heartbeat(heartbeat_id: str, triggered_by: str = "manual", trigger_id: s task_id = None try: - # Special case: agent='system' heartbeats run a Python script directly - # instead of invoking Claude. The script path is resolved by heartbeat id. - if hb["agent"] == "system": + # Special case: legacy agent='system' heartbeats without explicit handler + # run a Python script directly, resolved by heartbeat id. + # System heartbeats with `handler` use the in-process handler path below. + if hb["agent"] == "system" and not hb.get("handler"): full_prompt = f"[system heartbeat] {heartbeat_id}" result = _run_system_heartbeat(heartbeat_id, hb["timeout_seconds"]) result["agent"] = "system" result["started_at"] = started_at + elif hb.get("handler"): + # In-process handlers do not have/need a Claude agent identity. + _handler_ref = hb.get("handler") or "" + full_prompt = f"[handler heartbeat] {heartbeat_id}" + print(f"[heartbeat_runner] step7 in-process handler={_handler_ref}", flush=True) + import importlib + import time as _time + _t0 = _time.time() + try: + _mod_name, _fn_name = _handler_ref.rsplit(".", 1) + _mod = importlib.import_module(_mod_name) + _fn = getattr(_mod, _fn_name) + _handler_result = _fn() + _duration_ms = round((_time.time() - _t0) * 1000) + result = { + "status": "success", + "error": None, + "agent": hb.get("agent", "system"), + "duration_ms": _duration_ms, + "started_at": started_at, + "handler_result": _handler_result, + } + print(f"[heartbeat_runner] step7 in-process handler done duration_ms={_duration_ms}", flush=True) + except Exception as _h_exc: + import traceback + _duration_ms = round((_time.time() - _t0) * 1000) + result = { + "status": "fail", + "error": traceback.format_exc(), + "agent": hb.get("agent", "system"), + "duration_ms": _duration_ms, + "started_at": started_at, + } + print(f"[heartbeat_runner] step7 in-process handler failed: {_h_exc}", flush=True) else: # Step 1 identity = step1_load_identity(hb["agent"]) @@ -502,57 +538,18 @@ def run_heartbeat(heartbeat_id: str, triggered_by: str = "manual", trigger_id: s full_prompt = step6_assemble_context(identity, decision_ctx, hb.get("goal_id")) print(f"[heartbeat_runner] step6 prompt assembled ({len(full_prompt)} chars)", flush=True) - # Step 7 — in-process handler OR Claude CLI subprocess - _handler_ref = hb.get("handler") or "" - if _handler_ref: - # Wave 2.2r: in-process Python handler (e.g. plugin_integration_health.tick) - # Format: "module_name.function_name" - print(f"[heartbeat_runner] step7 in-process handler={_handler_ref}", flush=True) - import importlib - import time as _time - _t0 = _time.time() - try: - _mod_name, _fn_name = _handler_ref.rsplit(".", 1) - _mod = importlib.import_module(_mod_name) - _fn = getattr(_mod, _fn_name) - _handler_result = _fn() - _duration_ms = round((_time.time() - _t0) * 1000) - invoke_result = { - "status": "success", - "error": None, - "agent": hb.get("agent", "system"), - "duration_ms": _duration_ms, - "started_at": started_at, - "handler_result": _handler_result, - } - print(f"[heartbeat_runner] step7 in-process handler done duration_ms={_duration_ms}", flush=True) - except Exception as _h_exc: - import traceback - _duration_ms = round((_time.time() - _t0) * 1000) - invoke_result = { - "status": "fail", - "error": traceback.format_exc(), - "agent": hb.get("agent", "system"), - "duration_ms": _duration_ms, - "started_at": started_at, - } - print(f"[heartbeat_runner] step7 in-process handler failed: {_h_exc}", flush=True) - invoke_result["agent"] = hb.get("agent", "system") - invoke_result["started_at"] = started_at - result = invoke_result - else: - # Standard Claude CLI subprocess - print(f"[heartbeat_runner] step7 invoking claude agent={hb['agent']} max_turns={hb['max_turns']} timeout={hb['timeout_seconds']}s", flush=True) - invoke_result = step7_invoke_claude( - agent=hb["agent"], - prompt=full_prompt, - max_turns=hb["max_turns"], - timeout_seconds=hb["timeout_seconds"], - ) - invoke_result["agent"] = hb["agent"] - invoke_result["started_at"] = started_at - result = invoke_result - print(f"[heartbeat_runner] step7 done status={result['status']} duration_ms={result.get('duration_ms')}", flush=True) + # Step 7 — Claude CLI subprocess + print(f"[heartbeat_runner] step7 invoking claude agent={hb['agent']} max_turns={hb['max_turns']} timeout={hb['timeout_seconds']}s", flush=True) + invoke_result = step7_invoke_claude( + agent=hb["agent"], + prompt=full_prompt, + max_turns=hb["max_turns"], + timeout_seconds=hb["timeout_seconds"], + ) + invoke_result["agent"] = hb["agent"] + invoke_result["started_at"] = started_at + result = invoke_result + print(f"[heartbeat_runner] step7 done status={result['status']} duration_ms={result.get('duration_ms')}", flush=True) except Exception as exc: import traceback diff --git a/dashboard/backend/heartbeat_schema.py b/dashboard/backend/heartbeat_schema.py index 2a797d0d..0fdbf3ca 100644 --- a/dashboard/backend/heartbeat_schema.py +++ b/dashboard/backend/heartbeat_schema.py @@ -22,14 +22,15 @@ class HeartbeatConfig(BaseModel): id: Annotated[str, Field(min_length=1, max_length=100, pattern=r"^[a-z0-9-]+$")] agent: Annotated[str, Field(min_length=1, max_length=100)] interval_seconds: Annotated[int, Field(ge=60)] - max_turns: Annotated[int, Field(ge=1, le=100)] = 10 + max_turns: Annotated[int, Field(ge=0, le=100)] = 10 timeout_seconds: Annotated[int, Field(ge=30, le=3600)] = 600 lock_timeout_seconds: Annotated[int, Field(ge=60)] = 1800 wake_triggers: Annotated[List[WakeTrigger], Field(min_length=1)] enabled: bool = False goal_id: Optional[str] = None required_secrets: List[str] = Field(default_factory=list) - decision_prompt: Annotated[str, Field(min_length=20)] + decision_prompt: str + handler: Optional[str] = None source_plugin: Optional[str] = None # AC4: set to plugin slug for plugin-contributed heartbeats @field_validator("agent") @@ -70,6 +71,18 @@ def triggers_must_be_valid(cls, v: list) -> list: @model_validator(mode="after") def interval_trigger_requires_interval_field(self) -> "HeartbeatConfig": + if self.handler: + if self.max_turns != 0: + raise ValueError("Heartbeats with handler must set max_turns=0") + return self + + if self.max_turns < 1: + raise ValueError("Claude heartbeats without handler must set max_turns >= 1") + if len(self.decision_prompt.strip()) < 20: + raise ValueError( + "Claude heartbeats without handler must provide decision_prompt " + "with at least 20 characters" + ) return self diff --git a/dashboard/backend/plugin_schema.py b/dashboard/backend/plugin_schema.py index 1c30bf31..7f49f1e3 100644 --- a/dashboard/backend/plugin_schema.py +++ b/dashboard/backend/plugin_schema.py @@ -62,6 +62,10 @@ class Capability(str, Enum): # Wave 2.1 — full-screen plugin UI pages + writable data ui_pages = "ui_pages" writable_data = "writable_data" + # B2.0 — unauthenticated public pages served by the host (token-bound) + public_pages = "public_pages" + # B3 — safe uninstall with data preservation and 3-step wizard + safe_uninstall = "safe_uninstall" class PluginMcpServer(BaseModel): @@ -303,6 +307,13 @@ class ReadonlyQuery(BaseModel): id: Annotated[str, Field(min_length=1, max_length=100)] description: Annotated[str, Field(min_length=1, max_length=500)] sql: Annotated[str, Field(min_length=1)] + # B2.0: expose this query on the public portal without host auth. + # Value is the PluginPublicPage.id that gates access. + public_via: Optional[str] = None + # B2.0: named SQL parameter in ``sql`` that receives the URL token value. + # Required when public_via is set. The parameter must appear in ``sql`` + # as :token_param (e.g. ``WHERE magic_link_token = :token``). + bind_token_param: Optional[str] = None @field_validator("id") @classmethod @@ -566,6 +577,14 @@ class PluginWritableResource(BaseModel): ) # Optional JSON Schema for payload validation (jsonschema library) json_schema: Optional[WritableResourceJsonSchema] = None + # Wave 2.1.x: optional endpoint-level RBAC. When set, only authenticated + # users whose ``current_user.role`` is in this list may POST/PUT/DELETE this + # resource. Empty/None means any authenticated user passes (legacy default). + # Role 'admin' always passes regardless of the list (super-user override). + # Plugins use this to gate writable resources by role without needing a host + # PR or app-layer wrapper. See evonexus-plugin-nutri for split-endpoint + # patterns (patients_admin vs patients_clinical). + requires_role: Optional[List[Annotated[str, Field(min_length=1, max_length=64)]]] = None @field_validator("id") @classmethod @@ -585,6 +604,232 @@ def table_pattern(cls, v: str) -> str: ) return v + @field_validator("requires_role") + @classmethod + def requires_role_pattern(cls, v: Optional[List[str]]) -> Optional[List[str]]: + if v is None: + return v + for role in v: + if not re.match(r"^[a-z][a-z0-9-]*$", role): + raise ValueError( + f"requires_role entry '{role}' must match ^[a-z][a-z0-9-]*$ (kebab-case)" + ) + return v + + +class PluginPublicPageTokenSource(BaseModel): + """Token source declaration for a public page (B2.0). + + The host validates the incoming token against ``column`` in ``table`` + using a parametric query. Table must be slug-prefixed (enforced by the + PluginManifest validator ``public_pages_tables_slug_prefixed``). + + B2.0 v1 deliberately does NOT support a ``revoked_when`` SQL fragment to + prevent SQL injection. Revocation is the plugin's responsibility: nulling + or rotating the token column value causes the next request to 404. + """ + + # Plugin-owned table containing the token column (validated slug-prefixed) + table: Annotated[str, Field(min_length=1, max_length=200)] + # Column in ``table`` that holds the token value + column: Annotated[str, Field(min_length=1, max_length=100)] + + @field_validator("table") + @classmethod + def table_identifier(cls, v: str) -> str: + if not re.match(r"^[a-z][a-z0-9_]*$", v): + raise ValueError( + f"token_source.table '{v}' must match ^[a-z][a-z0-9_]*$" + ) + return v + + @field_validator("column") + @classmethod + def column_identifier(cls, v: str) -> str: + if not re.match(r"^[a-z][a-z0-9_]*$", v): + raise ValueError( + f"token_source.column '{v}' must match ^[a-z][a-z0-9_]*$" + ) + return v + + +class PluginPublicPage(BaseModel): + """A public (unauthenticated) page declared in plugin.yaml under public_pages (B2.0). + + The host registers ``/p/{slug}/{route_prefix}/{token}`` as a public route + and validates the token against ``token_source.column`` in ``token_source.table`` + on every request. Only B2.0 (read-only, no PIN) is supported in v1. + B2.1 (PIN + writable + auto_set_columns) is deferred. + """ + + # Unique identifier within this plugin's public_pages list + id: Annotated[str, Field(min_length=1, max_length=100)] + # Human-readable label for audit logs and admin UI + description: Annotated[str, Field(min_length=1, max_length=500)] + # URL prefix segment, without leading/trailing slashes (e.g. "portal") + route_prefix: Annotated[str, Field(min_length=1, max_length=100)] + # Token source — which plugin table/column the URL token is validated against + token_source: PluginPublicPageTokenSource + # Plugin JS bundle path (must be under ui/public/) + bundle: Annotated[str, Field(min_length=1, max_length=500)] + # Web component tag name registered by the bundle + custom_element_name: Annotated[str, Field(min_length=1, max_length=200)] + # auth_mode: only "token" is supported in B2.0 (B2.1 will add "pin") + auth_mode: Literal["token"] = "token" + # Rate limit override per page (requests/minute/IP); defaults to global limiter + rate_limit_per_ip: Optional[int] = None + # Optional action name to write to the audit log on each page view + audit_action: Optional[str] = None + + @field_validator("id") + @classmethod + def id_pattern(cls, v: str) -> str: + if not re.match(r"^[a-z0-9_]+$", v): + raise ValueError(f"PluginPublicPage id '{v}' must match ^[a-z0-9_]+$") + return v + + @field_validator("route_prefix") + @classmethod + def route_prefix_clean(cls, v: str) -> str: + """No leading/trailing slashes; only lowercase alphanum + hyphens.""" + v = v.strip("/") + if not re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$", v): + raise ValueError( + f"route_prefix '{v}' must be lowercase alphanum+hyphens, no slashes" + ) + return v + + @field_validator("bundle") + @classmethod + def bundle_in_public_subtree(cls, v: str) -> str: + """Bundle must live under ui/public/ to prevent leaking authenticated bundles.""" + if not v.startswith("ui/public/"): + raise ValueError( + f"PluginPublicPage bundle '{v}' must start with 'ui/public/' " + "(authenticated ui_pages bundles are not accessible from public routes)." + ) + ext = Path(v).suffix.lower() + if ext not in {".js", ".mjs"}: + raise ValueError( + f"PluginPublicPage bundle '{v}' must have a .js or .mjs extension." + ) + return v + + @field_validator("custom_element_name") + @classmethod + def custom_element_name_has_hyphen(cls, v: str) -> str: + if "-" not in v: + raise ValueError( + f"custom_element_name '{v}' must contain at least one hyphen " + "(Web Components specification requirement)." + ) + return v + + @field_validator("rate_limit_per_ip") + @classmethod + def rate_limit_positive(cls, v: Optional[int]) -> Optional[int]: + if v is not None and v < 1: + raise ValueError("rate_limit_per_ip must be a positive integer") + return v + + +class PluginPreUninstallHook(BaseModel): + """Pre-uninstall hook declaration (B3 safe_uninstall). + + Executed as a sandboxed subprocess before the uninstall wizard proceeds. + The hook must produce a file in ``output_dir`` when ``must_produce_file`` + is true — if it does not, the uninstall is blocked. + """ + + # Relative path to the hook script inside the plugin directory + script: Annotated[str, Field(min_length=1, max_length=500)] + # Output directory pattern (supports {slug} and {timestamp} interpolation) + output_dir: Annotated[str, Field(min_length=1, max_length=500)] + # Seconds before the subprocess is killed (max 600) + timeout_seconds: int = 600 + # If true, uninstall is blocked when the hook exits cleanly but produces no file + must_produce_file: bool = True + + @field_validator("script") + @classmethod + def script_relative(cls, v: str) -> str: + if v.startswith("/") or ".." in v: + raise ValueError( + f"pre_uninstall_hook.script '{v}' must be relative and must not traverse upward" + ) + return v + + @field_validator("timeout_seconds") + @classmethod + def timeout_in_range(cls, v: int) -> int: + if not 1 <= v <= 600: + raise ValueError("timeout_seconds must be between 1 and 600") + return v + + +class PluginUserConfirmation(BaseModel): + """User confirmation gate for safe_uninstall (B3). + + Defines the checkbox label and the exact phrase the user must type + to enable the Uninstall button. Phrase matching is case-sensitive. + """ + + checkbox_label: Annotated[str, Field(min_length=1, max_length=1000)] + typed_phrase: Annotated[str, Field(min_length=1, max_length=200)] + + +class PluginSafeUninstall(BaseModel): + """Safe uninstall declaration for plugins holding regulated data (B3). + + When ``enabled`` is true the host enforces: + 1. A 3-step wizard (pre-hook → checkbox → typed phrase + ZIP password). + 2. Preserved tables are NOT dropped and are renamed ``_orphan_{slug}_{table}``. + 3. Host-entity cascades respect ``preserved_host_entities`` filters. + 4. Reinstall detects orphaned tables and restores access after SHA256 verify. + + Plugins not declaring this block continue to use the default cascade-DELETE. + """ + + enabled: bool = False + # Human-readable regulatory reason shown to the admin before they confirm + reason: Optional[str] = None + # Pre-uninstall hook run before the wizard + pre_uninstall_hook: Optional[PluginPreUninstallHook] = None + # Checkbox + typed phrase gate + user_confirmation: Optional[PluginUserConfirmation] = None + # Tables that must NOT be dropped on uninstall (renamed to _orphan_{slug}_{table}) + preserved_tables: List[str] = Field(default_factory=list) + # Host-managed entity classes to partially preserve (table → WHERE clause EXCLUDING rows to delete) + # Dict mapping host table name to a SQL WHERE expression for rows that SHOULD be preserved. + # e.g. {"tickets": "source_plugin = 'nutri' AND linked_resource LIKE 'nutri_patients/%'"} + preserved_host_entities: Dict[str, str] = Field(default_factory=dict) + # If true, Uninstall button is completely disabled in the UI (for active audit windows, etc.) + block_uninstall: bool = False + + @field_validator("preserved_tables") + @classmethod + def table_names_identifier(cls, v: List[str]) -> List[str]: + for name in v: + if not re.match(r"^[a-z][a-z0-9_]*$", name): + raise ValueError( + f"preserved_tables entry '{name}' must match ^[a-z][a-z0-9_]*$" + ) + return v + + @field_validator("preserved_host_entities") + @classmethod + def host_entity_tables_known(cls, v: Dict[str, str]) -> Dict[str, str]: + _ALLOWED_HOST_TABLES = frozenset({ + "triggers", "tickets", "goal_tasks", "goals", "projects", "missions" + }) + for table in v: + if table not in _ALLOWED_HOST_TABLES: + raise ValueError( + f"preserved_host_entities key '{table}' is not a known host entity table. " + f"Allowed: {sorted(_ALLOWED_HOST_TABLES)}" + ) + return v + class PluginUIEntryPoints(BaseModel): """Typed container for ui_entry_points in plugin.yaml (Wave 2.1). @@ -655,6 +900,16 @@ class PluginManifest(BaseModel): # env_vars_needed is kept as deprecated warning-only for backwards compatibility. integrations: Optional[List["PluginIntegration"]] = None + # --- B2.0: Public pages (unauthenticated, token-bound) --- + # Declared under public_pages: in plugin.yaml. + # Requires Capability.public_pages in capabilities list. + public_pages: Optional[List[PluginPublicPage]] = None + + # --- B3: Safe uninstall with data preservation --- + # Declared under safe_uninstall: in plugin.yaml. + # Requires Capability.safe_uninstall in capabilities list. + safe_uninstall: Optional[PluginSafeUninstall] = None + @field_validator("id") @classmethod def slug_pattern(cls, v: str) -> str: @@ -802,6 +1057,133 @@ def pages_bundle_paths_unique(self) -> "PluginManifest": return self + @model_validator(mode="after") + def safe_uninstall_requires_capability(self) -> "PluginManifest": + """B3: safe_uninstall block requires Capability.safe_uninstall in capabilities.""" + if self.safe_uninstall and Capability.safe_uninstall not in self.capabilities: + raise ValueError( + "safe_uninstall is declared but Capability.safe_uninstall is missing " + "from capabilities list." + ) + return self + + @model_validator(mode="after") + def safe_uninstall_preserved_tables_slug_prefixed(self) -> "PluginManifest": + """B3: preserved_tables must start with {slug_under}.""" + if not self.safe_uninstall or not self.safe_uninstall.preserved_tables: + return self + slug_under = self.id.replace("-", "_") + "_" + for table in self.safe_uninstall.preserved_tables: + if not table.lower().startswith(slug_under): + raise ValueError( + f"safe_uninstall.preserved_tables entry '{table}' does not start " + f"with required prefix '{slug_under}'. " + "Preserved tables must be plugin-owned." + ) + return self + + @model_validator(mode="after") + def safe_uninstall_enabled_requires_confirmation(self) -> "PluginManifest": + """B3: if safe_uninstall.enabled is true, user_confirmation is required.""" + su = self.safe_uninstall + if su and su.enabled and not su.block_uninstall and not su.user_confirmation: + raise ValueError( + "safe_uninstall.enabled is true but user_confirmation is not declared. " + "Admin must confirm with a checkbox + typed phrase." + ) + return self + + @model_validator(mode="after") + def readonly_data_no_orphan_table_references(self) -> "PluginManifest": + """Vault B3.S4: readonly_data SQL must not reference _orphan_* tables. + + Orphan tables are renamed on uninstall to prevent hostile reinstall from + accessing them via readonly_data declarations. + """ + if not self.readonly_data: + return self + _TABLE_RE = re.compile( + r"\b(?:FROM|JOIN)\s+([a-zA-Z_][a-zA-Z0-9_]*)", + re.IGNORECASE, + ) + for query in self.readonly_data: + tables = _TABLE_RE.findall(query.sql) + for table in tables: + if table.lower().startswith("_orphan_"): + raise ValueError( + f"ReadonlyQuery '{query.id}' references orphan table '{table}'. " + "Queries must not reference _orphan_* tables — these are preserved " + "from a previous uninstall and are inaccessible under the plugin namespace." + ) + return self + + @model_validator(mode="after") + def public_pages_require_capability(self) -> "PluginManifest": + """B2.0: public_pages block requires Capability.public_pages in capabilities.""" + if self.public_pages and Capability.public_pages not in self.capabilities: + raise ValueError( + "public_pages is declared but Capability.public_pages is missing " + "from capabilities list." + ) + return self + + @model_validator(mode="after") + def public_pages_tables_slug_prefixed(self) -> "PluginManifest": + """B2.0: token_source.table must start with {slug_under} (same guard as readonly/writable).""" + if not self.public_pages: + return self + slug_under = self.id.replace("-", "_") + "_" + for page in self.public_pages: + table = page.token_source.table + if not table.lower().startswith(slug_under): + raise ValueError( + f"PluginPublicPage '{page.id}' token_source.table '{table}' " + f"does not start with required prefix '{slug_under}'. " + "Public page token sources must only reference the plugin's own tables." + ) + return self + + @model_validator(mode="after") + def public_pages_ids_unique(self) -> "PluginManifest": + """B2.0: public page ids and route_prefixes must be unique within a plugin.""" + if not self.public_pages: + return self + seen_ids: set[str] = set() + seen_prefixes: set[str] = set() + for page in self.public_pages: + if page.id in seen_ids: + raise ValueError( + f"Duplicate PluginPublicPage id '{page.id}' in public_pages." + ) + if page.route_prefix in seen_prefixes: + raise ValueError( + f"Duplicate PluginPublicPage route_prefix '{page.route_prefix}' in public_pages." + ) + seen_ids.add(page.id) + seen_prefixes.add(page.route_prefix) + return self + + @model_validator(mode="after") + def readonly_public_via_references_valid_page(self) -> "PluginManifest": + """B2.0: readonly_data[].public_via must reference a declared public_pages[].id.""" + has_public_via = [q for q in self.readonly_data if q.public_via] + if not has_public_via: + return self + page_ids = {p.id for p in (self.public_pages or [])} + for query in has_public_via: + if query.public_via not in page_ids: + raise ValueError( + f"ReadonlyQuery '{query.id}' references public_via='{query.public_via}' " + "which is not declared in public_pages." + ) + if not query.bind_token_param: + raise ValueError( + f"ReadonlyQuery '{query.id}' has public_via set but bind_token_param " + "is missing. The query must declare which SQL parameter receives the token." + ) + return self + + def load_plugin_manifest(plugin_dir: Path) -> PluginManifest: """Load and validate plugin.yaml from a plugin directory. diff --git a/dashboard/backend/provider_fallback.py b/dashboard/backend/provider_fallback.py new file mode 100644 index 00000000..69f32d82 --- /dev/null +++ b/dashboard/backend/provider_fallback.py @@ -0,0 +1,477 @@ +"""Provider Fallback Engine — automatic 429/quota detection & provider rotation. + +Reads the active provider from config/providers.json and its fallback_models / +fallback_providers configuration. When a subprocess call returns 429/quota +errors, this module cycles to the next model (within the same provider) or +to the next provider entirely. + +Cooldown tracking prevents thrashing — a model/provider that 429'd gets a +cooldown window before we try it again. + +Usage: + from provider_fallback import FallbackEngine + + engine = FallbackEngine() + for attempt in engine.attempts(prompt, max_turns=10, timeout=600): + result = attempt.run() + if result["status"] == "success": + break + # engine automatically records 429 and advances to next model/provider +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import signal +import subprocess +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterator + +WORKSPACE = Path(__file__).resolve().parent.parent.parent +PROVIDERS_CONFIG = WORKSPACE / "config" / "providers.json" + +# ── Error patterns that trigger fallback ────────────────────────────────────── + +_429_PATTERNS = [ + re.compile(r"429", re.IGNORECASE), + re.compile(r"rate.?limit", re.IGNORECASE), + re.compile(r"quota", re.IGNORECASE), + re.compile(r"too many requests", re.IGNORECASE), + re.compile(r"resource.?exhausted", re.IGNORECASE), + re.compile(r"capacity", re.IGNORECASE), + re.compile(r"overloaded", re.IGNORECASE), + re.compile(r"service.?unavailable", re.IGNORECASE), + re.compile(r"temporarily.?unavailable", re.IGNORECASE), + re.compile(r"insufficient_quota", re.IGNORECASE), + re.compile(r"billing.?limit", re.IGNORECASE), + re.compile(r"plan.?limit", re.IGNORECASE), +] + +# Fatal errors that should NOT trigger fallback (auth / config issues) +_FATAL_PATTERNS = [ + re.compile(r"401", re.IGNORECASE), + re.compile(r"403", re.IGNORECASE), + re.compile(r"invalid.?api.?key", re.IGNORECASE), + re.compile(r"authentication", re.IGNORECASE), + re.compile(r"unauthorized", re.IGNORECASE), + re.compile(r"forbidden", re.IGNORECASE), +] + + +def is_429_error(error_text: str) -> bool: + """Check if error text indicates a 429/rate-limit/quota issue.""" + if not error_text: + return False + has_429 = any(p.search(error_text) for p in _429_PATTERNS) + has_fatal = any(p.search(error_text) for p in _FATAL_PATTERNS) + if has_fatal and not has_429: + return False + return has_429 + + +# ── Cooldown tracking ───────────────────────────────────────────────────────── + +_cooldowns: dict[str, float] = {} +DEFAULT_COOLDOWN_SECONDS = 300 # 5 min + + +def set_cooldown(key: str, duration_seconds: float = DEFAULT_COOLDOWN_SECONDS): + _cooldowns[key] = time.time() + duration_seconds + + +def is_on_cooldown(key: str) -> bool: + deadline = _cooldowns.get(key) + if deadline is None: + return False + if time.time() > deadline: + _cooldowns.pop(key, None) + return False + return True + + +def clear_cooldown(key: str): + _cooldowns.pop(key, None) + + +def clear_all_cooldowns(): + _cooldowns.clear() + + +# ── Config reading ───────────────────────────────────────────────────────────── + +def _read_providers_config() -> dict: + try: + if PROVIDERS_CONFIG.is_file(): + return json.loads(PROVIDERS_CONFIG.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + pass + return {"active_provider": "nvidia", "providers": {}} + + +# ── Default fallback chains ─────────────────────────────────────────────────── + +# NVIDIA: models are independent — quota on one doesn't block others. +NVIDIA_MODEL_CHAIN = [ + "minimaxi/minimax-m3", # Primary + "z-ai/glm-5.1", # 2nd + "deepseek-ai/deepseek-v4-flash", # 3rd + "qwen/qwen3.5-397b-a17b", # 4th + "stepfun-ai/step-3.7-flash", # 5th (haiku tier) +] + +# Provider chain: NVIDIA → OpenRouter (owl-alpha) → Codex → Claude nativo +DEFAULT_PROVIDER_CHAIN = [ + { + "provider_id": "nvidia", + "cli_command": "openclaude", + "base_url": "https://integrate.api.nvidia.com/v1", + "env_vars": { + "CLAUDE_CODE_USE_OPENAI": "1", + "OPENAI_BASE_URL": "https://integrate.api.nvidia.com/v1", + }, + "model_chain": NVIDIA_MODEL_CHAIN, + }, + { + "provider_id": "openrouter", + "cli_command": "openclaude", + "base_url": "https://openrouter.ai/api/v1", + "env_vars": { + "CLAUDE_CODE_USE_OPENAI": "1", + "OPENAI_BASE_URL": "https://openrouter.ai/api/v1", + }, + "model_chain": ["openrouter/owl-alpha"], + }, + { + "provider_id": "codex_auth", + "cli_command": "openclaude", + "base_url": None, + "env_vars": { + "CLAUDE_CODE_USE_OPENAI": "1", + }, + "model_chain": ["codexplan", "codexspark"], + }, + { + "provider_id": "anthropic", + "cli_command": "claude", + "base_url": None, + "env_vars": {}, + "model_chain": [None], # claude binary uses its own model + }, +] + + +def _resolve_provider_chain(config: dict) -> list[dict]: + """Build the provider chain from config, falling back to defaults.""" + active_id = config.get("active_provider", "nvidia") + providers = config.get("providers", {}) + active_prov = providers.get(active_id, {}) + + explicit_chain = active_prov.get("fallback_providers") + if isinstance(explicit_chain, list) and explicit_chain: + chain = [_build_provider_entry(active_id, providers)] + for pid in explicit_chain: + if pid in providers: + chain.append(_build_provider_entry(pid, providers)) + return chain if len(chain) >= 2 else DEFAULT_PROVIDER_CHAIN + + return DEFAULT_PROVIDER_CHAIN + + +def _build_provider_entry(provider_id: str, providers: dict) -> dict: + prov = providers.get(provider_id, {}) + env_vars = {k: v for k, v in prov.get("env_vars", {}).items() + if v and k not in ("OPENAI_API_KEY", "OPENAI_MODEL")} + model_chain = prov.get("fallback_models", + [prov.get("default_model") or prov.get("env_vars", {}).get("OPENAI_MODEL")]) + + return { + "provider_id": provider_id, + "cli_command": prov.get("cli_command", "openclaude"), + "base_url": prov.get("default_base_url") or prov.get("env_vars", {}).get("OPENAI_BASE_URL"), + "env_vars": env_vars, + "model_chain": [m for m in model_chain if m], + } + + +def _get_api_key(provider_id: str, config: dict) -> str: + prov = config.get("providers", {}).get(provider_id, {}) + env_vars = prov.get("env_vars", {}) + for key_name in ("OPENAI_API_KEY", "NVIDIA_API_KEY", "GEMINI_API_KEY"): + val = env_vars.get(key_name, "") + if val and "****" not in val: + return val + return os.environ.get("OPENAI_API_KEY", "") + + +# ── Attempt record ───────────────────────────────────────────────────────────── + +@dataclass +class FallbackAttempt: + attempt_number: int + provider_id: str + model: str | None + cli_command: str + prompt: str + max_turns: int + timeout_seconds: int + env_overrides: dict = field(default_factory=dict) + _result: dict | None = field(default=None, repr=False) + + def run(self) -> dict: + self._result = _invoke_cli( + cli_command=self.cli_command, + prompt=self.prompt, + max_turns=self.max_turns, + timeout_seconds=self.timeout_seconds, + env_overrides=self.env_overrides, + ) + return self._result + + @property + def result(self) -> dict | None: + return self._result + + @property + def is_429(self) -> bool: + if not self._result: + return False + error = self._result.get("error") or "" + output = self._result.get("output") or "" + return is_429_error(error) or is_429_error(output) + + @property + def is_fatal(self) -> bool: + if not self._result: + return False + if self._result.get("status") == "success": + return False + if self.is_429: + return False + return True + + +# ── Core invocation ───────────────────────────────────────────────────────────── + +def _invoke_cli( + cli_command: str, + prompt: str, + max_turns: int, + timeout_seconds: int, + env_overrides: dict | None = None, +) -> dict: + cli_bin = shutil.which(cli_command) + if not cli_bin: + return { + "status": "fail", + "error": f"{cli_command} binary not found in PATH", + "output": "", + "duration_ms": 0, + "tokens_in": None, "tokens_out": None, "cost_usd": None, + } + + cmd = [cli_bin, "--print", "--max-turns", str(max_turns), + "--dangerously-skip-permissions", "--output-format", "json", "--", prompt] + + run_env = dict(os.environ) + if env_overrides: + for k, v in env_overrides.items(): + if v is not None: + run_env[k] = str(v) + + start_time = time.time() + proc = None + output = "" + error = None + status = "success" + + try: + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, cwd=str(WORKSPACE), start_new_session=True, env=run_env, + ) + try: + stdout, stderr = proc.communicate(timeout=timeout_seconds) + output = stdout or "" + if proc.returncode != 0: + status = "fail" + error = stderr[:2000] if stderr else f"exit code {proc.returncode}" + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except (ProcessLookupError, OSError): + proc.kill() + try: + proc.communicate(timeout=5) + except subprocess.TimeoutExpired: + pass + status = "timeout" + error = f"Killed after {timeout_seconds}s timeout" + except Exception as exc: + status = "fail" + error = str(exc) + + duration_ms = int((time.time() - start_time) * 1000) + + return { + "status": status, "output": output, "error": error, + "duration_ms": duration_ms, + "tokens_in": None, "tokens_out": None, "cost_usd": None, + } + + +# ── Fallback Engine ───────────────────────────────────────────────────────────── + +class FallbackEngine: + """Iterates through provider + model chains, falling back on 429/quota errors.""" + + def __init__(self, provider_chain: list[dict] | None = None): + if provider_chain is None: + config = _read_providers_config() + provider_chain = _resolve_provider_chain(config) + self.provider_chain = provider_chain + self._attempts_log: list[FallbackAttempt] = [] + + def attempts( + self, + prompt: str, + max_turns: int = 10, + timeout_seconds: int = 600, + agent: str = "", + force_provider: str | None = None, + force_model: str | None = None, + ) -> Iterator[FallbackAttempt]: + config = _read_providers_config() + attempt_num = 0 + + chain = self.provider_chain + if force_provider: + chain = [p for p in chain if p["provider_id"] == force_provider] + if not chain and force_provider == "nvidia": + chain = [DEFAULT_PROVIDER_CHAIN[0]] + + for provider_entry in chain: + provider_id = provider_entry["provider_id"] + cli_command = provider_entry["cli_command"] + base_url = provider_entry.get("base_url") + model_chain = provider_entry.get("model_chain", [None]) + base_env = dict(provider_entry.get("env_vars", {})) + + if base_url: + base_env["OPENAI_BASE_URL"] = base_url + + api_key = _get_api_key(provider_id, config) + if api_key: + base_env["OPENAI_API_KEY"] = api_key + + for model in model_chain: + if force_model and model != force_model: + continue + + cooldown_key = f"{provider_id}:{model}" if model else provider_id + if is_on_cooldown(cooldown_key): + continue + + # Provider-level cooldown (NVIDIA exempt — models independent) + if is_on_cooldown(provider_id) and provider_id != "nvidia": + continue + + attempt_num += 1 + + env_overrides = dict(base_env) + if model: + env_overrides["OPENAI_MODEL"] = model + + attempt = FallbackAttempt( + attempt_number=attempt_num, + provider_id=provider_id, + model=model, + cli_command=cli_command, + prompt=prompt, + max_turns=max_turns, + timeout_seconds=timeout_seconds, + env_overrides=env_overrides, + ) + self._attempts_log.append(attempt) + yield attempt + + # After caller runs the attempt, check result + if attempt.result is None: + continue + + if attempt.result.get("status") == "success": + return + + if attempt.is_429: + set_cooldown(cooldown_key) + print(f"[fallback] 429 on {provider_id}:{model} — " + f"cooldown {DEFAULT_COOLDOWN}s, trying next", flush=True) + continue + + if attempt.is_fatal: + print(f"[fallback] fatal error on {provider_id}:{model} — " + f"skipping to next provider", flush=True) + break + + continue + + @property + def attempts_log(self) -> list[FallbackAttempt]: + return list(self._attempts_log) + + def last_successful_attempt(self) -> FallbackAttempt | None: + for a in reversed(self._attempts_log): + if a.result and a.result.get("status") == "success": + return a + return None + + +# ── Convenience: single call with automatic fallback ─────────────────────────── + +def invoke_with_fallback( + prompt: str, + max_turns: int = 10, + timeout_seconds: int = 600, + agent: str = "", + force_provider: str | None = None, + force_model: str | None = None, +) -> dict: + """Invoke CLI with automatic 429 fallback. Returns the first successful result.""" + engine = FallbackEngine() + last_result = None + + for attempt in engine.attempts( + prompt=prompt, max_turns=max_turns, timeout_seconds=timeout_seconds, + agent=agent, force_provider=force_provider, force_model=force_model, + ): + result = attempt.run() + result["provider_id"] = attempt.provider_id + result["model"] = attempt.model + result["attempt_number"] = attempt.attempt_number + + if result["status"] == "success": + if attempt.attempt_number > 1: + print(f"[fallback] SUCCESS on attempt #{attempt.attempt_number} " + f"({attempt.provider_id}:{attempt.model})", flush=True) + return result + + last_result = result + print(f"[fallback] attempt #{attempt.attempt_number} failed " + f"({attempt.provider_id}:{attempt.model}) status={result['status']}", flush=True) + + if last_result: + last_result["fallback_exhausted"] = True + last_result["total_attempts"] = len(engine.attempts_log) + else: + last_result = { + "status": "fail", + "error": "No attempts made — all providers on cooldown or no chain configured", + "output": "", "duration_ms": 0, + "fallback_exhausted": True, "total_attempts": 0, + } + + return last_result diff --git a/dashboard/backend/rate_limit.py b/dashboard/backend/rate_limit.py new file mode 100644 index 00000000..c1b49939 --- /dev/null +++ b/dashboard/backend/rate_limit.py @@ -0,0 +1,26 @@ +"""Shared Flask-Limiter instance for EvoNexus. + +Placing the limiter here (rather than in app.py directly) breaks the +circular-import chain: app.py initialises it, route blueprints import it. + +Usage in a blueprint:: + + from rate_limit import limiter + + @bp.route("/api/shares//view") + @limiter.limit("60 per minute") + def view_share(token: str): + ... +""" + +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address + +# Uninitialised instance — app.py calls limiter.init_app(app) at startup. +limiter = Limiter( + get_remote_address, + # Default: generous to avoid false positives on authenticated API routes. + # Individual endpoints override with @limiter.limit() decorators. + default_limits=["600 per minute"], + storage_uri="memory://", +) diff --git a/dashboard/backend/routes/plugin_public_pages.py b/dashboard/backend/routes/plugin_public_pages.py new file mode 100644 index 00000000..3b88264d --- /dev/null +++ b/dashboard/backend/routes/plugin_public_pages.py @@ -0,0 +1,431 @@ +"""Plugin public pages — unauthenticated token-bound portals (B2.0). + +Routes registered here bypass the ``before_request`` auth gate in ``app.py``. +The host validates the URL token against a plugin-declared column in a +plugin-owned table on every request. + +B2.0 scope (read-only, no PIN): + GET /p/// — serve portal bundle + GET /p////data — serve public readonly query + GET /p//public-assets/ — serve ui/public/ static assets + +B2.1 (PIN + writable + token-bind) is deferred. + +Security controls applied here: + - Rate limit 60 req/min/IP (from rate_limit.py) on portal + data endpoints + - Vault §B2.S2: Referrer-Policy, Cache-Control no-store, HSTS on every response + - Token validated parametrically (no SQL injection risk on token value) + - table/column identifiers validated via PluginPublicPage schema at install time + - Path traversal prevented by realpath + startswith containment check + - MIME whitelist on public asset serving +""" + +from __future__ import annotations + +import os +import sqlite3 +from pathlib import Path +from typing import Any, Dict, Optional + +from flask import Blueprint, abort, jsonify, request, Response, after_this_request + +from models import audit +from rate_limit import limiter + +bp = Blueprint("plugin_public_pages", __name__) + +# Resolved once at module load; identical to plugins.py pattern. +WORKSPACE = Path(__file__).resolve().parent.parent.parent.parent +PLUGINS_DIR = WORKSPACE / "plugins" +DB_PATH = WORKSPACE / "dashboard" / "data" / "evonexus.db" + +# --------------------------------------------------------------------------- +# Module-level public prefix cache. +# Updated on install/uninstall via register_public_prefix / unregister_public_prefix. +# Read by app.py before_request middleware to bypass auth for /p/... paths. +# --------------------------------------------------------------------------- + +# Set of string prefixes, each entry like "/p/nutri/portal" +_PLUGIN_PUBLIC_PREFIXES: set[str] = set() + + +def register_public_prefix(slug: str, route_prefix: str) -> None: + """Add a plugin's public route prefix to the auth bypass cache. + + Called by plugin_loader.py (or routes/plugins.py) after a successful install. + """ + _PLUGIN_PUBLIC_PREFIXES.add(f"/p/{slug}/{route_prefix}") + + +def unregister_public_prefix(slug: str, route_prefix: str) -> None: + """Remove a plugin's public route prefix from the auth bypass cache. + + Called by routes/plugins.py during uninstall. + """ + _PLUGIN_PUBLIC_PREFIXES.discard(f"/p/{slug}/{route_prefix}") + + +def get_public_prefixes() -> frozenset[str]: + """Read-only snapshot of the current public prefix set. + + Used by app.py before_request middleware. + """ + return frozenset(_PLUGIN_PUBLIC_PREFIXES) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _security_headers(response: Response) -> Response: + """Vault §B2.S2: mandatory security headers on all public-page responses.""" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Cache-Control"] = "no-store, private, no-cache, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains" + return response + + +def _get_db() -> sqlite3.Connection: + conn = sqlite3.connect(str(DB_PATH), timeout=10) + conn.row_factory = sqlite3.Row + return conn + + +def _load_page_config(slug: str, route_prefix: str) -> Optional[Dict[str, Any]]: + """Return the installed public_pages config for the given slug + route_prefix. + + Reads from the manifest stored in plugins_installed (same pattern as plugins.py). + Returns None if not found or not installed. + """ + import json as _json + conn = _get_db() + try: + row = conn.execute( + "SELECT manifest_json FROM plugins_installed WHERE slug = ? AND status = 'active'", + (slug,), + ).fetchone() + if not row: + return None + manifest = _json.loads(row["manifest_json"]) + for page in manifest.get("public_pages") or []: + if page.get("route_prefix") == route_prefix: + return page + return None + finally: + conn.close() + + +def _validate_token(page_config: Dict[str, Any], token: str) -> bool: + """Validate the URL token against the plugin-declared token_source column. + + Uses a parametric query — only the `?` value is user-supplied. + Table and column names come from the manifest (validated at install by + PluginPublicPage schema; both are slug-prefixed and identifier-safe). + """ + token_source = page_config.get("token_source", {}) + table = token_source.get("table", "") + column = token_source.get("column", "") + + if not table or not column: + return False + + # Identifiers are validated at install time (PluginPublicPage schema) to + # match ^[a-z][a-z0-9_]*$ — safe to interpolate here. + sql = f"SELECT 1 FROM {table} WHERE {column} = ?" # noqa: S608 — identifiers whitelisted at install + + # The plugin DB is kept inside the plugin's own data directory. + # EvoNexus uses the shared evonexus.db for all plugin tables (no per-plugin DB). + conn = _get_db() + try: + row = conn.execute(sql, (token,)).fetchone() + return row is not None + except sqlite3.OperationalError: + # Table doesn't exist yet (e.g. install in progress) — fail closed. + return False + finally: + conn.close() + + +def _serve_bundle(slug: str, bundle_path: str) -> Response: + """Serve a plugin's ui/public/ bundle file (no auth check needed here — + caller already verified token; bundle is the entire page shell). + + ``bundle_path`` is relative to the plugin dir (e.g. "ui/public/portal.js"). + """ + plugin_dir = PLUGINS_DIR / slug + ui_public_root = os.path.realpath(str(plugin_dir / "ui" / "public")) + # Strip "ui/public/" prefix to get the sub-path + relative = bundle_path[len("ui/public/"):] + requested = os.path.realpath(os.path.join(ui_public_root, relative)) + + # Containment check — must stay inside plugins/{slug}/ui/public/ + if not requested.startswith(ui_public_root + os.sep) and requested != ui_public_root: + abort(404) + + if not os.path.isfile(requested): + abort(404) + + ext = os.path.splitext(requested)[1].lower() + mime_map = { + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".html": "text/html; charset=utf-8", + } + mime = mime_map.get(ext) + if not mime: + abort(404) + + with open(requested, "rb") as fh: + content = fh.read() + + resp = Response(content, mimetype=mime) + resp.headers["X-Content-Type-Options"] = "nosniff" + # Content-Security-Policy: restrict resource loading to same origin. + # 'unsafe-inline' is included for inline scripts in plugin bundles (Web Component pattern). + resp.headers["Content-Security-Policy"] = ( + "default-src 'self'; script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; img-src 'self' data:; " + "connect-src 'self'; frame-ancestors 'none'" + ) + return resp + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@bp.route("/p///", methods=["GET"]) +@limiter.limit("60 per minute") +def portal_page(slug: str, route_prefix: str, token: str): + """Serve the plugin portal page after validating the URL token. + + Flow: + 1. Load page config from plugins_installed manifest. + 2. Validate token against token_source.column (parametric SQL). + 3. Serve the plugin's ui/public/ bundle. + 4. Apply security headers. + """ + @after_this_request + def _headers(response: Response) -> Response: + return _security_headers(response) + + page_config = _load_page_config(slug, route_prefix) + if not page_config: + return jsonify({"error": "Link inválido ou expirado", "code": "not_found"}), 404 + + if not _validate_token(page_config, token): + ip = request.remote_addr or "-" + audit( + None, + page_config.get("audit_action") or "portal_view_denied", + f"plugins/{slug}/public_pages/{route_prefix}", + detail=f"token={token[:8]}... ip={ip} reason=token_invalid", + ) + return jsonify({"error": "Link inválido ou expirado", "code": "not_found"}), 404 + + # Token valid — log successful view + ip = request.remote_addr or "-" + ua = (request.headers.get("User-Agent", "-") or "-")[:200] + audit( + None, + page_config.get("audit_action") or "portal_view", + f"plugins/{slug}/public_pages/{route_prefix}", + detail=f"token={token[:8]}... ip={ip} ua={ua[:80]}", + ) + + bundle_path = page_config.get("bundle", "") + + # Wave 2.1.x — content negotiation: browsers send Accept: text/html and + # expect a rendered page; programmatic clients (or asset prefetchers) can + # fetch the raw bundle by NOT sending text/html in Accept (or by hitting + # /p/{slug}/public-assets/{file} directly, which does not require a token). + # + # When the caller wants HTML, generate a minimal shell that loads the bundle + # and instantiates the declared custom element. Without this, browsers see + # the JS source. With this, the portal renders. + accept = (request.headers.get("Accept") or "").lower() + wants_html = "text/html" in accept and "application/javascript" not in accept + if wants_html: + return _serve_html_shell(slug, page_config, token) + + return _serve_bundle(slug, bundle_path) + + +def _serve_html_shell(slug: str, page_config: dict, token: str) -> Response: + """Render a minimal HTML page that boots the plugin's custom element. + + The shell is generated server-side so plugin authors only ship a JS bundle + (custom element definition). The bundle is fetched as a module from + /p/{slug}/public-assets/{file} (no auth required for assets — they contain + no patient data; data lives behind the token-gated /data endpoint). + + The custom element receives the URL token via ``data-token`` attribute so + the bundle does not need to re-parse window.location. + """ + from html import escape as h + bundle_path = page_config.get("bundle", "") or "" + if not bundle_path.startswith("ui/public/"): + abort(404) + bundle_relative = bundle_path[len("ui/public/"):] + custom_element = page_config.get("custom_element_name") or "" + if not custom_element or not all(c.isalnum() or c in "-" for c in custom_element): + # Defense in depth — schema already validates this on install + abort(500) + label = page_config.get("description") or page_config.get("label") or "Portal" + + # CSP for the shell: + # - script-src 'self' allows the bundle module from same origin (public-assets/) + # - style-src 'self' 'unsafe-inline' covers minimal inline shell styling + # - img-src 'self' data: covers logos embedded as data URIs (brand workaround) + # - connect-src 'self' so the bundle can fetch /p/{slug}/{route}/{token}/data + asset_url = f"/p/{h(slug)}/public-assets/{h(bundle_relative)}" + body = ( + "" + "" + "" + "" + "" + f"{h(label)}" + "" + "" + f"<{custom_element} data-token=\"{h(token)}\" data-slug=\"{h(slug)}\">" + f"" + "" + ) + resp = Response(body, mimetype="text/html; charset=utf-8") + resp.headers["X-Content-Type-Options"] = "nosniff" + resp.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + "connect-src 'self'; " + "frame-ancestors 'none'" + ) + return resp + + +@bp.route("/p////data", methods=["GET"]) +@limiter.limit("120 per minute") +def portal_data(slug: str, route_prefix: str, token: str): + """Serve public readonly query results bound to the URL token. + + Requires a ``query_id`` query-string param that matches a declared + readonly_data entry with ``public_via`` pointing to this page. + """ + @after_this_request + def _headers(response: Response) -> Response: + return _security_headers(response) + + query_id = request.args.get("query_id", "").strip() + if not query_id: + return jsonify({"error": "query_id is required", "code": "bad_request"}), 400 + + page_config = _load_page_config(slug, route_prefix) + if not page_config: + return jsonify({"error": "Link inválido ou expirado", "code": "not_found"}), 404 + + if not _validate_token(page_config, token): + return jsonify({"error": "Link inválido ou expirado", "code": "not_found"}), 404 + + # Load readonly_data entries from the manifest to find the matching public query + import json as _json + conn_meta = _get_db() + try: + row = conn_meta.execute( + "SELECT manifest_json FROM plugins_installed WHERE slug = ? AND status = 'active'", + (slug,), + ).fetchone() + if not row: + return jsonify({"error": "Plugin not found", "code": "not_found"}), 404 + manifest = _json.loads(row["manifest_json"]) + finally: + conn_meta.close() + + # Find the query + public_page_id = page_config.get("id") + query_spec = None + for q in manifest.get("readonly_data") or []: + if q.get("id") == query_id and q.get("public_via") == public_page_id: + query_spec = q + break + + if not query_spec: + return jsonify({"error": "Query not found or not public", "code": "not_found"}), 404 + + bind_param = query_spec.get("bind_token_param") + sql = query_spec.get("sql", "") + + # Execute query with token bound to the declared parameter + conn_data = _get_db() + try: + if bind_param: + rows = conn_data.execute(sql, {bind_param: token}).fetchall() + else: + rows = conn_data.execute(sql).fetchall() + results = [dict(r) for r in rows] + except sqlite3.OperationalError as exc: + return jsonify({"error": "Query execution failed", "detail": str(exc)}), 500 + finally: + conn_data.close() + + return jsonify({"query_id": query_id, "rows": results}) + + +@bp.route("/p//public-assets/", methods=["GET"]) +def portal_static(slug: str, subpath: str): + """Serve plugin static assets from ui/public/ (no token required). + + CSS, images, and other non-JS assets referenced by the portal bundle. + Path must stay within plugins/{slug}/ui/public/ (containment check). + """ + @after_this_request + def _headers(response: Response) -> Response: + return _security_headers(response) + + plugin_dir = PLUGINS_DIR / slug + ui_public_root = os.path.realpath(str(plugin_dir / "ui" / "public")) + requested = os.path.realpath(os.path.join(ui_public_root, subpath)) + + # Containment check + if not requested.startswith(ui_public_root + os.sep): + abort(404) + + if not os.path.isfile(requested): + abort(404) + + ext = os.path.splitext(requested)[1].lower() + mime_map = { + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".json": "application/json; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".woff2": "font/woff2", + ".woff": "font/woff", + ".ttf": "font/ttf", + } + mime = mime_map.get(ext) + if not mime: + abort(404) + + with open(requested, "rb") as fh: + content = fh.read() + + resp = Response(content, mimetype=mime) + resp.headers["X-Content-Type-Options"] = "nosniff" + # Static assets can be cached by the browser (shorter TTL for public portal) + resp.headers["Cache-Control"] = "public, max-age=300" + return resp diff --git a/dashboard/backend/routes/plugins.py b/dashboard/backend/routes/plugins.py index a7f33cdc..5b92d728 100644 --- a/dashboard/backend/routes/plugins.py +++ b/dashboard/backend/routes/plugins.py @@ -13,8 +13,11 @@ import os import shutil import sqlite3 +import subprocess +import tempfile import threading import time +import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -778,6 +781,40 @@ def install_plugin(): except RuntimeError as exc: return jsonify({"error": str(exc)}), 409 + # B3: Check for orphaned tables from a previous uninstall (safe_uninstall). + # If orphans exist, verify SHA256 to prevent hostile reinstall (Vault B3.S3). + _orphan_check_conn = _get_db() + try: + _orphan_rows = _orphan_check_conn.execute( + "SELECT tablename, original_sha256, original_plugin_version FROM plugin_orphans " + "WHERE slug = ? AND recovered_at IS NULL", + (slug,), + ).fetchall() + except Exception: + _orphan_rows = [] + finally: + _orphan_check_conn.close() + + if _orphan_rows: + # Verify SHA256: the plugin being installed must match what was originally installed. + _install_sha256 = tarball_sha256 or "" + _original_sha256s = {row[1] for row in _orphan_rows if row[1]} + if _original_sha256s and _install_sha256: + if _install_sha256 not in _original_sha256s: + _admin_confirm = data.get("confirmed_sha256_change", False) + if not _admin_confirm: + return jsonify({ + "error": "sha256_mismatch", + "detail": ( + "Source changed since last install — possible hostile reinstall. " + "This plugin has orphaned tables from a previous install. " + "Pass confirmed_sha256_change=true to override (will be audited)." + ), + "orphaned_tables": [row[0] for row in _orphan_rows], + "expected_sha256": list(_original_sha256s), + "provided_sha256": _install_sha256, + }), 409 + plugin_dir = PLUGINS_DIR / slug state: dict[str, Any] = { "slug": slug, @@ -788,6 +825,40 @@ def install_plugin(): conn = _get_db() try: + # B3: Recover orphaned tables BEFORE copying/migrating (Vault B3.S3). + # Rename _orphan_{slug}_{table} back to {table} so install.sql can use them. + _recovered_tables: list[str] = [] + if _orphan_rows: + _recovery_conn = _get_db() + try: + for _orphan_row in _orphan_rows: + _orig_table = _orphan_row[0] + _orphan_table_name = f"_orphan_{slug}_{_orig_table}" + _existing = { + row[0] for row in _recovery_conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + if _orphan_table_name in _existing: + _recovery_conn.execute( + f"ALTER TABLE {_orphan_table_name} RENAME TO {_orig_table}" + ) + _recovery_conn.commit() + _recovered_tables.append(_orig_table) + logger.info("B3 reinstall: recovered orphaned table '%s'", _orig_table) + + # Mark orphans as recovered in plugin_orphans + if _recovered_tables: + _now = _now_iso() + for _t in _recovered_tables: + _recovery_conn.execute( + "UPDATE plugin_orphans SET recovered_at = ? WHERE slug = ? AND tablename = ?", + (_now, slug, _t), + ) + _recovery_conn.commit() + finally: + _recovery_conn.close() + # --- Step: copy plugin source to plugins/{slug}/ --- plugin_dir.mkdir(parents=True, exist_ok=True) @@ -1164,9 +1235,172 @@ def uninstall_plugin(slug: str): if not plugin_dir.exists(): return jsonify({"error": f"Plugin '{slug}' not found"}), 404 + # --- B3: safe_uninstall enforcement --- + # Load the installed manifest to check if safe_uninstall capability is declared. + _force_uninstall = os.environ.get("EVONEXUS_ALLOW_FORCE_UNINSTALL", "").strip() == "1" + _manifest_for_b3: dict = {} + _safe_uninstall_spec: dict = {} + try: + _manifest_conn = _get_db() + _manifest_row = _manifest_conn.execute( + "SELECT manifest_json FROM plugins_installed WHERE slug = ?", (slug,) + ).fetchone() + _manifest_conn.close() + if _manifest_row: + _manifest_for_b3 = json.loads(_manifest_row["manifest_json"] or "{}") + _safe_uninstall_spec = _manifest_for_b3.get("safe_uninstall") or {} + except Exception as _exc: + logger.warning("B3: could not load manifest for safe_uninstall check: %s", _exc) + + _su_enabled = _safe_uninstall_spec.get("enabled", False) + _block_uninstall = _safe_uninstall_spec.get("block_uninstall", False) + + if _block_uninstall and not _force_uninstall: + return jsonify({ + "error": "uninstall_blocked", + "detail": _safe_uninstall_spec.get("reason", "Plugin has declared block_uninstall: true."), + "code": "blocked", + }), 409 + + if _su_enabled and not _force_uninstall: + # Vault B3.S1: backend enforcement — require admin + confirmation_phrase + exported_at + if not hasattr(current_user, "role") or getattr(current_user, "role", None) != "admin": + return jsonify({ + "error": "admin_required", + "detail": "Only admin users may uninstall plugins with safe_uninstall enabled.", + "code": "forbidden", + }), 403 + + _body = request.get_json(force=True, silent=True) or {} + _phrase_required = (_safe_uninstall_spec.get("user_confirmation") or {}).get("typed_phrase", "") + _phrase_given = _body.get("confirmation_phrase", "") + if _phrase_required and _phrase_given != _phrase_required: + return jsonify({ + "error": "confirmation_phrase_mismatch", + "detail": f"Typed phrase must be exactly: {_phrase_required}", + "code": "bad_request", + }), 400 + + _exported_at = _body.get("exported_at", "") + if _exported_at: + if not os.path.exists(_exported_at): + return jsonify({ + "error": "export_file_not_found", + "detail": f"Export file not found at path: {_exported_at}", + "code": "bad_request", + }), 400 + + # Vault B3.S1: zip_password must be present (the actual encryption happens in the pre-hook) + _zip_password = _body.get("zip_password", "") + if not _zip_password: + return jsonify({ + "error": "zip_password_required", + "detail": "A ZIP password is required to encrypt the export archive.", + "code": "bad_request", + }), 400 + + if _force_uninstall: + # Vault B3.S6: force-uninstall MUST produce an audit row with reason + _force_reason = (request.get_json(force=True, silent=True) or {}).get("force_reason", "") + logger.warning( + "FORCE UNINSTALL activated for '%s' (EVONEXUS_ALLOW_FORCE_UNINSTALL=1). reason=%r user=%s", + slug, _force_reason, getattr(current_user, "username", "unknown"), + ) + # --- End B3 enforcement gate --- + conn = _get_db() + _orphan_records: list[str] = [] # B3: populated during orphan table rename phase try: - # Pre-uninstall hook + # B3: Sandboxed pre-uninstall hook (Vault B3.S2) + # Run BEFORE the legacy hook so it has access to DB state. + _su_hook_spec = _safe_uninstall_spec.get("pre_uninstall_hook") or {} + if _su_enabled and not _force_uninstall and _su_hook_spec: + _hook_script = _su_hook_spec.get("script", "") + _hook_output_dir_template = _su_hook_spec.get("output_dir", "") + _hook_timeout = _su_hook_spec.get("timeout_seconds", 600) + _must_produce = _su_hook_spec.get("must_produce_file", True) + _hook_script_path = plugin_dir / _hook_script + + if _hook_script_path.exists(): + _ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + _output_dir_str = _hook_output_dir_template.format(slug=slug, timestamp=_ts) + _output_dir_path = (WORKSPACE / _output_dir_str).resolve() + _output_dir_path.mkdir(parents=True, exist_ok=True) + + # Create a read-only copy of the DB for the hook (Vault B3.S2) + _db_readonly_path = "" + try: + _tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + _tmp_db.close() + _tmp_db_path = _tmp_db.name + _src_conn = sqlite3.connect(str(DB_PATH)) + _bk_conn = sqlite3.connect(_tmp_db_path) + _src_conn.backup(_bk_conn) + _src_conn.close() + _bk_conn.close() + _db_readonly_path = _tmp_db_path + except Exception as _dbe: + logger.warning("B3: could not create DB snapshot for hook: %s", _dbe) + + # Vault B3.S2: locked-down env — NO BRAIN_REPO_MASTER_KEY + _hook_env = { + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "PLUGIN_SLUG": slug, + "PLUGIN_VERSION": _manifest_for_b3.get("version", ""), + "OUTPUT_DIR": str(_output_dir_path), + "DB_READONLY_PATH": _db_readonly_path, + } + + try: + _proc = subprocess.run( + ["python3", str(_hook_script_path)], + cwd=str(plugin_dir), + env=_hook_env, + capture_output=True, + text=True, + timeout=_hook_timeout, + ) + _hook_stdout = _proc.stdout[:5000] + _hook_stderr = _proc.stderr[:5000] + _hook_exit = _proc.returncode + + _audit(conn, slug, "safe_uninstall_hook", { + "exit_code": _hook_exit, + "stdout": _hook_stdout, + "stderr": _hook_stderr, + "output_dir": str(_output_dir_path), + }) + + if _hook_exit != 0: + return jsonify({ + "error": "pre_hook_failed", + "detail": "Pre-uninstall hook failed — uninstall aborted to prevent data loss.", + "exit_code": _hook_exit, + "stderr": _hook_stderr, + }), 400 + + if _must_produce: + _produced = any(_output_dir_path.iterdir()) if _output_dir_path.exists() else False + if not _produced: + return jsonify({ + "error": "pre_hook_no_output", + "detail": "Pre-uninstall hook produced no files — uninstall aborted to prevent data loss.", + }), 400 + + except subprocess.TimeoutExpired: + return jsonify({ + "error": "pre_hook_timeout", + "detail": f"Pre-uninstall hook exceeded timeout of {_hook_timeout}s.", + }), 400 + finally: + # Clean up DB snapshot + if _db_readonly_path: + try: + os.unlink(_db_readonly_path) + except Exception: + pass + + # Legacy pre-uninstall hook (non-B3 path) pre_hook = plugin_dir / "hooks" / "pre-uninstall.sh" if pre_hook.exists(): try: @@ -1219,14 +1453,75 @@ def uninstall_plugin(slug: str): # Delete host rows this plugin seeded (goals/tasks/triggers capabilities). # DELETE WHERE source_plugin = ? leaves user-created rows untouched. # Order matters because of FKs: children → parents. + # B3: respect preserved_host_entities filters from safe_uninstall spec. + _preserved_host_entities = _safe_uninstall_spec.get("preserved_host_entities") or {} for _tbl in ("triggers", "tickets", "goal_tasks", "goals", "projects", "missions"): try: - conn.execute(f"DELETE FROM {_tbl} WHERE source_plugin = ?", (slug,)) + _where = "source_plugin = ?" + if _tbl in _preserved_host_entities and not _force_uninstall: + # Preserve rows matching the declared WHERE clause. + # Only the base condition (source_plugin = ?) is parameterized; + # the preservation clause comes from the manifest (validated at install). + _preserve_clause = _preserved_host_entities[_tbl] + _where = f"(source_plugin = ?) AND NOT ({_preserve_clause})" + conn.execute(f"DELETE FROM {_tbl} WHERE {_where}", (slug,)) conn.commit() except Exception as exc: logger.warning("Uninstall: failed to clean %s: %s", _tbl, exc) - # SQL uninstall + # B3: Rename preserved tables to _orphan_{slug}_{tablename} BEFORE SQL uninstall. + # This removes them from the plugin namespace (Vault B3.S4) and records them + # in plugin_orphans so reinstall can detect and recover them. + _preserved_tables = _safe_uninstall_spec.get("preserved_tables") or [] + if _preserved_tables and _su_enabled and not _force_uninstall: + _orphan_conn = sqlite3.connect(str(DB_PATH)) + try: + _existing_tables_set = { + row[0] for row in _orphan_conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + _user_id = getattr(current_user, "id", None) + _plugin_version = _manifest_for_b3.get("version", "") + _plugin_sha256 = _manifest_for_b3.get("source_sha256", "") + _plugin_publisher_url = _manifest_for_b3.get("source_url", "") + + for _orig_table in _preserved_tables: + if _orig_table not in _existing_tables_set: + logger.info("B3: preserved table '%s' does not exist, skipping", _orig_table) + continue + _orphan_name = f"_orphan_{slug}_{_orig_table}" + try: + # Rename to orphan name + _orphan_conn.execute(f"ALTER TABLE {_orig_table} RENAME TO {_orphan_name}") + _orphan_conn.commit() + logger.info("B3: renamed '%s' to '%s'", _orig_table, _orphan_name) + + # Record in plugin_orphans + _orphan_conn.execute( + "INSERT OR REPLACE INTO plugin_orphans " + "(id, slug, tablename, orphaned_at, orphaned_by_user_id, " + " original_plugin_version, original_sha256, original_publisher_url) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + str(uuid.uuid4()), + slug, + _orig_table, + _now_iso(), + _user_id, + _plugin_version, + _plugin_sha256, + _plugin_publisher_url, + ), + ) + _orphan_conn.commit() + _orphan_records.append(_orig_table) + except Exception as _te: + logger.warning("B3: failed to rename table '%s': %s", _orig_table, _te) + finally: + _orphan_conn.close() + + # SQL uninstall (runs after preserved tables are renamed — DROP won't touch them) uninstall_sql = plugin_dir / "migrations" / "uninstall.sql" if uninstall_sql.exists(): try: @@ -1294,10 +1589,15 @@ def uninstall_plugin(slug: str): # Reload scheduler _reload_scheduler() - _audit(conn, slug, "uninstall", { + _audit_action = "plugin_uninstall_safe" if (_su_enabled and not _force_uninstall) else "uninstall" + if _force_uninstall: + _audit_action = "plugin_uninstall_force" + _audit(conn, slug, _audit_action, { "removed_env_keys": _removed_env_keys, "removed_health_cache_count": _health_cache_removed, "mcp_audit": _mcp_audit, + "preserved_tables": _orphan_records, + "force_uninstall": _force_uninstall, }, success=True) invalidate_agent_meta_cache() return jsonify({ @@ -1305,6 +1605,7 @@ def uninstall_plugin(slug: str): "status": "uninstalled", "mcp_audit": _mcp_audit, "removed_env_keys": _removed_env_keys, + "preserved_tables": _orphan_records, }) except Exception as exc: @@ -1820,10 +2121,17 @@ def readonly_data(slug: str, query_name: str): if not sql: return jsonify({"error": "Invalid query declaration"}), 500 - # Build query params from request.args — only declared params allowed + # Build query params from request.args — only declared params allowed. + # Wave 2.1.x reserved params (current_user_id, current_user_role) are + # injected server-side below and MUST NOT come from the client. + _RESERVED_PARAMS = {"current_user_id", "current_user_role"} declared_params = query_decl.get("params", {}) params: dict = {} for key, value in request.args.items(): + if key in _RESERVED_PARAMS: + return jsonify({ + "error": f"Parameter '{key}' is reserved and cannot be supplied by the client" + }), 400 if key not in declared_params: return jsonify({"error": f"Parameter '{key}' not declared in manifest"}), 400 params[key] = value @@ -1845,6 +2153,15 @@ def readonly_data(slug: str, query_name: str): elif ":limit" in sql: params["limit"] = 1000 + # Wave 2.1.x — auto-inject current_user identity bind params (Gap 5 fix + # from evonexus-plugin-nutri Step 3). Plugins reference these as + # :current_user_id and :current_user_role in their SQL to enforce + # server-side scoping (e.g. `WHERE primary_nutritionist_id = :current_user_id`). + # These keys are reserved — manifest params with the same name are + # silently overridden. Always present, regardless of declaration. + params["current_user_id"] = getattr(current_user, "id", None) + params["current_user_role"] = getattr(current_user, "role", "viewer") + try: conn = _get_db() cur = conn.execute(sql, params) @@ -1926,6 +2243,19 @@ def writable_data(slug: str, resource_id: str): ) return jsonify({"error": "Internal manifest error"}), 500 + # Wave 2.1.x — endpoint-level RBAC enforcement (Gap 1 fix from + # evonexus-plugin-nutri Step 3 RBAC decision). When requires_role is set + # in the manifest, only users whose role is in the list may mutate. + # 'admin' always passes (super-user override). + requires_role = resource_decl.get("requires_role") + if requires_role: + actor_role = getattr(current_user, "role", "viewer") + if actor_role != "admin" and actor_role not in requires_role: + return jsonify({ + "error": f"Resource '{resource_id}' requires role in {requires_role}, " + f"current role is '{actor_role}'" + }), 403 + allowed_columns: list[str] = resource_decl.get("allowed_columns") or [] method = request.method diff --git a/dashboard/backend/routes/providers.py b/dashboard/backend/routes/providers.py index a2127ef8..c7b03978 100644 --- a/dashboard/backend/routes/providers.py +++ b/dashboard/backend/routes/providers.py @@ -49,6 +49,7 @@ "CODEX_API_KEY", "GEMINI_API_KEY", "GEMINI_MODEL", + "NVIDIA_API_KEY", "AWS_REGION", "AWS_BEARER_TOKEN_BEDROCK", "ANTHROPIC_VERTEX_PROJECT_ID", @@ -317,6 +318,15 @@ def update_provider_config(provider_id): # Reject values with shell metacharacters if not isinstance(value, str) or re.search(r'[;&|`$\n\r]', value): continue + # Never wipe a stored secret with an empty field — the UI submits + # blank password inputs when the user edits other fields (e.g. model), + # which would silently erase the saved key. + if ( + value == "" + and existing.get(key) + and any(tag in key for tag in ("KEY", "SECRET", "TOKEN")) + ): + continue existing[key] = value provider["env_vars"] = existing diff --git a/dashboard/backend/routes/routines.py b/dashboard/backend/routes/routines.py index 0a535d95..0cc86ac9 100644 --- a/dashboard/backend/routes/routines.py +++ b/dashboard/backend/routes/routines.py @@ -124,6 +124,8 @@ def list_adws(): "flux-2": {"per_image": 0.03, "input_per_1m": 0, "output_per_1m": 0}, "flux.2": {"per_image": 0.03, "input_per_1m": 0, "output_per_1m": 0}, "gpt-5-image": {"per_image": 0.04, "input_per_1m": 0.005, "output_per_1m": 0.015}, + # OpenAI Images API — billed per token (text input / image output rates). + "gpt-image-2": {"per_image": 0.0, "input_per_1m": 5.0, "output_per_1m": 40.0}, "seedream": {"per_image": 0.02, "input_per_1m": 0, "output_per_1m": 0}, "riverflow": {"per_image": 0.02, "input_per_1m": 0, "output_per_1m": 0}, } @@ -147,7 +149,13 @@ def _estimate_image_cost(entry: dict) -> float: return 0.03 cost = pricing["per_image"] - if total_tokens > 0 and pricing["input_per_1m"] > 0: + prompt_tokens = tokens.get("prompt_tokens", 0) + completion_tokens = tokens.get("completion_tokens", 0) + if prompt_tokens and completion_tokens and pricing.get("output_per_1m", 0) > 0: + # Split billing: text input vs image output rates (OpenAI Images API) + cost += (prompt_tokens / 1_000_000) * pricing["input_per_1m"] + cost += (completion_tokens / 1_000_000) * pricing["output_per_1m"] + elif total_tokens > 0 and pricing["input_per_1m"] > 0: cost += (total_tokens / 1_000_000) * pricing["input_per_1m"] return round(cost, 6) diff --git a/dashboard/backend/routes/shares.py b/dashboard/backend/routes/shares.py index 458b6550..901ebb07 100644 --- a/dashboard/backend/routes/shares.py +++ b/dashboard/backend/routes/shares.py @@ -5,10 +5,11 @@ from datetime import datetime, timezone, timedelta from pathlib import Path -from flask import Blueprint, jsonify, request, Response +from flask import Blueprint, jsonify, request, Response, after_this_request from flask_login import login_required, current_user from models import db, FileShare, audit, has_workspace_folder_access +from rate_limit import limiter from routes.auth_routes import require_permission bp = Blueprint("shares", __name__) @@ -184,6 +185,7 @@ def revoke_share(token: str): # ── Public endpoint (no auth required) ────────────────────────────────────── @bp.route("/api/shares//view", methods=["GET"]) +@limiter.limit("60 per minute") def view_share(token: str): """Serve the file content for a valid share token. No authentication required.""" share = FileShare.query.filter_by(token=token).first() @@ -214,6 +216,16 @@ def view_share(token: str): ua = (request.headers.get("User-Agent", "-") or "-")[:200] audit(None, "share_view", "shares", detail=f"token={token} ip={ip} ua={ua[:80]}") + # Vault §2.S2: security headers on all public share responses. + @after_this_request + def _add_security_headers(response): + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Cache-Control"] = "no-store, private, no-cache, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains" + return response + suffix = full.suffix.lower() # HTML/HTM: serve raw so browser renders it as a full page diff --git a/dashboard/frontend/src/components/AgentChat.tsx b/dashboard/frontend/src/components/AgentChat.tsx index b60ff664..a6e24666 100644 --- a/dashboard/frontend/src/components/AgentChat.tsx +++ b/dashboard/frontend/src/components/AgentChat.tsx @@ -137,28 +137,34 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e setStatus('connecting') setErrorMsg(null) let cancelled = false - let ws: WebSocket | null = null + let reconnectTimer: ReturnType | null = null + let reconnectAttempts = 0 + + // The server keeps the session alive when the socket drops — rejoining + // restores chat history, so a dead WS only needs a reconnect with + // capped exponential backoff instead of staying dead until remount. + function scheduleReconnect() { + if (cancelled || reconnectTimer) return + const delay = Math.min(1000 * 2 ** reconnectAttempts, 15000) + reconnectAttempts++ + setStatus('connecting') + reconnectTimer = setTimeout(() => { + reconnectTimer = null + connect() + }, delay) + } - ;(async () => { - // 1) HTTP preflight — fails fast on ECONNREFUSED so we can show a real error - // instead of hanging in 'connecting' forever (same pattern as AgentTerminal). - try { - const res = await fetch(`${TS_HTTP}/api/health`) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - } catch { - if (cancelled) return - setStatus('error') - setErrorMsg(`Could not reach terminal-server at ${TS_HTTP}. Is it running?`) - return - } + function connect() { if (cancelled) return - // 2) Open WS - ws = new WebSocket(`${TS_WS}/ws`) + // Open WS + const ws = new WebSocket(`${TS_WS}/ws`) wsRef.current = ws ws.onopen = () => { - ws!.send(JSON.stringify({ type: 'join_session', sessionId })) + reconnectAttempts = 0 + setErrorMsg(null) + ws.send(JSON.stringify({ type: 'join_session', sessionId })) setStatus('idle') } @@ -273,26 +279,60 @@ export default function AgentChat({ agent, sessionId, accentColor = '#00FFA7', e } ws.onerror = () => { - if (cancelled) return - setStatus('error') - setErrorMsg('WebSocket error') + // onclose always follows onerror — reconnect is handled there. } ws.onclose = () => { if (pingRef.current) { clearInterval(pingRef.current); pingRef.current = null } + if (cancelled) return + scheduleReconnect() } pingRef.current = setInterval(() => { - if (ws!.readyState === WebSocket.OPEN) { - ws!.send(JSON.stringify({ type: 'ping' })) + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) } }, 25000) + } + + ;(async () => { + // HTTP preflight — fails fast on ECONNREFUSED so we can show a real error + // instead of hanging in 'connecting' forever (same pattern as AgentTerminal). + try { + const res = await fetch(`${TS_HTTP}/api/health`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + } catch { + if (cancelled) return + setStatus('error') + setErrorMsg(`Could not reach terminal-server at ${TS_HTTP}. Is it running?`) + return + } + if (cancelled) return + connect() })() + // Returning to the tab reconnects immediately instead of waiting out + // the backoff (browsers also throttle timers in hidden tabs, so the + // pending reconnect may not have fired while the tab was away). + const onVisible = () => { + if (document.hidden || cancelled) return + const ws = wsRef.current + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + reconnectAttempts = 0 + connect() + } + document.addEventListener('visibilitychange', onVisible) + return () => { cancelled = true + document.removeEventListener('visibilitychange', onVisible) + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null } if (pingRef.current) { clearInterval(pingRef.current); pingRef.current = null } - try { ws?.close() } catch {} + try { wsRef.current?.close() } catch {} wsRef.current = null } }, [sessionId]) diff --git a/dashboard/frontend/src/components/AgentTerminal.tsx b/dashboard/frontend/src/components/AgentTerminal.tsx index ef438a0a..56957dce 100644 --- a/dashboard/frontend/src/components/AgentTerminal.tsx +++ b/dashboard/frontend/src/components/AgentTerminal.tsx @@ -184,6 +184,24 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor const term = termRef.current if (!term) return + let reconnectTimer: ReturnType | null = null + let reconnectAttempts = 0 + let alreadyActive = false + + // The server keeps the pty alive when the socket drops, so a dead WS + // only needs a rejoin — reconnect with capped exponential backoff + // instead of leaving the terminal dead until the component remounts. + function scheduleReconnect(sessionId: string) { + if (cancelled || reconnectTimer) return + const delay = Math.min(1000 * 2 ** reconnectAttempts, 15000) + reconnectAttempts++ + setStatus('connecting') + reconnectTimer = setTimeout(() => { + reconnectTimer = null + connect(sessionId, true) + }, delay) + } + async function run() { setStatus('connecting') setErrorMsg(null) @@ -191,7 +209,6 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor // 1) Use provided sessionId or find-or-create for this agent let sessionId: string - let alreadyActive = false try { if (externalSessionId) { // Use the specific session provided by the parent (multi-tab mode) @@ -223,11 +240,18 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor if (cancelled) return sessionIdRef.current = sessionId + connect(sessionId, false) + } + + function connect(sessionId: string, isReconnect: boolean) { + if (cancelled) return + // 2) Open WS const ws = new WebSocket(`${CC_WEB_WS}/ws`) wsRef.current = ws ws.onopen = () => { + setErrorMsg(null) ws.send(JSON.stringify({ type: 'join_session', sessionId })) } @@ -238,12 +262,18 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor switch (msg.type) { case 'session_joined': { + reconnectAttempts = 0 + // On reconnect the server replays the whole buffer — clear + // first so it doesn't duplicate what's already on screen. + if (isReconnect) term!.clear() // Replay any buffered output if (Array.isArray(msg.outputBuffer)) { msg.outputBuffer.forEach((chunk: string) => term!.write(chunk)) } - // If an agent is already running in this session, just attach - if (msg.active || alreadyActive) { + // If an agent is already running in this session, just attach. + // alreadyActive is only trustworthy on the first join — after a + // reconnect the process may have died while we were away. + if (msg.active || (!isReconnect && alreadyActive)) { setStatus('running') // Nudge a resize so the pty matches the current terminal size const fit = fitRef.current @@ -251,6 +281,11 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor try { fit.fit() } catch {} ws.send(JSON.stringify({ type: 'resize', cols: term!.cols, rows: term!.rows })) } + } else if (isReconnect) { + // Process ended while we were disconnected — surface it + // instead of silently restarting the agent. + setStatus('exited') + term!.write('\r\n\x1b[33m[Reconnected — process is no longer running]\x1b[0m\r\n') } else { // Start Claude with --agent // Pass cols/rows up-front so the pty is born at the right @@ -304,9 +339,7 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor } ws.onerror = () => { - if (cancelled) return - setStatus('error') - setErrorMsg('WebSocket error') + // onclose always follows onerror — reconnect is handled there. } ws.onclose = () => { @@ -314,6 +347,8 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor clearInterval(pingRef.current) pingRef.current = null } + if (cancelled) return + scheduleReconnect(sessionId) } // Keepalive @@ -324,10 +359,33 @@ export default function AgentTerminal({ agent, sessionId: externalSessionId, wor }, 25000) } + // Returning to the tab reconnects immediately instead of waiting out + // the backoff (browsers also throttle timers in hidden tabs, so the + // pending reconnect may not have fired while the tab was away). + const onVisible = () => { + if (document.hidden || cancelled) return + const sessionId = sessionIdRef.current + if (!sessionId) return + const ws = wsRef.current + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + reconnectAttempts = 0 + connect(sessionId, true) + } + document.addEventListener('visibilitychange', onVisible) + run() return () => { cancelled = true + document.removeEventListener('visibilitychange', onVisible) + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } if (pingRef.current) { clearInterval(pingRef.current) pingRef.current = null diff --git a/dashboard/frontend/src/components/PluginInstallModal.tsx b/dashboard/frontend/src/components/PluginInstallModal.tsx index 834e3708..ccc32703 100644 --- a/dashboard/frontend/src/components/PluginInstallModal.tsx +++ b/dashboard/frontend/src/components/PluginInstallModal.tsx @@ -7,7 +7,10 @@ import SecurityScanSection, { type ScanVerdict, type ScanResult } from './Securi interface PreviewResult { manifest: Record warnings: string[] - conflicts?: Record + // Backend returns conflicts as a list of human-readable strings (not a dict). + // See plugin_loader.PluginInstaller.preview() — each blocker is a string + // appended to result["conflicts"]. + conflicts?: string[] } interface Props { @@ -140,7 +143,9 @@ export default function PluginInstallModal({ onClose, onInstalled }: Props) { const manifest = preview?.manifest ?? {} const warnings = preview?.warnings ?? [] - const conflicts = preview?.conflicts ? Object.keys(preview.conflicts) : [] + const conflicts: string[] = Array.isArray(preview?.conflicts) + ? (preview!.conflicts as string[]).filter((c): c is string => typeof c === 'string' && c.length > 0) + : [] // Install button is amber for WARN, normal green otherwise const installBtnClass = @@ -338,7 +343,11 @@ export default function PluginInstallModal({ onClose, onInstalled }: Props) {

{t('plugins.conflicts')}

-

{conflicts.join(', ')}

+
    + {conflicts.map((c, i) => ( +
  • {c}
  • + ))} +
)} diff --git a/dashboard/frontend/src/components/PluginUninstall.tsx b/dashboard/frontend/src/components/PluginUninstall.tsx new file mode 100644 index 00000000..181a1f61 --- /dev/null +++ b/dashboard/frontend/src/components/PluginUninstall.tsx @@ -0,0 +1,320 @@ +/** + * PluginUninstall — B3 safe_uninstall 3-step wizard. + * + * Shown instead of window.confirm() when the plugin manifest declares + * safe_uninstall.enabled: true. + * + * Step 1 — Regulatory reason + "I accept responsibility" checkbox. + * Step 2 — ZIP password input (Vault B3.S5: AES-256 export encryption). + * Step 3 — Typed confirmation phrase + Uninstall button. + * + * For plugins without safe_uninstall (or safe_uninstall.enabled: false), + * render nothing — the caller falls back to the legacy window.confirm() path. + * + * Force-uninstall banner: if EVONEXUS_ALLOW_FORCE_UNINSTALL=1 is detected + * in the API response, a persistent orange alert is shown. + */ + +import { useState } from 'react' +import { AlertTriangle, Lock, Shield, Trash2, X } from 'lucide-react' +import { api } from '../lib/api' + +export interface SafeUninstallSpec { + enabled?: boolean + block_uninstall?: boolean + reason?: string + user_confirmation?: { + checkbox_label?: string + typed_phrase?: string + } + pre_uninstall_hook?: { + script?: string + output_dir?: string + timeout_seconds?: number + must_produce_file?: boolean + } + preserved_tables?: string[] +} + +interface Props { + slug: string + safeUninstall: SafeUninstallSpec + forceUninstallActive?: boolean + onClose: () => void + onUninstalled: () => void +} + +type Step = 1 | 2 | 3 + +export default function PluginUninstall({ + slug, + safeUninstall, + forceUninstallActive = false, + onClose, + onUninstalled, +}: Props) { + const [step, setStep] = useState(1) + const [checkboxChecked, setCheckboxChecked] = useState(false) + const [zipPassword, setZipPassword] = useState('') + const [zipPasswordConfirm, setZipPasswordConfirm] = useState('') + const [typedPhrase, setTypedPhrase] = useState('') + const [uninstalling, setUninstalling] = useState(false) + const [error, setError] = useState(null) + + const requiredPhrase = safeUninstall?.user_confirmation?.typed_phrase ?? '' + const checkboxLabel = + safeUninstall?.user_confirmation?.checkbox_label ?? + 'Tenho uma cópia dos dados exportados e assumo responsabilidade pela retenção legal.' + const reason = safeUninstall?.reason ?? '' + const preservedTables = safeUninstall?.preserved_tables ?? [] + + const phraseMatches = typedPhrase === requiredPhrase + const passwordsMatch = zipPassword === zipPasswordConfirm && zipPassword.length >= 8 + + async function handleUninstall() { + setUninstalling(true) + setError(null) + try { + const body: Record = { + confirmation_phrase: typedPhrase, + zip_password: zipPassword, + } + await api.delete(`/plugins/${slug}`, body) + onUninstalled() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Unexpected error during uninstall.') + setUninstalling(false) + } + } + + return ( +
+
+ {/* Header */} +
+
+ + Desinstalar plugin: {slug} +
+ +
+ + {/* Force-uninstall alert */} + {forceUninstallActive && ( +
+ ⚠ Force uninstall ATIVO — todas proteções desabilitadas +

+ EVONEXUS_ALLOW_FORCE_UNINSTALL=1 está definido. Esta ação ignora a confirmação e + preservação de dados. Todas as ações são auditadas. +

+
+ )} + +
+ {/* Step indicator */} +
+ {([1, 2, 3] as Step[]).map((s) => ( + s + ? 'bg-green-700 text-white' + : 'bg-neutral-700 text-neutral-400' + }`} + > + {s} + + ))} +
+ + {/* ── Step 1: Reason + checkbox ── */} + {step === 1 && ( +
+
+ +
+

Aviso regulatório

+

{reason}

+
+
+ + {preservedTables.length > 0 && ( +
+

+ Tabelas preservadas (renomeadas, não excluídas): +

+
    + {preservedTables.map((t) => ( +
  • + {t} → _orphan_{slug}_{t} +
  • + ))} +
+
+ )} + + + +
+ + +
+
+ )} + + {/* ── Step 2: ZIP password ── */} + {step === 2 && ( +
+
+ +
+

Senha do export (AES-256)

+

+ O arquivo de export será criptografado com esta senha. Anote em local seguro — + sem ela, o arquivo é inutilizável. +

+
+
+ +
+
+ + setZipPassword(e.target.value)} + placeholder="Senha do ZIP de export" + className="w-full rounded border border-neutral-700 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-[#00FFA7] focus:outline-none" + /> +
+
+ + setZipPasswordConfirm(e.target.value)} + placeholder="Repita a senha" + className="w-full rounded border border-neutral-700 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-[#00FFA7] focus:outline-none" + /> + {zipPassword && zipPasswordConfirm && !passwordsMatch && ( +

+ {zipPassword.length < 8 + ? 'Senha deve ter pelo menos 8 caracteres.' + : 'Senhas não coincidem.'} +

+ )} +
+
+ +
+ + +
+
+ )} + + {/* ── Step 3: Typed phrase confirmation ── */} + {step === 3 && ( +
+
+ +
+

+ Digite exatamente a frase abaixo para confirmar a desinstalação: +

+

+ {requiredPhrase} +

+
+
+ + setTypedPhrase(e.target.value)} + placeholder={requiredPhrase} + className="w-full rounded border border-neutral-700 bg-neutral-800 px-3 py-2 text-sm text-white placeholder-neutral-500 focus:border-[#00FFA7] focus:outline-none" + /> + {typedPhrase && !phraseMatches && ( +

+ Texto deve ser exatamente: {requiredPhrase} +

+ )} + + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+ )} +
+
+
+ ) +} diff --git a/dashboard/frontend/src/lib/api.ts b/dashboard/frontend/src/lib/api.ts index 213989d4..efc8c44a 100644 --- a/dashboard/frontend/src/lib/api.ts +++ b/dashboard/frontend/src/lib/api.ts @@ -15,7 +15,20 @@ async function buildError(res: Response): Promise { let detail = '' try { const data = await res.clone().json() - detail = data?.error || data?.description || data?.message || '' + // Try common error shapes first, then plugin-preview-shaped responses + // (`{conflicts: [...], manifest, ...}`). Without this, plugin install + // 409s surfaced as "409 CONFLICT" with no hint at the actual reason + // (e.g. version mismatch). + detail = + data?.error || + data?.description || + data?.message || + (Array.isArray(data?.conflicts) && data.conflicts.length > 0 + ? data.conflicts.join(' • ') + : '') || + (Array.isArray(data?.details) && data.details.length > 0 + ? data.details.join(' • ') + : '') } catch { try { const text = await res.text() @@ -73,11 +86,12 @@ export const api = { if (!res.ok) throw await buildError(res); return res.json(); }, - delete: async (path: string) => { + delete: async (path: string, body?: unknown) => { const res = await fetch(`${API}/api${path}`, { method: 'DELETE', - headers: { ...XHR_HEADER }, + headers: { 'Content-Type': 'application/json', ...XHR_HEADER }, credentials: 'include', + body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) throw await buildError(res); return res.json(); diff --git a/dashboard/frontend/src/pages/Costs.tsx b/dashboard/frontend/src/pages/Costs.tsx index 8687e939..944726e4 100644 --- a/dashboard/frontend/src/pages/Costs.tsx +++ b/dashboard/frontend/src/pages/Costs.tsx @@ -132,6 +132,38 @@ interface ImageCosts { totals: { count: number; total_tokens: number; total_seconds: number; total_bytes: number; total_cost_usd?: number } } +// Entries logged by older skill versions miss token_usage.total_tokens and +// other fields — unguarded access crashed the whole page render. +function normalizeImageCosts(raw: any): ImageCosts | null { + if (!raw || !Array.isArray(raw.entries)) return null + const entries: ImageCostEntry[] = raw.entries.map((e: any) => ({ + timestamp: typeof e?.timestamp === 'string' ? e.timestamp : '', + model: typeof e?.model === 'string' ? e.model : 'unknown', + provider: typeof e?.provider === 'string' ? e.provider : '', + mode: typeof e?.mode === 'string' ? e.mode : '', + output_file: typeof e?.output_file === 'string' ? e.output_file : '', + size_bytes: Number(e?.size_bytes || 0), + elapsed_seconds: Number(e?.elapsed_seconds || 0), + token_usage: { + prompt_tokens: Number(e?.token_usage?.prompt_tokens || 0), + completion_tokens: Number(e?.token_usage?.completion_tokens || 0), + total_tokens: Number(e?.token_usage?.total_tokens || 0), + }, + estimated_cost_usd: typeof e?.estimated_cost_usd === 'number' ? e.estimated_cost_usd : undefined, + })) + const t = raw.totals || {} + return { + entries, + totals: { + count: Number(t.count ?? entries.length), + total_tokens: Number(t.total_tokens || 0), + total_seconds: Number(t.total_seconds || 0), + total_bytes: Number(t.total_bytes || 0), + total_cost_usd: typeof t.total_cost_usd === 'number' ? t.total_cost_usd : undefined, + }, + } +} + function relativeTime(ts: string): string { try { const diff = Date.now() - new Date(ts).getTime() @@ -162,7 +194,7 @@ export default function Costs() { api.get('/routines/image-costs').catch(() => null), ]).then(([costRaw, imgRaw]) => { if (costRaw) setData(normalizeCostData(costRaw)) - if (imgRaw) setImageCosts(imgRaw) + if (imgRaw) setImageCosts(normalizeImageCosts(imgRaw)) }).finally(() => setLoading(false)) }, []) diff --git a/dashboard/frontend/src/pages/PluginDetail.tsx b/dashboard/frontend/src/pages/PluginDetail.tsx index 01b3b993..efeb8d59 100644 --- a/dashboard/frontend/src/pages/PluginDetail.tsx +++ b/dashboard/frontend/src/pages/PluginDetail.tsx @@ -9,6 +9,7 @@ import { import { api } from '../lib/api' import type { Plugin } from '../components/PluginCard' import UpdatePreviewModal from '../components/UpdatePreviewModal' +import PluginUninstall, { type SafeUninstallSpec } from '../components/PluginUninstall' interface HealthResult { slug: string @@ -147,6 +148,9 @@ export default function PluginDetail() { // Wave 2.0 — Icon fallback state const [iconError, setIconError] = useState(false) + // B3 — Safe uninstall wizard state + const [showUninstallWizard, setShowUninstallWizard] = useState(false) + // Wave 2.3 — MCP restart banner dismiss (persisted via localStorage) const mcpBannerKey = `mcp-restart-dismissed-${slug}` const [mcpBannerDismissed, setMcpBannerDismissed] = useState( @@ -191,16 +195,24 @@ export default function PluginDetail() { } } - async function handleUninstall() { - if (!slug || !window.confirm(t('plugins.confirmUninstall'))) return - setRemoving(true) - try { - await api.delete(`/plugins/${slug}`) - navigate('/plugins') - } catch (e: unknown) { - setError(e instanceof Error ? e.message : t('common.unexpectedError')) - setRemoving(false) + function handleUninstall() { + if (!slug) return + // B3: If plugin declares safe_uninstall.enabled, open the wizard instead of window.confirm. + const manifest = (plugin as unknown as Record | null)?.manifest_json as Record | undefined + const safeUninstall = (manifest?.safe_uninstall ?? {}) as SafeUninstallSpec + if (safeUninstall?.enabled) { + setShowUninstallWizard(true) + return } + // Legacy path: simple confirm dialog + if (!window.confirm(t('plugins.confirmUninstall'))) return + setRemoving(true) + api.delete(`/plugins/${slug}`) + .then(() => navigate('/plugins')) + .catch((e: unknown) => { + setError(e instanceof Error ? e.message : t('common.unexpectedError')) + setRemoving(false) + }) } async function handleToggle() { @@ -425,7 +437,21 @@ export default function PluginDetail() { mcpItems.length > 0 || integrationItems.length > 0 + // B3: Extract safe_uninstall spec from manifest for the wizard + const _manifest = (plugin as unknown as Record | null)?.manifest_json as Record | undefined + const _safeUninstallSpec = (_manifest?.safe_uninstall ?? {}) as SafeUninstallSpec + return ( + <> + {/* B3: Safe uninstall wizard overlay */} + {showUninstallWizard && slug && ( + setShowUninstallWizard(false)} + onUninstalled={() => navigate('/plugins')} + /> + )}
{/* Back */}
+ ) } diff --git a/dashboard/terminal-server/src/chat-bridge.js b/dashboard/terminal-server/src/chat-bridge.js index caf138b1..feb50e56 100644 --- a/dashboard/terminal-server/src/chat-bridge.js +++ b/dashboard/terminal-server/src/chat-bridge.js @@ -9,6 +9,7 @@ const os = require('os'); const { loadProviderConfig, resolveProviderModel, + getProviderMode, } = require('./provider-config'); let sdkModule = null; @@ -157,6 +158,90 @@ function resolveClaudeExecutable() { return _claudeExecutablePath; } +/** + * Resolve the CLI binary for external (non-Anthropic) providers. + * Mirrors ClaudeBridge.findClaudeCommand: shell `which` first, then hardcoded + * paths. Returns null when nothing is found — the SDK needs a real path, so + * the caller falls back to the plain chat-completion session. + * Hardcoded dispatch per command to satisfy semgrep. + */ +const _cliBinaryCache = new Map(); +function resolveCliBinary(cliCommand) { + if (_cliBinaryCache.has(cliCommand)) return _cliBinaryCache.get(cliCommand); + + const { execSync } = require('child_process'); + try { + let resolved; + if (cliCommand === 'openclaude') { + resolved = execSync('which openclaude', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); + } else { + resolved = execSync('which claude', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); + } + if (resolved) { + console.log(`[chat-bridge] Found ${cliCommand} at: ${resolved}`); + _cliBinaryCache.set(cliCommand, resolved); + return resolved; + } + } catch { /* which failed — try hardcoded paths */ } + + const home = process.env.HOME || '/'; + const candidates = cliCommand === 'openclaude' + ? [ + path.join(home, '.local', 'bin', 'openclaude'), + '/usr/local/bin/openclaude', + '/usr/bin/openclaude', + ] + : [ + path.join(home, '.claude', 'local', 'claude'), + path.join(home, '.local', 'bin', 'claude'), + '/usr/local/bin/claude', + '/usr/bin/claude', + ]; + for (const p of candidates) { + try { + if (fs.existsSync(p)) { + console.log(`[chat-bridge] Found ${cliCommand} at hardcoded path: ${p}`); + _cliBinaryCache.set(cliCommand, p); + return p; + } + } catch { continue; } + } + + console.error(`[chat-bridge] ${cliCommand} not found anywhere`); + _cliBinaryCache.set(cliCommand, null); + return null; +} + +/** + * Build the environment for an external-provider CLI process. + * Same approach as ClaudeBridge: whitelist essential system vars (no full + * process.env spread — stale OPENAI_API_KEY would override Codex OAuth) and + * layer the provider's env_vars on top. The SDK passes this object as the + * child process env verbatim. + */ +const PROVIDER_SYSTEM_VARS = [ + 'HOME', 'USER', 'SHELL', 'PATH', 'LANG', 'LC_ALL', 'LC_CTYPE', + 'LOGNAME', 'HOSTNAME', 'XDG_RUNTIME_DIR', 'XDG_DATA_HOME', + 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'TMPDIR', + 'SSH_AUTH_SOCK', 'SSH_AGENT_PID', + 'NVM_DIR', 'NVM_BIN', 'NVM_INC', + 'CODEX_HOME', 'CLAUDE_CONFIG_DIR', +]; +function buildProviderEnv(providerConfig) { + const env = {}; + for (const key of PROVIDER_SYSTEM_VARS) { + if (process.env[key]) env[key] = process.env[key]; + } + const providerEnv = { ...(providerConfig.env_vars || {}) }; + // Same model defaults as ClaudeBridge — codexplan/codexspark route to the + // Codex backend; a raw gpt-5.x would silently bypass Codex OAuth. + if (!providerEnv.OPENAI_MODEL) { + if (providerConfig.active === 'codex_auth') providerEnv.OPENAI_MODEL = 'codexplan'; + else if (providerConfig.active === 'openai') providerEnv.OPENAI_MODEL = 'gpt-4.1'; + } + return { ...env, ...providerEnv }; +} + /** * Scan a tool_result text for a ticket-creation response. * Returns the ticket id if a POST /api/tickets response is detected, else null. @@ -241,6 +326,11 @@ function detectCreatedTicketId(text) { class ChatBridge { constructor() { this.sessions = new Map(); // sessionId -> { query, abortController, active, sdkSessionId } + // Rate limiting state for OpenAI-compatible providers + this._lastRequestTime = 0; + this._minIntervalMs = parseInt(process.env.CHAT_MIN_INTERVAL_MS || '2000', 10); + this._maxRetries = parseInt(process.env.CHAT_MAX_RETRIES || '3', 10); + this._baseDelayMs = parseInt(process.env.CHAT_BASE_DELAY_MS || '5000', 10); } _buildChatCompletionSystemPrompt(agentName, cwd, sessionId) { @@ -274,6 +364,53 @@ class ChatBridge { return ''; } + /** + * Enforce minimum interval between requests, then fetch with retry/backoff. + * Retries on 429 (rate limited) and 503 (upstream unavailable). + */ + async _rateLimitedFetch(url, options) { + // Enforce minimum interval between requests + const now = Date.now(); + const elapsed = now - this._lastRequestTime; + if (elapsed < this._minIntervalMs) { + const wait = this._minIntervalMs - elapsed; + console.log(`[chat-bridge] Rate limit: aguardando ${wait}ms antes do próximo request...`); + await new Promise((r) => setTimeout(r, wait)); + } + + let lastError = null; + for (let attempt = 0; attempt <= this._maxRetries; attempt++) { + this._lastRequestTime = Date.now(); + + const resp = await fetch(url, options); + + if (resp.ok) return resp; + + const isRetryable = resp.status === 429 || resp.status === 503; + if (!isRetryable || attempt === this._maxRetries) { + return resp; // let caller handle non-retryable errors or final failure + } + + // Exponential backoff: base * 2^attempt (5s, 10s, 20s) + const delay = this._baseDelayMs * Math.pow(2, attempt); + let retryAfter = delay; + const retryAfterHeader = resp.headers.get('Retry-After'); + if (retryAfterHeader) { + const parsed = parseInt(retryAfterHeader, 10); + if (!isNaN(parsed)) retryAfter = parsed * 1000; + } + + console.warn( + `[chat-bridge] ${resp.status} no request (tentativa ${attempt + 1}/${this._maxRetries + 1}). ` + + `Aguardando ${Math.round(retryAfter / 1000)}s antes de tentar novamente...` + ); + await new Promise((r) => setTimeout(r, retryAfter)); + } + + // Should not reach here, but return last error response + throw new Error(`_rateLimitedFetch: todas as ${this._maxRetries + 1} tentativas falharam`); + } + async _startOpenAICompatibleSession(sessionId, options, providerConfig) { const { agentName, @@ -316,7 +453,7 @@ class ChatBridge { (async () => { try { - const resp = await fetch(`${baseUrl}/chat/completions`, { + const resp = await this._rateLimitedFetch(`${baseUrl}/chat/completions`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, @@ -408,8 +545,20 @@ class ChatBridge { } = options; const providerConfig = loadProviderConfig(); - if (providerConfig.active !== 'anthropic') { - return this._startOpenAICompatibleSession(sessionId, options, providerConfig); + const isExternalProvider = !!providerConfig.active && providerConfig.active !== 'anthropic'; + let externalCliPath = null; + if (isExternalProvider) { + // Code-mode providers go through the Agent SDK with the openclaude + // binary — full tool calling, streaming, session resume. Only genuinely + // chat-completion-only models keep the plain REST path. + if (getProviderMode(providerConfig) !== 'code') { + return this._startOpenAICompatibleSession(sessionId, options, providerConfig); + } + externalCliPath = resolveCliBinary(providerConfig.cli_command || 'openclaude'); + if (!externalCliPath) { + console.warn(`[chat-bridge] ${providerConfig.cli_command} binary not found — falling back to chat completion. Install with: npm install -g @gitlawb/openclaude`); + return this._startOpenAICompatibleSession(sessionId, options, providerConfig); + } } const { query: sdkQuery } = await loadSDK(); @@ -426,8 +575,14 @@ class ChatBridge { abortController, }; - const claudeExe = resolveClaudeExecutable(); - if (claudeExe) queryOptions.pathToClaudeCodeExecutable = claudeExe; + if (isExternalProvider) { + queryOptions.pathToClaudeCodeExecutable = externalCliPath; + queryOptions.env = buildProviderEnv(providerConfig); + console.log(`[chat-bridge] External provider "${providerConfig.active}" via ${externalCliPath} (model: ${resolveProviderModel(providerConfig) || 'default'})`); + } else { + const claudeExe = resolveClaudeExecutable(); + if (claudeExe) queryOptions.pathToClaudeCodeExecutable = claudeExe; + } // Load agent definition from .claude/agents/{name}.md if (agentName) { @@ -458,13 +613,26 @@ class ChatBridge { if (systemPromptExtras && !sdkSessionId) { promptAppend = promptAppend + '\n\n' + systemPromptExtras; } - queryOptions.systemPrompt = { - type: 'preset', - preset: 'claude_code', - append: promptAppend, - }; - if (agentDef.model) queryOptions.model = agentDef.model; - console.log(`[chat-bridge] Loaded agent "${agentName}" via systemPrompt.append (${agentDef.prompt.length} chars, model: ${agentDef.model || 'inherit'})`); + if (isExternalProvider) { + // Mirror ClaudeBridge: --append-system-prompt is too weak for GPT + // models, so REPLACE the system prompt to force the agent persona. + // agentDef.model is a Claude model name — meaningless here; the + // model comes from the provider's OPENAI_MODEL env var. + queryOptions.systemPrompt = promptAppend + '\n\n' + + 'CRITICAL: You MUST fully embody this agent persona. ' + + 'You are NOT Claude, OpenClaude, or a generic assistant — you ARE ' + agentName + '. ' + + 'When asked who you are, ALWAYS respond as ' + agentName + '. ' + + 'Never break character. Follow ALL instructions above.'; + console.log(`[chat-bridge] Loaded agent "${agentName}" via systemPrompt replace (${agentDef.prompt.length} chars, external provider)`); + } else { + queryOptions.systemPrompt = { + type: 'preset', + preset: 'claude_code', + append: promptAppend, + }; + if (agentDef.model) queryOptions.model = agentDef.model; + console.log(`[chat-bridge] Loaded agent "${agentName}" via systemPrompt.append (${agentDef.prompt.length} chars, model: ${agentDef.model || 'inherit'})`); + } } else { console.warn(`[chat-bridge] Agent "${agentName}" not found, running without agent`); } diff --git a/dashboard/terminal-server/src/claude-bridge.js b/dashboard/terminal-server/src/claude-bridge.js index baa68ae4..af792a1e 100644 --- a/dashboard/terminal-server/src/claude-bridge.js +++ b/dashboard/terminal-server/src/claude-bridge.js @@ -1,6 +1,9 @@ const { spawn } = require('node-pty'); const path = require('path'); const fs = require('fs'); + +// Workspace root is three levels up from this file (dashboard/terminal-server/src/). +const WORKSPACE_ROOT = path.resolve(__dirname, '..', '..', '..'); const { loadProviderConfig, resolveProviderModel, @@ -71,6 +74,30 @@ class ClaudeBridge { return cliCommand; } + /** + * True when the CLI has a persisted conversation file for this session id + * in this workingDir — i.e. a previous run got far enough to save state, + * so `--resume ` will succeed. Checks the config dirs used by both + * claude (~/.claude) and openclaude (~/.openclaude), plus + * CLAUDE_CONFIG_DIR when set. + */ + _hasPersistedConversation(sessionId, workingDir) { + const home = process.env.HOME || '/'; + const slug = String(workingDir).replace(/[^a-zA-Z0-9]/g, '-'); + const configDirs = [ + process.env.CLAUDE_CONFIG_DIR, + path.join(home, '.openclaude'), + path.join(home, '.claude'), + ].filter(Boolean); + return configDirs.some((dir) => { + try { + return fs.existsSync(path.join(dir, 'projects', slug, `${sessionId}.jsonl`)); + } catch { + return false; + } + }); + } + async startSession(sessionId, options = {}) { if (this.sessions.has(sessionId)) { const existing = this.sessions.get(sessionId); @@ -147,15 +174,27 @@ class ClaudeBridge { // --system-prompt REPLACES the default system prompt, ensuring the agent persona // takes priority over CLAUDE.md and other context that mentions "Claude". const active = providerConfig.active || 'anthropic'; + let agentTier = null; if (active !== 'anthropic' && agent) { - // Read the agent definition file to build a strong system prompt - const agentFile = path.join(workingDir, '.claude', 'agents', `${agent}.md`); + // Read the agent definition file to build a strong system prompt. + // Agent definitions live at the workspace root — workingDir varies per + // session (tickets, project folders), so prefer the root path. + const rootAgentFile = path.join(WORKSPACE_ROOT, '.claude', 'agents', `${agent}.md`); + const cwdAgentFile = path.join(workingDir, '.claude', 'agents', `${agent}.md`); + const agentFile = fs.existsSync(rootAgentFile) ? rootAgentFile : cwdAgentFile; let agentPrompt = ''; try { const content = fs.readFileSync(agentFile, 'utf8'); // Extract body (after YAML frontmatter ---) const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/); agentPrompt = match ? match[1].trim() : content; + // Extract the agent's model tier (opus|sonnet|haiku) from the + // frontmatter — used to pick a per-tier provider model below. + const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n/); + if (fmMatch) { + const tierMatch = fmMatch[1].match(/^model:\s*["']?([a-z0-9.-]+)["']?\s*$/mi); + if (tierMatch) agentTier = tierMatch[1].toLowerCase(); + } } catch { agentPrompt = `You are the ${agent} agent.`; } @@ -168,7 +207,48 @@ class ClaudeBridge { args.push('--system-prompt', enforcePrompt); } - const providerEnv = providerConfig.env_vars || {}; + + // Pin the CLI conversation to the terminal-server session UUID so a + // crash, provider error, or terminal-server restart doesn't lose the + // conversation: the first start registers the id with --session-id, + // and any later start of the same session resumes it with --resume. + const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (UUID_RE.test(sessionId)) { + if (this._hasPersistedConversation(sessionId, workingDir)) { + console.log(`[bridge] Resuming persisted conversation for session ${sessionId}`); + args.push('--resume', sessionId); + } else { + args.push('--session-id', sessionId); + } + } + + // Copy so per-session overrides don't leak into the shared config object. + const providerEnv = { ...(providerConfig.env_vars || {}) }; + + // Per-agent model tier: agents declare model: opus|sonnet|haiku in + // their frontmatter; providers.json maps each tier to a provider model + // via the provider's "model_tiers" field. + const tierModel = agentTier && providerConfig.model_tiers + ? providerConfig.model_tiers[agentTier] + : null; + if (active !== 'anthropic' && tierModel) { + providerEnv['OPENAI_MODEL'] = tierModel; + console.log(`[provider] Agent ${agent} tier "${agentTier}" → model ${tierModel}`); + } + + // Automatic model fallback when the primary is overloaded. The CLI + // accepts a single fallback — pass the first configured entry that + // differs from the primary model. + const fallbackModels = Array.isArray(providerConfig.fallback_models) + ? providerConfig.fallback_models + : []; + if (active !== 'anthropic') { + const primary = providerEnv['OPENAI_MODEL'] || ''; + const fallback = fallbackModels.find((m) => m && m !== primary); + if (fallback) { + args.push('--fallback-model', fallback); + } + } // Build a CLEAN environment for the spawned CLI process. // We DON'T spread process.env — it may contain stale/cached vars @@ -192,7 +272,7 @@ class ClaudeBridge { // to route to the Codex backend — a raw 'gpt-5.x' falls back to the // regular chat completions API, which bypasses Codex OAuth entirely. // - // codexplan → GPT-5.4 on Codex backend (high reasoning) + // codexplan → GPT-5.5 on Codex backend (high reasoning) // codexspark → GPT-5.3 Codex Spark (faster) // // For the plain 'openai' provider (API key mode), default to gpt-4.1. diff --git a/dashboard/terminal-server/src/provider-config.js b/dashboard/terminal-server/src/provider-config.js index 2065d480..33c0d75e 100644 --- a/dashboard/terminal-server/src/provider-config.js +++ b/dashboard/terminal-server/src/provider-config.js @@ -5,6 +5,7 @@ const WORKSPACE_ROOT = path.resolve(__dirname, '..', '..', '..'); const PROVIDERS_PATH = path.join(WORKSPACE_ROOT, 'config', 'providers.json'); const ALLOWED_CLI = new Set(['claude', 'openclaude']); +const ALLOWED_MODES = new Set(['code', 'chat']); const ALLOWED_ENV_VARS = new Set([ 'ANTHROPIC_API_KEY', 'CLAUDE_CODE_USE_OPENAI', @@ -18,6 +19,7 @@ const ALLOWED_ENV_VARS = new Set([ 'CODEX_API_KEY', 'GEMINI_API_KEY', 'GEMINI_MODEL', + 'NVIDIA_API_KEY', 'AWS_REGION', 'AWS_BEARER_TOKEN_BEDROCK', 'ANTHROPIC_VERTEX_PROJECT_ID', @@ -57,6 +59,9 @@ function resolveProviderModel(providerConfig) { function getProviderMode(providerConfig) { const active = providerConfig?.active || 'anthropic'; if (active === 'anthropic') return 'anthropic'; + // Explicit per-provider mode in providers.json wins over the name heuristic — + // model names like "openrouter/owl-alpha" are agentic but don't match isCodeModel. + if (ALLOWED_MODES.has(providerConfig?.mode)) return providerConfig.mode; const model = resolveProviderModel(providerConfig); if (isCodeModel(model)) return 'code'; return 'chat'; @@ -65,7 +70,7 @@ function getProviderMode(providerConfig) { function loadProviderConfig() { try { if (!fs.existsSync(PROVIDERS_PATH)) { - return { cli_command: 'claude', env_vars: {}, active: 'anthropic' }; + return { cli_command: 'claude', env_vars: {}, active: 'anthropic', fallback_models: [], model_tiers: {} }; } const config = JSON.parse(fs.readFileSync(PROVIDERS_PATH, 'utf8')); @@ -85,14 +90,46 @@ function loadProviderConfig() { delete envVars.OPENAI_API_KEY; } + // OpenClaude ≥0.18 detects the NVIDIA NIM base URL and requires the key + // in NVIDIA_API_KEY — derive it so the UI only asks for one key field. + if ( + !envVars.NVIDIA_API_KEY && + envVars.OPENAI_API_KEY && + /\bnvidia\.com\b/i.test(envVars.OPENAI_BASE_URL || '') + ) { + envVars.NVIDIA_API_KEY = envVars.OPENAI_API_KEY; + } + + // Ordered fallback chain — the CLI consumes the first entry + // (--fallback-model); the rest stay available for future chain support. + const fallbackModels = Array.isArray(provider.fallback_models) + ? provider.fallback_models + .filter((m) => typeof m === 'string' && m.trim()) + .map((m) => m.trim()) + : []; + + // Per-tier model map: agents declare model: opus|sonnet|haiku in their + // frontmatter; providers.json maps each tier to a provider model. + const modelTiers = {}; + if (provider.model_tiers && typeof provider.model_tiers === 'object' && !Array.isArray(provider.model_tiers)) { + for (const [tier, model] of Object.entries(provider.model_tiers)) { + if (typeof model === 'string' && model.trim()) { + modelTiers[tier.toLowerCase()] = model.trim(); + } + } + } + return { cli_command: cliCommand, env_vars: envVars, active, provider_name: provider.name || active, + mode: ALLOWED_MODES.has(provider.mode) ? provider.mode : null, + fallback_models: fallbackModels, + model_tiers: modelTiers, }; } catch { - return { cli_command: 'claude', env_vars: {}, active: 'anthropic' }; + return { cli_command: 'claude', env_vars: {}, active: 'anthropic', fallback_models: [], model_tiers: {} }; } } diff --git a/dashboard/terminal-server/test/provider-config.test.js b/dashboard/terminal-server/test/provider-config.test.js new file mode 100644 index 00000000..87dd251c --- /dev/null +++ b/dashboard/terminal-server/test/provider-config.test.js @@ -0,0 +1,47 @@ +const assert = require('assert/strict'); +const test = require('node:test'); + +const { + getProviderMode, + isCodeModel, +} = require('../src/provider-config'); + +test('getProviderMode returns anthropic for the native provider', () => { + assert.equal(getProviderMode({ active: 'anthropic' }), 'anthropic'); +}); + +test('getProviderMode falls back to the model-name heuristic', () => { + assert.equal( + getProviderMode({ active: 'openrouter', env_vars: { OPENAI_MODEL: 'qwen-coder' } }), + 'code' + ); + assert.equal( + getProviderMode({ active: 'openrouter', env_vars: { OPENAI_MODEL: 'openrouter/owl-alpha' } }), + 'chat' + ); +}); + +test('explicit mode overrides the model-name heuristic', () => { + assert.equal( + getProviderMode({ active: 'openrouter', mode: 'code', env_vars: { OPENAI_MODEL: 'openrouter/owl-alpha' } }), + 'code' + ); + assert.equal( + getProviderMode({ active: 'openrouter', mode: 'chat', env_vars: { OPENAI_MODEL: 'qwen-coder' } }), + 'chat' + ); +}); + +test('invalid mode values are ignored', () => { + assert.equal( + getProviderMode({ active: 'openrouter', mode: 'bogus', env_vars: { OPENAI_MODEL: 'openrouter/owl-alpha' } }), + 'chat' + ); +}); + +test('isCodeModel recognizes codex aliases and coder names', () => { + assert.equal(isCodeModel('codexplan'), true); + assert.equal(isCodeModel('codexspark'), true); + assert.equal(isCodeModel('devstral-small'), true); + assert.equal(isCodeModel('gpt-4.1'), false); +}); diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md new file mode 100644 index 00000000..9ca0006c --- /dev/null +++ b/docs/plugin-contract.md @@ -0,0 +1,173 @@ +# EvoNexus Plugin Contract + +This document describes the plugin.yaml schema for EvoNexus plugins, including capabilities, validated fields, and host-enforced contracts. + +--- + +## plugin.yaml — Top-Level Fields + +```yaml +schema_version: "1.0" # required; must be "1.0" +name: string # human-readable name +slug: string # kebab-case identifier; unique across plugins +version: string # semver +description: string +author: string +capabilities: # list of declared capabilities (see below) + - capability_name +``` + +--- + +## Capabilities + +A capability must be declared in `capabilities:` before the corresponding block is used. Unknown capabilities are rejected at install time. + +| Capability | Enum value | Purpose | +|---|---|---| +| `readonly_data` | `readonly_data` | Expose plugin data to agent queries | +| `custom_tools` | `custom_tools` | Register callable tools on agents | +| `public_pages` | `public_pages` | Token-gated public web pages served by host | +| `safe_uninstall` | `safe_uninstall` | 3-step uninstall wizard with data preservation | + +--- + +## `public_pages` — Token-Gated Public Pages + +Requires `capabilities: [public_pages]`. + +```yaml +public_pages: + - id: string # unique within this plugin + description: string + route_prefix: string # e.g. "orders"; becomes /p//orders/ + token_source: + table: string # must start with _ (snake_case) + column: string # column holding the access token (snake_case) + bundle: string # must start with ui/public/ + custom_element_name: string # e.g. "my-plugin-orders" + auth_mode: token # only "token" supported in v1 + rate_limit_per_ip: string # e.g. "60/minute" + audit_action: string # logged per request +``` + +### Routes + +| Method | Path | Description | +|---|---|---| +| `GET` | `/p///` | Serve the HTML bundle (portal entry) | +| `GET` | `/p////data` | Run a `public_via`-tagged readonly query | +| `GET` | `/p////public-assets/` | Serve static assets from `ui/public/` | + +All three endpoints: +1. Validate the token parametrically against `token_source.table/column` (SQL: `SELECT 1 FROM WHERE = ?`) +2. Apply rate limiting (60 req/min on portal, 120 req/min on data) +3. Emit security headers (CSP, X-Content-Type-Options, Referrer-Policy, HSTS) +4. Write an audit log row + +### Linking a `readonly_data` query to a public page + +```yaml +readonly_data: + queries: + - name: order_summary + sql: "SELECT id, status, total FROM nutri_orders WHERE id = :order_id" + public_via: orders # id of the public_page above + bind_token_param: order_id # parameter name that receives the token value +``` + +`public_via` must reference a declared `public_pages[].id`. When set, `bind_token_param` is required; the validated token value is injected at query time. + +--- + +## `safe_uninstall` — 3-Step Uninstall Wizard + +Requires `capabilities: [safe_uninstall]`. + +```yaml +safe_uninstall: + enabled: bool # true = enforce wizard; false = legacy confirm() + block_uninstall: bool # if true, uninstall is unconditionally blocked (409) + reason: string # displayed in wizard Step 1 (regulatory context) + + user_confirmation: + checkbox_label: string # Step 1 checkbox text + typed_phrase: string # Step 3 required phrase (exact match) + + pre_uninstall_hook: + script: string # relative path inside plugin dir (e.g. scripts/export.py) + output_dir: string # where the export lands (relative to plugin dir) + timeout_seconds: int # 1–600 + must_produce_file: bool # if true, fail if output_dir is empty after hook + + preserved_tables: # tables to rename rather than drop + - _tablename # must be prefixed with _ + + preserved_host_entities: # host-managed tables with partial row preservation + host_table_name: + "SQL condition for rows to KEEP" + # rows matching NOT (condition) are deleted + + block_uninstall: false +``` + +### Host enforcement + +When `enabled: true`: + +1. **Admin role required** — non-admin users receive 403. +2. **Confirmation phrase** — `DELETE /api/plugins/` body must include `confirmation_phrase` matching `user_confirmation.typed_phrase`. +3. **Export verification** — `exported_at` path must be provided and the file must exist. +4. **ZIP password** — `zip_password` must be present (forwarded to pre-uninstall hook if configured). +5. **Pre-uninstall hook** — if configured, runs in a sandboxed subprocess with no secret env vars (only `PLUGIN_SLUG`, `PLUGIN_VERSION`, `OUTPUT_DIR`, `DB_READONLY_PATH`). Hook failure aborts uninstall. +6. **Preserved tables** — tables listed in `preserved_tables` are renamed to `_orphan__` and recorded in `plugin_orphans`. They are **not dropped**. +7. **Cascade-DELETE filtering** — for tables listed in `preserved_host_entities`, only rows NOT matching the preservation condition are deleted. + +### Force-uninstall escape hatch + +Setting `EVONEXUS_ALLOW_FORCE_UNINSTALL=1` in the host environment bypasses all safe_uninstall checks. Every force-uninstall is logged as `plugin_uninstall_force` in the audit table with the acting user's identity. This flag is intended for emergency recovery only. + +### Reinstall after safe_uninstall + +On reinstall of a plugin with orphaned tables: + +1. Host checks `plugin_orphans` for unrecovered rows. +2. If present, compares `tarball_sha256` of the incoming tarball against `original_sha256` recorded at uninstall time. +3. SHA256 mismatch → install blocked unless request includes `confirmed_sha256_change: true` (explicit operator acknowledgment). +4. On SHA256 match (or explicit override): orphan tables are renamed back (`_orphan__
` → `
`) before install.sql runs. + +### `plugin_orphans` table (host-managed) + +```sql +CREATE TABLE plugin_orphans ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL, + tablename TEXT NOT NULL, -- original name (before _orphan_ prefix) + orphaned_at TEXT NOT NULL, + orphaned_by_user_id INTEGER, + original_plugin_version TEXT, + original_sha256 TEXT, + original_publisher_url TEXT, + recovered_at TEXT, -- NULL until reinstall recovery + UNIQUE(slug, tablename) +); +``` + +--- + +## Security Notes + +- Plugin SQL identifiers (`table`, `column`) are validated at install time against `^[a-z][a-z0-9_]*$`. The host never interpolates untrusted input into SQL identifiers. +- Token values in public-page routes are always bound as SQL parameters (`?`), never interpolated. +- Pre-uninstall hooks run with a read-only DB copy; no write access and no secret env vars. +- SQL in `readonly_data.queries` must not reference `_orphan_*` tables (rejected at install via schema validator). +- Rate limiting is applied at the IP level on all public endpoints (flask-limiter, in-memory storage, single-process). + +--- + +## Changelog + +| Version | Change | +|---|---| +| v1.0.0 | Initial contract: `readonly_data`, `custom_tools` | +| v1.1.0 | Added `public_pages` (B2) and `safe_uninstall` (B3) capabilities | diff --git a/pyproject.toml b/pyproject.toml index 802fcdc0..17da8867 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "evo-nexus" -version = "0.32.3" +version = "0.33.0" description = "Unofficial open source toolkit for Claude Code — AI-powered business operating system" requires-python = ">=3.10" dependencies = [ @@ -30,6 +30,7 @@ dependencies = [ "watchdog>=4.0", "sqlparse>=0.4,<1.0", "jsonschema>=4.21", + "flask-limiter>=3.5", ] [project.scripts] diff --git a/tests/backend/test_plugin_public_pages_html_shell.py b/tests/backend/test_plugin_public_pages_html_shell.py new file mode 100644 index 00000000..172851d4 --- /dev/null +++ b/tests/backend/test_plugin_public_pages_html_shell.py @@ -0,0 +1,229 @@ +"""Wave 2.1.x — content negotiation for plugin public pages. + +When a browser hits /p/{slug}/{route}/{token}, the host generates a minimal +HTML shell that loads the plugin bundle as a module and instantiates the +declared custom element. Programmatic clients (no text/html in Accept) keep +getting the raw bundle for backwards compat. + +Covers: +- HTML accept → renders shell with custom element + bundle script tag +- Token embedded in data-token attribute on custom element +- No HTML accept → bundle served as application/javascript (legacy) +- Bundle path enforced inside ui/public/ (containment guard reused) +- CSP + X-Content-Type-Options headers present on shell +- Custom element name enforced alphanum-dash (defense in depth) +- Invalid token → 404 (no shell leaked) +""" + +from __future__ import annotations + +import json +import sqlite3 +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +BACKEND_DIR = REPO_ROOT / "dashboard" / "backend" +sys.path.insert(0, str(BACKEND_DIR)) + + +@pytest.fixture +def tmp_db(tmp_path): + """Temp SQLite DB with plugins_installed (manifest_json column) + nutri_patients.""" + db_path = tmp_path / "test.db" + conn = sqlite3.connect(str(db_path)) + manifest_json = json.dumps({ + "id": "nutri", + "public_pages": [ + { + "id": "portal", + "description": "Portal do paciente", + "route_prefix": "portal", + "bundle": "ui/public/portal.js", + "custom_element_name": "nutri-patient-portal", + "auth_mode": "token", + "token_source": {"table": "nutri_patients", "column": "magic_link_token"}, + "audit_action": "portal_view", + } + ], + "readonly_data": [], + }) + conn.executescript( + """ + CREATE TABLE plugins_installed ( + slug TEXT PRIMARY KEY, enabled INTEGER, status TEXT, + manifest_json TEXT, capabilities_disabled TEXT + ); + CREATE TABLE nutri_patients ( + id TEXT PRIMARY KEY, name TEXT, magic_link_token TEXT, status TEXT + ); + INSERT INTO nutri_patients (id, name, magic_link_token, status) VALUES + ('p1', 'Alice', 'good-token-123', 'active'), + ('p2', 'Bob', NULL, 'active'); + """ + ) + conn.execute( + "INSERT INTO plugins_installed (slug, enabled, status, manifest_json, capabilities_disabled) " + "VALUES (?, 1, 'active', ?, '{}')", + ("nutri", manifest_json), + ) + conn.commit() + conn.close() + return db_path + + +@pytest.fixture +def app(tmp_path, tmp_db): + """Flask app with plugin_public_pages blueprint pointed at temp DB + bundle.""" + import flask + + plugins_root = tmp_path / "plugins" + plugin_dir = plugins_root / "nutri" + bundle_dir = plugin_dir / "ui" / "public" + bundle_dir.mkdir(parents=True) + (bundle_dir / "portal.js").write_text( + "// minimal bundle\ncustomElements.define('nutri-patient-portal', class extends HTMLElement {});\n", + encoding="utf-8", + ) + + import routes.plugin_public_pages as ppp_mod + ppp_mod.PLUGINS_DIR = plugins_root + + def _get_db_override(): + c = sqlite3.connect(str(tmp_db)) + c.row_factory = sqlite3.Row + return c + ppp_mod._get_db = _get_db_override + # audit() in plugin_public_pages writes to host DB — stub it to no-op + ppp_mod.audit = lambda *a, **kw: None + + flask_app = flask.Flask(__name__) + flask_app.config["TESTING"] = True + flask_app.config["SECRET_KEY"] = "test" + + # flask-limiter is applied at module load — patch in-memory storage + from flask_limiter import Limiter + if not hasattr(ppp_mod, "_limiter_inited"): + try: + ppp_mod.limiter.init_app(flask_app) + except Exception: + pass + + flask_app.register_blueprint(ppp_mod.bp) + return flask_app + + +@pytest.fixture +def client(app): + return app.test_client() + + +# ── Content negotiation ────────────────────────────────────────────────── + + +class TestHtmlShellNegotiation: + def test_browser_accept_html_returns_shell(self, client): + r = client.get( + "/p/nutri/portal/good-token-123", + headers={"Accept": "text/html,application/xhtml+xml"}, + ) + assert r.status_code == 200 + assert r.mimetype == "text/html" + body = r.get_data(as_text=True) + assert "" in body + # Token reaches the custom element via data-token + assert 'data-token="good-token-123"' in body + # Custom element instantiated + assert "" not in r.get_data() + + def test_html_shell_has_csp_and_no_sniff_headers(self, client): + r = client.get( + "/p/nutri/portal/good-token-123", + headers={"Accept": "text/html"}, + ) + assert r.headers.get("X-Content-Type-Options") == "nosniff" + csp = r.headers.get("Content-Security-Policy", "") + assert "default-src 'self'" in csp + assert "frame-ancestors 'none'" in csp + + def test_html_shell_xss_safe_token(self, client): + # Inject a token that would XSS if not escaped + # But it has to also be a VALID token so we'd need to seed it. + # Instead, verify that the page only ever contains the *exact* token + # bytes inside the data-token attribute (escape happens via html.escape). + r = client.get( + "/p/nutri/portal/good-token-123", + headers={"Accept": "text/html"}, + ) + body = r.get_data(as_text=True) + # No raw