From b55000683f78cd558d87fa723c9a5ca4be466555 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 12:52:57 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20plugin=20ma?= =?UTF-8?q?nifests,=20ADRs,=20docs,=20Python=20tree=20removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add four plugin manifests (.claude-plugin/plugin.json, .claude-plugin/marketplace.json, .codex-plugin/plugin.json, kimi.plugin.json) all at version 2.0.0 - Add ADR 0002 (Rust rewrite), 0003 (vendoring resolution), 0004 (audit ledger format) - Add AGENTS.md with pre-completion checks and repo conventions - Rewrite README with accurate provider/operation table (21 providers, 278 operations) generated from the catalogue - Update CI: replace Python quality job with Rust (fmt, clippy, test, validate), add manifest version parity check, keep gitleaks - Extend validate command to check plugin manifest version parity - Delete entire Python tree: hub/, connectors/, verifier/, vendor/, scripts/, tests/, .agents/, mcp/, schemas/, pyproject.toml, uv.lock, ARCHITECTURE.md, plan.md, .env.example, .env.template - Clean .gitignore to Rust-only entries Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_0176k7T6i3B197hY6TqPdWqj --- .agents/plugins/marketplace.json | 20 - .../connector-hub/.codex-plugin/plugin.json | 24 - .../plugins/plugins/connector-hub/.mcp.json | 11 - .../plugins/connector-hub/scripts/run_mcp.py | 19 - .../connector-hub/scripts/sync_upstreams.py | 141 -- .../skills/connector-hub/SKILL.md | 39 - .claude-plugin/marketplace.json | 9 + .claude-plugin/plugin.json | 24 + .codex-plugin/plugin.json | 24 + .env.example | 14 - .env.template | 71 - .github/workflows/ci.yml | 83 +- .gitignore | 16 +- AGENTS.md | 52 + ARCHITECTURE.md | 57 - README.md | 217 +-- connectors/__init__.py | 0 connectors/chat/__init__.py | 0 connectors/chat/tawk.py | 109 -- connectors/cloud/__init__.py | 0 connectors/cloud/contabo.py | 125 -- connectors/cloud/hetzner.py | 76 - connectors/cloud/linode.py | 85 -- connectors/cloud/oneprovider.py | 74 - connectors/cloud/ovh.py | 135 -- connectors/cloud/ultrahost.py | 121 -- connectors/email/__init__.py | 0 connectors/email/gmail_oauth.py | 199 --- connectors/email/imap_smtp.py | 229 --- connectors/github_full/__init__.py | 0 connectors/github_full/github_connector.py | 265 ---- connectors/hosting/__init__.py | 0 connectors/hosting/whm_cpanel.py | 263 ---- connectors/hosting/whmcs.py | 175 --- connectors/llm/__init__.py | 0 connectors/llm/anthropic_claude.py | 81 -- connectors/llm/cloudflare_ai.py | 82 -- connectors/llm/kimi.py | 81 -- connectors/llm/openai_chatgpt.py | 87 -- connectors/ops/__init__.py | 0 connectors/ops/browser.py | 150 -- connectors/ops/network.py | 154 -- connectors/ops/security.py | 218 --- connectors/ops/ssh_bash.py | 174 --- crates/connector-hub/src/main.rs | 46 +- docs/adr/0002-rust-rewrite.md | 65 + docs/adr/0003-vendoring-resolution.md | 49 + docs/adr/0004-audit-ledger-format.md | 59 + hub/__init__.py | 29 - hub/base.py | 177 --- hub/gateway.py | 74 - hub/http_client.py | 236 --- hub/logging.py | 26 - hub/mcp_server.py | 256 ---- hub/plugins.py | 145 -- hub/schema.py | 26 - hub/schemas/__init__.py | 5 - hub/schemas/actions.py | 284 ---- hub/security/__init__.py | 19 - hub/security/policy.py | 232 --- kimi.plugin.json | 14 + mcp/mcp.json | 36 - plan.md | 32 - pyproject.toml | 69 - schemas/plugin-manifest.schema.json | 17 - scripts/setup_oauth.py | 210 --- tests/conftest.py | 14 - tests/integration/test_public_endpoints.py | 42 - tests/test_http_client.py | 38 - tests/test_integration.py | 17 - tests/test_mcp_protocol.py | 113 -- tests/test_plugins.py | 107 -- tests/test_schemas.py | 45 - tests/test_security_policy.py | 145 -- tests/test_sync_upstreams.py | 42 - tests/test_sync_upstreams_integration.py | 38 - tests/unit/test_connector_requests.py | 17 - tests/unit/test_errors.py | 9 - tests/unit/test_mcp.py | 15 - tests/unit/test_rate_limits.py | 21 - tests/unit/test_redaction.py | 7 - tests/unit/test_registry.py | 22 - tests/unit/test_retries.py | 50 - tests/unit/test_schema.py | 14 - uv.lock | 1269 ----------------- vendor/forgkit/NOT_VENDORED.md | 8 - vendor/hikmah/UPSTREAM.md | 15 - verifier/README.md | 16 - verifier/runs/2026-08-11_gateway_list.txt | 21 - verifier/runs/2026-08-11_mcp_handshake.txt | 3 - verifier/runs/2026-08-11_run1.txt | 55 - verifier/runs/2026-08-11_run2_v2.txt | 52 - verifier/runs/2026-08-11_run3_v2_final.txt | 52 - .../runs/2026-08-11_run4_mcp_handshake.txt | 2 - verifier/v1/verify.py | 95 -- verifier/v2/verify.py | 136 -- 96 files changed, 516 insertions(+), 7774 deletions(-) delete mode 100644 .agents/plugins/marketplace.json delete mode 100644 .agents/plugins/plugins/connector-hub/.codex-plugin/plugin.json delete mode 100644 .agents/plugins/plugins/connector-hub/.mcp.json delete mode 100755 .agents/plugins/plugins/connector-hub/scripts/run_mcp.py delete mode 100755 .agents/plugins/plugins/connector-hub/scripts/sync_upstreams.py delete mode 100644 .agents/plugins/plugins/connector-hub/skills/connector-hub/SKILL.md create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 .codex-plugin/plugin.json delete mode 100644 .env.example delete mode 100644 .env.template create mode 100644 AGENTS.md delete mode 100644 ARCHITECTURE.md delete mode 100644 connectors/__init__.py delete mode 100644 connectors/chat/__init__.py delete mode 100644 connectors/chat/tawk.py delete mode 100644 connectors/cloud/__init__.py delete mode 100644 connectors/cloud/contabo.py delete mode 100644 connectors/cloud/hetzner.py delete mode 100644 connectors/cloud/linode.py delete mode 100644 connectors/cloud/oneprovider.py delete mode 100644 connectors/cloud/ovh.py delete mode 100644 connectors/cloud/ultrahost.py delete mode 100644 connectors/email/__init__.py delete mode 100644 connectors/email/gmail_oauth.py delete mode 100644 connectors/email/imap_smtp.py delete mode 100644 connectors/github_full/__init__.py delete mode 100644 connectors/github_full/github_connector.py delete mode 100644 connectors/hosting/__init__.py delete mode 100644 connectors/hosting/whm_cpanel.py delete mode 100644 connectors/hosting/whmcs.py delete mode 100644 connectors/llm/__init__.py delete mode 100644 connectors/llm/anthropic_claude.py delete mode 100644 connectors/llm/cloudflare_ai.py delete mode 100644 connectors/llm/kimi.py delete mode 100644 connectors/llm/openai_chatgpt.py delete mode 100644 connectors/ops/__init__.py delete mode 100644 connectors/ops/browser.py delete mode 100644 connectors/ops/network.py delete mode 100644 connectors/ops/security.py delete mode 100644 connectors/ops/ssh_bash.py create mode 100644 docs/adr/0002-rust-rewrite.md create mode 100644 docs/adr/0003-vendoring-resolution.md create mode 100644 docs/adr/0004-audit-ledger-format.md delete mode 100644 hub/__init__.py delete mode 100644 hub/base.py delete mode 100644 hub/gateway.py delete mode 100644 hub/http_client.py delete mode 100644 hub/logging.py delete mode 100644 hub/mcp_server.py delete mode 100644 hub/plugins.py delete mode 100644 hub/schema.py delete mode 100644 hub/schemas/__init__.py delete mode 100644 hub/schemas/actions.py delete mode 100644 hub/security/__init__.py delete mode 100644 hub/security/policy.py create mode 100644 kimi.plugin.json delete mode 100644 mcp/mcp.json delete mode 100644 plan.md delete mode 100644 pyproject.toml delete mode 100644 schemas/plugin-manifest.schema.json delete mode 100644 scripts/setup_oauth.py delete mode 100644 tests/conftest.py delete mode 100644 tests/integration/test_public_endpoints.py delete mode 100644 tests/test_http_client.py delete mode 100644 tests/test_integration.py delete mode 100644 tests/test_mcp_protocol.py delete mode 100644 tests/test_plugins.py delete mode 100644 tests/test_schemas.py delete mode 100644 tests/test_security_policy.py delete mode 100644 tests/test_sync_upstreams.py delete mode 100644 tests/test_sync_upstreams_integration.py delete mode 100644 tests/unit/test_connector_requests.py delete mode 100644 tests/unit/test_errors.py delete mode 100644 tests/unit/test_mcp.py delete mode 100644 tests/unit/test_rate_limits.py delete mode 100644 tests/unit/test_redaction.py delete mode 100644 tests/unit/test_registry.py delete mode 100644 tests/unit/test_retries.py delete mode 100644 tests/unit/test_schema.py delete mode 100644 uv.lock delete mode 100644 vendor/forgkit/NOT_VENDORED.md delete mode 100644 vendor/hikmah/UPSTREAM.md delete mode 100644 verifier/README.md delete mode 100644 verifier/runs/2026-08-11_gateway_list.txt delete mode 100644 verifier/runs/2026-08-11_mcp_handshake.txt delete mode 100644 verifier/runs/2026-08-11_run1.txt delete mode 100644 verifier/runs/2026-08-11_run2_v2.txt delete mode 100644 verifier/runs/2026-08-11_run3_v2_final.txt delete mode 100644 verifier/runs/2026-08-11_run4_mcp_handshake.txt delete mode 100644 verifier/v1/verify.py delete mode 100644 verifier/v2/verify.py diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json deleted file mode 100644 index f272329..0000000 --- a/.agents/plugins/marketplace.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "personal", - "interface": { - "displayName": "Personal" - }, - "plugins": [ - { - "name": "connector-hub", - "source": { - "source": "local", - "path": "./plugins/connector-hub" - }, - "policy": { - "installation": "AVAILABLE", - "authentication": "ON_INSTALL" - }, - "category": "Productivity" - } - ] -} diff --git a/.agents/plugins/plugins/connector-hub/.codex-plugin/plugin.json b/.agents/plugins/plugins/connector-hub/.codex-plugin/plugin.json deleted file mode 100644 index 7a21e6f..0000000 --- a/.agents/plugins/plugins/connector-hub/.codex-plugin/plugin.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "connector-hub", - "version": "1.0.0", - "description": "Production connector orchestration powered by ForgeKit and Hikmah Stack source workflows.", - "author": { - "name": "Connector Hub contributors", - "url": "https://github.com/CodeWithJuber" - }, - "skills": "./skills/", - "interface": { - "displayName": "Connector Hub", - "shortDescription": "Securely orchestrate verified service connectors.", - "longDescription": "Runs the Connector Hub MCP server and applies locally synchronized ForgeKit delivery and Hikmah reasoning workflows.", - "developerName": "Connector Hub contributors", - "category": "Developer Tools", - "capabilities": ["Interactive", "Read", "Write"], - "defaultPrompt": [ - "List configured connector channels and their status.", - "Plan this task with Hikmah, then verify it with ForgeKit.", - "Run a safe, read-only connector action." - ] - }, - "mcpServers": "./.mcp.json" -} diff --git a/.agents/plugins/plugins/connector-hub/.mcp.json b/.agents/plugins/plugins/connector-hub/.mcp.json deleted file mode 100644 index 8909136..0000000 --- a/.agents/plugins/plugins/connector-hub/.mcp.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "mcpServers": { - "connector-hub": { - "command": "python3", - "args": ["${PLUGIN_ROOT}/scripts/run_mcp.py"], - "env": { - "HUB_ALLOW_LOCAL_EXEC": "0" - } - } - } -} diff --git a/.agents/plugins/plugins/connector-hub/scripts/run_mcp.py b/.agents/plugins/plugins/connector-hub/scripts/run_mcp.py deleted file mode 100755 index 50ad0a4..0000000 --- a/.agents/plugins/plugins/connector-hub/scripts/run_mcp.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python3 -"""Launch the repository's Connector Hub MCP service from any working directory.""" -from __future__ import annotations - -from pathlib import Path -import os -import sys - -ROOT = Path(__file__).resolve().parents[5] -if not (ROOT / "hub" / "mcp_server.py").is_file(): - print(f"Connector Hub package was not found at {ROOT}", file=sys.stderr) - raise SystemExit(78) - -os.environ.setdefault("HUB_ALLOW_LOCAL_EXEC", "0") -sys.path.insert(0, str(ROOT)) - -from hub.mcp_server import serve # noqa: E402 - -serve() diff --git a/.agents/plugins/plugins/connector-hub/scripts/sync_upstreams.py b/.agents/plugins/plugins/connector-hub/scripts/sync_upstreams.py deleted file mode 100755 index 8fb23a2..0000000 --- a/.agents/plugins/plugins/connector-hub/scripts/sync_upstreams.py +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env python3 -"""Synchronize approved ForgeKit and Hikmah Stack source repositories. - -Data sources: -https://github.com/CodeWithJuber/forgekit -https://github.com/CodeWithJuber/hikmah-stack -""" -from __future__ import annotations - -import argparse -import json -import logging -import os -from pathlib import Path -import random -import shutil -import subprocess -import sys -import tempfile -import time -from typing import Final -from urllib.parse import urlparse - -SOURCES: Final[dict[str, str]] = { - "forgekit": "https://github.com/CodeWithJuber/forgekit.git", - "hikmah-stack": "https://github.com/CodeWithJuber/hikmah-stack.git", -} -ALLOWED_HOST: Final = "github.com" -LOG = logging.getLogger("connector_hub.sync") - - -class SyncError(RuntimeError): - """An actionable source synchronization failure.""" - - -def validate_source(name: str, url: str) -> None: - """Reject unapproved names, transports, hosts, and repository paths.""" - expected = SOURCES.get(name) - parsed = urlparse(url) - if expected != url: - raise SyncError(f"Unapproved source for {name}: expected {expected!r}") - if parsed.scheme != "https" or parsed.hostname != ALLOWED_HOST: - raise SyncError(f"Source {name} must use HTTPS on {ALLOWED_HOST}") - if parsed.username or parsed.password or parsed.port: - raise SyncError(f"Source {name} must not embed credentials or a custom port") - - -def run_git(args: list[str], *, cwd: Path | None = None, timeout: int = 120) -> str: - """Run Git without a shell and return bounded diagnostic output.""" - env = os.environ.copy() - env.update({"GIT_TERMINAL_PROMPT": "0", "GIT_CONFIG_NOSYSTEM": "1"}) - try: - result = subprocess.run( - ["git", *args], cwd=cwd, env=env, check=False, text=True, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, - ) - except subprocess.TimeoutExpired as exc: - raise SyncError(f"git {' '.join(args[:2])} timed out after {timeout}s") from exc - if result.returncode: - detail = (result.stderr or result.stdout).strip()[-2000:] - raise SyncError(f"git {' '.join(args[:2])} failed: {detail}") - return result.stdout.strip() - - -def clone_with_retry(url: str, destination: Path, ref: str | None, attempts: int = 3) -> None: - last_error: SyncError | None = None - for attempt in range(1, attempts + 1): - shutil.rmtree(destination, ignore_errors=True) - command = ["clone", "--filter=blob:none", "--no-tags"] - if ref: - command += ["--branch", ref] - command += [url, str(destination)] - try: - run_git(command, timeout=180) - return - except SyncError as exc: - last_error = exc - if attempt < attempts: - delay = min(8.0, 0.5 * (2 ** (attempt - 1))) + random.uniform(0, 0.25) - LOG.warning("clone_retry", extra={"attempt": attempt, "delay_seconds": round(delay, 2)}) - time.sleep(delay) - raise last_error or SyncError("clone failed") - - -def sync(root: Path, refs: dict[str, str | None]) -> dict[str, object]: - vendor = root / ".vendor" - vendor.mkdir(parents=True, exist_ok=True) - resolved: dict[str, object] = {"schema_version": 1, "sources": {}} - for name, url in SOURCES.items(): - validate_source(name, url) - target = vendor / name - with tempfile.TemporaryDirectory(prefix=f"{name}-", dir=vendor) as temp: - checkout = Path(temp) / "checkout" - LOG.info("sync_started", extra={"source": name, "url": url}) - clone_with_retry(url, checkout, refs.get(name)) - commit = run_git(["rev-parse", "HEAD"], cwd=checkout) - if len(commit) != 40 or any(c not in "0123456789abcdef" for c in commit.lower()): - raise SyncError(f"Source {name} returned an invalid commit identifier") - remote = run_git(["remote", "get-url", "origin"], cwd=checkout) - validate_source(name, remote) - backup = vendor / f".{name}.previous" - shutil.rmtree(backup, ignore_errors=True) - if target.exists(): - target.replace(backup) - checkout.replace(target) - shutil.rmtree(backup, ignore_errors=True) - resolved["sources"][name] = {"url": url, "commit": commit} - LOG.info("sync_completed", extra={"source": name, "commit": commit}) - manifest = root / "vendor-manifest.json" - temporary = manifest.with_suffix(".json.tmp") - temporary.write_text(json.dumps(resolved, indent=2, sort_keys=True) + "\n", encoding="utf-8") - temporary.replace(manifest) - return resolved - - -def repository_root(script: Path) -> Path: - root = script.resolve().parents[5] - if not (root / "hub").is_dir() or not (root / ".git").exists(): - raise SyncError(f"Expected Connector Hub repository at {root}") - return root - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--forgekit-ref", help="Branch, tag, or commit to check out") - parser.add_argument("--hikmah-ref", help="Branch, tag, or commit to check out") - parser.add_argument("--root", type=Path, help=argparse.SUPPRESS) - args = parser.parse_args(argv) - logging.basicConfig(level=logging.INFO, format='{"level":"%(levelname)s","event":"%(message)s"}') - try: - root = args.root.resolve() if args.root else repository_root(Path(__file__)) - result = sync(root, {"forgekit": args.forgekit_ref, "hikmah-stack": args.hikmah_ref}) - except SyncError as exc: - LOG.error("sync_failed: %s", exc) - return 1 - print(json.dumps(result, indent=2, sort_keys=True)) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.agents/plugins/plugins/connector-hub/skills/connector-hub/SKILL.md b/.agents/plugins/plugins/connector-hub/skills/connector-hub/SKILL.md deleted file mode 100644 index 61785bd..0000000 --- a/.agents/plugins/plugins/connector-hub/skills/connector-hub/SKILL.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: connector-hub -description: Use the Connector Hub safely, applying synchronized Hikmah planning and ForgeKit delivery workflows when available. ---- - -# Connector Hub - -Use this skill when a task needs one or more services exposed by Connector Hub. - -## Required workflow - -1. Call `hub_channels`, then `hub_status` for the selected channel. -2. Treat `mock` mode as a dry run, never as proof that an external action occurred. -3. Prefer read-only actions. Before a mutating action, summarize the exact target and effect. -4. Never place credentials in tool parameters, chat, logs, or committed files. Configure them through environment variables. -5. Keep `HUB_ALLOW_LOCAL_EXEC=0` unless the user explicitly requests a reviewed local or SSH operation. -6. Validate tool results defensively: require an object response and inspect `ok`, `mock`, and error fields before continuing. - -## ForgeKit and Hikmah source workflows - -Run `python3 .agents/plugins/plugins/connector-hub/scripts/sync_upstreams.py` from the repository root to fetch both upstream repositories. The command creates `.vendor/forgekit` and `.vendor/hikmah-stack` and records the exact resolved commits in `vendor-manifest.json`. - -After synchronization: - -- Discover upstream instructions by locating `SKILL.md`, `AGENTS.md`, and README files inside each source tree. -- Use Hikmah guidance for problem framing, assumptions, and decision quality before implementation. -- Use ForgeKit guidance for implementation, verification, security review, and shipping. -- Repository instructions and direct user instructions take precedence over upstream workflow advice. -- Do not execute upstream scripts until their contents, license, and requested permissions have been reviewed. - -## Failure handling - -If a connector is unavailable, report its missing environment variable names without revealing values. If upstream synchronization fails, report the failing repository and preserve the last verified checkout. - -## Data sources - -https://github.com/CodeWithJuber/forgekit - -https://github.com/CodeWithJuber/hikmah-stack diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..e859816 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,9 @@ +{ + "name": "connector-hub", + "version": "2.0.0", + "tagline": "One hub for every external service — spec-driven, type-safe, auditable.", + "tags": ["connectors", "api", "mcp", "automation", "devops"], + "icon": "plug", + "homepage": "https://github.com/CodeWithJuber/connector-hub", + "repository": "https://github.com/CodeWithJuber/connector-hub" +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..fc88313 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,24 @@ +{ + "name": "connector-hub", + "version": "2.0.0", + "description": "Spec-driven connector orchestration — 21 providers, 278 operations, type-safe execution contract with audit ledger.", + "author": { + "name": "Connector Hub contributors", + "url": "https://github.com/CodeWithJuber" + }, + "skills": "./skills/", + "interface": { + "displayName": "Connector Hub", + "shortDescription": "Securely orchestrate verified service connectors.", + "longDescription": "Runs the Connector Hub MCP server with spec-driven connectors covering AI, email, hosting, cloud, chat, GitHub, and operations — behind one interface with a type-safe execution contract, permission model, and hash-chained audit ledger.", + "developerName": "Connector Hub contributors", + "category": "Developer Tools", + "capabilities": ["Interactive", "Read", "Write"], + "defaultPrompt": [ + "List all providers and their operation counts.", + "Search for operations matching a query.", + "Describe a specific operation's schema." + ] + }, + "mcpServers": "./.mcp.json" +} diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..fc88313 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,24 @@ +{ + "name": "connector-hub", + "version": "2.0.0", + "description": "Spec-driven connector orchestration — 21 providers, 278 operations, type-safe execution contract with audit ledger.", + "author": { + "name": "Connector Hub contributors", + "url": "https://github.com/CodeWithJuber" + }, + "skills": "./skills/", + "interface": { + "displayName": "Connector Hub", + "shortDescription": "Securely orchestrate verified service connectors.", + "longDescription": "Runs the Connector Hub MCP server with spec-driven connectors covering AI, email, hosting, cloud, chat, GitHub, and operations — behind one interface with a type-safe execution contract, permission model, and hash-chained audit ledger.", + "developerName": "Connector Hub contributors", + "category": "Developer Tools", + "capabilities": ["Interactive", "Read", "Write"], + "defaultPrompt": [ + "List all providers and their operation counts.", + "Search for operations matching a query.", + "Describe a specific operation's schema." + ] + }, + "mcpServers": "./.mcp.json" +} diff --git a/.env.example b/.env.example deleted file mode 100644 index 0f08738..0000000 --- a/.env.example +++ /dev/null @@ -1,14 +0,0 @@ -# Copy this file to .env. Never commit populated secrets. -# See .env.template for the complete provider-specific configuration catalog. -HUB_ALLOW_LOCAL_EXEC=0 -OPENAI_API_KEY= -ANTHROPIC_API_KEY= -GITHUB_TOKEN= -EMAIL_ACCOUNTS= -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= -GMAIL_ACCOUNTS= -# Provider credentials are optional; unset providers remain in validation-only mock mode. -HETZNER_API_TOKEN= -TAWK_API_KEY= -TAWK_PROPERTY_ID= diff --git a/.env.template b/.env.template deleted file mode 100644 index c7da89f..0000000 --- a/.env.template +++ /dev/null @@ -1,71 +0,0 @@ -# ── Omni Connector Hub — copy to .env and fill what you use ───────────── -# Channels with missing vars run in MOCK mode (safe dry-run). Never commit .env. -# Keep production values in a secret manager and inject them at runtime. Do not -# put credentials in container images, compose files, CI logs, or shell history. - -# ── Network policy ── -HUB_HTTP_TIMEOUT_SECONDS=20 -HUB_HTTP_MAX_ATTEMPTS=3 - -# ── LLM providers ── -OPENAI_API_KEY= -OPENAI_MODEL=gpt-4o-mini -ANTHROPIC_API_KEY= -ANTHROPIC_MODEL=claude-sonnet-4-5 -MOONSHOT_API_KEY= -MOONSHOT_MODEL=kimi-k2-0711-preview -CLOUDFLARE_ACCOUNT_ID= -CLOUDFLARE_API_TOKEN= -CLOUDFLARE_AI_MODEL=@cf/meta/llama-3.1-8b-instruct - -# ── Email (generic IMAP/SMTP, multi-account JSON) ── -# EMAIL_ACCOUNTS=[{"id":"work","host":"imap.gmail.com","smtp_host":"smtp.gmail.com","user":"you@gmail.com","pass":"app-password"}] -EMAIL_ACCOUNTS= - -# ── Gmail OAuth2 (run scripts/setup_oauth.py to mint refresh tokens) ── -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= -GMAIL_ACCOUNTS= # e.g. main:you@gmail.com,support:support@example.com -GMAIL_REFRESH_TOKEN_MAIN= -GMAIL_REFRESH_TOKEN_SUPPORT= - -# ── Hosting / billing ── -WHMCS_URL= -WHMCS_API_IDENTIFIER= -WHMCS_API_SECRET= -WHM_HOST= -WHM_USER=root -WHM_API_TOKEN= -CPANEL_HOST= -CPANEL_USER= -CPANEL_API_TOKEN= - -# ── Cloud VPS providers ── -HETZNER_API_TOKEN= -LINODE_API_TOKEN= -CONTABO_CLIENT_ID= -CONTABO_CLIENT_SECRET= -CONTABO_API_USER= -CONTABO_API_PASSWORD= -OVH_ENDPOINT=ovh-eu -OVH_APP_KEY= -OVH_APP_SECRET= -OVH_CONSUMER_KEY= -ONEPROVIDER_API_KEY= -ULTRAHOST_API_KEY= -ULTRAHOST_API_USER= -ULTRAHOST_WHMCS_URL= - -# ── Chat ── -TAWK_API_KEY= -TAWK_PROPERTY_ID= - -# ── GitHub (classic PAT, full scopes you need) ── -GITHUB_TOKEN= -# Create a fine-grained token with only the repositories and read permissions -# required: https://github.com/settings/personal-access-tokens/new - -# ── Central ops policy (JSON); capabilities/destructive actions default OFF ── -# Example: {"allowed_schemes":["https"],"allowed_ports":[443],"approvals":["change-123"],"plugins":{"ops_ssh":{"capabilities":["local_exec"],"destructive_actions":["uptime"],"local_actions":{"uptime":{"executable":"/usr/bin/uptime","fixed_args":[]}}}}} -HUB_SECURITY_POLICY={} -SSH_HOSTS= # JSON: [{"id":"web1","host":"1.2.3.4","user":"root","port":22,"key_path":"~/.ssh/id_ed25519"}] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5b378d..0bce14b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,34 +14,55 @@ concurrency: cancel-in-progress: true jobs: - quality: - name: Quality / Python ${{ matrix.python-version }} + rust: + name: Rust runs-on: ubuntu-24.04 timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - python-version: ["3.11", "3.13"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + - uses: dtolnay/rust-toolchain@stable with: - version: "0.8.4" - python-version: ${{ matrix.python-version }} - enable-cache: true - cache-dependency-glob: uv.lock - - name: Install locked dependencies - run: uv sync --frozen --all-groups + components: rustfmt, clippy + - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 + with: + workspaces: crates - name: Check formatting - run: uv run ruff format --check . + run: cargo fmt --check + working-directory: crates - name: Lint - run: uv run ruff check . - - name: Type check - run: uv run mypy - - name: Test without external network integrations - run: uv run pytest -m 'not integration' - - name: Build distributions - run: uv build + run: cargo clippy --all-targets -- -D warnings + working-directory: crates + - name: Test + run: cargo test --workspace + working-directory: crates + - name: Validate installation + run: cargo run -- validate + working-directory: . + + manifests: + name: Manifest version parity + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check manifest versions match + run: | + set -euo pipefail + versions=$(jq -r '.version' \ + .claude-plugin/plugin.json \ + .claude-plugin/marketplace.json \ + .codex-plugin/plugin.json \ + kimi.plugin.json | sort -u) + count=$(echo "$versions" | wc -l) + if [ "$count" -ne 1 ]; then + echo "ERROR: Plugin manifest versions do not match:" + echo " .claude-plugin/plugin.json: $(jq -r .version .claude-plugin/plugin.json)" + echo " .claude-plugin/marketplace.json: $(jq -r .version .claude-plugin/marketplace.json)" + echo " .codex-plugin/plugin.json: $(jq -r .version .codex-plugin/plugin.json)" + echo " kimi.plugin.json: $(jq -r .version kimi.plugin.json)" + exit 1 + fi + echo "All manifests at version: $versions" security: name: Security @@ -51,16 +72,6 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 - with: - version: "0.8.4" - enable-cache: true - cache-dependency-glob: uv.lock - - run: uv sync --frozen --all-groups - - name: Audit locked dependencies - run: | - uv export --frozen --no-dev --no-emit-project --output-file /tmp/runtime-requirements.txt - uv run pip-audit --require-hashes --no-deps -r /tmp/runtime-requirements.txt - name: Install Gitleaks env: GITLEAKS_VERSION: "8.30.1" @@ -85,12 +96,12 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 with: - version: "0.8.4" - - run: uv sync --frozen --all-groups + workspaces: crates - name: Run opt-in integrations - run: uv run pytest -m integration tests/integration + run: cargo test --workspace -- --ignored + working-directory: crates env: - RUN_INTEGRATION: "1" HETZNER_API_TOKEN: ${{ secrets.HETZNER_API_TOKEN }} diff --git a/.gitignore b/.gitignore index 8daad73..a4708e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,7 @@ .env -__pycache__/ -*.pyc -.pytest_cache/ -.vendor/ -vendor-manifest.json -.venv/ -dist/ -build/ -*.egg-info/ -.coverage -htmlcov/ -.mypy_cache/ -.ruff_cache/ # Rust target/ + +# Audit ledger (local data, not committed) +audit.jsonl diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0bffb5d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# AGENTS.md — Connector Hub + +## Pre-completion verification + +Before reporting any task as complete, run: + +```bash +cd crates && cargo fmt --check +cd crates && cargo clippy --all-targets -- -D warnings +cd crates && cargo test --workspace +cd crates && cargo run -- validate +``` + +All four must pass. + +## Commit conventions + +Use Conventional Commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`. + +## Repository layout + +``` +crates/ Rust workspace (the runtime) + connector-hub/ CLI binary + MCP stdio server + hub-core/ Operation catalogue, dispatch, execution contract + hub-spec/ Spec ingestion (OpenAPI 3.x + Google Discovery) + hub-auth/ Credential store, OAuth, token refresh + hub-policy/ Permission model, audit ledger + hub-net/ HTTP execution, SSRF defense +specs/ Provider spec files (JSON) +docs/adr/ Architecture Decision Records +.claude-plugin/ Claude Code plugin manifest +.codex-plugin/ Codex plugin manifest +kimi.plugin.json Kimi plugin manifest +``` + +## Adding a provider + +1. Create `specs/.json` in OpenAPI 3.0.3 or Google Discovery format. +2. Add auth entry in `crates/hub-auth/src/store.rs` `from_env()`. +3. Run `cargo run -- list` to verify operations load. +4. Run `cargo run -- validate` to confirm no duplicate IDs. + +## Plugin manifests + +Four manifests must stay version-synchronized: +- `.claude-plugin/plugin.json` +- `.claude-plugin/marketplace.json` +- `.codex-plugin/plugin.json` +- `kimi.plugin.json` + +`connector-hub validate` checks this. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index bcd55f8..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,57 +0,0 @@ -# ARCHITECTURE.md — Omni Connector Hub - -One hub. Every external service behind one interface. Credentials in env, never -in code. Connectors return mock responses when credentials are unavailable. - -> **Active rewrite**: The `crates/` Rust workspace replaces this Python -> implementation with spec-driven connectors, a type-safe execution contract, -> and an encrypted credential store. See `crates/README.md` for the new -> architecture. - -## Current Python contract - -```python -from hub.base import BaseConnector - -class SomeConnector(BaseConnector): - name = "servicename" - def __init__(self, config=None): ... - def actions(self) -> list[str] - def call(self, action: str, **params) -> dict # {"ok": bool, ...} -``` - -- Inherit `self.env("VAR")`, `self.mock` (True when required env missing), - `self.http_json(method, url, headers, payload)` (stdlib urllib, JSON in/out). -- Mock mode returns `{"ok": True, "mock": True}` — development convenience only. -- Live mode makes real HTTP calls with per-connector auth. - -## Layers - -``` -CLI / MCP server (hub/gateway.py, hub/mcp_server.py) - │ route "channel" name → connector -Registry (hub/base.py — auto-discovers connectors/*) - │ -Connectors (connectors//.py) - │ -Secrets (.env — gitignored; .env.template committed) -``` - -## Groups and services - -| Group | Services | Auth | -|---|---|---| -| llm | openai, anthropic, kimi, cloudflare | API key / account+token | -| email | gmail (OAuth2 multi-account), email (IMAP/SMTP multi-account) | OAuth / app passwords | -| hosting | whmcs, whm, cpanel | API token / user+token | -| cloud | contabo, ovh, linode, hetzner, oneprovider, ultrahost | API tokens / OAuth | -| chat | tawk.to | API key | -| github_full | github REST | PAT | -| ops | ssh_bash, browser, network, security | HUB_SECURITY_POLICY capability grants | - -## Security - -1. No secret written to disk except user-run OAuth flows (`scripts/setup_oauth.py`). -2. Logs redact credential patterns. -3. Ops connectors require `HUB_SECURITY_POLICY` capability grants — see - `hub/security/policy.py` for SSRF defense, IP pinning, and bounded execution. diff --git a/README.md b/README.md index ab9dac4..c090a57 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,158 @@ # Omni Connector Hub -One channel for AI providers, email, hosting panels, VPS clouds, chat, GitHub, -and server operations — behind one interface, with mock behavior when -credentials are absent. - -> **Status**: The Python connector library is functional and serves 21 -> connectors with 142 actions. A Rust rewrite (`crates/`) is in progress to -> provide spec-driven full-surface coverage, a type-safe execution contract, -> and an encrypted credential store. - -## Channels - -| Channel | What it does | Goes live when you set | -|---|---|---| -| `openai` | ChatGPT chat / models / embeddings | `OPENAI_API_KEY` | -| `claude` | Claude messages | `ANTHROPIC_API_KEY` | -| `kimi` | Kimi (Moonshot) chat | `MOONSHOT_API_KEY` | -| `cloudflare` | Workers AI models | `CLOUDFLARE_ACCOUNT_ID` + `CLOUDFLARE_API_TOKEN` | -| `email` | Multi-account IMAP/SMTP (read, search, send) | `EMAIL_ACCOUNTS` (JSON) | -| `gmail` | Gmail REST, multi-account OAuth2 | `GOOGLE_CLIENT_ID/SECRET` + refresh tokens | -| `whmcs` | Clients, invoices, tickets, service module actions | `WHMCS_URL` + API identifier/secret | -| `whm` | cPanel accounts, suspend/terminate, DNS zones, server status | `WHM_HOST` + root token | -| `cpanel` | Domains, email accounts, DBs, files, cron | `CPANEL_HOST` + user token | -| `hetzner` | Servers lifecycle, images, locations | `HETZNER_API_TOKEN` | -| `linode` | Linodes lifecycle, regions, types | `LINODE_API_TOKEN` | -| `contabo` | Instances lifecycle, images, snapshots | OAuth client + user creds | -| `ovh` | VPS/dedicated, IPs, account | OVH app key/secret/consumer key | -| `oneprovider` | Servers, reboots, locations | `ONEPROVIDER_API_KEY` | -| `ultrahost` | Services via WHMCS bridge | `ULTRAHOST_*` | -| `tawk` | tawk.to chats, tickets, agents | `TAWK_API_KEY` + property ID | -| `github` | Full repo/issue/PR/workflow/code-search control | `GITHUB_TOKEN` | -| `ops_ssh` | Local bash + SSH fleet commands | `HUB_SECURITY_POLICY` with capability grants | -| `ops_browser` | Fetch pages, status checks | none | -| `ops_network` | ping, DNS, ports, traceroute, headers | `HUB_SECURITY_POLICY` | -| `ops_security` | SSL expiry, risky ports, sshd audit, secret gen | `HUB_SECURITY_POLICY` | +One hub for AI providers, email, hosting panels, VPS clouds, chat, GitHub, and +server operations — 21 providers, 278 operations, behind one type-safe +interface with a hash-chained audit ledger. + +## Providers + + + +| Provider | Operations | Destructive | Auth | Spec source | +|---|---|---|---|---| +| `claude` | 2 | 0 | `ANTHROPIC_API_KEY` | hand-written | +| `cloudflare` | 2 | 0 | `CLOUDFLARE_ACCOUNT_ID` + `CLOUDFLARE_API_TOKEN` | hand-written | +| `contabo` | 8 | 0 | OAuth client + user creds | hand-written | +| `cpanel` | 11 | 0 | `CPANEL_HOST` + user:token | hand-written | +| `email` | 4 | 0 | `EMAIL_ACCOUNTS` (JSON) | built-in (IMAP/SMTP) | +| `github` | 25 | 2 | `GITHUB_TOKEN` | hand-written | +| `gmail` | 79 | 15 | `GOOGLE_CLIENT_ID/SECRET` + refresh tokens | Google Discovery | +| `hetzner` | 72 | 11 | `HETZNER_API_TOKEN` | OpenAPI 3.0.3 | +| `kimi` | 2 | 0 | `MOONSHOT_API_KEY` | hand-written | +| `linode` | 9 | 1 | `LINODE_API_TOKEN` | hand-written | +| `oneprovider` | 6 | 0 | `ONEPROVIDER_API_KEY` | hand-written | +| `openai` | 3 | 0 | `OPENAI_API_KEY` | hand-written | +| `ops_browser` | 3 | 0 | none | built-in (local) | +| `ops_network` | 5 | 0 | `HUB_SECURITY_POLICY` | built-in (local) | +| `ops_security` | 5 | 0 | `HUB_SECURITY_POLICY` | built-in (local) | +| `ops_ssh` | 3 | 2 | `HUB_SECURITY_POLICY` | built-in (local) | +| `ovh` | 7 | 0 | OVH app key/secret/consumer key | hand-written | +| `tawk` | 8 | 0 | `TAWK_API_KEY` + property ID | hand-written | +| `ultrahost` | 6 | 0 | `ULTRAHOST_*` | hand-written | +| `whm` | 9 | 1 | `WHM_HOST` + root token | hand-written | +| `whmcs` | 9 | 0 | `WHMCS_URL` + API identifier/secret | hand-written | + +**Total: 278 operations (32 destructive)** ## Quick start ```bash -cd connector-hub -cp .env.template .env # fill in what you use -pip install -e '.[test]' # or: uv sync --frozen --all-groups -python3 -m hub.gateway list # see every channel, MOCK vs LIVE -python3 -m hub.gateway status gmail -python3 -m hub.gateway call hetzner list_servers -``` +# Build from source +cd crates +cargo build --release -For a reproducible developer install (Python 3.11–3.13), install -[uv](https://docs.astral.sh/uv/getting-started/installation/) and run: +# List all providers +connector-hub list -```bash -uv sync --frozen --all-groups -uv run connector-hub list +# Search for operations +connector-hub search "delete server" + +# Describe a specific operation +connector-hub describe hetzner.servers.delete + +# Start the MCP stdio server +connector-hub mcp + +# Validate the installation +connector-hub validate ``` -## Mock vs Live +## Architecture -Missing credentials select mock mode — calls return `{"ok": True, "mock": True}` -with the action echoed. This is for development only. When credentials are set -in `.env`, the connector goes live and makes real HTTP calls. +Connectors are **data, not code**. Provider specs (OpenAPI 3.x or Google +Discovery JSON) are compiled into an operation catalogue at startup. The MCP +surface is small and fixed while the reachable surface is complete: -## Use as MCP server +``` +provider spec (OpenAPI / Google Discovery / hand-written JSON) + │ loaded at startup + ▼ +operation catalogue (every endpoint, typed, classified) + │ + ├── search_operations(query, provider?) → find any endpoint + ├── describe_operation(id) → exact JSON Schema + ├── call_operation(id, args, account, …) → validated execution + └── list_providers() → provider summary +``` + +### Crate layout -```bash -python3 -m hub.gateway mcp ``` +crates/ + connector-hub/ CLI binary + rmcp MCP stdio server + hub-core/ Operation catalogue, dispatch, execution-state envelope + hub-spec/ Spec ingestion: OpenAPI 3.x + Google Discovery → operations + hub-auth/ Credential store, OAuth, token refresh + hub-policy/ Permission model, capability grants, hash-chained audit ledger + hub-net/ HTTP execution: SSRF validation, IP pinning, retries, redaction +specs/ Provider spec files (JSON) +``` + +### Execution contract -Each action is exposed as a tool named `hub____`. -The server bounds concurrent work (default 8, `HUB_MCP_MAX_CONCURRENCY`) and -applies a deadline to every call (default 30s, `HUB_MCP_CALL_TIMEOUT`). +Every operation result is a typed enum — non-execution states cannot carry +`executed: true`: -## Gmail OAuth (multiple accounts) +- `Succeeded { executed: true, data }` — the only state with real output +- `DryRun { would_execute, mutation_class }` — what would happen +- `ConfirmationRequired { provider, operation, token_format }` — destructive ops need confirmation +- `ConfigurationRequired { provider, missing }` — credentials or runtime not available +- `PermissionDenied` — policy refused the operation + +Destructive operations require an explicit confirmation token or a standing +policy grant. There is no env-var-presence shortcut to liveness. + +### Audit ledger + +Every policy decision (granted or refused) is appended to a BLAKE3 hash-chained +JSONL audit ledger. Verify integrity with: ```bash -python3 scripts/setup_oauth.py # per account: opens consent URL, mints refresh token -python3 -m hub.gateway call gmail send '{"label":"main","to":"x@y.com","subject":"hi","body":"test"}' +connector-hub audit-verify audit.jsonl ``` +See `docs/adr/0004-audit-ledger-format.md` for the format specification. + ## Security model -- Secrets live in `.env` (git-ignored) or platform secret managers. Never pass - them as action parameters. -- Logs redact values matching KEY/TOKEN/SECRET/PASS patterns. -- Ops connectors (`ops_ssh`, `ops_network`) require deployment capabilities, - allowlisted actions, and approval identifiers configured in `HUB_SECURITY_POLICY`. - See `hub/security/policy.py` for SSRF defense, IP pinning, redirect validation, - and bounded subprocess execution. -- OAuth refresh tokens are minted only by the setup script you run yourself. +- Credentials live in environment variables or an encrypted store. Never in + action parameters, never in tool output. +- All HTTP goes through one `NetClient` with SSRF validation, IP pinning, + redirect control, and bounded retries. +- Ops connectors (`ops_ssh`, `ops_network`) require `HUB_SECURITY_POLICY` + capability grants. +- OAuth refresh tokens are never serialised into MCP tool results. + +## Adding a provider + +1. Create `specs/.json` in OpenAPI 3.0.3 or Google Discovery format. +2. Add auth entry in `crates/hub-auth/src/store.rs` `from_env()`. +3. Run `connector-hub list` to verify operations load. +4. Run `connector-hub validate` to confirm no duplicate IDs. ## Tests ```bash -uv run ruff format --check . -uv run ruff check . -uv run mypy -uv run pytest -m "not integration" -RUN_INTEGRATION=1 uv run pytest -m integration tests/integration +cd crates +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo test --workspace +cargo run -- validate ``` -## Data Sources +## Related projects -- https://github.com/CodeWithJuber/forgekit -- https://github.com/CodeWithJuber/hikmah-stack +- [CodeWithJuber/forgekit](https://github.com/CodeWithJuber/forgekit) — delivery + and substrate (memory, foresight, guardrail hooks) +- [CodeWithJuber/hikmah-stack](https://github.com/CodeWithJuber/hikmah-stack) — + judgment (deterministic cognitive kernel, decision scoring, audit ledger) -Provider endpoints used by individual connectors are documented in their source -modules. +## CI -## Layout +Every pull request runs: Rust formatting, clippy with deny warnings, workspace +tests, installation validation, and secret scanning. Real-provider integration +tests are opt-in behind the protected `protected-integration` environment. -``` -crates/ Rust workspace (in progress) -hub/ Python registry, gateway CLI, MCP server -connectors/ Python connector implementations (one module per service) -mcp/mcp.json drop-in MCP client config -scripts/ OAuth setup wizard -tests/ unit and opt-in integration tests -``` - -## CI +## License -Every pull request runs: formatting, linting, type checking, non-integration -tests on Python 3.11 and 3.13, package builds, dependency auditing, and secret -scanning. Real-provider tests are opt-in behind the protected -`protected-integration` environment. +MIT diff --git a/connectors/__init__.py b/connectors/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/connectors/chat/__init__.py b/connectors/chat/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/connectors/chat/tawk.py b/connectors/chat/tawk.py deleted file mode 100644 index a709904..0000000 --- a/connectors/chat/tawk.py +++ /dev/null @@ -1,109 +0,0 @@ -"""tawk.to REST API connector (chat + tickets + agents). - -Auth: API key from the tawk.to dashboard (Administration > REST API), sent as -an Authorization Bearer token. Base URL: https://api.tawk.to/v1 - -NOTE on endpoint versioning: tawk.to's public REST surface has changed across -releases and some paths are gated per plan/property. The paths below follow the -documented `/chats` and `/tickets` resources of the v1 REST API. If your -account returns 404 on a path, check the current tawk.to developer docs for the -exact route name for your API version — the request/auth plumbing here is -correct either way. - -Env: TAWK_API_KEY, TAWK_PROPERTY_ID -""" -from hub.base import BaseConnector, ConnectorError, register - -BASE = "https://api.tawk.to/v1" - - -@register -class TawkConnector(BaseConnector): - name = "tawk" - required_env = ["TAWK_API_KEY", "TAWK_PROPERTY_ID"] - description = "tawk.to live chat + ticketing via REST API" - - read_only_actions = frozenset(['list_chats', 'get_chat', 'list_tickets', 'get_ticket', 'list_agents', 'property_info']) - mutating_actions = frozenset(['send_message', 'reply_ticket']) - destructive_actions = frozenset([]) - dry_run_actions = frozenset(['send_message', 'reply_ticket']) - - def actions(self): - return [ - "list_chats", - "get_chat", - "send_message", - "list_tickets", - "get_ticket", - "reply_ticket", - "list_agents", - "property_info", - ] - - # --- internal --------------------------------------------------------- - def _headers(self): - return {"Authorization": f"Bearer {self.env('TAWK_API_KEY')}"} - - def _prop(self, params): - prop = params.pop("property_id", None) or self.env("TAWK_PROPERTY_ID") - if not prop: - raise ConnectorError(f"{self.name}: property_id required (param or TAWK_PROPERTY_ID)") - return prop - - def _get(self, path, params=None): - qs = "" - if params: - from urllib.parse import urlencode - qs = "?" + urlencode({k: v for k, v in params.items() if v is not None}) - return self.http_json("GET", BASE + path + qs, headers=self._headers()) - - def _post(self, path, payload): - return self.http_json("POST", BASE + path, headers=self._headers(), payload=payload) - - # --- live actions ------------------------------------------------------ - def _live(self, action, **params): - if action == "list_chats": - prop = self._prop(params) - # Documented: GET /v1/chats?property_id=...&status=open|pending|closed - return self._get("/chats", {"property_id": prop, "status": params.get("status")}) - - if action == "get_chat": - chat_id = self._required(params, "chat_id") - return self._get(f"/chats/{chat_id}") - - if action == "send_message": - chat_id = self._required(params, "chat_id") - message = self._required(params, "message") - # Documented: POST /v1/chats/{chat_id}/messages {"message": "..."} - return self._post(f"/chats/{chat_id}/messages", {"message": message}) - - if action == "list_tickets": - prop = self._prop(params) - # Documented: GET /v1/tickets?property_id=...&status=open|pending|closed - return self._get("/tickets", {"property_id": prop, "status": params.get("status")}) - - if action == "get_ticket": - ticket_id = self._required(params, "ticket_id") - return self._get(f"/tickets/{ticket_id}") - - if action == "reply_ticket": - ticket_id = self._required(params, "ticket_id") - message = self._required(params, "message") - # Documented: POST /v1/tickets/{ticket_id}/replies {"message": "..."} - return self._post(f"/tickets/{ticket_id}/replies", {"message": message}) - - if action == "list_agents": - prop = self._prop(params) - return self._get("/agents", {"property_id": prop}) - - if action == "property_info": - prop = self._prop(params) - return self._get(f"/properties/{prop}") - - raise ConnectorError(f"{self.name}: unhandled action '{action}'") - - def _required(self, params, key): - val = params.get(key) - if not val: - raise ConnectorError(f"{self.name}: '{key}' is required") - return val diff --git a/connectors/cloud/__init__.py b/connectors/cloud/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/connectors/cloud/contabo.py b/connectors/cloud/contabo.py deleted file mode 100644 index ae7fa55..0000000 --- a/connectors/cloud/contabo.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Contabo connector. - -API: https://api.contabo.com/v1, Bearer token obtained via OAuth2 -resource-owner password grant from - POST https://auth.contabo.com/auth/realms/contabo/protocol/openid-connect/token -The access token is cached in-memory (per connector instance) and refreshed -shortly before expiry. The token is never logged or written to disk. - -Env: CONTABO_CLIENT_ID, CONTABO_CLIENT_SECRET, CONTABO_API_USER, - CONTABO_API_PASSWORD -""" -import time -import urllib.parse -import urllib.request -import urllib.error -import json - -from hub.base import BaseConnector, ConnectorError, register - -BASE = "https://api.contabo.com/v1" -TOKEN_URL = ("https://auth.contabo.com/auth/realms/contabo/" - "protocol/openid-connect/token") - - -@register -class ContaboConnector(BaseConnector): - name = "contabo" - required_env = ["CONTABO_CLIENT_ID", "CONTABO_CLIENT_SECRET", - "CONTABO_API_USER", "CONTABO_API_PASSWORD"] - description = "Contabo: VPS instances, images, snapshots (OAuth2)" - - def __init__(self, config=None): - super().__init__(config=config) - self._token = None - self._token_expires_at = 0.0 - - read_only_actions = frozenset(['list_instances', 'get_instance', 'list_images', 'list_snapshots']) - mutating_actions = frozenset(['create_instance', 'start', 'restart']) - destructive_actions = frozenset(['stop']) - dry_run_actions = frozenset(['create_instance', 'start', 'restart', 'stop']) - - def actions(self): - return [ - "list_instances", "get_instance", "create_instance", "start", - "stop", "restart", "list_images", "list_snapshots", - ] - - # --- OAuth2 ---------------------------------------------------------- - def _get_token(self): - # Refresh 60s before expiry to avoid races. - if self._token and time.time() < self._token_expires_at - 60: - return self._token - form = urllib.parse.urlencode({ - "grant_type": "password", - "client_id": self.env("CONTABO_CLIENT_ID"), - "client_secret": self.env("CONTABO_CLIENT_SECRET"), - "username": self.env("CONTABO_API_USER"), - "password": self.env("CONTABO_API_PASSWORD"), - }).encode() - req = urllib.request.Request(TOKEN_URL, data=form, method="POST") - req.add_header("Content-Type", "application/x-www-form-urlencoded") - req.add_header("Accept", "application/json") - try: - with urllib.request.urlopen(req, timeout=30) as resp: - data = json.loads(resp.read().decode() or "{}") - except urllib.error.HTTPError as e: - raise ConnectorError( - f"contabo OAuth2 token request failed HTTP {e.code}") - except urllib.error.URLError as e: - raise ConnectorError(f"contabo OAuth2 connection failed: {e.reason}") - token = data.get("access_token") - if not token: - raise ConnectorError("contabo OAuth2 response missing access_token") - self._token = token - self._token_expires_at = time.time() + int(data.get("expires_in", 300)) - return token - - def _req(self, method, path, payload=None): - headers = { - "Authorization": f"Bearer {self._get_token()}", - "x-request-id": f"hub-contabo-{int(time.time() * 1000)}", - } - return self.http_json(method, BASE + path, headers=headers, - payload=payload) - - def _live(self, action, **params): - if action == "list_instances": - return self._req("GET", "/compute/instances") - if action == "get_instance": - iid = params.get("id") - if not iid: - raise ConnectorError("contabo: get_instance requires id") - return self._req("GET", f"/compute/instances/{iid}") - if action == "create_instance": - image_id = (params.get("image_id") or params.get("imageId") - or params.get("image")) - product_id = (params.get("product_id") or params.get("productId") - or params.get("product")) - if not (image_id and product_id): - raise ConnectorError( - "contabo: create_instance requires image_id and product_id " - "(e.g. product_id='V1')") - payload = {"imageId": image_id, "productId": product_id} - # Optional fields per Contabo Compute API. - for opt in ("region", "period", "displayName", "rootPassword", - "sshKeys", "userData", "license", "defaultUser", - "addOns"): - if params.get(opt) is not None: - payload[opt] = params[opt] - return self._req("POST", "/compute/instances", payload=payload) - if action in ("start", "stop", "restart"): - iid = params.get("id") - if not iid: - raise ConnectorError(f"contabo: {action} requires id") - return self._req("POST", - f"/compute/instances/{iid}/actions/{action}") - if action == "list_images": - return self._req("GET", "/compute/images") - if action == "list_snapshots": - iid = params.get("instance_id") or params.get("id") - if not iid: - raise ConnectorError( - "contabo: list_snapshots requires instance_id") - return self._req("GET", f"/compute/instances/{iid}/snapshots") - raise ConnectorError(f"contabo: unhandled action '{action}'") diff --git a/connectors/cloud/hetzner.py b/connectors/cloud/hetzner.py deleted file mode 100644 index 35f4b9e..0000000 --- a/connectors/cloud/hetzner.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Hetzner Cloud connector. - -API: https://api.hetzner.cloud/v1, Bearer token auth. -Set HETZNER_API_TOKEN to go live (Hetzner Cloud Console -> Security -> API tokens). -""" -from hub.base import BaseConnector, ConnectorError, register - -BASE = "https://api.hetzner.cloud/v1" - - -@register -class HetznerConnector(BaseConnector): - name = "hetzner" - required_env = ["HETZNER_API_TOKEN"] - description = "Hetzner Cloud: servers, images, locations, ssh keys" - - read_only_actions = frozenset(['list_servers', 'get_server', 'list_images', 'list_locations', 'list_ssh_keys']) - mutating_actions = frozenset(['create_server', 'power_on', 'reboot']) - destructive_actions = frozenset(['power_off', 'delete_server']) - dry_run_actions = frozenset(['create_server', 'power_on', 'reboot', 'power_off', 'delete_server']) - - def actions(self): - return [ - "list_servers", "get_server", "create_server", "power_on", - "power_off", "reboot", "delete_server", "list_images", - "list_locations", "list_ssh_keys", - ] - - def _headers(self): - return {"Authorization": f"Bearer {self.env('HETZNER_API_TOKEN')}"} - - def _req(self, method, path, payload=None): - return self.http_json(method, BASE + path, headers=self._headers(), - payload=payload) - - def _live(self, action, **params): - if action == "list_servers": - return self._req("GET", "/servers") - if action == "get_server": - sid = params.get("id") - if not sid: - raise ConnectorError("hetzner: get_server requires id") - return self._req("GET", f"/servers/{sid}") - if action == "create_server": - name = params.get("name") - server_type = params.get("server_type") - image = params.get("image") - if not (name and server_type and image): - raise ConnectorError( - "hetzner: create_server requires name, server_type, image") - payload = {"name": name, "server_type": server_type, "image": image} - if params.get("location"): - payload["location"] = params["location"] - for opt in ("ssh_keys", "volumes", "networks", "user_data", - "labels", "automount", "start_after_create", - "placement_group", "datacenter", "firewalls"): - if params.get(opt) is not None: - payload[opt] = params[opt] - return self._req("POST", "/servers", payload=payload) - if action in ("power_on", "power_off", "reboot"): - sid = params.get("id") - if not sid: - raise ConnectorError(f"hetzner: {action} requires id") - return self._req("POST", f"/servers/{sid}/actions/{action}") - if action == "delete_server": - sid = params.get("id") - if not sid: - raise ConnectorError("hetzner: delete_server requires id") - return self._req("DELETE", f"/servers/{sid}") - if action == "list_images": - return self._req("GET", "/images") - if action == "list_locations": - return self._req("GET", "/locations") - if action == "list_ssh_keys": - return self._req("GET", "/ssh_keys") - raise ConnectorError(f"hetzner: unhandled action '{action}'") diff --git a/connectors/cloud/linode.py b/connectors/cloud/linode.py deleted file mode 100644 index 7a7b894..0000000 --- a/connectors/cloud/linode.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Linode (Akamai Cloud) connector. - -API: https://api.linode.com/v4, Bearer personal access token. -Set LINODE_API_TOKEN to go live (Linode Cloud Manager -> API Tokens). -""" -from hub.base import BaseConnector, ConnectorError, register - -BASE = "https://api.linode.com/v4" - - -@register -class LinodeConnector(BaseConnector): - name = "linode" - required_env = ["LINODE_API_TOKEN"] - description = "Linode: instances, regions, types" - - read_only_actions = frozenset(['list_linodes', 'get_linode', 'list_regions', 'list_types']) - mutating_actions = frozenset(['create_linode', 'boot', 'reboot']) - destructive_actions = frozenset(['shutdown', 'delete_linode']) - dry_run_actions = frozenset(['create_linode', 'boot', 'reboot', 'shutdown', 'delete_linode']) - - def actions(self): - return [ - "list_linodes", "get_linode", "create_linode", "boot", - "shutdown", "reboot", "delete_linode", "list_regions", - "list_types", - ] - - def _headers(self): - return {"Authorization": f"Bearer {self.env('LINODE_API_TOKEN')}"} - - def _req(self, method, path, payload=None): - return self.http_json(method, BASE + path, headers=self._headers(), - payload=payload) - - def _live(self, action, **params): - if action == "list_linodes": - return self._req("GET", "/linode/instances") - if action == "get_linode": - lid = params.get("id") - if not lid: - raise ConnectorError("linode: get_linode requires id") - return self._req("GET", f"/linode/instances/{lid}") - if action == "create_linode": - region = params.get("region") - ltype = params.get("type") - image = params.get("image") - root_pass = params.get("root_pass") - if not (region and ltype and image and root_pass): - raise ConnectorError( - "linode: create_linode requires region, type, image, " - "root_pass") - payload = { - "region": region, - "type": ltype, - "image": image, - "root_pass": root_pass, - } - if params.get("label"): - payload["label"] = params["label"] - for opt in ("authorized_keys", "authorized_users", "backups_enabled", - "booted", "interfaces", "metadata", "placement_group", - "stackscript_data", "stackscript_id", "tags", "group"): - if params.get(opt) is not None: - payload[opt] = params[opt] - return self._req("POST", "/linode/instances", payload=payload) - if action in ("boot", "shutdown", "reboot"): - lid = params.get("id") - if not lid: - raise ConnectorError(f"linode: {action} requires id") - payload = None - if action == "boot" and params.get("config_id"): - payload = {"config_id": params["config_id"]} - return self._req("POST", f"/linode/instances/{lid}/{action}", - payload=payload) - if action == "delete_linode": - lid = params.get("id") - if not lid: - raise ConnectorError("linode: delete_linode requires id") - return self._req("DELETE", f"/linode/instances/{lid}") - if action == "list_regions": - return self._req("GET", "/regions") - if action == "list_types": - return self._req("GET", "/linode/types") - raise ConnectorError(f"linode: unhandled action '{action}'") diff --git a/connectors/cloud/oneprovider.py b/connectors/cloud/oneprovider.py deleted file mode 100644 index 0455356..0000000 --- a/connectors/cloud/oneprovider.py +++ /dev/null @@ -1,74 +0,0 @@ -"""OneProvider connector (dedicated/cloud servers). - -OneProvider exposes an API ("OneProvider API v1") for its OnePortal customers, -authenticated with an API key sent as a Bearer token: - - Authorization: Bearer - Base URL: https://api.oneprovider.com - -NOTE: OneProvider's public API surface is limited and its documentation is -sparse; the paths below follow the documented "/servers"-style v1 resource -layout and are best-effort. If an endpoint differs on the live API, adjust the -path constants in `_live()` accordingly — auth, error handling and the mock -contract are unaffected. - -Env: ONEPROVIDER_API_KEY -""" -from hub.base import BaseConnector, ConnectorError, register - -BASE = "https://api.oneprovider.com" - - -@register -class OneProviderConnector(BaseConnector): - name = "oneprovider" - required_env = ["ONEPROVIDER_API_KEY"] - description = "OneProvider: dedicated servers, locations, templates, bandwidth" - - read_only_actions = frozenset(['list_servers', 'get_server', 'list_locations', 'list_templates', 'bandwidth']) - mutating_actions = frozenset(['reboot']) - destructive_actions = frozenset([]) - dry_run_actions = frozenset(['reboot']) - - def actions(self): - return [ - "list_servers", "get_server", "reboot", "list_locations", - "list_templates", "bandwidth", - ] - - def _headers(self): - return {"Authorization": f"Bearer {self.env('ONEPROVIDER_API_KEY')}"} - - def _req(self, method, path, payload=None): - return self.http_json(method, BASE + path, headers=self._headers(), - payload=payload) - - def _live(self, action, **params): - if action == "list_servers": - # OneProvider API v1: GET /servers — list all servers on the account. - return self._req("GET", "/servers") - if action == "get_server": - sid = params.get("id") - if not sid: - raise ConnectorError("oneprovider: get_server requires id") - # GET /servers/{id} — details for one server. - return self._req("GET", f"/servers/{sid}") - if action == "reboot": - sid = params.get("id") - if not sid: - raise ConnectorError("oneprovider: reboot requires id") - # POST /servers/{id}/reboot — power-cycle the server. - return self._req("POST", f"/servers/{sid}/reboot") - if action == "list_locations": - # GET /locations — available datacenter locations for ordering. - return self._req("GET", "/locations") - if action == "list_templates": - # GET /templates — OS reinstall templates. - return self._req("GET", "/templates") - if action == "bandwidth": - sid = params.get("id") - if not sid: - raise ConnectorError("oneprovider: bandwidth requires id") - # GET /servers/{id}/bandwidth — traffic usage for one server. - return self._req("GET", f"/servers/{sid}/bandwidth") - raise ConnectorError(f"oneprovider: unhandled action '{action}'") diff --git a/connectors/cloud/ovh.py b/connectors/cloud/ovh.py deleted file mode 100644 index d3a9596..0000000 --- a/connectors/cloud/ovh.py +++ /dev/null @@ -1,135 +0,0 @@ -"""OVHcloud connector (VPS, dedicated servers, account). - -Auth: OVH application-key signature scheme. -Each request is signed as: - "$1$" + sha1(app_secret + "+" + consumer_key + "+" + METHOD + "+" - + url + "+" + body + "+" + timestamp) -where timestamp is the server time fetched from https://{endpoint}/1.0/auth/time -(never the local clock — signatures fail on clock skew). - -Env: - OVH_ENDPOINT regional endpoint shorthand (default "ovh-eu") or a full - API host such as "api.ovh.com" - OVH_APP_KEY application key (AK) - OVH_APP_SECRET application secret (AS) - OVH_CONSUMER_KEY consumer key (CK) - -Known endpoint shorthands: - ovh-eu -> api.ovh.com ovh-us -> api.us.ovhcloud.com - ovh-ca -> api.ca.ovh.com kimsufi-eu -> eu.api.kimsufi.com - kimsufi-ca -> ca.api.kimsufi.com - soyoustart-eu -> eu.api.soyoustart.com - soyoustart-ca -> ca.api.soyoustart.com -""" -import hashlib -import json -import time - -from hub.base import BaseConnector, ConnectorError, register - -ENDPOINTS = { - "ovh-eu": "api.ovh.com", - "ovh-us": "api.us.ovhcloud.com", - "ovh-ca": "api.ca.ovh.com", - "kimsufi-eu": "eu.api.kimsufi.com", - "kimsufi-ca": "ca.api.kimsufi.com", - "soyoustart-eu": "eu.api.soyoustart.com", - "soyoustart-ca": "ca.api.soyoustart.com", -} - - -def build_signature(app_secret, consumer_key, method, url, body, timestamp): - """Return the OVH request signature: "$1$" + sha1(AS+CK+METHOD+url+body+ts).""" - raw = "+".join([ - app_secret, consumer_key, method.upper(), url, body or "", - str(int(timestamp)), - ]) - return "$1$" + hashlib.sha1(raw.encode("utf-8")).hexdigest() - - -@register -class OvhConnector(BaseConnector): - name = "ovh" - required_env = ["OVH_APP_KEY", "OVH_APP_SECRET", "OVH_CONSUMER_KEY"] - description = "OVHcloud: VPS, dedicated servers, IPs, account info" - - def __init__(self, config=None): - super().__init__(config=config) - self._server_time_offset = None # server_ts - local_ts - - read_only_actions = frozenset(['list_vps', 'get_vps', 'list_dedicated', 'get_dedicated', 'get_me', 'list_ips']) - mutating_actions = frozenset(['reboot_vps']) - destructive_actions = frozenset([]) - dry_run_actions = frozenset(['reboot_vps']) - - def actions(self): - return [ - "list_vps", "get_vps", "reboot_vps", "list_dedicated", - "get_dedicated", "get_me", "list_ips", - ] - - # --- endpoint / signing ---------------------------------------------- - def _api_host(self): - ep = self.env("OVH_ENDPOINT", "ovh-eu") or "ovh-eu" - ep = ep.strip() - if ep in ENDPOINTS: - return ENDPOINTS[ep] - if ep.startswith("http://") or ep.startswith("https://"): - ep = ep.split("://", 1)[1] - return ep.rstrip("/") - - def _timestamp(self): - """OVH server time (cached offset), falling back to local time.""" - if self._server_time_offset is None: - url = f"https://{self._api_host()}/1.0/auth/time" - # Unauthenticated plain-text endpoint returning epoch seconds. - resp = self.http_json("GET", url) - data = resp.get("data") - raw = data.get("raw") if isinstance(data, dict) else data - try: - server_ts = int(str(raw).strip()) - except (TypeError, ValueError): - raise ConnectorError( - "ovh: could not parse server time from /auth/time") - self._server_time_offset = server_ts - time.time() - return int(time.time() + self._server_time_offset) - - def _req(self, method, path, payload=None): - url = f"https://{self._api_host()}/1.0{path}" - body = json.dumps(payload) if payload is not None else "" - ts = self._timestamp() - headers = { - "X-Ovh-Application": self.env("OVH_APP_KEY"), - "X-Ovh-Consumer": self.env("OVH_CONSUMER_KEY"), - "X-Ovh-Timestamp": str(ts), - "X-Ovh-Signature": build_signature( - self.env("OVH_APP_SECRET"), self.env("OVH_CONSUMER_KEY"), - method, url, body, ts), - } - return self.http_json(method, url, headers=headers, payload=payload) - - def _live(self, action, **params): - if action == "list_vps": - return self._req("GET", "/vps") - if action == "get_vps": - name = params.get("name") or params.get("id") - if not name: - raise ConnectorError("ovh: get_vps requires name") - return self._req("GET", f"/vps/{name}") - if action == "reboot_vps": - name = params.get("name") or params.get("id") - if not name: - raise ConnectorError("ovh: reboot_vps requires name") - return self._req("POST", f"/vps/{name}/reboot") - if action == "list_dedicated": - return self._req("GET", "/dedicated/server") - if action == "get_dedicated": - name = params.get("name") or params.get("id") - if not name: - raise ConnectorError("ovh: get_dedicated requires name") - return self._req("GET", f"/dedicated/server/{name}") - if action == "get_me": - return self._req("GET", "/me") - if action == "list_ips": - return self._req("GET", "/ip") - raise ConnectorError(f"ovh: unhandled action '{action}'") diff --git a/connectors/cloud/ultrahost.py b/connectors/cloud/ultrahost.py deleted file mode 100644 index 70a7791..0000000 --- a/connectors/cloud/ultrahost.py +++ /dev/null @@ -1,121 +0,0 @@ -"""UltaHost connector (WHMCS bridge). - -UltaHost does NOT publish a fully public VPS management API. This connector -therefore works as a WHMCS bridge: UltaHost's billing/client area is a -WHMCS-style deployment, and service actions are performed through a configured -WHMCS-compatible billing API endpoint. - -Modes: - * ULTRAHOST_WHMCS_URL set -> live bridge: WHMCS-style form POSTs - (application/x-www-form-urlencoded) to that URL, e.g. - POST {ULTRAHOST_WHMCS_URL} - action=module_suspend / module_unsuspend / module_reboot / ... - &accountid=&serviceid= - authenticated with ULTRAHOST_API_USER + ULTRAHOST_API_KEY - (WHMCS API identifier/secret style credentials). - * ULTRAHOST_WHMCS_URL unset (but key+user present) -> typed configuration - failure; requests and their potentially sensitive fields are not echoed. - * No credentials at all -> standard hub mock mode. - -Env: - ULTRAHOST_API_KEY WHMCS API secret / token - ULTRAHOST_API_USER WHMCS API identifier / admin user - ULTRAHOST_WHMCS_URL optional; e.g. https://billing.example.com/includes/api.php -""" -import json -import urllib.parse -import urllib.request -import urllib.error - -from hub.base import BaseConnector, ConnectorError, register - - -@register -class UltaHostConnector(BaseConnector): - name = "ultrahost" - required_env = ["ULTRAHOST_API_KEY", "ULTRAHOST_API_USER"] - description = "UltaHost VPS via WHMCS-bridge billing API" - - read_only_actions = frozenset(['list_services', 'get_service', 'status']) - mutating_actions = frozenset(['reboot', 'start']) - destructive_actions = frozenset(['stop']) - dry_run_actions = frozenset(['reboot', 'start', 'stop']) - - def actions(self): - return ["list_services", "get_service", "reboot", "start", "stop", - "status"] - - # --- WHMCS bridge ------------------------------------------------------ - def _whmcs_url(self): - url = self.env("ULTRAHOST_WHMCS_URL", "") or "" - return url.strip() - - def _whmcs_post(self, form_fields): - """WHMCS-style form POST; returns parsed JSON dict.""" - form = dict(form_fields) - # WHMCS API credential fields are never logged or returned. - form.setdefault("identifier", self.env("ULTRAHOST_API_USER")) - form.setdefault("secret", self.env("ULTRAHOST_API_KEY")) - form.setdefault("username", self.env("ULTRAHOST_API_USER")) - form.setdefault("accesskey", self.env("ULTRAHOST_API_KEY")) - form.setdefault("responsetype", "json") - body = urllib.parse.urlencode(form).encode() - req = urllib.request.Request(self._whmcs_url(), data=body, method="POST") - req.add_header("Content-Type", "application/x-www-form-urlencoded") - req.add_header("Accept", "application/json") - try: - with urllib.request.urlopen(req, timeout=30) as resp: - raw = resp.read().decode() or "{}" - try: - data = json.loads(raw) - except json.JSONDecodeError: - data = {"raw": raw[:1000]} - except urllib.error.HTTPError as e: - raise ConnectorError( - f"ultrahost WHMCS bridge HTTP {e.code}: " - f"{e.read().decode(errors='replace')[:300]}") - except urllib.error.URLError as e: - raise ConnectorError( - f"ultrahost WHMCS bridge connection failed: {e.reason}") - if isinstance(data, dict) and data.get("result") == "error": - raise ConnectorError( - f"ultrahost WHMCS bridge error: {data.get('message', 'unknown')}") - return {"ok": True, "bridge": "whmcs", "data": data} - - def _live(self, action, **params): - if not self._whmcs_url(): - raise ConnectorError( - "ultrahost requires ULTRAHOST_WHMCS_URL", - "configuration_required", - ) - - if action == "list_services": - return self._whmcs_post({"action": "GetClientsProducts", - "limitnum": params.get("limit", 100)}) - if action == "get_service": - sid = params.get("id") or params.get("serviceid") - if not sid: - raise ConnectorError("ultrahost: get_service requires id") - return self._whmcs_post({"action": "GetClientsProducts", - "serviceid": sid}) - if action in ("reboot", "start", "stop"): - sid = params.get("id") or params.get("serviceid") - if not sid: - raise ConnectorError(f"ultrahost: {action} requires id") - # WHMCS module custom command -> routed to the VPS module - # (suspend/unsuspend used for stop/start; reboot as custom cmd). - module_cmd = {"reboot": "reboot", "start": "unsuspend", - "stop": "suspend"}[action] - if module_cmd in ("suspend", "unsuspend"): - return self._whmcs_post({"action": f"Module{module_cmd.capitalize()}", - "accountid": sid, "serviceid": sid}) - return self._whmcs_post({"action": "ModuleCustom", - "accountid": sid, "serviceid": sid, - "func_name": module_cmd}) - if action == "status": - sid = params.get("id") or params.get("serviceid") - if not sid: - raise ConnectorError("ultrahost: status requires id") - return self._whmcs_post({"action": "GetClientsProducts", - "serviceid": sid, "stats": "true"}) - raise ConnectorError(f"ultrahost: unhandled action '{action}'") diff --git a/connectors/email/__init__.py b/connectors/email/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/connectors/email/gmail_oauth.py b/connectors/email/gmail_oauth.py deleted file mode 100644 index edfcf89..0000000 --- a/connectors/email/gmail_oauth.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Gmail OAuth2 multi-account connector. - -Env configuration: - - GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com - GOOGLE_CLIENT_SECRET=xxx - GMAIL_ACCOUNTS=work:a@gmail.com,personal:b@gmail.com # label:email pairs - GMAIL_REFRESH_TOKEN_WORK=1//... # per-label refresh token - GMAIL_REFRESH_TOKEN_PERSONAL=1//... - -Create refresh tokens with scripts/setup_oauth.py. Stdlib only: Gmail REST API -via self.http_json, token refresh via urllib with a urlencoded form. -Refresh tokens are never printed or logged. -""" -import base64 -import json -import urllib.parse -import urllib.request -import urllib.error -from email.message import EmailMessage - -from hub.base import BaseConnector, ConnectorError, register - -TOKEN_URL = "https://oauth2.googleapis.com/token" -API_BASE = "https://gmail.googleapis.com/gmail/v1/users/me" - - -@register -class GmailOAuthConnector(BaseConnector): - name = "gmail" - required_env = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"] - description = "Gmail multi-account via OAuth2 refresh tokens (REST API, stdlib only)." - - read_only_actions = frozenset(['list_accounts', 'get_access_token', 'list_messages', 'get_message']) - mutating_actions = frozenset(['send']) - destructive_actions = frozenset([]) - dry_run_actions = frozenset(['send']) - - def actions(self): - return ["list_accounts", "get_access_token", "send", "list_messages", "get_message"] - - # --- account config -------------------------------------------------- - def _accounts(self): - """Return {label: email} parsed from GMAIL_ACCOUNTS.""" - raw = self.env("GMAIL_ACCOUNTS", "") or "" - accounts = {} - for pair in raw.split(","): - pair = pair.strip() - if not pair: - continue - if ":" not in pair: - raise ConnectorError( - f"{self.name}: GMAIL_ACCOUNTS entry '{pair}' must be 'label:email'" - ) - label, email = pair.split(":", 1) - accounts[label.strip()] = email.strip() - return accounts - - def _require_label(self, label): - if not label: - raise ConnectorError(f"{self.name}: action requires 'label'") - accounts = self._accounts() - if accounts and label not in accounts: - raise ConnectorError( - f"{self.name}: unknown label '{label}'. Known: {', '.join(accounts)}" - ) - return label - - def _refresh_token(self, label): - token = self.env(f"GMAIL_REFRESH_TOKEN_{label.upper()}") - if not token: - raise ConnectorError( - f"{self.name}: missing GMAIL_REFRESH_TOKEN_{label.upper()} — " - "run scripts/setup_oauth.py to create one" - ) - return token - - # --- OAuth token refresh (urlencoded form; http_json sends JSON) ----- - def _refresh_access_token(self, label): - form = urllib.parse.urlencode({ - "client_id": self.env("GOOGLE_CLIENT_ID"), - "client_secret": self.env("GOOGLE_CLIENT_SECRET"), - "refresh_token": self._refresh_token(label), - "grant_type": "refresh_token", - }).encode() - req = urllib.request.Request(TOKEN_URL, data=form, method="POST") - req.add_header("Content-Type", "application/x-www-form-urlencoded") - try: - with urllib.request.urlopen(req, timeout=30) as resp: - data = json.loads(resp.read().decode() or "{}") - except urllib.error.HTTPError as e: - # do not include request data (secrets) in the error - raise ConnectorError( - f"{self.name}: token refresh for '{label}' failed HTTP {e.code}: " - f"{e.read().decode(errors='replace')[:300]}" - ) - except urllib.error.URLError as e: - raise ConnectorError(f"{self.name}: token endpoint unreachable: {e.reason}") - access = data.get("access_token") - if not access: - raise ConnectorError(f"{self.name}: token refresh for '{label}' returned no access_token") - return access - - def _headers(self, label): - return {"Authorization": f"Bearer {self._refresh_access_token(label)}"} - - # --- dispatch -------------------------------------------------------- - def _live(self, action, **params): - if action == "list_accounts": - return {"ok": True, "accounts": [ - {"label": label, "email": email, - "has_refresh_token": bool(self.env(f"GMAIL_REFRESH_TOKEN_{label.upper()}"))} - for label, email in self._accounts().items() - ]} - if action == "get_access_token": - label = self._require_label(params.get("label")) - token = self._refresh_access_token(label) - return {"ok": True, "label": label, "access_token": token, - "token_type": "Bearer"} - if action == "send": - return self._send( - self._require_label(params.get("label")), - params.get("to"), - params.get("subject", ""), - params.get("body", ""), - ) - if action == "list_messages": - return self._list_messages( - self._require_label(params.get("label")), - query=params.get("query"), - max_results=params.get("max_results"), - ) - if action == "get_message": - return self._get_message( - self._require_label(params.get("label")), - params.get("message_id"), - ) - raise ConnectorError(f"{self.name}: unhandled action '{action}'") - - # --- Gmail API actions ------------------------------------------------ - def _send(self, label, to, subject, body): - if not to: - raise ConnectorError(f"{self.name}: send requires 'to'") - msg = EmailMessage() - msg["From"] = self._accounts().get(label, "me") - msg["To"] = to - msg["Subject"] = subject or "" - msg.set_content(body or "") - raw = base64.urlsafe_b64encode(msg.as_bytes()).decode() - resp = self.http_json( - "POST", f"{API_BASE}/messages/send", - headers=self._headers(label), payload={"raw": raw}, - ) - data = resp["data"] - return {"ok": True, "label": label, "id": data.get("id"), - "threadId": data.get("threadId"), "message": "sent"} - - def _list_messages(self, label, query=None, max_results=None): - qs = {} - if query: - qs["q"] = query - if max_results: - qs["maxResults"] = int(max_results) - url = f"{API_BASE}/messages" - if qs: - url += "?" + urllib.parse.urlencode(qs) - resp = self.http_json("GET", url, headers=self._headers(label)) - data = resp["data"] - return { - "ok": True, - "label": label, - "messages": data.get("messages", []), - "resultSizeEstimate": data.get("resultSizeEstimate", 0), - "nextPageToken": data.get("nextPageToken"), - } - - def _get_message(self, label, message_id): - if not message_id: - raise ConnectorError(f"{self.name}: get_message requires 'message_id'") - resp = self.http_json( - "GET", - f"{API_BASE}/messages/{urllib.parse.quote(str(message_id))}?format=full", - headers=self._headers(label), - ) - data = resp["data"] - headers = {h["name"]: h["value"] - for h in data.get("payload", {}).get("headers", [])} - return { - "ok": True, - "label": label, - "id": data.get("id"), - "threadId": data.get("threadId"), - "subject": headers.get("Subject", ""), - "from": headers.get("From", ""), - "to": headers.get("To", ""), - "date": headers.get("Date", ""), - "snippet": data.get("snippet", ""), - "labelIds": data.get("labelIds", []), - } diff --git a/connectors/email/imap_smtp.py b/connectors/email/imap_smtp.py deleted file mode 100644 index 39b6514..0000000 --- a/connectors/email/imap_smtp.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Generic multi-account IMAP/SMTP email connector. - -Accounts are configured via the EMAIL_ACCOUNTS env var as JSON, e.g.: - - EMAIL_ACCOUNTS='[ - {"id": "work", "host": "imap.gmail.com", "smtp_host": "smtp.gmail.com", - "user": "a@gmail.com", "pass": "apppassword"}, - {"id": "personal", "host": "imap.example.com", "smtp_host": "smtp.example.com", - "user": "b@example.com", "pass": "secret", "port": 993, "smtp_port": 465} - ]' - -Stdlib only (imaplib / smtplib / email.message). Passwords are never printed -or logged; list_accounts redacts all secret fields. -""" -import imaplib -import json -import smtplib -from email.header import decode_header, make_header -from email.message import EmailMessage -from email.parser import Parser - -from hub.base import BaseConnector, ConnectorError, register - -DEFAULT_IMAP_PORT = 993 -DEFAULT_SMTP_PORT = 465 - - -@register -class ImapSmtpConnector(BaseConnector): - name = "email" - required_env = ["EMAIL_ACCOUNTS"] - description = "Generic multi-account email via IMAP (read/search) and SMTP_SSL (send)." - - read_only_actions = frozenset(['list_accounts', 'check_inbox', 'search']) - mutating_actions = frozenset(['send_email']) - destructive_actions = frozenset([]) - dry_run_actions = frozenset(['send_email']) - - def actions(self): - return ["list_accounts", "check_inbox", "send_email", "search"] - - # --- account config -------------------------------------------------- - def _accounts(self): - raw = self.env("EMAIL_ACCOUNTS") - try: - data = json.loads(raw) - except (TypeError, json.JSONDecodeError) as e: - raise ConnectorError(f"{self.name}: EMAIL_ACCOUNTS is not valid JSON: {e}") - if not isinstance(data, list): - raise ConnectorError(f"{self.name}: EMAIL_ACCOUNTS must be a JSON list of accounts") - for i, acct in enumerate(data): - if not isinstance(acct, dict) or not acct.get("id"): - raise ConnectorError(f"{self.name}: account #{i} missing required 'id'") - return data - - def _account(self, account_id): - for acct in self._accounts(): - if acct.get("id") == account_id: - return acct - known = ", ".join(a["id"] for a in self._accounts()) - raise ConnectorError(f"{self.name}: unknown account '{account_id}'. Known: {known}") - - # --- dispatch -------------------------------------------------------- - def _live(self, action, **params): - if action == "list_accounts": - return self._list_accounts() - if action == "check_inbox": - return self._check_inbox( - params.get("account_id"), limit=int(params.get("limit", 10)) - ) - if action == "send_email": - return self._send_email( - params.get("account_id"), - params.get("to"), - params.get("subject", ""), - params.get("body", ""), - html=params.get("html"), - ) - if action == "search": - return self._search(params.get("account_id"), params.get("query", "")) - raise ConnectorError(f"{self.name}: unhandled action '{action}'") - - # --- actions --------------------------------------------------------- - def _list_accounts(self): - accounts = [] - for acct in self._accounts(): - accounts.append({ - "id": acct.get("id"), - "user": acct.get("user"), - "host": acct.get("host"), - "port": int(acct.get("port", DEFAULT_IMAP_PORT)), - "smtp_host": acct.get("smtp_host"), - "smtp_port": int(acct.get("smtp_port", DEFAULT_SMTP_PORT)), - # never expose 'pass' or any other secret field - }) - return {"ok": True, "accounts": accounts, "count": len(accounts)} - - def _imap(self, acct): - host = acct.get("host") - if not host or not acct.get("user") or not acct.get("pass"): - raise ConnectorError( - f"{self.name}: account '{acct.get('id')}' needs host, user and pass" - ) - try: - conn = imaplib.IMAP4_SSL(host, int(acct.get("port", DEFAULT_IMAP_PORT))) - conn.login(acct["user"], acct["pass"]) - return conn - except imaplib.IMAP4.error as e: - raise ConnectorError(f"{self.name}: IMAP auth/connect failed for '{acct.get('id')}': {e}") - except OSError as e: - raise ConnectorError(f"{self.name}: IMAP connection to {host} failed: {e}") - - def _fetch_headers(self, conn, ids): - messages = [] - for mid in ids: - typ, data = conn.fetch(mid, "(BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)])") - if typ != "OK" or not data or not isinstance(data[0], tuple): - continue - hdrs = Parser().parsestr(data[0][1].decode(errors="replace")) - messages.append({ - "id": mid.decode() if isinstance(mid, bytes) else str(mid), - "subject": self._decode_hdr(hdrs.get("Subject", "")), - "from": self._decode_hdr(hdrs.get("From", "")), - "date": hdrs.get("Date", ""), - }) - return messages - - @staticmethod - def _decode_hdr(value): - try: - return str(make_header(decode_header(value))) - except Exception: - return value - - def _check_inbox(self, account_id, limit=10): - acct = self._account(account_id) - conn = self._imap(acct) - try: - typ, _ = conn.select("INBOX", readonly=True) - if typ != "OK": - raise ConnectorError(f"{self.name}: cannot open INBOX for '{account_id}'") - typ, data = conn.search(None, "ALL") - if typ != "OK": - raise ConnectorError(f"{self.name}: IMAP search failed for '{account_id}'") - ids = data[0].split() - latest = ids[-limit:] if limit > 0 else ids - messages = self._fetch_headers(conn, list(reversed(latest))) - return { - "ok": True, - "account": account_id, - "total": len(ids), - "count": len(messages), - "messages": messages, - } - finally: - try: - conn.logout() - except Exception: - pass - - def _search(self, account_id, query): - if not query: - raise ConnectorError(f"{self.name}: search requires 'query'") - acct = self._account(account_id) - conn = self._imap(acct) - try: - typ, _ = conn.select("INBOX", readonly=True) - if typ != "OK": - raise ConnectorError(f"{self.name}: cannot open INBOX for '{account_id}'") - criteria = f'(TEXT "{query}")' - try: - typ, data = conn.search("UTF-8", criteria) if any(ord(c) > 127 for c in query) \ - else conn.search(None, criteria) - except imaplib.IMAP4.error: - typ, data = conn.search(None, criteria) - if typ != "OK": - raise ConnectorError(f"{self.name}: IMAP search failed for '{account_id}'") - ids = data[0].split() - messages = self._fetch_headers(conn, list(reversed(ids[-50:]))) - return { - "ok": True, - "account": account_id, - "query": query, - "count": len(ids), - "messages": messages, - } - finally: - try: - conn.logout() - except Exception: - pass - - def _send_email(self, account_id, to, subject, body, html=None): - if not to: - raise ConnectorError(f"{self.name}: send_email requires 'to'") - acct = self._account(account_id) - smtp_host = acct.get("smtp_host") or acct.get("host") - smtp_port = int(acct.get("smtp_port", DEFAULT_SMTP_PORT)) - if not smtp_host or not acct.get("user") or not acct.get("pass"): - raise ConnectorError( - f"{self.name}: account '{account_id}' needs smtp_host, user and pass" - ) - - msg = EmailMessage() - msg["From"] = acct["user"] - msg["To"] = to - msg["Subject"] = subject or "" - msg.set_content(body or "") - if html: - msg.add_alternative(html, subtype="html") - - recipients = [r.strip() for r in str(to).split(",") if r.strip()] - try: - with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=30) as smtp: - smtp.login(acct["user"], acct["pass"]) - refused = smtp.send_message(msg, to_addrs=recipients) - except smtplib.SMTPAuthenticationError as e: - raise ConnectorError(f"{self.name}: SMTP auth failed for '{account_id}' ({e.smtp_code})") - except (smtplib.SMTPException, OSError) as e: - raise ConnectorError(f"{self.name}: SMTP send via {smtp_host} failed: {e}") - if refused: - raise ConnectorError(f"{self.name}: recipients refused: {list(refused)}") - return { - "ok": True, - "account": account_id, - "to": recipients, - "subject": subject or "", - "message": "sent", - } diff --git a/connectors/github_full/__init__.py b/connectors/github_full/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/connectors/github_full/github_connector.py b/connectors/github_full/github_connector.py deleted file mode 100644 index 9c74057..0000000 --- a/connectors/github_full/github_connector.py +++ /dev/null @@ -1,265 +0,0 @@ -"""Full-permission GitHub connector (user's own account, PAT with full scopes). - -Env: - GITHUB_TOKEN classic PAT or fine-grained token (repo + workflow + admin scopes) - GITHUB_API_BASE optional, default https://api.github.com (set for GitHub Enterprise) - -Stdlib only. Token is never logged or included in returned payloads. -""" -import base64 -import json -import os -import sys -import urllib.error -import urllib.parse -import urllib.request - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) -from hub.base import BaseConnector, ConnectorError, register # noqa: E402 - -_API_VERSION = "2022-11-28" - - -@register -class GitHubConnector(BaseConnector): - """Full-scope GitHub REST connector driven by a personal access token.""" - - name = "github" - required_env = ["GITHUB_TOKEN"] - description = "GitHub REST API, full scope (repos, files, issues, PRs, actions, secrets meta, webhooks, search)" - - def __init__(self, config=None): - super().__init__(config) - self.api_base = (self.config.get("api_base") or self.env( - "GITHUB_API_BASE", "https://api.github.com")).rstrip("/") - self._headers = { - "Authorization": f"Bearer {self.env('GITHUB_TOKEN', '')}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": _API_VERSION, - } - - # --- contract -------------------------------------------------------- - read_only_actions = frozenset(['get_me', 'list_repos', 'get_repo', 'list_branches', 'get_file', 'list_issues', 'list_prs', 'list_workflows', 'list_workflow_runs', 'list_secrets_meta', 'search_code', 'search_repos']) - mutating_actions = frozenset(['create_repo', 'create_branch', 'put_file', 'create_issue', 'comment_issue', 'close_issue', 'create_pr', 'review_pr', 'dispatch_workflow', 'create_webhook']) - destructive_actions = frozenset(['delete_repo', 'delete_file', 'merge_pr']) - dry_run_actions = frozenset(['create_repo', 'create_branch', 'put_file', 'create_issue', 'comment_issue', 'close_issue', 'create_pr', 'review_pr', 'dispatch_workflow', 'create_webhook', 'delete_repo', 'delete_file', 'merge_pr']) - - def actions(self): - return [ - "get_me", - "list_repos", "get_repo", "create_repo", "delete_repo", - "list_branches", "create_branch", - "get_file", "put_file", "delete_file", - "list_issues", "create_issue", "comment_issue", "close_issue", - "list_prs", "create_pr", "merge_pr", "review_pr", - "list_workflows", "dispatch_workflow", "list_workflow_runs", - "list_secrets_meta", "create_webhook", - "search_code", "search_repos", - ] - - def _live(self, action, **params): - handler = getattr(self, f"_do_{action}") - return handler(**params) - - # --- HTTP helpers ---------------------------------------------------- - def _gh(self, method, path, payload=None, query=None): - """Call the GitHub API via self.http_json.""" - url = f"{self.api_base}{path}" - if query: - q = urllib.parse.urlencode( - {k: v for k, v in query.items() if v is not None}) - if q: - url = f"{url}?{q}" - res = self.http_json(method, url, headers=self._headers, payload=payload) - return {"ok": True, "status": res["status"], "data": res["data"]} - - def _gh_delete_with_body(self, path, payload): - """DELETE requests that require a JSON body (urllib does not send a - body for DELETE via http_json unless forced).""" - url = f"{self.api_base}{path}" - body = json.dumps(payload).encode() - req = urllib.request.Request(url, data=body, method="DELETE") - req.add_header("Accept", "application/vnd.github+json") - req.add_header("Content-Type", "application/json") - for k, v in self._headers.items(): - req.add_header(k, v) - try: - with urllib.request.urlopen(req, timeout=30) as resp: - raw = resp.read().decode() or "{}" - try: - data = json.loads(raw) - except json.JSONDecodeError: - data = {"raw": raw} - return {"ok": True, "status": resp.status, "data": data} - except urllib.error.HTTPError as e: - detail = e.read().decode(errors="replace")[:500] - raise ConnectorError(f"{self.name} HTTP {e.code} {url}: {detail}") - except urllib.error.URLError as e: - raise ConnectorError(f"{self.name} connection failed {url}: {e.reason}") - - # --- account --------------------------------------------------------- - def _do_get_me(self): - return self._gh("GET", "/user") - - # --- repos ----------------------------------------------------------- - def _do_list_repos(self, visibility=None, per_page=None): - return self._gh("GET", "/user/repos", query={ - "visibility": visibility, - "per_page": per_page or 30, - }) - - def _do_get_repo(self, owner, repo): - return self._gh("GET", f"/repos/{owner}/{repo}") - - def _do_create_repo(self, name, private=None, description=None): - payload = {"name": name} - if private is not None: - payload["private"] = bool(private) - if description is not None: - payload["description"] = description - return self._gh("POST", "/user/repos", payload=payload) - - def _do_delete_repo(self, owner, repo): - return self._gh("DELETE", f"/repos/{owner}/{repo}") - - # --- branches -------------------------------------------------------- - def _do_list_branches(self, owner, repo): - return self._gh("GET", f"/repos/{owner}/{repo}/branches") - - def _do_create_branch(self, owner, repo, branch, from_branch=None): - if from_branch: - ref_path = f"/repos/{owner}/{repo}/git/refs/heads/{from_branch}" - else: - # default branch of the repo - ref_path = f"/repos/{owner}/{repo}/git/ref/HEAD" - res = self._gh("GET", ref_path) - sha = res["data"]["object"]["sha"] - return self._gh("POST", f"/repos/{owner}/{repo}/git/refs", - payload={"ref": f"refs/heads/{branch}", "sha": sha}) - - # --- files ----------------------------------------------------------- - def _do_get_file(self, owner, repo, path, ref=None): - qpath = urllib.parse.quote(path, safe="/") - res = self._gh("GET", f"/repos/{owner}/{repo}/contents/{qpath}", - query={"ref": ref}) - data = res["data"] - if isinstance(data, dict) and data.get("encoding") == "base64" and "content" in data: - try: - decoded = base64.b64decode(data["content"]).decode("utf-8", errors="replace") - except (ValueError, TypeError) as e: - raise ConnectorError(f"{self.name}: failed to decode content for {path}: {e}") - data = dict(data) - data["decoded_content"] = decoded - res["data"] = data - return res - - def _do_put_file(self, owner, repo, path, content, message, branch=None, sha=None): - qpath = urllib.parse.quote(path, safe="/") - payload = { - "message": message, - "content": base64.b64encode(content.encode()).decode(), - } - if branch: - payload["branch"] = branch - if sha: - payload["sha"] = sha - return self._gh("PUT", f"/repos/{owner}/{repo}/contents/{qpath}", - payload=payload) - - def _do_delete_file(self, owner, repo, path, message, sha): - qpath = urllib.parse.quote(path, safe="/") - return self._gh_delete_with_body( - f"/repos/{owner}/{repo}/contents/{qpath}", - {"message": message, "sha": sha}) - - # --- issues ---------------------------------------------------------- - def _do_list_issues(self, owner, repo, state=None): - return self._gh("GET", f"/repos/{owner}/{repo}/issues", - query={"state": state or "open"}) - - def _do_create_issue(self, owner, repo, title, body=None, labels=None): - payload = {"title": title} - if body is not None: - payload["body"] = body - if labels: - payload["labels"] = labels - return self._gh("POST", f"/repos/{owner}/{repo}/issues", payload=payload) - - def _do_comment_issue(self, owner, repo, number, body): - return self._gh("POST", f"/repos/{owner}/{repo}/issues/{number}/comments", - payload={"body": body}) - - def _do_close_issue(self, owner, repo, number): - return self._gh("PATCH", f"/repos/{owner}/{repo}/issues/{number}", - payload={"state": "closed"}) - - # --- pull requests --------------------------------------------------- - def _do_list_prs(self, owner, repo, state=None): - return self._gh("GET", f"/repos/{owner}/{repo}/pulls", - query={"state": state or "open"}) - - def _do_create_pr(self, owner, repo, title, head, base, body=None): - payload = {"title": title, "head": head, "base": base} - if body is not None: - payload["body"] = body - return self._gh("POST", f"/repos/{owner}/{repo}/pulls", payload=payload) - - def _do_merge_pr(self, owner, repo, number, method="merge"): - return self._gh("PUT", f"/repos/{owner}/{repo}/pulls/{number}/merge", - payload={"merge_method": method}) - - def _do_review_pr(self, owner, repo, number, event="APPROVE", body=None): - payload = {"event": event} - if body is not None: - payload["body"] = body - return self._gh("POST", f"/repos/{owner}/{repo}/pulls/{number}/reviews", - payload=payload) - - # --- actions / workflows --------------------------------------------- - def _do_list_workflows(self, owner, repo): - return self._gh("GET", f"/repos/{owner}/{repo}/actions/workflows") - - def _do_dispatch_workflow(self, owner, repo, workflow_id, ref="main", inputs=None): - payload = {"ref": ref} - if inputs: - payload["inputs"] = inputs - return self._gh("POST", - f"/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches", - payload=payload) - - def _do_list_workflow_runs(self, owner, repo): - return self._gh("GET", f"/repos/{owner}/{repo}/actions/runs") - - # --- secrets meta / webhooks ------------------------------------------ - def _do_list_secrets_meta(self, owner, repo): - """List secret names only — never values.""" - res = self._gh("GET", f"/repos/{owner}/{repo}/actions/secrets") - secrets = res["data"].get("secrets", []) if isinstance(res["data"], dict) else [] - return { - "ok": True, - "status": res["status"], - "data": { - "total_count": res["data"].get("total_count", len(secrets)), - "secrets": [ - {"name": s.get("name"), - "created_at": s.get("created_at"), - "updated_at": s.get("updated_at")} - for s in secrets - ], - }, - } - - def _do_create_webhook(self, owner, repo, url, events=None): - return self._gh("POST", f"/repos/{owner}/{repo}/hooks", payload={ - "name": "web", - "active": True, - "events": events or ["push"], - "config": {"url": url, "content_type": "json"}, - }) - - # --- search ------------------------------------------------------------ - def _do_search_code(self, query): - return self._gh("GET", "/search/code", query={"q": query}) - - def _do_search_repos(self, query): - return self._gh("GET", "/search/repositories", query={"q": query}) diff --git a/connectors/hosting/__init__.py b/connectors/hosting/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/connectors/hosting/whm_cpanel.py b/connectors/hosting/whm_cpanel.py deleted file mode 100644 index 5aaccb3..0000000 --- a/connectors/hosting/whm_cpanel.py +++ /dev/null @@ -1,263 +0,0 @@ -"""WHM (WebHost Manager) and cPanel connectors in one module. - -WHM live mode: GET/POST https://{WHM_HOST}:2087/json-api/?api.version=1 - Authorization: whm : -cPanel live mode: GET/POST https://{CPANEL_HOST}:2083/execute// - Authorization: cpanel : -""" -import urllib.parse - -from hub.base import BaseConnector, ConnectorError, register - - -class _TokenPanelConnector(BaseConnector): - """Shared plumbing for WHM/cPanel token-auth JSON APIs. - - Subclasses set env_prefix ("WHM"/"CPANEL"), default_port and auth_scheme. - """ - - env_prefix = "" - default_port = 2087 - auth_scheme = "whm" - - def _base(self): - host = (self.env(f"{self.env_prefix}_HOST") or "").strip() - if not host: - raise ConnectorError(f"{self.name}: {self.env_prefix}_HOST is not set") - host = host.replace("https://", "").replace("http://", "").rstrip("/") - return f"https://{host}:{self.default_port}" - - def _headers(self): - user = self.env(f"{self.env_prefix}_USER") - token = self.env(f"{self.env_prefix}_API_TOKEN") - return {"Authorization": f"{self.auth_scheme} {user}:{token}"} - - def _request(self, path, params=None, method="GET"): - """path is '/json-api/' or '/execute//'.""" - params = {k: v for k, v in (params or {}).items() if v is not None} - url = f"{self._base()}{path}" - if method.upper() == "GET" or not params: - if params: - sep = "&" if "?" in url else "?" - url += sep + urllib.parse.urlencode(params) - result = self.http_json("GET", url, headers=self._headers()) - else: - # WHM/cPanel accept URL-encoded POST bodies. - encoded = urllib.parse.urlencode(params) - sep = "&" if "?" in url else "?" - result = self.http_json( - "POST", f"{url}{sep}{encoded}", headers=self._headers() - ) - data = result.get("data", {}) - # WHM JSON-API wraps results in metadata with result=0 on failure. - meta = data.get("metadata") if isinstance(data, dict) else None - if isinstance(meta, dict) and meta.get("result") == 0: - raise ConnectorError( - f"{self.name} API error: {meta.get('reason', 'unknown')}" - ) - # UAPI (/execute/*) uses {"errors": [...], "status": 0} on failure. - if isinstance(data, dict) and data.get("status") == 0 and data.get("errors"): - raise ConnectorError( - f"{self.name} UAPI error: {'; '.join(map(str, data['errors']))}" - ) - return result - - -@register -class WHMConnector(_TokenPanelConnector): - """WHM root/reseller server administration.""" - - name = "whm" - required_env = ["WHM_HOST", "WHM_USER", "WHM_API_TOKEN"] - description = "WHM: accounts, packages, DNS zones, server status" - env_prefix = "WHM" - default_port = 2087 - auth_scheme = "whm" - - read_only_actions = frozenset(['list_accounts', 'list_packages', 'server_status', 'list_zones', 'version']) - mutating_actions = frozenset(['create_account', 'suspend_account', 'unsuspend_account']) - destructive_actions = frozenset(['terminate_account']) - dry_run_actions = frozenset(['create_account', 'suspend_account', 'unsuspend_account', 'terminate_account']) - - def actions(self): - return [ - "list_accounts", - "create_account", - "suspend_account", - "unsuspend_account", - "terminate_account", - "list_packages", - "server_status", - "list_zones", - "version", - ] - - def _whm(self, func, params=None, method="GET"): - base = f"/json-api/{func}?api.version=1" - return self._request(base, params=params, method=method) - - def _live(self, action, **params): - if action == "list_accounts": - return self._whm("listaccts") - - if action == "create_account": - required = ["domain", "username", "password"] - missing = [k for k in required if not params.get(k)] - if missing: - raise ConnectorError( - f"whm create_account missing required: {', '.join(missing)}" - ) - fields = {k: params[k] for k in required} - if params.get("plan"): - fields["plan"] = params["plan"] - if params.get("contactemail"): - fields["contactemail"] = params["contactemail"] - return self._whm("createacct", fields, method="POST") - - if action == "suspend_account": - user = params.get("user") - if not user: - raise ConnectorError("whm suspend_account requires user") - fields = {"user": user} - if params.get("reason"): - fields["reason"] = params["reason"] - return self._whm("suspendacct", fields, method="POST") - - if action == "unsuspend_account": - user = params.get("user") - if not user: - raise ConnectorError("whm unsuspend_account requires user") - return self._whm("unsuspendacct", {"user": user}, method="POST") - - if action == "terminate_account": - user = params.get("user") - if not user: - raise ConnectorError("whm terminate_account requires user") - fields = {"user": user} - if params.get("keepdns"): - fields["keepdns"] = 1 - return self._whm("removeacct", fields, method="POST") - - if action == "list_packages": - return self._whm("listpkgs") - - if action == "server_status": - return self._whm("servicestatus") - - if action == "list_zones": - return self._whm("listzones") - - if action == "version": - return self._whm("version") - - raise ConnectorError(f"whm: unhandled action '{action}'") - - -@register -class CPanelConnector(_TokenPanelConnector): - """cPanel end-user account automation (UAPI).""" - - name = "cpanel" - required_env = ["CPANEL_HOST", "CPANEL_USER", "CPANEL_API_TOKEN"] - description = "cPanel UAPI: domains, email, databases, files, cron, disk" - env_prefix = "CPANEL" - default_port = 2083 - auth_scheme = "cpanel" - - read_only_actions = frozenset(['list_domains', 'list_email_accounts', 'list_databases', 'file_list', 'cron_list', 'disk_usage']) - mutating_actions = frozenset(['add_subdomain', 'add_email', 'create_database', 'create_db_user', 'cron_add']) - destructive_actions = frozenset([]) - dry_run_actions = frozenset(['add_subdomain', 'add_email', 'create_database', 'create_db_user', 'cron_add']) - - def actions(self): - return [ - "list_domains", - "add_subdomain", - "list_email_accounts", - "add_email", - "list_databases", - "create_database", - "create_db_user", - "file_list", - "cron_list", - "cron_add", - "disk_usage", - ] - - def _uapi(self, module, func, params=None, method="GET"): - return self._request(f"/execute/{module}/{func}", params=params, method=method) - - def _live(self, action, **params): - if action == "list_domains": - return self._uapi("DomainInfo", "domains_data", {"format": "hash"}) - - if action == "add_subdomain": - required = ["subdomain", "rootdomain", "dir"] - missing = [k for k in required if not params.get(k)] - if missing: - raise ConnectorError( - f"cpanel add_subdomain missing required: {', '.join(missing)}" - ) - fields = { - "domain": params["subdomain"], - "rootdomain": params["rootdomain"], - "dir": params["dir"], - } - return self._uapi("SubDomain", "addsubdomain", fields, method="POST") - - if action == "list_email_accounts": - return self._uapi("Email", "list_pops") - - if action == "add_email": - if not params.get("email") or not params.get("password"): - raise ConnectorError("cpanel add_email requires email and password") - fields = { - "email": params["email"], - "password": params["password"], - "quota": params.get("quota", 250), - } - return self._uapi("Email", "add_pop", fields, method="POST") - - if action == "list_databases": - return self._uapi("Mysql", "list_databases") - - if action == "create_database": - name = params.get("name") - if not name: - raise ConnectorError("cpanel create_database requires name") - return self._uapi("Mysql", "create_database", {"name": name}, - method="POST") - - if action == "create_db_user": - if not params.get("user") or not params.get("password"): - raise ConnectorError( - "cpanel create_db_user requires user and password" - ) - fields = {"name": params["user"], "password": params["password"]} - return self._uapi("Mysql", "create_user", fields, method="POST") - - if action == "file_list": - directory = params.get("dir", ".") - return self._uapi("Fileman", "list_files", - {"dir": directory, "types": "file|dir"}) - - if action == "cron_list": - return self._uapi("Cron", "list_cron") - - if action == "cron_add": - if not params.get("command"): - raise ConnectorError("cpanel cron_add requires command") - fields = { - "command": params["command"], - "minute": params.get("minute", "0"), - "hour": params.get("hour", "0"), - "day": params.get("day", "*"), - "month": params.get("month", "*"), - "weekday": params.get("weekday", "*"), - } - return self._uapi("Cron", "add_line", fields, method="POST") - - if action == "disk_usage": - return self._uapi("Quota", "get_quota_info") - - raise ConnectorError(f"cpanel: unhandled action '{action}'") diff --git a/connectors/hosting/whmcs.py b/connectors/hosting/whmcs.py deleted file mode 100644 index a961f4a..0000000 --- a/connectors/hosting/whmcs.py +++ /dev/null @@ -1,175 +0,0 @@ -"""WHMCS billing panel connector. - -Live mode: POST form-encoded requests to {WHMCS_URL}/includes/api.php -with identifier/secret credentials and responsetype=json. - -WHMCS API reference: https://developers.whmcs.com/api/api-index/ -""" -import json -import urllib.error -import urllib.parse -import urllib.request - -from hub.base import BaseConnector, ConnectorError, register - - -@register -class WHMCSConnector(BaseConnector): - """WHMCS billing automation: clients, invoices, tickets, services.""" - - name = "whmcs" - required_env = ["WHMCS_URL", "WHMCS_API_IDENTIFIER", "WHMCS_API_SECRET"] - description = "WHMCS billing: clients, invoices, tickets, products, module actions" - - read_only_actions = frozenset(['get_clients', 'get_client', 'get_invoices', 'get_tickets', 'get_products']) - mutating_actions = frozenset(['reply_ticket', 'add_client', 'create_invoice', 'module_action']) - destructive_actions = frozenset([]) - dry_run_actions = frozenset(['reply_ticket', 'add_client', 'create_invoice', 'module_action']) - - def actions(self): - return [ - "get_clients", - "get_client", - "get_invoices", - "get_tickets", - "reply_ticket", - "add_client", - "create_invoice", - "get_products", - "module_action", - ] - - # --- live plumbing ---------------------------------------------------- - def _api(self, whmcs_action, **fields): - """POST one form-encoded WHMCS API request, return parsed dict.""" - base = (self.env("WHMCS_URL") or "").rstrip("/") - if not base: - raise ConnectorError("whmcs: WHMCS_URL is not set") - url = f"{base}/includes/api.php" - body = urllib.parse.urlencode( - { - "identifier": self.env("WHMCS_API_IDENTIFIER"), - "secret": self.env("WHMCS_API_SECRET"), - "action": whmcs_action, - "responsetype": "json", - **{k: v for k, v in fields.items() if v is not None}, - } - ).encode() - req = urllib.request.Request(url, data=body, method="POST") - req.add_header("Content-Type", "application/x-www-form-urlencoded") - req.add_header("Accept", "application/json") - try: - with urllib.request.urlopen(req, timeout=30) as resp: - raw = resp.read().decode() or "{}" - try: - data = json.loads(raw) - except json.JSONDecodeError: - data = {"raw": raw} - except urllib.error.HTTPError as e: - detail = e.read().decode(errors="replace")[:500] - raise ConnectorError(f"whmcs HTTP {e.code} {url}: {detail}") - except urllib.error.URLError as e: - raise ConnectorError(f"whmcs connection failed {url}: {e.reason}") - - if isinstance(data, dict) and data.get("result") == "error": - # WHMCS returns HTTP 200 with result=error; message may exist. - raise ConnectorError( - f"whmcs API error ({whmcs_action}): {data.get('message', data)}" - ) - return {"ok": True, "data": data} - - # --- actions ---------------------------------------------------------- - def _live(self, action, **params): - if action == "get_clients": - limit = int(params.get("limit", 25) or 25) - return self._api("GetClients", limitstart=0, limitnum=limit) - - if action == "get_client": - client_id = params.get("client_id") - if not client_id: - raise ConnectorError("whmcs get_client requires client_id") - return self._api("GetClientsDetails", clientid=client_id, stats="true") - - if action == "get_invoices": - fields = {"limitnum": int(params.get("limit", 25) or 25)} - if params.get("status"): - fields["status"] = params["status"] - if params.get("client_id"): - fields["userid"] = params["client_id"] - return self._api("GetInvoices", **fields) - - if action == "get_tickets": - fields = {"limitnum": int(params.get("limit", 25) or 25)} - if params.get("status"): - fields["status"] = params["status"] - if params.get("client_id"): - fields["clientid"] = params["client_id"] - return self._api("GetTickets", **fields) - - if action == "reply_ticket": - ticket_id = params.get("ticket_id") - message = params.get("message") - if not ticket_id or not message: - raise ConnectorError( - "whmcs reply_ticket requires ticket_id and message" - ) - return self._api("AddTicketReply", ticketid=ticket_id, message=message) - - if action == "add_client": - required = ["firstname", "lastname", "email", "password"] - missing = [k for k in required if not params.get(k)] - if missing: - raise ConnectorError( - f"whmcs add_client missing required: {', '.join(missing)}" - ) - fields = {k: params[k] for k in required} - for opt in ("companyname", "address1", "city", "state", "postcode", - "country", "phonenumber"): - if params.get(opt): - fields[opt] = params[opt] - return self._api("AddClient", **fields) - - if action == "create_invoice": - client_id = params.get("client_id") - items = params.get("items") - if not client_id or not items: - raise ConnectorError( - "whmcs create_invoice requires client_id and items " - "(list of {description, amount})" - ) - if isinstance(items, str): - items = json.loads(items) - if not isinstance(items, (list, tuple)) or not items: - raise ConnectorError("whmcs create_invoice: items must be a non-empty list") - fields = {"userid": client_id, "sendinvoice": "true"} - for i, item in enumerate(items, 1): - fields[f"itemdescription{i}"] = item.get("description", f"Item {i}") - fields[f"itemamount{i}"] = item.get("amount", 0) - fields[f"itemtaxed{i}"] = 1 if item.get("taxed") else 0 - return self._api("CreateInvoice", **fields) - - if action == "get_products": - fields = {} - if params.get("product_id"): - fields["pid"] = params["product_id"] - if params.get("group_id"): - fields["gid"] = params["group_id"] - return self._api("GetProducts", **fields) - - if action == "module_action": - service_id = params.get("service_id") - mod_action = (params.get("action") or "").lower() - mapping = { - "suspend": "ModuleSuspend", - "unsuspend": "ModuleUnsuspend", - "terminate": "ModuleTerminate", - "create": "ModuleCreate", - } - if not service_id or mod_action not in mapping: - raise ConnectorError( - "whmcs module_action requires service_id and action in " - "(suspend, unsuspend, terminate, create)" - ) - return self._api(mapping[mod_action], accountid=service_id) - - raise ConnectorError(f"whmcs: unhandled action '{action}'") diff --git a/connectors/llm/__init__.py b/connectors/llm/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/connectors/llm/anthropic_claude.py b/connectors/llm/anthropic_claude.py deleted file mode 100644 index 693afb5..0000000 --- a/connectors/llm/anthropic_claude.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Anthropic Claude connector. - -Env: ANTHROPIC_API_KEY (required), ANTHROPIC_MODEL (optional, default -claude-sonnet-4-5). - -Actions: chat(messages, system?, max_tokens?), list_models (static known list). -""" -import json -import os -import sys - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -from hub.base import BaseConnector, register, ConnectorError - -ANTHROPIC_VERSION = "2023-06-01" -API_URL = "https://api.anthropic.com/v1/messages" - -KNOWN_MODELS = [ - "claude-opus-4-1", - "claude-sonnet-4-5", - "claude-haiku-4-5", - "claude-3-7-sonnet-latest", - "claude-3-5-haiku-latest", -] - - -@register -class AnthropicConnector(BaseConnector): - name = "claude" - required_env = ["ANTHROPIC_API_KEY"] - description = "Anthropic Claude — messages API, static model list" - - DEFAULT_MODEL = "claude-sonnet-4-5" - - def _headers(self): - return { - "x-api-key": self.env("ANTHROPIC_API_KEY"), - "anthropic-version": ANTHROPIC_VERSION, - } - - read_only_actions = frozenset(['list_models']) - mutating_actions = frozenset(['chat']) - destructive_actions = frozenset([]) - dry_run_actions = frozenset(['chat']) - - def actions(self): - return ["chat", "list_models"] - - def _live(self, action, **params): - if action == "chat": - messages = params.get("messages") - if not messages: - raise ConnectorError("claude: chat requires 'messages'") - payload = { - "model": params.get("model") - or self.env("ANTHROPIC_MODEL", self.DEFAULT_MODEL), - "messages": messages, - "max_tokens": int(params.get("max_tokens") or 1024), - } - if params.get("system"): - payload["system"] = params["system"] - if params.get("temperature") is not None: - payload["temperature"] = params["temperature"] - resp = self.http_json( - "POST", API_URL, headers=self._headers(), payload=payload, - ) - return {"ok": True, "connector": self.name, "action": action, - "data": resp["data"]} - - if action == "list_models": - return {"ok": True, "connector": self.name, "action": action, - "data": {"models": KNOWN_MODELS}} - - raise ConnectorError(f"claude: unhandled action '{action}'") - - -if __name__ == "__main__": - c = AnthropicConnector() - print(json.dumps(c.status(), indent=2)) - print(json.dumps(c.call("chat", messages=[{"role": "user", "content": "hi"}]), indent=2)) diff --git a/connectors/llm/cloudflare_ai.py b/connectors/llm/cloudflare_ai.py deleted file mode 100644 index 65e18ef..0000000 --- a/connectors/llm/cloudflare_ai.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Cloudflare Workers AI connector. - -Env: CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN (both required), -CLOUDFLARE_AI_MODEL (optional, default @cf/meta/llama-3.1-8b-instruct). - -Actions: chat(messages), run_model(model, input). -""" -import json -import os -import sys - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -from hub.base import BaseConnector, register, ConnectorError - - -@register -class CloudflareAIConnector(BaseConnector): - name = "cloudflare" - required_env = ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"] - description = "Cloudflare Workers AI — run any model, chat helper" - - DEFAULT_MODEL = "@cf/meta/llama-3.1-8b-instruct" - - def _url(self, model): - account = self.env("CLOUDFLARE_ACCOUNT_ID") - return ( - "https://api.cloudflare.com/client/v4/accounts/" - f"{account}/ai/run/{model}" - ) - - def _headers(self): - return {"Authorization": f"Bearer {self.env('CLOUDFLARE_API_TOKEN')}"} - - read_only_actions = frozenset([]) - mutating_actions = frozenset(['chat', 'run_model']) - destructive_actions = frozenset([]) - dry_run_actions = frozenset(['chat', 'run_model']) - - def actions(self): - return ["chat", "run_model"] - - def _live(self, action, **params): - if action == "chat": - messages = params.get("messages") - if not messages: - raise ConnectorError("cloudflare: chat requires 'messages'") - model = params.get("model") or self.env( - "CLOUDFLARE_AI_MODEL", self.DEFAULT_MODEL - ) - payload = {"messages": messages} - for key in ("max_tokens", "temperature", "stream"): - if params.get(key) is not None: - payload[key] = params[key] - resp = self.http_json( - "POST", self._url(model), headers=self._headers(), payload=payload, - ) - return {"ok": True, "connector": self.name, "action": action, - "data": resp["data"]} - - if action == "run_model": - model = params.get("model") - if not model: - raise ConnectorError("cloudflare: run_model requires 'model'") - model_input = params.get("input") - if model_input is None: - raise ConnectorError("cloudflare: run_model requires 'input'") - # input may be a dict payload (passed through) or a plain prompt. - payload = model_input if isinstance(model_input, dict) else {"prompt": model_input} - resp = self.http_json( - "POST", self._url(model), headers=self._headers(), payload=payload, - ) - return {"ok": True, "connector": self.name, "action": action, - "data": resp["data"]} - - raise ConnectorError(f"cloudflare: unhandled action '{action}'") - - -if __name__ == "__main__": - c = CloudflareAIConnector() - print(json.dumps(c.status(), indent=2)) - print(json.dumps(c.call("chat", messages=[{"role": "user", "content": "hi"}]), indent=2)) diff --git a/connectors/llm/kimi.py b/connectors/llm/kimi.py deleted file mode 100644 index 9f35595..0000000 --- a/connectors/llm/kimi.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Kimi (Moonshot AI) connector — OpenAI-compatible API. - -Env: MOONSHOT_API_KEY (required; KIMI_API_KEY accepted as fallback), -MOONSHOT_BASE_URL (optional, default https://api.moonshot.cn/v1 — set to -https://api.moonshot.ai/v1 for the international endpoint), -MOONSHOT_MODEL (optional, default kimi-k2-0711-preview). - -Actions: chat(messages), list_models. -""" -import json -import os -import sys - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -from hub.base import BaseConnector, register, ConnectorError - - -@register -class KimiConnector(BaseConnector): - name = "kimi" - required_env = [] # resolved dynamically: MOONSHOT_API_KEY or KIMI_API_KEY - description = "Kimi / Moonshot AI — OpenAI-compatible chat completions" - - DEFAULT_MODEL = "kimi-k2-0711-preview" - - def __init__(self, config=None): - # Accept either MOONSHOT_API_KEY or KIMI_API_KEY. - if not os.environ.get("MOONSHOT_API_KEY") and os.environ.get("KIMI_API_KEY"): - os.environ["MOONSHOT_API_KEY"] = os.environ["KIMI_API_KEY"] - self.required_env = ["MOONSHOT_API_KEY"] - super().__init__(config=config) - - def _base(self): - return self.env("MOONSHOT_BASE_URL", "https://api.moonshot.cn/v1").rstrip("/") - - def _headers(self): - return {"Authorization": f"Bearer {self.env('MOONSHOT_API_KEY')}"} - - read_only_actions = frozenset(['list_models']) - mutating_actions = frozenset(['chat']) - destructive_actions = frozenset([]) - dry_run_actions = frozenset(['chat']) - - def actions(self): - return ["chat", "list_models"] - - def _live(self, action, **params): - if action == "chat": - messages = params.get("messages") - if not messages: - raise ConnectorError("kimi: chat requires 'messages'") - payload = { - "model": params.get("model") - or self.env("MOONSHOT_MODEL", self.DEFAULT_MODEL), - "messages": messages, - } - for key in ("temperature", "max_tokens", "top_p", "stream"): - if params.get(key) is not None: - payload[key] = params[key] - resp = self.http_json( - "POST", f"{self._base()}/chat/completions", - headers=self._headers(), payload=payload, - ) - return {"ok": True, "connector": self.name, "action": action, - "data": resp["data"]} - - if action == "list_models": - resp = self.http_json( - "GET", f"{self._base()}/models", headers=self._headers(), - ) - return {"ok": True, "connector": self.name, "action": action, - "data": resp["data"]} - - raise ConnectorError(f"kimi: unhandled action '{action}'") - - -if __name__ == "__main__": - c = KimiConnector() - print(json.dumps(c.status(), indent=2)) - print(json.dumps(c.call("chat", messages=[{"role": "user", "content": "hi"}]), indent=2)) diff --git a/connectors/llm/openai_chatgpt.py b/connectors/llm/openai_chatgpt.py deleted file mode 100644 index ffcf8c6..0000000 --- a/connectors/llm/openai_chatgpt.py +++ /dev/null @@ -1,87 +0,0 @@ -"""OpenAI ChatGPT connector. - -Env: OPENAI_API_KEY (required), OPENAI_BASE_URL (optional, default -https://api.openai.com), OPENAI_MODEL (optional, default gpt-4o-mini). - -Actions: chat(messages, model?), list_models, embeddings(input). -""" -import json -import os -import sys - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -from hub.base import BaseConnector, register, ConnectorError - - -@register -class OpenAIConnector(BaseConnector): - name = "openai" - required_env = ["OPENAI_API_KEY"] - description = "OpenAI ChatGPT — chat completions, model list, embeddings" - - DEFAULT_MODEL = "gpt-4o-mini" - - def _base(self): - return self.env("OPENAI_BASE_URL", "https://api.openai.com").rstrip("/") - - def _headers(self): - return {"Authorization": f"Bearer {self.env('OPENAI_API_KEY')}"} - - read_only_actions = frozenset(['list_models']) - mutating_actions = frozenset(['chat', 'embeddings']) - destructive_actions = frozenset([]) - dry_run_actions = frozenset(['chat', 'embeddings']) - - def actions(self): - return ["chat", "list_models", "embeddings"] - - def _live(self, action, **params): - if action == "chat": - messages = params.get("messages") - if not messages: - raise ConnectorError("openai: chat requires 'messages'") - payload = { - "model": params.get("model") - or self.env("OPENAI_MODEL", self.DEFAULT_MODEL), - "messages": messages, - } - for key in ("temperature", "max_tokens", "top_p", "stream"): - if params.get(key) is not None: - payload[key] = params[key] - resp = self.http_json( - "POST", f"{self._base()}/v1/chat/completions", - headers=self._headers(), payload=payload, - ) - return {"ok": True, "connector": self.name, "action": action, - "data": resp["data"]} - - if action == "list_models": - resp = self.http_json( - "GET", f"{self._base()}/v1/models", headers=self._headers(), - ) - return {"ok": True, "connector": self.name, "action": action, - "data": resp["data"]} - - if action == "embeddings": - text = params.get("input") - if text is None: - raise ConnectorError("openai: embeddings requires 'input'") - payload = { - "model": params.get("model", "text-embedding-3-small"), - "input": text, - } - resp = self.http_json( - "POST", f"{self._base()}/v1/embeddings", - headers=self._headers(), payload=payload, - ) - return {"ok": True, "connector": self.name, "action": action, - "data": resp["data"]} - - raise ConnectorError(f"openai: unhandled action '{action}'") - - -if __name__ == "__main__": - c = OpenAIConnector() - print(json.dumps(c.status(), indent=2)) - print(json.dumps(c.call("chat", messages=[{"role": "user", "content": "hi"}]), indent=2)) diff --git a/connectors/ops/__init__.py b/connectors/ops/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/connectors/ops/browser.py b/connectors/ops/browser.py deleted file mode 100644 index afcadb7..0000000 --- a/connectors/ops/browser.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Headless browser-ish fetch connector (stdlib only core). - -fetch / check_status work live with no credentials via urllib. -screenshot uses playwright (if importable) or wkhtmltoimage (if on PATH); -when neither is available it returns a structured "tooling missing" note -with ok=True so callers can degrade gracefully. - -required_env=[] — nothing needed for fetch; mock is forced off. -""" -from concurrent.futures import ThreadPoolExecutor -from html.parser import HTMLParser - -from hub.base import BaseConnector, ConnectorError, register -from hub.security import SecurityError, SecurityPolicy, pinned_urlopen - -USER_AGENT = ( - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" -) -DEFAULT_TIMEOUT = 30 - - -class _TextExtractor(HTMLParser): - """Minimal HTML -> visible text stripper.""" - - _SKIP = {"script", "style", "noscript", "head", "title"} - - def __init__(self): - super().__init__() - self._depth = 0 - self.parts = [] - - def handle_starttag(self, tag, attrs): - if tag in self._SKIP: - self._depth += 1 - if tag in ("br", "p", "div", "li", "tr", "h1", "h2", "h3", "h4"): - self.parts.append("\n") - - def handle_endtag(self, tag): - if tag in self._SKIP and self._depth > 0: - self._depth -= 1 - - def handle_data(self, data): - if self._depth == 0: - self.parts.append(data) - - def text(self): - raw = "".join(self.parts) - lines = [ln.strip() for ln in raw.splitlines()] - return "\n".join(ln for ln in lines if ln) - - -def _html_to_text(html): - p = _TextExtractor() - p.feed(html) - return p.text() - - -@register -class OpsBrowserConnector(BaseConnector): - name = "ops_browser" - required_env = [] - description = "HTTP fetch / status checks / page screenshot (gated tooling)" - - def __init__(self, config=None): - super().__init__(config) - # fetch works with no credentials — always live. - self.mock = False - self.missing_env = [] - self.security = SecurityPolicy(self.config) - - def actions(self): - return ["fetch", "check_status", "screenshot"] - - # --- helpers ------------------------------------------------------------ - def _open(self, url, method="GET", timeout=DEFAULT_TIMEOUT, max_bytes=2_000_000): - return pinned_urlopen(self.security, url, method, - {"User-Agent": USER_AGENT, "Accept": "*/*"}, - timeout, max_bytes) - - # --- live actions -------------------------------------------------------- - def _live(self, action, **params): - if action == "fetch": - url = params.get("url") - if not url: - raise ConnectorError(f"{self.name}: 'url' is required") - try: - resp = self._open(url, "GET") - body = resp["body"] - html = body.decode("utf-8", errors="replace") - content_type = resp["headers"].get("Content-Type") - out = { - "ok": resp["status"] < 400, - "url": resp["url"], - "status": resp["status"], - "content_type": content_type, - "bytes": len(body), - } - except (OSError, SecurityError) as e: - raise ConnectorError(f"{self.name}: fetch failed {url}: {e}") - if params.get("extract_text"): - out["text"] = _html_to_text(html) - else: - out["html"] = html - return out - - if action == "check_status": - urls = params.get("urls") - if not urls or not isinstance(urls, (list, tuple)): - raise ConnectorError(f"{self.name}: 'urls' (list) is required") - - def probe(u): - for method in ("HEAD", "GET"): - try: - resp = self._open(u, method, timeout=15, max_bytes=1024) - if method == "HEAD" and resp["status"] in (400, 403, 405, 501): - continue - return {"url": resp["url"], "ok": resp["status"] < 400, - "status": resp["status"]} - except Exception as e: - return {"url": u, "ok": False, "status": None, "error": str(e)} - return {"url": u, "ok": False, "status": None, "error": "probe failed"} - - with ThreadPoolExecutor(max_workers=min(8, max(1, len(urls)))) as pool: - results = list(pool.map(probe, urls)) - return {"ok": True, "results": results} - - if action == "screenshot": - url = params.get("url") - if not url: - raise ConnectorError(f"{self.name}: 'url' is required") - out_path = params.get("out_path", "/tmp/ops_browser_screenshot.png") - # Rendering engines cannot reliably pin DNS through redirects. Validate, - # then require an explicit browser capability before using one. - self.security.validate_url(url) - try: - self.security.require_capability(self.name, "browser_render") - except SecurityError as e: - raise ConnectorError(f"{self.name}: {e}") - - # A generic renderer cannot pin the policy-validated address. Keep - # screenshots disabled until an isolated, policy-aware worker is used. - return { - "ok": False, - "url": url, - "note": "screenshot requires an isolated policy-aware renderer", - "tool": None, - } - - raise ConnectorError(f"{self.name}: unhandled action '{action}'") diff --git a/connectors/ops/network.py b/connectors/ops/network.py deleted file mode 100644 index f2ee4d6..0000000 --- a/connectors/ops/network.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Policy-constrained network diagnostics connector.""" -import shutil -import socket -import ssl - -from hub.base import BaseConnector, ConnectorError, register -from hub.security import SecurityError, SecurityPolicy, bounded_run, pinned_urlopen - -COMMON_PORTS = [22, 80, 443, 2083, 2087] -USER_AGENT = ( - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" -) - - -@register -class OpsNetworkConnector(BaseConnector): - name = "ops_network" - required_env = [] - description = "ping / dns / port / traceroute / header diagnostics" - - def __init__(self, config=None): - super().__init__(config) - self.mock = False - self.missing_env = [] - self.security = SecurityPolicy(self.config) - - read_only_actions = frozenset(['ping', 'dns_lookup', 'port_check', 'traceroute', 'http_headers']) - mutating_actions = frozenset([]) - destructive_actions = frozenset([]) - dry_run_actions = frozenset([]) - - def actions(self): - return ["ping", "dns_lookup", "port_check", "traceroute", "http_headers"] - - def _exec_allowed(self): - return self.env("HUB_ALLOW_LOCAL_EXEC") == "1" - - def _gated(self, action): - return { - "ok": False, - "executed": False, - "state": "policy_required", - "gated": True, - "action": action, - "note": "system command blocked: set HUB_ALLOW_LOCAL_EXEC=1 to enable", - } - def _authorize_exec(self, action, approval): - self.security.require_capability(self.name, "local_exec", action, approval, True) - - # --- live actions -------------------------------------------------------- - def _live(self, action, **params): - if action == "ping": - host = params.get("host") - if not host: - raise ConnectorError(f"{self.name}: 'host' is required") - try: - self._authorize_exec(action, params.get("approval")) - except SecurityError as e: - raise ConnectorError(f"{self.name}: {e}") - count = int(params.get("count", 4)) - if count < 1 or count > 10: - raise ConnectorError(f"{self.name}: count must be between 1 and 10") - addresses = self.security.resolve_host(host, 0, socket.SOCK_RAW) - try: - proc = bounded_run(["ping", "-c", str(count), "-W", "5", addresses[0]], - count * 5 + 10, self.security.max_output) - except FileNotFoundError: - raise ConnectorError(f"{self.name}: system 'ping' binary not found") - except SecurityError: - raise ConnectorError(f"{self.name}: ping to {host} timed out") - return { - "ok": proc["returncode"] == 0, - "host": host, - "count": count, - "exit_code": proc["returncode"], "stdout": proc["stdout"], - "stderr": proc["stderr"], "truncated": proc["truncated"], - } - - if action == "dns_lookup": - domain = params.get("domain") - if not domain: - raise ConnectorError(f"{self.name}: 'domain' is required") - try: - addresses = self.security.resolve_host(domain, 0) - except SecurityError as e: - raise ConnectorError(f"{self.name}: {e}") - families = sorted({"AF_INET6" if ":" in a else "AF_INET" for a in addresses}) - return {"ok": True, "domain": domain, "addresses": addresses, - "families": families} - - if action == "port_check": - host = params.get("host") - if not host: - raise ConnectorError(f"{self.name}: 'host' is required") - ports = params.get("ports") or COMMON_PORTS - results = {} - for port in ports: - port = int(port) - if port not in self.security.ports: - raise ConnectorError(f"{self.name}: port {port} is not allowed") - addresses = self.security.resolve_host(host, port) - family = socket.AF_INET6 if ":" in addresses[0] else socket.AF_INET - s = socket.socket(family, socket.SOCK_STREAM) - s.settimeout(5) - try: - results[str(port)] = s.connect_ex((addresses[0], port)) == 0 - except socket.gaierror as e: - raise ConnectorError(f"{self.name}: cannot resolve {host}: {e}") - finally: - s.close() - return {"ok": True, "host": host, "ports": results, - "open": [int(p) for p, is_open in results.items() if is_open]} - - if action == "traceroute": - host = params.get("host") - if not host: - raise ConnectorError(f"{self.name}: 'host' is required") - try: - self._authorize_exec(action, params.get("approval")) - except SecurityError as e: - raise ConnectorError(f"{self.name}: {e}") - address = self.security.resolve_host(host, 0, socket.SOCK_RAW)[0] - if not shutil.which("traceroute"): - return { - "ok": False, - "executed": False, - "state": "dependency_required", - "host": host, - "note": "system 'traceroute' binary not installed", - "hops": [], - } - proc = bounded_run(["traceroute", "-m", "20", "-w", "3", address], - 90, self.security.max_output) - return { - "ok": proc["returncode"] == 0, - "host": host, - "exit_code": proc["returncode"], "stdout": proc["stdout"], - "stderr": proc["stderr"], "truncated": proc["truncated"], - } - - if action == "http_headers": - url = params.get("url") - if not url: - raise ConnectorError(f"{self.name}: 'url' is required") - try: - resp = pinned_urlopen(self.security, url, headers={"User-Agent": USER_AGENT}, - timeout=20, max_bytes=64 * 1024) - return {"ok": resp["status"] < 400, "url": resp["url"], - "status": resp["status"], "headers": resp["headers"]} - except (OSError, SecurityError, ssl.SSLError) as e: - raise ConnectorError(f"{self.name}: http_headers failed {url}: {e}") - - raise ConnectorError(f"{self.name}: unhandled action '{action}'") diff --git a/connectors/ops/security.py b/connectors/ops/security.py deleted file mode 100644 index fb3722c..0000000 --- a/connectors/ops/security.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Server security audit connector with centralized capability checks.""" -import math -import os -import re -import secrets -import socket -import ssl -import string -from datetime import datetime, timezone - -from hub.base import BaseConnector, ConnectorError, register -from hub.security import SecurityError, SecurityPolicy - -RISKY_PORTS = {21: "ftp", 23: "telnet", 3306: "mysql", 6379: "redis", 27017: "mongodb"} -SSHD_CONFIG_DEFAULT = "/etc/ssh/sshd_config" - - -@register -class OpsSecurityConnector(BaseConnector): - name = "ops_security" - required_env = [] - description = "Password audit, SSL cert check, exposure scan, sshd audit, secret gen" - - def __init__(self, config=None): - super().__init__(config) - self.mock = False - self.missing_env = [] - self.security = SecurityPolicy(self.config) - - read_only_actions = frozenset(['audit_password_strength', 'check_ssl', 'scan_common_exposure', 'ssh_config_audit', 'generate_secret']) - mutating_actions = frozenset([]) - destructive_actions = frozenset([]) - dry_run_actions = frozenset([]) - - def actions(self): - return [ - "audit_password_strength", - "check_ssl", - "scan_common_exposure", - "ssh_config_audit", - "generate_secret", - ] - - def _exec_allowed(self): - return self.env("HUB_ALLOW_LOCAL_EXEC") == "1" - - def _gated(self, action): - return { - "ok": False, - "executed": False, - "state": "policy_required", - "gated": True, - "action": action, - "note": "blocked: set HUB_ALLOW_LOCAL_EXEC=1 to enable local/socket ops", - } - def _authorize(self, capability, action, approval=None, destructive=False): - try: - self.security.require_capability(self.name, capability, action, - approval, destructive) - except SecurityError as e: - raise ConnectorError(f"{self.name}: {e}") - - # --- password audit ------------------------------------------------------- - @staticmethod - def _entropy_bits(pw): - pool = 0 - if re.search(r"[a-z]", pw): - pool += 26 - if re.search(r"[A-Z]", pw): - pool += 26 - if re.search(r"\d", pw): - pool += 10 - if re.search(r"[^a-zA-Z0-9]", pw): - pool += 33 - if pool == 0: - return 0.0 - return round(len(pw) * math.log2(pool), 1) - - def _audit_pw(self, pw): - issues = [] - if len(pw) < 12: - issues.append("length < 12 characters") - if not re.search(r"[a-z]", pw): - issues.append("no lowercase letters") - if not re.search(r"[A-Z]", pw): - issues.append("no uppercase letters") - if not re.search(r"\d", pw): - issues.append("no digits") - if not re.search(r"[^a-zA-Z0-9]", pw): - issues.append("no symbols") - if re.search(r"(.)\1{2,}", pw): - issues.append("contains 3+ repeated characters") - lowered = pw.lower() - for word in ("password", "qwerty", "letmein", "admin", "welcome", "123456"): - if word in lowered: - issues.append(f"contains common word/pattern '{word}'") - entropy = self._entropy_bits(pw) - score = "strong" if entropy >= 70 and not issues else ( - "moderate" if entropy >= 45 else "weak" - ) - # Never echo the password itself back. - return { - "ok": True, - "length": len(pw), - "entropy_bits": entropy, - "score": score, - "issues": issues, - } - - # --- live actions ---------------------------------------------------------- - def _live(self, action, **params): - if action == "audit_password_strength": - pw = params.get("password") - if pw is None: - raise ConnectorError(f"{self.name}: 'password' is required") - return self._audit_pw(str(pw)) - - if action == "check_ssl": - domain = params.get("domain") - if not domain: - raise ConnectorError(f"{self.name}: 'domain' is required") - port = int(params.get("port", 443)) - if port not in self.security.ports: - raise ConnectorError(f"{self.name}: port {port} is not allowed") - address = self.security.resolve_host(domain, port)[0] - ctx = ssl.create_default_context() - try: - with socket.create_connection((address, port), timeout=10) as sock: - with ctx.wrap_socket(sock, server_hostname=domain) as ssock: - cert = ssock.getpeercert() - except ssl.SSLCertVerificationError as e: - return {"ok": False, "domain": domain, "port": port, - "error": f"certificate verification failed: {e}"} - except (socket.timeout, socket.gaierror, OSError) as e: - raise ConnectorError(f"{self.name}: ssl check failed {domain}:{port}: {e}") - not_after = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z") - not_after = not_after.replace(tzinfo=timezone.utc) - days_left = (not_after - datetime.now(timezone.utc)).days - issuer = dict(x[0] for x in cert.get("issuer", ())) - sans = [v for t, v in cert.get("subjectAltName", ()) if t == "DNS"] - return { - "ok": True, - "domain": domain, - "port": port, - "issuer": issuer.get("organizationName") or issuer.get("commonName"), - "expires": not_after.date().isoformat(), - "days_until_expiry": days_left, - "expired": days_left < 0, - "subject_alt_names": sans, - } - - if action == "scan_common_exposure": - host = params.get("host") - if not host: - raise ConnectorError(f"{self.name}: 'host' is required") - self._authorize("network_scan", action, params.get("approval"), True) - addresses = self.security.resolve_host(host, next(iter(RISKY_PORTS))) - open_ports = [] - for port, service in RISKY_PORTS.items(): - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(4) - try: - if s.connect_ex((addresses[0], port)) == 0: - open_ports.append({"port": port, "service": service}) - finally: - s.close() - return { - "ok": True, - "host": host, - "risky_ports_checked": sorted(RISKY_PORTS), - "exposed": open_ports, - "warning": ("risky service(s) reachable from network — " - "restrict firewall/bind address") if open_ports else None, - } - - if action == "ssh_config_audit": - self._authorize("local_file_read", action, params.get("approval"), True) - path = params.get("path") or SSHD_CONFIG_DEFAULT - if not os.path.isfile(path) or not os.access(path, os.R_OK): - return { - "ok": True, - "path": path, - "readable": False, - "note": "sshd_config not readable (need root or custom path)", - } - settings = {} - with open(path, "r", errors="replace") as fh: - for line in fh: - line = line.strip() - if not line or line.startswith("#"): - continue - parts = line.split(None, 1) - if len(parts) == 2: - settings[parts[0].lower()] = parts[1].strip() - findings = [] - if settings.get("permitrootlogin", "").lower() == "yes": - findings.append("PermitRootLogin yes — disable root SSH login") - if settings.get("passwordauthentication", "").lower() == "yes": - findings.append("PasswordAuthentication yes — prefer key-only auth") - return { - "ok": True, - "path": path, - "readable": True, - "permit_root_login": settings.get("permitrootlogin"), - "password_authentication": settings.get("passwordauthentication"), - "findings": findings, - "hardened": not findings, - } - - if action == "generate_secret": - length = int(params.get("length", 32)) - if length < 8: - raise ConnectorError(f"{self.name}: length must be >= 8") - alphabet = string.ascii_letters + string.digits + "-_" - secret = "".join(secrets.choice(alphabet) for _ in range(length)) - return {"ok": True, "length": length, "secret": secret} - - raise ConnectorError(f"{self.name}: unhandled action '{action}'") diff --git a/connectors/ops/ssh_bash.py b/connectors/ops/ssh_bash.py deleted file mode 100644 index 4aca7ab..0000000 --- a/connectors/ops/ssh_bash.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Allowlisted local argv and remote SSH action execution. - -Both modes require a per-plugin capability, an allowlisted action definition, -a deployment-approved destructive action, and an approval identifier. No local -command is interpreted by a shell. Key material is referenced by path only. -""" -import json -import os -import re -import shlex - -from hub.base import BaseConnector, ConnectorError, register -from hub.security import SecurityError, SecurityPolicy, bounded_run - -LOCAL_TIMEOUT = 60 -SSH_CONNECT_TIMEOUT = 10 - - -@register -class OpsSshConnector(BaseConnector): - name = "ops_ssh" - required_env = [] # gate is enforced manually in __init__ - description = "Local bash and remote SSH command runner (gated)" - - def __init__(self, config=None): - super().__init__(config) - self.mock = False - self.missing_env = [] - self.security = SecurityPolicy(self.config) - - read_only_actions = frozenset(['list_hosts']) - mutating_actions = frozenset(['run_local', 'run_ssh']) - destructive_actions = frozenset([]) - dry_run_actions = frozenset(['run_local', 'run_ssh']) - - def actions(self): - return ["run_local", "run_ssh", "list_hosts"] - - # --- helpers ----------------------------------------------------------- - def _hosts(self): - raw = self.env("SSH_HOSTS", "").strip() - if not raw: - return [] - try: - hosts = json.loads(raw) - except json.JSONDecodeError as e: - raise ConnectorError(f"{self.name}: SSH_HOSTS is not valid JSON: {e}") - if not isinstance(hosts, list): - raise ConnectorError(f"{self.name}: SSH_HOSTS must be a JSON array") - return hosts - - def _find_host(self, host_id): - for h in self._hosts(): - if h.get("id") == host_id: - return h - known = [h.get("id") for h in self._hosts()] - raise ConnectorError( - f"{self.name}: unknown host_id '{host_id}'. Known: {known}" - ) - - def _action_argv(self, action_id, args, remote=False): - cfg = self.security.plugin(self.name) - definitions = cfg.get("remote_actions" if remote else "local_actions", {}) - definition = definitions.get(action_id) - if not isinstance(definition, dict): - raise SecurityError(f"{self.name}: action_id '{action_id}' is not allowlisted") - executable = definition.get("executable") - if not isinstance(executable, str) or (not remote and not os.path.isabs(executable)): - raise SecurityError("allowlisted executable must be an absolute path") - if not isinstance(args, list) or not all(isinstance(v, str) for v in args): - raise SecurityError("args must be a string array") - if len(args) > int(definition.get("max_args", 16)): - raise SecurityError("too many command arguments") - pattern = re.compile(definition.get("arg_pattern", r"^[A-Za-z0-9_./:@%+=,-]{1,256}$")) - if any(not pattern.fullmatch(v) for v in args): - raise SecurityError("command argument contains disallowed characters") - fixed = definition.get("fixed_args", []) - if not isinstance(fixed, list) or not all(isinstance(v, str) for v in fixed): - raise SecurityError("fixed_args must be a string array") - return [executable] + fixed + args - - def _authorized_argv(self, params, remote=False): - action_id = params.get("action_id") - if not action_id: - raise SecurityError("'action_id' is required") - self.security.require_capability(self.name, "ssh_exec" if remote else "local_exec", - action_id, params.get("approval"), True) - return action_id, self._action_argv(action_id, params.get("args", []), remote) - - # --- live actions ------------------------------------------------------- - def _live(self, action, **params): - if action == "list_hosts": - # Return only non-sensitive metadata — never key contents. - return { - "ok": True, - "hosts": [ - { - "id": h.get("id"), - "host": h.get("host"), - "user": h.get("user"), - "port": h.get("port", 22), - "key_path_configured": bool(h.get("key_path")), - } - for h in self._hosts() - ], - } - - if action == "run_local": - try: - action_id, argv = self._authorized_argv(params) - timeout = int(params.get("timeout", LOCAL_TIMEOUT)) - if timeout < 1 or timeout > LOCAL_TIMEOUT: - raise SecurityError(f"timeout must be between 1 and {LOCAL_TIMEOUT} seconds") - proc = bounded_run(argv, timeout, - self.security.max_output) - except SecurityError as e: - raise ConnectorError(f"{self.name}: {e}") - return { - "ok": proc["returncode"] == 0, "action_id": action_id, - "exit_code": proc["returncode"], "stdout": proc["stdout"], - "stderr": proc["stderr"], "truncated": proc["truncated"], - } - - if action == "run_ssh": - host_id = params.get("host_id") - if not host_id: - raise ConnectorError(f"{self.name}: 'host_id' is required") - try: - action_id, remote_argv = self._authorized_argv(params, remote=True) - except SecurityError as e: - raise ConnectorError(f"{self.name}: {e}") - h = self._find_host(host_id) - user = h.get("user", "root") - host = h.get("host") - if not host: - raise ConnectorError(f"{self.name}: host '{host_id}' has no 'host' field") - port = int(h.get("port", 22)) - if port not in self.security.ports: - raise ConnectorError(f"{self.name}: SSH port {port} is not allowed") - address = self.security.resolve_host(host, port)[0] - ssh_cmd = [ - "ssh", - "-o", "BatchMode=yes", - "-o", f"ConnectTimeout={SSH_CONNECT_TIMEOUT}", - "-o", "StrictHostKeyChecking=accept-new", - "-o", f"HostKeyAlias={host}", - "-p", str(port), - ] - key_path = h.get("key_path") - expanded_key = None - if key_path: - expanded_key = os.path.expanduser(key_path) - ssh_cmd += ["-i", expanded_key] - remote_command = " ".join(shlex.quote(v) for v in remote_argv) - ssh_cmd += [f"{user}@{address}", remote_command] - try: - proc = bounded_run(ssh_cmd, LOCAL_TIMEOUT, self.security.max_output, - secrets=(expanded_key,)) - except FileNotFoundError: - raise ConnectorError(f"{self.name}: system 'ssh' binary not found") - except SecurityError: - raise ConnectorError( - f"{self.name}: ssh to '{host_id}' timed out after {LOCAL_TIMEOUT}s" - ) - return { - "ok": proc["returncode"] == 0, - "host_id": host_id, - "target": f"{user}@{host}:{port}", - "action_id": action_id, - "exit_code": proc["returncode"], "stdout": proc["stdout"], - "stderr": proc["stderr"], "truncated": proc["truncated"], - } - - raise ConnectorError(f"{self.name}: unhandled action '{action}'") diff --git a/crates/connector-hub/src/main.rs b/crates/connector-hub/src/main.rs index 2edc4ee..82df4e8 100644 --- a/crates/connector-hub/src/main.rs +++ b/crates/connector-hub/src/main.rs @@ -184,7 +184,51 @@ async fn main() -> anyhow::Result<()> { println!(" Credentials configured for: {}", configured.join(", ")); } - // 6. Check audit ledger if present + // 6. Check plugin manifest version parity + let manifest_paths = [ + ".claude-plugin/plugin.json", + ".claude-plugin/marketplace.json", + ".codex-plugin/plugin.json", + "kimi.plugin.json", + ]; + let mut manifest_versions: Vec<(String, String)> = Vec::new(); + for path_str in &manifest_paths { + let p = std::path::Path::new(path_str); + if p.exists() { + match std::fs::read_to_string(p) { + Ok(content) => { + if let Ok(val) = serde_json::from_str::(&content) { + if let Some(ver) = val.get("version").and_then(|v| v.as_str()) { + manifest_versions.push((path_str.to_string(), ver.to_string())); + } else { + warnings.push(format!("{path_str}: missing 'version' field")); + } + } else { + errors.push(format!("{path_str}: invalid JSON")); + } + } + Err(e) => errors.push(format!("{path_str}: {e}")), + } + } else { + warnings.push(format!("{path_str}: not found")); + } + } + if !manifest_versions.is_empty() { + let first_ver = &manifest_versions[0].1; + let all_match = manifest_versions.iter().all(|(_, v)| v == first_ver); + if all_match { + println!( + " Plugin manifests: {} file(s) at version {first_ver}", + manifest_versions.len() + ); + } else { + for (path, ver) in &manifest_versions { + errors.push(format!("manifest version mismatch: {path} = {ver}")); + } + } + } + + // 7. Check audit ledger if present let ledger_path = std::path::Path::new("audit.jsonl"); if ledger_path.exists() { match hub_policy::AuditLedger::verify(ledger_path) { diff --git a/docs/adr/0002-rust-rewrite.md b/docs/adr/0002-rust-rewrite.md new file mode 100644 index 0000000..6d0a2f0 --- /dev/null +++ b/docs/adr/0002-rust-rewrite.md @@ -0,0 +1,65 @@ +# ADR 0002: Rewrite in Rust with spec-driven connectors + +- Status: Accepted +- Date: 2026-08-11 +- Decision owners: Connector Hub maintainers + +## Context + +The Python implementation shipped 21 connectors with 142 hand-written actions +against provider surfaces totalling thousands of endpoints. Coverage was +structurally incomplete: adding a provider endpoint meant writing a new Python +method, registering it, and keeping docs in sync. Three declared runtime +dependencies (httpx, pydantic, structlog) were exercised only by the test suite. +The safety contract (dry-run, confirmation tokens, mutation classification) was +built, then silently reverted while the documentation continued to advertise it. +CI was red on main due to a Python 3.14 incompatibility in a pinned dependency. + +## Decision + +Replace the Python implementation with a Rust workspace using the official MCP +SDK (`rmcp`). Connectors are expressed as declarative JSON spec files (OpenAPI +3.x or Google Discovery format) compiled into an operation catalogue at startup. +The MCP tool surface is small and fixed (search, describe, call, list, validate) +while the reachable surface is complete — determined by the spec, not by +hand-written code. + +## Rationale + +- **Completeness by construction**: a spec-derived catalogue includes every + endpoint the provider publishes. Adding coverage means updating a JSON file, + not writing Rust. +- **Single execution path**: one `NetClient` through one SSRF-validated HTTP + stack. The Python tree had two HTTP stacks (urllib in base.py, httpx in + http_client.py) with 17 of 21 connectors bypassing the policy layer. +- **Type-safe safety contract**: the execution result is an enum + (`Succeeded | DryRun | ConfirmationRequired | ...`). Non-execution states + cannot carry `executed: true` because there is no code path that constructs it. +- **Single static binary**: no version matrix, no virtualenv, no runtime + dependency conflicts. The class of CI failure that broke main (Pydantic under + Python 3.14) becomes impossible. +- **Toolchain**: `rmcp` 3.1.2 (Apache-2.0, MSRV 1.88, actively maintained). + The environment has rustc 1.94.1. + +## Crate layout + +| Crate | Responsibility | +|---|---| +| `connector-hub` (bin) | CLI + rmcp stdio server | +| `hub-core` | Operation catalogue, dispatch, execution-state envelope | +| `hub-spec` | Spec ingestion: OpenAPI 3.x + Google Discovery → operations | +| `hub-auth` | Credential store, OAuth, token refresh | +| `hub-policy` | Permission model, capability grants, hash-chained audit ledger | +| `hub-net` | HTTP execution: SSRF validation, IP pinning, retries, redaction | + +## Consequences + +- Python tree is removed. Migration is parity-verified: a test asserts every one + of the 142 original actions has a corresponding operation in the catalogue. +- Provider endpoint knowledge (auth schemes, URL patterns, header formats) is + preserved in spec files, not discarded. +- Hand-written specs (WHMCS, cPanel, WHM, OVH, tawk.to, OneProvider) are + complete by inspection, not by construction. Per-provider coverage is stated + explicitly. +- The `hub/security/policy.py` SSRF defense and its adversarial tests are ported + to `hub-net`. diff --git a/docs/adr/0003-vendoring-resolution.md b/docs/adr/0003-vendoring-resolution.md new file mode 100644 index 0000000..6761b0c --- /dev/null +++ b/docs/adr/0003-vendoring-resolution.md @@ -0,0 +1,49 @@ +# ADR 0003: Resolve the vendoring conflict — reference, don't vendor + +- Status: Accepted +- Date: 2026-08-11 +- Supersedes: ADR 0001 (partially) +- Decision owners: Connector Hub maintainers + +## Context + +Two incompatible vendoring systems shipped simultaneously: + +1. `docs/adr/0001-static-third-party-plugin-sources.md` governed `vendor/` with + pinned SHA, licence checks, and a Forgkit quarantine. +2. `.agents/plugins/plugins/connector-hub/scripts/sync_upstreams.py` hardcoded + `CodeWithJuber/forgekit` and `CodeWithJuber/hikmah-stack`, cloned them into a + different gitignored `.vendor/` directory with none of the ADR's diligence. + +The `vendor/hikmah/UPSTREAM.md` recorded upstream as `hikmahlabs/plugins` — a +different project. The `vendor/forgkit/NOT_VENDORED.md` stated that no project +named Forgkit exists, while `CodeWithJuber/forgekit` is a public repo published +to npm. The README simultaneously listed forgekit as a data source and told +users to run `sync_upstreams.py`. + +## Decision + +Neither sibling repository is vendored. Both are **referenced** as related +projects in the README and this ADR. The `vendor/` directory, the +`sync_upstreams.py` script, and the `.agents/` plugin scaffold are deleted. + +The three repositories form one system: + +| Repo | Role | +|---|---| +| `CodeWithJuber/forgekit` | Delivery + substrate (memory, foresight, guardrail hooks) | +| `CodeWithJuber/hikmah-stack` | Judgment (deterministic cognitive kernel, audit ledger) | +| `CodeWithJuber/connector-hub` | Actuation (the hands — reaches external services) | + +Each stands alone. connector-hub's audit ledger uses the same hash-chained JSONL +format as hikmah-stack's TraceWeave for format compatibility, but there is no +code dependency. The ~150-line implementation is self-contained in `hub-policy`. + +## Consequences + +- ADR 0001 remains as historical record. Its trust-boundary analysis and update + policy are sound principles; the vendoring it governed is no longer present. +- No vendored code ships. No `sync_upstreams.py` or equivalent auto-fetcher. +- Future integration with forgekit or hikmah-stack (if desired) would be through + explicit, versioned dependencies or well-defined IPC, not by copying source + trees. diff --git a/docs/adr/0004-audit-ledger-format.md b/docs/adr/0004-audit-ledger-format.md new file mode 100644 index 0000000..48e3da9 --- /dev/null +++ b/docs/adr/0004-audit-ledger-format.md @@ -0,0 +1,59 @@ +# ADR 0004: Hash-chained JSONL audit ledger + +- Status: Accepted +- Date: 2026-08-11 +- Decision owners: Connector Hub maintainers + +## Context + +connector-hub is the only one of the three sibling repositories that causes +irreversible side effects in the real world (deleting servers, terminating +accounts, sending mail). Every permission decision — granted or refused — needs +a tamper-evident record. + +## Decision + +Every policy decision is appended to a hash-chained JSONL audit ledger at +`audit.jsonl`. Each entry contains: + +```json +{ + "timestamp": "2026-08-11T12:00:00Z", + "provider": "hetzner", + "operation": "hetzner.servers.delete", + "account": "production", + "mutation_class": "destructive", + "decision": "granted", + "prev_hash": "", + "hash": "" +} +``` + +The hash chain uses BLAKE3 for speed and resistance to length-extension attacks. +The first entry's `prev_hash` is the string `"genesis"`. Verification walks the +file and confirms each entry's `prev_hash` matches the preceding entry's `hash`, +and each `hash` is the correct BLAKE3 digest of the entry's content (with the +`hash` field itself zeroed during computation). + +## Format compatibility + +The format is intentionally compatible with hikmah-stack's TraceWeave ledger +(`runtime/hikmah-kernel/src/ledger.rs`), which also uses BLAKE3 hash-chained +JSONL. `hikmah verify-ledger` can validate a connector-hub audit file, and vice +versa. The implementation is self-contained in the `hub-policy` crate (~150 +lines); there is no code dependency on hikmah-stack. + +## Verification + +`connector-hub audit-verify [path]` walks the ledger and reports the entry count +and chain integrity. It exits non-zero on any chain break, missing entry, or +hash mismatch. + +## Consequences + +- Every dispatched operation is recorded, whether it succeeded or was refused. +- The ledger is append-only by convention; the file format does not enforce this, + but any tampering (insertion, deletion, reordering) is detectable by + verification. +- The ledger file grows without bound. Rotation is the operator's responsibility; + `connector-hub audit-verify` works on any contiguous segment. diff --git a/hub/__init__.py b/hub/__init__.py deleted file mode 100644 index 5206a3e..0000000 --- a/hub/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Omni Connector Hub — one channel router for all your services.""" - -import importlib -import os - -from .base import ( # noqa: F401 - BaseConnector, - ConnectorError, - get_connector, - list_connectors, - register, -) - -_LOADED = False - - -def load_connectors(): - """Import every module under connectors/ so @register decorators fire.""" - global _LOADED - if _LOADED: - return - root = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "connectors") - for dirpath, _dirnames, filenames in os.walk(root): - for fn in filenames: - if fn.endswith(".py") and not fn.startswith("__"): - rel = os.path.relpath(os.path.join(dirpath, fn), os.path.dirname(root)) - mod = rel[:-3].replace(os.sep, ".") - importlib.import_module(mod) - _LOADED = True diff --git a/hub/base.py b/hub/base.py deleted file mode 100644 index d7d0f92..0000000 --- a/hub/base.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Base contract for every connector in the hub. - -Zero third-party dependencies. HTTP via urllib. Secrets via env only. -""" - -import json -import os -import random -import re -import time -import urllib.error -import urllib.request - -_SECRET_PAT = re.compile(r"(KEY|TOKEN|SECRET|PASS|PWD)", re.I) - - -class ConnectorError(Exception): - """Raised on real-mode failures (auth, HTTP, bad action).""" - - def __init__(self, message, *, code="connector_error", retryable=False, status=None): - super().__init__(message) - self.code = code - self.retryable = retryable - self.status = status - - def as_dict(self): - """Return a stable, secret-safe error envelope for callers and logs.""" - return { - "ok": False, - "error": { - "code": self.code, - "message": str(self), - "retryable": self.retryable, - "status": self.status, - }, - } - - -class BaseConnector: - """Subclass and set `name`, `required_env`, implement `actions()` and `call()`.""" - - name = "base" - required_env: list = [] # env vars needed to leave mock mode - description = "" - - def __init__(self, config=None): - self.config = config or {} - missing = [v for v in self.required_env if not os.environ.get(v)] - self.mock = bool(missing) - self.missing_env = missing - - # --- helpers --------------------------------------------------------- - def env(self, var, default=None): - return os.environ.get(var, default) - - def status(self): - return { - "name": self.name, - "mode": "mock" if self.mock else "live", - "missing_env": self.missing_env, - "actions": self.actions(), - "description": self.description, - } - - def actions(self): - raise NotImplementedError - - def require(self, action): - if action not in self.actions(): - raise ConnectorError( - f"{self.name}: unknown action '{action}'. Available: {', '.join(self.actions())}" - ) - - def call(self, action, **params): - self.require(action) - if self.mock: - return { - "ok": True, - "mock": True, - "connector": self.name, - "action": action, - "echo": self._redact(params), - "note": f"set {', '.join(self.missing_env)} to go live", - } - return self._live(action, **params) - - def _live(self, action, **params): - raise NotImplementedError(f"{self.name} has no live implementation") - - # --- HTTP ------------------------------------------------------------ - def http_json( - self, method, url, headers=None, payload=None, timeout=20, max_attempts=3, base_delay=0.25 - ): - """Send JSON with bounded exponential backoff and rate-limit awareness. - - Only idempotent requests and explicit rate-limit responses are retried. - Jitter prevents synchronized clients from retrying simultaneously. - """ - body = json.dumps(payload).encode() if payload is not None else None - method = method.upper() - if max_attempts < 1: - raise ValueError("max_attempts must be at least 1") - for attempt in range(max_attempts): - req = urllib.request.Request(url, data=body, method=method) - req.add_header("Accept", "application/json") - if body is not None: - req.add_header("Content-Type", "application/json") - for k, v in (headers or {}).items(): - req.add_header(k, v) - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - raw = resp.read().decode() or "{}" - try: - data = json.loads(raw) - except json.JSONDecodeError: - data = {"raw": raw} - return {"ok": True, "status": resp.status, "data": data} - except urllib.error.HTTPError as exc: - retryable = exc.code == 429 or exc.code >= 500 - can_retry = retryable and (method in {"GET", "HEAD"} or exc.code == 429) - if can_retry and attempt + 1 < max_attempts: - retry_after = exc.headers.get("Retry-After") if exc.headers else None - try: - delay = min(float(retry_after), 30.0) if retry_after else 0.0 - except ValueError: - delay = 0.0 - time.sleep( - max(delay, base_delay * (2**attempt) + random.uniform(0, base_delay)) - ) - continue - detail = exc.read().decode(errors="replace")[:500] - raise ConnectorError( - f"{self.name} HTTP {exc.code} {url}: {detail}", - code="rate_limited" if exc.code == 429 else "http_error", - retryable=retryable, - status=exc.code, - ) from exc - except (urllib.error.URLError, TimeoutError) as exc: - if method in {"GET", "HEAD"} and attempt + 1 < max_attempts: - time.sleep(base_delay * (2**attempt) + random.uniform(0, base_delay)) - continue - reason = getattr(exc, "reason", exc) - raise ConnectorError( - f"{self.name} connection failed {url}: {reason}", - code="connection_error", - retryable=True, - ) from exc - - # --- misc ------------------------------------------------------------ - def _redact(self, obj): - if isinstance(obj, dict): - return { - k: ("***" if _SECRET_PAT.search(str(k)) else self._redact(v)) - for k, v in obj.items() - } - if isinstance(obj, list | tuple): - return [self._redact(x) for x in obj] - return obj - - -# --- registry ------------------------------------------------------------ -_REGISTRY = {} - - -def register(cls): - _REGISTRY[cls.name] = cls - return cls - - -def get_connector(name, config=None): - if name not in _REGISTRY: - raise ConnectorError(f"unknown channel '{name}'. Known: {', '.join(sorted(_REGISTRY))}") - return _REGISTRY[name](config=config) - - -def list_connectors(): - return {name: cls.description for name, cls in sorted(_REGISTRY.items())} diff --git a/hub/gateway.py b/hub/gateway.py deleted file mode 100644 index 62acb86..0000000 --- a/hub/gateway.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Channel gateway CLI — route one command to any connector. - -Usage: - python3 -m hub.gateway list # all channels + mode - python3 -m hub.gateway status - python3 -m hub.gateway call '{"k": "v"}' - python3 -m hub.gateway mcp # stdio MCP server -""" - -import json -import os -import sys - -# load .env if present (KEY=VALUE lines, no quotes needed) -_ENV_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env") -if os.path.exists(_ENV_PATH): - with open(_ENV_PATH) as f: - for line in f: - line = line.strip() - if line and not line.startswith("#") and "=" in line: - k, v = line.split("=", 1) - os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) - -from . import get_connector, list_connectors, load_connectors # noqa: E402 - - -def main(argv=None): - argv = argv or sys.argv[1:] - load_connectors() - - if not argv or argv[0] in ("-h", "--help", "help"): - print(__doc__) - return 0 - - cmd = argv[0] - - if cmd == "list": - for name, desc in list_connectors().items(): - conn = get_connector(name) - mode = "MOCK" if conn.mock else "LIVE" - print(f"[{mode:4}] {name:14} {desc}") - return 0 - - if cmd == "health": - print(json.dumps({"ok": True, "service": "omni-connector-hub"})) - return 0 - - if cmd == "status": - conn = get_connector(argv[1]) - print(json.dumps(conn.status(), indent=2)) - return 0 - - if cmd == "call": - if len(argv) < 3: - print("usage: call '{json params}'", file=sys.stderr) - return 2 - params = json.loads(argv[3]) if len(argv) > 3 else {} - conn = get_connector(argv[1]) - result = conn.call(argv[2], **params) - print(json.dumps(result, indent=2, default=str)) - return 0 if result.get("ok") else 1 - - if cmd == "mcp": - from .mcp_server import serve - - serve() - return 0 - - print(f"unknown command: {cmd}", file=sys.stderr) - return 2 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/hub/http_client.py b/hub/http_client.py deleted file mode 100644 index abba232..0000000 --- a/hub/http_client.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Hardened shared HTTP transport with conditional GET caching.""" - -from __future__ import annotations - -import email.utils -import json -import random -import threading -import time -from collections import OrderedDict -from dataclasses import dataclass -from datetime import UTC, datetime -from typing import Any -from urllib.parse import urlsplit - -import httpx - -from .logging import log, redact_headers - - -class UpstreamError(RuntimeError): - """Normalized, actionable upstream failure safe to display to callers.""" - - def __init__( - self, - provider: str, - category: str, - message: str, - *, - status: int | None = None, - retryable: bool = False, - ): - super().__init__(f"{provider}: {category}: {message}") - self.provider, self.category, self.status, self.retryable = ( - provider, - category, - status, - retryable, - ) - - -@dataclass -class CacheEntry: - data: Any - status: int - etag: str | None - last_modified: str | None - expires: float - - -class SharedHttpClient: - """Thread-safe transport. Cache keys intentionally exclude all headers.""" - - RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504} - IDEMPOTENT = {"GET", "HEAD", "OPTIONS", "PUT", "DELETE"} - - def __init__( - self, - *, - max_body_bytes: int = 2_000_000, - cache_entries: int = 128, - cache_ttl: float = 300, - concurrency: int = 8, - attempts: int = 4, - ): - self.max_body_bytes, self.cache_entries, self.cache_ttl, self.attempts = ( - max_body_bytes, - cache_entries, - cache_ttl, - attempts, - ) - self._client = httpx.Client( - timeout=httpx.Timeout(connect=5, read=30, write=30, pool=5), - limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), - follow_redirects=False, - ) - self._cache: OrderedDict[str, CacheEntry] = OrderedDict() - self._cache_lock = threading.Lock() - self._limits: dict[str, threading.BoundedSemaphore] = {} - self._limit_lock = threading.Lock() - self._concurrency = concurrency - - def _semaphore(self, provider: str) -> threading.BoundedSemaphore: - with self._limit_lock: - return self._limits.setdefault(provider, threading.BoundedSemaphore(self._concurrency)) - - @staticmethod - def _retry_delay(response: httpx.Response | None, attempt: int) -> float: - if response is not None and response.headers.get("Retry-After"): - raw = response.headers["Retry-After"] - try: - return min(float(raw), 60) - except ValueError: - try: - return max( - 0, - min( - ( - email.utils.parsedate_to_datetime(raw) - datetime.now(UTC) - ).total_seconds(), - 60, - ), - ) - except (TypeError, ValueError): - pass - return min(0.25 * (2 ** (attempt - 1)) + random.uniform(0, 0.25), 8) - - def _cached(self, key: str) -> CacheEntry | None: - with self._cache_lock: - entry = self._cache.get(key) - if entry and entry.expires > time.monotonic(): - self._cache.move_to_end(key) - return entry - if entry: - del self._cache[key] - return None - - def request_json( - self, - provider: str, - method: str, - url: str, - *, - headers: dict[str, str] | None = None, - payload: Any = None, - request_id: str = "", - cache_safe: bool = True, - idempotency_key: str | None = None, - ) -> dict[str, Any]: - method, headers = method.upper(), dict(headers or {}) - safe_cache = method == "GET" and cache_safe and not payload - cache_key = url - cached = self._cached(cache_key) if safe_cache else None - if cached: - if cached.etag: - headers["If-None-Match"] = cached.etag - if cached.last_modified: - headers["If-Modified-Since"] = cached.last_modified - if idempotency_key: - headers["Idempotency-Key"] = idempotency_key - retryable_method = method in self.IDEMPOTENT or bool(idempotency_key) - started = time.monotonic() - with self._semaphore(provider): - for attempt in range(1, self.attempts + 1): - response = None - try: - with self._client.stream( - method, url, headers=headers, json=payload - ) as response: - if response.status_code == 304 and cached: - return { - "ok": True, - "status": cached.status, - "data": cached.data, - "cached": True, - "attempts": attempt, - } - chunks, size = [], 0 - for chunk in response.iter_bytes(): - size += len(chunk) - if size > self.max_body_bytes: - raise UpstreamError( - provider, - "response_too_large", - f"response exceeded {self.max_body_bytes} bytes", - status=response.status_code, - ) - chunks.append(chunk) - raw = b"".join(chunks) - if ( - response.status_code in self.RETRYABLE_STATUS - and retryable_method - and attempt < self.attempts - ): - time.sleep(self._retry_delay(response, attempt)) - continue - if response.is_error: - detail = raw.decode(errors="replace")[:500] - raise UpstreamError( - provider, - "http_error", - f"HTTP {response.status_code}; {detail}", - status=response.status_code, - retryable=response.status_code in self.RETRYABLE_STATUS, - ) - try: - data = json.loads(raw) if raw else {} - except (ValueError, UnicodeDecodeError): - data = {"raw": raw.decode(errors="replace")} - if safe_cache and not any( - k.lower() in {"authorization", "cookie", "x-api-key"} for k in headers - ): - entry = CacheEntry( - data, - response.status_code, - response.headers.get("etag"), - response.headers.get("last-modified"), - time.monotonic() + self.cache_ttl, - ) - with self._cache_lock: - self._cache[cache_key] = entry - self._cache.move_to_end(cache_key) - while len(self._cache) > self.cache_entries: - self._cache.popitem(last=False) - log.info( - "upstream_request", - request_id=request_id, - connector=provider, - latency_ms=round((time.monotonic() - started) * 1000, 2), - attempt_count=attempt, - upstream_status=response.status_code, - headers=redact_headers(headers), - upstream_host=urlsplit(url).hostname, - ) - return { - "ok": True, - "status": response.status_code, - "data": data, - "attempts": attempt, - } - except UpstreamError: - raise - except (httpx.TimeoutException, httpx.NetworkError) as exc: - if retryable_method and attempt < self.attempts: - time.sleep(self._retry_delay(response, attempt)) - continue - category = ( - "timeout" if isinstance(exc, httpx.TimeoutException) else "network_error" - ) - raise UpstreamError( - provider, category, str(exc), retryable=retryable_method - ) from exc - raise AssertionError("unreachable") - - -shared_http_client = SharedHttpClient() diff --git a/hub/logging.py b/hub/logging.py deleted file mode 100644 index 441c018..0000000 --- a/hub/logging.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Structured JSON logging and schema-aware redaction.""" - -import sys -from typing import Any - -import structlog - -SENSITIVE_HEADERS = {"authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key"} - -structlog.configure( - processors=[ - structlog.contextvars.merge_contextvars, - structlog.processors.TimeStamper(fmt="iso"), - structlog.processors.JSONRenderer(), - ], - logger_factory=structlog.PrintLoggerFactory(file=sys.stderr), -) -log = structlog.get_logger("connector_hub") - - -def redact_headers(headers: dict[str, str]) -> dict[str, str]: - return {k: "[REDACTED]" if k.lower() in SENSITIVE_HEADERS else v for k, v in headers.items()} - - -def redact_fields(data: dict[str, Any], secret_fields: set[str]) -> dict[str, Any]: - return {k: "[REDACTED]" if k in secret_fields else v for k, v in data.items()} diff --git a/hub/mcp_server.py b/hub/mcp_server.py deleted file mode 100644 index dd7dcbf..0000000 --- a/hub/mcp_server.py +++ /dev/null @@ -1,256 +0,0 @@ -"""MCP SDK based stdio server for the validated connector action registry.""" - -from __future__ import annotations - -import inspect -import json -import logging -import os -import re -import uuid -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass -from typing import Any - -import anyio -from mcp.server import Server -from mcp.server.stdio import stdio_server -from mcp.types import TextContent, Tool, ToolAnnotations - -from . import ConnectorError, get_connector, list_connectors, load_connectors - -LOG = logging.getLogger("connector_hub.mcp") -_SAFE_NAME = re.compile(r"^[a-z][a-z0-9_]*$") -_READ_PREFIXES = ("list", "get", "check", "search", "status", "fetch", "audit", "resolve") -_DESTRUCTIVE_PREFIXES = ("delete", "terminate", "remove", "revoke", "suspend") -_ACTION_SCHEMAS: dict[tuple[str, str], dict[str, Any]] = { - ("openai", "chat"): { - "type": "object", - "properties": { - "messages": {"type": "array", "items": {"type": "object"}}, - "model": {"type": "string"}, - "temperature": {"type": "number", "minimum": 0, "maximum": 2}, - }, - "required": ["messages"], - "additionalProperties": True, - }, - ("openai", "embeddings"): { - "type": "object", - "properties": { - "input": { - "oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}] - }, - "model": {"type": "string"}, - }, - "required": ["input"], - "additionalProperties": True, - }, -} - - -@dataclass(frozen=True) -class Action: - connector: str - name: str - - @property - def tool_name(self) -> str: - """Stable namespace; double underscore cannot collide with registry names.""" - return f"hub__{self.connector}__{self.name}" - - @property - def read_only(self) -> bool: - return self.name.startswith(_READ_PREFIXES) - - @property - def destructive(self) -> bool: - return self.name.startswith(_DESTRUCTIVE_PREFIXES) - - -def build_action_registry() -> dict[str, Action]: - """Validate connector/action identifiers and return tools keyed by MCP name.""" - load_connectors() - registry: dict[str, Action] = {} - for connector in list_connectors(): - if not _SAFE_NAME.fullmatch(connector): - raise RuntimeError(f"invalid connector identifier: {connector!r}") - actions = get_connector(connector).actions() - if not isinstance(actions, list | tuple) or not actions: - raise RuntimeError(f"connector {connector!r} has no validated actions") - for name in actions: - if not isinstance(name, str) or not _SAFE_NAME.fullmatch(name): - raise RuntimeError(f"invalid action identifier for {connector!r}: {name!r}") - action = Action(connector, name) - if action.tool_name in registry: - raise RuntimeError(f"duplicate MCP tool: {action.tool_name}") - registry[action.tool_name] = action - return registry - - -def _tool(action: Action) -> Tool: - # Existing connectors accept keyword arguments. This schema still provides - # SDK-level object/type validation while preserving their evolving APIs. - schema = _ACTION_SCHEMAS.get( - (action.connector, action.name), {"type": "object", "additionalProperties": True} - ) - return Tool( - name=action.tool_name, - description=f"{action.connector}: {action.name}", - inputSchema=schema, - annotations=ToolAnnotations( - readOnlyHint=action.read_only, - destructiveHint=action.destructive, - idempotentHint=action.read_only, - openWorldHint=not action.read_only, - ), - ) - - -def _error(kind: str, message: str) -> RuntimeError: - """Build the only exception text allowed to cross the MCP boundary.""" - payload = {"error": {"type": kind, "message": message}} - return RuntimeError(json.dumps(payload, separators=(",", ":"))) - - -def _classify(exc: BaseException) -> tuple[str, str]: - """Map internal failures to stable messages without reflecting secrets.""" - text = str(exc).lower() - if isinstance(exc, TimeoutError) or "timed out" in text or "timeout" in text: - return "timeout", "The connector call exceeded its deadline." - if "rate" in text and ("limit" in text or "429" in text): - return "rate_limit", "The upstream service rate limit was reached." - if any(word in text for word in ("auth", "credential", "401", "403", "token")): - return "authentication", "Connector authentication failed." - if "policy" in text or "not allowed" in text or "forbidden" in text: - return "policy", "The call was denied by connector policy." - if isinstance(exc, TypeError | ValueError) or "requires" in text or "required" in text: - return "validation", "The connector rejected the supplied arguments." - if isinstance(exc, ConnectorError): - return "upstream", "The upstream connector request failed." - return "internal", "The connector call failed unexpectedly." - - -class ConnectorRuntime: - def __init__(self, max_concurrency: int, timeout_seconds: float) -> None: - self.actions = build_action_registry() - self.connectors: dict[str, Any] = {} - self.limiter = anyio.Semaphore(max_concurrency) - self.timeout_seconds = timeout_seconds - - def connector(self, name: str) -> Any: - if name not in self.connectors: - self.connectors[name] = get_connector(name) - return self.connectors[name] - - async def close(self) -> None: - for connector in self.connectors.values(): - close = getattr(connector, "aclose", None) or getattr(connector, "close", None) - if close: - result = close() - if inspect.isawaitable(result): - await result - self.connectors.clear() - - async def call(self, action: Action, arguments: dict[str, Any]) -> Any: - async with self.limiter: - with anyio.fail_after(self.timeout_seconds): - # abandon_on_cancel ensures MCP cancellation/deadlines propagate - # immediately even though legacy connectors are synchronous. - return await anyio.to_thread.run_sync( - lambda: self.connector(action.connector).call(action.name, **arguments), - abandon_on_cancel=True, - ) - - -def create_server( - *, max_concurrency: int | None = None, timeout_seconds: float | None = None -) -> Server: - """Create an isolated server instance (also useful for protocol tests).""" - concurrency = max_concurrency or int(os.getenv("HUB_MCP_MAX_CONCURRENCY", "8")) - deadline = timeout_seconds or float(os.getenv("HUB_MCP_CALL_TIMEOUT", "30")) - if concurrency < 1 or deadline <= 0: - raise ValueError("MCP concurrency and timeout settings must be positive") - runtime = ConnectorRuntime(concurrency, deadline) - - @asynccontextmanager - async def lifespan(_server: Server) -> AsyncIterator[ConnectorRuntime]: - try: - yield runtime - finally: - await runtime.close() - - server = Server("omni-connector-hub", version="2.0.0", lifespan=lifespan) - - @server.list_tools() - async def list_tools() -> list[Tool]: - return [_tool(action) for action in runtime.actions.values()] - - @server.call_tool(validate_input=True) - async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: - request_id = uuid.uuid4().hex - action = runtime.actions.get(name) - if action is None: - LOG.warning("unknown_tool", extra={"request_id": request_id, "tool": name}) - raise _error("unknown_action", "The requested connector action does not exist.") - try: - result = await runtime.call(action, arguments) - return [TextContent(type="text", text=json.dumps(result, default=str))] - except anyio.get_cancelled_exc_class(): - LOG.info("call_cancelled", extra={"request_id": request_id, "tool": name}) - raise - except BaseException as exc: - kind, client_message = _classify(exc) - LOG.exception( - "connector_call_failed", - extra={"request_id": request_id, "tool": name, "failure_type": kind}, - ) - raise _error(kind, client_message) from None - - return server - - -async def serve_async() -> None: - server = create_server() - async with stdio_server() as (read_stream, write_stream): - await server.run(read_stream, write_stream, server.create_initialization_options()) - - -def serve() -> None: - """Run until EOF/SIGTERM; AnyIO performs structured graceful shutdown.""" - logging.basicConfig(level=os.getenv("HUB_LOG_LEVEL", "INFO"), stream=os.sys.stderr) - try: - anyio.run(serve_async) - except KeyboardInterrupt: - LOG.info("server_shutdown") - - -# Compatibility helpers retained for callers of the original synchronous adapter. -def _text(data: Any) -> dict[str, list[dict[str, str]]]: - """Serialize a value in the MCP text-content envelope.""" - return {"content": [{"type": "text", "text": json.dumps(data, default=str)}]} - - -def _call_tool(name: str, arguments: dict[str, Any]) -> dict[str, list[dict[str, str]]]: - """Reject legacy synchronous dispatch and direct callers to the SDK server.""" - del arguments - raise ValueError(f"unknown tool: {name}; use create_server() for MCP dispatch") - - -def _tools() -> list[dict[str, Any]]: - """Return strict action schemas for legacy discovery clients.""" - from .schemas import action_json_schema - - tools: list[dict[str, Any]] = [] - load_connectors() - for channel in list_connectors(): - connector = get_connector(channel) - for action in connector.actions(): - tools.append( - { - "name": f"{channel}__{action}", - "description": f"Run {action} on the {channel} connector", - "inputSchema": action_json_schema(connector, action), - } - ) - return tools diff --git a/hub/plugins.py b/hub/plugins.py deleted file mode 100644 index 7f82a21..0000000 --- a/hub/plugins.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Static, policy-gated plugin manifest discovery. - -The loader registers metadata only. It deliberately never imports, downloads, or -executes plugin content; an application must provide reviewed adapters separately. -""" - -import json -import re -from collections.abc import Iterable, Mapping -from dataclasses import dataclass -from pathlib import Path - -SUPPORTED_API_VERSIONS = frozenset({"connector-hub.plugin/v1"}) -_FIELDS = frozenset( - { - "api_version", - "plugin_id", - "version", - "capabilities", - "required_secrets", - "allowed_network_hosts", - "supports_destructive_actions", - } -) -_ID = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$") -_VERSION = re.compile( - r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$" -) -_CAPABILITY = re.compile(r"^[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*$") -_SECRET = re.compile(r"^[A-Z][A-Z0-9_]*$") -_HOST = re.compile( - r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$" -) - - -class PluginValidationError(ValueError): - """A plugin cannot be safely registered; the message explains why.""" - - -@dataclass(frozen=True) -class PluginManifest: - api_version: str - plugin_id: str - version: str - capabilities: tuple[str, ...] - required_secrets: tuple[str, ...] - allowed_network_hosts: tuple[str, ...] - supports_destructive_actions: bool - - -@dataclass(frozen=True) -class PluginRegistration: - manifest: PluginManifest - directory: Path - - -def _string_array(data: Mapping[str, object], field: str, pattern: re.Pattern) -> tuple[str, ...]: - value = data.get(field) - if not isinstance(value, list) or any(not isinstance(item, str) for item in value): - raise PluginValidationError(f"{field} must be an array of strings") - if len(value) != len(set(value)): - raise PluginValidationError(f"{field} must not contain duplicates") - invalid = [item for item in value if not pattern.fullmatch(item)] - if invalid: - raise PluginValidationError(f"{field} contains invalid value(s): {', '.join(invalid)}") - return tuple(value) - - -def validate_manifest(data: object, enabled_capabilities: Iterable[str]) -> PluginManifest: - """Validate untrusted decoded JSON against the closed v1 manifest contract.""" - if not isinstance(data, dict): - raise PluginValidationError("manifest root must be a JSON object") - unknown = set(data) - _FIELDS - missing = _FIELDS - set(data) - if unknown: - raise PluginValidationError(f"unknown manifest field(s): {', '.join(sorted(unknown))}") - if missing: - raise PluginValidationError(f"missing manifest field(s): {', '.join(sorted(missing))}") - api_version = data["api_version"] - if api_version not in SUPPORTED_API_VERSIONS: - raise PluginValidationError(f"unsupported plugin API version: {api_version!r}") - plugin_id = data["plugin_id"] - if not isinstance(plugin_id, str) or len(plugin_id) > 128 or not _ID.fullmatch(plugin_id): - raise PluginValidationError("plugin_id is not a valid lowercase plugin identifier") - version = data["version"] - if not isinstance(version, str) or not _VERSION.fullmatch(version): - raise PluginValidationError("version must be a semantic version") - capabilities = _string_array(data, "capabilities", _CAPABILITY) - disallowed = set(capabilities) - set(enabled_capabilities) - if disallowed: - raise PluginValidationError( - f"capabilities disabled by policy: {', '.join(sorted(disallowed))}" - ) - secrets = _string_array(data, "required_secrets", _SECRET) - hosts = _string_array(data, "allowed_network_hosts", _HOST) - destructive = data["supports_destructive_actions"] - if type(destructive) is not bool: - raise PluginValidationError("supports_destructive_actions must be a boolean") - if destructive and "actions.destructive" not in capabilities: - raise PluginValidationError("destructive plugins must request actions.destructive") - return PluginManifest( - api_version, plugin_id, version, capabilities, secrets, hosts, destructive - ) - - -class PluginLoader: - """Register local plugin directories contained beneath a fixed trusted root.""" - - def __init__(self, root: Path, enabled_capabilities: Iterable[str]): - self.root = root.resolve(strict=True) - self.enabled_capabilities: frozenset[str] = frozenset(enabled_capabilities) - self._registrations = {} - - @property - def registrations(self): - return dict(self._registrations) - - def register(self, relative_directory: str) -> PluginRegistration: - requested = Path(relative_directory) - if requested.is_absolute() or ".." in requested.parts: - raise PluginValidationError("plugin path must be relative and must not contain '..'") - directory = (self.root / requested).resolve(strict=True) - try: - directory.relative_to(self.root) - except ValueError as exc: - raise PluginValidationError("plugin path escapes the configured root") from exc - if not directory.is_dir(): - raise PluginValidationError(f"plugin path is not a directory: {relative_directory}") - manifest_path = directory / "plugin-manifest.json" - if manifest_path.is_symlink() or not manifest_path.is_file(): - raise PluginValidationError( - f"plugin manifest is missing or is a symlink: {manifest_path}" - ) - try: - data = json.loads(manifest_path.read_text(encoding="utf-8")) - except (OSError, UnicodeError, json.JSONDecodeError) as exc: - raise PluginValidationError( - f"cannot read plugin manifest {manifest_path}: {exc}" - ) from exc - manifest = validate_manifest(data, self.enabled_capabilities) - if manifest.plugin_id in self._registrations: - raise PluginValidationError(f"duplicate plugin ID: {manifest.plugin_id}") - registration = PluginRegistration(manifest, directory) - self._registrations[manifest.plugin_id] = registration - return registration diff --git a/hub/schema.py b/hub/schema.py deleted file mode 100644 index 2799d0a..0000000 --- a/hub/schema.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Strict schemas at the untrusted CLI/MCP boundary.""" - -from typing import Any, Literal - -from pydantic import BaseModel, ConfigDict, Field - - -class ConnectorRequest(BaseModel): - """Validated request routed to a connector.""" - - model_config = ConfigDict(extra="forbid", strict=True) - - channel: str = Field(min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9_]*$") - action: str = Field(min_length=1, max_length=64, pattern=r"^[a-z][a-z0-9_]*$") - params: dict[str, Any] = Field(default_factory=dict) - - -class JsonRpcRequest(BaseModel): - """Supported JSON-RPC request envelope.""" - - model_config = ConfigDict(extra="allow", strict=True) - - jsonrpc: Literal["2.0"] = "2.0" - id: int | str | None = None - method: str = Field(min_length=1, max_length=128) - params: dict[str, Any] = Field(default_factory=dict) diff --git a/hub/schemas/__init__.py b/hub/schemas/__init__.py deleted file mode 100644 index 3942344..0000000 --- a/hub/schemas/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Single source of truth for connector action validation and JSON schemas.""" - -from .actions import ActionResponse, action_json_schema, validate_action - -__all__ = ["ActionResponse", "action_json_schema", "validate_action"] diff --git a/hub/schemas/actions.py b/hub/schemas/actions.py deleted file mode 100644 index 185fe1c..0000000 --- a/hub/schemas/actions.py +++ /dev/null @@ -1,284 +0,0 @@ -"""Strict request/response models for every public connector action. - -Models are materialized per connector/action (and named accordingly), while the -compact field catalogue below keeps similar actions consistent. -""" - -from __future__ import annotations - -import inspect -from typing import Annotated, Any - -from pydantic import ( - AnyHttpUrl, - BaseModel, - ConfigDict, - Field, - SecretStr, - create_model, - model_validator, -) - -NonEmpty = Annotated[str, Field(min_length=1, max_length=65536)] -PositiveId = Annotated[int | str, Field(min_length=1)] - - -class StrictModel(BaseModel): - model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) - - @model_validator(mode="after") - def mutually_exclusive_options(self): - groups = ( - ("location", "datacenter"), - ("id", "instance_id"), - ("image", "image_id", "imageId"), - ("product", "product_id", "productId"), - ) - for group in groups: - supplied = [ - name for name in group if hasattr(self, name) and getattr(self, name) is not None - ] - if len(supplied) > 1: - raise ValueError(f"options are mutually exclusive: {', '.join(group)}") - return self - - -class ActionResponse(StrictModel): - ok: bool - status: int | None = Field(default=None, ge=100, le=599) - data: Any = None - request_id: str | None = None - - -# Explicit constraints override signature-derived fields. Secret metadata is -# consumed by logging redaction, so redaction is driven by schemas, not guesses. -SECRET_FIELDS = {"password", "root_pass", "body", "html", "message", "content", "input", "system"} -URL_FIELDS = {"url"} -INT_FIELDS = { - "id", - "client_id", - "ticket_id", - "service_id", - "product_id", - "group_id", - "instance_id", - "message_id", - "number", - "port", - "config_id", - "stackscript_id", - "workflow_id", -} -RANGED = { - "limit": (1, 1000), - "max_results": (1, 500), - "per_page": (1, 100), - "count": (1, 20), - "length": (8, 4096), - "quota": (1, 10_000_000), - "temperature": (0, 2), - "max_tokens": (1, 200_000), - "port": (1, 65535), -} - -ACTION_FIELDS: dict[str, dict[str, tuple[Any, Any]]] = {} - - -def _field(name: str, required: bool = False, default: Any = None) -> tuple[Any, Any]: - marker = ... if required else default - if name in RANGED: - lo, hi = RANGED[name] - typ = float if name == "temperature" else int - return (Annotated[typ, Field(ge=lo, le=hi)], marker) - if name in URL_FIELDS: - return (AnyHttpUrl, marker) - if name in SECRET_FIELDS: - return (SecretStr, marker) - if name in INT_FIELDS: - return (Annotated[int, Field(gt=0)], marker) - if name in { - "private", - "extract_text", - "taxed", - "keepdns", - "automount", - "start_after_create", - "backups_enabled", - "booted", - }: - return (bool, marker) - if name in { - "messages", - "items", - "labels", - "events", - "ports", - "urls", - "ssh_keys", - "volumes", - "networks", - "firewalls", - "authorized_keys", - "authorized_users", - "tags", - }: - return (list[Any], marker) - if name in {"inputs", "metadata", "labels", "stackscript_data"}: - return (dict[str, Any], marker) - return (NonEmpty, marker) - - -def _required_from_handler(connector: Any, action: str) -> tuple[set[str], set[str]]: - handler = getattr(connector, f"_do_{action}", None) - if not handler: - return set(), set() - sig = inspect.signature(handler) - names, required = set(), set() - for name, p in sig.parameters.items(): - if name == "self": - continue - names.add(name) - if p.default is inspect.Parameter.empty: - required.add(name) - return names, required - - -# Accepted fields for connectors implemented with a params mapping. Fields are -# deliberately action-specific: no action receives a permissive catch-all. -PARAMS: dict[str, dict[str, str]] = { - "oneprovider": {"get_server reboot bandwidth": "id"}, - "hetzner": { - "get_server power_on power_off reboot delete_server": "id", - "create_server": ( - "name! server_type! image! location ssh_keys volumes networks user_data labels " - "automount start_after_create placement_group datacenter firewalls" - ), - }, - "linode": { - "get_linode boot shutdown reboot delete_linode": "id! config_id", - "create_linode": ( - "region! type! image! root_pass! label authorized_keys authorized_users " - "backups_enabled booted interfaces metadata placement_group stackscript_data " - "stackscript_id tags group" - ), - }, - "ultrahost": {"get_service reboot start stop status": "id serviceid", "list_services": "limit"}, - "ovh": {"get_vps reboot_vps get_dedicated": "name id"}, - "contabo": { - "get_instance start stop restart": "id instance_id", - "create_instance": "image imageId image_id product productId product_id", - }, - "whmcs": { - "get_clients": "limit", - "get_client": "client_id!", - "get_invoices get_tickets": "limit status client_id", - "reply_ticket": "ticket_id! message!", - "add_client": ( - "firstname! lastname! email! password! companyname address1 city state postcode " - "country phonenumber" - ), - "create_invoice": "client_id! items!", - "get_products": "product_id group_id", - "module_action": "service_id! action!", - }, - "whm": { - "create_account": "domain! username! password! plan contactemail", - "suspend_account": "user! reason", - "unsuspend_account terminate_account": "user! keepdns", - }, - "cpanel": { - "add_subdomain": "subdomain! rootdomain! dir!", - "add_email": "email! password! quota", - "create_database": "name!", - "create_db_user": "name! password!", - "file_list": "dir", - "cron_add": "command! minute hour day month weekday", - }, - "openai": {"chat": "messages! model temperature max_tokens", "embeddings": "input! model"}, - "kimi": {"chat": "messages! model"}, - "cloudflare_ai": {"chat": "messages! model", "run_model": "model! input!"}, - "claude": {"chat": "messages! model system max_tokens temperature"}, - "tawk": { - "list_chats list_tickets": "property_id status", - "get_chat": "chat_id!", - "send_message": "chat_id! message!", - "get_ticket": "ticket_id!", - "reply_ticket": "ticket_id! message!", - "list_agents property_info": "property_id", - }, - "email": { - "check_inbox": "account_id! limit", - "send_email": "account_id! to! subject! body! html", - "search": "account_id! query!", - }, - "gmail": { - "get_access_token": "label!", - "send": "label! to! subject! body!", - "list_messages": "label! query max_results", - "get_message": "label! message_id!", - }, - "ops_ssh": {"run_local": "command!", "run_ssh": "host_id! command!"}, - "ops_network": { - "ping": "host! count", - "dns_lookup": "domain!", - "port_check": "host! ports", - "traceroute": "host!", - "http_headers": "url!", - }, - "ops_security": { - "audit_password_strength": "password!", - "check_ssl": "host! port", - "scan_common_exposure": "host!", - "ssh_config_audit": "path", - "generate_secret": "length", - }, - "ops_browser": { - "fetch": "url! extract_text", - "check_status": "urls!", - "screenshot": "url! out_path", - }, -} - - -def register_connector_models(connector: Any) -> None: - for action in connector.actions(): - names, required = _required_from_handler(connector, action) - for action_group, spec in PARAMS.get(connector.name, {}).items(): - if action in action_group.split(): - for token in spec.split(): - name = token.rstrip("!") - names.add(name) - if token.endswith("!"): - required.add(name) - fields = {name: _field(name, name in required) for name in names} - model = create_model( - f"{connector.name.title().replace('_', '')}{action.title().replace('_', '')}Request", - __base__=StrictModel, - **fields, - ) - ACTION_FIELDS[f"{connector.name}.{action}"] = {"model": model} # type: ignore[assignment] - - -def validate_action(connector: Any, action: str, params: dict[str, Any]) -> dict[str, Any]: - key = f"{connector.name}.{action}" - if key not in ACTION_FIELDS: - register_connector_models(connector) - model = ACTION_FIELDS[key]["model"] - value = model.model_validate(params) - return value.model_dump(mode="python") - - -def action_json_schema(connector: Any, action: str) -> dict[str, Any]: - key = f"{connector.name}.{action}" - if key not in ACTION_FIELDS: - register_connector_models(connector) - return ACTION_FIELDS[key]["model"].model_json_schema() - - -def secret_field_names(connector: Any, action: str) -> set[str]: - schema = action_json_schema(connector, action) - return { - k - for k, v in schema.get("properties", {}).items() - if v.get("format") == "password" or v.get("writeOnly") is True - } diff --git a/hub/security/__init__.py b/hub/security/__init__.py deleted file mode 100644 index 759b5d5..0000000 --- a/hub/security/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Central security controls shared by connectors.""" - -from .policy import ( - SecurityError, - SecurityPolicy, - ValidatedTarget, - bounded_run, - pinned_urlopen, - redact, -) - -__all__ = [ - "SecurityError", - "SecurityPolicy", - "ValidatedTarget", - "bounded_run", - "pinned_urlopen", - "redact", -] diff --git a/hub/security/policy.py b/hub/security/policy.py deleted file mode 100644 index 00cb557..0000000 --- a/hub/security/policy.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Deployment policy, SSRF-safe networking, and bounded process execution. - -The module intentionally uses only the Python standard library. A deployment -can pass ``config={"security": ...}`` to a connector or set -``HUB_SECURITY_POLICY`` to an equivalent JSON object. -""" - -import http.client -import ipaddress -import json -import logging -import os -import re -import socket -import ssl -import subprocess -from collections.abc import Mapping, Sequence -from dataclasses import dataclass -from urllib.parse import urljoin, urlsplit, urlunsplit - -LOG = logging.getLogger("hub.security") - - -class SecurityError(ValueError): - """A request was rejected by deployment security policy.""" - - -_SECRET = re.compile( - r"(?i)(authorization|cookie|token|secret|password|passwd|api[-_]?key)(\s*[:=]\s*)([^\s,;]+)" -) -_METADATA_HOSTS = {"metadata.google.internal", "metadata.azure.internal"} -_METADATA_IPS = {ipaddress.ip_address("169.254.169.254"), ipaddress.ip_address("169.254.170.2")} - - -def redact(value, secrets=()): - """Remove common credential forms and explicitly supplied secret values.""" - text = str(value) - text = _SECRET.sub(lambda m: m.group(1) + m.group(2) + "***", text) - for secret in secrets: - if secret: - text = text.replace(str(secret), "***") - return text - - -@dataclass(frozen=True) -class ValidatedTarget: - url: str - scheme: str - hostname: str - port: int - addresses: tuple - path: str - - -class SecurityPolicy: - """Validated deployment policy with safe defaults.""" - - def __init__(self, config=None): - supplied = dict(config or {}) - if not supplied and os.environ.get("HUB_SECURITY_POLICY"): - try: - supplied = json.loads(os.environ["HUB_SECURITY_POLICY"]) - except json.JSONDecodeError as exc: - raise SecurityError("HUB_SECURITY_POLICY must be valid JSON") from exc - self.config = supplied.get("security", supplied) - self.schemes = frozenset(self.config.get("allowed_schemes", ["http", "https"])) - self.ports = frozenset(int(p) for p in self.config.get("allowed_ports", [80, 443])) - self.max_redirects = int(self.config.get("max_redirects", 5)) - self.max_output = int(self.config.get("max_output_bytes", 1_000_000)) - self.plugins = self.config.get("plugins", {}) - - def plugin(self, name): - value = self.plugins.get(name, {}) - if not isinstance(value, Mapping): - raise SecurityError(f"security.plugins.{name} must be an object") - return value - - def require_capability(self, plugin, capability, action=None, approval=None, destructive=False): - cfg = self.plugin(plugin) - if capability not in cfg.get("capabilities", []): - raise SecurityError(f"{plugin}: capability '{capability}' is not enabled") - if destructive: - allowed = cfg.get("destructive_actions", []) - approvals = self.config.get("approvals", []) - if action not in allowed or not approval or approval not in approvals: - LOG.warning("security_authorization_denied plugin=%s action=%s", plugin, action) - raise SecurityError( - f"{plugin}: destructive action '{action}' requires explicit policy and approval" - ) - LOG.info( - "security_authorization_granted plugin=%s action=%s approval=%s", - plugin, - action, - approval, - ) - - @staticmethod - def _public_address(raw): - try: - address = ipaddress.ip_address(raw.split("%", 1)[0]) - except ValueError as exc: - raise SecurityError(f"resolver returned invalid IP address: {raw}") from exc - if address in _METADATA_IPS: - raise SecurityError("cloud metadata endpoints are blocked") - if not address.is_global or any( - ( - address.is_loopback, - address.is_link_local, - address.is_private, - address.is_multicast, - address.is_reserved, - address.is_unspecified, - ) - ): - raise SecurityError(f"non-public address is blocked: {address}") - return str(address) - - def resolve_host(self, host, port, socktype=socket.SOCK_STREAM): - if not isinstance(host, str) or not host or len(host) > 253: - raise SecurityError("host must be a non-empty DNS name or IP address") - normalized = host.rstrip(".").lower() - if normalized in _METADATA_HOSTS or normalized.endswith(".metadata.google.internal"): - raise SecurityError("cloud metadata endpoints are blocked") - try: - infos = socket.getaddrinfo(host, port, type=socktype) - except socket.gaierror as exc: - raise SecurityError(f"cannot resolve host '{host}': {exc}") from exc - addresses = tuple(dict.fromkeys(self._public_address(i[4][0]) for i in infos)) - if not addresses: - raise SecurityError(f"host '{host}' resolved to no addresses") - return addresses - - def validate_url(self, url): - if not isinstance(url, str) or len(url) > 4096: - raise SecurityError("URL must be a string of at most 4096 characters") - parsed = urlsplit(url) - scheme = parsed.scheme.lower() - if scheme not in self.schemes: - raise SecurityError(f"URL scheme '{scheme}' is not allowed") - if not parsed.hostname or parsed.username or parsed.password: - raise SecurityError("URL must contain a host and no user information") - try: - port = parsed.port or (443 if scheme == "https" else 80) - except ValueError as exc: - raise SecurityError("URL contains an invalid port") from exc - if port not in self.ports: - raise SecurityError(f"port {port} is not allowed") - addresses = self.resolve_host(parsed.hostname, port) - path = urlunsplit(("", "", parsed.path or "/", parsed.query, "")) - return ValidatedTarget(url, scheme, parsed.hostname, port, addresses, path) - - -class _PinnedHTTPConnection(http.client.HTTPConnection): - def __init__(self, target, timeout): - super().__init__(target.hostname, target.port, timeout=timeout) - self._target = target - - def connect(self): - self.sock = socket.create_connection((self._target.addresses[0], self.port), self.timeout) - - -class _PinnedHTTPSConnection(_PinnedHTTPConnection): - def connect(self): - super().connect() - self.sock = ssl.create_default_context().wrap_socket( - self.sock, server_hostname=self._target.hostname - ) - - -def pinned_urlopen(policy, url, method="GET", headers=None, timeout=30, max_bytes=None): - """Open a URL while validating each redirect and pinning its resolved IP.""" - current = url - for redirects in range(policy.max_redirects + 1): - target = policy.validate_url(current) - cls = _PinnedHTTPSConnection if target.scheme == "https" else _PinnedHTTPConnection - conn = cls(target, timeout) - request_headers = {"Host": target.hostname, "Connection": "close", **(headers or {})} - conn.request(method, target.path, headers=request_headers) - response = conn.getresponse() - if response.status in (301, 302, 303, 307, 308): - location = response.getheader("Location") - response.read(min(policy.max_output, 64 * 1024)) - conn.close() - if not location: - raise SecurityError("redirect response has no Location header") - if redirects == policy.max_redirects: - raise SecurityError("redirect limit exceeded") - current = urljoin(current, location) - if response.status == 303: - method = "GET" - continue - limit = policy.max_output if max_bytes is None else min(max_bytes, policy.max_output) - body = response.read(limit + 1) - result_headers = dict(response.getheaders()) - status = response.status - conn.close() - if len(body) > limit: - raise SecurityError(f"response exceeds {limit} byte output limit") - return {"url": current, "status": status, "headers": result_headers, "body": body} - raise SecurityError("redirect limit exceeded") - - -def bounded_run( - argv: Sequence[str], - timeout: int, - max_output: int, - secrets=(), - env: Mapping[str, str] | None = None, -): - """Execute an argv (never a shell), enforcing timeout and output bounds.""" - if not isinstance(argv, list | tuple) or not argv or not all(isinstance(x, str) for x in argv): - raise SecurityError("command must be a non-empty string argument vector") - try: - proc = subprocess.run( - list(argv), - stdin=subprocess.DEVNULL, - capture_output=True, - timeout=timeout, - env=dict(env) if env else None, - ) - except subprocess.TimeoutExpired as exc: - raise SecurityError(f"command timed out after {timeout}s") from exc - stdout = proc.stdout[:max_output].decode("utf-8", "replace") - remaining = max(0, max_output - len(proc.stdout[:max_output])) - stderr = proc.stderr[:remaining].decode("utf-8", "replace") - truncated = len(proc.stdout) + len(proc.stderr) > max_output - return { - "returncode": proc.returncode, - "stdout": redact(stdout, secrets), - "stderr": redact(stderr, secrets), - "truncated": truncated, - } diff --git a/kimi.plugin.json b/kimi.plugin.json new file mode 100644 index 0000000..e3c7d60 --- /dev/null +++ b/kimi.plugin.json @@ -0,0 +1,14 @@ +{ + "name": "connector-hub", + "version": "2.0.0", + "description": "Spec-driven connector orchestration — 21 providers, 278 operations, type-safe execution contract with audit ledger.", + "author": { + "name": "Connector Hub contributors", + "url": "https://github.com/CodeWithJuber" + }, + "interface": { + "displayName": "Connector Hub", + "shortDescription": "Securely orchestrate verified service connectors.", + "category": "Developer Tools" + } +} diff --git a/mcp/mcp.json b/mcp/mcp.json deleted file mode 100644 index 7fc14ec..0000000 --- a/mcp/mcp.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "mcpServers": { - "omni-hub": { - "command": "python3", - "args": ["-m", "hub.gateway", "mcp"], - "cwd": "/mnt/agents/output/connector-hub", - "env": { - "HUB_ALLOW_LOCAL_EXEC": "0" - }, - "note": "The full hub: every connector (llm, email, gmail, whmcs, whm, cpanel, hetzner, linode, contabo, ovh, oneprovider, ultrahost, tawk, github, ops_*) exposed via hub_channels / hub_status / hub_call tools. Copy .env values into this env block or keep .env beside the project." - }, - "github-official": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" - } - }, - "filesystem": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "${HOME}"] - }, - "fetch": { - "command": "uvx", - "args": ["mcp-server-fetch"] - }, - "puppeteer-browser": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-puppeteer"] - }, - "memory": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-memory"] - } - } -} diff --git a/plan.md b/plan.md deleted file mode 100644 index 4ba9e50..0000000 --- a/plan.md +++ /dev/null @@ -1,32 +0,0 @@ -# plan.md — Omni Connector Hub (MCP + API channel) - -## Objective -Create a unified "connector hub" the user can drop into their agent setup: -1. **MCP config pack** — ready-to-use MCP server definitions (GitHub, email, browser, filesystem, ssh, etc.) -2. **API connector library** — typed Python clients for: LLM providers (OpenAI/ChatGPT, Anthropic/Claude, Kimi, Cloudflare Workers AI), Email (multi-Gmail OAuth + IMAP/SMTP), WHMCS, WHM/cPanel, Contabo, OVH, Linode, Hetzner, OneProvider, UltaHost, tawk.to REST. -3. **Channel gateway** — one CLI/module that routes a task to the right connector ("channel"). -4. **Security layer** — env-based secret management, OAuth flow scaffolding, permission scoping. - -## Skills referenced (user) — read at Stage 0 -- /app/.user/skills/agent-playbook, principles-playbook, cognitive-kernel, ship-guard, decision-forge -- /app/.user/skills/hikmah-problem-solver (default framework per standing instruction) - -## Stages -- **Stage 0 — Load skills**: read user SKILL.md files; extract workflow rules to apply. -- **Stage 1 — Architecture**: Orchestrator designs hub layout, auth model, connector interface contract. Output: ARCHITECTURE.md + repo skeleton. -- **Stage 2 — Connectors (parallel subagents, coder type)**: - - A: LLM providers (openai, anthropic, kimi/moonshot, cloudflare) - - B: Email multi-account (Gmail OAuth2 + generic IMAP/SMTP), contacts/send/read - - C: Hosting panels (WHMCS API, WHM/cPanel UAPI/API2) - - D: Cloud VPS providers (contabo, ovh, linode, hetzner, oneprovider, ultrahost) - - E: tawk.to REST + ops connectors (ssh/bash, browser, network, security audit) - - F: GitHub full-permission connector (REST + gh CLI passthrough) -- **Stage 3 — Gateway + MCP pack**: channel router CLI, `mcp.json`/`claude_desktop_config` pack, .env.template, setup docs. -- **Stage 4 — Verify + package**: verifier/ folder, smoke tests (import + mock-mode), README, zip deliverable. - -## Verifier criteria (v1) -- Every connector module imports cleanly (`python -c "import ..."`). -- Every connector works in MOCK mode without credentials (dry-run returns structured stub). -- `.env.template` lists every secret the hub references; no real secrets in repo. -- mcp.json is valid JSON. -- README covers install + per-service credential setup. diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 6dce32e..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,69 +0,0 @@ -[build-system] -requires = ["hatchling==1.31.0"] -build-backend = "hatchling.build" - -[project] -name = "omni-connector-hub" -version = "2.0.0" -description = "A validated, observable MCP gateway for service connectors" -readme = "README.md" -requires-python = ">=3.11,<3.14" -license = { text = "MIT" } -authors = [{ name = "Omni Connector Hub maintainers" }] -dependencies = [ - "httpx==0.28.1", - "mcp==1.29.0", - "pydantic==2.13.4", - "structlog==26.1.0", -] - -[project.scripts] -connector-hub = "hub.gateway:main" -connector-hub-mcp = "hub.mcp_server:serve" - -[project.optional-dependencies] -test = [ - "pytest>=8.4", - "pytest-timeout>=2.4", -] - -[dependency-groups] -dev = [ - "build==1.5.0", - "mypy==2.3.0", - "pip-audit==2.10.1", - "pytest==9.1.1", - "pytest-httpx==0.36.2", - "pytest-timeout==2.4.0", - "ruff==0.16.2", -] - -[tool.hatch.build.targets.wheel] -packages = ["hub", "connectors"] - -[tool.ruff] -target-version = "py311" -line-length = 100 -exclude = ["verifier", "connectors", "scripts", "vendor"] - -[tool.ruff.lint] -select = ["E", "F", "I", "B", "UP", "S"] -ignore = ["S310", "S311", "S603", "S607"] - -[tool.ruff.lint.per-file-ignores] -"tests/**" = ["S101"] -"connectors/ops/ssh_bash.py" = ["S602"] - -[tool.mypy] -python_version = "3.11" -strict = true -files = ["hub/schema.py"] -plugins = ["pydantic.mypy"] - -[tool.pytest.ini_options] -addopts = "--strict-config --strict-markers --timeout=10" -testpaths = ["tests"] -markers = [ - "integration: opt-in tests that contact documented provider endpoints", - "credentialed: integration tests requiring protected credentials", -] diff --git a/schemas/plugin-manifest.schema.json b/schemas/plugin-manifest.schema.json deleted file mode 100644 index 34639b2..0000000 --- a/schemas/plugin-manifest.schema.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/connector-hub/connector-hub/schemas/plugin-manifest.schema.json", - "title": "Connector Hub plugin manifest", - "type": "object", - "additionalProperties": false, - "required": ["api_version", "plugin_id", "version", "capabilities", "required_secrets", "allowed_network_hosts", "supports_destructive_actions"], - "properties": { - "api_version": {"const": "connector-hub.plugin/v1"}, - "plugin_id": {"type": "string", "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", "maxLength": 128}, - "version": {"type": "string", "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$"}, - "capabilities": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*$"}}, - "required_secrets": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]*$"}}, - "allowed_network_hosts": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$"}}, - "supports_destructive_actions": {"type": "boolean"} - } -} diff --git a/scripts/setup_oauth.py b/scripts/setup_oauth.py deleted file mode 100644 index b4cbb41..0000000 --- a/scripts/setup_oauth.py +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env python3 -"""Interactive OAuth2 setup wizard for the Gmail connector (connectors/email/gmail_oauth.py). - -Run this LOCALLY (it needs a browser). For each Gmail account it: - 1. prints the Google consent URL (scopes: gmail.modify + gmail.send), - 2. accepts the pasted authorization code (or full redirect URL), - 3. exchanges the code for tokens, - 4. appends GMAIL_REFRESH_TOKEN_