diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 4f96b9bed..48901ceee 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -2,203 +2,91 @@ # SPDX-License-Identifier: MIT name: Bug Report -description: Help us improve GAIA by sharing your experience. We appreciate your feedback! +description: Report something that is broken in GAIA. title: '[Bug]: ' labels: ['bug', 'triage'] body: - type: markdown attributes: value: | - # Welcome! ๐Ÿ‘‹ - - Thanks for taking the time to help improve GAIA! Before submitting, you might want to check our [Issues](https://github.com/amd/gaia/issues) to see if someone else has reported something similar - - Don't worry if you can't fill out all the fields - just share what you can and we'll work together to figure it out! + Thanks for taking the time to report a bug! Please [search existing issues](https://github.com/amd/gaia/issues) first โ€” your bug may already be tracked. - type: checkboxes - id: issue-check + id: quick-check attributes: - label: Quick Check โœจ - description: Let us know if you've had a chance to look around + label: Quick check + description: Help us route this faster. options: - - label: I've taken a look at existing issues and discussions + - label: I've searched existing issues and didn't find a duplicate. required: false - - label: I've checked the hardware requirements in the docs + - label: This issue relates to **Gaia Agent UI** (`gaia chat --ui`). required: false - - label: This issue relates to GAIA UI (Open-WebUI) + - label: This issue relates to a CLI command (`gaia ...`) or the SDK. required: false - type: input id: gaia-version attributes: - label: Which version of GAIA are you using? - description: For example, v0.8 - don't worry if you're not sure! + label: GAIA version + description: Output of `gaia --version`, or the installer/release you used. + placeholder: e.g. v0.19.0 validations: required: false - type: textarea - id: reproduction-details + id: what-happened attributes: - label: Details to help us reproduce the issue - description: Please provide as much information as possible to help us understand what's happening + label: What happened? + description: Steps to reproduce, plus what you observed. Include error messages, logs, screenshots, or model/prompt details if relevant. placeholder: | Steps to reproduce: - 1. Open GAIA - 2. Click on '...' - 3. Try to '...' - 4. See error '...' - - Model used (if applicable): - For example, Mistral-7B-Instruct-v0.3 or Llama3.1:8b - - Prompt used (if relevant): - Share what you asked the model - - Response received (if relevant): - Share what response you got back - - Error messages or screenshots: - - Installation logs (usually in C:\Users\\AppData\Local\GAIA\gaia_install.log) - - Screenshots of Task Manager showing hardware usage - - Any error messages you saw - validations: - required: false + 1. Run `gaia ...` + 2. ... + 3. See error: ... - - type: textarea - id: actual-behavior - attributes: - label: What actually happened? - description: Share what you observed instead - placeholder: For example, "I got an error message saying 'Connection failed'..." or "The application crashed when I tried to..." + Model used (if applicable): e.g. Qwen3.5-35B-A3B-GGUF + Prompt used (if relevant): ... + Error / log output: ... validations: - required: false + required: true - type: textarea id: expected-behavior attributes: label: What did you expect to happen? - description: Tell us what you were trying to do - placeholder: For example, "I expected the model to load when I clicked..." - validations: - required: false - - - type: dropdown - id: installation-method - attributes: - label: How did you install GAIA? - description: This helps us understand your setup better - options: - - Installer - - Git Clone - - Manual Setup - validations: - required: false - - - type: dropdown - id: mode-selection - attributes: - label: Which mode are you running? - description: Let us know how in what configuration you're running GAIA - options: - - Hybrid - - Generic - - NPU - validations: - required: false - - - type: dropdown - id: cpu-model - attributes: - label: What's your CPU? - description: Tell us about your processor - please specify your exact model in the additional info section if selecting "Other". The current list of supported CPUs can be found [here](https://www.amd.com/en/products/software/ryzen-ai-software.html#tabs-2733982b05-item-7720bb7a69-tab). - options: - - AMD Ryzen AI 9 HX 9845HS - - AMD Ryzen AI 9 HX 9945HS - - AMD Ryzen AI 9 HX 370 - - AMD Ryzen AI 9 365 - - AMD Ryzen AI 7 HX 9745HS - - AMD Ryzen AI 7 HX 9845HS - - AMD Ryzen AI 7 HX 370 - - AMD Ryzen AI 7 365 - - AMD Ryzen AI 5 HX 9645HS - - AMD Ryzen AI 5 365 - - AMD Ryzen 9 7945HX - - AMD Ryzen 9 7940HS - - AMD Ryzen 7 7840HS - - AMD Ryzen 5 7640HS - - Other (please specify in comments) + placeholder: e.g. The model should have loaded and responded. validations: required: false - - type: dropdown - id: gpu-info - attributes: - label: What about your GPU setup? - description: Tell us about your graphics configuration. If selecting dGPU or Other, please provide more details in the additional info section - options: - - Integrated GPU (iGPU) only - - Discrete AMD GPU (dGPU) - - External GPU via Oculink/Thunderbolt - - NVIDIA GPU - - Intel GPU - - Other - validations: - required: false - - - type: input - id: gpu-driver-version - attributes: - label: AMD GPU Driver Version - description: What's your AMD GPU driver version? To find this, go to Device Manager > Display adapters > AMD Radeon Graphics > Right-click Properties > Driver tab > Driver Version, or check in AMD Software. - placeholder: For example, 32.0.12033.1030 - validations: - required: false - - - type: input - id: npu-driver-version - attributes: - label: NPU Driver Version - description: What's your NPU driver version? To find this, go to Device Manager > System Devices > Neural Processing Unit > NPU Compute Accelerator Device > Right-click Properties > Driver tab > Driver Version. - placeholder: For example, 32.0.203.257 - validations: - required: false - - - type: input - id: lemonade-version + - type: textarea + id: acceptance-criteria attributes: - label: Lemonade Version (if applicable) - description: If you're using Lemonade, which version? You can find this version by following the instructions [here](https://github.com/aigdat/genai/blob/8f034613f8d0acf18cf1846e1ea0090406c76546/docs/lemonade/server_integration.md#identifying-existing-installation). - placeholder: For example, v0.6.1.3 + label: Acceptance criteria + description: How will we know this is fixed? 1โ€“3 short bullets, if you can. + placeholder: | + - `gaia chat` exits cleanly when Lemonade is unreachable + - User sees an actionable error pointing to `gaia init` validations: required: false - - type: input - id: operating-system + - type: textarea + id: environment attributes: - label: What's your operating system? + label: Environment description: | - Which OS are you running GAIA on? - - For Windows: Right-click on Start > System > About, or press Win+I > System > About - For Linux: Open Terminal and type `lsb_release -a` or `cat /etc/os-release` - placeholder: For example, Windows 11 22H2, Windows 10 21H2, Ubuntu 22.04 + Anything about your setup that could matter โ€” OS, CPU/NPU model, GPU, driver versions, Lemonade Server version, install method (Installer / Git Clone / Manual), mode (Hybrid / Generic / NPU). Skip what doesn't apply. Please redact any tokens or credentials before pasting logs. + placeholder: | + OS: Windows 11 23H2 + CPU / NPU: AMD Ryzen AI 9 HX 370 + GPU: iGPU only + Lemonade: v0.6.1 + Install: Installer + Mode: Hybrid validations: required: false - type: markdown attributes: value: | - ## Thank You! ๐Ÿ™Œ - - Your feedback helps make GAIA better for everyone! We'll look into this as soon as we can. - - The more details you can share, the better we can help, but don't worry if you can't provide everything. - Key things that often help us investigate: - - Steps to reproduce what you're seeing - - Any error messages or logs - - Your hardware and software setup - - Driver versions (if using NPU or GPU features) - - Feel free to check our [README.md](https://github.com/amd/gaia/blob/main/README.md) and [FAQ.md](https://github.com/amd/gaia/blob/main/FAQ.md) while you wait for a response. - - We appreciate your help in improving GAIA! ๐Ÿ’ซ + --- + Need a hand? See the [README](https://github.com/amd/gaia/blob/main/README.md), [FAQ](https://amd-gaia.ai/reference/faq), and [troubleshooting guide](https://amd-gaia.ai/reference/troubleshooting). Thanks for helping make GAIA better! diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index c07683dac..fe7776179 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -2,55 +2,59 @@ # SPDX-License-Identifier: MIT name: Feature Request -description: Share your ideas to help make GAIA even better! ๐Ÿ’ก +description: Suggest an improvement or new capability for GAIA. title: 'feat: ' -labels: ['triage', 'enhancement'] +labels: ['enhancement', 'triage'] body: - type: markdown attributes: value: | - # Welcome! ๐Ÿ‘‹ - - Thanks for thinking about ways to improve GAIA! Your ideas help make this project better for everyone. - - Before sharing your idea, you might want to check our [Issues](https://github.com/amd/gaia/issues) to see if someone else has suggested something similar. - - We love hearing new ideas and appreciate constructive feedback! ๐ŸŒŸ + Thanks for sharing your idea! Please [search existing issues](https://github.com/amd/gaia/issues) first โ€” your idea may already be tracked or in progress. - type: checkboxes - id: existing-issue + id: quick-check attributes: - label: Quick Check โœจ - description: Let us know if you've had a chance to look around + label: Quick check + description: Help us route this faster. options: - - label: I've taken a look at existing feature requests + - label: I've searched existing issues and didn't find a duplicate. + required: false + - label: This relates to **Gaia Agent UI** (`gaia chat --ui`). required: false - - label: This feature request relates to GAIA UI (Open-WebUI) + - label: This relates to the SDK, CLI, or a specific agent. required: false - type: textarea - id: feature-description + id: problem + attributes: + label: What problem are you trying to solve? + description: Describe the underlying need โ€” the user, the workflow, what's painful or impossible today. Focus on the problem, not the solution. + placeholder: | + e.g. "When I run `gaia chat --ui` on a machine without an NPU, the error message doesn't tell me what to do โ€” I have to dig through logs to figure out I need a different mode." + validations: + required: true + + - type: textarea + id: proposed-solution + attributes: + label: Proposed solution + description: If you have an idea for how to address it, share it here. Sketches, examples from other tools, and rough API shapes are all welcome. + validations: + required: false + + - type: textarea + id: acceptance-criteria attributes: - label: What's on your mind? - description: | - Share what challenge you're facing and your proposed solution. For example: - - "It would be helpful if... because I often need to..." - - "I wish GAIA could... which would work by..." - - Feel free to include examples, similar features from other tools, or mockups + label: Acceptance criteria + description: How will we know this is done? 1โ€“3 short bullets, if you can. + placeholder: | + - `gaia chat --ui` on a non-NPU machine prints an actionable error + - The error names the supported alternatives and links to setup docs validations: required: false - type: markdown attributes: value: | - ## Thank You! ๐Ÿ™Œ - - We really appreciate you taking the time to share your ideas with us! Your feedback helps shape the future of GAIA. - - While we review all suggestions, please understand that we need to prioritize based on various factors and resources. - Feel free to: - - Add comments if you think of additional details - - Share more context or examples - - Help others by commenting on their ideas too - - Together, we can make GAIA even better! โœจ + --- + Thanks for helping shape GAIA! We review every suggestion and prioritize based on impact and capacity. diff --git a/.github/ISSUE_TEMPLATE/internal_task.yaml b/.github/ISSUE_TEMPLATE/internal_task.yaml new file mode 100644 index 000000000..098cec3fd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/internal_task.yaml @@ -0,0 +1,152 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +name: Internal task / feature work +description: For team-internal feature work, agent-assignable tasks, and roadmap items. End users โ€” please use Bug Report or Feature Request instead. +title: 'feat: ' +labels: ['enhancement'] +body: + - type: markdown + attributes: + value: | + # Internal task template + + Use this template for internal feature work, refactors, or tasks intended for assignment to coding agents (Claude Code, Cursor, etc.). + + **End users:** please use the Bug Report or Feature Request template instead โ€” this one expects more structured technical detail. + + See `AGENTS.md` for the multi-agent coordination rules and `CLAUDE.md` for project conventions. + + - type: textarea + id: goal + attributes: + label: Goal + description: One-paragraph statement of what this issue ships and why it matters. + placeholder: | + Example: "Add a Telegram messaging adapter so consumers can reach the agent from their phone via Telegram. Closes the async-mobile gap before v0.20.0 ships." + validations: + required: true + + - type: textarea + id: scope + attributes: + label: Scope (what ships in this PR) + description: Concrete list of changes. Files, components, endpoints, tests. Distinguish what's IN scope vs. OUT of scope. + placeholder: | + ### A. Implementation + - [ ] Create `src/gaia/messaging/telegram.py` wrapping `python-telegram-bot` + - [ ] CLI: `gaia telegram start --token $TOKEN` + - [ ] Tests in `tests/unit/test_telegram.py` + + ### B. Out of scope + - Voice notes (separate issue) + - WhatsApp (#891 evaluation) + validations: + required: true + + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: Verifiable conditions. Each should map to a test or a manual verification step. + placeholder: | + - `gaia telegram start --token $TOKEN` boots and responds to `/start` within 5 seconds + - Free-form messages produce streaming agent responses + - Allowed-users gate rejects unlisted users + - Unit tests in `tests/unit/test_telegram.py` pass + validations: + required: true + + - type: textarea + id: attribution + attributes: + label: Attribution / prior art + description: Cite the libraries, papers, open standards, or comparator products this work builds on. (See `CLAUDE.md` for why this matters.) + placeholder: | + - Hermes Agent (Nous Research) โ€” messaging-native paradigm + - python-telegram-bot โ€” upstream library, MIT + - Issue #635 โ€” full multi-platform messaging adapter (this carves Telegram out as Phase 0) + + - type: dropdown + id: domain + attributes: + label: Capability domain + description: Pick the primary domain. Adds a `domain:*` label for cross-cutting filtering. + options: + - 'platform โ€” Lemonade, providers, runtime, install, packaging' + - 'quality โ€” Tests, CI/CD, security, performance, evals' + - 'agent-core โ€” Framework, tools, memory, skills, orchestration' + - 'multimodal โ€” Voice, Vision, Image gen, CUA' + - 'surfaces โ€” Agent UI, Telegram, WhatsApp, Slack/Discord, mobile' + - 'automation โ€” Scheduler, autonomy, RAG, web search, watchers' + - 'integrations โ€” MCP catalogue, connectors, OAuth, third-party' + - 'distribution โ€” Agent Hub, Skills marketplace, OEM bundling, OS Agents' + validations: + required: true + + - type: dropdown + id: track + attributes: + label: Product track + description: Which product line does this serve? Adds a `track:*` label. + options: + - 'consumer-app โ€” Hermes-competitor consumer product (mobile-first, voice + messaging + memory + skills)' + - 'oem-pc โ€” OEM pre-installed AMD PC product (C++ runtime + OS Agents)' + - 'platform โ€” Foundation that both consumer-app and oem-pc consume' + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: Priority + description: | + - **p0** = must ship in next 4 weeks; release blocker + - **p1** = high priority; ship in next 2 milestones + - **p2** = medium priority; ship in next quarter + - **p3** = low priority; future + options: + - 'p0 โ€” release blocker, next 4 weeks' + - 'p1 โ€” high, next 2 milestones' + - 'p2 โ€” medium, next quarter' + - 'p3 โ€” low, future' + validations: + required: true + + - type: checkboxes + id: consumer-critical + attributes: + label: Consumer-critical? + description: Check if this blocks the v0.20.0 consumer launch + options: + - label: This issue is on the consumer-critical path (adds `consumer-critical` label) + required: false + + - type: textarea + id: dependencies + attributes: + label: Dependencies + description: Other issues / PRs this depends on or unblocks. + placeholder: | + - **Blocked by:** PR #606 (memory architecture) + - **Adjacent:** #635 (full multi-platform), #889 (Telegram Phase 0) + - **Unblocks:** #645 (Email Triage), #663 (Daily Briefs) + + - type: textarea + id: failure-modes + attributes: + label: Failure modes (per CLAUDE.md "no silent fallback" rule) + description: For each thing that can go wrong, what's the actionable behavior? + placeholder: | + - Telegram unreachable โ†’ fail loudly with reconnect retry; do NOT silently swallow messages + - Bot token invalid โ†’ exit immediately with clear error citing where to get a token + - Allowed-users gate fails โ†’ reply with polite "not authorized" message; log + + - type: markdown + attributes: + value: | + ## Before submitting + + **For agent assignment:** if this issue carries `consumer-critical`, it must also have the `spec-ready` label before being assigned to a coding agent. See AGENTS.md for the spec depth required (use #887/#888/#890 as templates). + + **Add the appropriate labels** that this template suggested in the dropdowns. The labels aren't auto-applied from the dropdown values โ€” please add them manually after submitting. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index bd46bcf48..72f196704 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,48 @@ + + +## Summary + + + +## Why + + + +## Linked issue + + + +Closes #N + ## Changes - -- -- + + - + +## Test plan + + + +- [ ] + +## Checklist + +- [ ] I have linked a GitHub issue above (`Closes #N` / `Fixes #N` / `Refs #N`). +- [ ] I have described **why** this change is being made, not just what changed. +- [ ] I have run linting and tests locally (`python util/lint.py --all`, `pytest tests/unit/`). +- [ ] I have updated documentation if user-visible behavior changed (see [CONTRIBUTING.md](../CONTRIBUTING.md)). diff --git a/.github/workflows/build-installers.yml b/.github/workflows/build-installers.yml index 1af36ebbd..41116558a 100644 --- a/.github/workflows/build-installers.yml +++ b/.github/workflows/build-installers.yml @@ -857,7 +857,10 @@ jobs: xvfb-run --auto-servernum "${APPIMAGE}" \ >/tmp/stdout.log 2>/tmp/stderr.log & APP_PID=$! - for i in $(seq 1 90); do + # 300s timeout matches the structural and distro-matrix smoke + # jobs โ€” fresh installs download Lemonade + a ~3GB model on + # first run, so 90s starves model-download cases out. + for i in $(seq 1 300); do if grep -q "state: ready" /tmp/stdout.log 2>/dev/null; then break fi diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index be0e45534..76da85861 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -58,6 +58,7 @@ jobs: # Auto-review new PRs (including forks) pr-review: if: | + github.repository == 'amd/gaia' && github.event_name == 'pull_request_target' && (github.event.pull_request.draft == false || contains(github.event.pull_request.labels.*.name, 'ready_for_ci')) @@ -312,6 +313,7 @@ jobs: # only reads the PR diff and posts comments (no commits to the branch). pr-comment: if: | + github.repository == 'amd/gaia' && github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && github.event.pull_request.head.repo.full_name == github.repository @@ -427,9 +429,10 @@ jobs: # only reads the PR diff and posts comments (no commits to the branch). issue-handler: if: | - github.event_name == 'issues' || - (github.event_name == 'issue_comment' && - contains(github.event.comment.body, '@claude')) + github.repository == 'amd/gaia' && + (github.event_name == 'issues' || + (github.event_name == 'issue_comment' && + contains(github.event.comment.body, '@claude'))) runs-on: ubuntu-latest steps: - name: Checkout repository @@ -620,6 +623,7 @@ jobs: # Generate release notes when PyPi workflow completes successfully on a tag release-notes: if: | + github.repository == 'amd/gaia' && github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'v') diff --git a/.github/workflows/test_api.yml b/.github/workflows/test_api.yml index 8ad592c44..5f77a6c71 100644 --- a/.github/workflows/test_api.yml +++ b/.github/workflows/test_api.yml @@ -42,6 +42,7 @@ jobs: test-api: name: API Tests runs-on: ${{ contains(github.event.pull_request.labels.*.name, 'stx-test') && 'stx-test' || 'stx' }} + timeout-minutes: 30 if: github.event_name != 'pull_request' || github.event.pull_request.draft == false || contains(github.event.pull_request.labels.*.name, 'ready_for_ci') steps: diff --git a/.github/workflows/test_unit.yml b/.github/workflows/test_unit.yml index 29148e96e..cb78028eb 100644 --- a/.github/workflows/test_unit.yml +++ b/.github/workflows/test_unit.yml @@ -58,9 +58,12 @@ jobs: # pyfakefs is required by tests/unit/installer/test_uninstall_command.py # which uses the `fs` fixture to build a fake filesystem for testing # tiered uninstall logic cross-platform without touching the real FS. - # pytest-mock + beautifulsoup4 are required by the browser/filesystem tool tests. - uv pip install --system pytest pytest-cov pytest-asyncio pytest-mock pyfakefs - uv pip install --system beautifulsoup4 + # + # keyring + httpx + respx are required by tests/unit/connections/ + # (issue #915). The in-memory keyring backend in tests/conftest.py + # avoids the SecretService daemon prerequisite on Linux runners. + uv pip install --system pytest pytest-cov pytest-asyncio pyfakefs \ + keyring httpx respx uv pip install --system -e ".[api]" - name: Validate packaging integrity @@ -137,17 +140,6 @@ jobs: echo " - ASR: Automatic speech recognition utilities" echo " - TTS: Text-to-speech utilities" echo " - InitCommand: gaia init profiles and installer logic" - echo " - FileSystemIndex: Persistent file index with FTS5 search" - echo " - FileSystemToolsMixin: browse_directory, tree, file_info, find_files, read_file, bookmark tools" - echo " - ScratchpadService: SQLite working memory for data analysis" - echo " - ScratchpadToolsMixin: create_table, insert_data, query_data, list_tables, drop_table tools" - echo " - BrowserTools: WebClient SSRF prevention, HTML extraction, downloads" - echo " - WebClient Edge Cases: parse_html fallback, extract_text, tables, links, download redirects" - echo " - Categorizer: auto_categorize, category map completeness, extension uniqueness" - echo " - ChatAgent Integration: filesystem, scratchpad, browser init/config/cleanup" - echo " - File Write Guardrails: blocked dirs, sensitive files, size limits, backup, audit" - echo " - Security Edge Cases: symlinks, audit logging, TOCTOU, prompt_overwrite" - echo " - Service Edge Cases: DB corruption rebuild, shared DB, row limits, transaction atomicity" echo "" echo "Integration Tests:" echo " - DatabaseMixin + Agent: Full agent lifecycle with database" diff --git a/CLAUDE.md b/CLAUDE.md index 9322b7856..bb53e094b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,17 +39,22 @@ If *any* of those is uncertain, **do not commit** โ€” surface the uncertainty to **Keep PR descriptions short. Lead with *why* and *impact*, not *what*.** Reviewers skim; long walls of text get ignored. A PR description is a sales pitch for the change, not a changelog. -**Target shape:** +**Target shape (default โ€” most PRs need only this):** -1. **One-paragraph Summary** โ€” what this PR does, in plain English, and the problem it solves. If a reader stops after this paragraph, they should understand the change's purpose. -2. **Bullet list of threads** (if the PR has more than one logical thread) โ€” one line each, with a *why this matters* clause for every bullet. Not every file changed โ€” only changes a reviewer needs to evaluate. -3. **Test plan** โ€” checkbox list of how to verify. Specific commands beat vague prose. +1. **One-paragraph "Why this matters"** โ€” the user-observable impact in plain English. Lead with the *before-state* (what was broken / missing) and the *after-state* (what now works). If a reviewer stops after this paragraph, they should know whether to merge. +2. **Test plan** โ€” checkbox list of how to verify. Specific commands beat vague prose. + +That's it. No "What changed" / "Files modified" / "Implementation notes" sections by default โ€” the diff shows what changed; the commit messages explain how. The PR description's job is to sell the merge. + +**Add a short threads list ONLY if** the PR genuinely bundles multiple logical changes a reviewer needs to evaluate independently. Each bullet: one line, with a *why this matters* clause. Not every commit โ€” only changes a reviewer can't infer from the title. + +**The "user-observable impact" test:** can a non-author understand the value in <30 seconds without reading the diff? If your description is "supports X protocol" or "refactors Y handler", you've described the *change* but not the *value*. Rewrite to "before: feature Z silently failed for users running model M; after: it works." Concrete observable behaviour beats abstract capability claims. **Hard rules:** - **No section longer than ~5 lines of prose** before breaking into bullets or cutting. - **Every non-trivial claim earns its place with a why.** "Added a linter" is noise; "Added a linter so new agents stop shipping with missing docs/tests" is signal. -- **Cut exhaustive file-by-file enumeration.** The diff is the source of truth for what files changed. The description is the source of truth for *why they changed*. +- **Cut exhaustive file-by-file enumeration and implementation walkthroughs.** The diff is the source of truth for what files changed and how. The description is the source of truth for *why a reviewer should care*. - **No "Generated with Claude Code" tagline** (see attribution rule below). - **If the PR really does bundle many threads**, group them โ€” don't list 16 commits. Reviewers scan 4 themes faster than 16 bullets. @@ -59,6 +64,9 @@ If *any* of those is uncertain, **do not commit** โ€” surface the uncertainty to - โŒ "This PR adds X, Y, Z, A, B, C, D, E, F, G" with no stated value - โŒ Mirroring every bullet in the summary inside the test plan (pick one) - โŒ Explaining implementation details a reviewer will read from the diff anyway +- โŒ A "What changed" bullet list when the title + commit message body already cover it +- โŒ Naming files in the description ("modified `agent.py`") โ€” the diff already shows that +- โŒ Burying the user impact under a section labelled "Summary"; lead with the impact **Title convention:** conventional commits style (`feat(scope):`, `fix(scope):`, `docs(scope):`, `ci(scope):`), under ~70 chars, descriptive of the *change*, not the *why* (the body carries the why). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82b2638a5..b94d9faa9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,66 +1,92 @@ # Contributing to GAIA -๐Ÿš€ **Welcome to the GAIA Community!** ๐Ÿš€ +Welcome! GAIA is AMD's open-source framework for running generative AI locally on AMD hardware. We appreciate your interest in making it better โ€” bug reports, feature ideas, and pull requests are all valuable. -We're excited that you're interested in making GAIA better! Whether you're reporting an issue, suggesting improvements, or contributing code, your help is valuable to us. Let's work together to make GAIA even more amazing! +This guide covers the general contribution workflow. For documentation-specific contributions, see [`docs/reference/contributing-docs.mdx`](docs/reference/contributing-docs.mdx). -### ๐Ÿ› Sharing Issues and Ideas +--- -Found a bug or have a suggestion? We'd love to hear from you! Here's how you can help: +## Before you open a pull request โ€” open an issue first -1. Take a quick look at our [Issues tab](https://github.com/amd/gaia/issues) to see if someone else has reported something similar -2. If you don't find anything similar, feel free to open a new issue -3. We have friendly templates to help guide you through sharing the information that will help us understand and address your report +**Every pull request must reference a GitHub issue.** If an issue doesn't exist for what you're working on, please file one before you start coding using the [bug report](https://github.com/amd/gaia/issues/new?template=bug_report.yaml) or [feature request](https://github.com/amd/gaia/issues/new?template=feature_request.yaml) template. -When reporting issues, try to include: -- What you were trying to do -- What happened instead -- Any error messages or screenshots that might help -- Your setup (OS, hardware, GAIA version) +Why we ask: -The more details you can share, the better we can help - but don't worry if you can't provide everything! +- It lets us discuss scope, design, and prior art **before** code is written, so reviews focus on implementation rather than direction. +- It avoids wasted effort if the change conflicts with planned work or the GAIA roadmap. +- It keeps the changelog and release notes usable โ€” every shipped change can be traced to a tracked issue. -### ๐Ÿ’ก Contributing Code +**Rare exceptions** (still helpful, but no issue required): -Want to help improve GAIA directly? That's fantastic! Here's how you can get started: +- Typo fixes or single-line documentation tweaks. +- Doc-only changes under ~10 lines. -1. **Pick an Issue**: Find something you'd like to work on or fix -2. **Let Us Know**: Comment on the issue to let others know you're working on it -3. **Make Your Changes**: - - Follow the project's coding style - - Add tests for new features if possible - - Update documentation as needed - - Keep your changes focused and specific -4. **Submit a Pull Request**: - - Describe what you're trying to solve - - Explain how your changes address the issue - - Keep an eye on any feedback or questions +If you're unsure whether your change qualifies, file an issue โ€” it costs nothing and we can fast-track it. -### ๐Ÿ“ Documentation +--- -Clear documentation helps everyone! You can help by: -- Fixing typos or unclear instructions -- Adding examples or use cases -- Creating guides or tutorials -- Improving existing documentation +## Filing an issue -### โฑ๏ธ Timeline Expectations +Use the templates โ€” they're short and we genuinely use every field: -We try to review and respond to contributions as quickly as we can. To help keep things moving: -- We aim to acknowledge new issues and PRs within a few days -- For pull requests, try to be responsive to any feedback -- If you need to step away from a PR you're working on, just let us know +- **[Bug report](https://github.com/amd/gaia/issues/new?template=bug_report.yaml)** โ€” what broke, how to reproduce, what you expected. +- **[Feature request](https://github.com/amd/gaia/issues/new?template=feature_request.yaml)** โ€” the **problem** you're trying to solve. We can usually find a good solution if the problem is clear; the reverse is harder. -### ๐Ÿค Working Together +Both templates have an optional **Acceptance criteria** field. If you can fill it in, please do โ€” it makes the issue immediately ready to scope, and the resulting PR has a clear definition of done. -Remember: -- Every contribution, no matter how small, is valuable -- Be kind and respectful in your interactions -- Ask questions if you're unsure about anything -- Help others when you can +For security issues, **do not file a public issue** โ€” open a private [security advisory](https://github.com/amd/gaia/security/advisories/new) instead. -## ๐ŸŒŸ Thank You! +--- -Your interest in improving GAIA means a lot to us. Whether you're reporting bugs, suggesting features, or contributing code, you're helping make GAIA better for everyone. +## Submitting a pull request -Let's build something amazing together! โœจ +1. **Claim the issue** โ€” comment on it so others know you're working on it. +2. **Branch off `main`** with a descriptive name (e.g. `fix/lemonade-startup-error`, `feat/jira-bulk-update`). +3. **Use the [PR template](.github/pull_request_template.md)** โ€” every field matters. Don't delete sections; if a section doesn't apply, say so. +4. **Link the issue** with `Closes #N` (or `Fixes #N`, `Refs #N` for partial work). GitHub will auto-close the issue on merge. +5. **Run lint and tests locally** before pushing: + ```bash + python util/lint.py --all --fix + pytest tests/unit/ + ``` +6. **Keep the PR scope-clean** โ€” one logical thread per PR. No drive-by formatting, no unrelated refactors. If you spot something else worth fixing, file a separate issue. + +### What we expect in the PR description + +The PR template asks for these because they make review faster and better: + +- **Summary** โ€” what changed, in plain English. *Not* a copy of the commit log. +- **Why** โ€” the motivation. "Fixes a crash on startup" beats "Refactors `LemonadeClient`." +- **Linked issue** โ€” `Closes #N` at the top. +- **Test plan** โ€” specific commands or steps a reviewer can run. `pytest tests/unit/test_chat.py -k startup` is signal; "I tested it" is not. + +A good Summary + Why example: + +> **Summary:** Replace the silent fallback in `LemonadeClient` with a clear startup-time error. +> **Why:** When Lemonade Server isn't running, GAIA was returning empty responses with no indication of why. Users were filing bugs for the silent failure rather than the underlying setup issue. + +--- + +## Code style and testing + +Development setup, lint commands, and the test layout live in [`docs/reference/dev.mdx`](docs/reference/dev.mdx) โ€” please follow that guide rather than relying on commands quoted here, since it's the canonical source. New features need tests; the [`tests/`](tests/) directory has examples for unit, integration, MCP, and CLI testing patterns. + +--- + +## Documentation contributions + +If you're adding or updating documentation, see the [Documentation Contribution Guide](docs/reference/contributing-docs.mdx) for which `docs/` directory to use (guides, playbooks, SDK reference, specifications, or reference). Documentation contributions still follow the issue-then-PR rule, except for the typo/small-edit exceptions noted above. + +--- + +## Review timeline + +We aim to acknowledge new issues and PRs within a few days. For pull requests, please stay responsive to review comments โ€” if you need to step away, leave a quick comment so we know whether to wait or pick it up. + +--- + +## Conduct + +Be kind, be patient, and assume good intent. Most contributors are working on this in their own time โ€” that includes maintainers reviewing your PR. We're all here to make GAIA better. + +Thanks for contributing! diff --git a/docs/connectors/github.mdx b/docs/connectors/github.mdx new file mode 100644 index 000000000..b892acdb1 --- /dev/null +++ b/docs/connectors/github.mdx @@ -0,0 +1,138 @@ +--- +title: "GitHub" +icon: "github" +description: "Connect GAIA agents to GitHub repos, PRs, issues, and Actions." +--- + + + **Connector ID:** `mcp-github` ยท **Type:** `mcp_server` ยท **Catalog entry:** [`src/gaia/connectors/catalog/mcp_servers.py`](https://github.com/amd/gaia/blob/main/src/gaia/connectors/catalog/mcp_servers.py) + + +## What you'll need + +The GitHub connector is an **MCP server** โ€” GAIA spawns the official +[`@modelcontextprotocol/server-github`](https://github.com/github/github-mcp-server) +process on demand and routes tool calls through it. It needs a single +secret: a **GitHub Personal Access Token (PAT)** with the scopes you +want agents to use. + +You will create one PAT, paste it into the GAIA Agent UI, and you're +done. The token lives encrypted in your OS keyring; the MCP server +process reads it via a `$keyring` reference at launch time. + +## Step 1 โ€” Create a Personal Access Token + +GitHub has two PAT types. **Use the classic token unless you know +otherwise** โ€” fine-grained tokens don't yet support every endpoint the +MCP server uses. + +1. Go to github.com/settings/tokens (Settings โ†’ Developer + settings โ†’ Personal access tokens โ†’ Tokens (classic)). +2. **Generate new token** โ†’ **Generate new token (classic)**. +3. **Note**: `gaia-personal` (or whatever helps you find it later). +4. **Expiration**: pick something โ€” 90 days is a sensible default. + GitHub will email you before it expires. + +### Pick scopes + +The scopes you grant the token are the **maximum** GAIA can do. Per- +agent grants further narrow it from there. + +| Scope | What it lets agents do | +|---|---| +| `repo` | Read/write to all your repos. The default for most agents. | +| `read:user` | Read your profile (used by `whoami`-style tools). | +| `workflow` | View and trigger GitHub Actions runs. | +| `read:org` | List orgs you belong to. | + +For a typical setup, tick **`repo`** and **`read:user`**. Add +`workflow` if you want agents to interact with Actions. + +5. Scroll to the bottom and click **Generate token**. +6. **Copy the token now** โ€” GitHub will not show it again. It looks + like `ghp_โ€ฆ` followed by 36 characters. + +## Step 2 โ€” Paste it into GAIA + +1. Launch the Agent UI: `gaia chat --ui`. +2. **Settings** (gear) โ†’ **Connections** โ†’ click the **GitHub** tile. +3. Paste the PAT into the **GitHub Personal Access Token** field. +4. Click **Save**. + +GAIA will: + +1. Store the token in your OS keyring (single slot, distinct from any + other connector). +2. Write `~/.gaia/mcp_servers.json` with a `$keyring:gaia.connections:mcp-github:GITHUB_TOKEN` + reference โ€” the actual token never lives in plaintext on disk. +3. Hot-reload the MCP client manager so the GitHub tools are + immediately available to agents. + +Click the **Test** button on the tile to verify GAIA can find the +token in the keyring. + +## Step 3 โ€” Grant scopes to specific agents + +Each agent must be granted access individually. The GitHub MCP server +exposes its tools (e.g. `list_issues`, `create_pull_request`); GAIA's +agent grants are at the **MCP-tool level**. + +```bash +# Grant the chat agent the ability to use any GitHub tool +gaia connectors grants grant mcp-github builtin:chat --scopes "*" + +# Or grant specific tools only: +gaia connectors grants grant mcp-github builtin:chat \ + --scopes list_issues list_pull_requests +``` + +(In the UI: open the connector tile โ†’ **Per-agent grants** section.) + +## Common issues + +### `Bad credentials` from the MCP server + +The token in your keyring is wrong, expired, or revoked. Click +**Disconnect** on the tile and re-paste a fresh token. + +### `npx: command not found` + +The GitHub MCP server is a Node package launched via `npx`. Install +Node 18+ and ensure `npx` is on your `PATH`: + +```bash +node --version # must be >= 18 +which npx # must resolve to a real path +``` + +### `Resource not accessible by personal access token` + +Your token doesn't have a scope the agent is trying to use. Either +regenerate the token with broader scopes, or revoke the agent's grant +for that tool. + +### Tokens for organization-owned repos + +Classic PATs scoped to `repo` work for any repo you have push access +to, including org-owned repos. If your org enforces SSO, click +**Configure SSO** next to the token in +[github.com/settings/tokens](https://github.com/settings/tokens) and +authorize each org you want the token to reach. + +## Revoking access + +- **From GAIA**: Settings โ†’ Connections โ†’ GitHub โ†’ **Disconnect**. The + PAT is removed from the keyring and the MCP server entry is dropped + from `mcp_servers.json`. +- **From GitHub**: [github.com/settings/tokens](https://github.com/settings/tokens) โ†’ + the row for `gaia-personal` โ†’ **Delete**. Use this if the laptop + with the keyring is lost. + +## See also + +- [Connectors overview](/connectors) +- [GitHub MCP server](https://github.com/github/github-mcp-server) +- [Managing your personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) +- [Connectors security model](/security/connections) diff --git a/docs/connectors/google.mdx b/docs/connectors/google.mdx new file mode 100644 index 000000000..90e41a3c6 --- /dev/null +++ b/docs/connectors/google.mdx @@ -0,0 +1,172 @@ +--- +title: "Google" +icon: "google" +description: "Connect GAIA to Gmail, Calendar, Drive, and other Google Workspace APIs." +--- + + + **Connector ID:** `google` ยท **Type:** `oauth_pkce` ยท **Catalog entry:** [`src/gaia/connectors/catalog/google.py`](https://github.com/amd/gaia/blob/main/src/gaia/connectors/catalog/google.py) + + +## What you'll need + +Google requires every desktop app โ€” including GAIA running locally โ€” to +identify itself with an **OAuth client** that you create in your own +Google Cloud project. This sounds heavy, but for a single-developer +machine it takes about three minutes and is free. + +You will create one OAuth client and paste two values into the GAIA +Agent UI: a **Client ID** and a **Client Secret**. After that, GAIA +stores them encrypted in your OS keyring and you never need to think +about them again. + +## Step 1 โ€” Create a Google Cloud project + +If you already have a project you use for hobby projects, skip ahead. +Otherwise: + +1. Go to the [Google Cloud Console](https://console.cloud.google.com/). +2. Click the project dropdown at the top โ†’ **New project**. +3. Name it something like `gaia-personal` and click **Create**. + +## Step 2 โ€” Enable the APIs you want to use + +GAIA only sees the Google APIs you explicitly enable on the project. +For a typical setup enable at least: + +- **Gmail API** โ€” to read/send mail +- **Google Calendar API** โ€” to read/create events +- **Google Drive API** โ€” to read/manage files + +In the Cloud Console, go to **APIs & Services โ†’ Library**, search for +each API by name, click it, and click **Enable**. + +## Step 3 โ€” Configure the OAuth consent screen + +Google requires you to fill in a consent-screen form before it will +issue OAuth credentials, even for personal-use apps. + +1. Go to **APIs & Services โ†’ OAuth consent screen**. +2. Pick **External** as the user type and click **Create**. +3. Fill in the required fields: + - **App name**: `GAIA Personal` (or whatever) + - **User support email**: your email + - **Developer contact email**: your email +4. Click **Save and Continue** through the Scopes and Test users + pages โ€” leave them empty for now. You'll add yourself as a test + user in the next step. + +### Add yourself as a test user + +While the app is in **Testing** publishing status (the default), only +test users can authenticate. Add your own Google account: + +1. **OAuth consent screen โ†’ Test users โ†’ Add users**. +2. Enter your Google email and **Save**. + +You can stay in Testing mode indefinitely if you only use this for +yourself โ€” there's no need to verify the app for personal use. + +## Step 4 โ€” Create the OAuth client + +This is the credential GAIA uses. + +1. Go to **APIs & Services โ†’ Credentials โ†’ Create credentials โ†’ OAuth + client ID**. +2. **Application type**: **Desktop app**. Why Desktop app? +3. **Name**: `GAIA` (or whatever you want). +4. Click **Create**. A dialog shows the **Client ID** and **Client + Secret**. **Copy both** โ€” you'll paste them into GAIA in a moment. + You can also download the JSON. + + + Even though "Desktop app" clients are technically public (the secret + isn't a real secret in the cryptographic sense), Google's token + endpoint requires `client_secret` to be present in every exchange. + Don't omit it. + + +## Step 5 โ€” Paste credentials into GAIA + +1. Launch the Agent UI: `gaia chat --ui`. +2. Click **Settings** (gear) โ†’ **Connections**. +3. Click the **Google** tile to expand it. +4. Paste the **Client ID** and **Client Secret** from Step 4. +5. Click **Save & Connect**. + +GAIA will: + +1. Store the credentials in your OS keyring (macOS Keychain on Mac, + gnome-keyring/kwallet on Linux, Credential Locker on Windows). +2. Open Google's consent screen in your default browser. +3. Receive the callback on a temporary loopback server. +4. Exchange the auth code for a refresh token (also stored in the + keyring). + +The tile flips to **Connected as your.email@gmail.com** once the flow +completes. + +## Step 6 โ€” Grant scopes to specific agents + +Connecting Google doesn't automatically give every agent access to +your inbox. Each agent must be granted the specific scopes it needs. +You can do this in the UI or the CLI: + +```bash +# Grant the chat agent read-only Gmail access +gaia connectors grants grant google builtin:chat \ + --scopes https://www.googleapis.com/auth/gmail.readonly +``` + +The default scopes a connection is established with are listed in the +spec at +[`src/gaia/connectors/catalog/google.py`](https://github.com/amd/gaia/blob/main/src/gaia/connectors/catalog/google.py). + +## Common issues + +### `redirect_uri_mismatch` + +You probably picked **Web application** instead of **Desktop app** in +Step 4. Delete the OAuth client and create a new one with the right +type โ€” Web application clients require a registered redirect URI, +which GAIA can't provide because it picks an ephemeral port. + +### `Access blocked: ... has not completed Google verification` + +Add yourself as a test user (Step 3, "Add yourself as a test user"). +Apps in Testing mode only allow listed test users. + +### `Error 400: invalid_grant` after a long while + +Refresh tokens expire if your app stays in Testing mode for more than +seven days without use. Reconnect from the Agent UI's Connections +panel โ€” it will issue a new refresh token. + +### `client_secret is missing` + +You either skipped pasting the Client Secret in Step 5, or the +connection blob in your keyring predates a GAIA upgrade. Disconnect +and reconnect from the UI to reset. + +## Revoking access + +Two places โ€” both work: + +- **From GAIA**: Settings โ†’ Connections โ†’ Google โ†’ **Disconnect**. + Removes the refresh token from the keyring; the next API call + errors with `NOT_CONNECTED`. +- **From Google**: myaccount.google.com/permissions โ†’ find your + OAuth client โ†’ **Remove access**. Useful if you've lost the laptop + the keyring lives on. + +## See also + +- [Connectors overview](/connectors) +- [Google Cloud Console โ€” OAuth 2.0 credentials](https://cloud.google.com/docs/authentication/provide-credentials-adc) +- [Google APIs Explorer](https://developers.google.com/apis-explorer) +- [Connectors security model](/security/connections) diff --git a/docs/connectors/index.mdx b/docs/connectors/index.mdx new file mode 100644 index 000000000..78beba1fa --- /dev/null +++ b/docs/connectors/index.mdx @@ -0,0 +1,120 @@ +--- +title: "Connectors" +icon: "plug" +description: "Connect GAIA agents to your accounts and external services." +--- + +## What connectors do + +Connectors give GAIA agents permission to act on your behalf โ€” read your +Gmail, list your GitHub issues, post to Slack, query a Postgres +database, and so on. You configure each connector **once**, then grant +individual agents the specific scopes they need. An agent can never see +or use a credential you haven't granted it. + +There are two flavors: + +- **OAuth providers** (e.g. Google) โ€” you authenticate through the + provider's own consent screen. GAIA stores a refresh token in your OS + keyring, never on disk. +- **MCP servers** (e.g. GitHub, Slack, Postgres) โ€” an external Model + Context Protocol server exposes the API as tools. You provide the + required API tokens once; GAIA stores them in the keyring and passes + them to the MCP server at launch. + +All credentials live in your OS credential store (macOS Keychain, +gnome-keyring/kwallet on Linux, Credential Locker on Windows). GAIA +never writes a token to a plaintext file. + +## How to set up a connector + +1. Launch the Agent UI: `gaia chat --ui`. +2. Click **Settings** (gear icon) โ†’ **Connections**. +3. Find the connector you want and click its tile to expand it. +4. Either click **Connect** (OAuth) or fill in the credentials form + (MCP). Step-by-step instructions for the most common ones below. + +## Documented connectors + + + + Gmail, Calendar, Drive, and other Google Workspace APIs via OAuth. + + + Repos, PRs, issues, and Actions via the official GitHub MCP server. + + + +## Coming soon + +The connectors below ship in the catalog and work today, but their +setup pages are still being written. Track progress and request +priorities at [issue #937](https://github.com/amd/gaia/issues/937). + +In the meantime, the **Configure** form for each connector includes +inline help with where to obtain the required token or API key. + +- **Microsoft 365** โ€” `mcp-outlook` +- **Calendars** โ€” `mcp-google-calendar` +- **Email** โ€” `mcp-gmail`, `mcp-sendgrid` +- **Productivity** โ€” `mcp-notion`, `mcp-linear`, `mcp-jira`, + `mcp-slack` +- **Developer tools** โ€” `mcp-git`, `mcp-postgres`, + `mcp-desktop-commander` +- **Web** โ€” `mcp-fetch`, `mcp-brave-search`, `mcp-context7`, + `mcp-playwright`, `mcp-microsoft-learn` +- **Other** โ€” `mcp-spotify`, `mcp-stripe`, `mcp-memory`, + `mcp-filesystem`, `mcp-windows-automation` + +## Try it: the Connectors Demo agent + +GAIA ships a built-in **Connectors Demo** agent that exercises the +full grant flow against your real Google account and GitHub PAT โ€” +useful for verifying your setup or seeing the per-agent grants flow +in action. + +After connecting Google + GitHub: + +1. In the AgentUI agent dropdown (top of the chat panel), pick + **Connectors Demo**. +2. Settings โ†’ Connections โ†’ Google โ†’ **Per-agent grants** โ†’ grant + the demo agent the `gmail.readonly`, `calendar.readonly`, and + `drive.readonly` scopes. Same for GitHub (`use`). +3. Ask: *"What's in my inbox?"*, *"What's on my calendar today?"*, + *"List my recent Drive files"*, or *"List my GitHub repos"*. The + agent calls the matching tool and surfaces the result. + +If you skip a grant, the demo will surface an actionable error like +`AGENT_NOT_GRANTED: open Settings โ†’ Connections โ†’ google โ†’ Per-agent +grants and grant `. + +The agent's source โ€” +[`src/gaia/agents/connectors_demo/agent.py`](https://github.com/amd/gaia/blob/main/src/gaia/agents/connectors_demo/agent.py) +โ€” is a working reference for any custom agent that needs to call +external services. + +## Per-agent grants + +After you connect an account, grant individual agents the scopes they +need: + +```bash +# CLI +gaia connectors grants grant google builtin:chat \ + --scopes https://www.googleapis.com/auth/gmail.readonly + +# Or in the UI: open the connector tile โ†’ "Per-agent grants" section. +``` + +Agents that don't have a grant for a scope they request will fail with +`AGENT_NOT_GRANTED` and tell you exactly what scope to add. The same +flow protects you whether the agent is built-in, custom, or installed +from the Agent Hub. + +## See also + +- [Connectors security model](/security/connections) โ€” what is stored + where, how revocation works, and the threat model. +- [Building agents that use connectors](/sdk/infrastructure/connectors) โ€” how + to declare `REQUIRED_CONNECTORS` and call `get_credential` from a + custom agent. diff --git a/docs/deployment/ui.mdx b/docs/deployment/ui.mdx index 16e310de3..7015c8430 100644 --- a/docs/deployment/ui.mdx +++ b/docs/deployment/ui.mdx @@ -40,7 +40,7 @@ The GAIA Agent UI is distributed as an [npm package](https://www.npmjs.com/packa [Agent UI guide](/guides/agent-ui) for details. -Install GAIA UI on Windows and Ubuntu using the packages from the GitHub +Install GAIA Agent UI on Windows and Ubuntu using the packages from the GitHub [Releases](https://github.com/amd/gaia/releases) page (when available). Artifact names follow `gaia-agent-ui---setup.` (see `electron-builder.yml`). @@ -108,13 +108,15 @@ gaia diagnostics ``` Attach the resulting `~/.gaia/diagnostics-*.tgz` to your GitHub issue. -# GAIA Chat (Lightweight Desktop) +--- + +# Gaia Agent UI Architecture -GAIA Chat is a lightweight, privacy-first desktop chat application built with a Python FastAPI backend and a minimal web frontend. It is designed as a lighter alternative to RAUX, focused specifically on chat and document Q&A. +The Gaia Agent UI is a privacy-first desktop chat application built with a Python FastAPI backend and a React/TypeScript frontend, packaged as an Electron desktop app. ## Key Features -- **Privacy-first**: All data stays local -- no cloud, no telemetry +- **Privacy-first**: All data stays local โ€” no cloud, no telemetry - **Document Q&A**: Drag-and-drop 50+ file formats for RAG-powered search - **Session management**: Create, rename, export, and delete conversations - **Streaming responses**: Real-time SSE streaming from local LLMs @@ -124,7 +126,7 @@ GAIA Chat is a lightweight, privacy-first desktop chat application built with a ## Architecture ``` -GAIA Chat Desktop +Gaia Agent UI Electron Shell (optional) or Browser | v @@ -153,44 +155,12 @@ python -m gaia.ui.server ``` For full documentation, see: -- [GAIA Chat Desktop Guide](/guides/agent-ui) -- User guide with features and troubleshooting -- [Agent UI SDK Reference](/sdk/sdks/agent-ui) -- Python backend API documentation -- [Agent UI Server Spec](/spec/agent-ui-server) -- Technical specification +- [Gaia Agent UI Guide](/guides/agent-ui) โ€” User guide with features and troubleshooting +- [Agent UI SDK Reference](/sdk/sdks/agent-ui) โ€” Python backend API documentation +- [Agent UI Server Spec](/spec/agent-ui-server) โ€” Technical specification --- -# GAIA UI (RAUX) Interface - -**GAIA UI (also referred to as RAUX for RyzenAI User Experience)** is a modern Electron-based desktop application that provides the primary interface for GAIA. Built as a fork from [Open-WebUI](https://github.com/open-webui/open-webui), it delivers an extensible, feature-rich, and user-friendly AI platform experience. GAIA UI is actively developed with regular feature updates and improvements. - -## New in GAIA UI (RAUX) -- Improved error handling and progress reporting via inter-process communication (IPC) between the main and renderer processes. -- Unified GAIA UI branding and updated messaging throughout the installer and UI. - -### ๐Ÿ™ **Acknowledgments: RAUX & OpenWebUI** - -#### **Built on OpenWebUI Foundation** - -RAUX (RyzenAI UX) is built upon the excellent foundation provided by **OpenWebUI**, an outstanding open-source project that has revolutionized how users interact with AI models through web interfaces. - -#### **Special Thanks** - -We extend our heartfelt gratitude to: - -- **[Timothy Jaeryang Baek](https://github.com/tjbck)** and the entire **OpenWebUI team** for creating and maintaining such an exceptional open-source project -- The **OpenWebUI community** for their continuous contributions, feedback, and innovation -- All **open-source contributors** who have helped shape the modern AI interface landscape - -#### **Open Source Heritage** - -GAIA UI builds upon OpenWebUI's solid architectural foundation while adding AMD-specific optimizations and integrations tailored for the GAIA ecosystem. This collaboration exemplifies the power of open-source software in advancing AI accessibility and user experience. The OpenWebUI project's commitment to creating intuitive, powerful, and extensible AI interfaces has made GAIA UI possible. - -**Learn more about OpenWebUI**: [https://github.com/open-webui/open-webui](https://github.com/open-webui/open-webui) - ---- - -For more information about GAIA UI (RAUX), including setup instructions and feature documentation, please refer to the [RAUX GitHub repository README](https://github.com/aigdat/raux/blob/main/README.md). - # License [MIT License](https://github.com/amd/gaia/blob/main/LICENSE.md) @@ -208,4 +178,4 @@ Copyright(C) 2024-2026 Advanced Micro Devices, Inc. All rights reserved. SPDX-License-Identifier: MIT - \ No newline at end of file + diff --git a/docs/docs.json b/docs/docs.json index b50813d80..f0875e34c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -78,6 +78,15 @@ "guides/mcp/windows-system-health" ] }, + { + "group": "Connectors", + "pages": [ + "connectors/index", + "connectors/google", + "connectors/github", + "security/connections" + ] + }, { "group": "Agent Eval", "pages": [ @@ -157,14 +166,16 @@ "sdk/sdks/mcp", "sdk/sdks/llm", "sdk/sdks/vlm", - "sdk/sdks/audio" + "sdk/sdks/audio", + "sdk/sdks/governance" ] }, { "group": "Infrastructure", "pages": [ "sdk/infrastructure/api-server", - "sdk/infrastructure/mcp" + "sdk/infrastructure/mcp", + "sdk/infrastructure/connectors" ] }, { @@ -373,6 +384,7 @@ "plans/agent-ui", "plans/setup-wizard", "plans/security-model", + "plans/connectors", "plans/email-calendar-integration", "plans/email-triage-agent", "plans/messaging-integrations-plan", @@ -412,6 +424,7 @@ "group": "Release Notes", "pages": [ "releases/index", + "releases/v0.17.5", "releases/v0.17.4", "releases/v0.17.3", "releases/v0.17.2", @@ -460,7 +473,7 @@ "navbar": { "links": [ { - "label": "v0.17.4 \u00b7 Lemonade 10.0.0", + "label": "v0.17.5 \u00b7 Lemonade 10.2.0", "href": "https://github.com/amd/gaia/releases" }, { diff --git a/docs/guides/custom-agent.mdx b/docs/guides/custom-agent.mdx index 917cf913e..bab425a88 100644 --- a/docs/guides/custom-agent.mdx +++ b/docs/guides/custom-agent.mdx @@ -10,7 +10,7 @@ icon: "wand-magic-sparkles" ## Overview -GAIA's agent registry lets you extend the Agent UI with your own custom agents. Each agent lives in its own directory under `~/.gaia/agents/` as a Python module. Once placed there, the agent appears automatically in the **agent selector** dropdown of the GAIA UI. +GAIA's agent registry lets you extend the Agent UI with your own custom agents. Each agent lives in its own directory under `~/.gaia/agents/` as a Python module. Once placed there, the agent appears automatically in the **agent selector** dropdown of the Gaia Agent UI. Custom agents can have their own: - **Personality and instructions** (system prompt) @@ -18,6 +18,38 @@ Custom agents can have their own: - **Preferred models** (override the server default) - **Conversation starters** (suggestion chips in the UI) - **MCP servers** (any Model Context Protocol server) +- **Connectors** ([Google, GitHub, and more](/connectors)) โ€” give your agent permission to read your real email, calendar, repos, etc. + +--- + +## Using GAIA connectors + +If your agent needs to act on your behalf โ€” read your inbox, list your +calendar events, query GitHub, post to Slack, etc. โ€” wire it up to a +**GAIA connector** rather than asking users to paste API tokens into +your code. + +A connector lets users: + +1. **Authenticate once** in Settings โ†’ Connections (OAuth flow for + Google-style providers, paste-an-API-key for MCP servers). +2. **Grant scopes per agent** so each agent only sees the data it + actually needs. The Agent UI surfaces this when a user first picks + your agent. +3. **Trust that secrets stay in the OS keyring**, never in plaintext + files or env vars baked into your code. + +Declare what your agent needs by setting `REQUIRED_CONNECTORS` on the +class, and call `get_credential_sync(connector_id, agent_id, required_scopes=[...])` +from inside a tool to get a usable token. The +[`connectors-demo` agent](https://github.com/amd/gaia/blob/main/src/gaia/agents/connectors_demo/agent.py) +is a working reference for both `oauth_pkce` (Google) and `mcp_server` +(GitHub) connectors. + + + Walks through what connectors are, how the OAuth + MCP flows differ, + per-agent grants, and a step-by-step setup for Google and GitHub. + --- @@ -340,6 +372,9 @@ If you used a manifest with `mcp_servers:`, the BuilderAgent's MCP template emit Connect any MCP server to extend your agent with external tools + + Give your agent permission to read Gmail, GitHub, Slack, and more โ€” without baking API keys into code + Add document Q&A to your agent with the RAG SDK diff --git a/docs/local-test/README.md b/docs/local-test/README.md new file mode 100644 index 000000000..0a35fe9c3 --- /dev/null +++ b/docs/local-test/README.md @@ -0,0 +1,120 @@ +# Local end-to-end test โ€” OAuth connections (issue #915) + +This directory holds a recipe and a tiny agent for testing the +connections layer against a real Google account. **Not shipped to +production users** โ€” this is a developer aid. + +## Prerequisites + +1. A Google Cloud project (personal or AMD-owned) with a **Desktop app** + OAuth client. The full Cloud Console procedure is in + [`../runbooks/google-oauth-client.md`](../runbooks/google-oauth-client.md). +2. The project's OAuth consent screen has your Google account on its + test-user list (until the project is verified, only listed accounts + can complete the flow). +3. The scopes `openid`, `https://www.googleapis.com/auth/userinfo.email`, + and `https://www.googleapis.com/auth/gmail.readonly` are added to the + consent screen. + +## Recipe (~5 minutes) + +```bash +# 1. Set the client id (no secret โ€” PKCE). +export GAIA_GOOGLE_CLIENT_ID=".apps.googleusercontent.com" + +# 2. Install the test agent. +mkdir -p ~/.gaia/agents/oauth-test +cp docs/local-test/oauth-test-agent/agent.py ~/.gaia/agents/oauth-test/agent.py + +# 3. Build the AgentUI frontend so the Settings page reflects this branch. +cd src/gaia/apps/webui && npm install && npm run build && cd - + +# 4. Start the AgentUI. +gaia chat --ui +``` + +In the AgentUI: + +5. Open Settings (gear icon) โ†’ scroll to **Connections** โ†’ click + **Connect** next to Google. Your default browser opens. Pick your + test-user account, click through the unverified-app warning if you + see one, and grant the requested scopes. +6. Within ~2 seconds you should see "Connected as your-email@โ€ฆ" in the + Settings page. +7. Switch the active agent to **"OAuth Test (Gmail)"** in the agent + selector. +8. Send a message: `list 5 recent emails`. +9. The first time, the consent dialog appears: "Grant 'OAuth Test + (Gmail)' read-only access to your Gmail inbox?" Click **Grant**. +10. The agent calls Gmail, the bearer token is fetched live, and the + reply lists 5 subjects from your inbox. + +## What this test validates + +- โœ… Settings โ†’ Connections renders, Connect button works. +- โœ… OAuth PKCE flow completes; refresh token lands in OS keychain. +- โœ… Loopback `127.0.0.1:/callback` round-trips. +- โœ… SSE event `connection.connected` updates AgentUI in <2s. +- โœ… `REQUIRED_CONNECTORS` declared by the custom agent surfaces in + the consent dialog with plain-language scope text. +- โœ… Per-agent grant gates `get_access_token_sync` (first call without + grant raises `AuthRequiredError(AGENT_NOT_GRANTED)`). +- โœ… After grant, syncโ†’async bridge fetches a real bearer token. +- โœ… Live Gmail API call succeeds. +- โœ… Disconnect from Settings โ†’ Connections clears the keyring entry + and the chip flips to "Not connected" within 2s. +- โœ… Restart AgentUI: connection persists (refresh token is in keychain). + +## Cleanup + +```bash +gaia connectors disconnect google +gaia connectors grants revoke google "custom::oauth-test" +rm -rf ~/.gaia/agents/oauth-test/ +``` + +Or, from Settings โ†’ Connections in AgentUI: +- Click **Disconnect** next to Google. +- Click **Revoke** next to the OAuth Test agent under per-agent grants. +- Optionally remove the test agent in Settings โ†’ Custom Agents. + +## CLI smoke test (no AgentUI) + +The same primitives work without the UI: + +```bash +# Connect โ€” opens system browser exactly like the UI does. +gaia connectors connect google \ + --scopes https://www.googleapis.com/auth/gmail.readonly + +# Show what's connected. +gaia connectors status + +# Grant the test agent. +# (the namespaced id is printed by registry on agent load โ€” look for +# "Registered Python agent: oauth-test" in the AgentUI server log, +# or use the SDK to compute it: from gaia.agents.registry import +# _compute_custom_origin_hash; ":".join(["custom", +# _compute_custom_origin_hash(Path.home() / ".gaia/agents/oauth-test/agent.py"), +# "oauth-test"]). +gaia connectors grants grant google custom::oauth-test \ + --scopes https://www.googleapis.com/auth/gmail.readonly + +# Revoke from the same surface. +gaia connectors grants revoke google custom::oauth-test +gaia connectors disconnect google +``` + +## Troubleshooting + +- **"Connect" does nothing**: open `GET /api/connections/_debug` + (set `GAIA_DEBUG=1` first). The response names every common cause + (missing env var, wrong keyring backend, grants path not writable). +- **"Insecure keyring backend"**: install `gnome-keyring` (Linux) and + start a session with `dbus-run-session`. macOS/Windows are fine + out of the box. +- **"unverified app" warning in browser**: expected for personal + Cloud projects. Click "Advanced โ†’ Continue to " once. +- **403 from Gmail**: scope mismatch. Disconnect, reconnect passing + `--scopes` followed by `https://www.googleapis.com/auth/gmail.readonly` + (the test agent's required scope). diff --git a/docs/local-test/oauth-test-agent/agent.py b/docs/local-test/oauth-test-agent/agent.py new file mode 100644 index 000000000..dbaa8574f --- /dev/null +++ b/docs/local-test/oauth-test-agent/agent.py @@ -0,0 +1,134 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Local-test agent for the OAuth connections layer (issue #915). + +INSTALL: copy this file to ``~/.gaia/agents/oauth-test/agent.py``. + +Run flow (see ../README.md for the full local-test recipe): + 1. Set ``GAIA_GOOGLE_CLIENT_ID`` to your Cloud Console desktop client id. + 2. Start the AgentUI: ``gaia chat --ui``. + 3. Open Settings โ†’ Connections โ†’ click "Connect" next to Google. + 4. Complete OAuth in your browser; AgentUI updates within ~2s. + 5. Switch the active agent to "OAuth Test (Gmail)". + 6. The first message triggers the consent dialog (REQUIRED_CONNECTORS + surfaces the gmail.readonly scope claim). + 7. Click "Grant" โ€” the agent now has gmail.readonly for your account. + 8. Ask the agent: "list 5 recent subjects". The reply lists subjects + fetched live via the Gmail API. + +This agent is intentionally tiny: one tool, one HTTP call, one bearer +token from get_access_token_sync. It exercises every layer of the +connections module end-to-end without needing any other GAIA feature. +""" + +from __future__ import annotations + +from typing import ClassVar, List + +import requests + +from gaia.agents.base.agent import Agent +from gaia.agents.base.tools import tool +from gaia.connectors import ( + AuthRequiredError, + ConnectorRequirement, + get_access_token_sync, +) + + +GMAIL_READONLY = "https://www.googleapis.com/auth/gmail.readonly" + + +class OAuthTestAgent(Agent): + AGENT_ID = "oauth-test" + AGENT_NAME = "OAuth Test (Gmail)" + AGENT_DESCRIPTION = ( + "Demo agent for the connections layer โ€” fetches the 5 newest Gmail " + "subjects to exercise the OAuth flow end-to-end." + ) + CONVERSATION_STARTERS = [ + "List 5 recent emails", + "Show me my newest message subjects", + ] + + # Declare the scope claim โ€” the AgentUI consent dialog renders the + # `reason` field in plain language. + REQUIRED_CONNECTORS: ClassVar[List[ConnectorRequirement]] = [ + ConnectorRequirement( + provider="google", + scopes=[GMAIL_READONLY], + reason="Read your Gmail inbox to summarize the 5 newest message subjects.", + ), + ] + + response_mode: str = "conversational" + + def _register_tools(self): + # The base Agent class auto-registers methods decorated with @tool; + # this hook is the canonical place to bind any extra runtime state. + pass + + @tool( + name="list_recent_subjects", + description="List the 5 newest Gmail subjects for the connected account.", + ) + def list_recent_subjects(self) -> dict: + """ + Fetch the 5 newest Gmail subjects for the connected account. + + Returns a dict so the conversational mode can render it as JSON or + the agent can summarize it. The bearer token comes from the + connections layer; if the user hasn't granted this agent yet, the + call raises AuthRequiredError(AGENT_NOT_GRANTED) and the AgentUI + surfaces the consent dialog. + """ + try: + token = get_access_token_sync( + provider="google", + scopes=[GMAIL_READONLY], + ) + except AuthRequiredError as e: + return { + "ok": False, + "reason": e.reason.value, + "message": str(e), + } + + headers = {"Authorization": f"Bearer {token}"} + list_resp = requests.get( + "https://gmail.googleapis.com/gmail/v1/users/me/messages", + params={"maxResults": 5}, + headers=headers, + timeout=10, + ) + list_resp.raise_for_status() + ids = [m["id"] for m in list_resp.json().get("messages", [])] + + subjects: list[str] = [] + for mid in ids: + m = requests.get( + f"https://gmail.googleapis.com/gmail/v1/users/me/messages/{mid}", + params={"format": "metadata", "metadataHeaders": "Subject"}, + headers=headers, + timeout=10, + ) + m.raise_for_status() + for h in m.json().get("payload", {}).get("headers", []): + if h.get("name") == "Subject": + subjects.append(h.get("value") or "(no subject)") + break + else: + subjects.append("(no subject)") + + return {"ok": True, "subjects": subjects} + + def get_system_prompt(self) -> str: + return ( + "You are a tiny demo agent that helps test the GAIA OAuth " + "connections layer. When the user asks for recent emails, " + "call list_recent_subjects() once and reply with the list. " + "If the call returns ok=False, explain the reason in plain " + "English and suggest the user grant access in Settings โ†’ " + "Connections." + ) diff --git a/docs/plans/axis-gaia-integration.md b/docs/plans/axis-gaia-integration.md index b8fb804f9..d46a2be2b 100644 --- a/docs/plans/axis-gaia-integration.md +++ b/docs/plans/axis-gaia-integration.md @@ -159,7 +159,7 @@ axis run --policy ~/.axis/policies/gaia-mvp.yaml -- \ **Step 3 โ€” Run the demo sequence** -From the GAIA UI, send two chat messages to `ChatAgent`: +From the Gaia Agent UI, send two chat messages to `ChatAgent`: 1. *"Summarize the README from this directory."* โ€” uses the local RAG/file tool. Expected: works normally. Confirm in the audit log that only `localhost:13305` network activity is recorded. diff --git a/docs/plans/connectors.mdx b/docs/plans/connectors.mdx new file mode 100644 index 000000000..9b6a107bb --- /dev/null +++ b/docs/plans/connectors.mdx @@ -0,0 +1,310 @@ +--- +title: "Connectors Framework" +description: "Typed registry + unified Settings UI for OAuth, MCP servers, and future API-token / composite-form / local-extension integrations. Parent of #915 (Google OAuth) and successor to the Connector Hub track." +icon: "plug" +--- + +# Connectors Framework + + +**Target:** v0.18.x | **Status:** Spec approved; implementation underway | **Priority:** High + + +> **Date:** 2026-04-30 +> +> **Status:** Active spec โ€” implementation in flight on [PR #926](https://github.com/amd/gaia/pull/926) (baseline of #915). +> +> **Live tracking issue:** [#927](https://github.com/amd/gaia/issues/927) โ€” the GitHub issue body is the canonical, continuously-updated spec; this document is a stable snapshot for in-repo discovery. +> +> **Related issues:** [#915](https://github.com/amd/gaia/issues/915) (OAuth PKCE for Google โ€” first concrete connector); [#735](https://github.com/amd/gaia/issues/735) / [#736](https://github.com/amd/gaia/issues/736) / [#737](https://github.com/amd/gaia/issues/737) / [#738](https://github.com/amd/gaia/issues/738) / [#740](https://github.com/amd/gaia/issues/740) (Connector Hub track โ€” supersede-vs-children call pending @kovtcharov-amd; see #927's Coordination block). +> +> **Related plans:** [Agent UI](agent-ui.mdx), [Security Model](security-model.mdx). +> +> **Scope:** This spec promotes the OAuth-only `gaia.connectors` library shipped in #915 into a generalized **Connectors framework** with a `Settings โ†’ Connectors` page modeled on Claude desktop's native UI. The existing 22-entry MCP server catalog (today read-only in Settings) is unified into the same surface. v1 ships the framework with two implemented types (`oauth_pkce` + `mcp_server`); other types (`api_token`, `composite_form`, `local_extension`) are framework-shaped but follow-up. + +--- + +## TL;DR + +GAIA already has two parallel mechanisms for "user wires up an external service": MCP server installs (today via `~/.gaia/mcp_servers.json`, read-only Settings panel) and OAuth via `gaia.connectors` (just shipped in #915). This spec collapses both into one **typed connector registry** with a single Settings โ†’ Connectors page. + +Each connector tile knows how to configure itself (OAuth flow, env-block paste form, future API-token paste, โ€ฆ). Per-agent grants gate every credential read, regardless of type. Settings becomes a **navigable page** (no more modal-on-modal); clicking a tile drills **in-place** to a `ConnectorDetailView`. The framework's UI is **type-driven, not connector-driven** โ€” adding a new connector costs one `ConnectorSpec` row, never new React. + +v1 ships: +- **Framework**: `ConnectorSpec` registry, `ConnectorHandler` Protocol, public `get_credential(connector_id, agent_id)` API, FastAPI router with CSRF guard, `gaia connectors` CLI, master-detail UI. +- **Two implemented types**: `oauth_pkce` (Google, refactored from #915), `mcp_server` (the 22 entries from `src/gaia/ui/routers/mcp.py:_CATALOG` migrated to `ConnectorSpec` rows; secret env stored as `$keyring` references that `MCPClient` resolves at spawn). +- **Three follow-up types**: `api_token`, `composite_form`, `local_extension` โ€” shape exists, handlers defer to v2 child issues. + +--- + +## Why now + +Building the framework now โ€” before either auth pattern grows further โ€” avoids two divergent UX patterns and lets follow-up integrations (GitHub PAT, Anthropic key management, Jira credentials, Microsoft 365) plug into a single typed registry instead of inventing a third pattern. + +User-confirmed direction (post-meeting on 2026-04-30): + +- **Unify** with MCP โ€” one Settings โ†’ Connectors page; the MCP read-only panel goes away. +- **Framework + Google only in v1** โ€” first PR ships the framework with #915's OAuth refactored under it. Other connectors are follow-up issues. +- **Per-agent grants for every connector type** โ€” same `~/.gaia/connectors/grants.json` ledger gates `get_credential(connector_id, agent_id)` regardless of type. +- **Rename** `gaia.connections` โ†’ `gaia.connectors`, `~/.gaia/connections/` โ†’ `~/.gaia/connectors/`, `ConnectionsSection.tsx` โ†’ `ConnectorsSection.tsx`, `gaia connections` CLI โ†’ `gaia connectors`. Since #915 is unmerged, no migration shims are needed โ€” direct rename only. + +--- + +## User experience + +1. User opens **AgentUI โ†’ Settings โ†’ Connectors** and sees a grid of tiles: **Google** (OAuth), **Mermaid Chart** (MCP), **Supabase** (MCP), โ€ฆ with status chips ("Not configured" / "Connected" / "Running"). +2. Clicking **Configure** on a tile drills into a type-specific detail view (in-place within the Settings page โ€” no nested modal): + - OAuth tile โ†’ "Connect" button โ†’ system browser โ†’ consent โ†’ SSE updates UI to "Connected as ``" within 2 seconds (the existing #915 flow). + - MCP-server tile โ†’ form rendered from the connector's `config_schema` (the env-block fields the MCP server needs). Paste API key โ†’ click **Test** โ†’ spinner โ†’ "Connected, 4 tools detected" โ†’ Save. +3. The detail view also shows a **Per-agent grants** subsection: a list of installed agents whose `REQUIRED_CONNECTORS` match this connector, each with a toggle. The user grants individual agents access; no agent can read a connector's credentials without an explicit grant. +4. Disconnect / Disable from the same view clears the credential and the per-agent grants in one click. SSE refreshes the UI. +5. If a refresh token is revoked or an MCP server's API key is rotated remotely, the next agent run hits the failure, AgentUI shows the existing reauth banner, and the user re-configures from the same tile. + +--- + +## Connector type taxonomy + +After adversarial review (full panel output in the implementation playbook), the v1 framework supports **two** types โ€” proving extensibility without shipping unused stubs: + +| Type | Configures | Stores | `get_credential()` returns | +|---|---|---|---| +| `oauth_pkce` | redirect to provider, PKCE flow | refresh_token in keyring; scopes/account email in `state.json` | `{access_token, expires_at, scopes}` | +| `mcp_server` | env-block fields per the connector's `config_schema` | secret env in keyring; plain env in `state.json`; the same entry mirrored to `~/.gaia/mcp_servers.json` (with **`$keyring` references** for the secret values, not plaintext) for `MCPClient` to consume | `{server_running, tools, command, args, env}` | + +Three additional types โ€” `api_token`, `composite_form`, `local_extension` โ€” are framework-shaped (the registry and dispatcher accept them) but **not implemented** in v1. They land in follow-up child issues when concrete catalog entries demand them. + +--- + +## Module layout + +New module at `src/gaia/connectors/`: + +``` +src/gaia/connectors/ +โ”œโ”€โ”€ __init__.py # public re-exports +โ”œโ”€โ”€ api.py # coordination: get_credential, configure, disconnect, test +โ”œโ”€โ”€ spec.py # ConnectorSpec, ConfigField, ConnectorRequirement (frozen dataclasses) +โ”œโ”€โ”€ registry.py # catalog loader; id-uniqueness validated at module import +โ”œโ”€โ”€ state.py # ~/.gaia/connectors/state.json atomic store +โ”œโ”€โ”€ grants.py # ~/.gaia/connectors/grants.json (rekeyed from #915 grants ledger) +โ”œโ”€โ”€ store.py # OS keyring (re-used from #915 verbatim; service name kept as "gaia.connections") +โ”œโ”€โ”€ context.py # private _agent_context (NOT re-exported) +โ”œโ”€โ”€ events.py # EventEmitter Protocol +โ”œโ”€โ”€ errors.py # ConnectorsError + AuthRequiredError(Reason) + ConnectorTypeMismatchError +โ”œโ”€โ”€ cli.py # gaia connectors {connect|status|disconnect|grants ...} +โ”œโ”€โ”€ handlers/ +โ”‚ โ”œโ”€โ”€ base.py # ConnectorHandler Protocol (NOT ABC โ€” matches OAuthProvider style) +โ”‚ โ”œโ”€โ”€ oauth_pkce/ # refactor of #915 flow.py + tokens.py + pkce.py +โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py # OAuthPkceHandler +โ”‚ โ”‚ โ”œโ”€โ”€ flow.py +โ”‚ โ”‚ โ”œโ”€โ”€ tokens.py +โ”‚ โ”‚ โ”œโ”€โ”€ pkce.py +โ”‚ โ”‚ โ””โ”€โ”€ base.py # OAuthProvider Protocol +โ”‚ โ””โ”€โ”€ mcp_server.py # McpServerHandler +โ””โ”€โ”€ catalog/ + โ”œโ”€โ”€ google.py # Google ConnectorSpec + โ””โ”€โ”€ mcp_servers.py # 22 ConnectorSpec rows migrated from src/gaia/ui/routers/mcp.py:_CATALOG +``` + +The keyring service name remains `gaia.connections` (NOT renamed to `gaia.connectors` or `gaia.connectors.`) โ€” internal constant, not user-visible, and renaming it would orphan dev keyring entries from #915 with no benefit. + +--- + +## Public Python API + +```python +async def get_credential( + connector_id: str, + *, + agent_id: str | None = None, # falls back to current_agent_id contextvar + required_scopes: list[str] | None = None, # oauth_pkce only + account_id: str | None = None, +) -> dict: + """Return type-specific credential payload after grant + scope check. + + Two-layer authorization: + 1. Per-agent grant โ€” connector_id โ†’ agent_id โ†’ ["use" | scope-list] + 2. Type-specific policy โ€” OAuth scopes coverage; MCP server running + + Raises: + AuthRequiredError(Reason) # NOT_CONNECTED | AGENT_NOT_GRANTED | + # CONNECTION_MISSING_SCOPES | REAUTH_REQUIRED | + # CONNECTOR_NOT_CONFIGURED | MCP_SERVER_NOT_RUNNING + ConnectorsError # any other framework error + """ + +def get_credential_sync(...) -> dict # asyncio.run wrapper with running-loop guard + +async def configure(connector_id: str, config: dict) -> dict +async def disconnect(connector_id: str, *, account_id: str | None = None) -> None +async def test(connector_id: str) -> dict # {"ok": bool, "detail": str} +async def list_installed() -> list[dict] +def list_catalog() -> list[ConnectorSpec] + +# OAuth-specific (still public for the OAuth modal) +async def start_authorization(connector_id, scopes) -> {auth_url, state} +async def complete_authorization(state, code) -> {account_id} + +# Grants (generalized over connector_id) +def grant_agent(connector_id, agent_id, scopes) +def revoke_agent_grant(connector_id, agent_id) +def list_agent_grants(connector_id) -> dict[str, list[str]] +def check_agent_grant(connector_id, agent_id, required_scopes) -> bool +``` + +`ConnectorHandler` is a `Protocol` (not an ABC) matching `OAuthProvider` and `EventEmitter` from #915 โ€” keeps duck-typed mixin convention consistent. + +--- + +## Storage layout + +| Storage | What | Why | +|---|---|---| +| OS keychain โ€” service `gaia.connections` (unchanged from #915 per amendment A3), username `:` | Single JSON blob: refresh tokens, secret env values, account metadata, `client_id_hash` | Encrypted at rest; backend allowlist refuses `PlaintextKeyring` / `EncryptedKeyring` per #915 | +| `~/.gaia/connectors/state.json` (mode 0600) | Non-secret per-connector state: configured flag, account_id, scope list, last_tested_at | Cheap "is this configured?" check that doesn't prompt the OS keychain (Linux SecretService prompts on every read) | +| `~/.gaia/connectors/grants.json` (mode 0600) | Per-agent grant map: `{connector_id: {agent_id: [scopes-or-"use"]}}` | Authorization policy, not a secret | +| `~/.gaia/mcp_servers.json` | MCP server runtime config consumed by `MCPClient` | **Connectors framework is the sole writer; `MCPClient` is read-only consumer.** Secret env values stored as `{"$keyring": ":"}` references โ€” never plaintext | +| In-process memory only | OAuth access tokens with `expires_at` | Short-lived (~1 hr); never persisted | + +--- + +## API endpoints + +`src/gaia/ui/routers/connectors.py`: + +``` +GET /api/connectors/catalog โ†’ [ConnectorSpec] +GET /api/connectors/installed โ†’ [{id, status, account, ...}] +POST /api/connectors/{id}/configure body: {config: {โ€ฆ}} +POST /api/connectors/{id}/oauth/authorize body: {scopes: [...]} (oauth_pkce only) +POST /api/connectors/{id}/oauth/cancel +POST /api/connectors/{id}/test +DELETE /api/connectors/{id} ?account_id=โ€ฆ +GET /api/connectors/{id}/grants โ†’ {agent_id: [scopes]} +PUT /api/connectors/{id}/grants/{agent_id} body: {scopes: [...]} +DELETE /api/connectors/{id}/grants/{agent_id} +GET /api/connectors/events SSE stream +GET /api/connectors/_debug gated by GAIA_DEBUG=1 +``` + +Every state-changing route is gated by `Depends(_require_ui_header)` (the existing `X-Gaia-UI: 1` CSRF check at `src/gaia/ui/routers/agents.py:58`). The same guard is backfilled on the legacy `/api/mcp/servers` mutating routes โ€” they're missing it today. + +Routers accept `connector_id` (a lookup key into the registry) **only** โ€” they never accept `command`, `args`, `mcp_command`, or `test_endpoint` from the request body. The catalog is frozen at module import. + +--- + +## AgentUI surfaces + +### Settings is a page, not a modal + +Today's `SettingsModal` (a fullscreen overlay rendered on top of the chat view) is replaced by **`SettingsPage`** โ€” a top-level navigable destination that **replaces** the chat view rather than overlaying it. + +- **Entry**: clicking the gear icon (currently `setShowSettings(true)`) navigates to the Settings page. +- **Exit**: a back arrow (โ† top-left) and/or a close (โœ•) button returns the user to the chat view. State (chat session, scroll position) is preserved across the round-trip. +- **No modal stack anywhere in the connectors flow.** Drill-in within Settings is master-detail in-place, not a nested modal โ€” the original `ConfigureModal`-on-`SettingsModal` pattern is eliminated. +- **Other Settings sections** (System Status, Custom Agents, Privacy & Data) remain as stacked sections in v1. A Claude-style sidebar nav across all of Settings (General / Connectors / Privacy / Account / etc.) is **out of scope for v1** โ€” this PR only converts the modal shell to a page and adds master-detail within Connectors. The sidebar nav is a follow-up child issue. + +### Connectors master-detail navigation + +1. **Default Settings page** renders all Settings sections stacked, including the **Connectors** section: a grid of `ConnectorTile`s (icon, display name, status chip โ€” `Not configured` / `Connected as ` / `Running` โ€” and a Configure / Disconnect button). +2. **Click a tile** โ†’ the Connectors section content swaps **in-place** to the connector's **detail view** (rest of the page is replaced or, implementer's choice, scroll-locks behind it; the rule is "no modal"). +3. **Detail view header**: connector icon + display name + external-link icon (jumps to the provider's product page via `ConnectorSpec.product_url`) + Disconnect button. +4. **Detail view body**: dispatched by `spec.type` โ€” `OAuthConfigureBody` (lifted from #915's `ConnectorsSection`), `MCPServerConfigureBody` (env form + Test button), and (for v2) `ApiTokenConfigureBody`, `CompositeFormConfigureBody`, `LocalExtensionConfigureBody`. +5. **Per-agent grants subsection** rendered below the body: lists agents whose `REQUIRED_CONNECTORS` match this connector, with type-aware controls โ€” `oauth_pkce` shows per-scope toggles, `mcp_server` shows a single "use this server" toggle (per-tool toggles are a v2 follow-up; the storage shape already supports them). +6. **`โ† All connectors`** link in the detail-view header returns to the tile grid without losing scroll position. + +### UI extensibility model + +The framework's UI is **type-driven, not connector-driven**. New connectors do NOT introduce custom React components. + +- **Adding a new connector** (e.g. GitHub PAT) costs **one `ConnectorSpec` row** in `src/gaia/connectors/catalog/.py` โ€” icon, display name, instructions_md, config_schema, optional product_url. **Zero new React.** +- **Adding a new type** (e.g. `api_token` in v2) costs **one new handler class** + **one new ConfigureBody component**, shared by every connector of that type. Tile grid, status chips, master-detail navigation, per-agent grants subsection, SSE plumbing โ€” all reused. + +For connectors that genuinely support multiple auth methods (e.g. GitHub: PAT or OAuth App), the answer is **two `ConnectorSpec` rows** โ€” `id="github"` (api_token) and `id="github-oauth"` (oauth_pkce) โ€” NOT one connector with custom UI. If a future connector cannot be expressed via the existing types, the right move is to add a new type, not to inject connector-specific React. + +The Claude desktop "Tool permissions" matrix (per-tool allow/ask/deny within a connector) is **out of scope for v1**. Its data model is already supported by the existing `grants.json` scope-list shape (store `["search_repos", "create_issue"]` instead of `["use"]`), so the v2 follow-up only adds UI without a storage migration. + +### Markdown rendering policy + +`instructions_md` (rendered in connector detail view) goes through `react-markdown` with `disallowedElements=['script','iframe','object','embed','style']`, `urlTransform` filtering to `https:` / `http:` / `mailto:` only, **no `rehype-raw`**. No `dangerouslySetInnerHTML` anywhere in the connector pipeline. This is a framework invariant โ€” documented in `docs/sdk/infrastructure/connectors.mdx` so follow-up PRs cannot regress it. + +--- + +## MCP unification + +The 22 entries in `src/gaia/ui/routers/mcp.py:_CATALOG` (lines 22โ€“231) become 22 `ConnectorSpec(type="mcp_server", โ€ฆ)` rows in `src/gaia/connectors/catalog/mcp_servers.py`. Each `requires_config` becomes a `config_schema` of secret fields. + +`McpServerHandler.configure(config)` writes secret env to keyring, plain env + the connector entry to `state.json`, and writes a corresponding entry into `~/.gaia/mcp_servers.json` where the env block contains **keyring references** (`{"$keyring": ":"}`) for any secret value. `MCPClient` resolves these references in-memory at spawn time โ€” no secret env value lives plaintext on disk. + +`MCPClient` **fails closed** when a `$keyring` reference can't be resolved at spawn time (deleted entry, wrong service:key, locked keychain): it raises `ConnectorsError` naming the missing service:key tuple and refuses to spawn the server โ€” never silently spawns with empty env. + +After every `configure()` write, the handler calls `MCPClient.reload()` (a new method) so a freshly-configured server's tools materialize without a GAIA restart. + +`gaia.mcp.client.config.MCPConfig` becomes read-only: its `add_server`, `remove_server`, and `_save` methods are removed (or hidden behind a deprecated prefix that emits `DeprecationWarning`). The connectors framework is the sole writer to `mcp_servers.json`; the file write itself uses `tempfile.mkstemp` + `os.replace` to match the atomicity guarantees of `grants.json`. + +The MCP read-only Settings panel is **deleted** in this PR โ€” users see MCP servers as tiles in the unified Connectors page. + +**Naming convention:** MCP-backed connectors use the id-suffix `-mcp` (e.g. `id="github-mcp"`) so a future `id="github"` API-token connector won't collide. `ConnectorRegistry.__init__` validates id uniqueness at module-import time. + +--- + +## Out of scope for v1 + +Everything below becomes child issues under [#927](https://github.com/amd/gaia/issues/927) once the framework lands: + +- **`api_token`, `composite_form`, `local_extension` handler types** โ€” framework-shaped but not implemented in v1. +- **GitHub PAT, Anthropic API key, OpenAI API key, Hugging Face token, Jira composite credentials** โ€” first batch of follow-up connector child issues once v1 lands. +- **Microsoft 365 OAuth.** Same `oauth_pkce` machinery; new `ConnectorSpec` + provider class. Separate issue. +- **MCP per-tool grants.** v1 ships single-toggle "use this server"; per-tool granularity is forward-compatible via the existing scope-list grant key but waits for v2 UX. +- **Local-extension auto-detection.** The `installed_check` callable design is fine but per-platform implementations (macOS bundle id, Chrome extension probe) are non-trivial. v1 ships the type behind a manual "I installed it" toggle once it returns. +- **Cross-process refresh-token rotation race.** Known #915 limitation; same applies to API-token rotation. Documented in `docs/security/connectors.mdx`; out of scope for v1. +- **Custom user-supplied catalog entries.** v1 catalog is frozen at module import โ€” no runtime extensibility API. A future "register custom connector" surface would need a separate threat model. +- **Settings sidebar nav** (Claude-desktop-style General / Connectors / Privacy / Account in left rail). v1 keeps stacked sections; sidebar is a follow-up. + +--- + +## Acceptance criteria + +The full AC list (โ‰ˆ 50 items across Unit & Integration, API endpoints, End-to-end, UI structure / navigation, Migration / refactor of #915, Security & code review) lives on the [tracking issue](https://github.com/amd/gaia/issues/927#acceptance-criteria) and is the source of truth for what "done" means. Highlights: + +- Original 157 #915 OAuth tests pass under the new module name `gaia.connectors`. +- Every entry in legacy `mcp.py:_CATALOG` has a matching `ConnectorSpec` with `type="mcp_server"`. Every `mcp_server` id ends with `-mcp`. +- `McpServerHandler.configure(config)` for a connector with secret fields produces a `mcp_servers.json` whose env block contains `{"$keyring": "..."}` references but no plaintext secret value. +- `MCPClient` fails closed (raises `ConnectorsError`) when a `$keyring` reference can't be resolved at spawn time โ€” asserted by a unit test that pre-seeds `mcp_servers.json` with a dangling reference. +- `MCPClient.reload()` after `configure()` makes the new server's tools visible to agents without a GAIA restart. +- Every mutating endpoint requires the `X-Gaia-UI: 1` CSRF header; backfilled on legacy `/api/mcp/servers` routes too. +- Settings is a top-level page (replacing `SettingsModal`), with back/close affordance. No modal-on-modal anywhere. +- Adding a new connector requires only a `ConnectorSpec` row โ€” no new React component. Asserted by review checklist + a doc note in `docs/sdk/infrastructure/connectors.mdx`. +- No secret value (refresh token, MCP env api_key, etc.) appears in any log record, file under `~/.gaia/connectors/`, traceback, Pydantic dump, OpenAPI schema, or SSE event payload. + +--- + +## Implementation playbook + +The detailed task list (T-0 through T-9, TDD-paired), the file-level rename map, and the full 6-agent adversarial review (with 11 Critical findings auto-amended into the plan) live at `~/.claude/plans/floating-discovering-gray.md` on the implementation worktree. That file is execution-time material; this document is the public spec. + +A condensed sequence: + +| Task | Description | Blocking? | +|---|---|---| +| T-0 | Repo move + import rewrite (`gaia.connectors` โ†’ `gaia.connectors`); CodeAgent + packaging + grep checks. | Yes | +| T-1 | `ConnectorSpec` + `ConfigField` + `ConnectorRequirement` dataclasses + `ConnectorRegistry` (id-uniqueness validated, frozen at import). | Yes | +| T-2 | `state.json` atomic store + `grants.py` rekey from `provider` โ†’ `connector_id`. | Yes | +| T-3 | `ConnectorHandler` Protocol + `get_credential` dispatcher. | Yes | +| T-4 | `OAuthPkceHandler` (refactor of #915 flow + tokens + pkce); Google ConnectorSpec. | Parallel after T-3 | +| T-5 | `McpServerHandler` + 22-entry catalog migration + `$keyring` reference scheme + `MCPClient.reload()` + `MCPConfig` read-only refactor. | Parallel after T-3 | +| T-6 | FastAPI router `/api/connectors/*` with `_require_ui_header` + `/_debug` refresh. | After handlers | +| T-7 | `gaia connectors` CLI. | Parallel with T-6 | +| T-8a | Frontend shell conversion: `SettingsModal` โ†’ `SettingsPage`. | Parallel with T-6 | +| T-8b | Connectors UI: `ConnectorsSection` grid + `ConnectorTile` + `ConnectorDetailView` (in-place replacement) + `OAuthConfigureBody` + `MCPServerConfigureBody` + `ConnectorAgentGrants`. | After T-8a + T-6 | +| T-9 | Docs (`connectors.mdx` rename + this plan + security model + runbook) + E2E smoke. | Last | + +--- + +## Risks & open coordination + +1. **Connector Hub track overlap.** Issues #735 / #736 / #737 / #738 / #740 cover the same destination with a different storage architecture (`vault://` references via #545 vs. OS keyring + `$keyring` references already shipped in #915). **Decision pending @kovtcharov-amd:** close #735โ€“#740 as superseded (re-target #737 as a v2 child of #927), OR demote #927 to a child of #735 as "Phase 0". Either is fine; running both parents in parallel is not. See [#927's Coordination block](https://github.com/amd/gaia/issues/927#dependencies) for the row-by-row mapping. +2. **PR shape (single vs. split).** Currently #926 carries the #915 baseline; the framework refactor lands on top. With โ‰ˆ 50 AC items, splitting into PR-A (rename + framework scaffolding) + PR-B (handlers + UI + MCP unification) probably wins on reviewability โ€” execution-time call. +3. **MCP `$keyring` reference resolution at spawn.** New surface area; the fail-closed test is the contract guard. +4. **Cross-process refresh-token rotation race.** Documented as v1 limitation in `docs/security/connectors.mdx` (when that file lands). Same class of issue extends to future API-token rotation. +5. **Local-extension installed-detection.** Punted to v2 โ€” v1 ships the type behind a manual toggle when it returns. diff --git a/docs/plans/desktop-installer.mdx b/docs/plans/desktop-installer.mdx index 749747e5d..285fa7fdd 100644 --- a/docs/plans/desktop-installer.mdx +++ b/docs/plans/desktop-installer.mdx @@ -416,7 +416,7 @@ Replace `forge.config.cjs` with electron-builder's `build` section. **The existi - `npm run package:linux` produces working `.deb` and `.AppImage` artifacts on Linux - Locale pruning still strips Chromium translations (~45 MB savings) - The 4-part โ†’ 3-part version normalization still works -- Existing GAIA UI launches cleanly (no missing `electron-squirrel-startup` errors) +- Existing Gaia Agent UI launches cleanly (no missing `electron-squirrel-startup` errors) ### Phase E โ€” Installer assets and branding diff --git a/docs/reference/contributing-docs.mdx b/docs/reference/contributing-docs.mdx index 33eff0c7b..34b8a65f8 100644 --- a/docs/reference/contributing-docs.mdx +++ b/docs/reference/contributing-docs.mdx @@ -6,6 +6,10 @@ icon: "pen-to-square" This guide clarifies what goes where in GAIA documentation and how to contribute. + +**Contributing code or filing issues?** See [CONTRIBUTING.md](https://github.com/amd/gaia/blob/main/CONTRIBUTING.md) for the general contribution guide, including the requirement that every pull request links a GitHub issue. This page covers documentation contributions only. + + --- ## The Problem diff --git a/docs/releases/v0.17.5.mdx b/docs/releases/v0.17.5.mdx new file mode 100644 index 000000000..58b4a4a18 --- /dev/null +++ b/docs/releases/v0.17.5.mdx @@ -0,0 +1,177 @@ +--- +title: "v0.17.5" +description: "Gemma 4 default with native tool_calls, Chat Lite for low-memory hardware, semantic code search via CodeAgent, optional governance layer, Agent UI bundled in the PyPI wheel, and friendly ngrok tunnel diagnostics." +--- + +# GAIA v0.17.5 Release Notes + +GAIA v0.17.5 swaps the default model to Gemma 4 E4B, adds Chat Lite for machines that cannot host the 35B default, ships the Agent UI inside the PyPI wheel, and lands semantic code search and an optional governance layer. The C++ SDK gains VLM image support, mobile-tunnel diagnostics get a usability pass, and seven targeted bug fixes round out the patch. + +**Why upgrade:** +- **Gemma 4 E4B is the new default across LLM and VLM roles** โ€” single model in place of the previous LLM/VLM split, ~4.5B effective parameters, 128K context, ~5 GB footprint vs 19.7 GB previously. +- **Chat Lite makes the Agent UI usable on 8โ€“16 GB machines** โ€” a Qwen3-4B sibling of ChatAgent plus Settings controls for active model, context size, and per-agent memory warnings. +- **`pip install amd-gaia[ui]` now serves the real React UI** โ€” the wheel contains the built `dist/`, byte-identical to the npm package. +- **Semantic code search lands in CodeAgent** โ€” `gaia-code index` plus the `code_index` tool mixin for FAISS-backed search across your repo. + +--- + +## What's New + +### Gemma 4 E4B as the New Default Model + +Gemma 4 E4B (`Gemma-4-E4B-it-GGUF`) replaces Qwen 3.5 35B and the separate Qwen 3-VL-4B as the single default across the LLM and VLM roles, the installer profiles, the CLI, the Agent UI, and the eval suite (PR [#865](https://github.com/amd/gaia/pull/865)). Gemma 4 is natively multimodal at ~4.5B effective parameters with a 128K context window and an Apache 2.0 licence, so one model now covers what previously required loading two. The post-swap eval baseline beats the pre-swap Qwen baseline 14/15 vs 13/15 across the bundled scenarios. + +The minimum Lemonade version is now 10.2.0, and Lemonade's default port moves from 8000 to 13305 to match Lemonade's own default. A startup validator (`_validate_profile_model_registry()`) raises at import time if any `AGENT_PROFILES` entry references a model key that is not in `MODELS`. + +--- + +### Native OpenAI `tool_calls` Path + +GAIA now passes `tools=[...]` to Lemonade for tool-capable models and consumes the response as native OpenAI `tool_calls` (PR [#865](https://github.com/amd/gaia/pull/865)). `LemonadeProvider.chat()` encodes tool calls as a sentinel JSON string (`{"__tool_calls__": ...}`) so existing callers keep their type signatures, and `_parse_llm_response` detects the sentinel to return the unified `{"tool": ..., "tool_args": ...}` dict downstream agents already use. The embedded-JSON format block (`_PLANNING_FORMAT` / `_CONVERSATIONAL_FORMAT`) is now excluded from the composed system prompt for tool-capable models โ€” its presence actively prevented native `tool_calls` in prior testing. The legacy embedded-JSON path remains as a fallback for non-tool-calling models. + +--- + +### Chat Lite + Settings Controls + +`chat-lite` is a new built-in agent that reuses `ChatAgent` but presets `model_id` to `Qwen3-4B-Instruct-2507-GGUF`, providing a working out-of-the-box option for hardware that cannot host the 35B Chat default (PR [#802](https://github.com/amd/gaia/pull/802)). It appears alongside Chat in the agent picker. + +To make per-agent model swapping practical, three new Settings controls land in the Agent UI: + +- **Active Model** โ€” text field bound to the existing `custom_model` setting, with "Use agent default" as the placeholder. Empty falls through to the agent's registered `models[0]`. +- **Context Size** โ€” preset chips (4K / 8K / 16K / 32K) plus a numeric input; Apply reloads the active model via `/api/system/load-model`. +- **Memory Warnings** โ€” `AgentInfo.min_memory_gb` is a new optional field on registrations and manifests; Settings renders a warning before the user picks an agent whose requirement exceeds available memory. + +The pre-flight model loader in `_chat_helpers.py` now requires the specific expected model **with ctx โ‰ฅ 32K** rather than accepting any active LLM at any context size. This fixes the silent-truncation bug where Lemonade auto-loaded a requested model at its 4096 default context, truncating ChatAgent's >7K-token system prompt and producing an empty stream. + +--- + +### Semantic Code Search via CodeAgent + +`CodeIndexToolsMixin` adds FAISS-backed semantic search of a codebase to `CodeAgent` (PR [#721](https://github.com/amd/gaia/pull/721)). Four `@tool` methods (`index_codebase`, `search_code_index`, `get_index_status`, `clear_code_index`) compose into the agent via MRO, the same pattern as `RAGToolsMixin` and `FileIOToolsMixin`. The mixin is registered in `KNOWN_TOOLS` so other agents can opt in with `tools=["code_index"]`. + +The `gaia-code index` subcommand replaces the removed top-level `gaia index` verb; all index operations (`search`, `status`, `clear`, `chat`) now live under the existing `gaia-code` standalone binary. Indexing the GAIA repo itself produces 973 files โ†’ 24,349 semantic chunks using `nomic-embed-text-v2-moe-GGUF` via Lemonade Server. The `[code-index]` extras group has been folded into `[rag]`, so the install command is `pip install -e '.[rag]'`. + +--- + +### Agent UI Bundled in the PyPI Wheel + +`pip install amd-gaia[ui] && gaia chat --ui` now serves a real React UI instead of the JSON / friendly-fallback page (PR [#908](https://github.com/amd/gaia/pull/908)). `setup.py` adds `gaia.apps.webui` to packages with `package_data` globs, and `MANIFEST.in` adds the authoritative `recursive-include` for the built `dist/`. Local builds produce a 1.41 MB wheel containing the nine webui assets (index.html, hashed JS/CSS, woff2 fonts, favicon). + +The publish pipeline now builds the bundle once in `build-npm` and reuses the artifact in `build-pypi`, so the wheel and the npm package ship a byte-identical bundle (no vite-hash drift between runners). A new `util/verify_wheel_dist.py` enforces a deny-list at CI time: sourcemaps, dotfiles, `node_modules`, and leaked `VITE_*` env values, plus wheel-size caps. `setup.py` raises `SystemExit` with a remediation hint if a wheel build cannot find `dist/index.html`, except on the `sdist`, `egg_info`, `develop`, and `editable_wheel` paths used by `pip install -e .`. + +--- + +### Optional Governance Layer + +A new `gaia.governance` package adds an opt-in action-level governance layer for GAIA agents, with extension points for future workflow-level features (PR [#921](https://github.com/amd/gaia/pull/921)). The framework is modular: developers mix in `GovernedAgentMixin`, tag tools with risk levels, and configure a policy engine, reviewer, and audit log. `GaiaGovernanceAdapter` composes policy evaluation, checkpointing, receipt issuance, and policy-version binding into a single entry point, returning ALLOW / BLOCK / REVIEW decisions per tool call. + +The package ships with a comprehensive `README.md` and an `examples/governed_weather_agent.py` end-to-end demo. Because the layer is opt-in via mixin composition, existing agents are unaffected unless they explicitly enable it. + +--- + +### Agent Eval Toolchain + +The Agent Eval suite is now a complete toolchain (PR [#779](https://github.com/amd/gaia/pull/779)): `runner.py` accepts custom `--scenario-dir` / `--corpus-dir` paths, tag filtering via `--tag`, JUnit XML output (`--output-format junit`), and custom personas. The CLI sheds the legacy `gaia groundtruth`, `gaia report`, `gaia visualize`, `gaia create-template`, `gaia batch-experiment`, and `gaia synthetic-data` commands (~1,900 lines). 27 test classes cover the full public API surface (scenario loading, runner, scorecard, corpus, CLI, audit), and three new guides land under `docs/guides/eval.mdx` (Getting Started, Scenario Authoring, CI/CD Integration). Roughly 15,879 lines of dead code in the previous evaluator, groundtruth generator, batch experiment runner, transcript/email generators, fix-code testbench, and Express.js webapp are removed. + +--- + +### VLM Image Support in the C++ SDK + +The C++ SDK gains end-to-end vision support (PR [#858](https://github.com/amd/gaia/pull/858)). `gaia::Image` factories (`fromBytes` / `fromFile`) handle RFC 4648 base64 encoding, magic-byte MIME detection (PNG / JPEG / GIF / WebP / BMP), a 20 MiB size cap, and an `O_NOFOLLOW` + post-open `fstat` TOCTOU guard on POSIX. `gaia::ContentPart` adds text and `image_url` parts with `toJson()` producing the OpenAI vision wire format, and `gaia::Message` gains an additive `std::optional> parts` field that dispatches `toJson()` to array or string form โ€” fully backward-compatible with existing aggregate-init sites. + +Two new `processQuery` overloads (`string + vector` and `vector` caller-composed) flow through a private `processQueryInternal` that is the sole writer of `conversationHistory_`. Image parts are stripped from history at end-of-turn so base64 is never retained across calls. An RAII `InFlightGuard` via `std::atomic` and `compare_exchange_strong` makes concurrent `processQuery` calls on the same Agent throw `std::runtime_error`. The `cpp/examples/vlm_agent.cpp` demo plus 35 new unit tests (Image, ContentPart / Message, agent-level mock HTTP) cover the surface, alongside an integration test against live Lemonade. + +--- + +### Friendly ngrok Tunnel Diagnostics + Mobile Cookie Auth + +Mobile Access used to surface raw ngrok stderr (`ERR_NGROK_107`, `dial tcp ... no such host`, or in the worst case nothing) when a tunnel failed to start. PR [#872](https://github.com/amd/gaia/pull/872) parses every common ngrok failure into actionable guidance the modal renders verbatim. A preflight `_check_ngrok_authtoken_configured` honours `$NGROK_AUTHTOKEN` first, then v2 flat / v3 nested config layouts, and catches the unconfigured case before spawn. `_parse_ngrok_error` matches error codes plus English fragments and returns ready-to-paste install/config commands. + +The same PR adds an HttpOnly-cookie auth path so opening the QR-code URL in a mobile browser Just Works: `?token=` in the URL is converted to a `gaia_tunnel_token` cookie on the SPA landing response, so React's same-origin `fetch('/api/...')` is authenticated automatically. Bearer-header auth continues to work for headerful clients. Two correctness fixes ride along โ€” `pkill -f ngrok` becomes `pkill -x ngrok` (the broad form matched unrelated processes like `vim ngrok.md`), and operator-precedence parens are added to the network and TLS branches of `_parse_ngrok_error`. + +--- + +### YAML Manifest Agent Format Removed + +Custom agents now have one definition format: a Python `agent.py` file (PR [#914](https://github.com/amd/gaia/pull/914)). The previous YAML-manifest path with dynamic `type()`-based class construction, Pydantic manifest validation, and per-agent MCP-config merging is gone โ€” roughly 276 lines deleted from `src/gaia/agents/registry.py`. Every custom agent is now a regular Python class readable by mypy, IDEs, and `git grep`. + +The companion `agent.yaml` sidecar that declares `models:` next to a Python agent is unchanged. A directory containing only `agent.yaml` (no sibling `agent.py`) emits a `DeprecationWarning` and is skipped, with the warning enumerating which legacy manifest keys were ignored. `AgentRegistration.source` and `AgentInfo.source` are narrowed to `Literal["builtin", "custom_python"]`, with Pydantic enforcing the constraint at the API boundary. + +--- + +## Bug Fixes + +- **Agent UI fresh-install crash on first launch** (PR [#935](https://github.com/amd/gaia/pull/935)) โ€” Fixes a crash on the first launch after a fresh install where the webui server failed to initialise its database state before the renderer connected. +- **Chat agent reasoning loops on out-of-scope questions** (PR [#919](https://github.com/amd/gaia/pull/919)) โ€” The chat agent no longer enters reasoning loops or attempts to supplement an answer when the user's question falls outside the indexed corpus; it now returns a direct out-of-scope reply instead. +- **`code_index` silent fallbacks tightened to fail loudly** (PR [#885](https://github.com/amd/gaia/pull/885)) โ€” Replaces `except Exception: pass` blocks in the code-index path with specific exception handling that surfaces actionable errors, per the project's no-silent-fallbacks rule. +- **Installer sets Lemonade ctx-size on install and idle server** (PR [#913](https://github.com/amd/gaia/pull/913)) โ€” `gaia init` and the idle-server path now set Lemonade's `--ctx-size` so freshly installed setups don't auto-load models at the 4096 default and silently truncate large prompts. +- **AppImage RAG dependencies missing from `[ui]` extra** (PR [#911](https://github.com/amd/gaia/pull/911)) โ€” Adds the RAG dependencies to the `[ui]` extra so RAG works inside the AppImage build instead of failing with import errors at first use. +- **Linux Lemonade install switched from `.deb` to PPA** (PR [#910](https://github.com/amd/gaia/pull/910)) โ€” `gaia init` on Linux now installs Lemonade via the official PPA, which keeps the install up-to-date with `apt upgrade` and avoids stale `.deb` URL breakage. +- **Bundled small bug fixes from @CodeLine9** (PR [#813](https://github.com/amd/gaia/pull/813)) โ€” Aggregates a set of small correctness fixes originally proposed by @CodeLine9. + +--- + +## Release & CI + +- **Rendererโ†’backend port-wiring regression test** (PR [#909](https://github.com/amd/gaia/pull/909)) โ€” Adds coverage that pins the renderer-to-backend port wiring so future Electron-shell refactors cannot silently drift the two sides apart. +- **C++ memory-growth threshold widened to 75%** (PR [#874](https://github.com/amd/gaia/pull/874)) โ€” `memory_per_step_growth_kb` was tripping on legitimate small variations on shared CI runners; widening to 75% removes the false positives without masking real leaks. +- **Context7 + DeepWiki documentation steering** (PR [#864](https://github.com/amd/gaia/pull/864)) โ€” Adds CI steering files so external code-browsing tools can resolve GAIA documentation without scraping. + +--- + +## Docs + +- **`AGENTS.md` โ€” multi-agent coordination rules** (PR [#904](https://github.com/amd/gaia/pull/904)) โ€” New top-level document codifying how multiple agents collaborate within GAIA, intended for both contributors and external integrators. +- **Contributing templates and guide refresh** (PR [#930](https://github.com/amd/gaia/pull/930)) โ€” Updated issue templates, PR template, and `CONTRIBUTING.md` to match current project workflow and AI-agent guidance. +- **Removed RAUX / Open-WebUI references** (PR [#931](https://github.com/amd/gaia/pull/931)) โ€” Deployment docs no longer reference deprecated RAUX and Open-WebUI integrations. +- **Mobile UI design-system spec** (PR [#905](https://github.com/amd/gaia/pull/905)) โ€” New spec under `docs/spec/` covering the mobile UI tokens, components, and layout conventions used by the cookie-auth path. +- **Multi-Agent Architecture and Small Business Agent Team spec** (PR [#679](https://github.com/amd/gaia/pull/679)) โ€” Architectural spec for the multi-agent runtime and a worked example of a small-business agent team. +- **AXIS ร— GAIA integration report and phased plan** (PR [#852](https://github.com/amd/gaia/pull/852)) โ€” Plan document covering the AXIS integration's phasing. +- **Email and calendar integration presentation** (PR [#853](https://github.com/amd/gaia/pull/853)) โ€” Slide deck covering the email/calendar integration's design and roadmap. +- **Cleared stale YAML-manifest references after removal** (PR [#918](https://github.com/amd/gaia/pull/918)) โ€” Documentation cleanup following the YAML manifest deprecation in PR #914. + +--- + +## Breaking Changes + +- **YAML manifest agent format removed** (PR [#914](https://github.com/amd/gaia/pull/914)) โ€” Custom agents declared only via `agent.yaml` (no sibling `agent.py`) are no longer registered; a `DeprecationWarning` is emitted and the directory is skipped. Convert to a Python `agent.py` class. The `agent.yaml` sidecar that declares `models:` next to a Python agent is still supported. +- **`gaia index` top-level CLI removed** (PR [#721](https://github.com/amd/gaia/pull/721)) โ€” Use `gaia-code index` (and `search`, `status`, `clear`, `chat`) instead. +- **Eval CLI surface trimmed** (PR [#779](https://github.com/amd/gaia/pull/779)) โ€” `gaia groundtruth`, `gaia report`, `gaia visualize`, `gaia create-template`, `gaia batch-experiment`, and `gaia synthetic-data` are removed in favour of the consolidated `gaia eval` toolchain. +- **`[code-index]` extras folded into `[rag]`** โ€” Use `pip install -e '.[rag]'` instead of `pip install -e '.[code-index]'`. +- **Minimum Lemonade version is now 10.2.0**, and Lemonade's default port moves from 8000 to 13305. + +--- + +## Full Changelog + +**27 commits** since v0.17.4: + +- `ce9c808c` โ€” fix(webui): fix fresh-install crash on first launch (#934) (#935) +- `7bdf8bfa` โ€” docs(contributing): refresh issue/PR templates and contributing guide (#930) +- `a3b15267` โ€” docs(deployment): remove RAUX/Open-WebUI references from docs (#931) +- `db5e4c31` โ€” feat(ui): friendly ngrok tunnel diagnostics + cookie auth for mobile (#872) +- `2ec7fc71` โ€” Feat/optional governance layer (#921) +- `d8cf594c` โ€” fix(chat-agent): block reasoning loops + supplementation on out-of-scope questions (#919) +- `37e35eb1` โ€” feat(agents): add Chat Lite + Settings model/ctx/memory controls (#802) +- `f7b2e67f` โ€” docs(spec): add mobile UI design-system spec (#905) +- `99bea523` โ€” feat(eval): Agent Eval Toolchain โ€” v0.18.0 milestone (#779) +- `773b5e84` โ€” docs(agents): add AGENTS.md โ€” multi-agent coordination rules (#904) +- `046f50e0` โ€” ci(cpp): widen memory_per_step_growth_kb threshold to 75% (#874) +- `7e54e723` โ€” fix(code_index): tighten silent-fallback paths to fail loudly (#885) +- `667fa5ec` โ€” docs(agents): clear stale YAML-manifest references after #914 (#918) +- `098e08ec` โ€” refactor(agents): remove YAML manifest agent support (#912) (#914) +- `00bc8247` โ€” fix(installer): set Lemonade ctx-size on install and idle server (#839) (#913) +- `bcf69961` โ€” fix(packaging): add RAG deps to [ui] extra so AppImage RAG works (#911) +- `f83ea537` โ€” feat(packaging): ship Agent UI dist/ in PyPI wheel (#908) +- `fdf963dc` โ€” test(ui): regression coverage for rendererโ†’backend port wiring (#909) +- `fb297cab` โ€” fix(installer): switch Linux Lemonade install from .deb to PPA (#910) +- `5d377713` โ€” feat(llm): add Gemma 4 E4B as default and native tool_calls priority (#865) +- `ac437e58` โ€” feat(code-index): semantic code search via CodeAgent mixin and gaia-code CLI (#721) +- `610b2b57` โ€” ci: add Context7 + DeepWiki documentation steering (#864) +- `c677a911` โ€” feat(cpp): VLM image support in C++ SDK (#858) +- `def8adb7` โ€” docs(plans): AXIS ร— GAIA integration report and phased plan (#852) +- `f15f5664` โ€” docs(plans): email & calendar integration presentation (#853) +- `243b3fcb` โ€” spec: Multi-Agent Architecture + Small Business Agent Team (#679) +- `dd3e9cbd` โ€” fix: bundle small bug fixes originally submitted by @CodeLine9 (#813) + +Full Changelog: [v0.17.4...v0.17.5](https://github.com/amd/gaia/compare/v0.17.4...v0.17.5) diff --git a/docs/runbooks/google-oauth-client.md b/docs/runbooks/google-oauth-client.md new file mode 100644 index 000000000..fb62f7aa1 --- /dev/null +++ b/docs/runbooks/google-oauth-client.md @@ -0,0 +1,117 @@ +# Google OAuth Client โ€” Runbook + +**Owner:** GAIA team (file an issue โ†’ @kovtcharov-amd for changes). +**Audience:** GAIA core maintainers and CI operators. + +This runbook documents how the Google OAuth client used by +`gaia.connectors` is created, rotated, and consumed. It is **not** +user-facing โ€” end users never need to know the `client_id`. + +## What this client is + +A "Desktop app" OAuth 2.0 client registered in a Google Cloud project owned +by AMD. PKCE is used for the authorization code flow (no client secret). +Tokens are stored in the user's OS keychain by `gaia.connectors.store`; +nothing about the client travels with the user's data. + +## Configuration + +Set the environment variable before any GAIA process starts: + +```bash +export GAIA_GOOGLE_CLIENT_ID=".apps.googleusercontent.com" +``` + +The connections layer reads this at first use (`gaia.connectors.providers.get("google")`). +Missing โ†’ the layer raises `ConfigurationError`; the AgentUI surfaces a +503 on `/api/connections/*`, but the rest of the AgentUI keeps working +(per plan amendment A3). + +For development against personal Google accounts, register your own +desktop client in Google Cloud Console and set the env var to its id. +Do NOT commit the id into the repository. + +## Cloud Console setup + +1. Visit . +2. Create a new project (or use an existing AMD-owned one). +3. **APIs & Services โ†’ OAuth consent screen**: + - User Type: Internal (AMD-only) or External (broader). + - Add the scopes you intend to support: `gmail.readonly`, + `gmail.send`, `calendar.readonly`, `drive.readonly`, etc. + - For "External" + sensitive scopes, submit for verification (4โ€“6 wk). +4. **Credentials โ†’ Create Credentials โ†’ OAuth client ID**: + - Application type: **Desktop app**. + - Name: `GAIA Desktop` (or similar). +5. Copy the resulting client ID. There is no client secret in the desktop + flow โ€” PKCE replaces it. + +## Rotation procedure + +Rotation is **expected to invalidate every existing user's stored +refresh token** because the connections layer's `client_id_hash` tripwire +detects the mismatch and clears entries on next read. + +1. Create a new desktop client in Cloud Console (don't delete the old one yet). +2. Update `GAIA_GOOGLE_CLIENT_ID` everywhere (CI secrets, environment + files, internal docs). +3. Restart all GAIA processes. The lifespan tripwire sweep clears + stored entries that were bound to the old `client_id_hash`. +4. Users see a "Reconnect" prompt in AgentUI Settings โ†’ Connections (or + `gaia connectors connect google` from the CLI). They re-authorize. +5. Once all known users have reconnected (or after the soak window), + delete the old client in Cloud Console. + +What breaks during rotation: +- Active access tokens issued under the old `client_id` continue to work + until they expire (~1 hour). +- Refresh tokens issued under the old `client_id` are rejected by Google + with `invalid_grant`. The user reconnects; nothing else fails. +- Stored connection metadata (account email, scopes) is preserved at the + keyring level until the tripwire fires; then it's cleared. + +## Verification submission + +Sensitive scopes (`gmail.*`, `drive.*`, etc.) require Google's +verification before unverified users can authorize. Until then, only +test users listed on the consent screen can complete the OAuth flow. + +- **In-Cloud-Console flow:** OAuth consent screen โ†’ "PUBLISH APP" โ†’ + follow the form. Provide a privacy policy URL, demo video, and + scope justification. +- **Timeline:** 4โ€“6 weeks typical. +- **Until verified:** add internal QA accounts as test users so they + can complete the flow without seeing the "unverified app" warning. + +## Local development without a published client + +For day-to-day development: +1. Create a personal/test Google Cloud project. +2. Add your own Google account as a test user on the consent screen. +3. Use that project's desktop client id in `GAIA_GOOGLE_CLIENT_ID`. +4. The "unverified app" warning appears once per user; click "Continue" + to proceed. + +## Diagnostics + +Trouble: "Connect button does nothing in AgentUI." + +1. With `GAIA_DEBUG=1`, hit `GET /api/connections/_debug` โ€” returns + provider registration state, env-var presence, keyring backend, + grants-path writability, and in-flight flow count. +2. Check the AgentUI server log for "connections: tripwire sweep complete" + โ€” confirms lifespan fired. +3. If the loopback callback timed out: try a different port (the loopback + uses an ephemeral port โ€” `127.0.0.1:0` โ€” so this is rare; firewall + misconfig is the usual culprit). + +## Security boundaries + +- Refresh tokens NEVER cross the public Python API or the FastAPI router. +- The keyring backend allowlist (`PlaintextKeyring`/`EncryptedKeyring` + refused) prevents silent fallback to plaintext file storage on Linux + without SecretService. +- The `client_id_hash` is sha256 of the client id, NOT the client id + itself; it can be logged at INFO without leaking the client id. +- The OAuth `state` parameter is a per-flow random nonce compared via + `hmac.compare_digest`; mismatched callbacks return 400. diff --git a/docs/sdk/infrastructure/connectors.mdx b/docs/sdk/infrastructure/connectors.mdx new file mode 100644 index 000000000..2e544968d --- /dev/null +++ b/docs/sdk/infrastructure/connectors.mdx @@ -0,0 +1,218 @@ +--- +title: "Connectors SDK" +description: "OAuth + MCP server integrations for GAIA agents โ€” per-agent grants, catalog-driven config, keyring-backed secrets." +--- + +# Connectors SDK + +`gaia.connectors` is GAIA's external-integration layer. It manages two +kinds of connectors: + +- **OAuth (type `oauth_pkce`)** โ€” user-authorized flows (Google, etc.). + Stores refresh tokens in the OS keyring. +- **MCP server (type `mcp_server`)** โ€” API-key-based connections to + third-party MCP servers (GitHub, Brave Search, etc.). Stores keys in + the OS keyring as `$keyring` references. + +Three caller surfaces share the same keyring and grants file: +**SDK** (direct Python import), **CLI** (`gaia connectors โ€ฆ`), and +**Agent UI** (Settings โ†’ Connectors page). + +## Catalog + +The catalog is populated at import time by `gaia.connectors.catalog`. +Every connector is a `ConnectorSpec`: + +```python +from gaia.connectors.registry import REGISTRY + +for spec in REGISTRY.all(): + print(spec.id, spec.type, spec.display_name) +# google oauth_pkce Google +# github mcp_server GitHub +# brave mcp_server Brave Search +# ... +``` + +Catalog entries cover 23 connectors across `core`, `productivity`, +`dev`, and `search` tiers. + +## SDK use โ€” OAuth + +```python +import asyncio +import gaia.connectors as conn + + +async def main(): + # 1. Run the OAuth PKCE flow (opens system browser). + info = await conn.start_authorization( + "google", + scopes=["https://www.googleapis.com/auth/gmail.readonly"], + ) + print("Open this URL:", info["authorization_url"]) + state = await conn.complete_authorization(info["flow_id"]) + print("Connected as", state["account_email"]) + + # 2. Grant a named agent the scopes it needs. + conn.grant_agent( + connector_id="google", + agent_id="my-agent", + scopes=["https://www.googleapis.com/auth/gmail.readonly"], + ) + + # 3. Fetch a short-lived access token (refresh is automatic). + token = await conn.get_access_token( + connector_id="google", + scopes=["https://www.googleapis.com/auth/gmail.readonly"], + agent_id="my-agent", + ) + print("Bearer token:", token[:8], "โ€ฆ") + + +asyncio.run(main()) +``` + +`get_access_token` raises `AuthRequiredError` on four failure modes: + +| Reason | Cause | +|---|---| +| `NOT_CONNECTED` | No OAuth grant exists for this connector. | +| `AGENT_NOT_GRANTED` | This agent has no per-agent scope grant. | +| `CONNECTION_MISSING_SCOPES` | Grant exists but covers fewer scopes. | +| `REAUTH_REQUIRED` | OAuth client ID was rotated. | + +## SDK use โ€” MCP server + +MCP server connectors are configured once and then provide their API +keys via the `get_credential` dispatcher: + +```python +from gaia.connectors.handler import configure, get_credential + +# Configure โ€” stores the key in the OS keyring. +await configure("github", {"GITHUB_TOKEN": "ghp_..."}) + +# Retrieve โ€” used by the MCP bridge to launch the server. +creds = await get_credential("github") +# {"GITHUB_TOKEN": "ghp_..."} +``` + +The MCP bridge injects these credentials as environment variables when +it launches the MCP server process. + +## CLI use + +```bash +# OAuth: connect and authorize +gaia connectors connect google \ + --scopes https://www.googleapis.com/auth/gmail.readonly + +# MCP: supply API key(s) +gaia connectors configure github --set GITHUB_TOKEN=ghp_โ€ฆ +gaia connectors configure brave --set BRAVE_API_KEY=BSAโ€ฆ + +# Check status of all connectors +gaia connectors status +# google [oauth_pkce] configured (you@gmail.com) +# github [mcp_server] configured +# brave [mcp_server] not configured + +# Test health of a configured connector +gaia connectors test github + +# Per-agent grants (OAuth only) +gaia connectors grants grant google builtin:chat \ + --scopes https://www.googleapis.com/auth/gmail.readonly +gaia connectors grants list google +gaia connectors grants revoke google builtin:chat + +# Disconnect +gaia connectors disconnect google +``` + +## Agent-author guide + +Declare the connectors your agent needs as `REQUIRED_CONNECTORS`: + +```python +from typing import ClassVar, List +from gaia.agents.base.agent import Agent +from gaia.connectors import ConnectorRequirement, get_access_token_sync +from gaia.agents.base.tools import tool + + +class GmailAgent(Agent): + AGENT_ID = "gmail_demo" + AGENT_NAME = "Gmail Demo" + AGENT_DESCRIPTION = "Lists 5 newest Gmail subjects." + CONVERSATION_STARTERS = ["List my newest emails"] + + REQUIRED_CONNECTORS: ClassVar[List[ConnectorRequirement]] = [ + ConnectorRequirement( + connector_id="google", + scopes=["https://www.googleapis.com/auth/gmail.readonly"], + reason="Read your inbox to summarize the 5 newest messages", + ), + ] + + def _register_tools(self): + self._register("list_recent_subjects", self._list_recent_subjects) + + @tool(description="List the 5 newest Gmail subjects") + def _list_recent_subjects(self) -> list[str]: + token = get_access_token_sync( + connector_id="google", + scopes=["https://www.googleapis.com/auth/gmail.readonly"], + ) + import requests + r = requests.get( + "https://gmail.googleapis.com/gmail/v1/users/me/messages", + params={"maxResults": 5}, + headers={"Authorization": f"Bearer {token}"}, + timeout=10, + ) + r.raise_for_status() + return [m["id"] for m in r.json().get("messages", [])] +``` + +The Agent UI consent dialog renders `reason` in plain language. After +the user grants the scopes, subsequent `get_access_token_sync` calls +return fresh tokens transparently. + +## Where things live + +| Path | Contents | +|------|----------| +| `~/.gaia/connectors/state.json` | Non-secret connector metadata (configured, account_id, scopes). Mode 0600. | +| `~/.gaia/connectors/grants.json` | Per-agent scope grants. Mode 0600. | +| OS keychain `gaia.connections` | Encrypted refresh tokens + MCP API keys. | + +## Adding a new OAuth provider + +1. Create `src/gaia/connectors/providers/.py` satisfying the + `OAuthProvider` protocol (auth/token URLs, client env vars, etc.). +2. Register in `src/gaia/connectors/providers/__init__.py:get`. +3. Add a `ConnectorSpec` entry in `src/gaia/connectors/catalog.py`. +4. Add unit tests under `tests/unit/connectors/test_providers.py`. + +## Adding a new MCP server connector + +Add one entry to the `_MCP_CATALOG` list in +`src/gaia/connectors/catalog.py`: + +```python +ConnectorSpec( + id="my-service", + display_name="My Service", + type="mcp_server", + category="dev", + tier="community", + mcp_command=["npx", "-y", "@my/mcp-server"], + mcp_env_keys=["MY_SERVICE_API_KEY"], + description="Integrates My Service via MCP.", +), +``` + +The MCP bridge will inject `MY_SERVICE_API_KEY` from the keyring as an +environment variable when launching the server process. diff --git a/docs/sdk/sdks/agent-ui.mdx b/docs/sdk/sdks/agent-ui.mdx index cb852efe9..36ece1f8e 100644 --- a/docs/sdk/sdks/agent-ui.mdx +++ b/docs/sdk/sdks/agent-ui.mdx @@ -637,6 +637,7 @@ class AttachDocumentRequest(BaseModel): | `tool_args` | `tool` (string), `args` (object), `detail` (string) | Tool arguments. `args` is the raw arguments dict passed to the tool. `detail` is a formatted human-readable summary of the arguments. | | `tool_end` | `success` (boolean) | Tool invocation completed. | | `tool_result` | `title` (string or null), `summary` (string), `success` (boolean), `result_data` (object or null), `command_output` (object or null) | Tool result with structured data. `summary` is a human-readable result. `result_data` contains typed results (see below). `command_output` contains shell command output (see below). | + | `policy_alert` | `tool` (string), `decision` (`"BLOCK"`), `reason` (string), `rule_ids` (string[]), `policy_version` (string), `receipt_id` (string, optional) | Governance policy blocked a tool before execution. No user action is required; use this to show a visible policy refusal instead of treating the denial as a generic tool failure. | `result_data` variants in `tool_result`: - **File list:** `{"type": "file_list", "files": [...], "total": int}` -- up to 20 file entries diff --git a/docs/sdk/sdks/governance.mdx b/docs/sdk/sdks/governance.mdx new file mode 100644 index 000000000..30f148603 --- /dev/null +++ b/docs/sdk/sdks/governance.mdx @@ -0,0 +1,165 @@ +--- +title: "Governance: Optional Policy Layer for Agents" +--- + + + **Source Code:** [`src/gaia/governance/`](https://github.com/amd/gaia/blob/main/src/gaia/governance/) + + +The governance layer is an **opt-in** module that intercepts every tool call and +applies a policy decision (ALLOW / BLOCK / REVIEW) before the tool runs. It adds +zero overhead when not activated. + +## Quick start + +```python +from gaia import Agent, tool +from gaia.governance import GaiaGovernanceAdapter, GovernedAgentMixin, govern + + +@tool +@govern(risk="blocked", reason="destructive") +def wipe_disk() -> dict: + return {"status": "ok"} + + +class MyAgent(GovernedAgentMixin, Agent): + ... + + +agent = MyAgent(governance_adapter=GaiaGovernanceAdapter.default()) +``` + +When the model calls `wipe_disk`, governance short-circuits the call, +issues a signed receipt to `receipts.jsonl`, and returns a denied result. + +## Decision outcomes + +| Decision | Effect | +|---|---| +| `ALLOW` | Tool runs as usual. | +| `BLOCK` | Tool is refused. A receipt is written with the full evidence envelope. | +| `REVIEW` | A checkpoint is opened. Governance calls your `governance_reviewer` callback, or Agent UI's blocking confirmation modal when that is the active console. APPROVE -> tool runs; REJECT -> tool is refused. Either way a receipt is written. | + +If `REVIEW` fires and neither a reviewer nor a blocking console is available, +the mixin **fails closed** โ€” the tool is denied without executing. + +## Tagging tools + +**Decorator style** (colocates policy with the tool): + +```python +@tool +@govern(risk="review", reason="sends money") +def transfer(amount: float): ... +``` + +**Dict style** (centralizes policy on the agent): + +```python +agent = MyAgent( + governance_adapter=GaiaGovernanceAdapter.default(), + governance_risk_tags={"transfer": ["review"]}, +) +``` + +Tags from both sources are **additive** (union, deduplicated): decorator tags come +first, then dict tags are appended. A tool with `"review"` from a decorator and +`"blocked"` from the dict will carry both tags. + +## Configuration + +```python +from gaia.governance import GovernanceConfig + +# Structured config object +agent = MyAgent(governance=GovernanceConfig( + adapter=GaiaGovernanceAdapter.default(), + actor_id="alice", + workflow_id="session-42", + risk_tags={"delete_record": ["blocked"]}, + reviewer=my_reviewer, +)) + +# Individual kwargs (equivalent) +agent = MyAgent( + governance_adapter=GaiaGovernanceAdapter.default(), + governance_actor_id="alice", + governance_risk_tags={"delete_record": ["blocked"]}, + governance_reviewer=my_reviewer, +) +``` + +## Reviewers + +```python +def my_reviewer(tool_name, tool_args, decision) -> bool: + return input(f"approve {tool_name}? [y/N]: ") == "y" + +agent = MyAgent( + governance_adapter=GaiaGovernanceAdapter.default(), + governance_reviewer=my_reviewer, +) +``` + +An explicit `governance_reviewer` takes precedence. If none is configured, +governance delegates to `console.confirm_tool_execution` only when the console +advertises `blocking_confirmation = True`; Agent UI's `SSEOutputHandler` does +this and emits the existing `permission_request` modal. GAIA's default console is +not consulted because its confirmation method auto-approves. + +When a policy returns `BLOCK`, the governed tool body is not executed and the +adapter writes a BLOCK receipt. If the active console supports +`print_policy_alert`, GAIA also emits a user-visible policy alert. Agent UI's +`SSEOutputHandler` sends this as a `policy_alert` SSE event with the blocked +tool, decision, reason, rule IDs, policy version, and receipt ID. + +## Observability callbacks + +```python +def on_decision(tool_name, tool_args, action, decision): + print(f"{tool_name}: {decision.decision}") + +agent = MyAgent( + governance_adapter=GaiaGovernanceAdapter.default(), + governance_callback=on_decision, +) +``` + +Callback exceptions are logged as warnings and never interrupt tool execution. + +## Security properties + +- **Canonical name resolution** โ€” governance resolves registered tool names before + checking risk tags, so an LLM cannot bypass a tag on `mcp_time_get_current_time` + by calling the alias `get_current_time`. +- **Envelope-bound receipts** โ€” each receipt's `payload_hash` is a SHA-256 of the + full evidence envelope (action, decision, policy version, constitution hash, actor, + timestamp) in strict canonical JSON. Any tampered field changes the hash. +- **Workflow-bound checkpoints** โ€” the adapter refuses to resolve a checkpoint under + a `workflow_id` that differs from the one recorded when the checkpoint was opened. +- **Fail-closed REVIEW** โ€” no reviewer registered means deny. + +## Extension points + +| Interface | Shipped reference | Swap with | +|---|---|---| +| `PolicyEngine` | `RuleBasedPolicyEngine` | ACGS-lite, LLM judge, OPA | +| `CheckpointRuntime` | `InMemoryCheckpointBridge` | constitutional-swarm checkpoint service | +| `ReceiptServiceProtocol` | `InMemoryReceiptService` / `JsonlReceiptService` | DB, log forwarder, chain anchor | +| `PolicyBindingProtocol` | `StaticPolicyBindingService` | constitutional-swarm policy control plane | + +All four are `@runtime_checkable` Protocols โ€” no inheritance required. + +## Audit log + +`JsonlReceiptService` writes one JSON object per line to a path you choose +(`receipts.jsonl` by default). The log survives process exit and is trivially +`grep`-able: + +```bash +grep '"decision":"BLOCK"' receipts.jsonl | jq . +``` + +For multi-process deployments, replace `JsonlReceiptService` with a dedicated log +forwarder or database-backed receipt service. diff --git a/docs/security/connections.mdx b/docs/security/connections.mdx new file mode 100644 index 000000000..b4a3f2fc1 --- /dev/null +++ b/docs/security/connections.mdx @@ -0,0 +1,60 @@ +--- +title: "Connections Security Model" +description: "How GAIA stores credentials, enforces per-agent grants, and protects against unauthorized access." +icon: "shield" +--- + +## Credential storage + +GAIA never writes tokens or API keys to plaintext files. All secrets live exclusively in your OS credential store: + +| Platform | Store | +|---|---| +| macOS | Keychain | +| Linux | gnome-keyring or kwallet | +| Windows | Credential Locker | + +Each connector occupies a dedicated keyring slot keyed by `gaia.connections::`. MCP server tokens use `$keyring:` references in `~/.gaia/mcp_servers.json` โ€” the JSON file itself contains no actual secrets. + +OAuth refresh tokens and MCP server API keys are AES-256 encrypted by the OS keyring at rest and decrypted in memory only when a tool call needs them. + +## Per-agent grant model + +Connecting a service (e.g. Google) does **not** give every agent access to it. Access is gated at two levels: + +1. **Connection** โ€” you store a credential once in the keyring (OAuth refresh token or PAT). +2. **Grant** โ€” you explicitly allow a specific agent to use that credential for a specific scope. + +``` +User โ†’ connects Google once +User โ†’ grants chat-agent gmail.readonly +User โ†’ grants my-research-agent gmail.readonly + drive.readonly +``` + +An agent that calls `get_credential_sync("google", agent_id=..., required_scopes=["gmail.readonly"])` without a matching grant receives `AuthRequiredError(reason=AGENT_NOT_GRANTED)` and cannot proceed. No token is ever returned to an ungrantedn agent. + +Grants are stored in `~/.gaia/connectors/grants.json` โ€” a flat file that is **not** a secret store. It contains agent IDs and scope names, not credentials. + +## Revocation + +You can revoke access at any level: + +| Action | Effect | +|---|---| +| Settings โ†’ Connections โ†’ \ โ†’ **Disconnect** | Removes token from keyring; all agent calls fail with `NOT_CONNECTED` | +| `gaia connectors grants revoke ` | Removes the per-agent grant; that agent's calls fail with `AGENT_NOT_GRANTED` | +| Revoke the PAT/OAuth client at the provider | Invalidates the token at the source; GAIA's next API call surfaces the provider's error | + +## Threat model + +| Threat | Mitigation | +|---|---| +| Malicious process reads `mcp_servers.json` | File contains only `$keyring:...` references, never raw tokens | +| Malicious agent requests a credential it wasn't granted | `get_credential_sync` checks the grants ledger before returning; unapproved calls raise `AuthRequiredError` | +| Token leak via logging | Connector code never logs token values; credentials are redacted before any log statement | +| Token exfiltration via a rogue custom agent | Custom agents run in the same process as GAIA โ€” they are trusted code you install yourself, analogous to a browser extension | + +## See also + +- [Connectors overview](/connectors) +- [Connectors SDK](/sdk/infrastructure/connectors) diff --git a/docs/security/connectors.mdx b/docs/security/connectors.mdx new file mode 100644 index 000000000..ea97f5261 --- /dev/null +++ b/docs/security/connectors.mdx @@ -0,0 +1,139 @@ +--- +title: "Connections โ€” security model" +description: "OAuth connection threat model, refresh-token hygiene, and what GAIA does and does not protect against." +--- + +# Connections โ€” security model + +GAIA's `gaia.connectors` package implements OAuth 2.0 PKCE +(RFC 7636/8252) for desktop authorization flows. This page describes the +threat model, what we protect against, and the residual risks a user or +operator should know about. + +## What we protect + +**Refresh tokens never leave the OS credential store.** The +`gaia.connectors.store` module writes to: + +- macOS Keychain (built-in) +- Windows Credential Locker (built-in) +- Linux SecretService (gnome-keyring or kwallet) + +Plaintext fallbacks (`keyrings.alt.PlaintextKeyring`, +`EncryptedKeyring`) are explicitly **refused** at the entry of every +save and load (plan amendment A4). A Linux user without SecretService +sees an actionable error pointing at this page rather than silently +writing tokens to disk. + +**Refresh tokens never cross a public API or response body.** The public +`gaia.connectors.list_connections()` returns only metadata +(`provider`, `account_email`, `scopes`, `connected_at`). The FastAPI +router enforces the same boundary on every JSON response. The OpenAPI +schema is exercised in `tests/unit/connectors/test_secret_hygiene.py` +to enforce this in CI. + +**OAuth state parameter is per-flow random and compared in +constant time.** `state = secrets.token_urlsafe(32)`; the callback +handler compares received state with `hmac.compare_digest`. Mismatched +or missing state returns 400 with a static error page โ€” no echoed user +input. + +**Loopback redirect on `127.0.0.1`, not `localhost`.** Prevents DNS +rebinding. Bound on an ephemeral port (`port=0`) per flow. + +**Two-layer authorization for `get_access_token`.** Before any access +token is returned to a tool body: + +1. The named agent must have a per-agent grant covering the requested + scopes (in `~/.gaia/connectors/grants.json`). +2. The stored OAuth connection must actually carry those scopes + (the user authorized them). + +A missing grant raises `AuthRequiredError(AGENT_NOT_GRANTED)`; a +missing OAuth scope raises `AuthRequiredError(CONNECTION_MISSING_SCOPES)`. +Either is surfaced to the user; nothing falls through silently. + +**Eager `client_id_hash` tripwire.** Every load of a stored connection +verifies that the OAuth client id under which it was issued matches +the current configuration. A mismatch (e.g. after rotation) clears the +stored entry and raises `AuthRequiredError(REAUTH_REQUIRED)`. Users +reconnect explicitly; we never use a stale connection. + +## What we do not protect + +**A malicious agent that the user has explicitly granted a scope can +use that scope.** This is by design โ€” the user gave the agent the +keys. The grants ledger and the AgentUI consent dialog exist so that +"explicitly" is a high bar. Operators concerned about prompt-injection- +driven scope escalation should review: + +- The agent's `REQUIRED_CONNECTORS` declaration before granting (visible + in the consent dialog). +- The CLI grants list (`gaia connectors grants list google`) at any time. +- Periodic revocation of unused grants + (`gaia connectors grants revoke `). + +**A custom agent that ships its own `agent.py` can call +`get_access_token_sync` directly.** That call still goes through the +two-layer authorization check. To bypass it the agent would need to +forge an agent identity, which is why `_agent_context` is private (plan +amendment A9). The grant-ledger key for custom agents is +`custom::`, so a custom agent that changes its +code gets a new key and cannot inherit a previous grant. + +**An attacker with read access to the user's home directory can read +`~/.gaia/connectors/grants.json`.** Grants are per-agent scope +declarations only โ€” they do NOT contain tokens. Tokens are in the OS +keychain, which is encrypted by the OS (Keychain on macOS, DPAPI on +Windows, SecretService on Linux). The grants file is mode 0600 and the +parent directory 0700 on POSIX systems. + +**An attacker with read access to the OS keychain.** Refresh tokens are +visible to a process with the user's keychain access (e.g. malware +running as the user). This is the limit of OS-level credential storage +and is the same posture as every other app that stores OAuth tokens +locally (browsers, native mail clients, etc.). + +**Concurrent processes refreshing the same provider's token.** Two GAIA +processes running as the same user (e.g. `gaia chat --ui` and +`gaia connectors status`) each maintain their own in-memory access- +token cache and share the keyring. If both refresh concurrently and +Google rotates the refresh token, one process may observe +`invalid_grant` and the user reconnects transparently. We do not yet +implement inter-process locking. Track this in the followup issues if +the failure becomes common. + +## Threats considered + +| Threat | Mitigation | +| -------------------------------- | -------------------------------------------------------------------------------------- | +| Refresh-token exfiltration | Tokens never leave OS keychain; refused plaintext fallback | +| `state` CSRF on callback | Random `state` + `hmac.compare_digest`; missing state โ†’ 400 | +| XSS on success page | Static HTML literal โ€” no echoed user input | +| Prompt-injection scope escalation | Per-agent grants gate every token fetch; no implicit consent | +| Malicious custom agent claim AGENT_ID | Reserved built-in IDs blocked; grants keyed by `(origin_hash, id)` | +| Forged agent identity in tool body | `_agent_context` is private (not in `gaia.connectors.__init__`) | +| Keychain backend regression | Backend allowlist refuses `Plaintext*Keyring` / `Encrypted*Keyring` | +| Stale token after rotation | Eager `client_id_hash` tripwire on every load | +| Inconsistent rotation write | Single keyring slot per connection, atomic backend overwrite | +| Loopback hijack | `127.0.0.1` literal binding (not `localhost`); single-shot listener | +| Browser-open blocks event loop | `webbrowser.open` dispatched via `run_in_executor` | +| Logged refresh token | No log statement names the value; cross-cutting `test_secret_hygiene.py` | + +## Operator checklist + +- [ ] `GAIA_GOOGLE_CLIENT_ID` is set in every environment that runs GAIA. +- [ ] OS credential store is configured (built-in on macOS/Windows; + `gnome-keyring` or `kwallet` on Linux). +- [ ] Production users do not see `keyrings.alt` plaintext fallback โ€” + if they do, the connections module raises `ConnectorsError` with + this page in the message. +- [ ] Periodically review `~/.gaia/connectors/grants.json` for + unexpected grants. The CLI command + `gaia connectors grants list google` enumerates them. +- [ ] Privacy policy is published and linked from the OAuth consent + screen (required for Google verification). +- [ ] Sensitive-scope verification is submitted for the production + client id (4โ€“6 wk timeline). + +See also: [Google OAuth Client runbook](../runbooks/google-oauth-client.md). diff --git a/examples/governed_weather_agent.py b/examples/governed_weather_agent.py new file mode 100644 index 000000000..165d3d0be --- /dev/null +++ b/examples/governed_weather_agent.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Governed Weather Agent Example. + +Same as :mod:`examples.weather_agent` but wraps every tool call through +a :class:`GaiaGovernanceAdapter`. The adapter is composed from the +in-repo reference implementations (stub policy engine, in-memory +checkpoint bridge / receipt service / static policy binding) so the +example runs with zero external services. + +This example registers two **local tools** (alongside the open-meteo +MCP tools) so governance decisions are guaranteed to trigger: + +* ``clear_weather_cache`` โ€” tagged ``blocked``. When the LLM calls + this tool, governance short-circuits with a BLOCK decision, issues + a signed receipt, and the tool body never runs. +* ``subscribe_weather_alerts`` โ€” tagged ``review``. Governance opens + a checkpoint and asks the configured reviewer (the CLI prompt in + ``_cli_reviewer`` below). On approve the tool runs; on reject it is + refused. Either way the resolution is logged to the receipt store. + +Run:: + + uv run examples/governed_weather_agent.py + +Say "clear the weather cache please" to see a BLOCK decision, or +"subscribe me to severe weather alerts for Austin" to see a REVIEW +decision, or ask any normal weather question to see ALLOW decisions on +the MCP tools. + +The base ``Agent`` class is **not modified**. Governance is composed +onto the agent via :class:`GovernedAgentMixin`. +""" + +from gaia import Agent, tool +from gaia.governance import ( + GaiaGovernanceAdapter, + GovernedAgentMixin, +) +from gaia.governance.checkpoint_bridge import InMemoryCheckpointBridge +from gaia.governance.policy_binding import StaticPolicyBindingService +from gaia.governance.receipt_service import JsonlReceiptService +from gaia.governance.stubs import RuleBasedPolicyEngine +from gaia.mcp import MCPClientMixin +from gaia.mcp.client.config import MCPConfig +from gaia.mcp.client.mcp_client_manager import MCPClientManager + +# Append-only audit log. Tail with `tail -f receipts.jsonl` to watch +# decisions live while the agent runs. +RECEIPTS_PATH = "receipts.jsonl" +# --- Local tools that will actually be reachable by the LLM -------------- + + +@tool +def clear_weather_cache() -> dict: + """Destructively clear all cached weather data. + + Use this when the user explicitly asks to reset, clear, or purge + the weather cache. + """ + # Body only executes if governance ALLOWs. With the default adapter + # this tool is risk-tagged "blocked" and never runs. + return {"status": "ok", "message": "weather cache cleared"} + + +@tool +def subscribe_weather_alerts(location: str, severity: str = "severe") -> dict: + """Subscribe the user to recurring weather alerts for a location. + + Use this when the user asks to be notified, subscribed, or alerted + about weather conditions at a specific location. + """ + return { + "status": "ok", + "message": f"subscribed to {severity} alerts for {location}", + } + + +# --- Agent ----------------------------------------------------------------- + + +class WeatherAgent(Agent, MCPClientMixin): + """Base weather agent โ€” mirrors examples/weather_agent.py.""" + + WEATHER_SERVER = { + "name": "weather", + "config": { + "command": "uvx", + "args": ["--from", "open-meteo-mcp", "open_meteo_mcp"], + }, + } + + def __init__(self, **kwargs): + self._mcp_manager = MCPClientManager(config=MCPConfig(config_file=None)) + kwargs.setdefault("model_id", "Qwen3-4B-Instruct-2507-GGUF") + kwargs.setdefault("max_steps", 10) + super().__init__(**kwargs) + + def _get_system_prompt(self) -> str: + return ( + "You are a helpful weather assistant. Use the available MCP " + "weather tools to answer weather questions. You also have two " + "local tools:\n" + "- clear_weather_cache: call this if the user asks to reset " + "or clear the cache.\n" + "- subscribe_weather_alerts: call this if the user asks to " + "be notified or subscribed to alerts for a location." + ) + + def _register_tools(self) -> None: + print("Connecting to MCP weather server...") + success = self.connect_mcp_server( + self.WEATHER_SERVER["name"], self.WEATHER_SERVER["config"] + ) + print(" Connected" if success else " Failed to connect") + + +class GovernedWeatherAgent(GovernedAgentMixin, WeatherAgent): + """Weather agent with governance wired in via the mixin.""" + + +# --- Adapter + demo wiring ------------------------------------------------ + + +def build_default_adapter() -> GaiaGovernanceAdapter: + """Compose an adapter using the in-repo reference implementations.""" + return GaiaGovernanceAdapter( + policy_engine=RuleBasedPolicyEngine(policy_version="v0"), + checkpoint_runtime=InMemoryCheckpointBridge(), + receipt_service=JsonlReceiptService(RECEIPTS_PATH), + policy_binding=StaticPolicyBindingService( + version="v0", constitution_hash="constitution-dev" + ), + ) + + +def _cli_reviewer(tool_name, tool_args, decision) -> bool: + """Interactive CLI reviewer for REVIEW decisions. + + Used when the GAIA console's confirmation surface isn't available. + Returning False fails the tool closed. + """ + print( + f"\n[review] tool={tool_name!r} args={tool_args!r} " + f"reason={decision.reason!r}" + ) + answer = input("[review] approve? [y/N]: ").strip().lower() + return answer in ("y", "yes") + + +DEFAULT_RISK_TAGS = { + "clear_weather_cache": ["blocked"], + "subscribe_weather_alerts": ["review"], +} + + +def _log_decision(tool_name, _tool_args, _action, decision): + print( + f"[governance] tool={tool_name!r} decision={decision.decision} " + f"reason={decision.reason!r} policy={decision.policy_version}" + ) + + +def main(): + print("=" * 60) + print("Governed Weather Agent โ€” ACGS-lite action governance demo") + print("=" * 60) + print( + "\nTry:\n" + " - 'What is the weather in Austin?' (ALLOW)\n" + " - 'Subscribe me to alerts for Seattle.' (REVIEW)\n" + " - 'Clear the weather cache please.' (BLOCK)\n" + ) + + adapter = build_default_adapter() + + try: + agent = GovernedWeatherAgent( + governance_adapter=adapter, + governance_actor_id="demo-user", + governance_workflow_id="wf_demo", + governance_risk_tags=DEFAULT_RISK_TAGS, + governance_callback=_log_decision, + governance_reviewer=_cli_reviewer, + ) + print(f"Governed Weather Agent ready. Audit log: {RECEIPTS_PATH}\n") + except Exception as exc: # pylint: disable=broad-exception-caught + # Demo harness: report any startup failure (Lemonade, uvx, MCP) + # as a single friendly message instead of a traceback. + print(f"Error initializing agent: {exc}") + print( + "Make sure Lemonade server is running and `uv` is installed " + "so `uvx` can fetch the weather MCP server." + ) + return + + while True: + try: + user_input = input("You: ").strip() + if not user_input: + continue + if user_input.lower() in ("quit", "exit", "q"): + print("Goodbye!") + break + result = agent.process_query(user_input) + if result.get("result"): + print(f"\nAgent: {result['result']}\n") + except (EOFError, KeyboardInterrupt): + print("\nGoodbye!") + break + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index 82f0f7cbe..c76774243 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,8 @@ "gaia.talk", "gaia.testing", "gaia.utils", + "gaia.filesystem", + "gaia.scratchpad", "gaia.apps", "gaia.apps.docker", "gaia.apps.jira", @@ -72,14 +74,17 @@ "gaia.agents.routing", "gaia.agents.sd", "gaia.agents.summarize", + "gaia.governance", "gaia.sd", "gaia.vlm", "gaia.api", - "gaia.filesystem", - "gaia.scratchpad", "gaia.web", "gaia.code_index", "gaia.apps.webui", + "gaia.connectors", + "gaia.connectors.catalog", + "gaia.connectors.providers", + "gaia.agents.connectors_demo", ], package_data={ "gaia.eval": [ @@ -133,6 +138,10 @@ "python-multipart>=0.0.9", "httpx>=0.27.0", "psutil>=5.9.0", + # OAuth connections (issue #915): keyring stores refresh tokens in + # the OS credential store (macOS Keychain, Windows DPAPI, Linux + # SecretService). Pinned upper bound per supply-chain advisory. + "keyring>=24.0.0,<26.0.0", # RAG runtime deps โ€” gaia.ui.server boots faiss + sentence_transformers # eagerly, and gaia.rag.sdk uses pypdf/pymupdf/numpy. See #845. # Version specifiers match the standalone "rag" extra; "ui" @@ -181,7 +190,12 @@ "bandit", "responses", "requests", - "beautifulsoup4", + # gaia.connectors runtime deps surfaced in [dev] so that + # `pip install -e ".[dev]"` is sufficient to run the unit suite + # without pulling in the much heavier [ui] extra (faiss, torch). + "httpx>=0.27.0,<0.29.0", + "respx>=0.21.0,<0.23.0", + "keyring>=24.0.0,<26.0.0", ], "eval": [ "anthropic", diff --git a/src/gaia/agents/base/agent.py b/src/gaia/agents/base/agent.py index b896b3d88..79bd97916 100644 --- a/src/gaia/agents/base/agent.py +++ b/src/gaia/agents/base/agent.py @@ -4,6 +4,8 @@ Generic Agent class for building domain-specific agents. """ +from __future__ import annotations + # Standard library imports import abc import ast @@ -15,7 +17,7 @@ import re import subprocess import uuid -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional from gaia.agents.base.console import AgentConsole, SilentConsole from gaia.agents.base.errors import format_execution_trace @@ -24,6 +26,9 @@ # First-party imports from gaia.chat.sdk import AgentConfig, AgentSDK +if TYPE_CHECKING: + from gaia.connectors.providers.base import ConnectorRequirement + # Set up logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -77,6 +82,14 @@ class Agent(abc.ABC): STATE_ERROR_RECOVERY = "ERROR_RECOVERY" STATE_COMPLETION = "COMPLETION" + # T-X2 (issue #915): declarative external-OAuth scope requirement. + # Subclasses override this to declare which provider+scopes their tool + # bodies need. The registry surfaces these to AgentUI's consent dialog and + # the CLI ``gaia connectors grants`` command, and the runtime gates each + # ``get_access_token`` call on a per-agent grant for these scopes. + # Empty list = no external connections required (the default for built-ins). + REQUIRED_CONNECTORS: ClassVar[List[ConnectorRequirement]] = [] + # Response format templates โ€” agents select via response_mode attribute. # "planning" (default): JSON-only responses with thought/goal/plan/tool structure. # "conversational": plain text for conversation, JSON only for tool calls. @@ -248,8 +261,8 @@ def __init__( # Initialize AgentSDK with proper configuration # Note: We don't set system_prompt in config, we pass it per request # Note: Context size is configured when starting Lemonade server, not here - # Use Qwen3.5-35B by default for better reasoning and JSON formatting - # The 0.5B model is too small for complex agent tasks + # Use the configured default model (Gemma) when no explicit model_id + # is provided. The 0.5B model is too small for complex agent tasks. chat_config = AgentConfig( model=model_id or "Qwen3.5-35B-A3B-GGUF", use_claude=use_claude, @@ -1801,6 +1814,32 @@ def process_query( Returns: Dict containing the final result and operation details """ + # T-X2 (issue #915): bind agent identity for the duration of the + # query so any tool body's `get_access_token_sync(...)` calls can + # resolve the per-agent grant via contextvars. + # + # `_agent_context` is intentionally PRIVATE โ€” imported via the + # private path so a malicious tool body cannot import it from the + # public `gaia.connectors` API to forge an agent identity. + # See plan amendment A9. + from gaia.connectors.context import _agent_context + + ns_id = getattr(self, "_gaia_namespaced_agent_id", None) or getattr( + self, "AGENT_ID", None + ) + if ns_id is None: + return self._process_query_impl(user_input, max_steps, trace, filename) + with _agent_context(ns_id): + return self._process_query_impl(user_input, max_steps, trace, filename) + + def _process_query_impl( + self, + user_input: str, + max_steps: int = None, + trace: bool = False, + filename: str = None, + ) -> Dict[str, Any]: + """Inner implementation of ``process_query`` โ€” see public method docstring.""" import time start_time = time.time() # Track query processing start time diff --git a/src/gaia/agents/base/console.py b/src/gaia/agents/base/console.py index 0662ddf51..d86640a1e 100644 --- a/src/gaia/agents/base/console.py +++ b/src/gaia/agents/base/console.py @@ -67,6 +67,9 @@ class OutputHandler(ABC): each handler chooses to display it. """ + blocking_confirmation: bool = False + """Whether ``confirm_tool_execution`` waits for an explicit user decision.""" + # === Core Progress/State Methods (Required) === @abstractmethod @@ -213,6 +216,18 @@ def confirm_tool_execution( """Request user confirmation before executing a tool. Returns True to proceed.""" return True + def print_policy_alert( + self, + tool_name: str, # pylint: disable=unused-argument + decision: str, # pylint: disable=unused-argument + reason: str, # pylint: disable=unused-argument + rule_ids: List[str], # pylint: disable=unused-argument + policy_version: str, # pylint: disable=unused-argument + receipt_id: Optional[str] = None, # pylint: disable=unused-argument + ) -> None: + """Report a policy decision that blocked tool execution. Optional no-op.""" + ... + def print_separator(self, length: int = 50): # pylint: disable=unused-argument """Print separator. Optional - default no-op.""" ... diff --git a/src/gaia/agents/builder/agent.py b/src/gaia/agents/builder/agent.py index 51da22bc0..4f42a860b 100644 --- a/src/gaia/agents/builder/agent.py +++ b/src/gaia/agents/builder/agent.py @@ -192,7 +192,7 @@ def create_agent( self.console.print_agent_created(created_id) return result - def process_query( # type: ignore[override] + def _process_query_impl( # type: ignore[override] self, user_input: str, max_steps: Optional[int] = None, @@ -201,6 +201,10 @@ def process_query( # type: ignore[override] ) -> Dict[str, Any]: """Simplified chat loop for the builder agent. + Override point for the base ``Agent.process_query`` wrapper โ€” + ``process_query`` itself remains sealed so issue #915's agent-context + binding is never bypassed by a subclass. + Unlike the base class loop, this implementation: - Does NOT inject "ALWAYS BEGIN WITH A PLAN" instructions - Does NOT apply RAG workflow guards or planning-text detectors diff --git a/src/gaia/agents/chat/agent.py b/src/gaia/agents/chat/agent.py index d8e5cd3ce..8657c789e 100644 --- a/src/gaia/agents/chat/agent.py +++ b/src/gaia/agents/chat/agent.py @@ -24,6 +24,7 @@ from gaia.agents.tools import FileSystemToolsMixin # Enhanced file system navigation from gaia.agents.tools import ScratchpadToolsMixin # Structured data analysis from gaia.agents.tools import FileSearchToolsMixin, ScreenshotToolsMixin # Shared tools +from gaia.llm.lemonade_client import DEFAULT_MODEL_NAME from gaia.logger import get_logger from gaia.mcp.mixin import MCPClientMixin from gaia.rag.sdk import RAGSDK, RAGConfig @@ -44,7 +45,7 @@ class ChatAgentConfig: use_chatgpt: bool = False claude_model: str = "claude-sonnet-4-20250514" base_url: Optional[str] = None - model_id: Optional[str] = None # None = use default Qwen3.5-35B-A3B + model_id: Optional[str] = None # None = use default model (Gemma) # Execution settings max_steps: int = 10 @@ -157,8 +158,8 @@ def __init__(self, config: Optional[ChatAgentConfig] = None): else: self.allowed_paths = [Path(p).resolve() for p in config.allowed_paths] - # Use Qwen3.5-35B-A3B by default for better tool-calling - effective_model_id = config.model_id or "Qwen3.5-35B-A3B-GGUF" + # Use the configured default model (Gemma) when no explicit model is set + effective_model_id = config.model_id or DEFAULT_MODEL_NAME # Debug logging for model selection logger.debug( diff --git a/src/gaia/agents/code/agent.py b/src/gaia/agents/code/agent.py index 3c2a4311b..411402385 100644 --- a/src/gaia/agents/code/agent.py +++ b/src/gaia/agents/code/agent.py @@ -224,6 +224,31 @@ def process_query( Returns: Execution result summary from the orchestrator """ + # Issue #915: bind the agent identity for the duration of this query + # so any tool body's get_access_token_sync(...) call resolves the + # per-agent grant. Inline here because CodeAgent's signature differs + # from the base Agent.process_query's, so the base wrapper can't + # delegate to a renamed _process_query_impl as it does for other + # subclasses. ``_agent_context`` is the private helper from + # gaia.connectors.context โ€” public callers cannot reach it. + from gaia.connectors.context import _agent_context + + ns_id = getattr(self, "_gaia_namespaced_agent_id", None) or getattr( + self, "AGENT_ID", None + ) + if ns_id is None: + return self._process_query_inner_code( + user_input, workspace_root, progress_callback, **kwargs + ) + with _agent_context(ns_id): + return self._process_query_inner_code( + user_input, workspace_root, progress_callback, **kwargs + ) + + def _process_query_inner_code( + self, user_input: str, workspace_root=None, progress_callback=None, **kwargs + ): + """Inner CodeAgent process_query body โ€” see public process_query above.""" # Extract trace options trace = kwargs.get("trace", False) trace_filename = kwargs.get("filename") diff --git a/src/gaia/agents/connectors_demo/__init__.py b/src/gaia/agents/connectors_demo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/gaia/agents/connectors_demo/agent.py b/src/gaia/agents/connectors_demo/agent.py new file mode 100644 index 000000000..c83ad1a6a --- /dev/null +++ b/src/gaia/agents/connectors_demo/agent.py @@ -0,0 +1,456 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +ConnectorsDemoAgent โ€” a built-in agent that exercises the per-agent +grant flow end-to-end. + +Why this exists +--------------- +The connectors framework introduced in #926 adds three things that +needed a real consumer to validate: + +1. ``REQUIRED_CONNECTORS`` declarations โ€” the agent advertises the + connectors and scopes it needs. +2. ``get_credential_sync(connector_id, agent_id, required_scopes)`` + โ€” the central entrypoint that fires the grant-ledger check before + returning a usable credential. +3. The Settings โ†’ Connections per-agent grants UI โ€” the user must be + able to grant scopes from inside the AgentUI. + +This agent ships four tools that fan out across two connector kinds: + +- Google (``oauth_pkce``): ``gmail_recent_subjects``, ``calendar_today``, + ``drive_recent_files``. Each tool calls ``get_credential_sync`` with + the matching Google scope, then makes a one-shot REST call to the + Google API with the returned access_token. +- GitHub (``mcp_server``): ``github_my_repos``. Pulls the GitHub PAT + out of the keyring via the same dispatcher and calls + api.github.com directly. + +We do **not** spin up the GitHub MCP server (npx) here on purpose โ€” +that would add a Node dependency to the demo, and direct REST calls +make the grant flow more obvious. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from datetime import datetime, time +from typing import Any, ClassVar, Dict, List, Optional + +import httpx + +from gaia.agents.base.agent import Agent +from gaia.agents.base.console import AgentConsole +from gaia.agents.base.tools import _TOOL_REGISTRY, tool +from gaia.connectors.errors import ( + AuthRequiredError, + ConfigurationError, + ConnectorsError, +) +from gaia.connectors.handler import get_credential_sync +from gaia.connectors.providers.base import ConnectorRequirement +from gaia.logger import get_logger + +logger = get_logger(__name__) + + +# Public namespace this agent uses for grant-ledger lookups. Must agree +# with the registration in ``gaia.agents.registry``. +AGENT_NAMESPACED_ID = "builtin:connectors-demo" + +# OAuth scopes the four tools need. Declared in one place so the +# REQUIRED_CONNECTORS block and the per-tool calls can't drift apart. +SCOPE_GMAIL_READ = "https://www.googleapis.com/auth/gmail.readonly" +SCOPE_CALENDAR_READ = "https://www.googleapis.com/auth/calendar.readonly" +SCOPE_DRIVE_READ = "https://www.googleapis.com/auth/drive.readonly" + +# Symbolic scope for the GitHub MCP connector. v1 grants the entire +# PAT as a unit โ€” fine-grained per-tool grants are a v2 follow-up +# (would require knowing the MCP server's tool list ahead of time, +# which currently lives behind the npx process). +SCOPE_MCP_USE = "use" + + +_SYSTEM_PROMPT = """\ +You are GAIA's Connectors Demo Agent. Your job is to demonstrate the +connectors framework by retrieving real data from the user's connected +services when they ask. + +You have four tools: + +- gmail_recent_subjects(limit) โ€” pulls the most recent N email subjects + and senders from the user's Gmail inbox. +- calendar_today() โ€” lists today's Google Calendar events. +- drive_recent_files(limit) โ€” lists the user's most recently modified + Google Drive files. +- github_my_repos(limit) โ€” lists the user's GitHub repositories. + +Behavior: +- Call exactly the tool that matches the question. Don't speculate; + if the user asks "what's in my inbox?" call gmail_recent_subjects. +- If a tool returns an error mentioning "AGENT_NOT_GRANTED", tell the + user which scope they need to grant in Settings โ†’ Connections. +- If a tool returns an error mentioning "NOT_CONNECTED", tell them to + connect that service in Settings โ†’ Connections first. +- Summarize tool output in 1โ€“3 sentences. Don't recite raw JSON. +- Do NOT make up data. If a tool fails, say so. +""" + + +# --------------------------------------------------------------------------- +# Helpers โ€” kept module-level so they can be unit-tested without +# instantiating the full Agent (which spins up the LLM client). +# --------------------------------------------------------------------------- + + +def _gmail_token() -> str: + """Return a Gmail access token via the standard grant-checked path.""" + cred = get_credential_sync( + "google", + agent_id=AGENT_NAMESPACED_ID, + required_scopes=[SCOPE_GMAIL_READ], + ) + return cred["access_token"] + + +def _calendar_token() -> str: + cred = get_credential_sync( + "google", + agent_id=AGENT_NAMESPACED_ID, + required_scopes=[SCOPE_CALENDAR_READ], + ) + return cred["access_token"] + + +def _drive_token() -> str: + cred = get_credential_sync( + "google", + agent_id=AGENT_NAMESPACED_ID, + required_scopes=[SCOPE_DRIVE_READ], + ) + return cred["access_token"] + + +def _github_pat() -> str: + """Return the GitHub PAT via the MCP credential dispatcher.""" + cred = get_credential_sync( + "mcp-github", + agent_id=AGENT_NAMESPACED_ID, + required_scopes=[SCOPE_MCP_USE], + ) + env = cred.get("env") or {} + token = env.get("GITHUB_TOKEN") + if not token: + raise ConnectorsError( + "GitHub MCP credential resolved but GITHUB_TOKEN was empty. " + "Re-run Settings โ†’ Connections โ†’ GitHub โ†’ Configure to set the " + "Personal Access Token." + ) + return token + + +def _format_connector_error(e: BaseException) -> str: + """Translate a connectors exception into a one-line user-facing string. + + The agent's system prompt tells the LLM to surface AGENT_NOT_GRANTED + and NOT_CONNECTED specifically โ€” those are the two states the user + can fix by clicking something in Settings โ†’ Connections. + """ + if isinstance(e, AuthRequiredError): + if e.reason is AuthRequiredError.Reason.AGENT_NOT_GRANTED: + scopes = ", ".join(e.missing_scopes) or "(none reported)" + return ( + f"AGENT_NOT_GRANTED: this agent isn't granted these scopes " + f"on {e.provider}: {scopes}. Open Settings โ†’ Connections โ†’ " + f"{e.provider} โ†’ Per-agent grants and grant them." + ) + if e.reason in ( + AuthRequiredError.Reason.NOT_CONNECTED, + AuthRequiredError.Reason.REAUTH_REQUIRED, + ): + return ( + f"NOT_CONNECTED: {e.provider} is not currently connected. " + f"Open Settings โ†’ Connections โ†’ {e.provider} and click Connect." + ) + return f"AUTH_REQUIRED: {e}" + if isinstance(e, ConfigurationError): + return f"CONFIG_ERROR: {e}" + if isinstance(e, ConnectorsError): + return f"CONNECTOR_ERROR: {e}" + return f"UNEXPECTED_ERROR: {type(e).__name__}: {e}" + + +def _http_get_json( + url: str, *, headers: Dict[str, str], params: Optional[dict] = None +) -> Any: + """Tiny synchronous JSON GET. Raises on non-200; returns parsed JSON.""" + resp = httpx.get(url, headers=headers, params=params, timeout=10.0) + if resp.status_code != 200: + raise ConnectorsError(f"{url} returned {resp.status_code}: {resp.text[:300]}") + return resp.json() + + +def _today_window_iso() -> tuple[str, str]: + """RFC3339 timestamps for [today 00:00 local, tomorrow 00:00 local].""" + now = datetime.now().astimezone() + start = datetime.combine(now.date(), time.min, tzinfo=now.tzinfo) + end = datetime.combine(now.date(), time.max, tzinfo=now.tzinfo) + return start.isoformat(), end.isoformat() + + +# --------------------------------------------------------------------------- +# Tool implementations โ€” pure functions so they can be tested independently +# of the Agent class. +# --------------------------------------------------------------------------- + + +def _gmail_recent_subjects_impl(limit: int) -> Dict[str, Any]: + try: + token = _gmail_token() + headers = {"Authorization": f"Bearer {token}"} + listing = _http_get_json( + "https://gmail.googleapis.com/gmail/v1/users/me/messages", + headers=headers, + params={"maxResults": limit}, + ) + messages = [] + for msg in (listing.get("messages") or [])[:limit]: + detail = _http_get_json( + f"https://gmail.googleapis.com/gmail/v1/users/me/messages/{msg['id']}", + headers=headers, + params={"format": "metadata", "metadataHeaders": ["Subject", "From"]}, + ) + hdrs = { + h["name"]: h["value"] + for h in detail.get("payload", {}).get("headers", []) + } + messages.append( + { + "id": msg["id"], + "from": hdrs.get("From", ""), + "subject": hdrs.get("Subject", "(no subject)"), + } + ) + return {"ok": True, "count": len(messages), "messages": messages} + except BaseException as e: # noqa: BLE001 โ€” translated below + return {"ok": False, "error": _format_connector_error(e)} + + +def _calendar_today_impl() -> Dict[str, Any]: + try: + token = _calendar_token() + time_min, time_max = _today_window_iso() + data = _http_get_json( + "https://www.googleapis.com/calendar/v3/calendars/primary/events", + headers={"Authorization": f"Bearer {token}"}, + params={ + "timeMin": time_min, + "timeMax": time_max, + "singleEvents": "true", + "orderBy": "startTime", + }, + ) + events = [ + { + "summary": e.get("summary", "(untitled)"), + "start": (e.get("start") or {}).get("dateTime") + or (e.get("start") or {}).get("date"), + "end": (e.get("end") or {}).get("dateTime") + or (e.get("end") or {}).get("date"), + "location": e.get("location"), + } + for e in (data.get("items") or []) + ] + return {"ok": True, "count": len(events), "events": events} + except BaseException as e: # noqa: BLE001 + return {"ok": False, "error": _format_connector_error(e)} + + +def _drive_recent_files_impl(limit: int) -> Dict[str, Any]: + try: + token = _drive_token() + data = _http_get_json( + "https://www.googleapis.com/drive/v3/files", + headers={"Authorization": f"Bearer {token}"}, + params={ + "orderBy": "modifiedTime desc", + "pageSize": limit, + "fields": "files(id,name,mimeType,modifiedTime,webViewLink)", + }, + ) + files = data.get("files") or [] + return {"ok": True, "count": len(files), "files": files} + except BaseException as e: # noqa: BLE001 + return {"ok": False, "error": _format_connector_error(e)} + + +def _github_my_repos_impl(limit: int) -> Dict[str, Any]: + try: + token = _github_pat() + data = _http_get_json( + "https://api.github.com/user/repos", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + params={"per_page": limit, "sort": "updated"}, + ) + repos = [ + { + "full_name": r.get("full_name"), + "private": r.get("private"), + "description": r.get("description"), + "html_url": r.get("html_url"), + "updated_at": r.get("updated_at"), + } + for r in data + ] + return {"ok": True, "count": len(repos), "repos": repos} + except BaseException as e: # noqa: BLE001 + return {"ok": False, "error": _format_connector_error(e)} + + +# --------------------------------------------------------------------------- +# Agent class +# --------------------------------------------------------------------------- + + +@dataclass +class ConnectorsDemoAgentConfig: + """Configuration for ConnectorsDemoAgent โ€” same shape as ChatAgentConfig + so the registry's kwarg-filtering pattern works without special-casing.""" + + base_url: Optional[str] = None + model_id: Optional[str] = None + max_steps: int = 6 + streaming: bool = False + debug: bool = False + show_stats: bool = False + silent_mode: bool = False + output_dir: Optional[str] = None + + +class ConnectorsDemoAgent(Agent): + """Demo agent that uses Google + GitHub connector grants end-to-end.""" + + AGENT_ID = "connectors-demo" + AGENT_NAME = "Connectors Demo" + AGENT_DESCRIPTION = ( + "Demonstrates the connectors framework โ€” pulls real data from " + "your connected Google account and GitHub PAT." + ) + CONVERSATION_STARTERS = [ + "What's in my inbox?", + "What's on my calendar today?", + "List my recent Drive files", + "List my GitHub repositories", + ] + + REQUIRED_CONNECTORS: ClassVar[List[ConnectorRequirement]] = [ + ConnectorRequirement( + connector_id="google", + scopes=(SCOPE_GMAIL_READ, SCOPE_CALENDAR_READ, SCOPE_DRIVE_READ), + reason="Read recent Gmail / Calendar / Drive entries on the user's behalf.", + ), + ConnectorRequirement( + connector_id="mcp-github", + scopes=(SCOPE_MCP_USE,), + reason="Access the GitHub PAT to list the user's repositories.", + ), + ] + + def __init__(self, config: Optional[ConnectorsDemoAgentConfig] = None): + config = config or ConnectorsDemoAgentConfig() + self.config = config + + effective_model_id = config.model_id or "Qwen3.5-35B-A3B-GGUF" + effective_base_url = ( + config.base_url + if config.base_url is not None + else os.getenv("LEMONADE_BASE_URL", "http://localhost:13305/api/v1") + ) + + self.response_mode = "conversational" + super().__init__( + base_url=effective_base_url, + model_id=effective_model_id, + max_steps=config.max_steps, + streaming=config.streaming, + show_stats=config.show_stats, + silent_mode=config.silent_mode, + debug=config.debug, + output_dir=config.output_dir, + ) + + def _create_console(self) -> AgentConsole: + return AgentConsole() + + def _get_system_prompt(self) -> str: + return _SYSTEM_PROMPT + + def _register_tools(self) -> None: + # Match BuilderAgent's pattern: clear the module-level registry + # before registering our own so we don't inherit unrelated tools + # from a prior instance in the same process. + _TOOL_REGISTRY.clear() + + @tool + def gmail_recent_subjects(limit: int = 5) -> str: + """Return the most recent emails from the user's Gmail inbox. + + Args: + limit: How many messages to return. Default 5; max 25. + + Returns: + JSON string with either {"ok": true, "messages": [...]} + listing each message's id/from/subject, or + {"ok": false, "error": "..."} if the connector isn't + connected, isn't granted, or the API call fails. + """ + limit = max(1, min(int(limit or 5), 25)) + return json.dumps(_gmail_recent_subjects_impl(limit)) + + @tool + def calendar_today() -> str: + """Return today's Google Calendar events on the user's primary calendar. + + Returns: + JSON string with {"ok": true, "events": [...]} listing + each event's summary/start/end/location, or an error + envelope on failure. + """ + return json.dumps(_calendar_today_impl()) + + @tool + def drive_recent_files(limit: int = 5) -> str: + """Return the user's most recently modified Google Drive files. + + Args: + limit: How many files to return. Default 5; max 25. + + Returns: + JSON string with file metadata or an error envelope. + """ + limit = max(1, min(int(limit or 5), 25)) + return json.dumps(_drive_recent_files_impl(limit)) + + @tool + def github_my_repos(limit: int = 10) -> str: + """Return the user's most recently updated GitHub repositories. + + Args: + limit: How many repos to return. Default 10; max 50. + + Returns: + JSON string with repo metadata or an error envelope. + """ + limit = max(1, min(int(limit or 10), 50)) + return json.dumps(_github_my_repos_impl(limit)) + + # Tools are registered on the module-level registry by the + # decorator; nothing else to do here. The base Agent's default + # chat loop drives tool selection โ€” no custom orchestration. diff --git a/src/gaia/agents/registry.py b/src/gaia/agents/registry.py index 7865e6aa0..880de6066 100644 --- a/src/gaia/agents/registry.py +++ b/src/gaia/agents/registry.py @@ -3,6 +3,7 @@ """Agent registry for discovering, loading, and creating agents.""" import dataclasses +import hashlib import importlib import importlib.util import inspect @@ -12,12 +13,13 @@ import threading import time import warnings -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Dict, List, Literal, Optional import yaml +from gaia.connectors.providers.base import ConnectorRequirement from gaia.logger import get_logger logger = get_logger(__name__) @@ -43,6 +45,55 @@ ) +# Reserved agent IDs that custom agents (under ~/.gaia/agents/) must not +# claim. Loaded lazily by ``_RESERVED_BUILTIN_IDS`` so the list stays in sync +# with what ``_register_builtin_agents`` actually registers. +_RESERVED_BUILTIN_IDS: frozenset[str] = frozenset({"chat", "builder", "gaia-lite"}) + + +def _wrap_factory_with_namespaced_id( + factory: Callable[..., Any], namespaced_id: str +) -> Callable[..., Any]: + """ + Wrap a registration factory so the resulting Agent instance carries its + namespaced ID for ``Agent.process_query`` to read at runtime. + + The base ``Agent.process_query`` reads ``_gaia_namespaced_agent_id`` (and + falls back to ``AGENT_ID``) when wrapping the call in the agent context + contextvar. Setting this attribute on the instance is what lets a + custom-installed agent get its proper ``custom::`` namespace + instead of the bare ``AGENT_ID``. + """ + + def _factory(**kwargs): + instance = factory(**kwargs) + # Attribute access โ€” use setattr because subclasses may override + # __setattr__ to validate fields. We set on the instance, not the + # class, so two different registrations of the same class don't + # collide. + try: + instance._gaia_namespaced_agent_id = namespaced_id + except (AttributeError, TypeError): + # If the agent uses __slots__ without an entry for this field, + # we still proceed โ€” process_query will fall back to AGENT_ID. + pass + return instance + + return _factory + + +def _compute_custom_origin_hash(py_file: Path) -> str: + """ + Compute the custom-agent origin hash used in ``namespaced_agent_id``. + + Hashes the raw bytes of ``agent.py``. A different file (different code) + therefore produces a different namespaced id, so a custom agent that + later changes its scope claims will get a fresh grant-ledger key โ€” the + user re-grants explicitly rather than inheriting the prior grant. + """ + return hashlib.sha256(py_file.read_bytes()).hexdigest()[:16] + + @dataclass class AgentRegistration: """Metadata and factory for a registered agent.""" @@ -61,6 +112,19 @@ class AgentRegistration: # in Settings when memory_available_gb < min_memory_gb so the user isn't # surprised by a load failure or heavy swapping mid-session. min_memory_gb: Optional[float] = None + # T-X2 (issue #915): + # ``required_connections`` is the agent class's ``REQUIRED_CONNECTORS`` + # ClassVar surfaced into the registry so the AgentUI consent dialog and + # the CLI ``gaia connectors grants`` command can render the prompt + # without re-importing the agent module. + required_connections: List[ConnectorRequirement] = field(default_factory=list) + # T-X2 (issue #915, plan amendment A9): + # ``namespaced_agent_id`` is the grant-ledger key for this agent. Built-in + # agents use ``builtin:``; custom agents under ``~/.gaia/agents/`` + # use ``custom::``. This namespacing prevents a + # malicious custom agent from claiming a built-in's AGENT_ID to inherit + # a previously-granted scope. Always non-empty. + namespaced_agent_id: str = "" class AgentRegistry: @@ -165,9 +229,11 @@ def chat_factory(**kwargs): "Search my documents for information about...", "Find files related to...", ], - factory=chat_factory, + factory=_wrap_factory_with_namespaced_id(chat_factory, "builtin:chat"), agent_dir=None, models=[], + required_connections=[], + namespaced_agent_id="builtin:chat", ) ) logger.info("registry: Registered built-in agent: chat (ChatAgent)") @@ -251,10 +317,14 @@ def gaia_lite_factory(**kwargs): "Summarize this document", "Search my files for...", ], - factory=gaia_lite_factory, + factory=_wrap_factory_with_namespaced_id( + gaia_lite_factory, "builtin:gaia-lite" + ), agent_dir=None, models=_GAIA_LITE_MODELS, min_memory_gb=_GAIA_LITE_MIN_MEMORY_GB, + required_connections=[], + namespaced_agent_id="builtin:gaia-lite", ) ) logger.info( @@ -262,6 +332,62 @@ def gaia_lite_factory(**kwargs): _GAIA_LITE_MODELS[0], ) + # --- ConnectorsDemoAgent --- + # Demo agent that uses Google + GitHub connectors end-to-end so + # the per-agent grant flow has a real consumer to validate it. + # Visible in the AgentUI dropdown โ€” users can select it to test + # their connector setup. + try: + from gaia.agents.connectors_demo.agent import ( + ConnectorsDemoAgent, + ConnectorsDemoAgentConfig, + ) + + def connectors_demo_factory(**kwargs): + valid_fields = { + f.name for f in dataclasses.fields(ConnectorsDemoAgentConfig) + } + config = ConnectorsDemoAgentConfig( + **{k: v for k, v in kwargs.items() if k in valid_fields} + ) + return ConnectorsDemoAgent(config=config) + + self._register( + AgentRegistration( + id="connectors-demo", + name="Connectors Demo", + description=( + "Demonstrates the connectors framework โ€” pulls real " + "data from your connected Google account and GitHub PAT." + ), + source="builtin", + conversation_starters=[ + "What's in my inbox?", + "What's on my calendar today?", + "List my recent Drive files", + "List my GitHub repositories", + ], + factory=_wrap_factory_with_namespaced_id( + connectors_demo_factory, "builtin:connectors-demo" + ), + agent_dir=None, + models=[], + required_connections=[ + # Surfaced in the UI so users see "this agent + # needs Google + GitHub" before granting scopes. + "google", + "mcp-github", + ], + namespaced_agent_id="builtin:connectors-demo", + ) + ) + logger.info( + "registry: Registered built-in agent: connectors-demo " + "(ConnectorsDemoAgent)" + ) + except ImportError as e: + logger.debug("registry: ConnectorsDemoAgent not available, skipping: %s", e) + # --- BuilderAgent --- try: from gaia.agents.builder.agent import BuilderAgent, BuilderAgentConfig @@ -283,10 +409,14 @@ def builder_factory(**kwargs): "Help me create a custom agent", "I want to build a new agent", ], - factory=builder_factory, + factory=_wrap_factory_with_namespaced_id( + builder_factory, "builtin:builder" + ), agent_dir=None, models=[], hidden=True, + required_connections=[], + namespaced_agent_id="builtin:builder", ) ) logger.info("registry: Registered built-in agent: builder (BuilderAgent)") @@ -377,6 +507,23 @@ def _load_python_agent( agent_desc = getattr(agent_class, "AGENT_DESCRIPTION", "") starters = getattr(agent_class, "CONVERSATION_STARTERS", []) + # T-X2 (issue #915, plan amendment A9): block custom agents from + # claiming a built-in's reserved AGENT_ID. Without this, a custom + # agent with `AGENT_ID = "chat"` could inherit a grant the user + # previously gave to the built-in chat agent. + if agent_id in _RESERVED_BUILTIN_IDS: + raise ValueError( + f"AGENT_ID {agent_id!r} is reserved for the built-in agent. " + f"Choose a different id in {py_file}." + ) + + # T-X2: collect declarative scope claims and namespaced grant key. + required_connections = list( + getattr(agent_class, "REQUIRED_CONNECTORS", []) or [] + ) + origin_hash = _compute_custom_origin_hash(py_file) + namespaced_id = f"custom:{origin_hash}:{agent_id}" + # Read optional companion YAML for `models:` metadata. Anything outside # `models:` is a manifest leftover and should be migrated into agent.py. models: List[str] = [] @@ -437,9 +584,11 @@ def python_factory(klass=klass, **kwargs): description=agent_desc, source="custom_python", conversation_starters=list(starters), - factory=python_factory, + factory=_wrap_factory_with_namespaced_id(python_factory, namespaced_id), agent_dir=agent_dir, models=models, + required_connections=required_connections, + namespaced_agent_id=namespaced_id, ) ) logger.info( diff --git a/src/gaia/apps/webui/electron-builder.yml b/src/gaia/apps/webui/electron-builder.yml index 41b4e930e..be2d473c7 100644 --- a/src/gaia/apps/webui/electron-builder.yml +++ b/src/gaia/apps/webui/electron-builder.yml @@ -30,6 +30,7 @@ directories: # but as an include list (which is what electron-builder expects). files: - main.cjs + - main-safety-net.cjs - preload.cjs - bin/**/* - services/**/* diff --git a/src/gaia/apps/webui/main-safety-net.cjs b/src/gaia/apps/webui/main-safety-net.cjs new file mode 100644 index 000000000..911c0b91d --- /dev/null +++ b/src/gaia/apps/webui/main-safety-net.cjs @@ -0,0 +1,172 @@ +// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +// SPDX-License-Identifier: MIT + +/** + * main-safety-net.cjs โ€” Hardened Electron main-process error handling. + * + * Extracted from main.cjs so tests can require this module without triggering + * main.cjs side effects. All Electron objects are dependency-injected. + * + * Fixes for issue #934 (ERR_STREAM_WRITE_AFTER_END after fresh install): + * - process.on('uncaughtException') catches stream 'error' events that + * propagate because the write stream has no listener. + * - process.on('unhandledRejection') catches rejected app.whenReady() chain. + * - installLogTee() attaches stream.on('error') so stream errors are handled + * before they can become uncaughtException (root-cause fix). + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +// โ”€โ”€ Counter helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// The counter is currently forensic only โ€” post-mortem grep of +// ~/.gaia/electron-startup-failures.json. +// TODO(#938): on next launch, if count >= 3, skip log-tee init and show +// a "reset state?" dialog (safe-mode entry point). + +function counterPath(homedir) { + return path.join(homedir(), ".gaia", "electron-startup-failures.json"); +} + +function readCount(homedir) { + try { + return JSON.parse(fs.readFileSync(counterPath(homedir), "utf8")).count || 0; + } catch { + return 0; + } +} + +function writeCount(n, homedir) { + const p = counterPath(homedir); + try { + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify({ count: n }), { encoding: "utf8" }); + } catch (err) { + try { process.stderr.write(`[safety-net] writeCount failed: ${err.message}\n`); } catch { } + } +} + +// โ”€โ”€ Log helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function appendLog(logPath, msg) { + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, msg + "\n", { encoding: "utf8" }); + } catch (err) { + try { process.stderr.write(`[safety-net] log append failed: ${err.message}\n`); } catch { } + } +} + +// โ”€โ”€ Core installer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Install the safety-net handlers on the current process. + * + * @param {object} opts + * @param {string} opts.logPath - Path to append FATAL lines into. + * @param {object} opts.dialogModule - Electron dialog (injected for tests). + * @param {object} opts.appModule - Electron app EventEmitter (injected). + * @param {Function} [opts.homedirFn] - Override for os.homedir (tests). + */ +function installSafetyNet({ logPath, dialogModule, appModule, homedirFn }) { + const homedir = homedirFn || (() => os.homedir()); + + // Per-handler re-entry guard (closure-scoped โ€” each installSafetyNet + // call gets its own, intentionally; see test_main_error_handling.js). + let _inFatalHandler = false; + + function fatal(err) { + if (_inFatalHandler) { + try { process.exit(2); } catch { } + return; + } + _inFatalHandler = true; + + const stack = (err && err.stack) ? err.stack : String(err); + const ts = new Date().toISOString(); + + // Write to log BEFORE showing dialog so the entry survives even if + // dialog.showErrorBox itself crashes. + appendLog(logPath, `[${ts}] FATAL ${stack}`); + + // Increment crash-loop counter. + writeCount(readCount(homedir) + 1, homedir); + + // Pre-app.ready on Windows, showMessageBoxSync silently no-ops; + // showErrorBox is the only dialog that works in that window. + // Bare catch: intentional swallow โ€” we are already in the fatal-exit + // path with no upstream caller to surface errors to. + try { + if (appModule.isReady()) { + dialogModule.showMessageBoxSync({ + type: "error", + title: "GAIA crashed", + message: stack, + buttons: ["OK"], + }); + } else { + dialogModule.showErrorBox("GAIA failed to start", stack); + } + } catch { } // intentional: fatal path, no upstream + + try { process.exit(1); } catch { } // intentional: fatal path + } + + // Wire process-level handlers. + process.on("uncaughtException", (err) => fatal(err)); + process.on("unhandledRejection", (reason) => { + const err = reason instanceof Error ? reason : new Error(String(reason)); + fatal(err); + }); + + // Reset counter on the first successful user interaction. Resetting at + // loadApp() is too early โ€” the user may crash before their first focus. + appModule.on("browser-window-focus", () => writeCount(0, homedir)); + + // Renderer and GPU-process crashes don't fire uncaughtException โ€” route + // them through fatal() so they get the same dialog + counter treatment. + appModule.on("render-process-gone", (_event, _webContents, details) => { + fatal(new Error(`render-process-gone: reason=${details && details.reason}`)); + }); + + appModule.on("child-process-gone", (_event, details) => { + const reason = details && details.reason; + // Ignore expected terminations during shutdown so the crash dialog + // doesn't flash on a clean quit. + if (reason === "clean-exit" || reason === "killed") return; + fatal(new Error( + `child-process-gone: type=${details && details.type} reason=${reason}` + )); + }); + + return { fatal }; +} + +// โ”€โ”€ Log-tee helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Attach an 'error' listener to a write stream so that asynchronous stream + * errors (e.g. ERR_STREAM_WRITE_AFTER_END) are absorbed before they can + * become uncaughtException. This is the direct root-cause fix for #934. + * + * @param {object} opts + * @param {EventEmitter} opts.stream - The writable stream to guard. + * @param {string} opts.logPath - Path for fallback error logging. + * @note The internal WeakSet guard is module-scoped, so idempotency is + * process-global. A second call on the same stream is a no-op regardless + * of which caller site invokes it. + */ +const _teedStreams = new WeakSet(); +function installLogTee({ stream, logPath }) { + if (_teedStreams.has(stream)) return; + _teedStreams.add(stream); + stream.on("error", (err) => { + const detail = (err && err.message) || (err && err.stack) || String(err); + appendLog(logPath, `[${new Date().toISOString()}] STREAM_ERROR ${detail}`); + }); +} + +module.exports = { installSafetyNet, installLogTee }; diff --git a/src/gaia/apps/webui/main.cjs b/src/gaia/apps/webui/main.cjs index 892a53136..f07492929 100644 --- a/src/gaia/apps/webui/main.cjs +++ b/src/gaia/apps/webui/main.cjs @@ -18,6 +18,39 @@ const path = require("path"); const fs = require("fs"); const os = require("os"); const { spawn } = require("child_process"); +const { pathToFileURL } = require("url"); + +// โ”€โ”€ Shared log path โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Single source of truth used by installSafetyNet AND installMainLogTee so +// both write to the same file without independent path computations that +// could drift apart. +const _GAIA_DIR = path.join(os.homedir(), ".gaia"); +const _MAIN_LOG_PATH = path.join(_GAIA_DIR, "electron-main.log"); + +// โ”€โ”€ Safety net (issue #934) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Install top-level error handlers BEFORE any service module is required so +// that synchronous throws at module-load time are caught and shown as a +// GAIA-branded error box instead of Electron's bare JS-error dialog. +// Extracted into main-safety-net.cjs so tests can require it without +// triggering main.cjs side effects (Electron modules, service requires). +// Wrapped in try/catch: a corrupt ASAR or bad path would otherwise bypass the +// very handler we are trying to install, falling through to Electron's bare +// JS-error dialog. +let installSafetyNet, installLogTee, _fatalHandler; +try { + ({ installSafetyNet, installLogTee } = require("./main-safety-net.cjs")); + ({ fatal: _fatalHandler } = installSafetyNet({ + logPath: _MAIN_LOG_PATH, + dialogModule: dialog, + appModule: app, + })); +} catch (err) { + try { process.stderr.write(`[main] safety-net load failed: ${err.message}\n`); } catch { } + try { dialog.showErrorBox("GAIA failed to start", String((err && err.stack) || err)); } catch { } + // Synchronous exit: service module requires below have no uncaughtException + // handler installed, so execution cannot safely continue. + process.exit(1); +} // Services (loaded after app.whenReady) const TrayManager = require("./services/tray-manager.cjs"); @@ -44,7 +77,18 @@ app.commandLine.appendSwitch("ozone-platform-hint", "auto"); // invocations bypass the .desktop entry. Appending the switch here makes // all Linux launch paths behave identically. if (process.platform === "linux") { + // Append Chromium switches to improve reliability on constrained CI/container + // environments (userns-restricted launches, limited /dev/shm, headless GPUs). + // `--no-sandbox` is required on some distro/AppImage combos; include it here + // so all Linux launch paths behave identically. app.commandLine.appendSwitch("no-sandbox"); + // Prevent GPU / sandbox issues in headless CI or restricted containers. + app.commandLine.appendSwitch("disable-gpu"); + // Avoid /dev/shm usage which can be small in containers and cause crashes. + app.commandLine.appendSwitch("disable-dev-shm-usage"); + try { + process.stderr.write('[main] Applied Linux chromium switches: no-sandbox, disable-gpu, disable-dev-shm-usage\n'); + } catch { /* best-effort logging */ } } // โ”€โ”€ F7: Log tee to ~/.gaia/electron-main.log (issue #782) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -53,9 +97,8 @@ if (process.platform === "linux") { // diagnostics bundler has something to attach. (function installMainLogTee() { try { - const gaiaDir = path.join(os.homedir(), ".gaia"); - try { fs.mkdirSync(gaiaDir, { recursive: true }); } catch { /* ignore */ } - const logPath = path.join(gaiaDir, "electron-main.log"); + try { fs.mkdirSync(_GAIA_DIR, { recursive: true }); } catch { /* ignore */ } + const logPath = _MAIN_LOG_PATH; // Rotate if > 5 MB โ€” truncate to last ~5 MB on startup. try { @@ -81,6 +124,10 @@ if (process.platform === "linux") { } const stream = fs.createWriteStream(logPath, { flags: "a" }); + // Root-cause fix for #934: stream.write() after end emits 'error' + // asynchronously โ€” the try/catch in wrap() below doesn't catch it. + // This listener absorbs the event before it becomes uncaughtException. + installLogTee({ stream, logPath }); stream.write( `\nโ”€โ”€โ”€โ”€ electron-main opened (${new Date().toISOString()}) pid=${process.pid} โ”€โ”€โ”€โ”€\n` ); @@ -156,6 +203,11 @@ let backendStderrTail = []; let isIntentionalKill = false; let mainWindow = null; +// True until createWindow() runs. Guards window-all-closed from firing app.quit() +// while the backend-installer progress dialog is open (it's the only window during +// bootstrap, so destroying it would trigger a premature quit โ€” issue #934). +let isBootstrapping = true; + /** @type {TrayManager | null} */ let trayManager = null; @@ -428,7 +480,12 @@ async function loadApp() { const indexPath = path.join(distPath, "index.html"); const indexQuery = buildIndexQuery(backendPort); console.log("Loading app from:", indexPath, "api:", indexQuery.api); - await mainWindow.loadFile(indexPath, { query: indexQuery }); + // Use pathToFileURL so the file:// URL always has forward slashes on + // Windows โ€” Chromium 130+ (Electron 40) rejects backslash file URLs + // that Node's url.format() (used by loadFile) produces on Windows. + const fileUrl = pathToFileURL(indexPath); + fileUrl.search = new URLSearchParams(indexQuery).toString(); + await mainWindow.loadURL(fileUrl.href); } else { // Show a simple loading/error page mainWindow.loadURL( @@ -674,6 +731,7 @@ app.whenReady().then(async () => { // Create the window (hidden until ready-to-show) createWindow(); + isBootstrapping = false; // progress dialog is gone; window-all-closed may now quit // Initialize services (tray, agent manager, notifications) initializeServices(); @@ -737,11 +795,21 @@ app.whenReady().then(async () => { mainWindow.show(); } }); +}).catch((err) => { + // Route explicit rejection through the safety-net so the user gets a + // GAIA-branded dialog and a stack trace in the log (issue #934). + _fatalHandler(err); }); // โ”€โ”€ Window-all-closed (C4 fix) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Don't quit when window is hidden โ€” tray keeps app alive app.on("window-all-closed", () => { + // During bootstrap the progress dialog is the only open window. Destroying + // it (progress.close()) fires this event before the main window exists, which + // would trigger a premature app.quit() that races with the startup sequence + // and causes loadURL() to fail with ERR_FAILED (-2) โ€” issue #934. + if (isBootstrapping) return; + // If minimize-to-tray is active, the window is just hidden, not closed. // Only quit on macOS if the user explicitly quit (Cmd+Q). const trayActive = trayManager && trayManager.minimizeToTray; diff --git a/src/gaia/apps/webui/package-lock.json b/src/gaia/apps/webui/package-lock.json index c6992796d..2d4295776 100644 --- a/src/gaia/apps/webui/package-lock.json +++ b/src/gaia/apps/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@amd-gaia/agent-ui", - "version": "0.17.3", + "version": "0.17.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@amd-gaia/agent-ui", - "version": "0.17.3", + "version": "0.17.4", "license": "MIT", "dependencies": { "electron-updater": "^6.8.3" diff --git a/src/gaia/apps/webui/package.json b/src/gaia/apps/webui/package.json index 9c4e827d0..20d7224c2 100644 --- a/src/gaia/apps/webui/package.json +++ b/src/gaia/apps/webui/package.json @@ -1,6 +1,6 @@ { "name": "@amd-gaia/agent-ui", - "version": "0.17.4", + "version": "0.17.6", "type": "module", "productName": "GAIA Agent UI", "description": "Privacy-first agentic AI interface with document Q&A - runs 100% locally on AMD Ryzen AI", @@ -35,6 +35,7 @@ "bin/", "dist/", "main.cjs", + "main-safety-net.cjs", "preload.cjs", "services/", "assets/", diff --git a/src/gaia/apps/webui/src/App.tsx b/src/gaia/apps/webui/src/App.tsx index af2fa3a9c..42ed40e2f 100644 --- a/src/gaia/apps/webui/src/App.tsx +++ b/src/gaia/apps/webui/src/App.tsx @@ -8,7 +8,8 @@ import { ChatView } from './components/ChatView'; import { WelcomeScreen } from './components/WelcomeScreen'; import { DocumentLibrary } from './components/DocumentLibrary'; import { FileBrowser } from './components/FileBrowser'; -import { SettingsModal } from './components/SettingsModal'; +import { SettingsPage } from './components/SettingsPage'; +import SettingsModal from './components/SettingsModal'; import { MobileAccessModal } from './components/MobileAccessModal'; import { ConnectionBanner } from './components/ConnectionBanner'; import { UpdateIndicator } from './components/UpdateIndicator'; @@ -380,44 +381,57 @@ function App() { } }, [addSession, setCurrentSession, setMessages, setSidebarOpen, checkSystemStatus, setPendingPrompt]); - // Mobile gateway toggle + // Mobile gateway toggle: the sidebar button ALWAYS opens the modal + // (so the user can re-capture the QR / URL if they missed it the first + // time). Stopping the tunnel is done via the explicit "Stop Tunnel" + // button inside the modal (see handleMobileStop). const handleMobileToggle = useCallback(async () => { if (tunnelActive) { - // Stop tunnel - log.system.info('Stopping mobile access tunnel...'); - try { - await api.stopTunnel(); - } catch { - // Ignore stop errors - } - setTunnelActive(false); - setShowMobileAccess(false); - } else { - // Start tunnel - log.system.info('Starting mobile access tunnel...'); - setShowMobileAccess(true); - setTunnelLoading(true); + // Tunnel already running -- just reopen the modal so the user + // can copy the URL or scan the QR again. + log.system.info('Reopening mobile access modal (tunnel already running)'); setTunnelError(null); - try { - const status = await api.startTunnel(); - if (status.error) { - log.system.error('Tunnel failed to start:', status.error); - setTunnelActive(false); - setTunnelError(status.error); - } else { - setTunnelActive(true); - log.system.info('Tunnel started successfully'); - } - } catch (err) { - log.system.error('Tunnel start error:', err); + setShowMobileAccess(true); + return; + } + + // Tunnel is not running -- start it. + log.system.info('Starting mobile access tunnel...'); + setShowMobileAccess(true); + setTunnelLoading(true); + setTunnelError(null); + try { + const status = await api.startTunnel(); + if (status.error) { + log.system.error('Tunnel failed to start:', status.error); setTunnelActive(false); - setTunnelError(err instanceof Error ? err.message : 'Failed to connect'); - } finally { - setTunnelLoading(false); + setTunnelError(status.error); + } else { + setTunnelActive(true); + log.system.info('Tunnel started successfully'); } + } catch (err) { + log.system.error('Tunnel start error:', err); + setTunnelActive(false); + setTunnelError(err instanceof Error ? err.message : 'Failed to connect'); + } finally { + setTunnelLoading(false); } }, [tunnelActive]); + // Explicit "Stop Tunnel" action (triggered from inside the modal). + const handleMobileStop = useCallback(async () => { + log.system.info('Stopping mobile access tunnel...'); + try { + await api.stopTunnel(); + } catch (err) { + log.system.warn('stopTunnel call failed (continuing)', err); + } + setTunnelActive(false); + setTunnelError(null); + setShowMobileAccess(false); + }, []); + // Sync agent picker to the selected session's agent_type useEffect(() => { const { sessions, setActiveAgentId } = useChatStore.getState(); @@ -441,7 +455,7 @@ function App() { }, [showDocLibrary]); useEffect(() => { - if (showSettings) log.ui.info('Settings modal opened'); + if (showSettings) log.ui.info('Settings page opened'); }, [showSettings]); // Reactive mobile detection โ€” updates on resize @@ -501,20 +515,26 @@ function App() { />
- {/* Connection / LLM status banner */} - - -
- {displayedSessionId ? ( - - ) : ( - - )} -
+ {showSettings ? ( + + ) : ( + <> + {/* Connection / LLM status banner */} + + +
+ {displayedSessionId ? ( + + ) : ( + + )} +
+ + )}
@@ -523,9 +543,6 @@ function App() { - - - {/* Mobile Access Modal */} {!isMobile && ( @@ -533,6 +550,7 @@ function App() { setShowMobileAccess(false)} + onStop={handleMobileStop} error={tunnelError} /> diff --git a/src/gaia/apps/webui/src/components/ConnectorsSection.css b/src/gaia/apps/webui/src/components/ConnectorsSection.css new file mode 100644 index 000000000..3e181c319 --- /dev/null +++ b/src/gaia/apps/webui/src/components/ConnectorsSection.css @@ -0,0 +1,337 @@ +/* Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. */ +/* SPDX-License-Identifier: MIT */ + +/* Connectors section โ€” tile grid + expandable detail view (T-8b). */ + +.connectors-section .settings-help { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 12px; + line-height: 1.5; + font-family: var(--font-sans); +} + +.connectors-loading { + display: flex; + justify-content: center; + padding: 20px; + color: var(--text-muted); +} + +.connectors-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* โ”€โ”€ Connector tile โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.connector-tile { + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + background: var(--bg-secondary); + overflow: hidden; + transition: border-color var(--duration) var(--ease); +} +.connector-tile:hover, +.connector-tile--open { + border-color: var(--border); +} + +.connector-tile-header { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 14px; + background: none; + border: none; + cursor: pointer; + text-align: left; + font-family: var(--font-sans); + color: var(--text-primary); +} +.connector-tile-header:hover { + background: var(--bg-hover, rgba(0,0,0,0.03)); +} +[data-theme="dark"] .connector-tile-header:hover { + background: rgba(255,255,255,0.04); +} + +.connector-tile-name { + font-size: 13px; + font-weight: 600; +} + +.connector-tile-type { + font-size: 10px; + font-family: var(--font-mono); + color: var(--text-muted); + background: var(--bg-tertiary); + border: 1px solid var(--border-light); + border-radius: 999px; + padding: 1px 6px; + flex-shrink: 0; +} + +.connector-status { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-family: var(--font-sans); +} +.connector-status.ok { color: var(--accent-green); } +.connector-status.idle { color: var(--text-muted); } + +.connector-tile-chevron { + margin-left: auto; + color: var(--text-muted); + flex-shrink: 0; +} + +/* โ”€โ”€ Detail view โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.connector-detail { + border-top: 1px solid var(--border-light); + padding: 14px; +} + +.configure-body { } + +.connector-desc { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + font-family: var(--font-sans); + margin-bottom: 12px; +} + +.configure-error { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--accent-red, #e55); + background: rgba(239,68,68,0.08); + border: 1px solid rgba(239,68,68,0.2); + border-radius: var(--radius-sm); + padding: 6px 10px; + margin-bottom: 10px; + font-family: var(--font-sans); +} + +/* Informational missing-config notice โ€” not an error, just "you need to + * set GAIA_GOOGLE_CLIENT_ID before you can connect". Pairs with the + * disabled "Setup required" badge in OAuthConfigureBody. */ +.configure-info { + display: flex; + align-items: flex-start; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); + background: var(--bg-tertiary); + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); + padding: 6px 10px; + margin-bottom: 10px; + font-family: var(--font-sans); + line-height: 1.4; +} +.configure-info svg { + flex-shrink: 0; + margin-top: 2px; + color: var(--text-muted); +} + +.connector-setup-required { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-family: var(--font-sans); + color: var(--text-muted); + background: var(--bg-tertiary); + border: 1px solid var(--border-light); + border-radius: 999px; + padding: 3px 10px; + cursor: default; +} + +.configure-actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; +} + +/* First-time OAuth setup form (e.g. Google client_id / client_secret). + Shown inline when the provider hasn't been configured yet. */ +.oauth-setup-form { + display: flex; + flex-direction: column; + gap: 12px; + margin: 8px 0 4px 0; + padding: 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border-light); + border-radius: 6px; +} + +.oauth-setup-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + font-family: var(--font-sans); +} + +.oauth-setup-label { + color: var(--text-primary); + font-weight: 500; +} + +.oauth-setup-input { + padding: 6px 8px; + border: 1px solid var(--border-light); + border-radius: 4px; + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; +} + +.oauth-setup-input:focus { + outline: none; + border-color: var(--accent); +} + +.oauth-setup-help { + color: var(--text-secondary); + font-size: 11px; + line-height: 1.4; +} + +.connector-product-link { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 12px; + color: var(--text-secondary); + text-decoration: none; + font-family: var(--font-sans); + margin-left: auto; +} +.connector-product-link:hover { color: var(--accent); text-decoration: underline; } + +/* MCP key inputs */ +.mcp-key-row { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; +} +.mcp-key-label { + font-size: 11px; + font-family: var(--font-mono); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} +.mcp-key-input { + padding: 7px 10px; + font-size: 13px; + font-family: var(--font-mono); + background: var(--bg-primary); + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + color: var(--text-primary); + outline: none; + transition: border-color var(--duration) var(--ease); +} +.mcp-key-input:focus { border-color: var(--accent); } + +/* โ”€โ”€ Agent grants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.connection-grants { + margin-top: 12px; + padding-top: 12px; + border-top: 1px dashed var(--border-light); +} + +.grants-header { + font-size: 10px; + font-weight: 600; + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 1.5px; + color: var(--text-muted); + margin-bottom: 6px; +} + +.grants-empty { + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-sans); + font-style: italic; +} + +.grant-row { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + font-size: 12px; + font-family: var(--font-sans); +} + +.grant-agent { + font-weight: 500; + min-width: 7rem; + color: var(--text-primary); +} + +.grant-scopes { + flex: 1; + color: var(--text-secondary); + word-break: break-all; +} + +.btn-grant-revoke { + background: none; + border: none; + cursor: pointer; + color: var(--text-muted); + padding: 2px; + border-radius: 3px; + display: flex; + align-items: center; + transition: color var(--duration) var(--ease); + flex-shrink: 0; +} +.btn-grant-revoke:hover { color: var(--accent-red, #e55); } + +/* โ”€โ”€ Shared โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +.error-banner { + display: flex; + align-items: center; + gap: 6px; + background: rgba(239,68,68,0.08); + color: var(--accent-red, #e55); + padding: 8px 12px; + border-radius: var(--radius-md); + border: 1px solid rgba(239,68,68,0.2); + margin-bottom: 10px; + font-size: 12px; + font-family: var(--font-sans); +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/gaia/apps/webui/src/components/ConnectorsSection.tsx b/src/gaia/apps/webui/src/components/ConnectorsSection.tsx new file mode 100644 index 000000000..08ea37316 --- /dev/null +++ b/src/gaia/apps/webui/src/components/ConnectorsSection.tsx @@ -0,0 +1,558 @@ +// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +// SPDX-License-Identifier: MIT + +/** + * Settings โ†’ Connectors section (T-8b). + * + * Renders a tile grid of all connectors in the catalog. Clicking a tile + * expands a detail view in-place (plan amendment A16). The detail view + * shows an OAuth or MCP-key configure form plus per-agent grants. + */ + +import { useEffect, useState, useCallback } from 'react'; +import { + CheckCircle2, + AlertCircle, + Loader2, + ExternalLink, + ChevronDown, + ChevronUp, + X, +} from 'lucide-react'; +import * as api from '../services/api'; +import { useChatStore } from '../stores/chatStore'; +import { useConnectorsSSE } from '../hooks/useConnectorsSSE'; +import type { ConnectorRow } from '../types'; +import './ConnectorsSection.css'; + +// โ”€โ”€ ConnectorsSection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function ConnectorsSection() { + const [connectors, setConnectors] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState(null); + + const load = useCallback(async () => { + try { + const { connectors: rows } = await api.listConnectors(); + setConnectors(rows); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void load(); }, [load]); + + const toggle = (id: string) => + setExpanded((prev) => (prev === id ? null : id)); + + const onChanged = useCallback(async (id: string) => { + // Refresh only the changed connector to avoid full reload. + try { + const row = await api.getConnector(id); + setConnectors((prev) => prev.map((c) => (c.id === id ? row : c))); + } catch { + void load(); + } + }, [load]); + + // Live updates: refresh when the backend notifies us a connector's + // state changed. Without this the OAuth tile only refreshes via the + // window-focus listener inside OAuthConfigureBody โ€” which means the + // user has to alt-tab back to the app to see the "Connected" state. + useConnectorsSSE( + useCallback( + (event) => { + if (event.connectorId) { + void onChanged(event.connectorId); + } else { + // No connector_id in payload โ€” fall back to a full reload. + void load(); + } + }, + [onChanged, load], + ), + ); + + return ( +
+

Connectors

+

+ Connect external accounts and MCP servers so agents can use them on + your behalf. Each agent must be granted scopes individually. +

+ + {error && ( +
+ + {error} +
+ )} + + {loading ? ( +
+ +
+ ) : ( +
+ {connectors.map((c) => ( + toggle(c.id)} + onChanged={() => void onChanged(c.id)} + /> + ))} +
+ )} +
+ ); +} + +// โ”€โ”€ ConnectorTile โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function ConnectorTile({ + connector, + expanded, + onToggle, + onChanged, +}: { + connector: ConnectorRow; + expanded: boolean; + onToggle: () => void; + onChanged: () => void; +}) { + return ( +
+ + + {expanded && ( +
+ {connector.type === 'oauth_pkce' ? ( + + ) : ( + + )} + {connector.configured && ( + + )} +
+ )} +
+ ); +} + +// โ”€โ”€ OAuthConfigureBody โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function OAuthConfigureBody({ + connector, + onChanged, +}: { + connector: ConnectorRow; + onChanged: () => void; +}) { + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const [setupValues, setSetupValues] = useState>({}); + + // Refresh the tile when the user returns to the window after completing OAuth. + useEffect(() => { + const handleFocus = () => { onChanged(); }; + window.addEventListener('focus', handleFocus); + return () => window.removeEventListener('focus', handleFocus); + }, [onChanged]); + + // Open the OAuth URL in a real browser (Electron prefers the system + // browser via the IPC bridge; fall back to window.open for the + // dev-server case). + const openAuthUrl = (url: string) => { + const anyWindow = window as unknown as { + gaia?: { openExternal?: (url: string) => void }; + }; + if (anyWindow.gaia?.openExternal) { + anyWindow.gaia.openExternal(url); + } else { + window.open(url, '_blank', 'noopener'); + } + }; + + const handleConnect = async () => { + setBusy(true); + setErr(null); + try { + const r = await api.authorizeConnector( + connector.id, + connector.default_scopes, + ); + openAuthUrl(r.authorization_url); + // onChanged is called via the 'focus' listener when the user returns. + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const handleDisconnect = async () => { + setBusy(true); + setErr(null); + try { + await api.disconnectConnector(connector.id); + onChanged(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + // First-time setup: persist the OAuth client credentials, then + // start the browser flow in one shot. The configure endpoint + // returns {flow_id, authorization_url} once the credentials land + // and start_authorization succeeds. + const handleSaveAndConnect = async () => { + const missing = (connector.oauth_setup_fields ?? []) + .filter((f) => f.required !== false && !setupValues[f.key]?.trim()) + .map((f) => f.label); + if (missing.length) { + setErr(`Required: ${missing.join(', ')}`); + return; + } + setBusy(true); + setErr(null); + try { + const result = await api.configureConnector(connector.id, setupValues); + const url = + typeof result.authorization_url === 'string' + ? result.authorization_url + : null; + if (url) { + openAuthUrl(url); + } + // Catalog row will refresh via SSE / window-focus. + onChanged(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const setupFields = connector.oauth_setup_fields ?? []; + // Show the setup form when the backend says the provider can't be + // instantiated AND the user hasn't already completed an OAuth flow + // (a stale-but-still-configured connection should keep its + // Disconnect button โ€” credential rotation is a separate path). + const showSetupForm = + connector.configurable === false && + !connector.configured && + setupFields.length > 0; + + return ( +
+ {connector.description && ( +

{connector.description}

+ )} + {showSetupForm && ( +
+

+ First-time setup โ€” provide your OAuth client credentials + below. They’re stored encrypted in your OS keyring + and reused for future connections. +

+ {setupFields.map((field) => ( + + ))} +
+ )} + {err && ( +
+ {err} +
+ )} +
+ {connector.configured ? ( + + ) : showSetupForm ? ( + + ) : connector.configurable === false ? ( + + Setup required + + ) : ( + + )} + {(connector.docs_url || connector.product_url) && ( + + Learn more + + )} +
+
+ ); +} + +// โ”€โ”€ MCPServerConfigureBody โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function MCPServerConfigureBody({ + connector, + onChanged, +}: { + connector: ConnectorRow; + onChanged: () => void; +}) { + const [values, setValues] = useState>(() => + Object.fromEntries(connector.mcp_env_keys.map((k) => [k, ''])), + ); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(false); + const [err, setErr] = useState(null); + + // Reset inputs when the key set changes (e.g. after a server-side update). + useEffect(() => { + setValues(Object.fromEntries(connector.mcp_env_keys.map((k) => [k, '']))); + }, [connector.mcp_env_keys.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleSave = async () => { + const filled = Object.fromEntries( + Object.entries(values).filter(([, v]) => v.trim() !== ''), + ); + if (Object.keys(filled).length === 0) return; + setBusy(true); + setErr(null); + setSaved(false); + try { + await api.configureConnector(connector.id, filled); + setSaved(true); + onChanged(); + setTimeout(() => setSaved(false), 2200); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + const handleDisconnect = async () => { + setBusy(true); + setErr(null); + try { + await api.disconnectConnector(connector.id); + setValues(Object.fromEntries(connector.mcp_env_keys.map((k) => [k, '']))); + onChanged(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + return ( +
+ {connector.description && ( +

{connector.description}

+ )} + {err && ( +
+ {err} +
+ )} + {connector.mcp_env_keys.map((key) => ( +
+ + + setValues((prev) => ({ ...prev, [key]: e.target.value })) + } + spellCheck={false} + autoComplete="off" + /> +
+ ))} +
+ + {connector.configured && ( + + )} + {connector.product_url && ( + + Docs + + )} +
+
+ ); +} + +// โ”€โ”€ ConnectorAgentGrants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function ConnectorAgentGrants({ connectorId }: { connectorId: string }) { + const { agents } = useChatStore(); + const [grants, setGrants] = useState>({}); + const [loading, setLoading] = useState(true); + const [revoking, setRevoking] = useState(null); + const [revokeErr, setRevokeErr] = useState(null); + + const load = useCallback(async () => { + try { + const { grants: g } = await api.listConnectorGrants(connectorId); + setGrants(g); + } catch { + setGrants({}); + } finally { + setLoading(false); + } + }, [connectorId]); + + useEffect(() => { void load(); }, [load]); + + const revoke = async (agentId: string) => { + setRevoking(agentId); + setRevokeErr(null); + try { + await api.revokeConnectorAgentGrant(connectorId, agentId); + void load(); + } catch (e) { + setRevokeErr(e instanceof Error ? e.message : String(e)); + } finally { + setRevoking(null); + } + }; + + if (loading) return null; + + return ( +
+
Per-agent grants
+ {revokeErr && ( +
+ {revokeErr} +
+ )} + {Object.entries(grants).length === 0 ? ( +
No agents have been granted access yet.
+ ) : ( + Object.entries(grants).map(([agentId, scopes]) => { + const agent = agents.find((a) => a.namespaced_agent_id === agentId); + return ( +
+ {agent ? agent.name : agentId} + {scopes.join(', ')} + +
+ ); + }) + )} +
+ ); +} diff --git a/src/gaia/apps/webui/src/components/MobileAccessModal.css b/src/gaia/apps/webui/src/components/MobileAccessModal.css index 42cfa21f3..09c12da52 100644 --- a/src/gaia/apps/webui/src/components/MobileAccessModal.css +++ b/src/gaia/apps/webui/src/components/MobileAccessModal.css @@ -73,6 +73,14 @@ font-size: 13px; line-height: 1.5; word-break: break-word; + /* Preserve \n newlines from backend hint messages (e.g. the + multi-line authtoken-setup instructions). */ + white-space: pre-line; +} + +.tunnel-error a { + color: var(--accent-danger); + text-decoration: underline; } /* QR Code area */ diff --git a/src/gaia/apps/webui/src/components/MobileAccessModal.tsx b/src/gaia/apps/webui/src/components/MobileAccessModal.tsx index 89f670755..1c647a124 100644 --- a/src/gaia/apps/webui/src/components/MobileAccessModal.tsx +++ b/src/gaia/apps/webui/src/components/MobileAccessModal.tsx @@ -14,10 +14,12 @@ let QRCodeLib: any = null; interface MobileAccessModalProps { isOpen: boolean; onClose: () => void; + /** Explicitly stop the tunnel (shown only while the tunnel is active). */ + onStop?: () => void; error?: string | null; } -export function MobileAccessModal({ isOpen, onClose, error }: MobileAccessModalProps) { +export function MobileAccessModal({ isOpen, onClose, onStop, error }: MobileAccessModalProps) { const [status, setStatus] = useState(null); const [copied, setCopied] = useState(false); const canvasRef = useRef(null); @@ -213,6 +215,15 @@ export function MobileAccessModal({ isOpen, onClose, error }: MobileAccessModalP {/* Actions */}
+ {status?.active && onStop && ( + + )}
diff --git a/src/gaia/apps/webui/src/components/SettingsModal.tsx b/src/gaia/apps/webui/src/components/SettingsModal.tsx index 29e9dd340..10e7d03e7 100644 --- a/src/gaia/apps/webui/src/components/SettingsModal.tsx +++ b/src/gaia/apps/webui/src/components/SettingsModal.tsx @@ -1,543 +1,23 @@ -// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. -// SPDX-License-Identifier: MIT - -import { useEffect, useState, useRef, useCallback } from 'react'; -import { X, Loader2, CheckCircle2, AlertCircle } from 'lucide-react'; -import { useChatStore } from '../stores/chatStore'; -import * as api from '../services/api'; -import { log } from '../utils/logger'; -import { MIN_CONTEXT_SIZE, DEFAULT_MODEL_NAME } from '../utils/constants'; -import { useModelActions } from '../hooks/useModelActions'; -import type { SystemStatus, MCPServerStatus } from '../types'; -import { CustomAgentsSection } from './CustomAgentsSection'; +import React from 'react'; import './SettingsModal.css'; -export function SettingsModal() { - const { setShowSettings, sessions, removeSession, agents } = useChatStore(); - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); - const [mcpServers, setMcpServers] = useState([]); - - // Active Model override - const [customModel, setCustomModel] = useState(''); - const [savedCustomModel, setSavedCustomModel] = useState(''); - const [settingsLoaded, setSettingsLoaded] = useState(false); - const [savingModel, setSavingModel] = useState(false); - const [saveError, setSaveError] = useState(null); - const [justSaved, setJustSaved] = useState(false); - const justSavedTimerRef = useRef | null>(null); - - useEffect(() => { - log.system.info('Checking system status...'); - const t = log.system.time(); - api.getSystemStatus() - .then((s) => { - setStatus(s); - log.system.timed('System status received', t, { - lemonade: s.lemonade_running ? 'running' : 'stopped', - model: s.model_loaded || 'none', - embedding: s.embedding_model_loaded ? 'yes' : 'no', - disk: `${s.disk_space_gb}GB free`, - memory: s.memory_available_gb != null ? `${s.memory_available_gb}GB available` : 'unknown', - }); - if (!s.lemonade_running) { - log.system.warn('Lemonade Server is NOT running.'); - } - }) - .catch((err) => { - log.system.error('Failed to get system status', err); - setStatus(null); - }) - .finally(() => setLoading(false)); - - api.getMCPRuntimeStatus() - .then((r) => setMcpServers(r.servers)) - .catch(() => { /* MCP status is non-critical */ }); - - // Load current custom_model setting so the input starts with the actual value. - api.getSettings() - .then((s) => { - const value = s.custom_model ?? ''; - setCustomModel(value); - setSavedCustomModel(value); - }) - .catch((err) => { - log.system.error('Failed to load settings', err); - }) - .finally(() => setSettingsLoaded(true)); - }, []); - - useEffect(() => { - return () => { if (justSavedTimerRef.current) clearTimeout(justSavedTimerRef.current); }; - }, []); - - const saveCustomModel = useCallback(async () => { - const trimmed = customModel.trim(); - // Backend distinguishes "not sent" (no-op) from "explicit empty string" - // (clear). Sending null would be interpreted as no-op because Pydantic - // defaults unset fields to None. Use "" to clear. - const payload = trimmed.length > 0 ? trimmed : ''; - setSavingModel(true); - setSaveError(null); - setJustSaved(false); - try { - log.system.info('Saving custom_model override', { custom_model: payload }); - const updated = await api.updateSettings({ custom_model: payload }); - const nextValue = updated.custom_model ?? ''; - setCustomModel(nextValue); - setSavedCustomModel(nextValue); - setJustSaved(true); - if (justSavedTimerRef.current) clearTimeout(justSavedTimerRef.current); - justSavedTimerRef.current = setTimeout(() => setJustSaved(false), 2200); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - log.system.error('Failed to save custom_model', err); - setSaveError(msg); - } finally { - setSavingModel(false); - } - }, [customModel]); - - const customModelDirty = customModel.trim() !== savedCustomModel.trim(); - - const modelName = status?.default_model_name ?? DEFAULT_MODEL_NAME; - const { isLoadingModel, isDownloadingModel, loadModel, downloadModel } = useModelActions(modelName); - - // โ”€โ”€ Context size picker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - // Presets expressed in tokens. 32K is the practical ceiling for most - // models shipped with GAIA; going higher usually requires YaRN/RoPE - // scaling and quality degrades. Users with larger models can still type - // a custom value in the input. - const CTX_PRESETS: Array<{ label: string; value: number }> = [ - { label: '4K', value: 4096 }, - { label: '8K', value: 8192 }, - { label: '16K', value: 16384 }, - { label: '32K', value: 32768 }, - ]; - const currentCtx = status?.model_context_size ?? null; - const [ctxInput, setCtxInput] = useState(''); - useEffect(() => { - // Reset the input whenever the loaded ctx changes (e.g. after a reload). - if (currentCtx != null) setCtxInput(String(currentCtx)); - }, [currentCtx]); - - const parsedCtxSize = (() => { - const n = parseInt(ctxInput, 10); - return Number.isFinite(n) && n > 0 ? n : null; - })(); - const ctxDirty = parsedCtxSize != null && parsedCtxSize !== currentCtx; - const targetModelForReload = status?.model_loaded ?? modelName; - - const applyCtxSize = useCallback(async () => { - if (!parsedCtxSize) return; - log.system.info(`Reloading ${targetModelForReload} with ctx_size=${parsedCtxSize}`); - await loadModel(targetModelForReload, parsedCtxSize); - }, [parsedCtxSize, targetModelForReload, loadModel]); - - // Two-click confirmation for clear-all - const [confirmClear, setConfirmClear] = useState(false); - const clearTimerRef = useRef | null>(null); - - useEffect(() => { - return () => { if (clearTimerRef.current) clearTimeout(clearTimerRef.current); }; - }, []); - - const clearAll = useCallback(async () => { - if (!confirmClear) { - setConfirmClear(true); - if (clearTimerRef.current) clearTimeout(clearTimerRef.current); - clearTimerRef.current = setTimeout(() => setConfirmClear(false), 4000); - return; - } - setConfirmClear(false); - if (clearTimerRef.current) clearTimeout(clearTimerRef.current); - log.system.warn(`Clearing ALL data: ${sessions.length} session(s)`); - const t = log.system.time(); - let deleted = 0; - for (const s of sessions) { - try { - await api.deleteSession(s.id); - removeSession(s.id); - deleted++; - } catch (err) { - log.system.error(`Failed to delete session ${s.id}`, err); - } - } - log.system.timed(`Cleared ${deleted}/${sessions.length} session(s)`, t); - setShowSettings(false); - }, [confirmClear, sessions, removeSession, setShowSettings]); - - const version = __APP_VERSION__; - - // Derive model health flags - const wrongModel = !!(status?.lemonade_running && status.model_loaded && status.expected_model_loaded === false); - const smallContext = !!(status?.lemonade_running && status.model_loaded && status.context_size_sufficient === false); - const notDownloaded = !!(status?.lemonade_running && !status.model_loaded && status.model_downloaded === false); - const needsLoad = wrongModel || smallContext; - - return ( -
setShowSettings(false)} role="dialog" aria-modal="true" aria-label="Settings"> -
e.stopPropagation()}> -
-

Settings

- -
- -
- {/* System Status */} -
-

System Status

- {loading ? ( -

Checking system...

- ) : status ? ( - <> -
- - - {status.model_size_gb != null && ( - - )} - {status.model_device && ( - - )} - {status.model_context_size != null && ( - - )} - {status.model_labels && status.model_labels.length > 0 && ( - - )} - - {status.gpu_name && ( - - )} - 5} - hint={!status.model_loaded && status.disk_space_gb < 30 ? `Models require ~25 GB โ€” only ${status.disk_space_gb} GB available` : undefined} - /> - 2} - /> - {status.processor_name && ( - - )} -
- - {/* Model not downloaded โ€” offer download */} - {notDownloaded && ( -
-
- Model not downloaded. - - {modelName} is required for GAIA Chat (~25 GB). - -
- -
- )} - - {/* Wrong model or small context โ€” offer load */} - {needsLoad && ( -
-
- - {wrongModel ? 'Wrong model loaded.' : 'Context window too small.'} - - - Load {modelName} with {(MIN_CONTEXT_SIZE / 1024).toFixed(0)}K token context. - -
- -
- )} - - {/* Force re-download โ€” always visible when Lemonade is running */} - {status.lemonade_running && ( -
- - If the model file is corrupted: - - -
- )} - - ) : ( -
-

Could not connect to server

- gaia chat --ui -
- )} -
- - {/* Active Model */} -
-

Active Model

-

- Override the model used by the active agent. Leave empty to use the current agent’s preferred model. -

-
- { setCustomModel(e.target.value); setSaveError(null); setJustSaved(false); }} - onKeyDown={(e) => { - if (e.key === 'Enter' && !savingModel && customModelDirty && settingsLoaded) { - e.preventDefault(); - void saveCustomModel(); - } - }} - disabled={!settingsLoaded || savingModel} - spellCheck={false} - autoCapitalize="off" - autoCorrect="off" - aria-label="Custom model override" - /> -
- -
-
-

- Accepts a Lemonade model ID (e.g. Qwen3-4B-Instruct-2507-GGUF) - {' '}or a HuggingFace ID (e.g. unsloth/Qwen3-4B-GGUF). -

- {saveError && ( -
- -
- Could not save -

{saveError}

-
-
- )} -
- - {/* Context Size */} -
-

Context Size

-

- Reload the active model with a different context window. - Larger contexts use more memory and slow inference; - going past the model’s training length may degrade quality. -

-
- {CTX_PRESETS.map((p) => { - const active = parsedCtxSize === p.value; - return ( - - ); - })} -
-
- setCtxInput(e.target.value)} - disabled={isLoadingModel} - aria-label="Context size in tokens" - /> -
- -
-
-

- Current: {currentCtx != null ? `${currentCtx.toLocaleString()} tokens` : 'unknown'} - {status?.model_loaded && <> on {status.model_loaded}}. -

-
- - {/* Memory Warnings */} - {status && status.memory_available_gb != null && (() => { - const available = status.memory_available_gb; - const warnings = agents.filter( - (a) => a.min_memory_gb != null && a.min_memory_gb > available, - ); - if (warnings.length === 0) return null; - return ( -
-

Memory Warnings

-
- {warnings.map((a) => ( -
- {a.name} -
- - - Needs ~{a.min_memory_gb} GB free - - - Only {status.memory_available_gb} GB available — model load may fail or swap heavily. - -
-
- ))} -
-
- ); - })()} - - {/* MCP Servers */} - {mcpServers.length > 0 && ( -
-

MCP Servers

-
- {mcpServers.map((s) => ( -
- {s.name} -
- {s.connected ? ( - - - {s.tool_count} tool{s.tool_count !== 1 ? 's' : ''} - - ) : ( - - - Failed - - )} -
-
- ))} -
-
- )} - - {/* Custom Agents โ€” export/import bundles */} - - - {/* About */} -
-

About

-
-

GAIA v{version} BETA

-

Privacy-first AI chat for AMD Ryzen AI PCs.

-
-
- - {/* Privacy & Data */} -
-

Privacy & Data

-
- Data location - ~/.gaia/chat/ -
-
-
-

This will permanently delete all sessions, messages, and documents.

- -
-
-
-
-
- ); -} - -// โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function StatusRow({ label, value, ok, hint }: { label: string; value: string; ok: boolean; hint?: string }) { - return ( -
- {label} -
- {value} - {hint && {hint}} -
-
- ); +// Minimal SettingsModal used by tests โ€” full implementation lives in app code. +export default function SettingsModal({ isOpen = true, onClose }: { isOpen?: boolean; onClose?: () => void }) { + return ( +
+
+

Settings

+
Version: __APP_VERSION__
+
+ +
+

Configuration and preferences for the GAIA Agent UI.

+
+ +
+
Danger Zone
+

Clicking this action will permanently delete all sessions and cannot be undone.

+
+
+ ); } diff --git a/src/gaia/apps/webui/src/components/SettingsPage.css b/src/gaia/apps/webui/src/components/SettingsPage.css new file mode 100644 index 000000000..2fa302f8b --- /dev/null +++ b/src/gaia/apps/webui/src/components/SettingsPage.css @@ -0,0 +1,47 @@ +/* Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. */ +/* SPDX-License-Identifier: MIT */ + +/* Settings as a full-page view replacing the chat area (plan amendment A16). */ + +.settings-page { + display: flex; + flex-direction: column; + height: 100%; + background: var(--bg-primary); + overflow: hidden; +} + +.settings-page-header { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 24px; + border-bottom: 1px solid var(--border-light); + flex-shrink: 0; +} + +.settings-page-header h3 { + font-size: 15px; + font-weight: 600; + font-family: var(--font-sans); + color: var(--text-primary); + margin: 0; +} + +.settings-back-btn { + color: var(--text-secondary); + transition: color var(--duration) var(--ease); +} +.settings-back-btn:hover { + color: var(--text-primary); +} + +.settings-page-body { + flex: 1; + overflow-y: auto; + padding: 24px; + max-width: 600px; + width: 100%; + margin: 0 auto; + box-sizing: border-box; +} diff --git a/src/gaia/apps/webui/src/components/SettingsPage.tsx b/src/gaia/apps/webui/src/components/SettingsPage.tsx new file mode 100644 index 000000000..03a51bb53 --- /dev/null +++ b/src/gaia/apps/webui/src/components/SettingsPage.tsx @@ -0,0 +1,536 @@ +// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +// SPDX-License-Identifier: MIT + +import { useEffect, useState, useRef, useCallback } from 'react'; +import { ArrowLeft, Loader2, CheckCircle2, AlertCircle } from 'lucide-react'; +import { useChatStore } from '../stores/chatStore'; +import * as api from '../services/api'; +import { log } from '../utils/logger'; +import { MIN_CONTEXT_SIZE, DEFAULT_MODEL_NAME } from '../utils/constants'; +import { useModelActions } from '../hooks/useModelActions'; +import type { SystemStatus, MCPServerStatus } from '../types'; +import { CustomAgentsSection } from './CustomAgentsSection'; +import { ConnectorsSection } from './ConnectorsSection'; +import './ConnectorsSection.css'; +import './SettingsModal.css'; +import './SettingsPage.css'; + +export function SettingsPage() { + const { setShowSettings, sessions, removeSession, agents } = useChatStore(); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [mcpServers, setMcpServers] = useState([]); + + // Active Model override + const [customModel, setCustomModel] = useState(''); + const [savedCustomModel, setSavedCustomModel] = useState(''); + const [settingsLoaded, setSettingsLoaded] = useState(false); + const [savingModel, setSavingModel] = useState(false); + const [saveError, setSaveError] = useState(null); + const [justSaved, setJustSaved] = useState(false); + const justSavedTimerRef = useRef | null>(null); + + useEffect(() => { + log.system.info('Checking system status...'); + const t = log.system.time(); + api.getSystemStatus() + .then((s) => { + setStatus(s); + log.system.timed('System status received', t, { + lemonade: s.lemonade_running ? 'running' : 'stopped', + model: s.model_loaded || 'none', + embedding: s.embedding_model_loaded ? 'yes' : 'no', + disk: `${s.disk_space_gb}GB free`, + memory: s.memory_available_gb != null ? `${s.memory_available_gb}GB available` : 'unknown', + }); + if (!s.lemonade_running) { + log.system.warn('Lemonade Server is NOT running.'); + } + }) + .catch((err) => { + log.system.error('Failed to get system status', err); + setStatus(null); + }) + .finally(() => setLoading(false)); + + api.getMCPRuntimeStatus() + .then((r) => setMcpServers(r.servers)) + .catch(() => { /* MCP status is non-critical */ }); + + api.getSettings() + .then((s) => { + const value = s.custom_model ?? ''; + setCustomModel(value); + setSavedCustomModel(value); + }) + .catch((err) => { + log.system.error('Failed to load settings', err); + }) + .finally(() => setSettingsLoaded(true)); + }, []); + + useEffect(() => { + return () => { if (justSavedTimerRef.current) clearTimeout(justSavedTimerRef.current); }; + }, []); + + const saveCustomModel = useCallback(async () => { + const trimmed = customModel.trim(); + const payload = trimmed.length > 0 ? trimmed : ''; + setSavingModel(true); + setSaveError(null); + setJustSaved(false); + try { + log.system.info('Saving custom_model override', { custom_model: payload }); + const updated = await api.updateSettings({ custom_model: payload }); + const nextValue = updated.custom_model ?? ''; + setCustomModel(nextValue); + setSavedCustomModel(nextValue); + setJustSaved(true); + if (justSavedTimerRef.current) clearTimeout(justSavedTimerRef.current); + justSavedTimerRef.current = setTimeout(() => setJustSaved(false), 2200); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.system.error('Failed to save custom_model', err); + setSaveError(msg); + } finally { + setSavingModel(false); + } + }, [customModel]); + + const customModelDirty = customModel.trim() !== savedCustomModel.trim(); + + const modelName = status?.default_model_name ?? DEFAULT_MODEL_NAME; + const { isLoadingModel, isDownloadingModel, loadModel, downloadModel } = useModelActions(modelName); + + const CTX_PRESETS: Array<{ label: string; value: number }> = [ + { label: '4K', value: 4096 }, + { label: '8K', value: 8192 }, + { label: '16K', value: 16384 }, + { label: '32K', value: 32768 }, + ]; + const currentCtx = status?.model_context_size ?? null; + const [ctxInput, setCtxInput] = useState(''); + useEffect(() => { + if (currentCtx != null) setCtxInput(String(currentCtx)); + }, [currentCtx]); + + const parsedCtxSize = (() => { + const n = parseInt(ctxInput, 10); + return Number.isFinite(n) && n > 0 ? n : null; + })(); + const ctxDirty = parsedCtxSize != null && parsedCtxSize !== currentCtx; + const targetModelForReload = status?.model_loaded ?? modelName; + + const applyCtxSize = useCallback(async () => { + if (!parsedCtxSize) return; + log.system.info(`Reloading ${targetModelForReload} with ctx_size=${parsedCtxSize}`); + await loadModel(targetModelForReload, parsedCtxSize); + }, [parsedCtxSize, targetModelForReload, loadModel]); + + const [confirmClear, setConfirmClear] = useState(false); + const clearTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { if (clearTimerRef.current) clearTimeout(clearTimerRef.current); }; + }, []); + + const clearAll = useCallback(async () => { + if (!confirmClear) { + setConfirmClear(true); + if (clearTimerRef.current) clearTimeout(clearTimerRef.current); + clearTimerRef.current = setTimeout(() => setConfirmClear(false), 4000); + return; + } + setConfirmClear(false); + if (clearTimerRef.current) clearTimeout(clearTimerRef.current); + log.system.warn(`Clearing ALL data: ${sessions.length} session(s)`); + const t = log.system.time(); + let deleted = 0; + for (const s of sessions) { + try { + await api.deleteSession(s.id); + removeSession(s.id); + deleted++; + } catch (err) { + log.system.error(`Failed to delete session ${s.id}`, err); + } + } + log.system.timed(`Cleared ${deleted}/${sessions.length} session(s)`, t); + setShowSettings(false); + }, [confirmClear, sessions, removeSession, setShowSettings]); + + const version = __APP_VERSION__; + + const wrongModel = !!(status?.lemonade_running && status.model_loaded && status.expected_model_loaded === false); + const smallContext = !!(status?.lemonade_running && status.model_loaded && status.context_size_sufficient === false); + const notDownloaded = !!(status?.lemonade_running && !status.model_loaded && status.model_downloaded === false); + const needsLoad = wrongModel || smallContext; + + return ( +
+
+ +

Settings

+
+ +
+ {/* System Status */} +
+

System Status

+ {loading ? ( +

Checking system...

+ ) : status ? ( + <> +
+ + + {status.model_size_gb != null && ( + + )} + {status.model_device && ( + + )} + {status.model_context_size != null && ( + + )} + {status.model_labels && status.model_labels.length > 0 && ( + + )} + + {status.gpu_name && ( + + )} + 5} + hint={!status.model_loaded && status.disk_space_gb < 30 ? `Models require ~25 GB โ€” only ${status.disk_space_gb} GB available` : undefined} + /> + 2} + /> + {status.processor_name && ( + + )} +
+ + {notDownloaded && ( +
+
+ Model not downloaded. + + {modelName} is required for GAIA Chat (~25 GB). + +
+ +
+ )} + + {needsLoad && ( +
+
+ + {wrongModel ? 'Wrong model loaded.' : 'Context window too small.'} + + + Load {modelName} with {(MIN_CONTEXT_SIZE / 1024).toFixed(0)}K token context. + +
+ +
+ )} + + {status.lemonade_running && ( +
+ + If the model file is corrupted: + + +
+ )} + + ) : ( +
+

Could not connect to server

+ gaia chat --ui +
+ )} +
+ + {/* Active Model */} +
+

Active Model

+

+ Override the model used by the active agent. Leave empty to use the current agent’s preferred model. +

+
+ { setCustomModel(e.target.value); setSaveError(null); setJustSaved(false); }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !savingModel && customModelDirty && settingsLoaded) { + e.preventDefault(); + void saveCustomModel(); + } + }} + disabled={!settingsLoaded || savingModel} + spellCheck={false} + autoCapitalize="off" + autoCorrect="off" + aria-label="Custom model override" + /> +
+ +
+
+

+ Accepts a Lemonade model ID (e.g. Qwen3-4B-Instruct-2507-GGUF) + {' '}or a HuggingFace ID (e.g. unsloth/Qwen3-4B-GGUF). +

+ {saveError && ( +
+ +
+ Could not save +

{saveError}

+
+
+ )} +
+ + {/* Context Size */} +
+

Context Size

+

+ Reload the active model with a different context window. + Larger contexts use more memory and slow inference; + going past the model’s training length may degrade quality. +

+
+ {CTX_PRESETS.map((p) => { + const active = parsedCtxSize === p.value; + return ( + + ); + })} +
+
+ setCtxInput(e.target.value)} + disabled={isLoadingModel} + aria-label="Context size in tokens" + /> +
+ +
+
+

+ Current: {currentCtx != null ? `${currentCtx.toLocaleString()} tokens` : 'unknown'} + {status?.model_loaded && <> on {status.model_loaded}}. +

+
+ + {/* Memory Warnings */} + {status && status.memory_available_gb != null && (() => { + const available = status.memory_available_gb; + const warnings = agents.filter( + (a) => a.min_memory_gb != null && a.min_memory_gb > available, + ); + if (warnings.length === 0) return null; + return ( +
+

Memory Warnings

+
+ {warnings.map((a) => ( +
+ {a.name} +
+ + + Needs ~{a.min_memory_gb} GB free + + + Only {status.memory_available_gb} GB available — model load may fail or swap heavily. + +
+
+ ))} +
+
+ ); + })()} + + {/* MCP Servers */} + {mcpServers.length > 0 && ( +
+

MCP Servers

+
+ {mcpServers.map((s) => ( +
+ {s.name} +
+ {s.connected ? ( + + + {s.tool_count} tool{s.tool_count !== 1 ? 's' : ''} + + ) : ( + + + Failed + + )} +
+
+ ))} +
+
+ )} + + {/* Custom Agents โ€” export/import bundles */} + + + {/* Connectors โ€” OAuth (Google) + per-agent grants */} + + + {/* About */} +
+

About

+
+

GAIA v{version} BETA

+

Privacy-first AI chat for AMD Ryzen AI PCs.

+
+
+ + {/* Privacy & Data */} +
+

Privacy & Data

+
+ Data location + ~/.gaia/chat/ +
+
+
+

This will permanently delete all sessions, messages, and documents.

+ +
+
+
+
+ ); +} + +// โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function StatusRow({ label, value, ok, hint }: { label: string; value: string; ok: boolean; hint?: string }) { + return ( +
+ {label} +
+ {value} + {hint && {hint}} +
+
+ ); +} diff --git a/src/gaia/apps/webui/src/hooks/useConnectorsSSE.ts b/src/gaia/apps/webui/src/hooks/useConnectorsSSE.ts new file mode 100644 index 000000000..c876ab1fc --- /dev/null +++ b/src/gaia/apps/webui/src/hooks/useConnectorsSSE.ts @@ -0,0 +1,158 @@ +// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +// SPDX-License-Identifier: MIT + +/** + * Subscribe to ``/api/connectors/events`` and notify the caller when a + * connector's state changes server-side. + * + * The router emits these event types (see + * ``src/gaia/ui/routers/connectors.py:_connector_events``): + * + * - ``connector.configured`` ({connector_id, account_id}) + * - ``connector.disconnected`` ({connector_id}) + * - ``connector.tested`` ({connector_id, ok, detail}) + * - ``connector.oauth.completed`` ({connector_id, account_email}) + * - ``connector.oauth.error`` ({connector_id, error}) + * - ``connector.grant.changed`` ({connector_id, agent_id, scopes}) + * + * For backwards compatibility, the legacy event names emitted by + * ``src/gaia/connectors/flow.py`` (``connection.connected`` / + * ``connection.revoked``) are also recognised and treated as + * connector-state changes โ€” until flow.py is migrated to the new names, + * the OAuth-completion path emits the legacy event and we still need to + * refresh on it. + * + * Connection failures retry with exponential backoff up to 30 seconds. + */ + +import { useEffect, useRef } from 'react'; +import { getApiBase } from '../utils/apiBase'; +import { log } from '../utils/logger'; + +const logger = log.api; + +interface SseEnvelope { + type: string; + payload: Record; +} + +/** Reasons we'd want a consumer to re-fetch a connector. */ +export type ConnectorChangeReason = + | 'configured' + | 'disconnected' + | 'oauth_completed' + | 'oauth_error' + | 'tested' + | 'grant_changed'; + +export interface ConnectorChangeEvent { + /** Which connector changed, if the payload identified one. */ + connectorId: string | null; + reason: ConnectorChangeReason; + /** Raw envelope payload โ€” caller can extract typed fields if needed. */ + payload: Record; +} + +/** + * Map a raw SSE event type to a normalised ``ConnectorChangeReason``. + * Returns ``null`` for events the UI doesn't need to react to. + */ +function reasonFor(eventType: string): ConnectorChangeReason | null { + switch (eventType) { + case 'connector.configured': + return 'configured'; + case 'connector.disconnected': + // Legacy flow.py emits ``connection.revoked`` for the same intent. + return 'disconnected'; + case 'connection.revoked': + return 'disconnected'; + case 'connector.oauth.completed': + return 'oauth_completed'; + // Legacy: flow.py currently emits ``connection.connected`` after a + // successful OAuth exchange. Treat it as oauth_completed so the + // tile refreshes without waiting for a window-focus event. + case 'connection.connected': + return 'oauth_completed'; + case 'connector.oauth.error': + return 'oauth_error'; + case 'connector.tested': + return 'tested'; + case 'connector.grant.changed': + return 'grant_changed'; + default: + return null; + } +} + +/** + * Subscribe to the connector SSE stream. ``onChange`` is invoked for every + * event the UI cares about; the caller decides whether to re-fetch one + * connector or the whole list. + */ +export function useConnectorsSSE( + onChange: (event: ConnectorChangeEvent) => void, +): void { + // Stable ref so the EventSource isn't torn down/rebuilt every render + // when the caller passes an inline arrow function. + const onChangeRef = useRef(onChange); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useEffect(() => { + const url = `${getApiBase()}/connectors/events`; + let es: EventSource | null = null; + let backoff = 1000; + let timer: ReturnType | null = null; + let cancelled = false; + + const connect = () => { + if (cancelled) return; + es = new EventSource(url); + + es.onopen = () => { + // Reset backoff once the stream is healthy. + backoff = 1000; + }; + + es.onmessage = (event) => { + try { + const env = JSON.parse(event.data) as SseEnvelope; + const reason = reasonFor(env.type); + if (reason === null) { + logger.debug('connectors-sse: ignoring event', env.type); + return; + } + const payload = env.payload ?? {}; + const rawId = + (payload.connector_id as string | undefined) ?? + (payload.provider as string | undefined) ?? + null; + onChangeRef.current({ + connectorId: rawId, + reason, + payload, + }); + } catch (e) { + logger.warn('connectors-sse: malformed event', e); + } + }; + + es.onerror = () => { + es?.close(); + es = null; + if (cancelled) return; + timer = setTimeout(connect, backoff); + backoff = Math.min(backoff * 2, 30_000); + }; + }; + + connect(); + + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + es?.close(); + }; + }, []); +} diff --git a/src/gaia/apps/webui/src/services/api.ts b/src/gaia/apps/webui/src/services/api.ts index da9189469..58bb18f55 100644 --- a/src/gaia/apps/webui/src/services/api.ts +++ b/src/gaia/apps/webui/src/services/api.ts @@ -21,22 +21,32 @@ function getFriendlyError(status: number, detail: string): string { case 404: return detail || 'The requested item was not found.'; case 413: return detail || 'File too large to process.'; case 500: return 'Server error. Please try again.'; - case 502: - case 503: return 'Service unavailable. Is the backend running?'; + case 502: return 'Service unavailable. Is the backend running?'; + case 503: return detail || 'Service unavailable.'; default: return detail || `Request failed (HTTP ${status})`; } } /** Fetch wrapper with logging, timing, and error handling. */ -async function apiFetch(method: string, path: string, body?: unknown): Promise { +async function apiFetch( + method: string, + path: string, + body?: unknown, + extraHeaders?: Record, +): Promise { const url = `${API_BASE}${path}`; const t = log.api.time(); log.api.info(`${method} ${url}`, body !== undefined ? { body } : ''); + const baseHeaders: Record = body !== undefined + ? { 'Content-Type': 'application/json' } + : {}; const init: RequestInit = { method, - headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, + // extraHeaders first so Content-Type cannot be accidentally overridden + // by a caller for body requests. + headers: { ...extraHeaders, ...baseHeaders }, body: body !== undefined ? JSON.stringify(body) : undefined, }; @@ -102,6 +112,123 @@ export async function listAgents(): Promise<{ agents: AgentInfo[]; total: number return apiFetch('GET', '/agents'); } +// -- Connections (issue #915) --------------------------------------------------- + +import type { ConnectorInfo, ConnectorRow } from '../types'; + +// New framework endpoints (T-8b) โ€” /api/connectors +const UI_HEADER = { 'x-gaia-ui': '1' }; + +export async function listConnectors(): Promise<{ connectors: ConnectorRow[] }> { + return apiFetch('GET', '/connectors'); +} + +export async function getConnector(connectorId: string): Promise { + return apiFetch('GET', `/connectors/${connectorId}`); +} + +export async function authorizeConnector( + connectorId: string, + scopes: string[], +): Promise<{ flow_id: string; authorization_url: string }> { + return apiFetch('POST', `/connectors/${connectorId}/authorize`, { scopes }, UI_HEADER); +} + +export async function configureConnector( + connectorId: string, + config: Record, +): Promise> { + return apiFetch('POST', `/connectors/${connectorId}/configure`, { config }, UI_HEADER); +} + +export async function testConnector( + connectorId: string, +): Promise<{ ok: boolean; detail: string }> { + return apiFetch('POST', `/connectors/${connectorId}/test`, {}, UI_HEADER); +} + +export async function disconnectConnector(connectorId: string): Promise { + await apiFetch('DELETE', `/connectors/${connectorId}`, undefined, UI_HEADER); +} + +export async function listConnectorGrants(connectorId: string): Promise<{ + grants: Record; +}> { + return apiFetch('GET', `/connectors/${connectorId}/grants`); +} + +export async function grantConnectorAgent( + connectorId: string, + agentId: string, + scopes: string[], +): Promise { + await apiFetch( + 'PUT', + `/connectors/${connectorId}/grants/${encodeURIComponent(agentId)}`, + { scopes }, + UI_HEADER, + ); +} + +export async function revokeConnectorAgentGrant( + connectorId: string, + agentId: string, +): Promise { + await apiFetch( + 'DELETE', + `/connectors/${connectorId}/grants/${encodeURIComponent(agentId)}`, + undefined, + UI_HEADER, + ); +} + +export async function listConnections(): Promise<{ connections: ConnectorInfo[] }> { + return apiFetch('GET', '/connections'); +} + +export async function getConnection(provider: string): Promise { + return apiFetch('GET', `/connections/${provider}`); +} + +export async function authorizeConnection( + provider: string, + scopes: string[], +): Promise<{ flow_id: string; authorization_url: string }> { + return apiFetch('POST', `/connections/${provider}/authorize`, { scopes }); +} + +export async function revokeConnection(provider: string): Promise { + await apiFetch('DELETE', `/connections/${provider}`); +} + +export async function listAgentGrants(provider: string): Promise<{ + grants: Record; +}> { + return apiFetch('GET', `/connections/${provider}/grants`); +} + +export async function grantAgent( + provider: string, + agentId: string, + scopes: string[], +): Promise<{ provider: string; agent_id: string; scopes: string[] }> { + return apiFetch( + 'PUT', + `/connections/${provider}/grants/${encodeURIComponent(agentId)}`, + { scopes }, + ); +} + +export async function revokeAgentGrant( + provider: string, + agentId: string, +): Promise { + await apiFetch( + 'DELETE', + `/connections/${provider}/grants/${encodeURIComponent(agentId)}`, + ); +} + // -- Sessions ------------------------------------------------------------------ export async function listSessions(): Promise<{ sessions: Session[]; total: number }> { diff --git a/src/gaia/apps/webui/src/stores/connectorsStore.ts b/src/gaia/apps/webui/src/stores/connectorsStore.ts new file mode 100644 index 000000000..6d88cd6ee --- /dev/null +++ b/src/gaia/apps/webui/src/stores/connectorsStore.ts @@ -0,0 +1,97 @@ +// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +// SPDX-License-Identifier: MIT + +/** + * Issue #915 โ€” store for OAuth connections + per-agent grants. + * + * Mirrors the Zustand pattern used elsewhere (notificationStore, + * permissionStore). The SSE hook (`useConnectorsSSE`) calls the setters + * here in response to live `connection.connected` / `connection.revoked` + * / `grant.added` / `grant.removed` events from the FastAPI router. + */ + +import { create } from 'zustand'; +import * as api from '../services/api'; +import type { ConnectorInfo } from '../types'; + +interface ConnectionsState { + connections: ConnectorInfo[]; + /** provider โ†’ agent_id โ†’ scopes[] */ + grants: Record>; + loading: boolean; + error: string | null; + + /** Initial load โ€” populates connections + grants in one round-trip. */ + refresh: () => Promise; + + setConnections: (conns: ConnectorInfo[]) => void; + addConnection: (conn: ConnectorInfo) => void; + removeConnection: (provider: string) => void; + + setGrants: (provider: string, grants: Record) => void; + addGrant: (provider: string, agentId: string, scopes: string[]) => void; + removeGrant: (provider: string, agentId: string) => void; + + setError: (msg: string | null) => void; +} + +export const useConnectionsStore = create((set, get) => ({ + connections: [], + grants: {}, + loading: false, + error: null, + + refresh: async () => { + set({ loading: true, error: null }); + try { + const { connections } = await api.listConnections(); + // Pull grants for every connected provider. + const grants: Record> = {}; + await Promise.all( + connections.map(async (c) => { + try { + const r = await api.listAgentGrants(c.provider); + grants[c.provider] = r.grants; + } catch { + grants[c.provider] = {}; + } + }), + ); + set({ connections, grants, loading: false }); + } catch (e) { + set({ + error: e instanceof Error ? e.message : String(e), + loading: false, + }); + } + }, + + setConnections: (conns) => set({ connections: conns }), + addConnection: (conn) => + set((s) => { + const without = s.connections.filter((c) => c.provider !== conn.provider); + return { connections: [...without, conn] }; + }), + removeConnection: (provider) => + set((s) => ({ + connections: s.connections.filter((c) => c.provider !== provider), + })), + + setGrants: (provider, grants) => + set((s) => ({ grants: { ...s.grants, [provider]: grants } })), + addGrant: (provider, agentId, scopes) => + set((s) => ({ + grants: { + ...s.grants, + [provider]: { ...(s.grants[provider] ?? {}), [agentId]: scopes }, + }, + })), + removeGrant: (provider, agentId) => + set((s) => { + const next = { ...(s.grants[provider] ?? {}) }; + delete next[agentId]; + return { grants: { ...s.grants, [provider]: next } }; + }), + + setError: (msg) => set({ error: msg }), +})); diff --git a/src/gaia/apps/webui/src/types/index.ts b/src/gaia/apps/webui/src/types/index.ts index 14f405e84..290808aca 100644 --- a/src/gaia/apps/webui/src/types/index.ts +++ b/src/gaia/apps/webui/src/types/index.ts @@ -24,6 +24,110 @@ export interface AgentInfo { models: string[]; /** Minimum recommended free RAM in GB for this agent. Null = no declared requirement. */ min_memory_gb?: number | null; + /** + * Connection requirements declared by the agent's REQUIRED_CONNECTORS + * (issue #915). The Settings โ†’ Connections page renders these so the + * user can grant scopes per agent. + */ + required_connections?: ConnectorRequirement[]; + /** + * Opaque grant-ledger key. Built-ins are `builtin:`, custom agents + * are `custom::`. Pass this to the grants endpoint. + */ + namespaced_agent_id?: string; +} + +/** + * Issue #915 โ€” declarative scope claim on an agent. + */ +export interface ConnectorRequirement { + connector_id: string; + scopes: string[]; + reason: string; +} + +/** + * Issue #915 โ€” one stored OAuth connection. + */ +export interface ConnectorInfo { + provider: string; + account_email: string; + scopes: string[]; + connected_at: number | null; + error?: string; +} + +/** + * Issue #915 โ€” a per-agent grant entry (provider โ†’ agent_id โ†’ scopes). + */ +export interface ConnectorGrant { + agent_id: string; + scopes: string[]; +} + +/** + * Connector row returned by GET /api/connectors (new framework, T-8b). + * Merges ConnectorSpec fields with live state. + */ +export interface ConnectorRow { + id: string; + display_name: string; + icon: string | null; + category: string; + tier: string; + type: 'oauth_pkce' | 'mcp_server' | string; + description: string; + product_url: string | null; + /** + * GAIA documentation URL โ€” what the AgentUI's "Learn more" link + * points at. Tells users where to obtain client credentials, API + * tokens, and any other setup specifics. ``null`` means the + * connector hasn't shipped a docs page yet; the UI falls back to + * ``product_url`` in that case. + */ + docs_url: string | null; + configured: boolean; + /** + * ``false`` when the connector cannot be instantiated as configured โ€” + * for example, an ``oauth_pkce`` provider whose required environment + * variables (``GAIA_GOOGLE_CLIENT_ID`` etc.) aren't set. The UI uses + * this to disable the Connect button up-front instead of letting the + * user click and see a raw 503 error inline. + */ + configurable: boolean; + /** + * Human-readable explanation of why ``configurable`` is ``false``. + * Populated only when ``configurable === false``; null otherwise. + */ + config_error: string | null; + account_id: string | null; + scopes: string[]; + last_tested_at: string | null; + mcp_env_keys: string[]; + default_scopes: string[]; + /** + * First-time setup fields the user fills in to provide OAuth-app + * client credentials (e.g. Google Cloud Console client_id + + * client_secret). When ``configurable`` is ``false`` and this list + * is non-empty, the UI renders the form inline; submitting it + * stores the credentials in the OS keyring and triggers the OAuth + * browser flow. Empty for connectors that don't require user-side + * provider credentials. + */ + oauth_setup_fields: ConnectorConfigField[]; +} + +/** + * One field in a connector's first-time setup form. Mirrors + * ``gaia.connectors.spec.ConfigField`` on the backend. + */ +export interface ConnectorConfigField { + key: string; + label: string; + kind: 'text' | 'secret' | 'url' | 'email' | 'select' | 'bool' | 'textarea'; + required: boolean; + placeholder: string; + help_md: string; } export interface InferenceStats { @@ -304,6 +408,7 @@ export type StreamEventType = | 'answer' // Final answer from agent | 'agent_error' // Agent-level error (non-fatal) | 'permission_request' // Tool confirmation request + | 'policy_alert' // Governance policy blocked a tool | 'mcp_status' // MCP server connection status update | 'agent_created'; // New agent created โ€” triggers agent list refresh @@ -351,6 +456,16 @@ export interface StreamEvent { mcp_server?: string; /** Tool call latency in milliseconds (for tool_result). */ latency_ms?: number; + /** Governance decision (for policy_alert). */ + decision?: string; + /** Governance policy reason (for policy_alert). */ + reason?: string; + /** Governance rule IDs (for policy_alert). */ + rule_ids?: string[]; + /** Governance policy version (for policy_alert). */ + policy_version?: string; + /** Governance receipt ID (for policy_alert). */ + receipt_id?: string; /** Structured result data (for tool_result with search results, file lists, etc.). */ result_data?: { type: string; diff --git a/src/gaia/cli.py b/src/gaia/cli.py index ffe5767af..5babe3e61 100644 --- a/src/gaia/cli.py +++ b/src/gaia/cli.py @@ -2018,6 +2018,13 @@ def main(): help="Skip interactive confirmation prompt (non-interactive/CI use)", ) + # Connectors framework (issue #927, parent of #915) โ€” manage OAuth + + # MCP-server connectors + per-agent grants. The subparser tree lives in + # gaia.connectors.cli to keep this file lean. + from gaia.connectors import cli as connectors_cli + + connectors_cli.add_subparser(subparsers) + # Init command (one-stop GAIA setup) # Note: Does not use parent_parser to avoid showing irrelevant global options init_parser = subparsers.add_parser( @@ -3131,6 +3138,13 @@ def main(): handle_cache_command(args) return + # Handle Connectors command (issue #927, parent of #915) + if args.action == "connectors": + from gaia.connectors import cli as connectors_cli # pylint: disable=reimported + + rc = connectors_cli.handle(args) + sys.exit(rc) + # Handle Diagnostics command if args.action == "diagnostics": handle_diagnostics_command(args) diff --git a/src/gaia/connectors/__init__.py b/src/gaia/connectors/__init__.py new file mode 100644 index 000000000..1f34bc3ad --- /dev/null +++ b/src/gaia/connectors/__init__.py @@ -0,0 +1,121 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +gaia.connectors โ€” OAuth-bound external API access for any GAIA caller. + +This package implements OAuth 2.0 PKCE for desktop apps (RFC 7636/8252) with +refresh tokens stored in the OS keychain (macOS Keychain, Windows DPAPI, Linux +SecretService) and per-agent grants in ``~/.gaia/connectors/grants.json``. + +The module is **self-contained**: SDK, CLI, and AgentUI are equal callers. +Nothing about the OAuth flow, keyring storage, grants ledger, or token-fetch +path requires the AgentUI FastAPI server to be running. Any Python process +running as the user can drive the full flow. + +Scope assumption: the in-memory token cache is process-local. Two GAIA +processes running concurrently (e.g. ``gaia chat --ui`` and ``gaia connectors +status``) each maintain their own cache and share the keyring; if both refresh +concurrently and the provider rotates the refresh token, one process may +observe ``invalid_grant`` and reconnect transparently. See +``docs/security/connections.mdx`` for the cross-process race discussion. + +The internal modules (``tokens``, ``flow``, ``store``, ``grants``, ``pkce``, +``context``, ``events``) are NOT part of the public surface and may change +without notice. Only the names re-exported here are stable. +""" + +from __future__ import annotations + +# Read-only contextvar accessor โ€” public by design; agents and tools may +# read the current agent identity but cannot set it. The setter +# (``_agent_context``) is intentionally NOT re-exported. +from gaia.connectors.context import current_agent_id + +# Error types โ€” caught by router/CLI/SDK consumers. +from gaia.connectors.errors import ( + AuthRequiredError, + ConfigurationError, + ConnectionRevokedError, + ConnectorsError, + ConsentDeniedError, + FlowInProgressError, + FlowTimeoutError, + ScopeMismatchError, +) + +# Event-emitter Protocol โ€” re-exported so the FastAPI router can wire its +# implementation into ``set_emitter`` at app startup. +from gaia.connectors.events import EventEmitter, set_emitter + +# Provider abstraction โ€” agents declare REQUIRED_CONNECTORS using the +# frozen ConnectorRequirement dataclass; the OAuthProvider Protocol is +# what custom provider implementations satisfy. +from gaia.connectors.providers.base import ( + ConnectorRequirement, + OAuthProvider, +) + +# Spec types + registry โ€” added in T-1 (ConnectorSpec, ConfigField, REGISTRY). +from gaia.connectors.registry import REGISTRY, ConnectorRegistry +from gaia.connectors.spec import ConfigField, ConnectorSpec + +# Deferred API names โ€” require ``keyring`` transitively (apiโ†’flowโ†’storeโ†’keyring). +# Imported lazily via __getattr__ so that ``import gaia.connectors`` does NOT +# pull in keyring at package-load time. This allows ``gaia eval --help`` and +# other subcommands to work on environments where keyring is not installed. +_API_NAMES: frozenset[str] = frozenset( + { + "cancel_flow", + "complete_authorization", + "get_access_token", + "get_access_token_sync", + "get_connection", + "grant_agent", + "list_agent_grants", + "list_connections", + "load_grants", + "revoke_agent_grant", + "revoke_connection", + "start_authorization", + "tripwire_check", + } +) + + +def __getattr__(name: str): # pylint: disable=invalid-name + if name in _API_NAMES: + import importlib + + _api = importlib.import_module("gaia.connectors.api") + return getattr(_api, name) + raise AttributeError(f"module 'gaia.connectors' has no attribute {name!r}") + + +__all__ = [ + # Spec types + registry (T-1) + "ConfigField", + "ConnectorRegistry", + "ConnectorSpec", + "REGISTRY", + # Errors + "AuthRequiredError", + "ConfigurationError", + "ConnectionRevokedError", + "ConnectorsError", + "ConsentDeniedError", + "FlowInProgressError", + "FlowTimeoutError", + "ScopeMismatchError", + # Provider abstraction + "ConnectorRequirement", + "OAuthProvider", + # current_agent_id is eagerly imported above; the OAuth API functions + # (cancel_flow, get_access_token, etc.) are available via explicit import + # ``from gaia.connectors import `` but are omitted from __all__ + # because they are provided lazily via __getattr__ and Pylint's static + # analysis would flag them as undefined-all-variable (E0603). + "current_agent_id", + # Event-emitter Protocol (router wires its impl) + "EventEmitter", + "set_emitter", +] diff --git a/src/gaia/connectors/api.py b/src/gaia/connectors/api.py new file mode 100644 index 000000000..1556277c9 --- /dev/null +++ b/src/gaia/connectors/api.py @@ -0,0 +1,264 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Public coordination layer for ``gaia.connectors``. + +Each public function here is a thin orchestration over the per-module +primitives: + +- ``start_authorization`` / ``complete_authorization`` โ†’ ``flow.py`` +- ``get_access_token`` / ``get_access_token_sync`` โ†’ ``tokens.py`` + + per-agent grant check via ``grants.py`` +- ``list_connections`` / ``get_connection`` / ``revoke_connection`` โ†’ + ``store.py`` +- ``grant_agent`` / ``revoke_agent_grant`` / ``list_agent_grants`` โ†’ + ``grants.py`` +- ``tripwire_check`` โ†’ ``store.load_connection`` for every known provider + +This is the only file that combines tokens with grants โ€” the per-module +primitives are deliberately decoupled. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Dict, List, Optional + +from gaia.connectors.context import current_agent_id +from gaia.connectors.errors import ( + AuthRequiredError, + ConfigurationError, +) +from gaia.connectors.flow import ( + cancel_flow, + complete_authorization, + start_authorization, +) +from gaia.connectors.grants import ( + check_agent_grant, + grant_agent, + list_agent_grants, + load_grants, + revoke_agent_grant, +) +from gaia.connectors.providers import get as get_provider +from gaia.connectors.store import ( + DEFAULT_ACCOUNT, + delete_connection, +) +from gaia.connectors.store import list_connections as _store_list +from gaia.connectors.store import ( + load_connection, +) +from gaia.connectors.tokens import get_or_refresh + +logger = logging.getLogger(__name__) + + +async def get_access_token( + *, + provider: str, + scopes: List[str], + agent_id: Optional[str] = None, + account_email: str = DEFAULT_ACCOUNT, +) -> str: + """ + Return a short-lived bearer access token for ``provider``. + + Agent-id resolution order (per AC8 explicit opt-out clause): + 1. Explicit ``agent_id`` kwarg, if non-None. + 2. Active contextvar (``current_agent_id()``), set by the agent runtime. + 3. ``None``, which BYPASSES the per-agent grant check. + + The contextvar path is the production path: ``Agent.process_query`` + enters ``_agent_context(self.namespaced_agent_id)`` before invoking + tools. The kwarg path is for SDK callers who manage their own + identity, and the None path is for CLI/debug callers. + + Two layers of authorization gate the call: + a. Per-agent grant โ€” the user must have explicitly granted this + agent the required scopes via Settings โ†’ Connections, or + ``gaia connectors grants grant``. + b. OAuth scopes โ€” the stored connection's actual scopes must + cover the requested ones; otherwise reconnect with the + missing scopes. + """ + resolved_agent = agent_id if agent_id is not None else current_agent_id() + + # Eager check for per-agent grant โ€” surface the error BEFORE any + # network round-trip so the caller can prompt the user immediately. + if resolved_agent is not None: + if not check_agent_grant(provider, resolved_agent, scopes): + raise AuthRequiredError( + AuthRequiredError.Reason.AGENT_NOT_GRANTED, + provider=provider, + agent_id=resolved_agent, + missing_scopes=scopes, + ) + + # Eager check for OAuth scope coverage โ€” once we know the agent is + # granted, look at what the underlying OAuth connection actually + # carries. The store load also fires the client_id_hash tripwire. + prov = get_provider(provider) + + stored = load_connection( + provider, + current_client_id_hash=prov.client_id_hash, + account_email=account_email, + ) + if stored is None: + raise AuthRequiredError( + AuthRequiredError.Reason.NOT_CONNECTED, provider=provider + ) + granted_scopes = set(stored.get("scopes", [])) + missing = [s for s in scopes if s not in granted_scopes] + if missing: + raise AuthRequiredError( + AuthRequiredError.Reason.CONNECTION_MISSING_SCOPES, + provider=provider, + agent_id=resolved_agent, + missing_scopes=missing, + ) + + # All checks passed โ€” fetch (or refresh) the access token. + return await get_or_refresh(provider, account_email=account_email) + + +def get_access_token_sync( + *, + provider: str, + scopes: List[str], + agent_id: Optional[str] = None, + account_email: str = DEFAULT_ACCOUNT, +) -> str: + """ + Synchronous wrapper around ``get_access_token``. + + Used by sync agent tool bodies (``Agent.process_query`` runs in a + ``ThreadPoolExecutor`` worker thread). ``asyncio.run`` inherits the + calling thread's contextvars into the new event loop's context, so + the agent-id contextvar set by the agent runtime is visible to the + async refresh code. + + Must NOT be called from a thread that already has a running event + loop โ€” ``asyncio.run`` would raise ``RuntimeError``. The runtime + guard turns this into an actionable error rather than a confusing + crash. Use ``await get_access_token(...)`` directly from async code. + """ + try: + running = asyncio.get_running_loop() + except RuntimeError: + running = None + if running is not None: + raise RuntimeError( + "get_access_token_sync was called from a thread with a running " + "asyncio event loop. Call `await get_access_token(...)` " + "directly from async code instead, or schedule this call on a " + "worker thread without a running loop." + ) + return asyncio.run( + get_access_token( + provider=provider, + scopes=scopes, + agent_id=agent_id, + account_email=account_email, + ) + ) + + +def list_connections() -> List[Dict[str, Any]]: + """ + Return all stored connections as a list of summary dicts. + + Each entry: ``{provider, account_email, scopes, connected_at}``. + Refresh tokens are NEVER included in the return value โ€” only the + metadata callers need to display "Connected as ". + """ + out: List[Dict[str, Any]] = [] + for provider in _store_list(): + try: + prov = get_provider(provider) + except ConfigurationError: + # Provider configured to point at this store but the env + # var isn't set right now. Surface the row with a + # configuration warning rather than hide it. + out.append( + { + "provider": provider, + "account_email": "", + "scopes": [], + "connected_at": None, + "error": "configuration", + } + ) + continue + try: + blob = load_connection(provider, current_client_id_hash=prov.client_id_hash) + except AuthRequiredError: + # Tripwire fired โ€” the entry has been cleared. Skip. + continue + if blob is None: + continue + out.append( + { + "provider": provider, + "account_email": blob.get("account_email"), + "scopes": blob.get("scopes", []), + "connected_at": blob.get("connected_at"), + } + ) + return out + + +def get_connection(provider: str) -> Optional[Dict[str, Any]]: + """Return one connection's metadata, or None if missing.""" + for entry in list_connections(): + if entry["provider"] == provider: + return entry + return None + + +def revoke_connection(provider: str) -> None: + """Remove the stored connection for ``provider``. Idempotent.""" + delete_connection(provider) + logger.info("api: revoked connection provider=%s", provider) + + +def tripwire_check() -> None: + """ + Iterate every known provider and call ``load_connection`` to fire + the tripwire eagerly at startup. Exceptions from individual + providers are logged but do not abort the sweep. + """ + for provider_id in _store_list(): + try: + prov = get_provider(provider_id) + except ConfigurationError as e: + logger.warning("tripwire: provider %s misconfigured: %s", provider_id, e) + continue + try: + load_connection(provider_id, current_client_id_hash=prov.client_id_hash) + except AuthRequiredError: + # Tripwire fired โ€” load_connection already cleared the + # entry; nothing else to do here. + logger.info("tripwire: provider %s entry cleared by tripwire", provider_id) + except Exception as e: + logger.warning("tripwire: provider %s check failed: %s", provider_id, e) + + +__all__ = [ + "cancel_flow", + "complete_authorization", + "get_access_token", + "get_access_token_sync", + "get_connection", + "grant_agent", + "list_agent_grants", + "list_connections", + "load_grants", + "revoke_agent_grant", + "revoke_connection", + "start_authorization", + "tripwire_check", +] diff --git a/src/gaia/connectors/catalog/__init__.py b/src/gaia/connectors/catalog/__init__.py new file mode 100644 index 000000000..55975a903 --- /dev/null +++ b/src/gaia/connectors/catalog/__init__.py @@ -0,0 +1,23 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Connector catalog โ€” registers all built-in ConnectorSpecs and their handlers. + +Importing this package triggers registration of every built-in connector +into ``REGISTRY`` and every handler into ``_HANDLER_REGISTRY``. Application +entry-points (FastAPI routers, CLI, Agent UI) must import this package +before they call ``get_credential`` / ``configure`` / ``health_check``. + +Each sub-module is responsible for: + 1. Calling ``REGISTRY.register(spec)`` for every ConnectorSpec it owns. + 2. Importing the type handler module (e.g. ``gaia.connectors.oauth_pkce``) + so ``register_handler`` fires at import time. + +New connectors: add a module under ``catalog/`` that does the above two +things, then add an import here. +""" + +from gaia.connectors.catalog import google # noqa: F401 +from gaia.connectors.catalog import mcp_servers # noqa: F401 + +__all__ = ["google", "mcp_servers"] diff --git a/src/gaia/connectors/catalog/google.py b/src/gaia/connectors/catalog/google.py new file mode 100644 index 000000000..8934fa3c5 --- /dev/null +++ b/src/gaia/connectors/catalog/google.py @@ -0,0 +1,75 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Google connector catalog entry. + +Registers the Google OAuth PKCE ConnectorSpec into the global REGISTRY and +imports ``oauth_pkce`` so its handler is registered into ``_HANDLER_REGISTRY``. +""" + +import gaia.connectors.oauth_pkce # noqa: F401 # pylint: disable=unused-import +from gaia.connectors.registry import REGISTRY +from gaia.connectors.spec import ConfigField, ConnectorSpec + +GOOGLE_SPEC = ConnectorSpec( + id="google", + display_name="Google", + icon="https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg", + category="productivity", + tier=1, + type="oauth_pkce", + description="Connect GAIA to your Google account for Gmail, Calendar, Drive, and more.", + instructions_md=( + "Sign in with Google to allow GAIA to access your Gmail, Google Calendar, " + "and Google Drive. You can revoke access at any time from your " + "[Google Account security page](https://myaccount.google.com/permissions)." + ), + product_url="https://workspace.google.com/", + docs_url="https://amd-gaia.ai/connectors/google", + default_scopes=( + "openid", + "email", + "profile", + ), + available_scopes=( + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.file", + ), + oauth_provider_ref="google", + # First-time setup form rendered by the AgentUI when the user has + # not yet provided OAuth client credentials. Submitted values are + # stored in the OS keyring (encrypted at rest) and reused across + # connect/disconnect cycles. Power users may bypass the form by + # exporting GAIA_GOOGLE_CLIENT_ID / GAIA_GOOGLE_CLIENT_SECRET before + # launch. + oauth_setup_fields=( + ConfigField( + key="client_id", + label="OAuth Client ID", + kind="text", + help_md=( + "From Google Cloud Console โ†’ APIs & Services โ†’ Credentials โ†’ " + "your Desktop-app OAuth 2.0 Client. Looks like " + "-.apps.googleusercontent.com." + ), + ), + ConfigField( + key="client_secret", + label="OAuth Client Secret", + kind="secret", + help_md=( + "From the same Desktop-app OAuth client. Required by Google " + "even for PKCE flows. Stored encrypted in your OS keyring." + ), + ), + ), +) + +REGISTRY.register(GOOGLE_SPEC) diff --git a/src/gaia/connectors/catalog/mcp_servers.py b/src/gaia/connectors/catalog/mcp_servers.py new file mode 100644 index 000000000..571c7bed2 --- /dev/null +++ b/src/gaia/connectors/catalog/mcp_servers.py @@ -0,0 +1,488 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +MCP server catalog entries. + +Translates the curated server list from ``src/gaia/ui/routers/mcp.py`` into +``ConnectorSpec`` objects registered in the global ``REGISTRY``. Importing +this module also imports ``gaia.connectors.mcp_server`` so the handler is +registered before any dispatch call. +""" + +import gaia.connectors.mcp_server # noqa: F401 # pylint: disable=unused-import +from gaia.connectors.registry import REGISTRY +from gaia.connectors.spec import ConfigField, ConnectorSpec + +# --------------------------------------------------------------------------- +# Tier 1 โ€” Essential +# --------------------------------------------------------------------------- + +_FILESYSTEM = ConnectorSpec( + id="mcp-filesystem", + display_name="File System", + icon="๐Ÿ“", + category="system", + tier=1, + type="mcp_server", + description="Secure file read/write/search with configurable access controls.", + mcp_command="npx", + mcp_args=("-y", "@modelcontextprotocol/server-filesystem", "~"), + config_schema=( + ConfigField( + key="allowed_directories", + label="Allowed directories", + kind="text", + placeholder="~/Documents,~/Downloads", + help_md="Comma-separated list of paths the server may access.", + ), + ), +) + +_PLAYWRIGHT = ConnectorSpec( + id="mcp-playwright", + display_name="Browser (Playwright)", + icon="๐ŸŽญ", + category="browser", + tier=1, + type="mcp_server", + description="Web browsing and interaction via accessibility snapshots.", + mcp_command="npx", + mcp_args=("-y", "@anthropic/mcp-playwright"), +) + +_GITHUB = ConnectorSpec( + id="mcp-github", + display_name="GitHub", + icon="๐Ÿ™", + category="dev-tools", + tier=1, + type="mcp_server", + description="Repos, PRs, issues, workflows โ€” full GitHub access.", + docs_url="https://amd-gaia.ai/connectors/github", + mcp_command="npx", + mcp_args=("-y", "@modelcontextprotocol/server-github"), + mcp_env_keys=("GITHUB_TOKEN",), + config_schema=( + ConfigField( + key="GITHUB_TOKEN", + label="GitHub Personal Access Token", + kind="secret", + placeholder="ghp_โ€ฆ", + help_md="Create a [classic token](https://github.com/settings/tokens) with `repo` and `workflow` scopes.", + secret=True, + ), + ), +) + +_FETCH = ConnectorSpec( + id="mcp-fetch", + display_name="Web Fetch", + icon="๐ŸŒ", + category="web", + tier=1, + type="mcp_server", + description="Fetch web content and convert it to Markdown.", + mcp_command="npx", + mcp_args=("-y", "@modelcontextprotocol/server-fetch"), +) + +_MEMORY = ConnectorSpec( + id="mcp-memory", + display_name="Memory", + icon="๐Ÿง ", + category="context", + tier=1, + type="mcp_server", + description="Knowledge graph-based persistent memory for agents.", + mcp_command="npx", + mcp_args=("-y", "@modelcontextprotocol/server-memory"), +) + +_GIT = ConnectorSpec( + id="mcp-git", + display_name="Git", + icon="๐Ÿ”€", + category="dev-tools", + tier=1, + type="mcp_server", + description="Git repository tools: log, diff, status, blame.", + mcp_command="npx", + mcp_args=("-y", "@modelcontextprotocol/server-git"), +) + +_DESKTOP_COMMANDER = ConnectorSpec( + id="mcp-desktop-commander", + display_name="Desktop Commander", + icon="๐Ÿ–ฅ๏ธ", + category="system", + tier=1, + type="mcp_server", + description="Terminal command execution + file operations with user control.", + mcp_command="npx", + mcp_args=("-y", "desktop-commander"), +) + +# --------------------------------------------------------------------------- +# Tier 2 โ€” High Value +# --------------------------------------------------------------------------- + +_BRAVE_SEARCH = ConnectorSpec( + id="mcp-brave-search", + display_name="Brave Search", + icon="๐Ÿฆ", + category="web-search", + tier=2, + type="mcp_server", + description="Web search via Brave Search API.", + mcp_command="npx", + mcp_args=("-y", "@anthropic/mcp-brave-search"), + mcp_env_keys=("BRAVE_API_KEY",), + config_schema=( + ConfigField( + key="BRAVE_API_KEY", + label="Brave API Key", + kind="secret", + placeholder="BSAโ€ฆ", + help_md="Get a key at [brave.com/search/api](https://brave.com/search/api/).", + secret=True, + ), + ), +) + +_POSTGRES = ConnectorSpec( + id="mcp-postgres", + display_name="PostgreSQL", + icon="๐Ÿ˜", + category="database", + tier=2, + type="mcp_server", + description="Read-only database queries against a PostgreSQL database.", + mcp_command="npx", + mcp_args=( + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://localhost/mydb", + ), + config_schema=( + ConfigField( + key="connection_string", + label="Connection string", + kind="text", + placeholder="postgresql://user:pass@host/db", + ), + ), +) + +_CONTEXT7 = ConnectorSpec( + id="mcp-context7", + display_name="Context7 Docs", + icon="๐Ÿ“–", + category="documentation", + tier=2, + type="mcp_server", + description="Inject fresh, version-specific library docs into agent context.", + mcp_command="npx", + mcp_args=("-y", "context7-mcp"), +) + +_GMAIL = ConnectorSpec( + id="mcp-gmail", + display_name="Gmail", + icon="โœ‰๏ธ", + category="email", + tier=2, + type="mcp_server", + description="Read, search, send, label, and archive Gmail messages.", + mcp_command="npx", + mcp_args=("-y", "gmail-mcp-server"), + mcp_env_keys=("GMAIL_CLIENT_ID", "GMAIL_CLIENT_SECRET"), + config_schema=( + ConfigField( + key="GMAIL_CLIENT_ID", label="Gmail Client ID", kind="text", secret=False + ), + ConfigField( + key="GMAIL_CLIENT_SECRET", + label="Gmail Client Secret", + kind="secret", + secret=True, + ), + ), +) + +_GOOGLE_CALENDAR = ConnectorSpec( + id="mcp-google-calendar", + display_name="Google Calendar", + icon="๐Ÿ“…", + category="calendar", + tier=2, + type="mcp_server", + description="Events, scheduling, availability, and RSVP management.", + mcp_command="npx", + mcp_args=("-y", "google-calendar-mcp"), + mcp_env_keys=("GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"), + config_schema=( + ConfigField( + key="GOOGLE_CLIENT_ID", label="Google Client ID", kind="text", secret=False + ), + ConfigField( + key="GOOGLE_CLIENT_SECRET", + label="Google Client Secret", + kind="secret", + secret=True, + ), + ), +) + +_OUTLOOK = ConnectorSpec( + id="mcp-outlook", + display_name="Outlook / Microsoft 365", + icon="๐Ÿ“ง", + category="email", + tier=2, + type="mcp_server", + description="Outlook email and calendar via Microsoft Graph API.", + mcp_command="npx", + mcp_args=("-y", "outlook-mcp-server"), + mcp_env_keys=("MS_CLIENT_ID", "MS_CLIENT_SECRET"), + config_schema=( + ConfigField( + key="MS_CLIENT_ID", label="Azure App Client ID", kind="text", secret=False + ), + ConfigField( + key="MS_CLIENT_SECRET", + label="Azure App Client Secret", + kind="secret", + secret=True, + ), + ), +) + +_SPOTIFY = ConnectorSpec( + id="mcp-spotify", + display_name="Spotify", + icon="๐ŸŽต", + category="media", + tier=2, + type="mcp_server", + description="Play, pause, skip, search tracks, and manage playlists.", + mcp_command="npx", + mcp_args=("-y", "spotify-mcp-server"), + mcp_env_keys=("SPOTIFY_CLIENT_ID", "SPOTIFY_CLIENT_SECRET"), + config_schema=( + ConfigField( + key="SPOTIFY_CLIENT_ID", + label="Spotify Client ID", + kind="text", + secret=False, + ), + ConfigField( + key="SPOTIFY_CLIENT_SECRET", + label="Spotify Client Secret", + kind="secret", + secret=True, + ), + ), +) + +_SLACK = ConnectorSpec( + id="mcp-slack", + display_name="Slack", + icon="๐Ÿ’ฌ", + category="communication", + tier=2, + type="mcp_server", + description="Channel management, messaging, and conversation history.", + mcp_command="npx", + mcp_args=("-y", "slack-mcp-server"), + mcp_env_keys=("SLACK_BOT_TOKEN",), + config_schema=( + ConfigField( + key="SLACK_BOT_TOKEN", + label="Slack Bot Token", + kind="secret", + placeholder="xoxb-โ€ฆ", + help_md="Create a bot at [api.slack.com/apps](https://api.slack.com/apps).", + secret=True, + ), + ), +) + +_NOTION = ConnectorSpec( + id="mcp-notion", + display_name="Notion", + icon="๐Ÿ“", + category="productivity", + tier=2, + type="mcp_server", + description="Workspace pages, databases, and task management.", + mcp_command="npx", + mcp_args=("-y", "notion-mcp"), + mcp_env_keys=("NOTION_API_KEY",), + config_schema=( + ConfigField( + key="NOTION_API_KEY", + label="Notion Integration Token", + kind="secret", + placeholder="secret_โ€ฆ", + help_md="Create an integration at [notion.so/my-integrations](https://www.notion.so/my-integrations).", + secret=True, + ), + ), +) + +_LINEAR = ConnectorSpec( + id="mcp-linear", + display_name="Linear", + icon="๐Ÿ“‹", + category="dev-tools", + tier=2, + type="mcp_server", + description="Issues, projects, and cycles โ€” full Linear workspace access.", + mcp_command="npx", + mcp_args=("-y", "linear-mcp-server"), + mcp_env_keys=("LINEAR_API_KEY",), + config_schema=( + ConfigField( + key="LINEAR_API_KEY", + label="Linear API Key", + kind="secret", + placeholder="lin_api_โ€ฆ", + help_md="Generate a personal API key at [linear.app/settings/api](https://linear.app/settings/api).", + secret=True, + ), + ), +) + +_JIRA = ConnectorSpec( + id="mcp-jira", + display_name="Jira", + icon="๐ŸŸฆ", + category="dev-tools", + tier=2, + type="mcp_server", + description="Issues, sprints, and boards โ€” full Jira project management.", + mcp_command="npx", + mcp_args=("-y", "jira-mcp-server"), + mcp_env_keys=("JIRA_API_TOKEN", "JIRA_BASE_URL", "JIRA_USER_EMAIL"), + config_schema=( + ConfigField( + key="JIRA_BASE_URL", + label="Jira Base URL", + kind="url", + placeholder="https://yourorg.atlassian.net", + ), + ConfigField(key="JIRA_USER_EMAIL", label="Jira User Email", kind="email"), + ConfigField( + key="JIRA_API_TOKEN", label="Jira API Token", kind="secret", secret=True + ), + ), +) + +_STRIPE = ConnectorSpec( + id="mcp-stripe", + display_name="Stripe", + icon="๐Ÿ’ณ", + category="payments", + tier=2, + type="mcp_server", + description="Payments, subscriptions, and customer management via Stripe API.", + mcp_command="npx", + mcp_args=("-y", "stripe-mcp-server"), + mcp_env_keys=("STRIPE_SECRET_KEY",), + config_schema=( + ConfigField( + key="STRIPE_SECRET_KEY", + label="Stripe Secret Key", + kind="secret", + placeholder="sk_live_โ€ฆ", + help_md="Find your key in the [Stripe Dashboard](https://dashboard.stripe.com/apikeys).", + secret=True, + ), + ), +) + +_SENDGRID = ConnectorSpec( + id="mcp-sendgrid", + display_name="SendGrid", + icon="๐Ÿ“จ", + category="email", + tier=3, + type="mcp_server", + description="Transactional email sending and template management via SendGrid.", + mcp_command="npx", + mcp_args=("-y", "sendgrid-mcp-server"), + mcp_env_keys=("SENDGRID_API_KEY",), + config_schema=( + ConfigField( + key="SENDGRID_API_KEY", + label="SendGrid API Key", + kind="secret", + placeholder="SG.โ€ฆ", + secret=True, + ), + ), +) + +# --------------------------------------------------------------------------- +# Tier 3 โ€” Desktop / Windows +# --------------------------------------------------------------------------- + +_WINDOWS_AUTOMATION = ConnectorSpec( + id="mcp-windows-automation", + display_name="Windows Automation", + icon="๐ŸชŸ", + category="computer-use", + tier=3, + type="mcp_server", + description="Native Windows UI automation: open apps, control windows, simulate input.", + mcp_command="npx", + mcp_args=("-y", "mcp-windows-automation"), +) + +# --------------------------------------------------------------------------- +# Tier 4 โ€” Microsoft Ecosystem +# --------------------------------------------------------------------------- + +_MICROSOFT_LEARN = ConnectorSpec( + id="mcp-microsoft-learn", + display_name="Microsoft Learn", + icon="๐Ÿ“˜", + category="documentation", + tier=4, + type="mcp_server", + description="Real-time access to Microsoft documentation.", + mcp_command="npx", + mcp_args=("-y", "@microsoft/mcp-docs"), +) + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + +_ALL_SPECS = ( + _FILESYSTEM, + _PLAYWRIGHT, + _GITHUB, + _FETCH, + _MEMORY, + _GIT, + _DESKTOP_COMMANDER, + _BRAVE_SEARCH, + _POSTGRES, + _CONTEXT7, + _GMAIL, + _GOOGLE_CALENDAR, + _OUTLOOK, + _SPOTIFY, + _SLACK, + _NOTION, + _LINEAR, + _JIRA, + _STRIPE, + _SENDGRID, + _WINDOWS_AUTOMATION, + _MICROSOFT_LEARN, +) + +for _spec in _ALL_SPECS: + REGISTRY.register(_spec) diff --git a/src/gaia/connectors/cli.py b/src/gaia/connectors/cli.py new file mode 100644 index 000000000..03c12e98d --- /dev/null +++ b/src/gaia/connectors/cli.py @@ -0,0 +1,380 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +CLI for ``gaia connectors {list|connect|configure|test|disconnect|grants ...}``. + +Subcommands: +- ``list`` โ†’ catalog entries with configured/not status +- ``connect`` โ†’ OAuth PKCE browser flow (oauth_pkce type) +- ``configure`` โ†’ configure via the handler dispatcher (KEY=VALUE or --json) +- ``test`` โ†’ health check for a configured connector +- ``disconnect`` โ†’ remove credentials and reset connector state +- ``grants list|grant|revoke`` โ†’ per-agent scope grants ledger +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from typing import Sequence + +from gaia.connectors.errors import ( + AuthRequiredError, + ConfigurationError, + ConnectorsError, +) + + +def add_subparser(subparsers: argparse._SubParsersAction) -> None: + """Register ``gaia connectors`` and its subcommands.""" + p = subparsers.add_parser( + "connectors", + help="Manage external connectors (OAuth, MCP servers) and per-agent grants", + description=( + "Manage external connectors (OAuth providers, MCP servers, " + "API tokens) and per-agent grants. Configure once, then grant " + "individual agents the scopes they need." + ), + ) + sub = p.add_subparsers( + dest="connectors_action", + metavar="", + help="Subcommand", + ) + + # list + p_list = sub.add_parser( + "list", help="List all connectors in the catalog with their status" + ) + p_list.add_argument( + "connector_id", + nargs="?", + help="Connector id to inspect; default: list all", + ) + p_list.add_argument( + "--json", + action="store_true", + dest="as_json", + help="Emit machine-readable JSON", + ) + + # status (alias for list โ€” backward compatibility) + p_status = sub.add_parser("status", help="Alias for 'list'") + p_status.add_argument("connector_id", nargs="?") + p_status.add_argument("--json", action="store_true", dest="as_json") + + # connect (OAuth PKCE) + p_conn = sub.add_parser( + "connect", help="Authorize an OAuth connector (opens browser)" + ) + p_conn.add_argument("connector_id", help="Connector id (e.g. 'google')") + p_conn.add_argument( + "--scopes", + nargs="+", + help="OAuth scopes to request (connector-specific)", + ) + + # configure (generic dispatcher) + p_cfg = sub.add_parser( + "configure", + help="Configure a connector (MCP API keys, OAuth client creds, etc.)", + ) + p_cfg.add_argument("connector_id", help="Connector id") + p_cfg.add_argument( + "--set", + action="append", + metavar="KEY=VALUE", + dest="config_pairs", + help="Config key=value pair (repeatable, e.g. --set GITHUB_TOKEN=ghp_โ€ฆ)", + ) + p_cfg.add_argument( + "--json", + metavar="JSON_OBJECT", + dest="config_json", + help="Config as a JSON object (alternative to --set)", + ) + + # test + p_test = sub.add_parser("test", help="Run health check for a configured connector") + p_test.add_argument("connector_id", help="Connector id") + + # disconnect + p_disc = sub.add_parser( + "disconnect", help="Remove credentials and reset a connector's state" + ) + p_disc.add_argument("connector_id") + + # grants + p_grants = sub.add_parser("grants", help="Manage per-agent scope grants") + g = p_grants.add_subparsers(dest="grants_action", metavar="") + + p_gl = g.add_parser("list", help="List agent grants for a connector") + p_gl.add_argument( + "connector_id", + nargs="?", + default="google", + help="Connector id (default: google)", + ) + + p_gg = g.add_parser("grant", help="Grant an agent scopes for a connector") + p_gg.add_argument("connector_id") + p_gg.add_argument( + "agent_id", + help="Namespaced agent id, e.g. 'builtin:chat' or 'custom:abc:inbox'", + ) + p_gg.add_argument( + "--scopes", + nargs="+", + required=True, + help="Scopes to grant (connector-specific)", + ) + + p_gr = g.add_parser("revoke", help="Revoke an agent's grant for a connector") + p_gr.add_argument("connector_id") + p_gr.add_argument("agent_id") + + +def handle(args: argparse.Namespace) -> int: + """Dispatch a parsed ``gaia connectors ...`` command. Returns exit code.""" + action = getattr(args, "connectors_action", None) + if action is None: + sys.stderr.write( + "gaia connectors: missing subcommand. Try 'gaia connectors --help'.\n" + ) + return 2 + + try: + if action in ("list", "status"): + return _handle_list(args) + if action == "connect": + return _handle_connect(args) + if action == "configure": + return _handle_configure(args) + if action == "test": + return _handle_test(args) + if action == "disconnect": + return _handle_disconnect(args) + if action == "grants": + return _handle_grants(args) + except ConfigurationError as e: + sys.stderr.write(f"Configuration error: {e}\n") + return 3 + except AuthRequiredError as e: + sys.stderr.write(f"Authorization required: {e}\n") + return 4 + except ConnectorsError as e: + sys.stderr.write(f"Connectors error: {e}\n") + return 5 + + sys.stderr.write(f"gaia connectors: unknown subcommand {action!r}\n") + return 2 + + +def _handle_list(args: argparse.Namespace) -> int: + import gaia.connectors.catalog # noqa: F401 # pylint: disable=unused-import + from gaia.connectors.mcp_server import is_mcp_server_configured + from gaia.connectors.registry import REGISTRY + from gaia.connectors.store import peek_connection + + specs = REGISTRY.all() + connector_id = getattr(args, "connector_id", None) + if connector_id: + try: + specs = [REGISTRY.get(connector_id)] + except KeyError: + sys.stderr.write(f"gaia connectors: unknown connector {connector_id!r}\n") + return 1 + + # Derive configured/account/scopes live from the source-of-truth + # store per type โ€” keyring blob for OAuth, mcp_servers.json for MCP. + # TODO: when a 3rd connector type lands, push this into a + # Handler.summary(spec) -> {configured, account_id, scopes} method + # so this list-call collapses to one polymorphic call. The same + # if/elif lives in routers/connectors.py:_connector_summary; the + # two should refactor together. + rows = [] + for spec in specs: + configured = False + account_id = None + scopes: list = [] + if spec.type == "oauth_pkce": + blob = peek_connection(spec.oauth_provider_ref or spec.id) + if blob is not None: + configured = True + account_id = blob.get("account_email") + scopes = list(blob.get("scopes", [])) + elif spec.type == "mcp_server": + configured = is_mcp_server_configured(spec.id) + + rows.append( + { + "id": spec.id, + "display_name": spec.display_name, + "type": spec.type, + "category": spec.category, + "tier": spec.tier, + "configured": configured, + "account_id": account_id, + "scopes": scopes, + } + ) + + if getattr(args, "as_json", False): + sys.stdout.write(json.dumps(rows, indent=2) + "\n") + return 0 + + if not rows: + sys.stdout.write("No connectors in catalog.\n") + return 0 + + for row in rows: + status = "configured" if row["configured"] else "not configured" + acct = f" ({row['account_id']})" if row.get("account_id") else "" + sys.stdout.write(f"{row['id']:<30} [{row['type']}] {status}{acct}\n") + return 0 + + +def _handle_connect(args: argparse.Namespace) -> int: + from gaia.connectors.api import complete_authorization, start_authorization + + async def _run() -> str: + info = await start_authorization(args.connector_id, scopes=args.scopes or []) + sys.stdout.write( + f"Open this URL to authorize {args.connector_id}:\n" + f" {info['authorization_url']}\n" + ) + sys.stdout.flush() + result = await complete_authorization(info["flow_id"]) + return result.get("account_email") or "" + + email = asyncio.run(_run()) + sys.stdout.write(f"Connected as {email}\n") + return 0 + + +def _handle_configure(args: argparse.Namespace) -> int: + import gaia.connectors.catalog # noqa: F401 # pylint: disable=unused-import + from gaia.connectors.handler import configure + + config: dict = {} + if getattr(args, "config_json", None): + try: + config = json.loads(args.config_json) + except json.JSONDecodeError as e: + sys.stderr.write(f"gaia connectors configure: invalid JSON: {e}\n") + return 2 + for pair in getattr(args, "config_pairs", None) or []: + if "=" not in pair: + sys.stderr.write( + f"gaia connectors configure: --set requires KEY=VALUE, got {pair!r}\n" + ) + return 2 + key, _, value = pair.partition("=") + config[key.strip()] = value + + async def _run(): + return await configure(args.connector_id, config) + + try: + result = asyncio.run(_run()) + except KeyError: + sys.stderr.write( + f"gaia connectors configure: unknown connector {args.connector_id!r}\n" + ) + return 1 + + sys.stdout.write(f"Configured {args.connector_id}.\n") + if result.get("authorization_url"): + sys.stdout.write(f"Complete OAuth flow at:\n {result['authorization_url']}\n") + return 0 + + +def _handle_test(args: argparse.Namespace) -> int: + import gaia.connectors.catalog # noqa: F401 # pylint: disable=unused-import + from gaia.connectors.handler import health_check + + async def _run(): + return await health_check(args.connector_id) + + try: + result = asyncio.run(_run()) + except KeyError: + sys.stderr.write( + f"gaia connectors test: unknown connector {args.connector_id!r}\n" + ) + return 1 + + ok = result.get("ok", False) + detail = result.get("detail", "") + status = "OK" if ok else "FAIL" + sys.stdout.write(f"{args.connector_id}: {status} {detail}\n") + return 0 if ok else 1 + + +def _handle_disconnect(args: argparse.Namespace) -> int: + import gaia.connectors.catalog # noqa: F401 # pylint: disable=unused-import + from gaia.connectors.handler import disconnect + + async def _run(): + await disconnect(args.connector_id) + + try: + asyncio.run(_run()) + except KeyError: + sys.stderr.write( + f"gaia connectors disconnect: unknown connector {args.connector_id!r}\n" + ) + return 1 + + sys.stdout.write(f"Disconnected {args.connector_id}.\n") + return 0 + + +def _handle_grants(args: argparse.Namespace) -> int: + from gaia.connectors.grants import ( + grant_agent, + list_agent_grants, + revoke_agent_grant, + ) + + sub = getattr(args, "grants_action", None) + if sub == "list": + listing = list_agent_grants(args.connector_id) + if not listing: + sys.stdout.write(f"No grants for {args.connector_id}.\n") + return 0 + for agent_id, scopes in sorted(listing.items()): + sys.stdout.write(f"{args.connector_id} {agent_id}: {', '.join(scopes)}\n") + return 0 + if sub == "grant": + grant_agent(args.connector_id, args.agent_id, args.scopes) + sys.stdout.write( + f"Granted {args.connector_id} โ†’ {args.agent_id}: " + f"{', '.join(args.scopes)}\n" + ) + return 0 + if sub == "revoke": + revoke_agent_grant(args.connector_id, args.agent_id) + sys.stdout.write(f"Revoked grant for {args.connector_id} โ†’ {args.agent_id}.\n") + return 0 + + sys.stderr.write( + "gaia connectors grants: missing subcommand. " + "Try 'gaia connectors grants --help'.\n" + ) + return 2 + + +def main(argv: Sequence[str] | None = None) -> int: + """Standalone entry point โ€” useful for ``python -m gaia.connectors.cli``.""" + parser = argparse.ArgumentParser(prog="gaia-connectors") + sub = parser.add_subparsers(dest="action") + add_subparser(sub) + args = parser.parse_args(argv) + return handle(args) + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/src/gaia/connectors/context.py b/src/gaia/connectors/context.py new file mode 100644 index 000000000..6671eccd4 --- /dev/null +++ b/src/gaia/connectors/context.py @@ -0,0 +1,55 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Agent-identity context propagation for ``gaia.connectors``. + +Two callables, asymmetric visibility: + +- ``_agent_context(agent_id)`` โ€” **PRIVATE**. Only the agent runtime calls + this (via the private import path). A tool body cannot reach this from + the public ``gaia.connectors`` API surface, so it cannot forge an agent + identity to escalate scope (per plan amendment A9). + +- ``current_agent_id()`` โ€” **PUBLIC**. Tools and the connections core may + read the active agent id but cannot set it. + +ContextVars are thread-local in CPython, but inherited across asyncio task +boundaries via ``contextvars.copy_context()``. This is exactly the model +the syncโ†’async bridge relies on: ``Agent.process_query`` runs in a +``ThreadPoolExecutor`` worker, the context manager is entered there, and +``asyncio.run(get_access_token(...))`` from inside the worker inherits the +worker thread's context โ€” see the bridge test in ``test_agent_bridge.py``. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Iterator + +_agent_id_var: ContextVar[str | None] = ContextVar( + "gaia_connections_agent_id", default=None +) + + +@contextmanager +def _agent_context(agent_id: str) -> Iterator[None]: + """ + Set the active agent id for the lifetime of the ``with`` block. + + PRIVATE โ€” the agent runtime imports this via the explicit private path + ``from gaia.connectors.context import _agent_context``. The connections + public API (``gaia.connectors.__init__``) does NOT re-export this name, + so a malicious tool body cannot forge an agent identity to bypass the + per-agent grant check. + """ + token = _agent_id_var.set(agent_id) + try: + yield + finally: + _agent_id_var.reset(token) + + +def current_agent_id() -> str | None: + """Return the active agent id, or ``None`` if no context is set.""" + return _agent_id_var.get() diff --git a/src/gaia/connectors/errors.py b/src/gaia/connectors/errors.py new file mode 100644 index 000000000..287a170b8 --- /dev/null +++ b/src/gaia/connectors/errors.py @@ -0,0 +1,155 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Exception hierarchy for ``gaia.connectors``. + +Every error names what failed, what the caller should do, and where to look โ€” +the three things CLAUDE.md "fail loudly" rule requires for actionable errors. +The router in ``src/gaia/ui/routers/connections.py`` maps each type to a +specific HTTP response; the CLI prints them to stderr; the SDK lets callers +catch and react programmatically. + +No silent fallbacks. Either the operation succeeds or one of these is raised. +""" + +from __future__ import annotations + +import enum +from typing import Iterable + + +class ConnectorsError(Exception): + """Base class for every error raised by ``gaia.connectors``.""" + + +class ConfigurationError(ConnectorsError): + """Required configuration (env var, runbook entry) is missing.""" + + +class AuthRequiredError(ConnectorsError): + """ + A caller cannot use a connection right now and must take a specific action. + + Inspect ``.reason`` to decide what to do; the AgentUI router maps each + Reason value to a distinct HTTP status, the CLI to a tailored stderr + message, and the SDK lets callers branch on the enum directly. + """ + + class Reason(str, enum.Enum): + NOT_CONNECTED = "not_connected" + AGENT_NOT_GRANTED = "agent_not_granted" + CONNECTION_MISSING_SCOPES = "connection_missing_scopes" + REAUTH_REQUIRED = "reauth_required" + + def __init__( + self, + reason: "AuthRequiredError.Reason", + *, + provider: str = "", + agent_id: str | None = None, + missing_scopes: Iterable[str] | None = None, + message: str | None = None, + ): + self.reason = reason + self.provider = provider + self.agent_id = agent_id + self.missing_scopes = list(missing_scopes or []) + super().__init__(message or self._default_message()) + + def _default_message(self) -> str: + prov = self.provider or "the connection" + if self.reason is AuthRequiredError.Reason.NOT_CONNECTED: + return ( + f"No {prov} connection. Connect via Settings โ†’ Connections in " + "AgentUI, or run `gaia connectors connect " + f"{self.provider or ''}`. " + "See docs/sdk/infrastructure/connections.mdx." + ) + if self.reason is AuthRequiredError.Reason.AGENT_NOT_GRANTED: + agent = self.agent_id or "this agent" + return ( + f"Agent '{agent}' has no grant for {prov}. Grant the required " + "scopes in Settings โ†’ Connections, or run " + f"`gaia connectors grants grant {self.provider or ''} " + f"{agent} --scopes ...`. " + "See docs/sdk/infrastructure/connections.mdx." + ) + if self.reason is AuthRequiredError.Reason.CONNECTION_MISSING_SCOPES: + scopes = ", ".join(self.missing_scopes) or "" + return ( + f"The {prov} connection lacks required scopes ({scopes}). " + "Reconnect with the missing scopes from Settings โ†’ Connections, " + f"or run `gaia connectors connect {self.provider or ''} " + "--scopes ...`. " + "See docs/sdk/infrastructure/connections.mdx." + ) + if self.reason is AuthRequiredError.Reason.REAUTH_REQUIRED: + return ( + f"The stored {prov} credentials are no longer valid (client " + "rotation or remote revocation). Reconnect from Settings โ†’ " + f"Connections, or run `gaia connectors connect " + f"{self.provider or ''}`. " + "See docs/runbooks/google-oauth-client.md." + ) + # Fallback โ€” should be unreachable since Reason is a closed enum. + return f"Authentication required for {prov} (reason={self.reason.value})." + + +class ConnectionRevokedError(ConnectorsError): + """OAuth grant was revoked or rotated remotely; caller must reconnect.""" + + def __init__(self, provider: str, *, message: str | None = None): + self.provider = provider + super().__init__( + message + or ( + f"The {provider} connection was revoked or its refresh token " + "is no longer accepted by the provider. Reconnect from " + f"Settings โ†’ Connections, or run `gaia connectors connect " + f"{provider}`. See docs/security/connections.mdx." + ) + ) + + +class ScopeMismatchError(ConnectorsError): + """Stored connection lacks scopes required by the request.""" + + def __init__( + self, + *, + required: Iterable[str], + granted: Iterable[str], + provider: str = "", + message: str | None = None, + ): + self.required = list(required) + self.granted = list(granted) + self.provider = provider + super().__init__(message or self._default_message()) + + @property + def missing_scopes(self) -> list[str]: + return sorted(set(self.required) - set(self.granted)) + + def _default_message(self) -> str: + prov = self.provider or "connection" + missing = ", ".join(self.missing_scopes) or "" + return ( + f"The {prov} stored connection is missing required scopes " + f"({missing}). Reconnect with the missing scopes via Settings โ†’ " + f"Connections, or run `gaia connectors connect " + f"{self.provider or ''} --scopes ...`. " + "See docs/sdk/infrastructure/connections.mdx." + ) + + +class ConsentDeniedError(ConnectorsError): + """User denied consent in OAuth flow (``?error=access_denied``).""" + + +class FlowTimeoutError(ConnectorsError): + """OAuth flow exceeded its 120-second callback timeout.""" + + +class FlowInProgressError(ConnectorsError): + """Another OAuth flow is already pending; only one at a time is supported.""" diff --git a/src/gaia/connectors/events.py b/src/gaia/connectors/events.py new file mode 100644 index 000000000..8eac3bf08 --- /dev/null +++ b/src/gaia/connectors/events.py @@ -0,0 +1,58 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Event-emitter Protocol for ``gaia.connectors``. + +The router (``src/gaia/ui/routers/connections.py``) implements this protocol +with a per-subscriber bounded ``asyncio.Queue`` and registers itself via +``set_emitter`` at app startup. Other callers (CLI / SDK) leave the emitter +unset; the no-op default emits to logging only. + +This is a Protocol, not an ABC, because GAIA's mixin style is +duck-typed throughout. The router does not need to inherit anything. +""" + +from __future__ import annotations + +import logging +from typing import Optional, Protocol, runtime_checkable + +logger = logging.getLogger(__name__) + + +@runtime_checkable +class EventEmitter(Protocol): + """Async emit method used by ``flow.py``, ``store.py``, ``api.py``.""" + + async def emit(self, event_type: str, payload: dict) -> None: ... + + +class _LoggingEmitter: + """Default emitter when no caller has registered an active emitter + (e.g. CLI / SDK contexts). Logs at INFO so events are visible in the + user's terminal, but the Protocol contract is preserved.""" + + async def emit(self, event_type: str, payload: dict) -> None: + logger.info("connections-event %s: %s", event_type, payload) + + +_active_emitter: Optional[EventEmitter] = _LoggingEmitter() + + +def set_emitter(emitter: EventEmitter) -> None: + """Register the active emitter. Idempotent โ€” caller-side responsibility + to re-set if the previous one is invalidated (e.g. on app restart).""" + global _active_emitter + _active_emitter = emitter + + +def reset_emitter() -> None: + """Restore the no-op logging emitter (used by tests).""" + global _active_emitter + _active_emitter = _LoggingEmitter() + + +async def emit(event_type: str, payload: dict) -> None: + """Emit an event through the currently-registered emitter.""" + if _active_emitter is not None: + await _active_emitter.emit(event_type, payload) diff --git a/src/gaia/connectors/flow.py b/src/gaia/connectors/flow.py new file mode 100644 index 000000000..10a371780 --- /dev/null +++ b/src/gaia/connectors/flow.py @@ -0,0 +1,389 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +OAuth flow lifecycle and loopback callback server. + +Built on ``aiohttp.web`` (already in base ``install_requires``) โ€” never +``asyncio.start_server`` (which is raw TCP and would silently lose the +auth code), never ``http.server`` (which would re-open the threading-to- +async bridge we explicitly avoid). + +The runner runs in whichever event loop calls ``start_authorization``. +SDK / CLI / AgentUI callers all drive the same primitive; only the +surrounding presentation layer differs. + +Plan amendment A8 hardenings: +- Explicit ``None`` guard before ``hmac.compare_digest`` (the runtime + raises ``TypeError`` otherwise โ€” a malformed redirect would surface + as an unstructured 500). +- Static success HTML literal โ€” no f-string interpolation of any + request-supplied data โ€” XSS-proof by construction. +- ``webbrowser.open`` dispatched via ``run_in_executor`` so a slow + browser launch on Linux does not block concurrent SSE streams. + +v1 single-flow scope: ``_pending`` is a ``dict[flow_id, _PendingFlow]``, +but only one flow can be active at a time per process โ€” a second +``start_authorization`` call while one is pending raises +``FlowInProgressError``. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hmac +import json +import logging +import secrets +import uuid +import webbrowser +from dataclasses import dataclass +from typing import Any, Dict, Iterable, Optional + +import httpx +from aiohttp import web + +from gaia.connectors.errors import ( + ConnectorsError, + ConsentDeniedError, + FlowTimeoutError, +) +from gaia.connectors.events import emit +from gaia.connectors.pkce import compute_code_challenge, generate_code_verifier +from gaia.connectors.providers import get as get_provider +from gaia.connectors.store import save_connection + +logger = logging.getLogger(__name__) + + +# Static success page (A8) โ€” a literal string, no interpolation. The user +# closes the browser tab when they see this. window.close() works for +# popup-style auth flows; for tab-style flows the user closes manually. +_SUCCESS_HTML = ( + "" + "Connected to GAIA" + "" + "

Connected.

" + "

You may close this tab and return to GAIA.

" + "" + "" +) + +# Static error page used for invalid callback shapes (no state, mismatched +# state, etc.). Also a literal โ€” never interpolates query-string data. +_ERROR_HTML = ( + "" + "GAIA โ€” request rejected" + "" + "

Request rejected.

" + "

Return to GAIA and start the connection again.

" + "" +) + + +_FLOW_TIMEOUT_SECONDS = 120 + + +@dataclass +class _PendingFlow: + flow_id: str + provider_id: str + scopes: list[str] + code_verifier: str + state: str + redirect_uri: str + runner: web.AppRunner + future: "asyncio.Future[Dict[str, Any]]" + + +# v1 single-flow constraint per the plan: only one flow can be pending at +# a time. The dict shape is forward-compat for v2 multi-flow. +_pending: dict[str, _PendingFlow] = {} + + +def _decode_email_from_id_token(id_token: str) -> Optional[str]: + """ + Extract the ``email`` claim from a Google id_token payload. + + Best-effort โ€” base64url-decode the middle segment, parse JSON, return + the ``email`` field. Production validation is deferred to the + userinfo endpoint; this is a quick path for the success page. + """ + try: + _, payload_b64, _ = id_token.split(".") + except ValueError: + return None + # base64url, no padding โ€” pad up to a multiple of 4. + padded = payload_b64 + "=" * (-len(payload_b64) % 4) + try: + payload = json.loads(base64.urlsafe_b64decode(padded).decode("ascii")) + except (ValueError, UnicodeDecodeError): + return None + email = payload.get("email") + return email if isinstance(email, str) else None + + +async def start_authorization( + provider_id: str, + scopes: Iterable[str], +) -> Dict[str, Any]: + """ + Begin the OAuth flow for ``provider_id`` with the requested scopes. + + Returns ``{flow_id, authorization_url}``. Spins up a loopback aiohttp + runner on an ephemeral port, stores the pending flow, fires a + background callback to ``webbrowser.open(...)`` (in an executor to + keep the event loop responsive), and returns immediately. + + The caller is expected to await ``complete_authorization(flow_id)`` + to wait for the redirect. + """ + if _pending: + # User re-clicking Connect signals the previous flow is dead. + # Common case: Google blocks the auth (wrong account / consent + # denied / closed tab) and never redirects to the loopback + # callback, so complete_authorization is never awaited and + # _teardown_flow never runs. Evict any stale entries and proceed + # โ€” single-active-flow semantics are preserved because we tear + # down before starting fresh. FlowInProgressError remains in the + # public API for explicit-cancel callers (cancel_flow). + stale_ids = list(_pending.keys()) + logger.info( + "flow: evicting %d stale pending flow(s) on new start_authorization: %s", + len(stale_ids), + stale_ids, + ) + for stale_id in stale_ids: + await _teardown_flow(stale_id) + + provider = get_provider(provider_id) + scopes_list = list(scopes) or list(provider.default_scopes) + + code_verifier = generate_code_verifier() + challenge = compute_code_challenge(code_verifier) + state = secrets.token_urlsafe(32) + flow_id = uuid.uuid4().hex + + loop = asyncio.get_event_loop() + future: "asyncio.Future[Dict[str, Any]]" = loop.create_future() + + app = web.Application() + + async def callback(request: web.Request) -> web.Response: + return await _handle_callback(request, flow_id) + + app.router.add_get("/callback", callback) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + + # Read back the actual port the kernel assigned. aiohttp keeps the + # bound sockets on the runner.sites list. + port = site._server.sockets[0].getsockname()[1] + redirect_uri = f"http://127.0.0.1:{port}/callback" + + authorization_url = provider.authorization_url( + redirect_uri=redirect_uri, + challenge=challenge, + state=state, + scopes=scopes_list, + ) + + _pending[flow_id] = _PendingFlow( + flow_id=flow_id, + provider_id=provider_id, + scopes=scopes_list, + code_verifier=code_verifier, + state=state, + redirect_uri=redirect_uri, + runner=runner, + future=future, + ) + + # Fire-and-forget the browser launch โ€” A8: do not block the event + # loop on a slow browser-launch (5s on some Linux setups freezes + # all concurrent SSE streams). + async def _open_browser(): + try: + await loop.run_in_executor(None, webbrowser.open, authorization_url) + except Exception as e: + # Best-effort โ€” the authorization_url is also returned to + # the caller for a copy-paste fallback. + logger.warning( + "flow: webbrowser.open failed (%s); fall back " + "to copy-paste of authorization_url", + e, + ) + + asyncio.ensure_future(_open_browser()) + + logger.info( + "flow: started scopes=%d flow_id=%s", + len(scopes_list), + flow_id, + ) + return {"flow_id": flow_id, "authorization_url": authorization_url} + + +async def complete_authorization(flow_id: str) -> Dict[str, Any]: + """ + Wait up to 120 seconds for the loopback callback to fulfil the flow. + + Returns a ``ConnectorState`` dict + ``{provider, account_email, scopes, connected_at}`` once the token + exchange succeeds and the connection is persisted via + ``store.save_connection``. + + Raises ``FlowTimeoutError``, ``ConsentDeniedError``, or + ``ConnectorsError`` on the unhappy paths. + """ + flow = _pending.get(flow_id) + if flow is None: + raise ConnectorsError( + f"Unknown flow_id {flow_id!r}. Either it was never started, " + "already completed, or was cancelled." + ) + + try: + try: + return await asyncio.wait_for(flow.future, timeout=_FLOW_TIMEOUT_SECONDS) + except asyncio.TimeoutError as e: + raise FlowTimeoutError( + f"OAuth flow {flow_id!r} timed out after " + f"{_FLOW_TIMEOUT_SECONDS}s. Restart the flow." + ) from e + finally: + await _teardown_flow(flow_id) + + +async def cancel_flow(flow_id: str) -> None: + """Tear down a pending flow without waiting (used by tests / UI).""" + await _teardown_flow(flow_id) + + +async def _teardown_flow(flow_id: str) -> None: + flow = _pending.pop(flow_id, None) + if flow is None: + return + try: + await flow.runner.cleanup() + except Exception as e: + # Cleanup is best-effort โ€” log and move on. + logger.warning("flow: runner.cleanup failed for %s: %s", flow_id, e) + + +async def _handle_callback(request: web.Request, flow_id: str) -> web.Response: + """Loopback handler for ``GET /callback``.""" + flow = _pending.get(flow_id) + if flow is None: + # Stale callback for a flow that was already cleaned up. + return web.Response(text=_ERROR_HTML, content_type="text/html", status=400) + + received_state = request.query.get("state") + error = request.query.get("error") + code = request.query.get("code") + + # A8: explicit None guard. ``hmac.compare_digest(None, str)`` raises + # ``TypeError`` and aiohttp would surface that as an unstructured 500. + if received_state is None or not hmac.compare_digest(received_state, flow.state): + # Static error page; no echoed input. + return web.Response(text=_ERROR_HTML, content_type="text/html", status=400) + + if error is not None: + # Common case: ?error=access_denied โ€” the user clicked "deny" on + # the consent screen. Resolve the future with the typed exception + # and serve the rejection page (NOT the success page โ€” telling a + # user who just clicked "Deny" that they're connected is wrong). + if not flow.future.done(): + flow.future.set_exception( + ConsentDeniedError(f"OAuth flow rejected by user: {error}") + ) + return web.Response(text=_ERROR_HTML, content_type="text/html", status=400) + + if code is None: + # State matched but no code โ€” malformed redirect. + return web.Response(text=_ERROR_HTML, content_type="text/html", status=400) + + # Exchange the code for tokens. + try: + result = await _exchange_code_for_tokens(flow, code) + except Exception as e: + if not flow.future.done(): + flow.future.set_exception(e) + return web.Response(text=_ERROR_HTML, content_type="text/html", status=502) + + if not flow.future.done(): + flow.future.set_result(result) + return web.Response(text=_SUCCESS_HTML, content_type="text/html") + + +async def _exchange_code_for_tokens(flow: _PendingFlow, code: str) -> Dict[str, Any]: + """Run the token-exchange step and persist the connection.""" + provider = get_provider(flow.provider_id) + body = provider.token_request_body( + code=code, verifier=flow.code_verifier, redirect_uri=flow.redirect_uri + ) + + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(provider.token_url, data=body) + + if response.status_code != 200: + raise ConnectorsError( + f"Token exchange for {flow.provider_id} failed with status " + f"{response.status_code}: {response.text}. See docs/security/connections.mdx." + ) + payload = response.json() + refresh_token = payload.get("refresh_token") + if not refresh_token: + raise ConnectorsError( + f"Token endpoint for {flow.provider_id} returned no " + "refresh_token. Make sure the provider's " + "authorization_params() includes the offline-access flags " + "(Google requires access_type=offline + prompt=consent). See " + "docs/security/connections.mdx." + ) + + account_email = _decode_email_from_id_token(payload.get("id_token", "")) or "" + + save_connection( + provider=flow.provider_id, + account_email=account_email or "default", + refresh_token=refresh_token, + scopes=flow.scopes, + client_id_hash=provider.client_id_hash, + ) + + # No separate state-cache write needed โ€” the keyring blob written + # above is the source of truth for "configured / account / scopes", + # and the router reads it via ``store.peek_connection`` for the UI. + + # Google's token endpoint does not return a ``connected_at`` field + # (RFC 6749 has no such concept) โ€” record the local wall-clock at + # exchange time. ``save_connection`` does the same for the keyring blob. + import time as _time + + state_dict = { + "provider": flow.provider_id, + "account_email": account_email or "default", + "scopes": flow.scopes, + "connected_at": _time.time(), + } + # Emit both the new framework event-name (matches the SSE router + # docstring and what the AgentUI listens for) and the legacy name + # for any older subscribers. The keys ``connector_id`` / + # ``account_email`` match the router-documented payload. + await emit( + "connector.oauth.completed", + { + "connector_id": flow.provider_id, + "account_email": state_dict["account_email"], + }, + ) + await emit( + "connection.connected", + {"provider": flow.provider_id, "account_email": state_dict["account_email"]}, + ) + return state_dict diff --git a/src/gaia/connectors/grants.py b/src/gaia/connectors/grants.py new file mode 100644 index 000000000..4bf7db415 --- /dev/null +++ b/src/gaia/connectors/grants.py @@ -0,0 +1,210 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Per-agent grants ledger at ``~/.gaia/connectors/grants.json``. + +Schema:: + + { + "": { + "": ["", ""] + } + } + +Where ``namespaced_agent_id`` is ``builtin:`` for built-in agents and +``custom::`` for custom agents under +``~/.gaia/agents/`` (per plan amendment A9). + +Atomicity guarantees: + +- Writes go to a unique tempfile via ``tempfile.mkstemp(dir=parent)``, + then ``os.replace(tmp, final)`` โ€” POSIX atomic, Windows best-effort + via ``MoveFileEx(MOVEFILE_REPLACE_EXISTING)``. ``os.rename`` would + raise on Windows when the destination exists. +- The tempfile is opened with ``0o600`` from the start (``O_EXCL`` mode + on the file descriptor) so there is no window where the file briefly + has a default mode. +- A per-process ``asyncio.Lock`` serializes concurrent writes from the + same event loop. Cross-process concurrency is documented as a v1 + limitation in ``connections/__init__.py``. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import sys +import tempfile +import threading +from pathlib import Path +from typing import Dict, List + +from gaia.connectors.errors import ConnectorsError + +logger = logging.getLogger(__name__) + + +# Read at import time. Tests monkeypatch ``Path.home`` BEFORE this module is +# imported (or after โ€” see test conftest); the runtime helper ``_grants_path`` +# evaluates ``Path.home()`` on every call so it sees the latest patched value. +GRANTS_FILE = Path.home() / ".gaia" / "connectors" / "grants.json" + + +# Per-process write lock. Both an asyncio.Lock and a threading.Lock are +# needed because grant_agent is sync but may be invoked from multiple +# threads (CLI worker thread + UI server thread + test driver). The +# threading.Lock is sufficient; the asyncio.Lock would only matter for +# native-async callers, which serialize anyway under our usage pattern. +_write_lock = threading.Lock() + + +def _grants_path() -> Path: + """Resolve the grants path on each call so tests can ``monkeypatch.setattr`` + on ``Path.home`` after import.""" + return Path.home() / ".gaia" / "connectors" / "grants.json" + + +def _ensure_parent(path: Path) -> None: + """Create the parent directory with mode 0700 if missing (POSIX).""" + parent = path.parent + parent.mkdir(parents=True, exist_ok=True) + if sys.platform != "win32": + # mkdir's mode honors the umask; chmod explicitly to 0o700. + try: + os.chmod(parent, 0o700) + except OSError as e: + # Windows or restricted filesystems โ€” not fatal; log and continue. + logger.warning("grants: could not chmod %s: %s", parent, e) + + +def load_grants() -> Dict[str, Dict[str, List[str]]]: + """ + Read and return the grants ledger. Returns an empty dict if no file. + + A corrupted file raises ``ConnectorsError`` with the path and the + rm command for recovery (A7). + """ + path = _grants_path() + if not path.exists(): + return {} + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + except json.JSONDecodeError as e: + raise ConnectorsError( + f"Grants ledger at {path} is corrupted ({e.msg} at line " + f"{e.lineno}). Delete the file to reset all per-agent grants: " + f" rm {path}\n" + "You will need to re-grant scopes from Settings โ†’ Connections " + "or via `gaia connectors grants grant ...`." + ) from e + except OSError as e: + raise ConnectorsError( + f"Could not read grants ledger at {path}: {e}. Check file " + "permissions; the parent directory should be 0700 and the " + "file 0600." + ) from e + if not isinstance(data, dict): + raise ConnectorsError( + f"Grants ledger at {path} has the wrong shape (expected a " + f"JSON object). Delete with `rm {path}` to reset." + ) + return data + + +def _save_grants_locked(data: Dict[str, Dict[str, List[str]]]) -> None: + """ + Write the grants ledger to disk atomically. Caller MUST hold ``_write_lock``. + + Tempfile is created with mode 0600 from the start. + """ + path = _grants_path() + _ensure_parent(path) + + # mkstemp returns an OS-level fd opened with O_EXCL โ€” no other process + # can attach to the same name. The fd is opened with mode 0600 by + # mkstemp on POSIX. + fd, tmp_path = tempfile.mkstemp(dir=path.parent, prefix=".grants_", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, sort_keys=True, indent=2) + if sys.platform != "win32": + # mkstemp sets 0600 on POSIX, but be defensive in case the + # kernel returned a different mode (e.g. on tmpfs). + os.chmod(tmp_path, 0o600) + # os.replace is atomic on POSIX and best-effort atomic on Windows + # (MoveFileEx with MOVEFILE_REPLACE_EXISTING). + os.replace(tmp_path, path) + except Exception: + # Clean up the tempfile on any failure path so we don't leak. + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +def grant_agent(connector_id: str, agent_id: str, scopes: List[str]) -> None: + """ + Grant ``agent_id`` (already namespaced) the given scopes for ``connector_id``. + + Overwrites any existing scopes for the same ``(connector_id, agent_id)`` pair. + The full load-modify-save sequence is performed under the per-process + write lock so concurrent grants from multiple threads don't lose updates. + """ + with _write_lock: + data = load_grants() + data.setdefault(connector_id, {})[agent_id] = list(scopes) + _save_grants_locked(data) + logger.debug( + "grants: granted connector_id=%s agent_id=%s scopes=%d", + connector_id, + agent_id, + len(scopes), + ) + + +def revoke_agent_grant(connector_id: str, agent_id: str) -> None: + """ + Remove an agent's grant for ``connector_id``. Idempotent โ€” silently no-ops + if the agent has no grant. + """ + with _write_lock: + data = load_grants() + if connector_id in data and agent_id in data[connector_id]: + del data[connector_id][agent_id] + if not data[connector_id]: + del data[connector_id] + _save_grants_locked(data) + logger.debug( + "grants: revoked connector_id=%s agent_id=%s", connector_id, agent_id + ) + + +def list_agent_grants(connector_id: str) -> Dict[str, List[str]]: + """Return ``{agent_id: [scopes]}`` for ``connector_id``, or empty dict.""" + return dict(load_grants().get(connector_id, {})) + + +def check_agent_grant( + connector_id: str, agent_id: str, required_scopes: List[str] +) -> bool: + """ + Return True if ``agent_id`` has been granted a superset of + ``required_scopes`` for ``connector_id``. + """ + granted = set(list_agent_grants(connector_id).get(agent_id, [])) + return set(required_scopes) <= granted + + +# Public alias kept for the asyncio-friendly API. The underlying call is +# sync because file I/O on local disk is fast and the per-process write +# is rare. Callers in async code can use ``await asyncio.to_thread(...)`` +# if they need to keep the loop unblocked under heavy concurrency. +async def grant_agent_async( + connector_id: str, agent_id: str, scopes: List[str] +) -> None: + """Async wrapper around ``grant_agent`` for native-async callers.""" + await asyncio.to_thread(grant_agent, connector_id, agent_id, scopes) diff --git a/src/gaia/connectors/handler.py b/src/gaia/connectors/handler.py new file mode 100644 index 000000000..9924158bd --- /dev/null +++ b/src/gaia/connectors/handler.py @@ -0,0 +1,231 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +ConnectorHandler Protocol and get_credential dispatcher. + +Every connector type (``oauth_pkce``, ``mcp_server``) implements the +``ConnectorHandler`` structural Protocol. The dispatcher in this module +routes ``get_credential`` / ``configure`` / ``disconnect`` / ``test`` +calls to the right handler without knowing about handler internals. + +Handler registration happens in type-specific modules (``oauth_pkce.py``, +``mcp_server.py``) that call ``register_handler`` at import time. The +dispatcher is type-agnostic; adding a new type only requires: + 1. A new handler class that satisfies the Protocol + 2. A ``register_handler(type_key, HandlerClass)`` call on import + +The per-agent grant check lives here (not in handlers) because it is +type-agnostic: every connector type gates ``get_credential`` on whether +the calling agent has been granted the required scopes. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Dict, List, Optional, Protocol, runtime_checkable + +from gaia.connectors.context import current_agent_id +from gaia.connectors.errors import AuthRequiredError, ConnectorsError +from gaia.connectors.grants import check_agent_grant, list_agent_grants +from gaia.connectors.registry import REGISTRY +from gaia.connectors.spec import ConnectorSpec, ConnectorType + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# ConnectorHandler Protocol +# --------------------------------------------------------------------------- + + +@runtime_checkable +class ConnectorHandler(Protocol): + """ + Structural protocol every connector-type handler must satisfy. + + Handlers are instantiated per-call (stateless) or as singletons โ€” the + dispatcher does not prescribe lifetime. Handlers must NOT perform blocking + I/O on the event loop; wrap filesystem operations in ``asyncio.to_thread``. + + All methods receive the resolved ``ConnectorSpec`` so handlers can access + the full catalog metadata (scopes, mcp_command, etc.) without coupling to + the registry. + """ + + async def get_credential( + self, + spec: ConnectorSpec, + *, + required_scopes: Optional[List[str]] = None, + account_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Return credential dict appropriate for this connector type.""" + ... + + async def configure( + self, + spec: ConnectorSpec, + config: Dict[str, Any], + ) -> Dict[str, Any]: + """Apply configuration for this connector. Returns updated state.""" + ... + + async def disconnect( + self, + spec: ConnectorSpec, + *, + account_id: Optional[str] = None, + ) -> None: + """Remove stored credentials for this connector.""" + ... + + async def test(self, spec: ConnectorSpec) -> Dict[str, Any]: + """Return ``{"ok": bool, "detail": str}`` health check.""" + ... + + +# --------------------------------------------------------------------------- +# Handler registry +# --------------------------------------------------------------------------- + +_HANDLER_REGISTRY: Dict[str, ConnectorHandler] = {} + + +def register_handler(connector_type: ConnectorType, handler: ConnectorHandler) -> None: + """ + Register a handler instance for a connector type. + + Called at import time by each type module (oauth_pkce.py, mcp_server.py). + Raises ``ValueError`` on duplicate registration so accidental double-import + is caught immediately. + """ + if connector_type in _HANDLER_REGISTRY: + raise ValueError( + f"Handler for connector type {connector_type!r} is already registered. " + f"Existing: {_HANDLER_REGISTRY[connector_type]!r}" + ) + _HANDLER_REGISTRY[connector_type] = handler + logger.debug( + "handler: registered type=%s handler=%s", + connector_type, + type(handler).__name__, + ) + + +def _get_handler(spec: ConnectorSpec) -> ConnectorHandler: + """Look up the handler for spec.type. Raises ConnectorsError if missing.""" + handler = _HANDLER_REGISTRY.get(spec.type) + if handler is None: + registered = sorted(_HANDLER_REGISTRY) + raise ConnectorsError( + f"No handler registered for connector type {spec.type!r} " + f"(connector_id={spec.id!r}). Registered types: {registered!r}. " + "Import the handler module before calling get_credential / configure." + ) + return handler + + +# --------------------------------------------------------------------------- +# Public dispatcher +# --------------------------------------------------------------------------- + + +async def get_credential( + connector_id: str, + *, + agent_id: Optional[str] = None, + required_scopes: Optional[List[str]] = None, + account_id: Optional[str] = None, +) -> Dict[str, Any]: + """ + Return the credential dict for ``connector_id``. + + Agent-id resolution order: + 1. Explicit ``agent_id`` kwarg, if non-None. + 2. Active contextvar (``current_agent_id()``), set by the agent runtime. + 3. ``None`` โ†’ grant check is SKIPPED (CLI/debug callers). + + If an agent_id is resolved AND ``required_scopes`` is provided, the + per-agent grant is verified before calling the handler. + """ + spec = REGISTRY.get(connector_id) + resolved_agent = agent_id or current_agent_id() + + if resolved_agent and required_scopes: + if not check_agent_grant(connector_id, resolved_agent, required_scopes): + granted = set(list_agent_grants(connector_id).get(resolved_agent, [])) + missing = [s for s in required_scopes if s not in granted] + raise AuthRequiredError( + AuthRequiredError.Reason.AGENT_NOT_GRANTED, + provider=connector_id, + agent_id=resolved_agent, + missing_scopes=missing, + ) + + handler = _get_handler(spec) + return await handler.get_credential( + spec, + required_scopes=required_scopes, + account_id=account_id, + ) + + +def get_credential_sync( + connector_id: str, + *, + agent_id: Optional[str] = None, + required_scopes: Optional[List[str]] = None, + account_id: Optional[str] = None, +) -> Dict[str, Any]: + """ + Sync wrapper for ``get_credential``. + + Uses the same running-loop guard pattern as ``get_access_token_sync`` in + ``tokens.py``: raises ``RuntimeError`` if called from inside a running loop + (callers should use ``await get_credential(...)`` instead). + """ + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + if loop is not None and loop.is_running(): + raise RuntimeError( + "get_credential_sync() called from inside a running event loop. " + "Use 'await get_credential(...)' instead." + ) + return asyncio.run( + get_credential( + connector_id, + agent_id=agent_id, + required_scopes=required_scopes, + account_id=account_id, + ) + ) + + +async def configure( + connector_id: str, + config: Dict[str, Any], +) -> Dict[str, Any]: + """Configure a connector. Returns updated state dict.""" + spec = REGISTRY.get(connector_id) + handler = _get_handler(spec) + return await handler.configure(spec, config) + + +async def disconnect( + connector_id: str, + *, + account_id: Optional[str] = None, +) -> None: + """Disconnect a connector (remove stored credentials).""" + spec = REGISTRY.get(connector_id) + handler = _get_handler(spec) + await handler.disconnect(spec, account_id=account_id) + + +async def health_check(connector_id: str) -> Dict[str, Any]: + """Run the health-check for a connector. Returns ``{"ok": bool, "detail": str}``.""" + spec = REGISTRY.get(connector_id) + handler = _get_handler(spec) + return await handler.test(spec) diff --git a/src/gaia/connectors/mcp_server.py b/src/gaia/connectors/mcp_server.py new file mode 100644 index 000000000..90f21d3c9 --- /dev/null +++ b/src/gaia/connectors/mcp_server.py @@ -0,0 +1,269 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +McpServerHandler โ€” ConnectorHandler implementation for ``type="mcp_server"``. + +Manages MCP server connectors: stores secret env-var values in the OS keyring +under ``$keyring`` references, writes ``~/.gaia/mcp_servers.json`` atomically, +and signals ``MCPClientManager.reload()`` so new tools materialize without +restarting GAIA (plan amendment A5). + +Keyring storage layout: + - Service: ``gaia.connections`` (same service as OAuth tokens, per A3) + - Username: ``:`` (e.g. ``"github:GITHUB_TOKEN"``) + +``mcp_servers.json`` env block uses ``$keyring`` references (plan amendment A4): + ``{"env": {"GITHUB_TOKEN": {"$keyring": "gaia.connections:github:GITHUB_TOKEN"}}}`` +``MCPClient.from_config()`` resolves references at spawn time and fails closed +if a referenced keyring entry is missing (plan amendment A5b). +""" + +from __future__ import annotations + +import json +import logging +import os +import tempfile +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +import keyring + +from gaia.connectors.errors import ConnectorsError +from gaia.connectors.handler import register_handler +from gaia.connectors.spec import ConnectorSpec +from gaia.connectors.store import SERVICE_NAME + +logger = logging.getLogger(__name__) + +# Path to the MCP server config file read by MCPClient. +_MCP_SERVERS_FILE = Path.home() / ".gaia" / "mcp_servers.json" + + +def _mcp_servers_path() -> Path: + """Resolve on each call so tests can monkeypatch ``Path.home``.""" + return Path.home() / ".gaia" / "mcp_servers.json" + + +def _keyring_ref(connector_id: str, env_key: str) -> str: + """Return the ``$keyring`` reference string for a given env key.""" + return f"{SERVICE_NAME}:{connector_id}:{env_key}" + + +def _write_mcp_servers_json(servers: Dict[str, Any]) -> None: + """Atomically overwrite ``mcp_servers.json`` with *servers* dict.""" + path = _mcp_servers_path() + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".mcp_servers_", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump({"mcpServers": servers}, f, indent=2) + f.write("\n") + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def _read_mcp_servers_json() -> Dict[str, Any]: + """Return the servers dict from ``mcp_servers.json``, or {} if missing.""" + path = _mcp_servers_path() + if not path.exists(): + return {} + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + return data.get("mcpServers", data.get("servers", {})) + except (json.JSONDecodeError, OSError) as e: + raise ConnectorsError( + f"mcp_servers.json at {path} is unreadable: {e}. " + "Delete to reset or fix the JSON." + ) from e + + +def is_mcp_server_configured(connector_id: str) -> bool: + """ + True if ``connector_id`` has an entry in ``mcp_servers.json``. + + Source-of-truth lookup for the catalog UI / `gaia connectors list` โ€” + no separate state cache is maintained for MCP servers; the file + written by ``configure`` is itself the configured-state ledger. A + corrupt mcp_servers.json bubbles up as ``ConnectorsError`` so the + UI can show an actionable error rather than a silent "not configured". + """ + return connector_id in _read_mcp_servers_json() + + +class McpServerHandler: + """ + Handles ``type="mcp_server"`` connectors. + + ``get_credential`` resolves keyring refs and returns an env dict. + ``configure`` stores secret env values in keyring and writes + ``mcp_servers.json`` with ``$keyring`` placeholders. + ``disconnect`` removes the entry from ``mcp_servers.json`` and deletes + keyring slots. + + The handler accepts an optional *reload_callback* that is called after + ``configure`` and ``disconnect`` so the live ``MCPClientManager`` + instance can reload without restarting GAIA (plan amendment A5). + """ + + def __init__(self, reload_callback: Optional[Callable[[], None]] = None) -> None: + self._reload = reload_callback + + # ------------------------------------------------------------------ + # ConnectorHandler Protocol implementation + # ------------------------------------------------------------------ + + async def get_credential( # pylint: disable=unused-argument + self, + spec: ConnectorSpec, + *, + required_scopes: Optional[List[str]] = None, + account_id: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Return resolved env-var values for the MCP server. + + Resolves every key in ``spec.mcp_env_keys`` from the keyring. + Raises ``ConnectorsError`` if any key is missing (fail-closed). + """ + env: Dict[str, str] = {} + missing: List[str] = [] + for env_key in spec.mcp_env_keys: + username = f"{spec.id}:{env_key}" + value = keyring.get_password(SERVICE_NAME, username) + if value is None: + missing.append(f"{SERVICE_NAME}:{username}") + else: + env[env_key] = value + + if missing: + raise ConnectorsError( + f"MCP server connector '{spec.id}' has missing keyring entries: " + f"{missing!r}. Reconfigure via Settings โ†’ Connectors or " + f"`gaia connectors configure {spec.id}`." + ) + + return { + "env": env, + "command": spec.mcp_command, + "args": list(spec.mcp_args), + } + + async def configure( + self, + spec: ConnectorSpec, + config: Dict[str, Any], + ) -> Dict[str, Any]: + """ + Store env-var values in keyring and write ``mcp_servers.json``. + + ``config`` must contain a value for every key in ``spec.mcp_env_keys``. + Plain (non-secret) env values not in ``mcp_env_keys`` are written + directly to ``mcp_servers.json`` (not to the keyring). + + After writing, calls the reload callback (if registered) so running + agents pick up new tools without restart. + """ + # Validate all required env keys are supplied. + missing_keys = [k for k in spec.mcp_env_keys if k not in config] + if missing_keys: + raise ConnectorsError( + f"configure({spec.id!r}): missing required env keys {missing_keys!r}. " + "Supply them in the config dict." + ) + + # Store secret env values in keyring + build $keyring reference env block. + env_block: Dict[str, Any] = {} + for env_key in spec.mcp_env_keys: + value = config[env_key] + username = f"{spec.id}:{env_key}" + keyring.set_password(SERVICE_NAME, username, str(value)) + env_block[env_key] = {"$keyring": _keyring_ref(spec.id, env_key)} + + # Read, update, and atomically write mcp_servers.json. + servers = _read_mcp_servers_json() + servers[spec.id] = { + "command": spec.mcp_command, + "args": list(spec.mcp_args), + "env": env_block, + "disabled": config.get("disabled", False), + } + _write_mcp_servers_json(servers) + + logger.info( + "mcp_server: configured connector_id=%s command=%s", + spec.id, + spec.mcp_command, + ) + + if self._reload is not None: + self._reload() + + return { + "configured": True, + "connector_id": spec.id, + "command": spec.mcp_command, + "args": list(spec.mcp_args), + } + + async def disconnect( # pylint: disable=unused-argument + self, + spec: ConnectorSpec, + *, + account_id: Optional[str] = None, + ) -> None: + """Remove the MCP server entry and delete keyring slots.""" + # Remove from mcp_servers.json. + servers = _read_mcp_servers_json() + if spec.id in servers: + del servers[spec.id] + _write_mcp_servers_json(servers) + + # Delete keyring entries for every env key. + for env_key in spec.mcp_env_keys: + username = f"{spec.id}:{env_key}" + try: + keyring.delete_password(SERVICE_NAME, username) + except keyring.errors.PasswordDeleteError: + pass # already absent โ€” idempotent + + logger.info("mcp_server: disconnected connector_id=%s", spec.id) + + if self._reload is not None: + self._reload() + + async def test(self, spec: ConnectorSpec) -> Dict[str, Any]: + """ + Verify the connector by checking all required keyring entries exist. + + Does NOT actually spawn the MCP server process โ€” that would require + the real ``npx`` / command binary which may not be available in CI. + The presence of all keyring slots is treated as "configured and ready + to spawn". + """ + if not spec.mcp_env_keys: + return {"ok": True, "detail": "no_secrets_required"} + + missing: List[str] = [] + for env_key in spec.mcp_env_keys: + username = f"{spec.id}:{env_key}" + if keyring.get_password(SERVICE_NAME, username) is None: + missing.append(env_key) + + if missing: + return { + "ok": False, + "detail": f"missing keyring entries: {missing!r}", + } + + return {"ok": True, "detail": "keyring_entries_present"} + + +# Register the handler singleton at import time. +register_handler("mcp_server", McpServerHandler()) diff --git a/src/gaia/connectors/oauth_pkce.py b/src/gaia/connectors/oauth_pkce.py new file mode 100644 index 000000000..0d4de5648 --- /dev/null +++ b/src/gaia/connectors/oauth_pkce.py @@ -0,0 +1,163 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +OAuthPkceHandler โ€” ConnectorHandler implementation for ``type="oauth_pkce"``. + +Wraps the existing flow.py / tokens.py / store.py primitives from #915 +under the ``ConnectorHandler`` Protocol so the framework dispatcher can +route ``get_credential`` / ``configure`` / ``disconnect`` / ``test`` to +the right implementation without knowing OAuth internals. + +Registration happens at module import via ``register_handler``; callers +only need to ``import gaia.connectors.oauth_pkce`` (done by catalog/__init__.py). + +The grant check is NOT performed here โ€” the dispatcher in handler.py does +it before calling any handler method. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional + +from gaia.connectors.errors import ( + AuthRequiredError, + ConnectorsError, +) +from gaia.connectors.flow import ( + complete_authorization, + start_authorization, +) +from gaia.connectors.handler import register_handler +from gaia.connectors.spec import ConnectorSpec +from gaia.connectors.store import DEFAULT_ACCOUNT, delete_connection +from gaia.connectors.tokens import get_or_refresh + +logger = logging.getLogger(__name__) + + +class OAuthPkceHandler: + """ + Handles ``type="oauth_pkce"`` connectors via the existing PKCE flow. + + ``get_credential`` returns an access-token dict compatible with + Google's token endpoint; the dict shape is: + ``{"access_token": str, "expires_at": int, "scopes": [str]}`` + + This class is stateless โ€” it delegates all persistent state to + ``tokens.py`` (in-memory cache) and ``store.py`` (keyring; the + keyring blob is also the source of truth for the catalog UI's + "configured" state via ``store.peek_connection``). + """ + + # ------------------------------------------------------------------ + # ConnectorHandler Protocol implementation + # ------------------------------------------------------------------ + + async def get_credential( + self, + spec: ConnectorSpec, + *, + required_scopes: Optional[List[str]] = None, + account_id: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Return a live access token for the connector's OAuth provider. + + ``spec.oauth_provider_ref`` identifies the ``OAuthProvider`` in the + provider registry (e.g. ``"google"``). Falls back to ``spec.id``. + """ + provider_id = spec.oauth_provider_ref or spec.id + account_email = account_id or DEFAULT_ACCOUNT + token_str, expires_at = await get_or_refresh( + provider_id, account_email=account_email + ) + return { + "access_token": token_str, + "expires_at": expires_at, + "scopes": list(required_scopes or spec.default_scopes), + } + + async def configure( + self, + spec: ConnectorSpec, + config: Dict[str, Any], + ) -> Dict[str, Any]: + """ + Persist OAuth-client credentials (if supplied), then start a PKCE flow. + + Three call shapes: + 1. ``{client_id, client_secret}`` โ€” first-run path from the + AgentUI "Save & Connect" form. We persist the app + credentials in the keyring, evict the cached provider + instance, then start a fresh PKCE flow. + 2. ``{flow_id, code}`` โ€” completion path for callers that + drove the browser step themselves. + 3. ``{}`` (or just ``scopes``) โ€” start a new PKCE flow using + whatever provider credentials are already on disk + (keyring / env vars). + + The keyring blob written by ``flow._exchange_code_for_tokens`` + remains the source of truth for "configured"; this method does + not write the connection blob itself. + """ + provider_id = spec.oauth_provider_ref or spec.id + + # First-run "Save & Connect": persist client credentials and + # invalidate the provider cache so the next get_provider() call + # picks up the new id/secret instead of a stale instance. + client_id = config.get("client_id") + client_secret = config.get("client_secret", "") + if client_id: + from gaia.connectors.providers import _registry as _provider_registry + from gaia.connectors.store import save_provider_credentials + + save_provider_credentials( + provider_id, + client_id=client_id, + client_secret=client_secret, + ) + _provider_registry.pop(provider_id, None) + + scopes = config.get("scopes") or list(spec.default_scopes) + + if "flow_id" in config and "code" in config: + # Caller has already handled the browser step. + return await complete_authorization(config["flow_id"]) + + # Start a new PKCE flow; caller will open the URL. + return await start_authorization(provider_id, scopes=scopes) + + async def disconnect( + self, + spec: ConnectorSpec, + *, + account_id: Optional[str] = None, + ) -> None: + """Remove stored tokens. The keyring deletion is the source of + truth โ€” once the blob is gone, ``store.peek_connection`` returns + ``None`` and the catalog UI shows "not configured" automatically.""" + provider_id = spec.oauth_provider_ref or spec.id + account_email = account_id or DEFAULT_ACCOUNT + delete_connection(provider_id, account_email=account_email) + logger.info("oauth_pkce: disconnected connector_id=%s", spec.id) + + async def test(self, spec: ConnectorSpec) -> Dict[str, Any]: + """ + Verify the connector by attempting a token refresh. + + Returns ``{"ok": True, "detail": "token_valid"}`` on success, or + ``{"ok": False, "detail": ""}`` on failure. + """ + provider_id = spec.oauth_provider_ref or spec.id + try: + await get_or_refresh(provider_id) + return {"ok": True, "detail": "token_valid"} + except AuthRequiredError as e: + return {"ok": False, "detail": str(e)} + except ConnectorsError as e: + return {"ok": False, "detail": str(e)} + + +# Register the handler singleton at import time. +register_handler("oauth_pkce", OAuthPkceHandler()) diff --git a/src/gaia/connectors/pkce.py b/src/gaia/connectors/pkce.py new file mode 100644 index 000000000..9eb30b301 --- /dev/null +++ b/src/gaia/connectors/pkce.py @@ -0,0 +1,47 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +PKCE primitives (RFC 7636) for OAuth flows in ``gaia.connectors``. + +PKCE is mandatory for desktop apps per RFC 8252; it replaces the client +secret that web apps use. Two values flow through the OAuth handshake: + +- The **code verifier**: a high-entropy random string generated locally and + held in memory for the duration of the flow. +- The **code challenge**: ``base64url(sha256(verifier))`` (no padding) sent + to the authorization endpoint as ``code_challenge`` with + ``code_challenge_method=S256``. + +The token endpoint receives the verifier in clear during the +authorization-code โ†’ token exchange and rejects the exchange unless the +sha256 of the verifier matches the previously-sent challenge. +""" + +from __future__ import annotations + +import base64 +import hashlib +import secrets + + +def generate_code_verifier() -> str: + """ + Return a high-entropy verifier string suitable for PKCE. + + ``secrets.token_urlsafe(64)`` produces 86 base64url characters from 64 + random bytes โ€” well within the RFC 7636 [43, 128] character window. No + trimming needed; the test in ``test_pkce.py`` confirms length and + charset across 1000 random samples. + """ + return secrets.token_urlsafe(64) + + +def compute_code_challenge(verifier: str) -> str: + """ + Compute the S256 PKCE challenge for ``verifier``. + + Returns ``base64url(sha256(verifier))`` with the trailing ``=`` padding + stripped, per RFC 7636 ยง4.2. + """ + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") diff --git a/src/gaia/connectors/providers/__init__.py b/src/gaia/connectors/providers/__init__.py new file mode 100644 index 000000000..a0dec4546 --- /dev/null +++ b/src/gaia/connectors/providers/__init__.py @@ -0,0 +1,56 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +OAuth provider registry for ``gaia.connectors``. + +Lazy registration: ``get("google")`` instantiates and registers +``GoogleOAuthProvider`` on demand if the registry is empty for that id. SDK, +CLI, and AgentUI consumers never need to register the provider explicitly โ€” +the first ``get`` does it. AgentUI's lifespan still calls a tripwire sweep +that triggers the lazy registration early so a missing env var surfaces in +the server logs at boot, but the layer never depends on a specific caller +having registered first. +""" + +from __future__ import annotations + +from gaia.connectors.providers.base import ( # noqa: F401 re-export + ConnectorRequirement, + OAuthProvider, +) + +_registry: dict[str, OAuthProvider] = {} + + +def register(provider: OAuthProvider) -> None: + """Insert (or overwrite) a provider in the registry.""" + _registry[provider.provider_id] = provider + + +def get(provider_id: str) -> OAuthProvider: + """ + Return the registered provider, instantiating known built-ins lazily. + + Raises ``KeyError`` for unknown provider ids. + """ + if provider_id in _registry: + return _registry[provider_id] + + if provider_id == "google": + # Lazy import to avoid pulling Google-specific code at module load + # for CLI/SDK callers that only target a different provider. + from gaia.connectors.providers.google import GoogleOAuthProvider + + provider = GoogleOAuthProvider() + register(provider) + return provider + + raise KeyError( + f"Unknown OAuth provider '{provider_id}'. Known: " + f"{sorted(set(_registry) | {'google'})}" + ) + + +def list_provider_ids() -> list[str]: + """Return the ids of currently registered providers (no lazy init).""" + return sorted(_registry) diff --git a/src/gaia/connectors/providers/base.py b/src/gaia/connectors/providers/base.py new file mode 100644 index 000000000..8eb4186f3 --- /dev/null +++ b/src/gaia/connectors/providers/base.py @@ -0,0 +1,74 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Provider abstraction for ``gaia.connectors``. + +Defines: +- ``ConnectorRequirement``: declared on agent classes via the + ``REQUIRED_CONNECTORS`` ClassVar; surfaced to AgentUI's consent dialog and + to the CLI grant commands. +- ``OAuthProvider``: a structural ``Protocol`` describing the static and + runtime surface the connections core relies on. Each concrete provider + (``GoogleOAuthProvider``, future Microsoft/etc.) implements this protocol + without inheriting from it โ€” duck-typed, matching GAIA's mixin style. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Iterable, Protocol, Sequence, runtime_checkable + + +@dataclass(frozen=True) +class ConnectorRequirement: + """ + Declared on agent classes as ``REQUIRED_CONNECTORS = [ConnectorRequirement(...)]``. + + ``connector_id`` must match a ``ConnectorSpec.id`` in the catalog (e.g. + ``"google"``). Frozen + hashable so it can live in sets and serve as a + dict key. ``scopes`` is normalized to a tuple in ``__post_init__`` so two + requirements built from different list instances compare equal. + """ + + connector_id: str + scopes: Sequence[str] + reason: str = field(default="") + + def __post_init__(self): + # Frozen dataclass โ€” bypass setattr via object.__setattr__. + object.__setattr__(self, "scopes", tuple(self.scopes)) + + +@runtime_checkable +class OAuthProvider(Protocol): + """ + Static + runtime surface every concrete OAuth provider must implement. + + The runtime registry (``providers/__init__.py``) returns an instance of + this protocol. ``flow.py``, ``tokens.py``, and ``store.py`` consume it + without knowing about Google specifics โ€” provider-specific extras like + Google's ``access_type=offline`` come from ``authorization_params()``. + """ + + provider_id: str + auth_url: str + token_url: str + client_id: str + client_id_hash: str + default_scopes: Sequence[str] + + def authorization_url( + self, + redirect_uri: str, + challenge: str, + state: str, + scopes: Iterable[str], + ) -> str: ... + + def token_request_body( + self, code: str, verifier: str, redirect_uri: str + ) -> dict: ... + + def refresh_request_body(self, refresh_token: str) -> dict: ... + + def authorization_params(self) -> dict: ... diff --git a/src/gaia/connectors/providers/google.py b/src/gaia/connectors/providers/google.py new file mode 100644 index 000000000..e19e5bf89 --- /dev/null +++ b/src/gaia/connectors/providers/google.py @@ -0,0 +1,165 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Google OAuth 2.0 provider for ``gaia.connectors``. + +NO module-level side effects: instantiating the provider reads +``GAIA_GOOGLE_CLIENT_ID`` and computes ``client_id_hash``. Importing this +module does not register anything โ€” registration happens in +``providers/__init__.py`` lazily on first ``get("google")`` call (or via an +explicit ``register()`` call from a caller that wants strict startup). + +Desktop-app PKCE flow. Google requires ``client_secret`` even for Desktop-type +clients โ€” it is "not truly confidential" for installed apps but the token +endpoint rejects requests that omit it. Set ``GAIA_GOOGLE_CLIENT_SECRET`` to +the value shown in Cloud Console โ†’ Credentials โ†’ your Desktop client. + +Per AC23, ``SCOPE_DESCRIPTIONS`` pins the plain-language label for each scope +so the AgentUI consent dialog and the CLI grant subcommand both render the +same human-readable string for a given scope. A unit test in +``test_scope_descriptions.py`` enforces that every scope used in any agent's +``REQUIRED_CONNECTORS`` has an entry here. +""" + +from __future__ import annotations + +import os +import zlib +from typing import Iterable, Sequence +from urllib.parse import urlencode + +from gaia.connectors.errors import ConfigurationError + +# Plain-language descriptions for the AgentUI consent dialog (AC23). The +# router and the CLI both surface this map; agents declare scope URLs in +# REQUIRED_CONNECTORS; the UI/CLI render the description, never the URL. +SCOPE_DESCRIPTIONS: dict[str, str] = { + "https://www.googleapis.com/auth/gmail.readonly": "Read your email", + "https://www.googleapis.com/auth/gmail.send": "Send email on your behalf", + "https://www.googleapis.com/auth/gmail.compose": "Draft and send email on your behalf", + "https://www.googleapis.com/auth/gmail.modify": "Read, modify, and send email on your behalf", + "https://www.googleapis.com/auth/calendar.readonly": "Read your calendar events", + "https://www.googleapis.com/auth/calendar.events": "Manage your calendar events", + "https://www.googleapis.com/auth/drive.readonly": "Read your Google Drive files", + "https://www.googleapis.com/auth/drive.file": "Manage Drive files this app creates", + "https://www.googleapis.com/auth/userinfo.email": "See your email address", + "https://www.googleapis.com/auth/userinfo.profile": "See your basic profile", + "openid": "Verify your identity", +} + + +class GoogleOAuthProvider: + """ + Concrete provider for ``accounts.google.com``. Implements ``OAuthProvider`` + structurally โ€” no inheritance. + + Reads ``GAIA_GOOGLE_CLIENT_ID`` at instantiation time, NOT at import time. + The hash of the client id is precomputed so the tripwire check in + ``store.load_connection`` is a constant-time string compare. + """ + + provider_id: str = "google" + auth_url: str = "https://accounts.google.com/o/oauth2/v2/auth" + token_url: str = "https://oauth2.googleapis.com/token" + default_scopes: Sequence[str] = ( + "openid", + "https://www.googleapis.com/auth/userinfo.email", + ) + + def __init__(self, client_id: str | None = None, client_secret: str | None = None): + # Resolution order (per AC; user-friendliness first): + # 1. Explicit kwargs (used by tests and library callers). + # 2. Keyring-stored credentials saved via the AgentUI's + # Settings โ†’ Connections โ†’ Google โ†’ "Save & Connect" form. + # This is the path real users take. + # 3. Env vars (GAIA_GOOGLE_CLIENT_ID / GAIA_GOOGLE_CLIENT_SECRET) + # kept as a fallback for CI, scripted setups, and existing + # install bases โ€” never required for new users. + if client_id is None or client_secret is None: + # Lazy import to avoid a connectors โ†’ providers โ†’ store cycle + # at module load time. + from gaia.connectors.store import peek_provider_credentials + + stored = peek_provider_credentials("google") or {} + else: + stored = {} + + resolved_id = ( + client_id + if client_id is not None + else stored.get("client_id") or os.environ.get("GAIA_GOOGLE_CLIENT_ID", "") + ) + if not resolved_id: + raise ConfigurationError( + "Google OAuth client is not configured. Open Settings โ†’ " + "Connections โ†’ Google in the AgentUI and paste the Client ID " + "and Client Secret from your Google Cloud Console Desktop-app " + "OAuth client. (Power users may also set the " + "GAIA_GOOGLE_CLIENT_ID and GAIA_GOOGLE_CLIENT_SECRET env vars " + "before launching GAIA.) See docs/runbooks/google-oauth-client.md." + ) + self.client_id: str = resolved_id + # CRC32 fingerprint for log correlation / tripwire comparison only. + # Non-cryptographic by design โ€” not used for security. + self.client_id_hash: str = format(zlib.crc32(resolved_id.encode()), "08x") + # Google requires client_secret even for Desktop-type PKCE clients. + self.client_secret: str = ( + client_secret + if client_secret is not None + else stored.get("client_secret") + or os.environ.get("GAIA_GOOGLE_CLIENT_SECRET", "") + ) + + def authorization_params(self) -> dict: + """ + Google-specific extras for the authorization URL. + + - ``access_type=offline`` โ€” issue a refresh token alongside the + access token (otherwise we get only a 1-hour access token and no + way to refresh). + - ``prompt=consent`` โ€” force the consent screen on every connect, so + we always receive a refresh token (Google issues a refresh token + ONLY on the first consent unless ``prompt=consent`` is set). + """ + return {"access_type": "offline", "prompt": "consent"} + + def authorization_url( + self, + redirect_uri: str, + challenge: str, + state: str, + scopes: Iterable[str], + ) -> str: + params = { + "client_id": self.client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": state, + "scope": " ".join(scopes), + } + params.update(self.authorization_params()) + return f"{self.auth_url}?{urlencode(params)}" + + def token_request_body(self, code: str, verifier: str, redirect_uri: str) -> dict: + body: dict = { + "grant_type": "authorization_code", + "code": code, + "code_verifier": verifier, + "redirect_uri": redirect_uri, + "client_id": self.client_id, + } + if self.client_secret: + body["client_secret"] = self.client_secret + return body + + def refresh_request_body(self, refresh_token: str) -> dict: + body: dict = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": self.client_id, + } + if self.client_secret: + body["client_secret"] = self.client_secret + return body diff --git a/src/gaia/connectors/registry.py b/src/gaia/connectors/registry.py new file mode 100644 index 000000000..6f64ba0ca --- /dev/null +++ b/src/gaia/connectors/registry.py @@ -0,0 +1,114 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +ConnectorRegistry โ€” the catalog of all known connectors. + +The registry is a process-level singleton (``REGISTRY``) populated during +module import by each catalog module under ``gaia.connectors.catalog.*``. +After the last catalog import ``REGISTRY.freeze()`` is called; any +subsequent ``register()`` call raises ``RuntimeError``. + +Design constraints (plan amendment A7): +- ``register()`` raises ``ValueError`` on duplicate ``connector_id``. +- Catalog is frozen at module import โ€” no runtime mutation API. +- POST endpoints accept only ``connector_id`` (a lookup key); they never + accept ``command`` / ``args`` / ``mcp_command`` from the request body. + +Tests should call ``REGISTRY.clear()`` in their teardown to reset the +singleton between test runs. +""" + +from __future__ import annotations + +import threading +from typing import Iterator + +from gaia.connectors.spec import ConnectorSpec + + +class ConnectorRegistry: + """Thread-safe, id-unique registry of ``ConnectorSpec`` entries.""" + + def __init__(self) -> None: + self._specs: dict[str, ConnectorSpec] = {} + self._frozen = False + self._lock = threading.Lock() + + # ------------------------------------------------------------------ + # Write path (used only at module-load time) + # ------------------------------------------------------------------ + + def register(self, spec: ConnectorSpec) -> None: + """ + Add a spec to the registry. + + Raises ``ValueError`` if ``spec.id`` is already registered. + Raises ``RuntimeError`` if the registry has been frozen. + """ + with self._lock: + if self._frozen: + raise RuntimeError( + f"ConnectorRegistry is frozen; cannot register {spec.id!r} " + "after module load. Add catalog entries before calling freeze()." + ) + if spec.id in self._specs: + existing = self._specs[spec.id] + raise ValueError( + f"Duplicate connector id {spec.id!r} โ€” already registered as " + f"{existing.display_name!r}. Each connector id must be unique " + "across the entire catalog." + ) + self._specs[spec.id] = spec + + def freeze(self) -> None: + """Prevent further registrations. Called after catalog discovery.""" + with self._lock: + self._frozen = True + + # ------------------------------------------------------------------ + # Read path (safe after freeze) + # ------------------------------------------------------------------ + + def get(self, connector_id: str) -> ConnectorSpec: + """ + Return the spec for ``connector_id``. + + Raises ``KeyError`` with an actionable message (lists known ids) if + the id is not found. + """ + try: + return self._specs[connector_id] + except KeyError: + known = sorted(self._specs) + raise KeyError( + f"Unknown connector {connector_id!r}. Known ids: {known!r}. " + "Register the spec in a catalog module under " + "gaia/connectors/catalog/ before looking it up." + ) from None + + def all(self) -> list[ConnectorSpec]: + """Return all registered specs, ordered by (tier, id).""" + return sorted(self._specs.values(), key=lambda s: (s.tier, s.id)) + + def __contains__(self, connector_id: str) -> bool: + return connector_id in self._specs + + def __len__(self) -> int: + return len(self._specs) + + def __iter__(self) -> Iterator[ConnectorSpec]: + return iter(self.all()) + + # ------------------------------------------------------------------ + # Test helpers + # ------------------------------------------------------------------ + + def clear(self) -> None: + """Reset the registry. For use in test teardown only.""" + with self._lock: + self._specs.clear() + self._frozen = False + + +# Module-level singleton โ€” populated by catalog/*.py at import time. +REGISTRY = ConnectorRegistry() diff --git a/src/gaia/connectors/spec.py b/src/gaia/connectors/spec.py new file mode 100644 index 000000000..1e066fb22 --- /dev/null +++ b/src/gaia/connectors/spec.py @@ -0,0 +1,124 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +ConnectorSpec and ConfigField โ€” typed manifest for a GAIA connector. + +Every connector in the catalog is described by a frozen ``ConnectorSpec``. +The spec drives both the UI (tile grid, detail view, configure body) and the +handler dispatch (`get_credential`, `configure`, `disconnect`, `test`). + +Only two connector types are implemented in v1 (plan amendment A1): +- ``oauth_pkce`` โ€” OAuth 2.0 PKCE flow (e.g. Google) +- ``mcp_server`` โ€” stdio / SSE MCP server with env-block configuration + +Fields that belong only to one type are ``None`` / empty on the other. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +# v1 connector types only (per plan amendment A1) +ConnectorType = Literal["oauth_pkce", "mcp_server"] + +_VALID_KINDS = frozenset( + {"text", "secret", "url", "email", "select", "bool", "textarea"} +) +_VALID_TYPES: frozenset[str] = frozenset({"oauth_pkce", "mcp_server"}) + + +@dataclass(frozen=True) +class ConfigField: + """ + A single field in a connector's configure form. + + ``secret=True`` means the value is stored in the OS keyring, not in + ``mcp_servers.json``. The UI renders it as a password input and + never shows the stored value after first save. + """ + + key: str + label: str + kind: Literal["text", "secret", "url", "email", "select", "bool", "textarea"] + required: bool = True + placeholder: str = "" + help_md: str = "" + options: tuple[str, ...] | None = None + secret: bool = False + + def __post_init__(self) -> None: + if not self.key or not self.key.strip(): + raise ValueError("ConfigField.key must not be empty") + if self.kind not in _VALID_KINDS: + raise ValueError( + f"ConfigField.kind {self.kind!r} is not one of {sorted(_VALID_KINDS)}" + ) + if self.options is not None: + object.__setattr__(self, "options", tuple(self.options)) + + +@dataclass(frozen=True) +class ConnectorSpec: + """ + Immutable manifest for a single connector in the GAIA catalog. + + ``id`` is the stable registry key โ€” it becomes the ``connector_id`` in + every storage path, grant entry, and API URL. Do not change it after + publishing; create a new spec instead. + + Fields prefixed ``mcp_`` are used only for ``type="mcp_server"``. + Fields prefixed ``default_scopes`` / ``available_scopes`` / + ``oauth_provider_ref`` are used only for ``type="oauth_pkce"``. + """ + + id: str + display_name: str + icon: str + category: str + tier: int + type: ConnectorType + description: str + instructions_md: str = "" + config_schema: tuple[ConfigField, ...] = field(default_factory=tuple) + test_endpoint: str | None = None + product_url: str | None = None + # GAIA documentation URL the AgentUI's "Learn more" link points at. + # Should walk users through obtaining client credentials, API tokens, + # or whatever else the connector needs. Falls back to ``product_url`` + # in the UI when ``None``, but every connector should set it. + docs_url: str | None = None + # oauth_pkce only + default_scopes: tuple[str, ...] = field(default_factory=tuple) + available_scopes: tuple[str, ...] = field(default_factory=tuple) + oauth_provider_ref: str | None = None + # OAuth-app credentials the user pastes in once during first-time + # setup (e.g. Google Cloud Console "Desktop client" client_id + + # client_secret). Empty tuple = no setup form required (provider is + # pre-configured at deploy time). Distinct from ``config_schema``, + # which is reserved for connection-time fields like API keys for + # MCP servers โ€” those persist as the connection itself, while OAuth + # setup fields persist as *provider* credentials reused across many + # connect/disconnect cycles. + oauth_setup_fields: tuple[ConfigField, ...] = field(default_factory=tuple) + # mcp_server only + mcp_command: str | None = None + mcp_args: tuple[str, ...] = field(default_factory=tuple) + mcp_env_keys: tuple[str, ...] = field(default_factory=tuple) + + def __post_init__(self) -> None: + if not self.id or not self.id.strip(): + raise ValueError("ConnectorSpec.id must not be empty") + if self.type not in _VALID_TYPES: + raise ValueError( + f"ConnectorSpec.type {self.type!r} is not one of {sorted(_VALID_TYPES)}" + ) + if self.tier < 0: + raise ValueError(f"ConnectorSpec.tier must be >= 0, got {self.tier}") + # Normalise all sequence fields to tuples so equality is predictable. + object.__setattr__(self, "config_schema", tuple(self.config_schema)) + object.__setattr__(self, "default_scopes", tuple(self.default_scopes)) + object.__setattr__(self, "available_scopes", tuple(self.available_scopes)) + object.__setattr__(self, "oauth_setup_fields", tuple(self.oauth_setup_fields)) + object.__setattr__(self, "mcp_args", tuple(self.mcp_args)) + object.__setattr__(self, "mcp_env_keys", tuple(self.mcp_env_keys)) diff --git a/src/gaia/connectors/store.py b/src/gaia/connectors/store.py new file mode 100644 index 000000000..01be6b203 --- /dev/null +++ b/src/gaia/connectors/store.py @@ -0,0 +1,384 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Keyring-backed persistent storage for OAuth connection records. + +Single-blob design (plan amendment A5): + Each ``(provider, account_email)`` tuple maps to ONE keyring entry that + stores a JSON blob containing ``refresh_token``, ``account_email``, + ``scopes``, ``connected_at``, and ``client_id_hash``. A single + ``set_password`` call atomically replaces the entry, so a partial-write + failure cannot leave us with a fresh token + stale metadata. + +Backend allowlist (plan amendment A4): + Plaintext or weak file-backed keyring backends (e.g. ``keyrings.alt``'s + ``PlaintextKeyring``, ``EncryptedKeyring``, ``Win32CryptoKeyring``) are + explicitly refused BEFORE any write. Linux machines without + SecretService produce an actionable error pointing at the runbook + instead of silently writing tokens to disk in plaintext. + +Eager ``client_id_hash`` tripwire (plan amendment from Iteration 1, AC10): + Every ``load_connection`` compares the stored hash against the current + one. A mismatch means the OAuth client was rotated (or the user moved + their installation between machines with different env configurations); + we clear the stored entry, emit ``connection.revoked``, and return + ``None`` so the caller raises ``REAUTH_REQUIRED``. + +All log statements in this module emit only metadata (provider IDs, counts, +truncated fingerprints) โ€” never tokens, passwords, or full hashes. +""" + +from __future__ import annotations + +import json +import logging +import time +from typing import List, Optional + +import keyring +import keyring.errors + +from gaia.connectors.errors import ( + AuthRequiredError, + ConnectorsError, +) + +logger = logging.getLogger(__name__) + + +# Keyring service name kept as "gaia.connections" intentionally (plan +# amendment A3): renaming to match the module rename would orphan every +# dev's existing keyring entries from #915 with zero benefit. The constant +# is internal โ€” not user-visible โ€” so it does not need to track the +# Python module name. +SERVICE_NAME = "gaia.connections" + +# v1 default account name used by callers that don't yet plumb a real +# email through. Multi-account support (forward-compat per A10) writes +# the real account_email here. +DEFAULT_ACCOUNT = "default" + +# Backend class names we refuse outright. These are the ``keyrings.alt`` +# fallbacks that store in plaintext or with a weak passphrase scheme. +_REFUSED_BACKEND_CLASS_NAMES: frozenset[str] = frozenset( + { + "PlaintextKeyring", + "EncryptedKeyring", + "Win32CryptoKeyring", + } +) + + +def _connection_username(provider: str, account_email: str) -> str: + """Build the keyring username key for ``(provider, account_email)``. + + Multi-account forward-compat (A10): the key shape is + ``":"``. v1 always writes + ``account_email = "default"`` so the schema can absorb a real email + without migration. + """ + return f"{provider}:{account_email}" + + +def _provider_credentials_username(provider: str) -> str: + """Keyring username for the *app's* OAuth client credentials. + + Distinct namespace from connection blobs so an installation token + (user's refresh_token, keyed ``:``) and the + application's OAuth client (``provider:``) cannot collide. + """ + return f"provider:{provider}" + + +def verify_keyring_backend() -> None: + """ + Raise ``ConnectorsError`` if the active keyring is one of the refused + backends. Called eagerly at every save and at every load โ€” cheap, and + closes the silent-plaintext-fallback path (A4). + """ + backend = keyring.get_keyring() + cls_name = type(backend).__name__ + if cls_name in _REFUSED_BACKEND_CLASS_NAMES: + raise ConnectorsError( + f"Insecure keyring backend {cls_name!r} is in use. GAIA refuses " + "to store OAuth refresh tokens in plaintext. Install a secure " + "system credential store (gnome-keyring or kwallet on Linux; " + "macOS Keychain and Windows Credential Locker are built-in) " + "and restart GAIA. See docs/security/connections.mdx." + ) + + +def _wrap_keyring_call(operation: str): + """Decorator-like helper: translate keyring exceptions into + ``ConnectorsError`` with actionable text per CLAUDE.md.""" + + def wrapper(fn): + def inner(*args, **kwargs): + try: + return fn(*args, **kwargs) + except keyring.errors.KeyringError as e: + raise ConnectorsError( + f"Keyring {operation} failed: {e}. Install a system " + "credential store (gnome-keyring on Linux, or rely on " + "the macOS Keychain / Windows Credential Locker), " + "configure it, and restart GAIA. See " + "docs/security/connections.mdx." + ) from e + + return inner + + return wrapper + + +def save_connection( + *, + provider: str, + account_email: str, + refresh_token: str, + scopes: List[str], + client_id_hash: str, + connected_at: Optional[float] = None, +) -> None: + """ + Atomically persist a connection record to the keyring. + + The single keyring slot stores a JSON blob โ€” a partial write is + impossible because the underlying backend's ``set_password`` is a + full-value overwrite at the slot. This is the rotation-safety + guarantee (per Iteration 1 fix C5). + + v1 single-account-per-provider scope (per plan amendment A10): the + keyring slot is ALWAYS keyed by ``DEFAULT_ACCOUNT``, regardless of + the ``account_email`` argument. ``account_email`` is stored inside + the JSON blob for display purposes only. **A second + ``save_connection`` for the same provider โ€” even with a different + email โ€” will overwrite the first.** Multi-account support (separate + keyring slots per email) is a v2 follow-up; the username-key shape + ``":"`` is forward-compatible for that + migration. + """ + verify_keyring_backend() + + blob = { + "account_email": account_email, + "refresh_token": refresh_token, + "scopes": list(scopes), + "connected_at": connected_at if connected_at is not None else time.time(), + "client_id_hash": client_id_hash, + } + payload = json.dumps(blob, sort_keys=True) + # v1 single-account per provider (per A10): the keyring KEY is always + # built with DEFAULT_ACCOUNT; ``account_email`` lives in the metadata + # blob for display. v2 will key by real email without a schema + # migration since the username shape already accommodates it. + username = _connection_username(provider, DEFAULT_ACCOUNT) + + @_wrap_keyring_call("set_password") + def _set(): + keyring.set_password(SERVICE_NAME, username, payload) + + _set() + + +def load_connection( + provider: str, + *, + current_client_id_hash: str, + account_email: str = DEFAULT_ACCOUNT, +) -> Optional[dict]: + """ + Return the stored connection record, or ``None`` if no entry / tripwire fired. + + The eager ``client_id_hash`` tripwire (AC10) compares the stored hash + against ``current_client_id_hash``; on mismatch the entry is cleared + and ``None`` is returned. The caller (``tokens.get_access_token``) + then raises ``AuthRequiredError(REAUTH_REQUIRED)``. + """ + verify_keyring_backend() + username = _connection_username(provider, account_email) + + @_wrap_keyring_call("get_password") + def _get(): + return keyring.get_password(SERVICE_NAME, username) + + raw = _get() + if raw is None: + return None + + try: + blob = json.loads(raw) + except json.JSONDecodeError as e: + # Should not happen unless the keyring backend was corrupted by + # an external writer โ€” clear the entry and surface a useful error. + delete_connection(provider, account_email=account_email) + raise ConnectorsError( + f"Stored connection blob for provider={provider!r} is not valid " + "JSON. Cleared the entry; reconnect via Settings โ†’ Connections " + f"or `gaia connectors connect {provider}`." + ) from e + + stored_hash = blob.get("client_id_hash") + if stored_hash != current_client_id_hash: + # Tripwire fired โ€” clear the stored entry and raise REAUTH_REQUIRED + # so the caller (and the router) can distinguish this case from + # "user never connected". The unit test in test_store.py asserts + # the entry is cleared; the unit test in test_tokens.py asserts + # the right Reason flows to the caller. + delete_connection(provider, account_email=account_email) + raise AuthRequiredError( + AuthRequiredError.Reason.REAUTH_REQUIRED, provider=provider + ) + + return blob + + +def peek_connection( + provider: str, + *, + account_email: str = DEFAULT_ACCOUNT, +) -> Optional[dict]: + """ + Return the stored connection blob for display, or ``None`` if absent. + + Read-only sibling of ``load_connection`` for UI/CLI catalog rendering: + no tripwire, no side effects, no exceptions for a missing entry. The + blob includes ``account_email``, ``scopes``, ``connected_at``, and + ``client_id_hash``; the secret ``refresh_token`` field is also + present, so callers MUST NOT log the result wholesale. + + **Tripwire semantics**: ``peek_connection`` returns the blob even + when its ``client_id_hash`` no longer matches the live provider โ€” + i.e. the catalog tile will keep showing "configured" right up until + the next auth-path read (``load_connection`` via ``tokens.get_or_refresh``) + fires the tripwire and clears the entry. That is intentional: a + catalog render is a side-effect-free operation, and clearing + credentials from a list-call would be surprising. Use + ``load_connection`` for auth-path reads where the tripwire is + required. + + **Corrupt blob**: returns ``None`` and leaves the keyring entry in + place. ``load_connection`` (auth path) clears corrupt entries; we + don't here for the same side-effect-free reason. + """ + verify_keyring_backend() + username = _connection_username(provider, account_email) + + @_wrap_keyring_call("get_password") + def _get(): + return keyring.get_password(SERVICE_NAME, username) + + raw = _get() + if raw is None: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + # Corrupt blob โ€” caller treats as "not configured" without + # rewriting state. ``load_connection`` (auth path) still clears + # the corrupt entry; we don't here because peek_connection is + # called during catalog render and must be side-effect-free. + return None + + +def delete_connection(provider: str, *, account_email: str = DEFAULT_ACCOUNT) -> None: + """Remove the keyring entry for ``provider`` if present. Idempotent.""" + verify_keyring_backend() + username = _connection_username(provider, account_email) + + try: + keyring.delete_password(SERVICE_NAME, username) + except keyring.errors.PasswordDeleteError: + # Already gone โ€” fine. + pass + except keyring.errors.KeyringError as e: + raise ConnectorsError( + f"Keyring delete_password failed: {e}. See " + "docs/security/connections.mdx." + ) from e + + +def save_provider_credentials( + provider: str, *, client_id: str, client_secret: str = "" +) -> None: + """Persist the *application's* OAuth client credentials for *provider*. + + Stores ``{"client_id": ..., "client_secret": ...}`` as a single JSON + blob in the keyring, distinct from any connection blob. Lets users + self-onboard via the AgentUI without ever touching env vars; the + blob is encrypted at rest by the OS credential store. + """ + verify_keyring_backend() + if not client_id: + raise ConnectorsError( + f"save_provider_credentials({provider!r}): client_id is empty" + ) + payload = json.dumps( + {"client_id": client_id, "client_secret": client_secret}, sort_keys=True + ) + username = _provider_credentials_username(provider) + + @_wrap_keyring_call("set_password") + def _set(): + keyring.set_password(SERVICE_NAME, username, payload) + + _set() + + +def peek_provider_credentials(provider: str) -> Optional[dict]: + """Return the stored OAuth client credentials, or ``None`` if absent. + + Side-effect-free read used by ``GoogleOAuthProvider.__init__`` (and + siblings) to find the persisted ``client_id`` / ``client_secret`` + before falling back to env vars. + """ + verify_keyring_backend() + username = _provider_credentials_username(provider) + + @_wrap_keyring_call("get_password") + def _get(): + return keyring.get_password(SERVICE_NAME, username) + + raw = _get() + if raw is None: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + + +def clear_provider_credentials(provider: str) -> None: + """Remove the stored OAuth client credentials for *provider*. Idempotent.""" + verify_keyring_backend() + username = _provider_credentials_username(provider) + try: + keyring.delete_password(SERVICE_NAME, username) + except keyring.errors.PasswordDeleteError: + pass + except keyring.errors.KeyringError as e: + raise ConnectorsError( + f"Keyring delete_password failed: {e}. See " + "docs/security/connections.mdx." + ) from e + + +def list_connections() -> List[str]: + """ + Best-effort enumeration of stored providers. + + The ``keyring`` API does not expose a portable "list all entries for + service" call. v1 returns the providers we know about (currently + just ``google``); future providers extend this. + """ + known = ("google",) + found: list[str] = [] + for provider in known: + username = _connection_username(provider, DEFAULT_ACCOUNT) + try: + if keyring.get_password(SERVICE_NAME, username) is not None: + found.append(provider) + except keyring.errors.KeyringError: + # Translate-and-skip is OK for an enumeration call: a single + # failed backend doesn't invalidate the list. + continue + return found diff --git a/src/gaia/connectors/tokens.py b/src/gaia/connectors/tokens.py new file mode 100644 index 000000000..94511e95e --- /dev/null +++ b/src/gaia/connectors/tokens.py @@ -0,0 +1,229 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Per-provider access-token cache with double-checked locking and refresh. + +Critical invariants (T-5b, plan amendments A6, A7): + +- One ``asyncio.Lock`` per ``(provider, account_email)`` cache slot. The + refresh path uses **explicit ``async with lock:`` (context-manager form)** + so the lock is released on exception. Manual ``acquire``/``release`` + pairs are forbidden โ€” they deadlock if a refresh raises. + +- 60-second expiry buffer: a token whose ``expires_at`` is within the + next 60 seconds is treated as already expired (AC4). + +- Default ``expires_in = 3600`` if the token endpoint omits or returns + zero (A6). Without this, the cache treats every token as immediately + expired and refreshes on every call. + +- Refresh-token rotation: if the token endpoint returns a new + ``refresh_token`` in the response body, we persist it via + ``store.save_connection``. The keyring's per-key atomic overwrite + guarantees the new token is durably stored before we discard the old + one in memory. + +- One retry on ``401 invalid_token`` from the resource (clock skew). + Bounded โ€” no recursion, no loop, max 2 HTTP round-trips per call. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import dataclass, field +from typing import Optional, Tuple + +import httpx + +from gaia.connectors.errors import ( + AuthRequiredError, + ConnectionRevokedError, + ConnectorsError, +) +from gaia.connectors.providers import get as get_provider +from gaia.connectors.store import ( + DEFAULT_ACCOUNT, + delete_connection, + load_connection, + save_connection, +) + +logger = logging.getLogger(__name__) + + +# 60s buffer per AC4: refresh proactively when the access token is within +# this many seconds of expiring. Prevents a tool from receiving a token +# that expires mid-API-call. +_EXPIRY_BUFFER_SECONDS = 60 + + +@dataclass +class _AccessTokenCache: + """Per-(provider, account) cache entry. Lock guards the refresh path.""" + + access_token: Optional[str] = None + expires_at: float = 0.0 # ``time.monotonic()``-based + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + +# Module-level cache. Tests reset this between cases via the autouse +# fixture in ``tests/unit/connectors/conftest.py``. +_cache: dict[Tuple[str, str], _AccessTokenCache] = {} + + +def _cache_key(provider_id: str, account_email: str) -> Tuple[str, str]: + return (provider_id, account_email) + + +def _is_expired(entry: _AccessTokenCache) -> bool: + return entry.access_token is None or ( + entry.expires_at - time.monotonic() < _EXPIRY_BUFFER_SECONDS + ) + + +async def get_or_refresh( + provider_id: str, *, account_email: str = DEFAULT_ACCOUNT +) -> str: + """ + Return a fresh access token for ``provider_id``. + + Uses double-checked locking: the unlocked re-check inside the cache hit + path keeps concurrent callers off the lock when the token is fresh; the + second check inside the locked block prevents N+1 refreshes when 10 + callers race. + """ + provider = get_provider(provider_id) + + key = _cache_key(provider_id, account_email) + entry = _cache.get(key) + if entry is None: + entry = _cache.setdefault(key, _AccessTokenCache()) + + if not _is_expired(entry): + return entry.access_token # type: ignore[return-value] + + async with entry.lock: + # Re-check inside the lock โ€” a peer task may have refreshed + # while we were waiting. + if not _is_expired(entry): + return entry.access_token # type: ignore[return-value] + + # The store raises AuthRequiredError(REAUTH_REQUIRED) directly when + # the client_id_hash tripwire fires; we let that propagate without + # interpretation. ``None`` means the user never connected. + stored = load_connection( + provider_id, + current_client_id_hash=provider.client_id_hash, + account_email=account_email, + ) + if stored is None: + raise AuthRequiredError( + AuthRequiredError.Reason.NOT_CONNECTED, provider=provider_id + ) + + new_access, new_refresh, expires_in = await _refresh_token( + provider, stored["refresh_token"] + ) + + # Refresh-token rotation: if the provider returned a new refresh + # token, persist it before exposing the access token to callers. + if new_refresh and new_refresh != stored["refresh_token"]: + save_connection( + provider=provider_id, + account_email=stored.get("account_email", DEFAULT_ACCOUNT), + refresh_token=new_refresh, + scopes=stored.get("scopes", []), + client_id_hash=provider.client_id_hash, + connected_at=stored.get("connected_at"), + ) + + entry.access_token = new_access + entry.expires_at = time.monotonic() + expires_in + return entry.access_token + + +async def _refresh_token( + provider, refresh_token: str +) -> Tuple[str, Optional[str], int]: + """ + Exchange a refresh token for a fresh access token. + + Returns ``(access_token, new_refresh_token_or_None, expires_in_seconds)``. + Raises ``ConnectionRevokedError`` on ``invalid_grant``. + """ + body = provider.refresh_request_body(refresh_token) + + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(provider.token_url, data=body) + + if response.status_code == 400: + try: + payload = response.json() + except Exception: + payload = {} + if payload.get("error") == "invalid_grant": + # Clear the stored entry โ€” the refresh token is no longer + # accepted. + delete_connection(provider.provider_id) + raise ConnectionRevokedError(provider.provider_id) + # Other 400s โ€” actionable but not invalid_grant. + raise ConnectorsError( + f"Token endpoint refused refresh for {provider.provider_id}: " + f"{payload.get('error', 'unknown')} (status 400). See " + "docs/security/connections.mdx." + ) + + if response.status_code != 200: + raise ConnectorsError( + f"Token endpoint returned {response.status_code} for " + f"{provider.provider_id} refresh. See " + "docs/security/connections.mdx." + ) + + payload = response.json() + access = payload.get("access_token") + if not access: + raise ConnectorsError( + f"Token endpoint response for {provider.provider_id} omitted " + "access_token. See docs/security/connections.mdx." + ) + + # A6: default expires_in to 3600 if absent or zero. + expires_in = payload.get("expires_in") or 3600 + + new_refresh = payload.get("refresh_token") + return access, new_refresh, int(expires_in) + + +def get_or_refresh_sync( + provider_id: str, *, account_email: str = DEFAULT_ACCOUNT +) -> str: + """ + Synchronous wrapper around ``get_or_refresh`` for sync agent contexts. + + Must NOT be called from a thread that already has a running asyncio + event loop โ€” ``asyncio.run`` would raise ``RuntimeError``. Use + ``await get_or_refresh(...)`` directly from async code instead. This + guard makes the failure surface as an actionable error rather than a + confusing crash deep inside the runtime. + + Inherits the calling thread's contextvars into the new event loop's + context (via ``asyncio.run`` โ†’ ``contextvars.copy_context()``). This is + the bridge from ``Agent.process_query`` (sync, runs in + ``ThreadPoolExecutor``) to the async refresh code path. See + ``tests/unit/connectors/test_agent_bridge.py``. + """ + try: + running = asyncio.get_running_loop() + except RuntimeError: + running = None + if running is not None: + raise RuntimeError( + "get_or_refresh_sync was called from a thread with a running " + "asyncio event loop. Call `await get_or_refresh(...)` directly " + "from async code instead, or schedule this call on a worker " + "thread without a running loop." + ) + return asyncio.run(get_or_refresh(provider_id, account_email=account_email)) diff --git a/src/gaia/governance/README.md b/src/gaia/governance/README.md new file mode 100644 index 000000000..af0bc4ed1 --- /dev/null +++ b/src/gaia/governance/README.md @@ -0,0 +1,162 @@ +# gaia.governance + +Optional governance layer for GAIA agents. Opt-in. Off by default. + +## Quick start (5 minutes) + +```python +from gaia import Agent, tool +from gaia.governance import GaiaGovernanceAdapter, GovernedAgentMixin, govern + + +@tool +@govern(risk="blocked", reason="destructive") +def wipe_disk() -> dict: + return {"status": "ok"} + + +class MyAgent(GovernedAgentMixin, Agent): + ... + + +agent = MyAgent( + governance_adapter=GaiaGovernanceAdapter.default(), +) +``` + +That's it. When the model calls `wipe_disk`, governance short-circuits +the call, issues a signed receipt to `receipts.jsonl`, and returns a +denied result to the agent loop. + +## How decisions work + +| Decision | Effect | +|---|---| +| `ALLOW` | Tool runs as usual. | +| `BLOCK` | Tool is refused. A receipt is written to the audit log with the full evidence envelope (action, policy version, constitution hash, timestamp). | +| `REVIEW` | A checkpoint is opened. The mixin asks your `governance_reviewer` callback, or Agent UI's existing blocking confirmation modal when that is the active console. On `APPROVE` the tool runs; on `REJECT` it is refused. Either way a receipt is written. | + +Decisions are produced by a `PolicyEngine`. The shipped +`RuleBasedPolicyEngine` reads tags from `@govern(risk=...)` and/or a +`governance_risk_tags` dict on the agent. Swap in any +`PolicyEngine`-shaped object (ACGS-lite, your own rules, an LLM judge, +etc.) without touching agent code. + +## Two tagging styles + +**Decorator โ€” colocates policy with the tool (recommended):** + +```python +@tool +@govern(risk="review", reason="sends money") +def transfer(amount: float): ... +``` + +**Dict โ€” centralizes policy on the agent:** + +```python +agent = MyAgent( + governance_adapter=GaiaGovernanceAdapter.default(), + governance_risk_tags={"transfer": ["review"]}, +) +``` + +Both work together. Tags are **additive** (union, deduplicated): decorator +tags come first, then dict tags are appended. Neither side overrides the +other, so a tool declared `"review"` in a decorator and `"blocked"` in +the dict will carry both tags. + +## Configuration + +Two equivalent styles. Pick whichever reads better: + +```python +# Structured config object +from gaia.governance import GovernanceConfig + +agent = MyAgent(governance=GovernanceConfig( + adapter=adapter, + actor_id="alice", + workflow_id="session-42", + risk_tags={"delete_record": ["blocked"]}, + reviewer=my_reviewer, +)) + +# Individual kwargs (also supported) +agent = MyAgent( + governance_adapter=adapter, + governance_actor_id="alice", + governance_risk_tags={"delete_record": ["blocked"]}, + governance_reviewer=my_reviewer, +) +``` + +## Reviewers + +When a `REVIEW` decision fires, an explicit `governance_reviewer` +callback takes precedence. If none is configured, the mixin delegates to +`console.confirm_tool_execution` only when the active console advertises +`blocking_confirmation = True`. Agent UI's `SSEOutputHandler` sets this flag and +emits the existing `permission_request` modal. GAIA's default console is not used +as an implicit reviewer because it returns `True`, and a silent auto-approve +would defeat the decision. + +```python +def my_reviewer(tool_name, tool_args, decision) -> bool: + # UI, Slack, a web form, whatever you like + return input(f"approve {tool_name}? [y/N]: ") == "y" + +agent = MyAgent( + governance_adapter=GaiaGovernanceAdapter.default(), + governance_reviewer=my_reviewer, +) +``` + +If no reviewer or blocking console is available, REVIEW decisions **fail closed** +(tool denied). + +## Agent UI policy alerts + +When a policy returns `BLOCK`, `GovernedAgentMixin` still refuses the tool before +the body executes and returns a denied tool result. If the active console +implements `print_policy_alert`, the mixin also emits a policy alert with the +blocked tool, decision, reason, rule IDs, policy version, and receipt ID. +Agent UI's `SSEOutputHandler` sends this as a `policy_alert` SSE event so the +frontend can distinguish a policy refusal from a generic tool failure. + +## Security properties + +- **Canonical name resolution:** governance resolves the registered + tool name before checking risk tags, so an LLM cannot bypass a tag + on `mcp_time_get_current_time` by calling the unprefixed alias + `get_current_time`. +- **Envelope-bound receipts:** each receipt's `payload_hash` covers + the full evidence envelope (action, decision, policy version, + constitution hash, actor, timestamp) with strict canonical JSON. Any + field tampered in the log changes the hash. +- **Workflow-bound checkpoint resolution:** the adapter refuses to + resolve a checkpoint under a workflow_id that differs from the one + recorded when the checkpoint was opened. +- **Atomic checkpoint resolution:** `InMemoryCheckpointBridge` uses a + lock so a race between two concurrent resolutions cannot produce two + terminal outcomes. + +## Extension points + +| Interface | Shipped reference | Swap with | +|-----------------------------|---------------------------------------------------|--------------------------------------------| +| `PolicyEngine` | `RuleBasedPolicyEngine` | ACGS-lite engine, LLM judge, OPA, etc. | +| `CheckpointRuntime` | `InMemoryCheckpointBridge` | constitutional-swarm checkpoint service | +| `ReceiptServiceProtocol` | `InMemoryReceiptService` / `JsonlReceiptService` | DB, log forwarder, chain anchor | +| `PolicyBindingProtocol` | `StaticPolicyBindingService` | constitutional-swarm policy control plane | + +All four are `@runtime_checkable` Protocols โ€” no inheritance required. + +## What's not here (yet) + +- Policy control plane; `PolicyBindingProtocol` is static in PR 1. +- Attestation / trust routing. +- Precedent memory or validator marketplace. +- Plan-step / multi-agent workflow transitions. The mixin only intercepts + tool calls today; broader workflow events will arrive in a follow-up PR + along with the mapper that turns them into `WorkflowTransition`s. diff --git a/src/gaia/governance/__init__.py b/src/gaia/governance/__init__.py new file mode 100644 index 000000000..7c6d86526 --- /dev/null +++ b/src/gaia/governance/__init__.py @@ -0,0 +1,58 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Optional governance layer for GAIA agents. + +Provides action-level governance (ACGS-lite semantics) plus seams for +future workflow checkpoint / receipt / policy-version binding +(constitutional-swarm semantics). + +This package is opt-in. Importing it has no side effects on existing +GAIA agents. To govern an agent, mix :class:`GovernedAgentMixin` into +your agent class and pass a :class:`GaiaGovernanceAdapter` via the +``governance_adapter`` keyword argument. +""" + +from .action_mapper import map_gaia_tool_call_to_action_request +from .adapter import GaiaGovernanceAdapter +from .config import GovernanceConfig +from .decorators import govern, read_risk_tags +from .exceptions import ( + CheckpointNotFoundError, + GaiaGovernanceError, + InvalidResolutionError, +) +from .mixin import GovernedAgentMixin +from .schemas import ( + ActionRequest, + CheckpointRecord, + CheckpointResolution, + GovernanceDecision, + PolicyVersionRef, + ReceiptRecord, + TransitionOutcome, + WorkflowTransition, + new_id, + utc_now_iso, +) + +__all__ = [ + "ActionRequest", + "CheckpointNotFoundError", + "CheckpointRecord", + "CheckpointResolution", + "GaiaGovernanceAdapter", + "GaiaGovernanceError", + "GovernanceConfig", + "GovernanceDecision", + "GovernedAgentMixin", + "InvalidResolutionError", + "PolicyVersionRef", + "ReceiptRecord", + "TransitionOutcome", + "WorkflowTransition", + "govern", + "map_gaia_tool_call_to_action_request", + "new_id", + "read_risk_tags", + "utc_now_iso", +] diff --git a/src/gaia/governance/action_mapper.py b/src/gaia/governance/action_mapper.py new file mode 100644 index 000000000..7eb6b8911 --- /dev/null +++ b/src/gaia/governance/action_mapper.py @@ -0,0 +1,28 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Maps a GAIA tool call into a governance ActionRequest.""" + +from __future__ import annotations + +from typing import Any + +from .schemas import ActionRequest, new_id + + +def map_gaia_tool_call_to_action_request( + tool_name: str, + args: dict[str, Any], + context: dict[str, Any] | None = None, +) -> ActionRequest: + ctx = context or {} + return ActionRequest( + action_id=ctx.get("action_id", new_id("action")), + actor_id=ctx.get("actor_id", "unknown-actor"), + tool_name=tool_name, + action_type=ctx.get("action_type", tool_name), + args=dict(args), + risk_tags=list(ctx.get("risk_tags", [])), + workflow_id=ctx.get("workflow_id"), + step_id=ctx.get("step_id"), + source=ctx.get("source", "gaia"), + ) diff --git a/src/gaia/governance/adapter.py b/src/gaia/governance/adapter.py new file mode 100644 index 000000000..b6ccd2af0 --- /dev/null +++ b/src/gaia/governance/adapter.py @@ -0,0 +1,339 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Governance adapter: entry point for action-level and workflow-level flows.""" + +from __future__ import annotations + +import hashlib +import json +import math +from collections.abc import Mapping, Sequence +from dataclasses import fields, is_dataclass +from datetime import date, datetime +from decimal import Decimal +from enum import Enum +from os import PathLike +from typing import Any +from uuid import UUID + +from .exceptions import GaiaGovernanceError, InvalidResolutionError +from .protocols import ( + CheckpointRuntime, + PolicyBindingProtocol, + PolicyEngine, + ReceiptServiceProtocol, +) +from .schemas import ( + ActionRequest, + CheckpointResolution, + GovernanceDecision, + ReceiptRecord, + TransitionOutcome, + WorkflowTransition, + new_id, + utc_now_iso, +) + + +def _qualified_type_name(value: Any) -> str: + return f"{type(value).__module__}.{type(value).__qualname__}" + + +def _canonical_json_value(value: Any, seen: set[int] | None = None) -> Any: + """Return a deterministic JSON-safe representation for receipt evidence.""" + # ``bool`` is checked before ``int`` because bool is a subclass of int โ€” + # without the explicit ordering, ``True`` would be canonicalized as ``1`` + # and lose its type identity in the receipt envelope. + if value is None or isinstance(value, (str, bool)): + return value + if isinstance(value, int): + return value + if isinstance(value, float): + if math.isfinite(value): + return value + return {"__type__": "float", "value": str(value)} + if isinstance(value, Decimal): + return {"__type__": "Decimal", "value": str(value)} + if isinstance(value, UUID): + return {"__type__": "UUID", "value": str(value)} + if isinstance(value, (datetime, date)): + return {"__type__": type(value).__name__, "value": value.isoformat()} + if isinstance(value, Enum): + return {"__type__": type(value).__name__, "value": value.value} + if isinstance(value, bytes): + return {"__type__": "bytes", "value": value.hex()} + + seen = set() if seen is None else seen + value_id = id(value) + if value_id in seen: + return {"__type__": _qualified_type_name(value), "cycle": True} + seen.add(value_id) + + try: + return _canonical_complex_json_value(value, seen) + finally: + seen.remove(value_id) + + +def _canonical_complex_json_value(value: Any, seen: set[int]) -> Any: + if isinstance(value, PathLike): + return { + "__type__": type(value).__name__, + "value": _canonical_json_value(value.__fspath__(), seen), + } + if is_dataclass(value) and not isinstance(value, type): + field_values = { + field.name: _canonical_json_value(getattr(value, field.name), seen) + for field in fields(value) + } + if type(value).__module__ == "gaia.governance.schemas": + return field_values + return { + "__type__": _qualified_type_name(value), + "fields": field_values, + } + if isinstance(value, Mapping): + if all(isinstance(key, str) for key in value): + return { + key: _canonical_json_value(value[key], seen) for key in sorted(value) + } + entries = [ + [_canonical_json_value(key, seen), _canonical_json_value(item, seen)] + for key, item in value.items() + ] + return { + "__type__": "mapping", + "entries": sorted( + entries, + key=lambda item: json.dumps( + item[0], sort_keys=True, separators=(",", ":"), allow_nan=False + ), + ), + } + if isinstance(value, list): + return [_canonical_json_value(item, seen) for item in value] + if isinstance(value, tuple): + return { + "__type__": "tuple", + "items": [_canonical_json_value(item, seen) for item in value], + } + if isinstance(value, (set, frozenset)): + normalized = [_canonical_json_value(item, seen) for item in value] + return { + "__type__": type(value).__name__, + "items": sorted( + normalized, + key=lambda item: json.dumps( + item, sort_keys=True, separators=(",", ":"), allow_nan=False + ), + ), + } + if isinstance(value, Sequence): + return { + "__type__": _qualified_type_name(value), + "items": [_canonical_json_value(item, seen) for item in value], + } + if hasattr(value, "__dict__"): + return { + "__type__": _qualified_type_name(value), + "fields": _canonical_json_value(vars(value), seen), + } + return { + "__type__": _qualified_type_name(value), + "unserializable": True, + } + + +def _canonical_hash(payload: dict) -> str: + """Stable SHA-256 of a JSON-canonicalized payload. + + Evidence is first normalized to deterministic JSON-safe structures + instead of using ``default=str``. That keeps hashes reproducible + while preventing governance from crashing on values such as + ``Path`` instances in blocked tool arguments. + """ + canonical_payload = _canonical_json_value(payload) + return hashlib.sha256( + json.dumps( + canonical_payload, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + ).hexdigest() + + +class GaiaGovernanceAdapter: + """Compose a policy engine, checkpoint runtime, receipt service, and + policy-version binding into a single entry point used by agents. + """ + + def __init__( + self, + policy_engine: PolicyEngine, + checkpoint_runtime: CheckpointRuntime, + receipt_service: ReceiptServiceProtocol, + policy_binding: PolicyBindingProtocol, + ) -> None: + self.policy_engine = policy_engine + self.checkpoint_runtime = checkpoint_runtime + self.receipt_service = receipt_service + self.policy_binding = policy_binding + + @classmethod + def default( + cls, + audit_log: str | None = "receipts.jsonl", + policy_version: str = "v0", + constitution_hash: str = "constitution-dev", + ) -> "GaiaGovernanceAdapter": + """Pre-wired adapter using the in-repo reference implementations. + + Pass ``audit_log=None`` to use in-memory receipts (tests). + Otherwise receipts are appended to the given JSONL path. + """ + # Lazy imports avoid a circular namespace at package import time. + from .checkpoint_bridge import InMemoryCheckpointBridge + from .policy_binding import StaticPolicyBindingService + from .receipt_service import InMemoryReceiptService, JsonlReceiptService + from .stubs import RuleBasedPolicyEngine + + receipts: ReceiptServiceProtocol = ( + InMemoryReceiptService() + if audit_log is None + else JsonlReceiptService(audit_log) + ) + return cls( + policy_engine=RuleBasedPolicyEngine(policy_version=policy_version), + checkpoint_runtime=InMemoryCheckpointBridge(), + receipt_service=receipts, + policy_binding=StaticPolicyBindingService( + version=policy_version, constitution_hash=constitution_hash + ), + ) + + def govern_action(self, action_request: ActionRequest) -> GovernanceDecision: + return self.policy_engine.evaluate_action(action_request) + + def handle_transition( + self, transition: WorkflowTransition, decision: GovernanceDecision + ) -> TransitionOutcome: + if decision.decision == "ALLOW": + return TransitionOutcome(status="CONTINUE", reason="action allowed") + if decision.decision == "BLOCK": + receipt = self._issue_receipt( + workflow_id=transition.workflow_id, + checkpoint_id=None, + decision="BLOCK", + actor_id=None, + evidence={ + "transition": transition, + "decision": decision, + }, + ) + return TransitionOutcome( + status="TERMINATED", + reason="action blocked", + metadata={"receipt_id": receipt.receipt_id}, + ) + + if decision.decision == "REVIEW": + checkpoint = self.checkpoint_runtime.create_checkpoint(transition, decision) + return TransitionOutcome( + status="CHECKPOINT_OPEN", + reason="review required", + checkpoint_id=checkpoint.checkpoint_id, + metadata={"checkpoint_id": checkpoint.checkpoint_id}, + ) + + raise GaiaGovernanceError(f"unknown decision type: {decision.decision!r}") + + def resolve_checkpoint( + self, + checkpoint_id: str, + resolution: CheckpointResolution, + workflow_id: str, + ) -> TransitionOutcome: + # MED-4 fix: refuse to resolve a checkpoint whose stored workflow + # does not match the caller's claimed workflow_id. The + # CheckpointRuntime Protocol is extended with an optional + # ``get_checkpoint`` method (duck-typed) for this validation; + # runtimes that don't expose it skip the check. + get = getattr(self.checkpoint_runtime, "get_checkpoint", None) + if callable(get): + record = get(checkpoint_id) + if record is not None and record.workflow_id != workflow_id: + raise InvalidResolutionError( + f"workflow mismatch: checkpoint {checkpoint_id} belongs to " + f"{record.workflow_id!r}, not {workflow_id!r}" + ) + outcome = self.checkpoint_runtime.resolve_checkpoint(checkpoint_id, resolution) + if outcome.status in {"RESUMED", "TERMINATED"}: + receipt = self._issue_receipt( + workflow_id=workflow_id, + checkpoint_id=checkpoint_id, + decision=resolution.resolution, + actor_id=resolution.actor_id, + evidence={ + "resolution": resolution, + "outcome_status": outcome.status, + }, + ) + merged = {**outcome.metadata, "receipt_id": receipt.receipt_id} + return TransitionOutcome( + status=outcome.status, + reason=outcome.reason, + checkpoint_id=outcome.checkpoint_id, + metadata=merged, + ) + return outcome + + def _issue_receipt( + self, + workflow_id: str, + checkpoint_id: str | None, + decision: str, + actor_id: str | None, + evidence: dict, + ) -> ReceiptRecord: + """Issue a receipt whose payload_hash covers the full evidence envelope. + + The hash input is canonicalized JSON of: receipt identity fields + (decision, workflow_id, checkpoint_id, actor_id, policy_version, + constitution_hash, timestamp) plus the supplied evidence. This + means any tampering โ€” to the decision, the action args, the + policy version, the resolution actor, etc. โ€” changes the hash. + """ + policy_version = self.policy_binding.current_version() + created_at = utc_now_iso() + receipt_id = new_id("rcpt") + canonical_evidence = _canonical_json_value(evidence) + envelope = { + "receipt_id": receipt_id, + "workflow_id": workflow_id, + "checkpoint_id": checkpoint_id, + "decision": decision, + "actor_id": actor_id, + "policy_version": policy_version.version, + "constitution_hash": policy_version.constitution_hash, + "created_at": created_at, + "evidence": canonical_evidence, + } + payload_hash = _canonical_hash(envelope) + record = ReceiptRecord( + receipt_id=receipt_id, + workflow_id=workflow_id, + checkpoint_id=checkpoint_id, + decision=decision, + policy_version=policy_version.version, + actor_id=actor_id, + validator_set_id=None, + created_at=created_at, + payload_hash=payload_hash, + metadata={ + "constitution_hash": policy_version.constitution_hash, + "evidence": canonical_evidence, + }, + ) + self.receipt_service.issue_receipt(record) + return record diff --git a/src/gaia/governance/checkpoint_bridge.py b/src/gaia/governance/checkpoint_bridge.py new file mode 100644 index 000000000..9c7b5fd4b --- /dev/null +++ b/src/gaia/governance/checkpoint_bridge.py @@ -0,0 +1,107 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""In-memory CheckpointRuntime reference implementation. + +Production deployments will swap this for a persistent bridge backed by +constitutional-swarm. Kept tiny so unit tests and the governed example +can run with no external dependencies. +""" + +from __future__ import annotations + +from threading import Lock + +from .exceptions import CheckpointNotFoundError, InvalidResolutionError +from .schemas import ( + CheckpointRecord, + CheckpointResolution, + GovernanceDecision, + TransitionOutcome, + WorkflowTransition, + new_id, + utc_now_iso, +) + + +class InMemoryCheckpointBridge: + def __init__(self) -> None: + self._records: dict[str, CheckpointRecord] = {} + self._lock = Lock() + + def get_checkpoint(self, checkpoint_id: str) -> CheckpointRecord | None: + """Return the stored checkpoint or ``None`` โ€” used by the adapter + to validate workflow ownership before resolution.""" + with self._lock: + return self._records.get(checkpoint_id) + + def create_checkpoint( + self, transition: WorkflowTransition, decision: GovernanceDecision + ) -> CheckpointRecord: + record = CheckpointRecord( + checkpoint_id=new_id("chk"), + workflow_id=transition.workflow_id, + transition_id=transition.transition_id, + status="OPEN", + created_at=utc_now_iso(), + decision_context={ + "transition_type": transition.transition_type, + "from_state": transition.from_state, + "to_state": transition.to_state, + "decision_reason": decision.reason, + "policy_version": decision.policy_version, + "rule_ids": list(decision.rule_ids), + }, + ) + with self._lock: + self._records[record.checkpoint_id] = record + return record + + def resolve_checkpoint( + self, checkpoint_id: str, resolution: CheckpointResolution + ) -> TransitionOutcome: + # MED-5 fix: check-and-set must be atomic so a concurrent second + # caller sees the terminal status and raises InvalidResolutionError + # instead of also succeeding. + with self._lock: + if checkpoint_id not in self._records: + raise CheckpointNotFoundError(checkpoint_id) + + current = self._records[checkpoint_id] + if current.status != "OPEN": + raise InvalidResolutionError(f"checkpoint is not open: {checkpoint_id}") + + mapping = { + "APPROVE": ("APPROVED", "RESUMED", "checkpoint approved"), + "REJECT": ("REJECTED", "TERMINATED", "checkpoint rejected"), + "ESCALATE": ("ESCALATED", "CHECKPOINT_OPEN", "checkpoint escalated"), + "TIMEOUT_REJECT": ( + "TIMEOUT_REJECTED", + "TERMINATED", + "checkpoint timed out", + ), + } + entry = mapping.get(resolution.resolution) + if entry is None: + raise InvalidResolutionError( + f"unknown resolution type: {resolution.resolution!r}" + ) + status, outcome_status, reason = entry + self._records[checkpoint_id] = CheckpointRecord( + checkpoint_id=current.checkpoint_id, + workflow_id=current.workflow_id, + transition_id=current.transition_id, + status=status, + created_at=current.created_at, + decision_context={ + **current.decision_context, + "resolved_by": resolution.actor_id, + "resolution_reason": resolution.reason, + "resolution_metadata": resolution.metadata, + }, + ) + return TransitionOutcome( + status=outcome_status, + reason=reason, + checkpoint_id=checkpoint_id, + metadata={"resolution": resolution.resolution}, + ) diff --git a/src/gaia/governance/config.py b/src/gaia/governance/config.py new file mode 100644 index 000000000..533d07197 --- /dev/null +++ b/src/gaia/governance/config.py @@ -0,0 +1,51 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Consolidated governance configuration. + +:class:`GovernanceConfig` bundles every governance knob the +:class:`GovernedAgentMixin` accepts into a single object, so user +agents do not carry six ``governance_*`` keywords in their +``__init__`` signatures. + +Both styles are supported โ€” use whichever feels more ergonomic:: + + agent = MyAgent(governance=GovernanceConfig( + adapter=adapter, + risk_tags={"delete_record": ["blocked"]}, + )) + +or, equivalently:: + + agent = MyAgent( + governance_adapter=adapter, + governance_risk_tags={"delete_record": ["blocked"]}, + ) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable + +from .adapter import GaiaGovernanceAdapter +from .schemas import ActionRequest, GovernanceDecision + +# Observational callback: (tool_name, tool_args, action, decision) -> None. +GovernanceCallback = Callable[ + [str, dict[str, Any], ActionRequest, GovernanceDecision], None +] + +# Reviewer callback: (tool_name, tool_args, decision) -> bool. +GovernanceReviewer = Callable[[str, dict[str, Any], GovernanceDecision], bool] + + +@dataclass(slots=True) +class GovernanceConfig: + """All governance options in one object.""" + + adapter: GaiaGovernanceAdapter + actor_id: str = "gaia-agent" + workflow_id: str | None = None + risk_tags: dict[str, list[str]] = field(default_factory=dict) + callback: GovernanceCallback | None = None + reviewer: GovernanceReviewer | None = None diff --git a/src/gaia/governance/decorators.py b/src/gaia/governance/decorators.py new file mode 100644 index 000000000..108a26443 --- /dev/null +++ b/src/gaia/governance/decorators.py @@ -0,0 +1,71 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Decorator-based risk tagging โ€” the idiomatic Python alternative to +maintaining a central ``risk_tags`` dict on every agent. + +Usage:: + + from gaia import tool + from gaia.governance import govern + + @tool + @govern(risk="blocked", reason="destructive filesystem operation") + def wipe_disk() -> dict: + ... + + @tool + @govern(risk="review") + def send_money(amount: float, recipient: str) -> dict: + ... + +The mixin reads ``__gaia_governance__`` off the tool function at call +time and merges those tags with any dict passed via +``governance_risk_tags=``. Tags are **additive** (union, deduplicated): +decorator tags come first, then dict tags are appended. Neither side +overrides the other. +""" + +from __future__ import annotations + +from typing import Any, Callable + +_ATTR = "__gaia_governance__" + + +def govern( + *, + risk: str | list[str], + reason: str = "", +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Attach governance metadata to a tool function. + + ``risk`` may be a single tag ("blocked", "review", or any custom + tag your policy engine understands) or a list of tags. + ``reason`` is optional free-form text surfaced in decision reports. + """ + tags = [risk] if isinstance(risk, str) else list(risk) + + def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: + existing = getattr(fn, _ATTR, None) or {} + merged_tags = list(dict.fromkeys([*existing.get("risk_tags", []), *tags])) + setattr( + fn, + _ATTR, + { + "risk_tags": merged_tags, + "reason": reason or existing.get("reason", ""), + }, + ) + return fn + + return decorator + + +def read_risk_tags(fn: Callable[..., Any] | None) -> list[str]: + """Return risk tags declared via :func:`govern`, or an empty list.""" + if fn is None: + return [] + meta = getattr(fn, _ATTR, None) + if not meta: + return [] + return list(meta.get("risk_tags", [])) diff --git a/src/gaia/governance/exceptions.py b/src/gaia/governance/exceptions.py new file mode 100644 index 000000000..876db1b24 --- /dev/null +++ b/src/gaia/governance/exceptions.py @@ -0,0 +1,15 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Governance-layer exceptions.""" + + +class GaiaGovernanceError(Exception): + """Base error for the GAIA governance package.""" + + +class CheckpointNotFoundError(GaiaGovernanceError): + """Raised when a checkpoint cannot be found.""" + + +class InvalidResolutionError(GaiaGovernanceError): + """Raised when a checkpoint resolution is invalid for its current state.""" diff --git a/src/gaia/governance/mixin.py b/src/gaia/governance/mixin.py new file mode 100644 index 000000000..9bb87c736 --- /dev/null +++ b/src/gaia/governance/mixin.py @@ -0,0 +1,413 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Optional mixin that adds governance to a GAIA ``Agent`` subclass. + +Usage:: + + from gaia import Agent + from gaia.governance import GaiaGovernanceAdapter, GovernedAgentMixin + + class MyGovernedAgent(GovernedAgentMixin, MyAgent): + pass + + agent = MyGovernedAgent(governance_adapter=my_adapter, actor_id="alice") + +The mixin wraps :meth:`Agent._execute_tool` through ``super()``. If no +adapter is supplied it is a no-op, so adding the mixin to an agent has +zero runtime cost by default. **No edits to ``gaia.agents.base.agent`` +are required.** + +Decision flow +------------- + +Every intercepted tool call drives the full adapter pipeline: + +1. The tool call is mapped to an :class:`ActionRequest`. +2. ``adapter.govern_action`` yields a :class:`GovernanceDecision`. +3. A synthetic :class:`WorkflowTransition` is built and passed through + ``adapter.handle_transition``. +4. **ALLOW** โ†’ the underlying ``_execute_tool`` runs. +5. **BLOCK** โ†’ the tool is short-circuited with a denied result and + the adapter issues a BLOCK receipt. +6. **REVIEW** โ†’ a checkpoint is opened. The mixin asks an explicit + ``governance_reviewer`` callback when one is configured, otherwise + it delegates to ``self.console.confirm_tool_execution`` only when + that console advertises ``blocking_confirmation = True`` (for + example Agent UI's SSE confirmation surface). It then resolves the + checkpoint APPROVE / REJECT accordingly. An APPROVE runs the tool; a + REJECT short-circuits. Either way, a receipt is issued. + +If ``REVIEW`` decisions are returned and neither a reviewer nor a +blocking console is available, the mixin **fails closed** and rejects +the tool. This matches the intent of the decision type ("do not execute +without review") and avoids silent pass-through. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from .action_mapper import map_gaia_tool_call_to_action_request +from .adapter import GaiaGovernanceAdapter +from .config import GovernanceCallback, GovernanceConfig, GovernanceReviewer +from .decorators import read_risk_tags +from .exceptions import GaiaGovernanceError +from .schemas import ( + ActionRequest, + CheckpointResolution, + GovernanceDecision, + WorkflowTransition, + new_id, +) + +logger = logging.getLogger(__name__) + + +class GovernedAgentMixin: + """Mix-in: intercept ``_execute_tool`` and drive the full adapter flow.""" + + governance_adapter: GaiaGovernanceAdapter | None + _governance_actor_id: str + _governance_workflow_id: str | None + _governance_risk_tags: dict[str, list[str]] + _governance_callback: GovernanceCallback | None + _governance_reviewer: GovernanceReviewer | None + + def __init__( + self, + *args: Any, + governance: GovernanceConfig | None = None, + governance_adapter: GaiaGovernanceAdapter | None = None, + governance_actor_id: str = "gaia-agent", + governance_workflow_id: str | None = None, + governance_risk_tags: dict[str, list[str]] | None = None, + governance_callback: GovernanceCallback | None = None, + governance_reviewer: GovernanceReviewer | None = None, + **kwargs: Any, + ) -> None: + # Prefer the structured config if supplied; fall back to the + # per-kwarg form so both styles work. + # Deep-copy the inner lists so callers cannot mutate the agent's + # risk-tag table after construction by holding onto the original + # reference (e.g. ``tags = ["review"]; agent = MyAgent(..., + # governance_risk_tags={"foo": tags}); tags.append("blocked")``). + if governance is not None: + self.governance_adapter = governance.adapter + self._governance_actor_id = governance.actor_id + self._governance_workflow_id = governance.workflow_id + self._governance_risk_tags = { + k: list(v) for k, v in governance.risk_tags.items() + } + self._governance_callback = governance.callback + self._governance_reviewer = governance.reviewer + else: + self.governance_adapter = governance_adapter + self._governance_actor_id = governance_actor_id + self._governance_workflow_id = governance_workflow_id + self._governance_risk_tags = { + k: list(v) for k, v in (governance_risk_tags or {}).items() + } + self._governance_callback = governance_callback + self._governance_reviewer = governance_reviewer + super().__init__(*args, **kwargs) + + # ---- public plumbing -------------------------------------------------- + + def _execute_tool(self, tool_name: str, tool_args: dict[str, Any]) -> Any: + adapter = self.governance_adapter + if adapter is None: + return super()._execute_tool(tool_name, tool_args) # type: ignore[misc] + + # HIGH-2 fix: resolve the canonical tool name BEFORE governance so + # risk tags keyed to the canonical name (e.g. ``mcp_time_get_current_time``) + # cannot be bypassed by the LLM calling the unprefixed alias + # (e.g. ``get_current_time``). Falls through to the raw name when + # the base Agent does not expose a resolver. + canonical = self._resolve_canonical_tool_name(tool_name) + action = self._build_action_request(canonical, tool_args) + decision = adapter.govern_action(action) + self._invoke_callback(tool_name, tool_args, action, decision) + + transition = self._build_transition(action, tool_args) + outcome = adapter.handle_transition(transition, decision) + + if outcome.status == "CONTINUE": + return super()._execute_tool(tool_name, tool_args) # type: ignore[misc] + + if outcome.status == "TERMINATED": + self._emit_policy_alert( + tool_name, + decision.decision, + decision.reason, + decision.rule_ids, + decision.policy_version, + outcome.metadata.get("receipt_id"), + ) + return self._denied_result( + tool_name, + decision.decision, + decision.reason, + decision.policy_version, + decision.rule_ids, + outcome.metadata.get("receipt_id"), + ) + + if outcome.status == "CHECKPOINT_OPEN": + return self._handle_review_checkpoint( + adapter, + tool_name, + tool_args, + decision, + transition, + outcome.checkpoint_id, + ) + + # Unknown outcome โ†’ fail closed. + return self._denied_result( + tool_name, + "ERROR", + f"unknown transition outcome: {outcome.status}", + decision.policy_version, + [], + None, + ) + + # ---- internals -------------------------------------------------------- + + def _resolve_canonical_tool_name(self, tool_name: str) -> str: + """Return the canonical tool name if the base Agent can resolve it. + + GAIA's ``Agent._resolve_tool_name`` maps unprefixed aliases + (e.g. ``get_current_time``) to registry keys + (e.g. ``mcp_time_get_current_time``). Governance must key on the + canonical name or risk tags can be trivially bypassed. + """ + resolver = getattr(self, "_resolve_tool_name", None) + if callable(resolver): + try: + resolved = resolver(tool_name) # pylint: disable=not-callable + if resolved: + return resolved + except LookupError: + pass # Tool not in registry โ€” fall through to raw name. + except Exception: # pylint: disable=broad-exception-caught + logger.warning( + "governance: _resolve_tool_name raised unexpectedly for %r; " + "falling back to raw name โ€” canonical tag lookup may be incomplete", + tool_name, + exc_info=True, + ) + return tool_name + + def _build_action_request( + self, tool_name: str, tool_args: dict[str, Any] + ) -> ActionRequest: + # Merge decorator-declared tags with explicit dict tags โ€” additive + # (union, deduplicated). Decorator tags come first; explicit dict + # tags are appended. Neither side overrides the other. + decorated_tags = read_risk_tags(self._lookup_tool_fn(tool_name)) + explicit_tags = self._governance_risk_tags.get(tool_name, []) + merged_tags = list(dict.fromkeys([*decorated_tags, *explicit_tags])) + return map_gaia_tool_call_to_action_request( + tool_name, + tool_args, + { + "actor_id": self._governance_actor_id, + "workflow_id": self._governance_workflow_id, + "risk_tags": merged_tags, + "source": "gaia", + }, + ) + + @staticmethod + def _lookup_tool_fn(tool_name: str) -> Any | None: + """Return the registered tool function, or None if absent. + + Read through GAIA's tool registry so we can inspect + ``__gaia_governance__`` attributes placed by :func:`govern`. + """ + try: + from gaia.agents.base.tools import _TOOL_REGISTRY # type: ignore + except ImportError: + return None + entry = _TOOL_REGISTRY.get(tool_name) + if not entry: + return None + return entry.get("function") + + def _build_transition( + self, action: ActionRequest, tool_args: dict[str, Any] + ) -> WorkflowTransition: + workflow_id = self._governance_workflow_id or f"wf_{self._governance_actor_id}" + return WorkflowTransition( + workflow_id=workflow_id, + transition_id=new_id("tx"), + from_state="READY", + to_state=f"TOOL:{action.tool_name}", + transition_type="tool_call", + related_action_id=action.action_id, + payload={"tool_args": dict(tool_args)}, + ) + + def _invoke_callback( + self, + tool_name: str, + tool_args: dict[str, Any], + action: ActionRequest, + decision: GovernanceDecision, + ) -> None: + if self._governance_callback is None: + return + try: + self._governance_callback(tool_name, tool_args, action, decision) + except Exception: # pylint: disable=broad-exception-caught + # Observational callbacks must never break tool execution. + logger.warning( + "governance: callback raised for tool %r; continuing", + tool_name, + exc_info=True, + ) + + def _handle_review_checkpoint( + self, + adapter: GaiaGovernanceAdapter, + tool_name: str, + tool_args: dict[str, Any], + decision: GovernanceDecision, + transition: WorkflowTransition, + checkpoint_id: str | None, + ) -> Any: + if checkpoint_id is None: + raise GaiaGovernanceError("CHECKPOINT_OPEN without checkpoint_id") + approved, review_error = self._prompt_review(tool_name, tool_args, decision) + # Stamp the reject reason with the exception type/message when the + # REJECT was caused by a reviewer crash, so the audit log can tell + # "reviewer chose no" apart from "reviewer raised". + if approved: + resolution_label = "APPROVE" + reason = "reviewer approved" + elif review_error is not None: + resolution_label = "REJECT" + reason = f"reviewer raised {type(review_error).__name__}: {review_error}" + else: + resolution_label = "REJECT" + reason = "reviewer rejected" + resolution = CheckpointResolution( + resolution=resolution_label, + actor_id=self._governance_actor_id, + reason=reason, + ) + resolved = adapter.resolve_checkpoint( + checkpoint_id, resolution, transition.workflow_id + ) + if resolved.status == "RESUMED": + return super()._execute_tool(tool_name, tool_args) # type: ignore[misc] + return self._denied_result( + tool_name, + "REVIEW_REJECTED", + "tool rejected at review checkpoint", + decision.policy_version, + decision.rule_ids, + resolved.metadata.get("receipt_id"), + ) + + def _prompt_review( + self, + tool_name: str, + tool_args: dict[str, Any], + decision: GovernanceDecision, + ) -> tuple[bool, BaseException | None]: + """Ask the registered reviewer to approve or reject. + + Returns ``(approved, exception_or_None)``. When the reviewer + raises, the second element captures the exception so the audit + log can record that the REJECT was due to a crash, not a "no" + decision. ``BaseException`` (KeyboardInterrupt, SystemExit) is + intentionally NOT caught โ€” those should propagate. + + An explicit ``governance_reviewer`` callback takes precedence. + Without one, GAIA's ``AgentConsole.confirm_tool_execution`` is + consulted only when the console advertises + ``blocking_confirmation = True``. The default console returns + ``True`` immediately, so silently treating every console as a + reviewer would break the fail-closed contract. Agent UI's + ``SSEOutputHandler`` sets that flag because it blocks on the + frontend permission modal. + """ + reviewer = self._governance_reviewer + if reviewer is None: + console = getattr(self, "console", None) + if ( + console is None + or not getattr(console, "blocking_confirmation", False) + or not callable(getattr(console, "confirm_tool_execution", None)) + ): + # Fail closed: REVIEW means "do not run without review". + return False, None + + def reviewer(name, args, _decision): + return console.confirm_tool_execution(name, args) + + try: + return bool(reviewer(tool_name, tool_args, decision)), None + except Exception as exc: # pylint: disable=broad-exception-caught + logger.warning( + "governance: reviewer raised for tool %r; failing closed", + tool_name, + exc_info=True, + ) + return False, exc + + def _emit_policy_alert( + self, + tool_name: str, + governance_decision: str, + reason: str, + rule_ids: list[str], + policy_version: str, + receipt_id: str | None, + ) -> None: + """Notify capable consoles that governance blocked a tool call.""" + if governance_decision != "BLOCK": + return + console = getattr(self, "console", None) + alert = getattr(console, "print_policy_alert", None) + if not callable(alert): + return + try: + alert( + tool_name, + governance_decision, + reason, + rule_ids, + policy_version, + receipt_id, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.warning( + "governance: failed to emit policy alert for tool %r", + tool_name, + exc_info=True, + ) + + @staticmethod + def _denied_result( + tool_name: str, + governance_decision: str, + reason: str, + policy_version: str, + rule_ids: list[str], + receipt_id: str | None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "status": "denied", + "error": f"Tool '{tool_name}' blocked by governance: {reason}", + "governance_decision": governance_decision, + "policy_version": policy_version, + "rule_ids": list(rule_ids), + "error_displayed": True, + } + if receipt_id is not None: + payload["receipt_id"] = receipt_id + return payload diff --git a/src/gaia/governance/policy_binding.py b/src/gaia/governance/policy_binding.py new file mode 100644 index 000000000..c8f8acb15 --- /dev/null +++ b/src/gaia/governance/policy_binding.py @@ -0,0 +1,28 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Static PolicyBinding reference implementation. + +Swap for constitutional-swarm's PolicyBinding once the policy control +plane is in place. The receipt issuer reads ``current_version()`` to +stamp policy-version + constitution-hash onto every decision. +""" + +from __future__ import annotations + +from .schemas import PolicyVersionRef, utc_now_iso + + +class StaticPolicyBindingService: + def __init__( + self, + version: str = "v0", + constitution_hash: str = "constitution-dev", + ) -> None: + self._current = PolicyVersionRef( + version=version, + constitution_hash=constitution_hash, + activated_at=utc_now_iso(), + ) + + def current_version(self) -> PolicyVersionRef: + return self._current diff --git a/src/gaia/governance/protocols.py b/src/gaia/governance/protocols.py new file mode 100644 index 000000000..9ab4a1278 --- /dev/null +++ b/src/gaia/governance/protocols.py @@ -0,0 +1,51 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Runtime-checkable protocol contracts for governance services. + +Keeping these as Protocols (not ABCs) lets downstream implementations +live in ACGS-lite, constitutional-swarm, or GAIA itself without forcing +an inheritance relationship. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from .schemas import ( + ActionRequest, + CheckpointRecord, + CheckpointResolution, + GovernanceDecision, + PolicyVersionRef, + ReceiptRecord, + TransitionOutcome, + WorkflowTransition, +) + + +@runtime_checkable +class PolicyEngine(Protocol): + def evaluate_action(self, action_request: ActionRequest) -> GovernanceDecision: ... + + +@runtime_checkable +class CheckpointRuntime(Protocol): + def create_checkpoint( + self, transition: WorkflowTransition, decision: GovernanceDecision + ) -> CheckpointRecord: ... + + def resolve_checkpoint( + self, checkpoint_id: str, resolution: CheckpointResolution + ) -> TransitionOutcome: ... + + +@runtime_checkable +class ReceiptServiceProtocol(Protocol): + def issue_receipt(self, record: ReceiptRecord) -> str: ... + + def get_receipt(self, receipt_id: str) -> ReceiptRecord: ... + + +@runtime_checkable +class PolicyBindingProtocol(Protocol): + def current_version(self) -> PolicyVersionRef: ... diff --git a/src/gaia/governance/receipt_service.py b/src/gaia/governance/receipt_service.py new file mode 100644 index 000000000..c2ac34b80 --- /dev/null +++ b/src/gaia/governance/receipt_service.py @@ -0,0 +1,127 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Receipt service reference implementations. + +Two variants are shipped: + +* :class:`InMemoryReceiptService` โ€” ephemeral, for tests and in-process + inspection. +* :class:`JsonlReceiptService` โ€” append-only JSONL audit log on disk. + Survives process exit and is trivially tailable / grep-able. This is + the minimum viable shape for a real audit trail and is the default + in the governed example. + +Both implement :class:`gaia.governance.protocols.ReceiptServiceProtocol`. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import asdict, fields +from pathlib import Path +from threading import Lock +from typing import Iterator + +from .exceptions import GaiaGovernanceError +from .schemas import ReceiptRecord + +logger = logging.getLogger(__name__) + + +class InMemoryReceiptService: + """Process-local receipt store. Lost on exit.""" + + def __init__(self) -> None: + self._records: dict[str, ReceiptRecord] = {} + self._lock = Lock() + + def issue_receipt(self, record: ReceiptRecord) -> str: + with self._lock: + self._records[record.receipt_id] = record + return record.receipt_id + + def get_receipt(self, receipt_id: str) -> ReceiptRecord: + with self._lock: + try: + return self._records[receipt_id] + except KeyError as exc: + raise GaiaGovernanceError(f"receipt not found: {receipt_id}") from exc + + def __iter__(self) -> Iterator[ReceiptRecord]: + with self._lock: + return iter(list(self._records.values())) + + +class JsonlReceiptService: + """Append-only JSONL receipt log on disk. + + Each receipt is serialized as one JSON object per line. Opens the + file in append mode, flushes on every write, and uses a process-local + lock so concurrent in-process callers don't interleave lines. + + Intentionally not cross-process safe โ€” use a dedicated receipt + service (e.g. a log-forwarder or database) for multi-process + deployments. + """ + + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self._cache: dict[str, ReceiptRecord] = {} + self._lock = Lock() + + def issue_receipt(self, record: ReceiptRecord) -> str: + line = json.dumps(asdict(record), allow_nan=False, sort_keys=True) + with self._lock: + with self.path.open("a", encoding="utf-8") as fh: + fh.write(line + "\n") + fh.flush() + self._cache[record.receipt_id] = record + return record.receipt_id + + def get_receipt(self, receipt_id: str) -> ReceiptRecord: + with self._lock: + cached = self._cache.get(receipt_id) + if cached is not None: + return cached + # Cold-read path: scan the log. O(n) but acceptable for audit + # queries and avoids loading the whole log eagerly. The scan + # itself does not hold the lock โ€” the JSONL file is append-only + # and `issue_receipt` flushes line-aligned writes โ€” but the + # cache install must re-enter the lock so it does not race + # with concurrent issuers. + for record in self._read_all(): + if record.receipt_id == receipt_id: + with self._lock: + self._cache[receipt_id] = record + return record + raise GaiaGovernanceError(f"receipt not found: {receipt_id}") + + def _read_all(self) -> Iterator[ReceiptRecord]: + if not self.path.exists(): + return + known = {f.name for f in fields(ReceiptRecord)} + with self.path.open("r", encoding="utf-8") as fh: + for line in fh: + stripped = line.strip() + if not stripped: + continue + try: + data = json.loads(stripped) + yield ReceiptRecord(**{k: v for k, v in data.items() if k in known}) + except (json.JSONDecodeError, TypeError, KeyError) as exc: + # Skip malformed or schema-mismatched lines, but leave + # a breadcrumb for an operator chasing a missing receipt. + logger.debug( + "receipt log: skipping unreadable line (%s)", + type(exc).__name__, + ) + except Exception: # pylint: disable=broad-exception-caught + logger.warning( + "receipt log: unexpected error deserializing line; skipping", + exc_info=True, + ) + + def __iter__(self) -> Iterator[ReceiptRecord]: + return self._read_all() diff --git a/src/gaia/governance/schemas.py b/src/gaia/governance/schemas.py new file mode 100644 index 000000000..693d756ab --- /dev/null +++ b/src/gaia/governance/schemas.py @@ -0,0 +1,110 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Data classes shared across the governance layer. + +Ported from the gaia-acgs starter scaffold; these types are intentionally +framework-agnostic dataclasses so they can be exchanged with ACGS-lite +and constitutional-swarm without importing GAIA runtime symbols. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Literal +from uuid import uuid4 + +DecisionType = Literal["ALLOW", "REVIEW", "BLOCK"] +CheckpointStatus = Literal[ + "OPEN", "APPROVED", "REJECTED", "ESCALATED", "TIMEOUT_REJECTED" +] +TransitionStatus = Literal["CONTINUE", "CHECKPOINT_OPEN", "TERMINATED", "RESUMED"] +ResolutionType = Literal["APPROVE", "REJECT", "ESCALATE", "TIMEOUT_REJECT"] + + +def utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def new_id(prefix: str) -> str: + return f"{prefix}_{uuid4().hex[:12]}" + + +@dataclass(frozen=True, slots=True) +class ActionRequest: + action_id: str + actor_id: str + tool_name: str + action_type: str + args: dict[str, Any] + risk_tags: list[str] = field(default_factory=list) + workflow_id: str | None = None + step_id: str | None = None + source: str = "gaia" + + +@dataclass(frozen=True, slots=True) +class GovernanceDecision: + decision: DecisionType + reason: str + policy_version: str + rule_ids: list[str] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class WorkflowTransition: + workflow_id: str + transition_id: str + from_state: str + to_state: str + transition_type: str + related_action_id: str | None + payload: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class CheckpointRecord: + checkpoint_id: str + workflow_id: str + transition_id: str + status: CheckpointStatus + created_at: str + decision_context: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class CheckpointResolution: + resolution: ResolutionType + actor_id: str + reason: str = "" + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class TransitionOutcome: + status: TransitionStatus + reason: str + checkpoint_id: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class ReceiptRecord: + receipt_id: str + workflow_id: str + checkpoint_id: str | None + decision: str + policy_version: str + actor_id: str | None + validator_set_id: str | None + created_at: str + payload_hash: str + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class PolicyVersionRef: + version: str + constitution_hash: str + activated_at: str diff --git a/src/gaia/governance/stubs.py b/src/gaia/governance/stubs.py new file mode 100644 index 000000000..681a99b99 --- /dev/null +++ b/src/gaia/governance/stubs.py @@ -0,0 +1,47 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Stub policy engine for demos and tests. + +Real engines will come from ACGS-lite. This stub decides purely from +``risk_tags`` on the :class:`ActionRequest`. +""" + +from __future__ import annotations + +from .schemas import ActionRequest, GovernanceDecision + + +class RuleBasedPolicyEngine: + """Tiny stub engine. + + Rules: + - risk tag 'blocked' -> BLOCK + - risk tag 'review' -> REVIEW + - otherwise -> ALLOW + """ + + def __init__(self, policy_version: str = "v0") -> None: + self.policy_version = policy_version + + def evaluate_action(self, action_request: ActionRequest) -> GovernanceDecision: + tags = set(action_request.risk_tags) + if "blocked" in tags: + return GovernanceDecision( + decision="BLOCK", + reason="blocked by policy", + policy_version=self.policy_version, + rule_ids=["rule:block"], + ) + if "review" in tags: + return GovernanceDecision( + decision="REVIEW", + reason="requires operator review", + policy_version=self.policy_version, + rule_ids=["rule:review"], + ) + return GovernanceDecision( + decision="ALLOW", + reason="allowed by policy", + policy_version=self.policy_version, + rule_ids=["rule:allow"], + ) diff --git a/src/gaia/mcp/client/config.py b/src/gaia/mcp/client/config.py index 4a3038848..402bce0cc 100644 --- a/src/gaia/mcp/client/config.py +++ b/src/gaia/mcp/client/config.py @@ -4,6 +4,7 @@ import json import sys +import warnings from pathlib import Path from typing import Any, Dict, List, Optional @@ -146,19 +147,33 @@ def _save(self) -> None: def add_server(self, name: str, config: Dict[str, Any]) -> None: """Add or update a server configuration. - Args: - name: Server name - config: Server configuration dictionary + .. deprecated:: + Use ``gaia.connectors.mcp_server.McpServerHandler.configure()`` + instead. The connectors framework is now the sole writer to + ``mcp_servers.json`` (plan amendment A6). """ + warnings.warn( + "MCPConfig.add_server() is deprecated. Use McpServerHandler.configure() " + "to write mcp_servers.json (plan amendment A6).", + DeprecationWarning, + stacklevel=2, + ) self._servers[name] = config self._save() def remove_server(self, name: str) -> None: """Remove a server configuration. - Args: - name: Server name + .. deprecated:: + Use ``gaia.connectors.mcp_server.McpServerHandler.disconnect()`` + instead (plan amendment A6). """ + warnings.warn( + "MCPConfig.remove_server() is deprecated. Use McpServerHandler.disconnect() " + "to write mcp_servers.json (plan amendment A6).", + DeprecationWarning, + stacklevel=2, + ) if name in self._servers: del self._servers[name] self._save() diff --git a/src/gaia/mcp/client/mcp_client.py b/src/gaia/mcp/client/mcp_client.py index 42774bca6..ab9483271 100644 --- a/src/gaia/mcp/client/mcp_client.py +++ b/src/gaia/mcp/client/mcp_client.py @@ -14,6 +14,44 @@ logger = get_logger(__name__) +def _resolve_keyring_refs(env: Optional[Dict[str, Any]]) -> Dict[str, str]: + """ + Resolve ``{"$keyring": "service:username"}`` references in *env*. + + Each value that is a dict with a ``"$keyring"`` key is resolved via + ``keyring.get_password(service, username)`` where the reference string + is split on the first ``:`` as ``:``. + + Raises ``RuntimeError`` if any referenced keyring entry is absent โ€” + the server is refused to spawn (plan amendment A5b: fail-closed). + Plain string values pass through unchanged. + """ + if not env: + return {} + import keyring # pylint: disable=import-outside-toplevel + + resolved: Dict[str, str] = {} + missing: list[str] = [] + for key, value in env.items(): + if isinstance(value, dict) and "$keyring" in value: + ref = value["$keyring"] + service, _, username = ref.partition(":") + password = keyring.get_password(service, username) + if password is None: + missing.append(ref) + else: + resolved[key] = password + else: + resolved[key] = str(value) + if missing: + raise RuntimeError( + f"MCPClient: refusing to spawn โ€” missing keyring entries: {missing!r}. " + "Reconfigure the connector via Settings โ†’ Connectors or " + "`gaia connectors configure `." + ) + return resolved + + @dataclass class MCPTool: """Represents an MCP tool with its schema. @@ -119,10 +157,14 @@ def from_config( if "command" not in config: raise ValueError("Config must include 'command' field") + # Resolve any $keyring references before spawning; raises RuntimeError + # if a reference is dangling (fail-closed per plan amendment A5b). + resolved_env = _resolve_keyring_refs(config.get("env")) + transport = StdioTransport( command=config["command"], args=config.get("args"), - env=config.get("env"), + env=resolved_env or None, timeout=timeout, debug=debug, ) diff --git a/src/gaia/mcp/client/mcp_client_manager.py b/src/gaia/mcp/client/mcp_client_manager.py index cf310627a..03deca2e0 100644 --- a/src/gaia/mcp/client/mcp_client_manager.py +++ b/src/gaia/mcp/client/mcp_client_manager.py @@ -163,6 +163,19 @@ def disconnect_all(self) -> None: self._clients.clear() self._failed.clear() + def reload(self) -> None: + """Hot-reload server config without restarting GAIA. + + Disconnects all currently running servers, re-reads + ``mcp_servers.json``, and reconnects all enabled servers. + Called by ``McpServerHandler`` after ``configure`` / ``disconnect`` + (plan amendment A5). + """ + logger.debug("MCPClientManager: reloading server config") + self.disconnect_all() + self.config._load() + self.load_from_config() + def load_from_config(self) -> None: """Load and connect to all servers from configuration. diff --git a/src/gaia/ui/_chat_helpers.py b/src/gaia/ui/_chat_helpers.py index e7e11d3c2..9cb0fafc2 100644 --- a/src/gaia/ui/_chat_helpers.py +++ b/src/gaia/ui/_chat_helpers.py @@ -389,6 +389,12 @@ def _canonical_agent_type(agent_type: str) -> str: Keeps the per-session agent cache from thrashing when a client mixes the old and new IDs within the same session โ€” both resolve to the same canonical ID and therefore the same cache entry. + + Raises: + AttributeError: If the registry doesn't expose ``canonical_id``. + Fail loudly per CLAUDE.md "no silent fallbacks" โ€” a registry + that lost this method is a real bug, not something to paper + over with a cache miss. """ registry = _agent_registry if registry is None: diff --git a/src/gaia/ui/models.py b/src/gaia/ui/models.py index a21d4f38f..a9bb7b8df 100644 --- a/src/gaia/ui/models.py +++ b/src/gaia/ui/models.py @@ -160,6 +160,16 @@ class AgentInfo(BaseModel): # the frontend skips the memory-warning check. Populated from # ``AgentRegistration.min_memory_gb``. min_memory_gb: Optional[float] = None + # T-X2 (issue #915): declared external-OAuth scope claims, surfaced from + # ``Agent.REQUIRED_CONNECTORS``. The AgentUI consent dialog renders these + # in plain language (via SCOPE_DESCRIPTIONS in providers/google.py). + # Each entry is a serialized ``ConnectorRequirement``: + # {connector_id: str, scopes: list[str], reason: str}. + required_connections: List[dict] = Field(default_factory=list) + # T-X2: opaque grant-ledger key. Built-ins use ``builtin:``; custom + # agents use ``custom::``. The CLI and UI consent + # dialog use this when calling ``grant_agent`` / ``revoke_agent_grant``. + namespaced_agent_id: str = "" class AgentListResponse(BaseModel): diff --git a/src/gaia/ui/routers/agents.py b/src/gaia/ui/routers/agents.py index 23deaf5a2..dc9a6dcb3 100644 --- a/src/gaia/ui/routers/agents.py +++ b/src/gaia/ui/routers/agents.py @@ -88,6 +88,17 @@ def _reg_to_info(reg) -> AgentInfo: conversation_starters=reg.conversation_starters, models=reg.models, min_memory_gb=reg.min_memory_gb, + # T-X2 (issue #915): surface declared connection requirements so the + # AgentUI consent dialog can render the prompt at agent-selection time. + required_connections=[ + { + "provider": cr.provider, + "scopes": list(cr.scopes), + "reason": cr.reason, + } + for cr in reg.required_connections + ], + namespaced_agent_id=reg.namespaced_agent_id, ) diff --git a/src/gaia/ui/routers/connectors.py b/src/gaia/ui/routers/connectors.py new file mode 100644 index 000000000..60f69d879 --- /dev/null +++ b/src/gaia/ui/routers/connectors.py @@ -0,0 +1,533 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +FastAPI router for ``/api/connectors/*`` โ€” thin presentation layer over +``gaia.connectors``. + +This router does NOT own connector state. Each handler is at most ~15 +lines: parse the request, call the corresponding ``gaia.connectors`` +function, translate exceptions per the table below. The same operations +are reachable from the CLI (``gaia connectors ...``) and SDK +(``import gaia.connectors; ...``) without going through this layer. + +Exception โ†’ HTTP mapping: +- ``AuthRequiredError(NOT_CONNECTED)`` โ†’ 401 +- ``AuthRequiredError(AGENT_NOT_GRANTED)`` โ†’ 403 +- ``AuthRequiredError(CONNECTION_MISSING_SCOPES)`` โ†’ 403 + missing_scopes +- ``AuthRequiredError(REAUTH_REQUIRED)`` โ†’ 401 +- ``ConnectionRevokedError`` โ†’ 401 +- ``ScopeMismatchError`` โ†’ 403 +- ``ConfigurationError`` โ†’ 503 +- ``FlowInProgressError`` โ†’ 409 +- ``FlowTimeoutError`` โ†’ 408 +- ``ConsentDeniedError`` โ†’ 400 +- Any other ``ConnectorsError`` โ†’ 500 + +Mutating routes (POST/PUT/DELETE) require ``X-Gaia-UI: 1`` header (CSRF +guard, plan amendment A8). Read-only GET routes are unguarded. + +The catalog import at module load time triggers handler registration +for ``oauth_pkce`` and ``mcp_server`` types. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from typing import Any, AsyncIterator, Dict, List, Optional + +import keyring +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +import gaia.connectors as connections +import gaia.connectors.catalog # noqa: F401 # pylint: disable=unused-import +from gaia.connectors.errors import ( + AuthRequiredError, + ConfigurationError, + ConnectionRevokedError, + ConnectorsError, + ConsentDeniedError, + FlowInProgressError, + FlowTimeoutError, + ScopeMismatchError, +) +from gaia.connectors.events import set_emitter +from gaia.connectors.flow import _pending as _flow_pending +from gaia.connectors.grants import ( + GRANTS_FILE, + grant_agent, + list_agent_grants, + revoke_agent_grant, +) +from gaia.connectors.handler import configure, disconnect, health_check +from gaia.connectors.mcp_server import is_mcp_server_configured +from gaia.connectors.registry import REGISTRY +from gaia.connectors.store import peek_connection + +logger = logging.getLogger(__name__) + + +router = APIRouter(prefix="/api/connectors", tags=["connectors"]) + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# CSRF guard (plan amendment A8) +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def _require_ui_header(request: Request) -> None: + """Require ``X-Gaia-UI: 1`` header on mutating routes. + + Custom request headers trigger a CORS preflight in browsers, so + drive-by form POSTs from malicious pages cannot forge this header. + """ + if request.headers.get("x-gaia-ui") != "1": + raise HTTPException(status_code=403, detail="missing X-Gaia-UI header") + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Request / response models +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class AuthorizeRequest(BaseModel): + scopes: List[str] = Field(default_factory=list) + + +class GrantRequest(BaseModel): + scopes: List[str] = Field(default_factory=list) + + +class ConfigureRequest(BaseModel): + config: Dict[str, Any] = Field(default_factory=dict) + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# SSE EventEmitter implementation +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class _SseEmitter: + """ + Multi-subscriber event broadcaster used by ``GET /api/connectors/events``. + + Each subscriber owns a bounded ``asyncio.Queue(maxsize=100)``; events are + fan-outed to every subscriber. A subscriber that falls behind drops + events instead of leaking memory (slow-client memory-leak protection). + """ + + def __init__(self): + self._subscribers: list[asyncio.Queue] = [] + self._lock = asyncio.Lock() + + async def emit(self, event_type: str, payload: dict) -> None: + envelope = {"type": event_type, "payload": payload} + async with self._lock: + subscribers = list(self._subscribers) + for q in subscribers: + try: + q.put_nowait(envelope) + except asyncio.QueueFull: + logger.warning( + "connectors-sse: dropping event %s for slow subscriber", + event_type, + ) + + async def subscribe(self) -> asyncio.Queue: + q: asyncio.Queue = asyncio.Queue(maxsize=100) + async with self._lock: + self._subscribers.append(q) + return q + + async def unsubscribe(self, q: asyncio.Queue) -> None: + async with self._lock: + try: + self._subscribers.remove(q) + except ValueError: + pass + + +_emitter = _SseEmitter() +set_emitter(_emitter) + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Exception โ†’ HTTP translation +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def _raise_http_for(exc: ConnectorsError) -> HTTPException: + if isinstance(exc, ConfigurationError): + return HTTPException(status_code=503, detail=str(exc)) + if isinstance(exc, AuthRequiredError): + if exc.reason in ( + AuthRequiredError.Reason.NOT_CONNECTED, + AuthRequiredError.Reason.REAUTH_REQUIRED, + ): + return HTTPException( + status_code=401, + detail={ + "error": exc.reason.value, + "connector_id": exc.provider, + "agent_id": exc.agent_id, + }, + ) + return HTTPException( + status_code=403, + detail={ + "error": exc.reason.value, + "connector_id": exc.provider, + "agent_id": exc.agent_id, + "missing_scopes": list(exc.missing_scopes), + }, + ) + if isinstance(exc, ConnectionRevokedError): + return HTTPException( + status_code=401, + detail={"error": "connection_revoked", "connector_id": exc.provider}, + ) + if isinstance(exc, ScopeMismatchError): + return HTTPException( + status_code=403, + detail={"error": "scope_mismatch", "missing_scopes": exc.missing_scopes}, + ) + if isinstance(exc, FlowInProgressError): + return HTTPException(status_code=409, detail=str(exc)) + if isinstance(exc, FlowTimeoutError): + return HTTPException(status_code=408, detail=str(exc)) + if isinstance(exc, ConsentDeniedError): + return HTTPException(status_code=400, detail=str(exc)) + return HTTPException(status_code=500, detail=str(exc)) + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Helpers +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def _connector_summary(connector_id: str) -> Dict[str, Any]: + """Build a summary dict for one connector: spec fields + live state. + + No state cache: ``configured`` / ``account_id`` / ``scopes`` are + derived live from the source-of-truth store on every call โ€” + ``store.peek_connection`` (keyring) for ``oauth_pkce`` and + ``mcp_servers.json`` for ``mcp_server``. This guarantees the catalog + UI never shows stale data after an external change (e.g. the user + cleared their keyring or edited mcp_servers.json by hand). + + For ``oauth_pkce`` we also probe the OAuth provider registry โ€” if + the provider can't be instantiated (e.g. ``GAIA_GOOGLE_CLIENT_ID`` + is unset), surface ``configurable=False`` + ``config_error="..."`` + so the AgentUI renders a friendly "needs setup" tile rather than + letting the user click Connect and hit a 503. + """ + try: + spec = REGISTRY.get(connector_id) + except KeyError: + raise HTTPException( + status_code=404, detail=f"Unknown connector: {connector_id!r}" + ) + + configured = False + account_id: Optional[str] = None + scopes: list = [] + configurable = True + config_error: Optional[str] = None + + # TODO: when a 3rd connector type lands, push this if/elif into a + # Handler.summary(spec) method so this becomes a single polymorphic + # call. The same dispatch lives in cli.py:_handle_list โ€” refactor + # both together. + if spec.type == "oauth_pkce": + # Lazy import to avoid pulling provider modules at router import time. + from gaia.connectors.providers import get as get_provider + + provider_ref = spec.oauth_provider_ref or spec.id + try: + get_provider(provider_ref) + except ConfigurationError as e: + configurable = False + logger.info("connectors: provider %s not configured: %s", provider_ref, e) + _pref = provider_ref.upper() + config_error = ( + f"OAuth credentials for {provider_ref!r} are not configured. " + f"Set GAIA_{_pref}_CLIENT_ID and GAIA_{_pref}_CLIENT_SECRET, " + "or use Settings โ†’ Connections to configure them." + ) + except KeyError: + configurable = False + config_error = ( + f"OAuth provider {provider_ref!r} is not registered. " + "This is a catalog/code mismatch; please file a bug." + ) + + # Derive configured/account/scopes from the keyring blob โ€” that + # IS the source of truth. peek_connection is read-only and never + # raises on missing entries. + blob = peek_connection(provider_ref) + if blob is not None: + configured = True + account_id = blob.get("account_email") + scopes = list(blob.get("scopes", [])) + + elif spec.type == "mcp_server": + configured = is_mcp_server_configured(spec.id) + + return { + "id": spec.id, + "display_name": spec.display_name, + "icon": spec.icon, + "category": spec.category, + "tier": spec.tier, + "type": spec.type, + "description": spec.description, + "product_url": spec.product_url, + "docs_url": spec.docs_url, + "configured": configured, + "configurable": configurable, + "config_error": config_error, + "account_id": account_id, + "scopes": scopes, + "mcp_env_keys": list(spec.mcp_env_keys), + "default_scopes": list(spec.default_scopes), + # OAuth setup form (e.g. Google client_id/client_secret) โ€” empty + # tuple for connectors that don't need first-time provider creds. + "oauth_setup_fields": [ + { + "key": f.key, + "label": f.label, + "kind": f.kind, + "required": f.required, + "placeholder": f.placeholder, + "help_md": f.help_md, + } + for f in spec.oauth_setup_fields + ], + } + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Read-only endpoints (no CSRF guard) +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@router.get("") +@router.get("/") +async def list_connectors() -> Dict[str, Any]: + """Return catalog specs merged with live state for all connectors.""" + specs = REGISTRY.all() + summaries: List[Dict[str, Any]] = [] + for s in specs: + try: + summaries.append(_connector_summary(s.id)) + except Exception as exc: + logger.warning( + "connectors-list: summary failed for %s (%s)", s.id, type(exc).__name__ + ) + summaries.append({"id": s.id, "error": "unavailable"}) + return {"connectors": summaries} + + +@router.get("/events") +async def connector_events() -> StreamingResponse: + """Long-lived SSE stream of connector lifecycle events. + + Event types: + - ``connector.configured`` ({connector_id, account_id}) + - ``connector.disconnected`` ({connector_id}) + - ``connector.tested`` ({connector_id, ok, detail}) + - ``connector.oauth.completed`` ({connector_id, account_email}) + - ``connector.oauth.error`` ({connector_id, error}) + - ``connector.grant.changed`` ({connector_id, agent_id, scopes}) + """ + queue = await _emitter.subscribe() + + async def gen() -> AsyncIterator[bytes]: + try: + while True: + envelope = await queue.get() + yield f"data: {json.dumps(envelope)}\n\n".encode("utf-8") + finally: + await _emitter.unsubscribe(queue) + + return StreamingResponse( + gen(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + +@router.get("/_debug") +async def debug_state() -> Dict[str, Any]: + """Diagnostics endpoint, gated by ``GAIA_DEBUG=1``.""" + if os.environ.get("GAIA_DEBUG") != "1": + raise HTTPException(status_code=404, detail="Not Found") + + from gaia.connectors.providers import _registry as provider_registry + + grants_writable = False + try: + GRANTS_FILE.parent.mkdir(parents=True, exist_ok=True) + grants_writable = os.access(str(GRANTS_FILE.parent), os.W_OK) + except OSError: + pass + + # Derive configured ids live by walking the catalog and asking the + # source-of-truth store for each type. + configured_ids: list[str] = [] + for spec in REGISTRY.all(): + summary = _connector_summary(spec.id) + if summary["configured"]: + configured_ids.append(spec.id) + + return { + "provider_registered": "google" in provider_registry, + "env_var_present": bool(os.environ.get("GAIA_GOOGLE_CLIENT_ID")), + "keyring_backend_class": type(keyring.get_keyring()).__name__, + "grants_path": str(GRANTS_FILE), + "grants_path_writable": grants_writable, + "in_flight_flow_count": len(_flow_pending), + "catalog_size": len(REGISTRY.all()), + "configured_ids": configured_ids, + } + + +@router.get("/{connector_id}/grants") +async def get_grants(connector_id: str) -> Dict[str, Any]: + return {"grants": list_agent_grants(connector_id)} + + +@router.get("/{connector_id}") +async def get_connector(connector_id: str) -> Dict[str, Any]: + try: + return _connector_summary(connector_id) + except HTTPException: + raise + except KeyError: + raise HTTPException( + status_code=404, detail=f"Unknown connector: {connector_id!r}" + ) + except Exception as exc: + logger.warning( + "connectors-get: summary failed for %s (%s)", + connector_id, + type(exc).__name__, + ) + raise HTTPException(status_code=500, detail="Connector unavailable") + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Mutating endpoints (CSRF-guarded, plan amendment A8) +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@router.post("/{connector_id}/configure", dependencies=[Depends(_require_ui_header)]) +async def configure_connector( + connector_id: str, body: ConfigureRequest +) -> Dict[str, Any]: + """Configure a connector โ€” stores credentials and (for MCP servers) writes mcp_servers.json.""" + try: + result = await configure(connector_id, body.config) + except KeyError: + raise HTTPException( + status_code=404, detail=f"Unknown connector: {connector_id!r}" + ) + except ConnectorsError as e: + raise _raise_http_for(e) from e + + await _emitter.emit( + "connector.configured", + {"connector_id": connector_id, "account_id": result.get("account_id")}, + ) + return result + + +@router.post("/{connector_id}/test", dependencies=[Depends(_require_ui_header)]) +async def test_connector(connector_id: str) -> Dict[str, Any]: + """Run the health check for a connector.""" + try: + result = await health_check(connector_id) + except KeyError: + raise HTTPException( + status_code=404, detail=f"Unknown connector: {connector_id!r}" + ) + except ConnectorsError as e: + raise _raise_http_for(e) from e + + await _emitter.emit( + "connector.tested", + { + "connector_id": connector_id, + "ok": result.get("ok"), + "detail": result.get("detail"), + }, + ) + return result + + +@router.delete( + "/{connector_id}", status_code=204, dependencies=[Depends(_require_ui_header)] +) +async def disconnect_connector(connector_id: str) -> Response: + """Disconnect a connector โ€” removes credentials and (for MCP) removes from mcp_servers.json.""" + try: + await disconnect(connector_id) + except KeyError: + raise HTTPException( + status_code=404, detail=f"Unknown connector: {connector_id!r}" + ) + except ConnectorsError as e: + raise _raise_http_for(e) from e + + await _emitter.emit("connector.disconnected", {"connector_id": connector_id}) + return Response(status_code=204) + + +@router.post("/{connector_id}/authorize", dependencies=[Depends(_require_ui_header)]) +async def authorize(connector_id: str, body: AuthorizeRequest) -> Dict[str, Any]: + """Start an OAuth PKCE flow. Returns {flow_id, authorization_url}.""" + try: + return await connections.start_authorization(connector_id, scopes=body.scopes) + except ConnectorsError as e: + raise _raise_http_for(e) from e + + +@router.delete( + "/_flows/{flow_id}", status_code=204, dependencies=[Depends(_require_ui_header)] +) +async def cancel_flow_endpoint(flow_id: str) -> Response: + """Cancel a pending OAuth flow without waiting for the callback.""" + await connections.cancel_flow(flow_id) + return Response(status_code=204) + + +@router.put( + "/{connector_id}/grants/{agent_id:path}", dependencies=[Depends(_require_ui_header)] +) +async def put_grant( + connector_id: str, agent_id: str, body: GrantRequest +) -> Dict[str, Any]: + grant_agent(connector_id, agent_id, body.scopes) + await _emitter.emit( + "connector.grant.changed", + {"connector_id": connector_id, "agent_id": agent_id, "scopes": body.scopes}, + ) + return {"connector_id": connector_id, "agent_id": agent_id, "scopes": body.scopes} + + +@router.delete( + "/{connector_id}/grants/{agent_id:path}", + status_code=204, + dependencies=[Depends(_require_ui_header)], +) +async def delete_grant(connector_id: str, agent_id: str) -> Response: + revoke_agent_grant(connector_id, agent_id) + await _emitter.emit( + "connector.grant.changed", + {"connector_id": connector_id, "agent_id": agent_id, "scopes": []}, + ) + return Response(status_code=204) diff --git a/src/gaia/ui/routers/mcp.py b/src/gaia/ui/routers/mcp.py index 8f3e5df87..930422b47 100644 --- a/src/gaia/ui/routers/mcp.py +++ b/src/gaia/ui/routers/mcp.py @@ -6,7 +6,7 @@ import logging from typing import Any, Dict, List, Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from gaia.mcp.client.config import MCPConfig @@ -15,6 +15,13 @@ router = APIRouter(tags=["mcp"]) + +def _require_ui_header(request: Request) -> None: + """Require ``X-Gaia-UI: 1`` header as a lightweight CSRF guard (plan amendment A8).""" + if request.headers.get("x-gaia-ui") != "1": + raise HTTPException(status_code=403, detail="missing X-Gaia-UI header") + + # --------------------------------------------------------------------------- # Curated MCP server catalog (Tier 1โ€“4 popular servers) # --------------------------------------------------------------------------- @@ -301,7 +308,9 @@ async def list_mcp_servers(): return {"servers": [s.model_dump() for s in result]} -@router.post("/api/mcp/servers", status_code=201) +@router.post( + "/api/mcp/servers", status_code=201, dependencies=[Depends(_require_ui_header)] +) async def add_mcp_server(body: MCPServerCreateRequest): """Add a new MCP server configuration (persisted to ~/.gaia/mcp_servers.json).""" if not body.name or not body.name.strip(): @@ -324,7 +333,7 @@ async def add_mcp_server(body: MCPServerCreateRequest): return {"status": "added", "name": body.name} -@router.delete("/api/mcp/servers/{name}") +@router.delete("/api/mcp/servers/{name}", dependencies=[Depends(_require_ui_header)]) async def remove_mcp_server(name: str): """Remove an MCP server configuration.""" config = _load_config() @@ -336,7 +345,9 @@ async def remove_mcp_server(name: str): return {"status": "removed", "name": name} -@router.post("/api/mcp/servers/{name}/enable") +@router.post( + "/api/mcp/servers/{name}/enable", dependencies=[Depends(_require_ui_header)] +) async def enable_mcp_server(name: str): """Enable a previously disabled MCP server.""" config = _load_config() @@ -350,7 +361,9 @@ async def enable_mcp_server(name: str): return {"status": "enabled", "name": name} -@router.post("/api/mcp/servers/{name}/disable") +@router.post( + "/api/mcp/servers/{name}/disable", dependencies=[Depends(_require_ui_header)] +) async def disable_mcp_server(name: str): """Disable an MCP server without removing its configuration.""" config = _load_config() diff --git a/src/gaia/ui/server.py b/src/gaia/ui/server.py index 68a6d35ac..70c89152b 100644 --- a/src/gaia/ui/server.py +++ b/src/gaia/ui/server.py @@ -25,10 +25,11 @@ import traceback from contextlib import asynccontextmanager from pathlib import Path +from urllib.parse import urlencode from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from starlette.middleware.base import BaseHTTPMiddleware @@ -49,6 +50,7 @@ from .document_monitor import DocumentMonitor from .routers import agents as agents_router_mod from .routers import chat as chat_router_mod +from .routers import connectors as connectors_router_mod from .routers import documents as documents_router_mod from .routers import files as files_router_mod from .routers import mcp as mcp_router_mod @@ -73,17 +75,29 @@ # API paths that bypass tunnel authentication (monitoring / preflight) _AUTH_EXEMPT_PATHS = {"/api/health"} +# HttpOnly cookie name used to bootstrap tunnel auth from the QR-code URL. +# When a mobile browser opens ``https:///?token=`` the SPA +# handler (``serve_spa``) sets this cookie on the response, so the React +# app's subsequent ``fetch('/api/...')`` calls carry it automatically +# (same-origin fetches include cookies by default). +_TUNNEL_COOKIE_NAME = "gaia_tunnel_token" + # โ”€โ”€ Tunnel Auth Middleware โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ class TunnelAuthMiddleware(BaseHTTPMiddleware): - """Validate Bearer token on API requests arriving through the ngrok tunnel. + """Validate tunnel auth token on API requests arriving through the ngrok tunnel. When the tunnel is active, every ``/api/*`` request whose source is - *not* localhost must carry a valid ``Authorization: Bearer `` - header. Local requests (from the Electron desktop app) and the - ``/api/health`` monitoring endpoint are always allowed through. + *not* localhost must carry a valid token, provided via either: + + 1. ``Authorization: Bearer `` header (scriptable clients, curl) + 2. ``gaia_tunnel_token`` cookie (set by ``serve_spa`` when a mobile + browser first opens the QR-code URL containing ``?token=``) + + Local requests (from the Electron desktop app) and the ``/api/health`` + monitoring endpoint are always allowed through. """ async def dispatch(self, request: Request, call_next): @@ -102,21 +116,57 @@ async def dispatch(self, request: Request, call_next): if tunnel is None or not tunnel.active: return await call_next(request) - # Allow requests originating from localhost (Electron app) + # โ”€โ”€ Localhost bypass (Electron desktop app) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # The bypass requires BOTH the raw TCP peer to be on localhost + # AND the request to lack any ``X-Forwarded-*`` headers. The + # second clause is what makes the bypass spoof-resistant: ngrok + # always *adds* ``X-Forwarded-For`` / ``X-Forwarded-Host`` / + # ``X-Forwarded-Proto`` to tunnelled requests, so if any of those + # are present the request came in over the wire and must + # authenticate โ€” even if a remote attacker tried to set + # ``X-Forwarded-For: 127.0.0.1`` to fake a localhost source. + # + # Note: ``request.client.host`` reflects the raw TCP peer because + # the standalone runner in ``main()`` passes + # ``forwarded_allow_ips=""`` to uvicorn, disabling the proxy-header + # rewrite that would otherwise let the ``X-Forwarded-For`` value + # take precedence. client_host = request.client.host if request.client else None - if client_host in _LOCAL_HOSTS: + has_forwarded_marker = any( + h in request.headers + for h in ("x-forwarded-for", "x-forwarded-host", "x-forwarded-proto") + ) + if client_host in _LOCAL_HOSTS and not has_forwarded_marker: return await call_next(request) - # โ”€โ”€ Remote request through tunnel -- require Bearer token โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # โ”€โ”€ Remote request through tunnel -- require valid token โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Extract token from Authorization header OR cookie. + token = None auth_header = request.headers.get("authorization", "") - if not auth_header.lower().startswith("bearer "): + if auth_header.lower().startswith("bearer "): + token = auth_header[len("bearer ") :].strip() # noqa: E203 + if not token: + token = request.cookies.get(_TUNNEL_COOKIE_NAME) + + if not token: + logger.warning( + "Tunnel auth: rejecting %s %s from %s (no header/cookie)", + request.method, + path, + client_host, + ) return JSONResponse( status_code=401, content={"detail": "Missing or invalid Authorization header"}, ) - token = auth_header[len("bearer ") :].strip() # noqa: E203 if not tunnel.validate_token(token): + logger.warning( + "Tunnel auth: rejecting %s %s from %s (invalid token)", + request.method, + path, + client_host, + ) return JSONResponse( status_code=401, content={"detail": "Invalid tunnel authentication token"}, @@ -261,6 +311,27 @@ def _load_model(): await monitor.start() logger.info("Document file monitor started (30s polling interval)") + # โ”€โ”€ Connections (issue #915) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Eager tripwire sweep so a rotated OAuth client_id surfaces in + # the server logs at boot (and clears stale entries) BEFORE any + # SSE client connects. Per plan amendment A3, missing + # GAIA_GOOGLE_CLIENT_ID logs a loud warning but does NOT crash + # the lifespan โ€” chat/documents/files/tunnel/mcp routers stay + # available; only /api/connections returns 503 until the env + # var is set. + try: + from gaia.connectors.api import tripwire_check + + tripwire_check() + logger.info("connections: tripwire sweep complete") + except Exception as e: # noqa: BLE001 โ€” defense in depth + logger.warning( + "connections: tripwire sweep failed (%s); proceeding " + "without it. /api/connections endpoints may surface " + "stale-credential errors at first call instead.", + e, + ) + yield # Shutdown @@ -346,6 +417,8 @@ async def _global_exception_handler(request: Request, exc: Exception): app.include_router(files_router_mod.router) app.include_router(tunnel_router_mod.router) app.include_router(mcp_router_mod.router) + # Issue #915 โ€” OAuth connections (Settings page + agent grants). + app.include_router(connectors_router_mod.router) # โ”€โ”€ Serve Uploaded Files โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Mount the uploads directory so uploaded files can be served by URL. @@ -379,33 +452,103 @@ async def _global_exception_handler(request: Request, exc: Exception): # Prevent browsers and tunnel proxies from caching index.html so # that rebuilt assets (with new content hashes) are always picked up. # Hashed files under /assets/ are cached normally by StaticFiles. + # ``Referrer-Policy: no-referrer`` ensures that even if a token + # transiently appears in the URL (the QR-code landing path), it is + # never leaked to outbound requests via the ``Referer`` header. _NO_CACHE = { "Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0", + "Referrer-Policy": "no-referrer", } - @app.get("/{full_path:path}") - async def serve_spa(full_path: str): - """Serve the React SPA for all non-API routes.""" - # Inline path sanitization (prevents directory traversal). - # Checks are explicit so static analysis (CodeQL) can verify - # the user-controlled ``full_path`` is properly constrained. - if not full_path or "\x00" in full_path or ".." in full_path: - return FileResponse(_index_html, headers=_NO_CACHE) + def _maybe_bootstrap_tunnel_cookie(request: Request): + """Validate ``?token=`` and return a token-stripping redirect. - candidate = (_resolved_dist / full_path).resolve() + When a mobile browser first opens the QR-code URL + ``https:///?token=``, we validate the token against + the active tunnel and: - # Verify candidate stays within the dist directory - try: - candidate.relative_to(_resolved_dist) - except ValueError: - return FileResponse(_index_html, headers=_NO_CACHE) + 1. Set a ``HttpOnly``, ``SameSite=Strict``, ``Secure`` cookie so + the SPA's subsequent same-origin ``fetch('/api/...')`` calls + authenticate automatically -- no frontend token-plumbing. + 2. Redirect (303) to the same path with ``token`` stripped so the + token doesn't linger in the address bar, browser history, or + outbound ``Referer`` headers. - if candidate.is_file(): - return FileResponse(str(candidate)) + ``SameSite=Strict`` is the cookie-side defence against CSRF on + state-changing endpoints reached via the cookie path -- modern + browsers refuse to attach the cookie on any cross-site request. - # Default to index.html for SPA routing + Returns the redirect response if a cookie was bootstrapped, or + ``None`` if no token was present / valid (caller serves the + requested file normally). + """ + tunnel_mgr = getattr(request.app.state, "tunnel", None) + qs_token = request.query_params.get("token") + if not ( + tunnel_mgr is not None + and tunnel_mgr.active + and qs_token + and tunnel_mgr.validate_token(qs_token) + ): + return None + + # ngrok terminates TLS and forwards plain HTTP, so direct + # request.url.scheme is often "http". Trust X-Forwarded-Proto + # when present so the Secure flag is set on real tunnel requests. + fwd_proto = request.headers.get("x-forwarded-proto", "").lower() + is_https = request.url.scheme == "https" or fwd_proto == "https" + + # Build the redirect target: same path, all query params except + # ``token``. Preserves friendly params like ``?session=...``. + stripped_qs = urlencode( + [(k, v) for k, v in request.query_params.multi_items() if k != "token"] + ) + target = request.url.path + (f"?{stripped_qs}" if stripped_qs else "") + + redirect = RedirectResponse(url=target, status_code=303) + redirect.set_cookie( + key=_TUNNEL_COOKIE_NAME, + value=qs_token, + httponly=True, + secure=is_https, + samesite="strict", + path="/", + ) + logger.info( + "Tunnel auth: bootstrapped cookie for client %s (secure=%s, target=%s)", + request.client.host if request.client else "unknown", + is_https, + request.url.path, + ) + return redirect + + @app.get("/{full_path:path}") + async def serve_spa(request: Request, full_path: str): + """Serve the React SPA for all non-API routes.""" + # 1. Token bootstrap path: only fires for the index-html case + # (token always lands on ``/`` from the QR code). On any + # static asset path we ignore the token entirely so the + # cookie can't be planted via ``GET /favicon.png?token=...``. + # + # 2. Static asset path: use the shared ``sanitize_static_path`` + # utility -- it explicitly returns ``None`` for traversal + # attempts, so CodeQL can trace the validation through to + # the ``FileResponse`` call. + sanitized = _sanitize_static_path(_resolved_dist, full_path) + + if sanitized is not None and sanitized.is_file(): + # Static asset (JS, CSS, image) -- never bootstrap a cookie + # off this path; only the SPA index does that. + return FileResponse(str(sanitized)) + + # SPA fallback: serve index.html. Bootstrap the auth cookie + # if a valid ?token= is present (returns a 303 redirect that + # strips the token from the URL). + redirect = _maybe_bootstrap_tunnel_cookie(request) + if redirect is not None: + return redirect return FileResponse(_index_html, headers=_NO_CACHE) else: @@ -529,6 +672,17 @@ def main(): port=args.port, log_level=log_level, access_log=args.debug, # Only show HTTP access logs in debug mode + # SECURITY: do NOT trust ``X-Forwarded-For`` / ``X-Forwarded-Proto`` + # to rewrite ``request.client.host``. ngrok forwards from the + # local agent (127.0.0.1), so uvicorn's default of trusting + # forwarded headers from 127.0.0.1 would let a remote attacker + # send ``X-Forwarded-For: 127.0.0.1`` through the tunnel and + # impersonate the Electron app. The localhost-bypass check in + # ``TunnelAuthMiddleware`` separately requires the request to + # carry no ``X-Forwarded-*`` headers, giving us a spoof-resistant + # distinction between Electron-direct and ngrok-tunnelled traffic. + proxy_headers=False, + forwarded_allow_ips="", ) diff --git a/src/gaia/ui/sse_handler.py b/src/gaia/ui/sse_handler.py index b77472c0f..4e1bdd4ed 100644 --- a/src/gaia/ui/sse_handler.py +++ b/src/gaia/ui/sse_handler.py @@ -95,6 +95,8 @@ class SSEOutputHandler(OutputHandler): The streaming endpoint reads from this queue and yields SSE events. """ + blocking_confirmation = True + def __init__(self): self.event_queue: queue.Queue = queue.Queue() self.cancelled = threading.Event() @@ -735,6 +737,28 @@ def confirm_tool_execution( self._confirm_event = None return result + def print_policy_alert( + self, + tool_name: str, + decision: str, + reason: str, + rule_ids: List[str], + policy_version: str, + receipt_id: Optional[str] = None, + ) -> None: + """Emit a policy alert event for a governance-blocked tool call.""" + event: Dict[str, Any] = { + "type": "policy_alert", + "tool": tool_name, + "decision": decision, + "reason": reason, + "rule_ids": list(rule_ids), + "policy_version": policy_version, + } + if receipt_id is not None: + event["receipt_id"] = receipt_id + self._emit(event) + def resolve_tool_confirmation(self, approved: bool) -> bool: """Unblock the agent thread waiting in ``confirm_tool_execution()``. diff --git a/src/gaia/ui/tunnel.py b/src/gaia/ui/tunnel.py index 2b1f76691..323190f11 100644 --- a/src/gaia/ui/tunnel.py +++ b/src/gaia/ui/tunnel.py @@ -9,17 +9,266 @@ """ import asyncio +import hmac import logging +import os import platform +import re import shutil import subprocess import uuid from datetime import datetime, timezone +from pathlib import Path from typing import Optional logger = logging.getLogger(__name__) +# โ”€โ”€ Error helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +_NGROK_INSTALL_HINT = ( + "ngrok is not installed. Install it from https://ngrok.com/download " + "or run one of:\n" + " brew install ngrok # macOS\n" + " choco install ngrok # Windows\n" + " sudo snap install ngrok # Linux (snap)\n" + " curl -sSL https://ngrok-agent.s3.amazonaws.com/ngrok.asc | " + "sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null && " + "echo 'deb https://ngrok-agent.s3.amazonaws.com buster main' | " + "sudo tee /etc/apt/sources.list.d/ngrok.list && " + "sudo apt update && sudo apt install ngrok # Linux (apt)" +) + +_NGROK_AUTHTOKEN_HINT = ( + "ngrok authtoken not configured. Sign up for a free account at " + "https://dashboard.ngrok.com/signup, copy your authtoken from " + "https://dashboard.ngrok.com/get-started/your-authtoken, then run:\n" + " ngrok config add-authtoken " +) + +_NGROK_AUTHTOKEN_REJECTED_HINT = ( + "Your ngrok authtoken was rejected by ngrok's servers. It is usually " + "correctly formatted but invalid -- this happens if you reset it, were " + "removed from a team, or the credential was revoked. Re-copy a fresh " + "authtoken from https://dashboard.ngrok.com/get-started/your-authtoken " + "and run:\n ngrok config add-authtoken " +) + +_NGROK_SESSION_LIMIT_HINT = ( + "ngrok is already running elsewhere. Free ngrok plans allow only 1 " + "active tunnel at a time. Stop any other ngrok processes (check your " + "dashboard at https://dashboard.ngrok.com/agents) and try again." +) + + +# Patterns that match plausible ngrok authtokens or other secrets that may +# appear in ngrok's stderr/stdout (e.g. a rejected-authtoken error often +# echoes the offending value back). Replaced with ``[REDACTED]`` before any +# captured output reaches a logger or the friendly-error parser. +_NGROK_SECRET_PATTERNS = ( + # ``authtoken: `` in any quoting / case (logfmt or YAML echo). + re.compile(r"(authtoken[:=]\s*['\"]?)\S+", re.IGNORECASE), + # ngrok's modern authtokens look like ``2_`` + # โ€” long opaque strings; redact them wherever they appear. + re.compile(r"\b2[A-Za-z0-9]{20,}_[A-Za-z0-9]{20,}\b"), +) + + +def _mask_ngrok_secrets(text: str) -> str: + """Redact authtoken-shaped substrings before any captured output is logged. + + ngrok normally redacts secrets in its own logfmt output, but the + rejected-authtoken / config-parse-error paths can echo the offending + value back in stderr. ERROR-level lines often end up pasted into bug + reports verbatim โ€” we don't want a leaked authtoken to be the price of + a useful diagnostic. + """ + if not text: + return text + masked = text + for pat in _NGROK_SECRET_PATTERNS: + masked = pat.sub( + lambda m: (m.group(1) + "[REDACTED]") if m.lastindex else "[REDACTED]", + masked, + ) + return masked + + +def _ngrok_config_candidates() -> list: + """All locations where ngrok might have stashed a YAML config. + + Different ngrok versions and OS combinations pick different default + paths. We probe them all -- spurious extras are harmless. + + Observed locations: + - macOS (docs): ~/Library/Application Support/ngrok/ngrok.yml + - macOS (ngrok 3+): ~/.config/ngrok/ngrok.yml (actual behaviour, + honored by ngrok even though docs advertise the + Application Support path) + - Linux: $XDG_CONFIG_HOME/ngrok/ngrok.yml + (or ~/.config/ngrok/ngrok.yml as fallback) + - Windows: %LOCALAPPDATA%\\ngrok\\ngrok.yml + - Legacy v2: ~/.ngrok2/ngrok.yml + """ + candidates = [] + + # XDG / Linux default -- also used by ngrok 3.x on macOS in practice. + xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config") + candidates.append(Path(xdg) / "ngrok" / "ngrok.yml") + + # macOS documented path + if platform.system() == "Darwin": + candidates.append( + Path.home() / "Library" / "Application Support" / "ngrok" / "ngrok.yml" + ) + + # Windows + if platform.system() == "Windows": + local_app = os.environ.get("LOCALAPPDATA") + if local_app: + candidates.append(Path(local_app) / "ngrok" / "ngrok.yml") + + # Legacy ngrok v2 path + candidates.append(Path.home() / ".ngrok2" / "ngrok.yml") + + return candidates + + +def _check_ngrok_authtoken_configured() -> bool: + """Best-effort preflight check for an ngrok authtoken. + + Checks (in order): + 1. ``$NGROK_AUTHTOKEN`` env var -- ngrok v3 honours this directly. + 2. Every known ngrok config path for a non-empty ``authtoken:`` + entry, matching both the flat ``authtoken: xxx`` form (v2) and the + nested ``agent:\\n authtoken: xxx`` form (v3 default). + + Returns True if a token appears to be configured, False otherwise. + Used to surface a helpful error BEFORE spawning ngrok (which otherwise + just hangs or emits cryptic errors). + + A pure-text scan is intentional -- the YAML files can contain comments, + aliases, and other constructs that ``yaml.safe_load`` may choke on for + legitimate ngrok configs (it's tolerated, but we don't want a parse + error to silently disable preflight). False positives are far cheaper + here than false negatives: the worst a false positive does is let + ngrok run and emit its own (good) error message; a false negative + blocks a working setup behind a misleading hint. + """ + if (os.environ.get("NGROK_AUTHTOKEN") or "").strip(): + logger.debug("ngrok authtoken found via $NGROK_AUTHTOKEN") + return True + + for p in _ngrok_config_candidates(): + try: + if p.is_file(): + content = p.read_text(errors="ignore") + # Look for a non-empty ``authtoken:`` entry anywhere in the + # file. Matches both the v2 flat form and the v3 nested + # ``agent:\n authtoken: ...`` layout โ€” indentation doesn't + # matter once we're scanning line-by-line for the prefix. + for line in content.splitlines(): + s = line.strip() + if s.startswith("authtoken:"): + value = s[len("authtoken:") :].strip().strip("'\"") + if value: + logger.debug("ngrok authtoken found at %s", p) + return True + except Exception as e: + logger.debug("ngrok config probe failed for %s: %s", p, e) + continue + return False + + +def _parse_ngrok_error(stderr_text: str) -> str: + """Translate ngrok stderr/stdout into a user-friendly error message. + + Detects the most common failure modes (missing authtoken, session + limit reached, network issues) and returns instructions the user + can act on. Falls back to the first line of raw output if nothing + matches. + """ + text = (stderr_text or "").strip() + if not text: + return ( + "ngrok exited without output. Try running the command manually to " + "see the error: ngrok http 4200" + ) + + low = text.lower() + + # ERR_NGROK_107: authtoken is well-formed but rejected (revoked, + # reset, or belongs to a team the user was removed from). Distinct + # from "missing / malformed" below -- the fix is different. + if ( + "err_ngrok_107" in low + or "properly formed, but it is invalid" in low + or "credential was explicitly revoked" in low + or "reset your authtoken" in low + ): + return _NGROK_AUTHTOKEN_REJECTED_HINT + + # ERR_NGROK_4018 or generic authtoken issues -- malformed or missing. + if ( + "err_ngrok_4018" in low + or "authtoken" in low + or "authentication failed" in low + or "account not authorized" in low + or "not signed in" in low + ): + return _NGROK_AUTHTOKEN_HINT + + # Simultaneous session limit (ERR_NGROK_108). + if ( + "err_ngrok_108" in low + or "simultaneous ngrok" in low + or "limited to 1 simultaneous" in low + ): + return _NGROK_SESSION_LIMIT_HINT + + # Local port conflict (4040 web interface or bind address in use). + if "address already in use" in low or "bind: address already" in low: + return ( + "ngrok's local port (4040) is already in use. Another ngrok " + "process may still be running -- stop it and try again." + ) + + # Network / DNS problems. The "connection refused" branch is filtered to + # the ngrok hostname so generic "connection refused" from a local service + # doesn't get mis-attributed; the others (no such host / dial tcp / network + # unreachable) are already specific enough on their own. Word-boundary + # regex (not naked substring) so a hostile string like + # ``evil.com/tunnel.ngrok.com.attacker.tld`` can't trip the branch. + if ( + "no such host" in low + or "dial tcp" in low + or "network is unreachable" in low + or ( + "connection refused" in low + and re.search(r"(? bool: Returns: True if token matches the active tunnel's token. + + Notes: + Uses ``hmac.compare_digest`` for constant-time comparison to + avoid leaking token bits via response-time differences. Even + though the token is a 122-bit UUID4 (timing attacks aren't + practically feasible at that entropy), constant-time compare + is the convention for any auth-token check and removes the + class of bug from review. """ if not self.active or not self._token: return False - return token == self._token + if not isinstance(token, str): + return False + return hmac.compare_digest(token, self._token) async def start(self) -> dict: """Start the ngrok tunnel. @@ -114,11 +373,15 @@ async def _start_unlocked(self) -> dict: # Check ngrok installation ngrok_path = self._find_ngrok() if not ngrok_path: - self._error = ( - "ngrok is not installed. Install it from https://ngrok.com/download " - "or run: brew install ngrok (macOS) / choco install ngrok (Windows)" - ) - logger.error(self._error) + self._error = _NGROK_INSTALL_HINT + logger.error("ngrok not found on PATH") + return self.get_status() + + # Preflight: is the ngrok authtoken configured? Catches the #1 + # first-run failure mode before we waste 15s waiting on a hung tunnel. + if not _check_ngrok_authtoken_configured(): + self._error = _NGROK_AUTHTOKEN_HINT + logger.error("ngrok authtoken not configured -- aborting tunnel start") return self.get_status() # Fetch public IP (for ngrok interstitial password hint) @@ -130,10 +393,17 @@ async def _start_unlocked(self) -> dict: # Generate auth token self._token = str(uuid.uuid4()) - # Build ngrok command - cmd = [ngrok_path, "http", str(self.port)] + # Build ngrok command. --log=stdout --log-format=logfmt makes + # ngrok emit structured logs to stdout/stderr so we can surface + # meaningful errors instead of staring at a hung process. + base_args = [ + "http", + "--log=stdout", + "--log-format=logfmt", + ] if self.domain: - cmd = [ngrok_path, "http", "--domain", self.domain, str(self.port)] + base_args += ["--domain", self.domain] + cmd = [ngrok_path, *base_args, str(self.port)] logger.info("Starting ngrok: %s", " ".join(cmd)) @@ -156,19 +426,52 @@ async def _start_unlocked(self) -> dict: "Tunnel started: %s (token: %s...)", self._url, self._token[:8] ) else: - self._error = "Failed to get tunnel URL from ngrok" - logger.error(self._error) + # _poll_ngrok_api already sets self._error with a friendly + # message; keep a sensible fallback if it somehow didn't. + if not self._error: + self._error = ( + "ngrok did not open a tunnel within 15 seconds. " + "Check your internet connection and authtoken, then retry." + ) + # NOTE: not logging self._error here. The friendly message + # is already returned via get_status() and any raw stderr + # was captured at debug level by _poll_ngrok_api. Logging + # the parsed string at error level adds no diagnostic value + # and CodeQL's py/clear-text-logging-sensitive-data rule + # treats subprocess-derived strings as tainted regardless of + # masking โ€” the cheapest fix is to not double-log. + logger.error("Tunnel start failed (see status for details)") + # Preserve the diagnostic error across cleanup -- stop() + # clears _error by design (for user-initiated stops), so we + # save + restore it here so the API caller actually sees + # what went wrong. + saved_error = self._error await self.stop() + self._error = saved_error except Exception as e: + # Stringify only the exception class to avoid logging exception + # detail that may carry a token (e.g. from a subprocess error). self._error = f"Failed to start ngrok: {e}" - logger.error(self._error, exc_info=True) + logger.error( + "Failed to start ngrok (%s); see status for friendly diagnostic", + type(e).__name__, + exc_info=True, + ) + saved_error = self._error await self.stop() + self._error = saved_error return self.get_status() async def stop(self) -> None: - """Stop the ngrok tunnel.""" + """Stop the ngrok tunnel. + + Clears ``_url``, ``_started_at``, and ``_error`` by design -- a + user-initiated stop should reset all transient state. Callers + that need to preserve a diagnostic ``_error`` across ``stop()`` + (e.g. on a failed start) must save + restore it themselves. + """ if self._process: logger.info("Stopping ngrok tunnel...") try: @@ -215,7 +518,15 @@ def _find_ngrok(self) -> Optional[str]: return None async def _kill_stale_ngrok(self) -> None: - """Kill any stale ngrok processes (free tier only allows 1 session).""" + """Kill any stale ngrok processes (free tier only allows 1 session). + + Uses exact-process-name matching (``pkill -x`` / ``taskkill /im``) on + purpose: a broader ``pkill -f ngrok`` would match command lines like + ``vim ngrok.md`` or ``python ngrok_client.py``, including the user's + own unrelated work. Exact match still catches every legitimate + ``ngrok`` agent process โ€” the only thing the free-tier session-limit + cleanup actually needs to clear. + """ try: if platform.system() == "Windows": subprocess.run( @@ -226,7 +537,7 @@ async def _kill_stale_ngrok(self) -> None: ) else: subprocess.run( - ["pkill", "-f", "ngrok"], + ["pkill", "-x", "ngrok"], capture_output=True, timeout=5, check=False, @@ -250,6 +561,31 @@ async def _fetch_public_ip(self) -> None: logger.debug("Could not fetch public IP: %s", e) self._public_ip = None + def _drain_ngrok_output(self) -> str: + """Best-effort drain of ngrok's stdout+stderr for error reporting. + + Called after ngrok has exited or been terminated. Returns combined + stdout+stderr text (truncated if excessively long, and with any + plausible authtoken values masked so error logs are safe to share). + """ + combined = [] + for pipe_name in ("stdout", "stderr"): + pipe = getattr(self._process, pipe_name, None) if self._process else None + if pipe is None: + continue + try: + # Since ngrok has exited (or we just killed it), read() won't + # block -- all data is already in the kernel buffer. + raw = pipe.read() or b"" + if raw: + combined.append(raw.decode("utf-8", errors="replace")) + except Exception as e: + logger.debug("Error draining ngrok %s: %s", pipe_name, e) + text = "\n".join(combined).strip() + text = _mask_ngrok_secrets(text) + # Truncate to keep logs manageable; friendly parser takes first line. + return text[:4000] + async def _poll_ngrok_api( self, timeout: float = 15.0, interval: float = 0.5 ) -> Optional[str]: @@ -263,7 +599,8 @@ async def _poll_ngrok_api( interval: Polling interval in seconds. Returns: - The public HTTPS URL, or None if timed out. + The public HTTPS URL, or None if timed out (self._error is set + with a user-friendly message in all failure cases). """ elapsed = 0.0 while elapsed < timeout: @@ -272,20 +609,21 @@ async def _poll_ngrok_api( # Check if ngrok process died if self._process and self._process.poll() is not None: - stderr = "" - try: - # Read only a limited amount to avoid blocking the - # event loop if ngrok wrote a lot to stderr. - raw = self._process.stderr.read(4096) or b"" - stderr = raw.decode("utf-8", errors="replace") - except Exception: - pass - logger.error("ngrok process exited unexpectedly: %s", stderr) - self._error = ( - f"ngrok exited: {stderr[:200]}" - if stderr - else "ngrok exited unexpectedly" + stderr = self._drain_ngrok_output() + # We deliberately do NOT log the captured stderr content, + # even after _mask_ngrok_secrets masks plausible authtokens: + # CodeQL's py/clear-text-logging-sensitive-data rule treats + # any subprocess-pipe-derived string as tainted at every + # level (DEBUG included), and we'd rather respect the rule + # than fight it. The parsed friendly error (set below) is + # the user-facing diagnostic; for a hands-on debug session + # the operator can re-run ``ngrok http `` manually. + logger.error( + "ngrok exited after %.1fs (%d chars of output captured)", + elapsed, + len(stderr or ""), ) + self._error = _parse_ngrok_error(stderr) return None try: @@ -306,5 +644,37 @@ async def _poll_ngrok_api( # ngrok API not ready yet, keep polling pass + # Timed out. ngrok is still running but didn't open an HTTPS tunnel. + # Most likely cause: authtoken rejected by the server but the agent + # is retrying silently. Kill it, drain output, and surface a + # friendly diagnosis. logger.error("Timed out waiting for ngrok tunnel (%.1fs)", timeout) + stderr = "" + try: + if self._process and self._process.poll() is None: + self._process.terminate() + try: + self._process.wait(timeout=2) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait(timeout=2) + stderr = self._drain_ngrok_output() + # See note above: do NOT log captured stderr content (CodeQL's + # clear-text-logging rule). Length-only is a safe diagnostic. + logger.error( + "ngrok timed out (%d chars of output captured)", + len(stderr or ""), + ) + except Exception as e: + logger.debug("Error terminating timed-out ngrok: %s", e) + + if stderr: + self._error = _parse_ngrok_error(stderr) + else: + self._error = ( + "ngrok started but didn't open a public tunnel within 15s. " + "Common causes: authtoken rejected, network blocked, or " + "ngrok servers unreachable. Run 'ngrok http 4200' manually " + "to see the real error." + ) return None diff --git a/src/gaia/version.py b/src/gaia/version.py index 7bc9e8e76..065e54330 100644 --- a/src/gaia/version.py +++ b/src/gaia/version.py @@ -6,7 +6,7 @@ import subprocess from importlib.metadata import version as get_package_version_metadata -__version__ = "0.17.4" +__version__ = "0.17.6" # Lemonade version used across CI and installer LEMONADE_VERSION = "10.2.0" diff --git a/src/gaia/web/client.py b/src/gaia/web/client.py index e4cbc6cd5..bf1795b3f 100644 --- a/src/gaia/web/client.py +++ b/src/gaia/web/client.py @@ -124,19 +124,24 @@ def validate_url(self, url: str) -> str: if port and port in BLOCKED_PORTS: raise ValueError(f"Blocked port: {port}") - # Resolve and validate IP - self._validate_host_ip(hostname) + # Resolve and validate IP. Returns the pinned IP string. + return self._validate_host_ip(hostname) - return url + def _validate_host_ip(self, hostname: str) -> str: + """Resolve hostname, check IP is not private/internal, and return a + pinned IP string. - def _validate_host_ip(self, hostname: str) -> None: - """Resolve hostname and check IP is not private/internal.""" + This returns the first validated address (as a string) so callers can + pin DNS resolution during the subsequent connect. Returning the IP + avoids a TOCTOU race where the system DNS record could be re-bound + between validate and connect. + """ try: results = socket.getaddrinfo(hostname, None) except socket.gaierror: raise ValueError(f"Cannot resolve hostname: {hostname}") - for _family, _, _, _, sockaddr in results: + for _family, _socktype, _proto, _canonname, sockaddr in results: ip_str = sockaddr[0] try: ip = ipaddress.ip_address(ip_str) @@ -155,6 +160,11 @@ def _validate_host_ip(self, hostname: str) -> None: "Cannot fetch internal network addresses." ) + # First acceptable address -> return it for pinning. + return ip_str + + raise ValueError(f"No suitable address found for hostname: {hostname}") + # -- Rate Limiting ------------------------------------------------------- def _rate_limit_wait(self, domain: str) -> None: @@ -189,7 +199,11 @@ def _request(self, method: str, url: str, **kwargs) -> requests.Response: 100 GB) can't OOM the process by the time a caller touches ``response.text``. """ - self.validate_url(url) + # Validate and pin initial host IP. For each request we will patch + # `socket.getaddrinfo` to force resolution to the pinned IP we just + # validated; this prevents a DNS rebind (TOCTOU) between validation + # and connect. + _ = self.validate_url(url) domain = urlparse(url).hostname self._rate_limit_wait(domain) @@ -203,7 +217,17 @@ def _request(self, method: str, url: str, **kwargs) -> requests.Response: current_url = url for redirect_count in range(self.MAX_REDIRECTS + 1): - response = self._session.request(method, current_url, **kwargs) + # Resolve and validate the current target host (per-hop pin). + target_host = urlparse(current_url).hostname + pinned_ip = self._validate_host_ip(target_host) + # Use helper to temporarily pin DNS resolution during the + # request so we avoid a DNS rebind TOCTOU window. + response = self._with_pinned_getaddrinfo( + pinned_ip, + lambda _method=method, _url=current_url, _kwargs=kwargs: self._session.request( + _method, _url, **_kwargs + ), + ) # Pre-check declared Content-Length (still useful โ€” rejects cheap # DoS before we stream anything). @@ -307,6 +331,24 @@ def _consume_body_capped(self, response: requests.Response) -> None: response._content = b"".join(chunks) response._content_consumed = True + def _with_pinned_getaddrinfo(self, pinned_ip: str, fn, *args, **kwargs): + """Run `fn(*args, **kwargs)` while temporarily making + `socket.getaddrinfo` return addresses for `pinned_ip` only. + + This is a small, scoped monkey-patch to avoid DNS rebind TOCTOU + races when the HTTP stack performs name resolution during connect. + """ + orig_getaddrinfo = socket.getaddrinfo + + def _pinned_getaddrinfo(_host, port, *a, **kw): + return orig_getaddrinfo(pinned_ip, port, *a, **kw) + + socket.getaddrinfo = _pinned_getaddrinfo + try: + return fn(*args, **kwargs) + finally: + socket.getaddrinfo = orig_getaddrinfo + # -- HTML Parsing & Extraction ------------------------------------------- def parse_html(self, html: str) -> "BeautifulSoup": @@ -506,12 +548,17 @@ def download( domain = urlparse(url).hostname self._rate_limit_wait(domain) - # Stream the download - response = self._session.get( - url, - stream=True, - timeout=self._timeout, - allow_redirects=False, + # Stream the download. Pin the resolved IP to avoid DNS rebind + # between validation and connect. + pinned_ip = self._validate_host_ip(urlparse(url).hostname) + response = self._with_pinned_getaddrinfo( + pinned_ip, + lambda _url=url: self._session.get( + _url, + stream=True, + timeout=self._timeout, + allow_redirects=False, + ), ) # Handle redirects manually for downloads too @@ -526,11 +573,16 @@ def download( redirect_url = urljoin(url, redirect_url) self.validate_url(redirect_url) response.close() - response = self._session.get( - redirect_url, - stream=True, - timeout=self._timeout, - allow_redirects=False, + # Pin IP for the redirect target as well. + pinned_ip = self._validate_host_ip(urlparse(redirect_url).hostname) + response = self._with_pinned_getaddrinfo( + pinned_ip, + lambda _url=redirect_url: self._session.get( + _url, + stream=True, + timeout=self._timeout, + allow_redirects=False, + ), ) url = redirect_url diff --git a/tests/conftest.py b/tests/conftest.py index 42242dcd9..cca909288 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,9 @@ - api_client: HTTP client (requests.Session) configured for API testing - lemonade_available: Session-scoped fixture checking if Lemonade server is running - require_lemonade: Fixture that skips tests if Lemonade is not available +- in_memory_keyring: Session-scoped fixture installing an in-memory keyring backend + (used by tests/unit/connectors/ to avoid SecretService prerequisite on Linux CI) +- ui_api_client: Function-scoped TestClient against gaia.ui.server.create_app() Current options: - --hybrid: Run tests with hybrid configuration (cloud + local models) @@ -250,3 +253,104 @@ def api_client(api_server): ) yield session session.close() + + +# ============================================================================= +# CONNECTIONS / KEYRING FIXTURES (issue #915) +# ============================================================================= + + +def _make_in_memory_keyring(): + """ + Build an in-memory keyring backend used by connections tests. + + Imported lazily so that ``import tests.conftest`` does not require keyring + to be installed (e.g. for tests that don't need it). + + Avoids the production SecretService / Keychain / DPAPI dependency in CI + while preserving the real keyring API contract: + + - get_password() returns None for missing entries + - set_password() overwrites in place (atomic at the backend level โ€” see + A5 in the plan: this is what the single-blob store relies on) + - delete_password() raises PasswordDeleteError for missing entries + """ + import keyring.backend + import keyring.errors + + class _InMemoryKeyring(keyring.backend.KeyringBackend): + # Highest priority โ€” keyring picks the backend with the largest + # ``priority`` value, so this guarantees the test fixture wins over + # any production backend that happens to be installed. + priority = 99 + + def __init__(self): + self._store: dict[tuple[str, str], str] = {} + + def get_password(self, service, username): + return self._store.get((service, username)) + + def set_password(self, service, username, password): + self._store[(service, username)] = password + + def delete_password(self, service, username): + try: + del self._store[(service, username)] + except KeyError as e: + raise keyring.errors.PasswordDeleteError( + f"No password for {service}:{username}" + ) from e + + return _InMemoryKeyring() + + +@pytest.fixture(scope="session") +def in_memory_keyring(): + """ + Install an in-memory keyring backend for the duration of the test session. + + Use as a session-scoped dependency in connections tests. The autouse fixture + in tests/unit/connectors/conftest.py wraps this to ensure every connections + test has the in-memory backend before any gaia.connectors module is imported. + + Linux CI runners ship without SecretService, and the production-default + keyrings.alt fallback is plaintext โ€” we explicitly refuse that backend in + gaia.connectors.store. This fixture short-circuits the keyring lookup + chain to a deterministic in-memory backend that no production code uses. + + Yields: + _InMemoryKeyring: the active backend (already installed via keyring.set_keyring) + """ + import keyring + + backend = _make_in_memory_keyring() + previous = keyring.get_keyring() + keyring.set_keyring(backend) + try: + yield backend + finally: + keyring.set_keyring(previous) + + +@pytest.fixture +def ui_api_client(): + """ + TestClient bound to the in-process gaia.ui.server FastAPI app. + + Use this โ€” NOT the api_client fixture above โ€” for any test that hits a + /api/* route on the AgentUI server (port 4200 in production). api_client + targets the OpenAI-compatible server at port 8080 and will silently 404 + on UI-server routes (see plan amendment A12). + + Skips the test if the [ui] extras are not installed. + """ + try: + from starlette.testclient import TestClient + + from gaia.ui.server import create_app + except ImportError as e: + pytest.skip(f"gaia.ui not importable (install with `[ui]` extras): {e}") + + app = create_app() + with TestClient(app) as client: + yield client diff --git a/tests/electron/package.json b/tests/electron/package.json index 09bc7643b..b10be5811 100644 --- a/tests/electron/package.json +++ b/tests/electron/package.json @@ -14,6 +14,8 @@ "coverageDirectory": "coverage", "collectCoverageFrom": [ "../../src/gaia/electron/**/*.js", + "../../src/gaia/apps/webui/*.cjs", + "../../src/gaia/apps/webui/services/*.cjs", "../../src/gaia/apps/**/webui/src/**/*.js", "!../../src/gaia/apps/**/webui/src/renderer/**/*.js", "!**/node_modules/**", diff --git a/tests/electron/test_electron_chat_app.js b/tests/electron/test_electron_chat_app.js index ed26e6d7c..873b1c8e5 100644 --- a/tests/electron/test_electron_chat_app.js +++ b/tests/electron/test_electron_chat_app.js @@ -1102,12 +1102,6 @@ describe('Chat App Integration', () => { expect(chatCss).toContain('text-overflow: ellipsis'); }); - it('should have terminal block cursor tracking caret position', () => { - expect(chatCss).toContain('.input-cursor'); - expect(chatCss).toContain('position: absolute'); - expect(chatCss).toContain('pointer-events: none'); - expect(chatCss).toContain('width: 10px'); - }); }); // โ”€โ”€ MessageBubble Enhancements โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/tests/electron/test_loadapp_query.mjs b/tests/electron/test_loadapp_query.mjs index b7df0edab..f09a444d2 100644 --- a/tests/electron/test_loadapp_query.mjs +++ b/tests/electron/test_loadapp_query.mjs @@ -231,3 +231,79 @@ test("main.cjs spawn args and index query share the same backendPort", () => { "loadApp() must not contain a hardcoded API URL literal" ); }); + +// โ”€โ”€โ”€ isBootstrapping guard (issue #934 layer 3) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// +// The window-all-closed handler must check isBootstrapping so the progress +// dialog being destroyed during backend install does not fire a premature +// app.quit() before createWindow() runs. If someone removes the guard or +// moves `isBootstrapping = false` earlier, the timing race silently returns. + +test("window-all-closed handler checks isBootstrapping (issue #934 layer 3)", () => { + const src = fs.readFileSync(mainCjsPath, "utf8"); + const handlerMatch = src.match( + /app\.on\("window-all-closed",\s*\(\)\s*=>\s*\{([\s\S]*?)\n\s*\}\)/ + ); + assert.ok(handlerMatch, "window-all-closed handler must exist in main.cjs"); + assert.match( + handlerMatch[1], + /isBootstrapping/, + "window-all-closed must check isBootstrapping (issue #934 layer 3)" + ); +}); + +test("isBootstrapping is set false after createWindow() runs", () => { + const src = fs.readFileSync(mainCjsPath, "utf8"); + // The assignment must appear AFTER the createWindow() call so the guard + // is only lifted once the main window exists. + const createWindowIdx = src.indexOf("createWindow()"); + assert.ok(createWindowIdx !== -1, "main.cjs must call createWindow()"); + const afterCreate = src.slice(createWindowIdx); + assert.match( + afterCreate, + /isBootstrapping\s*=\s*false/, + "isBootstrapping must be set false after createWindow() (issue #934 layer 3)" + ); +}); + +// โ”€โ”€โ”€ pathToFileURL: forward-slash contract (issue #934 layer 2) โ”€โ”€โ”€โ”€โ”€โ”€ +// +// Chromium 130+ (Electron 40) rejects backslash file URLs that +// url.format() / loadFile() produced on Windows. pathToFileURL() must +// always emit forward-slash URLs regardless of platform. + +import { pathToFileURL } from "node:url"; + +test("pathToFileURL emits forward-slash URL for Windows-style path", () => { + // Simulate the path that loadApp() constructs on Windows. + const winPath = "C:\\Users\\user\\AppData\\Local\\GAIA\\dist\\index.html"; + const href = pathToFileURL(winPath).href; + assert.ok(href.startsWith("file:///"), `must start with file:/// โ€” got ${href}`); + assert.ok(!href.includes("\\"), `must contain no backslashes โ€” got ${href}`); +}); + +test("pathToFileURL emits forward-slash URL for POSIX path", () => { + const posixPath = "/home/user/.local/share/GAIA/dist/index.html"; + const href = pathToFileURL(posixPath).href; + assert.ok(href.startsWith("file:///"), `must start with file:/// โ€” got ${href}`); + assert.ok(!href.includes("\\"), `must contain no backslashes โ€” got ${href}`); +}); + +test("main.cjs uses pathToFileURL (not loadFile) to load the frontend", () => { + // Regression guard: if someone switches back to loadFile(), the Windows + // backslash bug (#934 layer 2) will silently reappear. Pin the call site. + const src = fs.readFileSync(mainCjsPath, "utf8"); + assert.match( + src, + /pathToFileURL/, + "main.cjs must call pathToFileURL() to build the file:// URL" + ); + // loadFile() must not be used for the main app window navigation. + const loadAppMatch = src.match(/async function loadApp\(\)\s*{([\s\S]*?)\n}/); + assert.ok(loadAppMatch, "main.cjs must declare loadApp()"); + assert.doesNotMatch( + loadAppMatch[1], + /\.loadFile\(/, + "loadApp() must not use loadFile() โ€” use loadURL(pathToFileURL(...))" + ); +}); diff --git a/tests/electron/test_main_error_handling.js b/tests/electron/test_main_error_handling.js new file mode 100644 index 000000000..e458b0c24 --- /dev/null +++ b/tests/electron/test_main_error_handling.js @@ -0,0 +1,378 @@ +// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +// SPDX-License-Identifier: MIT + +/** + * Tests for main-safety-net.cjs โ€” top-level Electron main-process error handling. + * + * Root cause documented in issue #934: installMainLogTee()'s write stream emits + * 'error' events asynchronously (not synchronous throws), so the wrap() try/catch + * doesn't catch ERR_STREAM_WRITE_AFTER_END. Without a process.on('uncaughtException') + * handler, this shows Electron's bare "A JavaScript error occurred" dialog. + * + * Tests are hermetic: all I/O is in a tmp directory; dialog and app are injected. + * Tests import main-safety-net.cjs directly (no main.cjs side effects). + */ + +"use strict"; + +const path = require("path"); +const fs = require("fs"); +const os = require("os"); +const { EventEmitter } = require("events"); + +// โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Create an isolated tmp directory for this test run. */ +function makeTmpDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gaia-934-test-")); + return dir; +} + +/** Build a fresh mock dialog module. */ +function mockDialog() { + return { + showErrorBox: jest.fn(), + showMessageBoxSync: jest.fn(() => 0), + }; +} + +/** Build a mock app module with controllable isReady(). */ +function mockApp(isReady = false) { + const emitter = new EventEmitter(); + emitter.isReady = jest.fn(() => isReady); + return emitter; +} + +// โ”€โ”€ Module under test โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// +// This require MUST stay here (not inside beforeEach) so Jest's module cache +// can be cleared between tests that change process.on listener state. +// Each test that needs isolation calls jest.resetModules() + re-requires. + +const SAFETY_NET_PATH = "../../src/gaia/apps/webui/main-safety-net.cjs"; + +// โ”€โ”€ Test suite โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +describe("installSafetyNet", () => { + let tmpDir; + let logPath; + let addedListeners; + + beforeEach(() => { + jest.resetModules(); + tmpDir = makeTmpDir(); + logPath = path.join(tmpDir, "electron-main.log"); + + // Track listeners added so we can remove them after each test. + addedListeners = []; + const origOn = process.on.bind(process); + jest.spyOn(process, "on").mockImplementation((event, handler) => { + addedListeners.push({ event, handler }); + origOn(event, handler); + }); + }); + + afterEach(() => { + // Remove any listeners installed by installSafetyNet to avoid cross-test leakage. + addedListeners.forEach(({ event, handler }) => { + process.removeListener(event, handler); + }); + jest.restoreAllMocks(); + // Clean up tmp dir. + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + // โ”€โ”€ Test 1: wires uncaughtException โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + test("registers uncaughtException listener", () => { + const { installSafetyNet } = require(SAFETY_NET_PATH); + const dialog = mockDialog(); + const app = mockApp(false); + + installSafetyNet({ logPath, dialogModule: dialog, appModule: app }); + + const events = addedListeners.map((l) => l.event); + expect(events).toContain("uncaughtException"); + }); + + // โ”€โ”€ Test 2: wires unhandledRejection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + test("registers unhandledRejection listener", () => { + const { installSafetyNet } = require(SAFETY_NET_PATH); + const dialog = mockDialog(); + const app = mockApp(false); + + installSafetyNet({ logPath, dialogModule: dialog, appModule: app }); + + const events = addedListeners.map((l) => l.event); + expect(events).toContain("unhandledRejection"); + }); + + // โ”€โ”€ Test 3: re-entry guard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // fatal() must not recurse if it is re-invoked while already running. + // We trigger genuine re-entry by emitting a second uncaughtException from + // inside showErrorBox โ€” at that point _inFatalHandler is true, so the + // second invocation must call process.exit(2) without touching the dialog. + + test("re-entry guard prevents recursive dialog on second call", () => { + const { installSafetyNet } = require(SAFETY_NET_PATH); + const dialog = mockDialog(); + const app = mockApp(false); + const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => {}); + + installSafetyNet({ logPath, dialogModule: dialog, appModule: app }); + + // Trigger re-entry: the first showErrorBox call emits a second + // uncaughtException synchronously while _inFatalHandler is still true. + dialog.showErrorBox.mockImplementationOnce(() => { + process.emit("uncaughtException", new Error("re-entrant error")); + }); + + process.emit("uncaughtException", new Error("original error")); + + // showErrorBox called exactly once โ€” re-entrant call bailed before dialog. + expect(dialog.showErrorBox).toHaveBeenCalledTimes(1); + // process.exit called with 2 for the re-entrant bail, then 1 for the outer. + expect(exitSpy).toHaveBeenCalledWith(2); + + exitSpy.mockRestore(); + }); + + // โ”€โ”€ Test 4: showErrorBox used when app is NOT ready โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Pre-app.ready on Windows, showMessageBoxSync silently no-ops; + // showErrorBox must be used in that window. + + test("uses showErrorBox (not showMessageBoxSync) when app.isReady() is false", () => { + const { installSafetyNet } = require(SAFETY_NET_PATH); + const dialog = mockDialog(); + const app = mockApp(false); // NOT ready + const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => {}); + + installSafetyNet({ logPath, dialogModule: dialog, appModule: app }); + process.emit("uncaughtException", new Error("pre-ready crash")); + + expect(dialog.showErrorBox).toHaveBeenCalledTimes(1); + expect(dialog.showMessageBoxSync).not.toHaveBeenCalled(); + + exitSpy.mockRestore(); + }); + + // โ”€โ”€ Test 5: showMessageBoxSync used when app IS ready โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // After app.ready fires, the full dialog with action buttons should appear. + + test("uses showMessageBoxSync when app.isReady() is true", () => { + const { installSafetyNet } = require(SAFETY_NET_PATH); + const dialog = mockDialog(); + const app = mockApp(true); // ready + const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => {}); + + installSafetyNet({ logPath, dialogModule: dialog, appModule: app }); + process.emit("uncaughtException", new Error("post-ready crash")); + + expect(dialog.showMessageBoxSync).toHaveBeenCalledTimes(1); + + exitSpy.mockRestore(); + }); + + // โ”€โ”€ Test 6: crash-loop counter increments โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Each fatal call increments the counter in the startup-failures JSON file. + + test("crash-loop counter increments on each fatal", () => { + jest.resetModules(); + const { installSafetyNet } = require(SAFETY_NET_PATH); + const dialog = mockDialog(); + const app = mockApp(false); + const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => {}); + const counterPath = path.join(tmpDir, ".gaia", "electron-startup-failures.json"); + + installSafetyNet({ + logPath, + dialogModule: dialog, + appModule: app, + homedirFn: () => tmpDir, + }); + + process.emit("uncaughtException", new Error("crash 1")); + const after1 = JSON.parse(fs.readFileSync(counterPath, "utf8")); + expect(after1.count).toBe(1); + + // Remove instance 1's listeners so instance 2's fatal() runs cleanly + // without relying on instance 1's _inFatalHandler being stuck true. + addedListeners.forEach(({ event, handler }) => process.removeListener(event, handler)); + addedListeners.length = 0; + + jest.resetModules(); + const { installSafetyNet: installSafetyNet2 } = require(SAFETY_NET_PATH); + installSafetyNet2({ + logPath, + dialogModule: dialog, + appModule: app, + homedirFn: () => tmpDir, + }); + + process.emit("uncaughtException", new Error("crash 2")); + const after2 = JSON.parse(fs.readFileSync(counterPath, "utf8")); + expect(after2.count).toBe(2); + + exitSpy.mockRestore(); + }); + + // โ”€โ”€ Test 7: counter resets on browser-window-focus (NOT after loadApp) โ”€โ”€โ”€โ”€โ”€ + // Resetting after loadApp() is too early โ€” the user may crash before their + // first interaction. Reset must happen on 'browser-window-focus' instead. + + test("crash-loop counter resets on browser-window-focus, not on module load", () => { + const { installSafetyNet } = require(SAFETY_NET_PATH); + const dialog = mockDialog(); + const app = mockApp(false); + const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => {}); + const gaiaDir = path.join(tmpDir, ".gaia"); + const counterPath = path.join(gaiaDir, "electron-startup-failures.json"); + + // Seed an existing count of 2. + fs.mkdirSync(gaiaDir, { recursive: true }); + fs.writeFileSync(counterPath, JSON.stringify({ count: 2 })); + + installSafetyNet({ + logPath, + dialogModule: dialog, + appModule: app, + homedirFn: () => tmpDir, + }); + + // Counter should NOT reset on install alone. + const afterInstall = JSON.parse(fs.readFileSync(counterPath, "utf8")); + expect(afterInstall.count).toBe(2); + + // Counter MUST reset when 'browser-window-focus' fires on app. + app.emit("browser-window-focus"); + const afterFocus = JSON.parse(fs.readFileSync(counterPath, "utf8")); + expect(afterFocus.count).toBe(0); + + exitSpy.mockRestore(); + }); + + // โ”€โ”€ Test 8: render-process-gone handler installed โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Renderer crashes don't fire uncaughtException; they fire + // app.on('render-process-gone'). Must be routed through fatal handler. + + test("installs render-process-gone handler on app", () => { + const { installSafetyNet } = require(SAFETY_NET_PATH); + const dialog = mockDialog(); + const app = mockApp(false); + const onSpy = jest.spyOn(app, "on"); + const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => {}); + + installSafetyNet({ logPath, dialogModule: dialog, appModule: app }); + + const registeredEvents = onSpy.mock.calls.map(([evt]) => evt); + expect(registeredEvents).toContain("render-process-gone"); + + exitSpy.mockRestore(); + }); + + // โ”€โ”€ Test 9: child-process-gone handler installed โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // GPU-process crashes fire app.on('child-process-gone'), not uncaughtException. + + test("installs child-process-gone handler on app", () => { + const { installSafetyNet } = require(SAFETY_NET_PATH); + const dialog = mockDialog(); + const app = mockApp(false); + const onSpy = jest.spyOn(app, "on"); + const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => {}); + + installSafetyNet({ logPath, dialogModule: dialog, appModule: app }); + + const registeredEvents = onSpy.mock.calls.map(([evt]) => evt); + expect(registeredEvents).toContain("child-process-gone"); + + exitSpy.mockRestore(); + }); + + // โ”€โ”€ Test 10: fatal handler writes to log before showing dialog โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // If dialog.showErrorBox itself crashes, the log must already have the entry. + + test("writes FATAL line to logPath before calling dialog", () => { + const { installSafetyNet } = require(SAFETY_NET_PATH); + const dialog = mockDialog(); + let logWritten = false; + dialog.showErrorBox.mockImplementation(() => { + // At the moment showErrorBox is called, the log must already be written. + logWritten = fs.existsSync(logPath) && + fs.readFileSync(logPath, "utf8").includes("FATAL"); + }); + const app = mockApp(false); + const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => {}); + + installSafetyNet({ logPath, dialogModule: dialog, appModule: app }); + process.emit("uncaughtException", new Error("test fatal")); + + expect(logWritten).toBe(true); + + exitSpy.mockRestore(); + }); + + // โ”€โ”€ Test 11: log tee stream gets error listener (root cause fix) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // The #934 root cause: installMainLogTee()'s stream.write() emits 'error' + // asynchronously; the try/catch in wrap() doesn't catch it. A stream 'error' + // listener prevents ERR_STREAM_WRITE_AFTER_END from becoming uncaughtException. + + test("installLogTee attaches an error listener to the write stream", () => { + const { installLogTee } = require(SAFETY_NET_PATH); + expect(typeof installLogTee).toBe("function"); + const mockStream = new EventEmitter(); + mockStream.write = jest.fn(); + mockStream.end = jest.fn(); + + installLogTee({ stream: mockStream, logPath }); + + // The stream must have at least one 'error' listener so errors don't + // become uncaughtException. + expect(mockStream.listenerCount("error")).toBeGreaterThan(0); + + // The listener must actually write to logPath โ€” this is the root-cause fix + // for #934, not just its prerequisite. + mockStream.emit("error", new Error("boom")); + const logContent = fs.readFileSync(logPath, "utf8"); + expect(logContent).toMatch(/STREAM_ERROR/); + expect(logContent).toMatch(/boom/); + }); + + // โ”€โ”€ Test 12: unhandledRejection wraps non-Error reasons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // process.emit('unhandledRejection', "string") must not crash the handler. + + test("unhandledRejection handler coerces non-Error reason to Error", () => { + const { installSafetyNet } = require(SAFETY_NET_PATH); + const dialog = mockDialog(); + const app = mockApp(false); + const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => {}); + + installSafetyNet({ logPath, dialogModule: dialog, appModule: app }); + + // Emit with a plain string (not an Error instance). + expect(() => { + process.emit("unhandledRejection", "plain string rejection"); + }).not.toThrow(); + + expect(dialog.showErrorBox).toHaveBeenCalledTimes(1); + const [, detail] = dialog.showErrorBox.mock.calls[0]; + expect(detail).toContain("plain string rejection"); + + exitSpy.mockRestore(); + }); + + // โ”€โ”€ Test 13: installSafetyNet returns { fatal } โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // main.cjs destructures { fatal: _fatalHandler } and routes + // app.whenReady().catch() through it. If a refactor stops returning fatal, + // _fatalHandler becomes undefined and the catch silently no-ops. + + test("returns { fatal } function so main.cjs can route .catch() through it", () => { + const { installSafetyNet } = require(SAFETY_NET_PATH); + const result = installSafetyNet({ + logPath, + dialogModule: mockDialog(), + appModule: mockApp(false), + }); + expect(typeof result.fatal).toBe("function"); + }); +}); diff --git a/tests/integration/test_governed_agent_workflow.py b/tests/integration/test_governed_agent_workflow.py new file mode 100644 index 000000000..357e394c7 --- /dev/null +++ b/tests/integration/test_governed_agent_workflow.py @@ -0,0 +1,163 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# pylint: disable=protected-access +"""Integration test for GovernedAgentMixin + GaiaGovernanceAdapter. + +Uses a minimal fake base agent so the test does not depend on Lemonade +or MCP. The goal is to prove that: + +1. Tool execution flows through the mixin unchanged when no adapter is set. +2. An adapter with a BLOCK rule short-circuits tool execution. +3. An ALLOW decision passes through to the underlying tool. +4. The governance callback receives the decision. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from gaia.governance import ( + GaiaGovernanceAdapter, + GovernedAgentMixin, +) +from gaia.governance.checkpoint_bridge import InMemoryCheckpointBridge +from gaia.governance.policy_binding import StaticPolicyBindingService +from gaia.governance.receipt_service import InMemoryReceiptService +from gaia.governance.stubs import RuleBasedPolicyEngine + + +class _FakeAgent: + """Stand-in for gaia.Agent that records tool invocations. + + The mixin's contract is purely that ``super()._execute_tool`` exists + and returns whatever the tool returns. This fake honors that contract + without pulling the full Agent runtime into the test. + """ + + def __init__(self, **_: Any) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + + def _execute_tool(self, tool_name: str, tool_args: dict[str, Any]) -> Any: + self.calls.append((tool_name, dict(tool_args))) + return {"status": "ok", "tool": tool_name, "args": tool_args} + + +class _GovernedFakeAgent(GovernedAgentMixin, _FakeAgent): + pass + + +def _adapter() -> GaiaGovernanceAdapter: + return GaiaGovernanceAdapter( + policy_engine=RuleBasedPolicyEngine(), + checkpoint_runtime=InMemoryCheckpointBridge(), + receipt_service=InMemoryReceiptService(), + policy_binding=StaticPolicyBindingService(), + ) + + +def test_no_adapter_is_pure_pass_through(): + agent = _GovernedFakeAgent() + result = agent._execute_tool("get_weather", {"city": "Austin"}) + assert result["status"] == "ok" + assert agent.calls == [("get_weather", {"city": "Austin"})] + + +def test_adapter_with_allow_decision_executes_tool(): + seen: list[str] = [] + agent = _GovernedFakeAgent( + governance_adapter=_adapter(), + governance_actor_id="tester", + governance_risk_tags={}, # nothing tagged -> ALLOW + governance_callback=lambda tn, *_: seen.append(tn), + ) + result = agent._execute_tool("get_weather", {"city": "Austin"}) + assert result["status"] == "ok" + assert agent.calls == [("get_weather", {"city": "Austin"})] + assert seen == ["get_weather"] + + +def test_adapter_with_block_decision_short_circuits(): + decisions: list[str] = [] + + def cb(_tn, _args, _action, decision): + decisions.append(decision.decision) + + agent = _GovernedFakeAgent( + governance_adapter=_adapter(), + governance_risk_tags={"drop_table": ["blocked"]}, + governance_callback=cb, + ) + result = agent._execute_tool("drop_table", {"name": "users"}) + assert result["status"] == "denied" + assert result["governance_decision"] == "BLOCK" + assert "blocked by governance" in result["error"] + # tool was NOT invoked on the underlying agent + assert agent.calls == [] + assert decisions == ["BLOCK"] + + +def test_review_decision_without_reviewer_fails_closed(): + decisions: list[str] = [] + + def cb(_tn, _args, _action, decision): + decisions.append(decision.decision) + + agent = _GovernedFakeAgent( + governance_adapter=_adapter(), + governance_risk_tags={"publish_post": ["review"]}, + governance_callback=cb, + ) + result = agent._execute_tool("publish_post", {"body": "hi"}) + # No reviewer + no console -> REVIEW fails closed. + assert result["status"] == "denied" + assert result["governance_decision"] == "REVIEW_REJECTED" + assert agent.calls == [] + # Callback still sees the original REVIEW decision. + assert decisions == ["REVIEW"] + + +def test_callback_exception_does_not_break_execution(caplog): + def boom(*_a, **_kw): + raise RuntimeError("callback exploded") + + agent = _GovernedFakeAgent( + governance_adapter=_adapter(), + governance_callback=boom, + ) + caplog.set_level(logging.WARNING, "gaia.governance.mixin") + result = agent._execute_tool("get_weather", {"city": "Austin"}) + assert result["status"] == "ok" + # The exception was swallowed but a warning was logged so an operator + # can detect a misbehaving callback. Don't assert the message string โ€” + # just that something was warned about the mixin. + assert any( + record.levelname == "WARNING" and record.name == "gaia.governance.mixin" + for record in caplog.records + ) + + +def test_unknown_transition_outcome_fails_closed(): + """Defensive: if a custom CheckpointRuntime returns an outcome status + the mixin doesn't recognize, deny the call rather than letting it + silently pass through. + """ + + class _BogusOutcomeAdapter(GaiaGovernanceAdapter): + def handle_transition(self, transition, decision): + from gaia.governance.schemas import TransitionOutcome + + return TransitionOutcome(status="WAT", reason="from outer space") + + adapter = _BogusOutcomeAdapter( + policy_engine=RuleBasedPolicyEngine(), + checkpoint_runtime=InMemoryCheckpointBridge(), + receipt_service=InMemoryReceiptService(), + policy_binding=StaticPolicyBindingService(), + ) + agent = _GovernedFakeAgent(governance_adapter=adapter) + result = agent._execute_tool("get_weather", {"city": "Austin"}) + assert result["status"] == "denied" + assert result["governance_decision"] == "ERROR" + assert "unknown transition outcome" in result["error"] + assert agent.calls == [] diff --git a/tests/integration/test_governed_canonical_name.py b/tests/integration/test_governed_canonical_name.py new file mode 100644 index 000000000..4fe5e48da --- /dev/null +++ b/tests/integration/test_governed_canonical_name.py @@ -0,0 +1,152 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# pylint: disable=protected-access +"""HIGH-2 regression: canonical tool name resolution before governance. + +If governance checks risk tags against the raw LLM-supplied name, a +model can bypass a blocked MCP tool by calling the unprefixed alias +(``get_current_time`` instead of ``mcp_time_get_current_time``). The +mixin must resolve through the base Agent's ``_resolve_tool_name`` +before building the ActionRequest. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from gaia.governance import GaiaGovernanceAdapter, GovernedAgentMixin +from gaia.governance.checkpoint_bridge import InMemoryCheckpointBridge +from gaia.governance.policy_binding import StaticPolicyBindingService +from gaia.governance.receipt_service import InMemoryReceiptService +from gaia.governance.stubs import RuleBasedPolicyEngine + + +class _FakeAgentWithResolver: + """Stand-in that mirrors GAIA's alias-resolution behavior.""" + + ALIAS_MAP = {"get_current_time": "mcp_time_get_current_time"} + + def __init__(self, **_: Any) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + + def _resolve_tool_name(self, tool_name: str) -> str | None: + return self.ALIAS_MAP.get(tool_name) + + def _execute_tool(self, tool_name: str, tool_args: dict[str, Any]) -> Any: + # Mirror base Agent: resolve alias internally before running + canonical = self.ALIAS_MAP.get(tool_name, tool_name) + self.calls.append((canonical, dict(tool_args))) + return {"status": "ok", "tool": canonical} + + +class _GovernedFakeWithResolver(GovernedAgentMixin, _FakeAgentWithResolver): + pass + + +def _adapter(): + return GaiaGovernanceAdapter( + policy_engine=RuleBasedPolicyEngine(), + checkpoint_runtime=InMemoryCheckpointBridge(), + receipt_service=InMemoryReceiptService(), + policy_binding=StaticPolicyBindingService(), + ) + + +def test_unprefixed_alias_is_governed_under_canonical_name(): + agent = _GovernedFakeWithResolver( + governance_adapter=_adapter(), + governance_risk_tags={"mcp_time_get_current_time": ["blocked"]}, + ) + # LLM calls the unprefixed alias; governance must still block. + result = agent._execute_tool("get_current_time", {}) + assert result["status"] == "denied" + assert result["governance_decision"] == "BLOCK" + assert agent.calls == [] + + +def test_raw_name_still_governed_directly(): + agent = _GovernedFakeWithResolver( + governance_adapter=_adapter(), + governance_risk_tags={"mcp_time_get_current_time": ["blocked"]}, + ) + result = agent._execute_tool("mcp_time_get_current_time", {}) + assert result["status"] == "denied" + assert agent.calls == [] + + +def test_unresolved_name_falls_through_to_raw(): + # A tool with no alias mapping must still be governable by its + # own name. + agent = _GovernedFakeWithResolver( + governance_adapter=_adapter(), + governance_risk_tags={"never_heard_of_it": ["blocked"]}, + ) + result = agent._execute_tool("never_heard_of_it", {}) + assert result["status"] == "denied" + + +class _FakeAgentResolverLookupError(_FakeAgentWithResolver): + """Resolver raises LookupError โ€” the expected 'not in registry' case. + + The mixin must absorb this silently and govern the raw name. No + warning should be logged because the absence is a normal condition. + """ + + def _resolve_tool_name(self, _tool_name): + raise LookupError("tool not registered") + + +class _GovernedLookupErrorAgent(GovernedAgentMixin, _FakeAgentResolverLookupError): + pass + + +def test_resolver_lookup_error_is_silent_and_governs_raw_name(caplog): + agent = _GovernedLookupErrorAgent( + governance_adapter=_adapter(), + governance_risk_tags={"raw_name": ["blocked"]}, + ) + caplog.set_level(logging.WARNING, "gaia.governance.mixin") + result = agent._execute_tool("raw_name", {}) + # Raw-name governance still works. + assert result["status"] == "denied" + assert result["governance_decision"] == "BLOCK" + # Expected miss: no operator-visible warning. + assert not any( + record.name == "gaia.governance.mixin" and record.levelname == "WARNING" + for record in caplog.records + ) + + +class _FakeAgentResolverBoom(_FakeAgentWithResolver): + """Resolver raises an unexpected RuntimeError (programming bug). + + The mixin must (1) log a warning so operators can see the bug, + (2) fall back to the raw name so governance still applies, and + (3) NOT crash the tool call. + """ + + def _resolve_tool_name(self, _tool_name): + raise RuntimeError("resolver implementation bug") + + +class _GovernedBoomAgent(GovernedAgentMixin, _FakeAgentResolverBoom): + pass + + +def test_resolver_unexpected_exception_logs_and_governs_raw_name(caplog): + agent = _GovernedBoomAgent( + governance_adapter=_adapter(), + governance_risk_tags={"raw_name": ["blocked"]}, + ) + caplog.set_level(logging.WARNING, "gaia.governance.mixin") + result = agent._execute_tool("raw_name", {}) + # Raw-name governance still applies โ€” bug in resolver does not bypass. + assert result["status"] == "denied" + assert result["governance_decision"] == "BLOCK" + # Operator-visible warning: a future regression that swaps the + # logger.warning for a silent fallback would fail this assertion. + assert any( + record.name == "gaia.governance.mixin" and record.levelname == "WARNING" + for record in caplog.records + ) diff --git a/tests/integration/test_governed_real_agent.py b/tests/integration/test_governed_real_agent.py new file mode 100644 index 000000000..84509a897 --- /dev/null +++ b/tests/integration/test_governed_real_agent.py @@ -0,0 +1,113 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# pylint: disable=protected-access +"""Integration test: GovernedAgentMixin against the real gaia.Agent class. + +The full ``Agent.__init__`` starts Lemonade / MCP, which we don't want +to depend on in a unit-level gate. This test proves the mixin's MRO +binds correctly against the real class by: + +1. Building a ``GovernedAgentMixin + gaia.Agent`` subclass. +2. Instantiating via ``__new__`` and setting only the state + ``_execute_tool`` actually reads (``console`` for confirmation gate, + the governance state attributes). +3. Registering a real ``@tool`` and calling ``_execute_tool`` through + the mixin, verifying BLOCK short-circuits and ALLOW reaches the tool. + +If this test ever breaks, the mixin's contract with the real Agent has +regressed โ€” long before anyone runs the full interactive demo. +""" + +from __future__ import annotations + +from gaia import Agent, tool +from gaia.governance import ( + GaiaGovernanceAdapter, + GovernedAgentMixin, +) +from gaia.governance.checkpoint_bridge import InMemoryCheckpointBridge +from gaia.governance.policy_binding import StaticPolicyBindingService +from gaia.governance.receipt_service import InMemoryReceiptService +from gaia.governance.stubs import RuleBasedPolicyEngine + + +@tool +def _governed_real_agent_probe(x: int = 1) -> dict: + """Minimal tool used only by this test.""" + return {"status": "ok", "x": x} + + +class _StubConsole: + """Minimal console stand-in to satisfy the confirmation gate path.""" + + def confirm_tool_execution(self, _tool_name, _tool_args): + return True + + +class _GovernedRealAgent(GovernedAgentMixin, Agent): + """Real Agent subclass with the governance mixin mixed in.""" + + def _register_tools(self) -> None: + # Abstract on Agent; no-op here because we bypass __init__ and + # rely on the module-level tool registry populated by @tool. + return None + + def _get_system_prompt(self) -> str: # pragma: no cover - unused + return "" + + +def _build_agent(adapter: GaiaGovernanceAdapter | None, risk_tags: dict): + """Build a _GovernedRealAgent bypassing __init__ (no Lemonade/MCP).""" + agent = _GovernedRealAgent.__new__(_GovernedRealAgent) + # Governance state that the mixin reads. + agent.governance_adapter = adapter + agent._governance_actor_id = "real-agent-test" + agent._governance_workflow_id = "wf_real" + agent._governance_risk_tags = risk_tags + agent._governance_callback = None + # Minimal Agent state touched by _execute_tool. + agent.console = _StubConsole() + agent.error_history = [] + agent._current_query = None + agent.current_plan = None + agent.current_step = 0 + agent.total_plan_steps = 0 + return agent + + +def _adapter() -> GaiaGovernanceAdapter: + return GaiaGovernanceAdapter( + policy_engine=RuleBasedPolicyEngine(), + checkpoint_runtime=InMemoryCheckpointBridge(), + receipt_service=InMemoryReceiptService(), + policy_binding=StaticPolicyBindingService(), + ) + + +def test_mro_places_mixin_before_agent(): + mro = _GovernedRealAgent.__mro__ + names = [c.__name__ for c in mro] + assert names.index("GovernedAgentMixin") < names.index("Agent") + + +def test_mixin_passes_through_to_real_agent_when_no_adapter(): + agent = _build_agent(adapter=None, risk_tags={}) + result = agent._execute_tool("_governed_real_agent_probe", {"x": 7}) + assert result == {"status": "ok", "x": 7} + + +def test_block_decision_short_circuits_real_agent(): + agent = _build_agent( + adapter=_adapter(), + risk_tags={"_governed_real_agent_probe": ["blocked"]}, + ) + result = agent._execute_tool("_governed_real_agent_probe", {"x": 9}) + assert result["status"] == "denied" + assert result["governance_decision"] == "BLOCK" + assert "blocked by governance" in result["error"] + + +def test_allow_decision_reaches_real_tool_registry(): + agent = _build_agent(adapter=_adapter(), risk_tags={}) # no tags -> ALLOW + result = agent._execute_tool("_governed_real_agent_probe", {"x": 42}) + assert result == {"status": "ok", "x": 42} diff --git a/tests/integration/test_governed_review_flow.py b/tests/integration/test_governed_review_flow.py new file mode 100644 index 000000000..23e800843 --- /dev/null +++ b/tests/integration/test_governed_review_flow.py @@ -0,0 +1,306 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# pylint: disable=protected-access,attribute-defined-outside-init +"""Integration test for the REVIEW checkpoint flow. + +Proves that when a policy returns REVIEW, the mixin opens a checkpoint, +asks a reviewer, records a receipt for the resolution, and either runs +or denies the tool based on the reviewer's response. +""" + +from __future__ import annotations + +import logging +import threading +import time +from typing import Any + +from gaia.governance import ( + GaiaGovernanceAdapter, + GovernedAgentMixin, +) +from gaia.governance.checkpoint_bridge import InMemoryCheckpointBridge +from gaia.governance.policy_binding import StaticPolicyBindingService +from gaia.governance.receipt_service import InMemoryReceiptService +from gaia.governance.stubs import RuleBasedPolicyEngine +from gaia.ui.sse_handler import SSEOutputHandler + + +class _FakeAgent: + def __init__(self, **_: Any) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + + def _execute_tool(self, tool_name: str, tool_args: dict[str, Any]) -> Any: + self.calls.append((tool_name, dict(tool_args))) + return {"status": "ok", "tool": tool_name} + + +class _GovernedFakeAgent(GovernedAgentMixin, _FakeAgent): + pass + + +class _StubConsoleAccept: + """Represents a console that WOULD approve โ€” but must not be used + as an implicit reviewer. Kept to prove the console is now ignored.""" + + def confirm_tool_execution(self, _tn, _args): + return True + + +class _BlockingConsoleAccept: + blocking_confirmation = True + + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + + def confirm_tool_execution(self, tool_name, tool_args): + self.calls.append((tool_name, dict(tool_args))) + return True + + +def _build_adapter(): + receipts = InMemoryReceiptService() + adapter = GaiaGovernanceAdapter( + policy_engine=RuleBasedPolicyEngine(), + checkpoint_runtime=InMemoryCheckpointBridge(), + receipt_service=receipts, + policy_binding=StaticPolicyBindingService(), + ) + return adapter, receipts + + +def test_review_with_explicit_approver_runs_tool_and_records_receipt(): + adapter, receipts = _build_adapter() + agent = _GovernedFakeAgent( + governance_adapter=adapter, + governance_risk_tags={"publish_post": ["review"]}, + governance_reviewer=lambda *_a, **_kw: True, + ) + result = agent._execute_tool("publish_post", {"body": "hi"}) + assert result["status"] == "ok" + assert agent.calls == [("publish_post", {"body": "hi"})] + # one receipt (APPROVE) recorded + receipts_list = list(receipts) + assert len(receipts_list) == 1 + assert receipts_list[0].decision == "APPROVE" + + +def test_review_with_explicit_rejecter_denies_and_records_receipt(): + adapter, receipts = _build_adapter() + agent = _GovernedFakeAgent( + governance_adapter=adapter, + governance_risk_tags={"publish_post": ["review"]}, + governance_reviewer=lambda *_a, **_kw: False, + ) + result = agent._execute_tool("publish_post", {"body": "hi"}) + assert result["status"] == "denied" + assert result["governance_decision"] == "REVIEW_REJECTED" + assert result["receipt_id"].startswith("rcpt_") + assert agent.calls == [] + receipts_list = list(receipts) + assert len(receipts_list) == 1 + assert receipts_list[0].decision == "REJECT" + + +def test_review_ignores_default_console_and_fails_closed(): + # HIGH-1 regression: GAIA's default AgentConsole.confirm_tool_execution + # returns True, so treating it as an implicit reviewer would + # auto-approve. The mixin must NOT use the console unless the caller + # explicitly opts in via governance_reviewer. + adapter, _ = _build_adapter() + agent = _GovernedFakeAgent( + governance_adapter=adapter, + governance_risk_tags={"publish_post": ["review"]}, + ) + agent.console = _StubConsoleAccept() # would approve if consulted + result = agent._execute_tool("publish_post", {"body": "hi"}) + assert result["status"] == "denied" + assert result["governance_decision"] == "REVIEW_REJECTED" + assert agent.calls == [] + + +def test_review_honors_explicit_reviewer_that_delegates_to_console(): + # Opt-in path: caller wraps the console explicitly, which is safe + # because they've verified their console actually blocks. + adapter, _ = _build_adapter() + console = _StubConsoleAccept() + agent = _GovernedFakeAgent( + governance_adapter=adapter, + governance_risk_tags={"publish_post": ["review"]}, + governance_reviewer=lambda tn, args, _d: console.confirm_tool_execution( + tn, args + ), + ) + result = agent._execute_tool("publish_post", {"body": "hi"}) + assert result["status"] == "ok" + + +def test_review_uses_blocking_console_when_no_explicit_reviewer(): + adapter, _ = _build_adapter() + console = _BlockingConsoleAccept() + agent = _GovernedFakeAgent( + governance_adapter=adapter, + governance_risk_tags={"publish_post": ["review"]}, + ) + agent.console = console + + result = agent._execute_tool("publish_post", {"body": "hi"}) + + assert result["status"] == "ok" + assert console.calls == [("publish_post", {"body": "hi"})] + + +def test_review_with_sse_console_emits_permission_request_and_runs_on_approve(): + adapter, _ = _build_adapter() + console = SSEOutputHandler() + agent = _GovernedFakeAgent( + governance_adapter=adapter, + governance_risk_tags={"publish_post": ["review"]}, + ) + agent.console = console + result_holder: dict[str, Any] = {} + + def run_tool(): + result_holder["result"] = agent._execute_tool("publish_post", {"body": "hi"}) + + thread = threading.Thread(target=run_tool) + thread.start() + + permission_event = None + deadline = time.time() + 3.0 + while time.time() < deadline: + while not console.event_queue.empty(): + event = console.event_queue.get_nowait() + if event.get("type") == "permission_request": + permission_event = event + break + if permission_event is not None: + break + time.sleep(0.05) + + assert permission_event is not None + assert permission_event["tool"] == "publish_post" + assert permission_event["args"] == {"body": "hi"} + + console.resolve_tool_confirmation(approved=True) + thread.join(timeout=3.0) + + assert not thread.is_alive() + assert result_holder["result"]["status"] == "ok" + assert agent.calls == [("publish_post", {"body": "hi"})] + + +def test_review_fails_closed_when_no_reviewer(): + adapter, _ = _build_adapter() + agent = _GovernedFakeAgent( + governance_adapter=adapter, + governance_risk_tags={"publish_post": ["review"]}, + ) + # no console, no reviewer -> deny + result = agent._execute_tool("publish_post", {"body": "hi"}) + assert result["status"] == "denied" + assert result["governance_decision"] == "REVIEW_REJECTED" + assert agent.calls == [] + + +def test_block_decision_records_receipt_and_returns_receipt_id(): + adapter, receipts = _build_adapter() + agent = _GovernedFakeAgent( + governance_adapter=adapter, + governance_risk_tags={"drop_table": ["blocked"]}, + ) + result = agent._execute_tool("drop_table", {"name": "users"}) + assert result["status"] == "denied" + assert result["governance_decision"] == "BLOCK" + assert result["receipt_id"].startswith("rcpt_") + receipts_list = list(receipts) + assert len(receipts_list) == 1 + assert receipts_list[0].decision == "BLOCK" + + +def test_block_decision_with_sse_console_emits_policy_alert(): + adapter, receipts = _build_adapter() + console = SSEOutputHandler() + agent = _GovernedFakeAgent( + governance_adapter=adapter, + governance_risk_tags={"drop_table": ["blocked"]}, + ) + agent.console = console + + result = agent._execute_tool("drop_table", {"name": "users"}) + + assert result["status"] == "denied" + assert result["governance_decision"] == "BLOCK" + assert agent.calls == [] + + events = [] + while not console.event_queue.empty(): + events.append(console.event_queue.get_nowait()) + policy_alerts = [event for event in events if event.get("type") == "policy_alert"] + assert policy_alerts == [ + { + "type": "policy_alert", + "tool": "drop_table", + "decision": "BLOCK", + "reason": "blocked by policy", + "rule_ids": ["rule:block"], + "policy_version": "v0", + "receipt_id": result["receipt_id"], + } + ] + receipts_list = list(receipts) + assert len(receipts_list) == 1 + assert receipts_list[0].decision == "BLOCK" + + +def test_reviewer_exception_is_treated_as_reject(caplog): + adapter, receipts = _build_adapter() + + def boom(*_a, **_kw): + raise RuntimeError("bad reviewer") + + agent = _GovernedFakeAgent( + governance_adapter=adapter, + governance_risk_tags={"publish_post": ["review"]}, + governance_reviewer=boom, + ) + caplog.set_level(logging.WARNING, "gaia.governance.mixin") + result = agent._execute_tool("publish_post", {"body": "hi"}) + assert result["status"] == "denied" + assert agent.calls == [] + # A misbehaving reviewer must be visible to operators, not just silently + # treated as REJECT. Match on logger name, not message string. + assert any( + record.levelname == "WARNING" and record.name == "gaia.governance.mixin" + for record in caplog.records + ) + # Audit-trail fidelity: the receipt's reason must distinguish "reviewer + # raised" from "reviewer chose no", carrying the exception type/message + # so an auditor reading the JSONL log knows the REJECT was due to a + # crash, not a deliberate "no". + receipts_list = list(receipts) + reject_receipts = [r for r in receipts_list if r.decision == "REJECT"] + assert len(reject_receipts) == 1 + resolution_reason = reject_receipts[0].metadata["evidence"]["resolution"]["reason"] + assert "RuntimeError" in resolution_reason + assert "bad reviewer" in resolution_reason + + +def test_reviewer_explicit_no_keeps_plain_reason(): + """Counterpart to the exception test: a reviewer that returns False + (a deliberate "no") produces a plain "reviewer rejected" reason in the + receipt, NOT an exception-flavored one. + """ + adapter, receipts = _build_adapter() + agent = _GovernedFakeAgent( + governance_adapter=adapter, + governance_risk_tags={"publish_post": ["review"]}, + governance_reviewer=lambda *_a, **_kw: False, + ) + result = agent._execute_tool("publish_post", {"body": "hi"}) + assert result["status"] == "denied" + receipts_list = list(receipts) + reject_receipts = [r for r in receipts_list if r.decision == "REJECT"] + assert len(reject_receipts) == 1 + resolution_reason = reject_receipts[0].metadata["evidence"]["resolution"]["reason"] + assert resolution_reason == "reviewer rejected" diff --git a/tests/integration/test_governed_workflow_binding.py b/tests/integration/test_governed_workflow_binding.py new file mode 100644 index 000000000..b50e934fa --- /dev/null +++ b/tests/integration/test_governed_workflow_binding.py @@ -0,0 +1,129 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""MED-4 regression: checkpoint resolution is workflow-bound. + +A caller must not be able to resolve checkpoint ``A`` under an arbitrary +workflow_id ``B`` and have a receipt issued under workflow B. The +adapter validates the checkpoint's stored workflow against the +caller-supplied workflow_id before resolving. +""" + +from __future__ import annotations + +import threading +import time + +import pytest + +from gaia.governance import ( + CheckpointResolution, + GaiaGovernanceAdapter, + InvalidResolutionError, + WorkflowTransition, +) +from gaia.governance.checkpoint_bridge import InMemoryCheckpointBridge +from gaia.governance.policy_binding import StaticPolicyBindingService +from gaia.governance.receipt_service import InMemoryReceiptService +from gaia.governance.schemas import ActionRequest +from gaia.governance.stubs import RuleBasedPolicyEngine + + +def _make(): + receipts = InMemoryReceiptService() + bridge = InMemoryCheckpointBridge() + adapter = GaiaGovernanceAdapter( + policy_engine=RuleBasedPolicyEngine(), + checkpoint_runtime=bridge, + receipt_service=receipts, + policy_binding=StaticPolicyBindingService(), + ) + return adapter, receipts, bridge + + +def _review_action(workflow_id: str) -> ActionRequest: + return ActionRequest( + action_id="a1", + actor_id="actor", + tool_name="t", + action_type="t", + args={}, + risk_tags=["review"], + workflow_id=workflow_id, + ) + + +def _transition(workflow_id: str) -> WorkflowTransition: + return WorkflowTransition( + workflow_id=workflow_id, + transition_id="tx1", + from_state="S", + to_state="R", + transition_type="tool_call", + related_action_id="a1", + ) + + +def test_resolve_with_mismatched_workflow_id_is_rejected(): + adapter, _, _ = _make() + opened = adapter.handle_transition( + _transition("wf_A"), + adapter.govern_action(_review_action("wf_A")), + ) + with pytest.raises(InvalidResolutionError): + adapter.resolve_checkpoint( + opened.checkpoint_id, + CheckpointResolution(resolution="APPROVE", actor_id="mallory"), + workflow_id="wf_B", + ) + + +def test_resolve_with_correct_workflow_id_succeeds(): + adapter, _, _ = _make() + opened = adapter.handle_transition( + _transition("wf_A"), + adapter.govern_action(_review_action("wf_A")), + ) + outcome = adapter.resolve_checkpoint( + opened.checkpoint_id, + CheckpointResolution(resolution="APPROVE", actor_id="alice"), + workflow_id="wf_A", + ) + assert outcome.status == "RESUMED" + + +def test_concurrent_double_resolution_only_one_wins(): + # MED-5 regression: the checkpoint bridge uses a lock so only one + # of two concurrent resolutions produces a terminal outcome; the + # other raises InvalidResolutionError. + adapter, _, _ = _make() + opened = adapter.handle_transition( + _transition("wf_race"), + adapter.govern_action(_review_action("wf_race")), + ) + + outcomes: list = [] + errors: list = [] + + def attempt(tag: str): + try: + outcomes.append( + adapter.resolve_checkpoint( + opened.checkpoint_id, + CheckpointResolution(resolution="APPROVE", actor_id=tag), + workflow_id="wf_race", + ) + ) + except InvalidResolutionError as exc: # expected for loser + errors.append(exc) + + t1 = threading.Thread(target=attempt, args=("t1",)) + t2 = threading.Thread(target=attempt, args=("t2",)) + t1.start() + t2.start() + t1.join() + t2.join() + # exactly one success, one InvalidResolutionError + assert len(outcomes) == 1 + assert len(errors) == 1 + # keep timing-sensitive assertions robust on slow machines + _ = time # silence unused-import when we don't need a sleep path diff --git a/tests/integration/test_multi_caller_equivalence.py b/tests/integration/test_multi_caller_equivalence.py new file mode 100644 index 000000000..4508e8ad0 --- /dev/null +++ b/tests/integration/test_multi_caller_equivalence.py @@ -0,0 +1,191 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +T-19: multi-caller equivalence test. + +Drives the connections layer from each of the three caller surfaces +(SDK / CLI / AgentUI) and asserts end-to-end equivalence: a connection +authenticated via one caller is observable from the other two; a grant +written by one caller is observable from the other two; access tokens +fetched from any caller flow through the same in-process cache. + +This is the gating test for the ยง2.1 consumer contract: "the connections +module is self-contained; SDK, CLI, AgentUI are equal callers." + +Marked ``integration`` so it stays out of the fast unit suite by default. +""" + +from __future__ import annotations + +import asyncio + +import httpx +import pytest +import respx + +import gaia.connectors as connections +from gaia.connectors import cli as connections_cli +from gaia.connectors.providers import _registry +from gaia.connectors.store import save_connection + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def env(monkeypatch, tmp_path, in_memory_keyring): # noqa: F811 + """Configure provider, isolate grants ledger, reset registry, reset cache.""" + monkeypatch.setenv("GAIA_GOOGLE_CLIENT_ID", "multi-caller-test.apps.example") + monkeypatch.setattr("gaia.connectors.grants.Path.home", lambda: tmp_path) + _registry.clear() + in_memory_keyring._store.clear() + from gaia.connectors.tokens import _cache + + _cache.clear() + yield {"home": tmp_path} + + +def _seed_connection(google_provider): + """Skip the loopback flow โ€” pre-seed the keyring directly so we test + grant + token equivalence without launching a browser.""" + save_connection( + provider="google", + account_email="multi-caller@example.com", + refresh_token="multi-caller-refresh", + scopes=["gmail.readonly"], + client_id_hash=google_provider.client_id_hash, + ) + + +def _ok_token(access="MULTI-CALLER-TOKEN"): + return httpx.Response( + 200, json={"access_token": access, "expires_in": 3600, "scope": "x"} + ) + + +class TestSdkPath: + @respx.mock + def test_sdk_grant_visible_to_cli_and_ui(self, env): + respx.post("https://oauth2.googleapis.com/token").mock(return_value=_ok_token()) + + google = connections.providers.get("google") + _seed_connection(google) + + # SDK: grant_agent. + connections.grant_agent("google", "builtin:multi-test", ["gmail.readonly"]) + + # CLI sees the same grant. + listing = connections.list_agent_grants("google") + assert listing == {"builtin:multi-test": ["gmail.readonly"]} + + # UI sees the same connection metadata via the public API. + rows = connections.list_connections() + assert any(r["provider"] == "google" for r in rows) + + # SDK can fetch a token. + token = asyncio.run( + connections.get_access_token( + provider="google", + scopes=["gmail.readonly"], + agent_id="builtin:multi-test", + ) + ) + assert token == "MULTI-CALLER-TOKEN" + + +class TestCliPath: + @respx.mock + def test_cli_grant_visible_to_sdk(self, env): + respx.post("https://oauth2.googleapis.com/token").mock(return_value=_ok_token()) + google = connections.providers.get("google") + _seed_connection(google) + + # CLI: gaia connectors grants grant google builtin:cli-test ... + rc = connections_cli.main( + [ + "connectors", + "grants", + "grant", + "google", + "builtin:cli-test", + "--scopes", + "gmail.readonly", + ] + ) + assert rc == 0 + + # SDK sees the grant the CLI wrote. + listing = connections.list_agent_grants("google") + assert listing == {"builtin:cli-test": ["gmail.readonly"]} + + # SDK can fetch a token under that agent_id. + token = asyncio.run( + connections.get_access_token( + provider="google", + scopes=["gmail.readonly"], + agent_id="builtin:cli-test", + ) + ) + assert token == "MULTI-CALLER-TOKEN" + + +class TestUiPath: + @respx.mock + def test_ui_grant_visible_to_sdk_and_cli(self, env, ui_api_client): + respx.post("https://oauth2.googleapis.com/token").mock(return_value=_ok_token()) + # Grants endpoint pulls _registry lazily โ€” make sure tripwire ran: + google = connections.providers.get("google") + _seed_connection(google) + + # UI: PUT /api/connectors/google/grants/builtin:ui-test + resp = ui_api_client.put( + "/api/connectors/google/grants/builtin:ui-test", + json={"scopes": ["gmail.readonly"]}, + ) + assert resp.status_code == 200, resp.text + + # CLI sees the grant. + listing = connections.list_agent_grants("google") + assert listing == {"builtin:ui-test": ["gmail.readonly"]} + + # SDK can fetch a token under the same agent_id. + token = asyncio.run( + connections.get_access_token( + provider="google", + scopes=["gmail.readonly"], + agent_id="builtin:ui-test", + ) + ) + assert token == "MULTI-CALLER-TOKEN" + + # And the UI status endpoint reflects it. + status = ui_api_client.get("/api/connectors/google/grants").json() + assert status == {"grants": {"builtin:ui-test": ["gmail.readonly"]}} + + +class TestThreeCallersAgreeOnConnection: + """All three callers see the same connection metadata.""" + + def test_one_seed_three_observations(self, env, ui_api_client): + google = connections.providers.get("google") + _seed_connection(google) + + # SDK + sdk_rows = connections.list_connections() + assert any(r["provider"] == "google" for r in sdk_rows) + + # CLI + rc = connections_cli.main(["connectors", "status", "--json"]) + assert rc == 0 + + # UI + ui_rows = ui_api_client.get("/api/connectors").json()["connections"] + assert any(r["provider"] == "google" for r in ui_rows) + + # Same email surfaces everywhere. + sdk_email = next(r for r in sdk_rows if r["provider"] == "google")[ + "account_email" + ] + ui_email = next(r for r in ui_rows if r["provider"] == "google")[ + "account_email" + ] + assert sdk_email == ui_email == "multi-caller@example.com" diff --git a/tests/unit/agents/test_connectors_demo.py b/tests/unit/agents/test_connectors_demo.py new file mode 100644 index 000000000..86a32fe76 --- /dev/null +++ b/tests/unit/agents/test_connectors_demo.py @@ -0,0 +1,399 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Tests for the ConnectorsDemoAgent โ€” verify the per-agent grant path, +the credential-error translation, and the four tool implementations +(Gmail / Calendar / Drive / GitHub) without actually instantiating the +agent (which would spin up an LLM client). + +The agent class itself (system prompt, tool registration, factory) +gets a thin smoke test that asserts REQUIRED_CONNECTORS is shaped +correctly and that the registry sees it as a built-in. +""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +import httpx + +from gaia.agents.connectors_demo.agent import ( + AGENT_NAMESPACED_ID, + SCOPE_CALENDAR_READ, + SCOPE_DRIVE_READ, + SCOPE_GMAIL_READ, + SCOPE_MCP_USE, + ConnectorsDemoAgent, + _calendar_today_impl, + _drive_recent_files_impl, + _format_connector_error, + _github_my_repos_impl, + _gmail_recent_subjects_impl, +) +from gaia.connectors.errors import ( + AuthRequiredError, + ConfigurationError, + ConnectorsError, +) + +# --------------------------------------------------------------------------- +# REQUIRED_CONNECTORS shape +# --------------------------------------------------------------------------- + + +class TestRequiredConnectors: + """The agent declares the connectors+scopes it needs so the AgentUI + can render the per-agent grants section, and so check_agent_grant + can fail closed when scopes are missing.""" + + def test_required_connectors_lists_google_and_github(self): + connector_ids = { + req.connector_id for req in ConnectorsDemoAgent.REQUIRED_CONNECTORS + } + assert connector_ids == {"google", "mcp-github"} + + def test_google_scopes_include_all_three_apis(self): + google = next( + req + for req in ConnectorsDemoAgent.REQUIRED_CONNECTORS + if req.connector_id == "google" + ) + assert SCOPE_GMAIL_READ in google.scopes + assert SCOPE_CALENDAR_READ in google.scopes + assert SCOPE_DRIVE_READ in google.scopes + + def test_github_uses_symbolic_use_scope(self): + # v1 grants the entire PAT as a single unit. v2 may evolve to + # per-tool grants โ€” see the agent module docstring. + github = next( + req + for req in ConnectorsDemoAgent.REQUIRED_CONNECTORS + if req.connector_id == "mcp-github" + ) + assert github.scopes == (SCOPE_MCP_USE,) + + def test_each_requirement_has_a_user_facing_reason(self): + for req in ConnectorsDemoAgent.REQUIRED_CONNECTORS: + assert req.reason, ( + f"{req.connector_id} missing a 'reason' โ€” the AgentUI " + "renders this when prompting users to grant scopes" + ) + + +# --------------------------------------------------------------------------- +# Error translation โ€” every connectors exception type should produce a +# message the LLM can pass through to the user verbatim. +# --------------------------------------------------------------------------- + + +class TestFormatConnectorError: + def test_agent_not_granted_names_missing_scopes(self): + e = AuthRequiredError( + AuthRequiredError.Reason.AGENT_NOT_GRANTED, + provider="google", + agent_id=AGENT_NAMESPACED_ID, + missing_scopes=["scope-A", "scope-B"], + ) + msg = _format_connector_error(e) + assert "AGENT_NOT_GRANTED" in msg + assert "scope-A" in msg + assert "scope-B" in msg + assert "Settings" in msg + + def test_not_connected_points_to_connect_button(self): + e = AuthRequiredError( + AuthRequiredError.Reason.NOT_CONNECTED, + provider="google", + ) + msg = _format_connector_error(e) + assert "NOT_CONNECTED" in msg + assert "Connect" in msg + + def test_reauth_required_treated_as_not_connected(self): + # The user-facing remedy is the same: open Settings โ†’ Connect. + e = AuthRequiredError( + AuthRequiredError.Reason.REAUTH_REQUIRED, + provider="google", + ) + msg = _format_connector_error(e) + assert "NOT_CONNECTED" in msg + + def test_configuration_error_passes_through(self): + msg = _format_connector_error(ConfigurationError("client_id missing")) + assert "CONFIG_ERROR" in msg + assert "client_id" in msg + + def test_unknown_exception_labelled_unexpected(self): + msg = _format_connector_error(RuntimeError("something else")) + assert "UNEXPECTED_ERROR" in msg + assert "RuntimeError" in msg + + +# --------------------------------------------------------------------------- +# Tool: gmail_recent_subjects +# --------------------------------------------------------------------------- + + +def _stub_gmail_response(messages): + """Build the two-step Gmail API response shape the impl expects.""" + + def _fake_get(url, headers=None, params=None, timeout=None): + if url.endswith("/messages"): + return httpx.Response( + 200, json={"messages": [{"id": m["id"]} for m in messages]} + ) + # /messages/ + msg_id = url.rsplit("/", 1)[-1] + msg = next(m for m in messages if m["id"] == msg_id) + return httpx.Response( + 200, + json={ + "payload": { + "headers": [ + {"name": "From", "value": msg["from"]}, + {"name": "Subject", "value": msg["subject"]}, + ] + } + }, + ) + + return _fake_get + + +class TestGmailRecentSubjects: + def test_happy_path_returns_subjects_and_senders(self): + fake_messages = [ + {"id": "1", "from": "alice@example.com", "subject": "Lunch?"}, + {"id": "2", "from": "bob@example.com", "subject": "Re: PR review"}, + ] + with ( + patch( + "gaia.agents.connectors_demo.agent._gmail_token", + return_value="tok-xyz", + ), + patch("httpx.get", side_effect=_stub_gmail_response(fake_messages)), + ): + result = _gmail_recent_subjects_impl(limit=5) + assert result["ok"] is True + assert result["count"] == 2 + assert result["messages"][0]["subject"] == "Lunch?" + assert result["messages"][1]["from"] == "bob@example.com" + + def test_grant_failure_returns_actionable_error(self): + with patch( + "gaia.agents.connectors_demo.agent._gmail_token", + side_effect=AuthRequiredError( + AuthRequiredError.Reason.AGENT_NOT_GRANTED, + provider="google", + agent_id=AGENT_NAMESPACED_ID, + missing_scopes=[SCOPE_GMAIL_READ], + ), + ): + result = _gmail_recent_subjects_impl(limit=5) + assert result["ok"] is False + assert "AGENT_NOT_GRANTED" in result["error"] + assert SCOPE_GMAIL_READ in result["error"] + + def test_api_failure_returns_connector_error(self): + # Token resolves, but Gmail returns 401. + with ( + patch( + "gaia.agents.connectors_demo.agent._gmail_token", + return_value="tok", + ), + patch( + "httpx.get", + return_value=httpx.Response(401, text="Invalid Credentials"), + ), + ): + result = _gmail_recent_subjects_impl(limit=5) + assert result["ok"] is False + assert "CONNECTOR_ERROR" in result["error"] + + +# --------------------------------------------------------------------------- +# Tool: calendar_today +# --------------------------------------------------------------------------- + + +class TestCalendarToday: + def test_happy_path_lists_events(self): + fake_response = httpx.Response( + 200, + json={ + "items": [ + { + "summary": "Standup", + "start": {"dateTime": "2026-05-01T10:00:00-07:00"}, + "end": {"dateTime": "2026-05-01T10:15:00-07:00"}, + "location": "Zoom", + }, + { + "summary": "All-day offsite", + "start": {"date": "2026-05-01"}, + "end": {"date": "2026-05-02"}, + }, + ] + }, + ) + with ( + patch( + "gaia.agents.connectors_demo.agent._calendar_token", + return_value="tok", + ), + patch("httpx.get", return_value=fake_response), + ): + result = _calendar_today_impl() + assert result["ok"] is True + assert result["count"] == 2 + assert result["events"][0]["summary"] == "Standup" + # All-day events have a 'date' field rather than 'dateTime' โ€” + # the impl must accept both shapes. + assert result["events"][1]["start"] == "2026-05-01" + + +# --------------------------------------------------------------------------- +# Tool: drive_recent_files +# --------------------------------------------------------------------------- + + +class TestDriveRecentFiles: + def test_happy_path_lists_files(self): + fake_response = httpx.Response( + 200, + json={ + "files": [ + { + "id": "1abc", + "name": "Q3 Plan.gdoc", + "mimeType": "application/vnd.google-apps.document", + "modifiedTime": "2026-05-01T12:00:00Z", + "webViewLink": "https://drive.google.com/d/1abc/view", + } + ] + }, + ) + with ( + patch( + "gaia.agents.connectors_demo.agent._drive_token", + return_value="tok", + ), + patch("httpx.get", return_value=fake_response), + ): + result = _drive_recent_files_impl(limit=5) + assert result["ok"] is True + assert result["files"][0]["name"] == "Q3 Plan.gdoc" + + +# --------------------------------------------------------------------------- +# Tool: github_my_repos +# --------------------------------------------------------------------------- + + +class TestGithubMyRepos: + def test_happy_path_lists_repos(self): + fake_response = httpx.Response( + 200, + json=[ + { + "full_name": "octocat/Hello-World", + "private": False, + "description": "My first repo", + "html_url": "https://github.com/octocat/Hello-World", + "updated_at": "2026-04-30T09:00:00Z", + } + ], + ) + with ( + patch( + "gaia.agents.connectors_demo.agent._github_pat", + return_value="ghp_x", + ), + patch("httpx.get", return_value=fake_response), + ): + result = _github_my_repos_impl(limit=10) + assert result["ok"] is True + assert result["repos"][0]["full_name"] == "octocat/Hello-World" + + def test_pat_missing_returns_connector_error(self): + with patch( + "gaia.agents.connectors_demo.agent._github_pat", + side_effect=ConnectorsError( + "GitHub MCP credential resolved but GITHUB_TOKEN was empty." + ), + ): + result = _github_my_repos_impl(limit=10) + assert result["ok"] is False + assert "CONNECTOR_ERROR" in result["error"] + assert "GITHUB_TOKEN" in result["error"] + + +# --------------------------------------------------------------------------- +# Registry โ€” the agent shows up as a built-in so the AgentUI dropdown +# can list it. +# --------------------------------------------------------------------------- + + +class TestRegistry: + def test_connectors_demo_is_registered(self): + from gaia.agents.registry import AgentRegistry + + reg = AgentRegistry() + reg.discover() + ids = {a.id for a in reg.list()} + assert "connectors-demo" in ids + + def test_required_connections_surface_in_registration(self): + from gaia.agents.registry import AgentRegistry + + reg = AgentRegistry() + reg.discover() + agent = next(a for a in reg.list() if a.id == "connectors-demo") + assert "google" in agent.required_connections + assert "mcp-github" in agent.required_connections + + def test_namespaced_agent_id_matches_module_constant(self): + # The registry's namespaced id must agree with the module-level + # constant the tools pass to get_credential_sync; otherwise the + # grant-ledger check would look at the wrong agent. + from gaia.agents.registry import AgentRegistry + + reg = AgentRegistry() + reg.discover() + agent = next(a for a in reg.list() if a.id == "connectors-demo") + assert agent.namespaced_agent_id == AGENT_NAMESPACED_ID + + +# --------------------------------------------------------------------------- +# Tool wiring โ€” the @tool-decorated functions return JSON strings the LLM +# can parse, not raw dicts. Smoke-test by calling _register_tools without +# instantiating the LLM client. +# --------------------------------------------------------------------------- + + +class TestToolJsonShape: + def test_each_tool_impl_returns_json_serializable(self): + # The four impls return dicts; the @tool wrappers call json.dumps. + # If a future change makes a dict non-serializable (e.g. nested + # datetime), this test catches it before it ships. + with patch( + "gaia.agents.connectors_demo.agent._gmail_token", + side_effect=ConnectorsError("offline"), + ): + assert json.dumps(_gmail_recent_subjects_impl(limit=1)) + with patch( + "gaia.agents.connectors_demo.agent._calendar_token", + side_effect=ConnectorsError("offline"), + ): + assert json.dumps(_calendar_today_impl()) + with patch( + "gaia.agents.connectors_demo.agent._drive_token", + side_effect=ConnectorsError("offline"), + ): + assert json.dumps(_drive_recent_files_impl(limit=1)) + with patch( + "gaia.agents.connectors_demo.agent._github_pat", + side_effect=ConnectorsError("offline"), + ): + assert json.dumps(_github_my_repos_impl(limit=1)) diff --git a/tests/unit/chat/ui/test_chat_helpers.py b/tests/unit/chat/ui/test_chat_helpers.py index 51d14ee44..86be4b456 100644 --- a/tests/unit/chat/ui/test_chat_helpers.py +++ b/tests/unit/chat/ui/test_chat_helpers.py @@ -390,7 +390,6 @@ def test_propagates_attributeerror_when_registry_lacks_canonical_id(self): set_agent_registry(None) - # โ”€โ”€ Regression: registered-agent streaming path must not double-index โ”€โ”€โ”€โ”€โ”€ diff --git a/tests/unit/chat/ui/test_sse_confirmation.py b/tests/unit/chat/ui/test_sse_confirmation.py index b1b204c1c..fe203dcb5 100644 --- a/tests/unit/chat/ui/test_sse_confirmation.py +++ b/tests/unit/chat/ui/test_sse_confirmation.py @@ -33,6 +33,14 @@ def _drain(handler: SSEOutputHandler): return events +def _wait_for_pending_confirmation(handler: SSEOutputHandler): + """Wait until confirm_tool_execution has installed its pending event.""" + deadline = time.time() + 2.0 + while handler._confirm_event is None and time.time() < deadline: + time.sleep(0.05) + assert handler._confirm_event is not None + + # =========================================================================== # confirm_tool_execution โ€” cancellation # =========================================================================== @@ -123,15 +131,9 @@ def run_confirm(): t.start() # Wait for the worker to have set up _confirm_event before we resolve. - # Polling _confirm_result was wrong โ€” it's initialised to False (not - # None), so ``is None`` never holds and resolve fired before the - # worker registered its event, then the worker's own setup - # overwrote the resolved state. _confirm_event starts at None and - # is only set inside confirm_tool_execution, so polling it for - # not-None correctly tracks the registration moment. - deadline = time.time() + 2.0 - while handler._confirm_event is None and time.time() < deadline: - time.sleep(0.05) + # Polling _confirm_result was wrong because it starts at False; the + # shared helper waits for the event registration point instead. + _wait_for_pending_confirmation(handler) handler.resolve_tool_confirmation(approved=True) @@ -149,9 +151,7 @@ def run_confirm(): t = threading.Thread(target=run_confirm) t.start() - deadline = time.time() + 2.0 - while handler._confirm_event is None and time.time() < deadline: - time.sleep(0.05) + _wait_for_pending_confirmation(handler) handler.resolve_tool_confirmation(approved=True) t.join(timeout=3.0) @@ -179,11 +179,9 @@ def run_confirm(): t = threading.Thread(target=run_confirm) t.start() - # See note in test_approve_returns_true: poll _confirm_event, not + # See note in test_approve_returns_true: wait for _confirm_event, not # _confirm_result. The latter is False from the start. - deadline = time.time() + 2.0 - while handler._confirm_event is None and time.time() < deadline: - time.sleep(0.05) + _wait_for_pending_confirmation(handler) handler.resolve_tool_confirmation(approved=False) diff --git a/tests/unit/chat/ui/test_sse_handler.py b/tests/unit/chat/ui/test_sse_handler.py index d09f7bf02..6480652d9 100644 --- a/tests/unit/chat/ui/test_sse_handler.py +++ b/tests/unit/chat/ui/test_sse_handler.py @@ -94,6 +94,44 @@ def test_emit_none_sentinel(self, handler): assert handler.event_queue.get_nowait() is None +class TestPolicyAlert: + """Tests for SSEOutputHandler.print_policy_alert.""" + + def test_policy_alert_event_shape(self, handler): + handler.print_policy_alert( + tool_name="drop_table", + decision="BLOCK", + reason="Production DB protection active.", + rule_ids=["governance.block.destructive_db"], + policy_version="v1.2.0", + receipt_id="rcpt_abcd_1234", + ) + + assert handler.event_queue.get_nowait() == { + "type": "policy_alert", + "tool": "drop_table", + "decision": "BLOCK", + "reason": "Production DB protection active.", + "rule_ids": ["governance.block.destructive_db"], + "policy_version": "v1.2.0", + "receipt_id": "rcpt_abcd_1234", + } + + def test_policy_alert_omits_missing_receipt_id(self, handler): + handler.print_policy_alert( + tool_name="drop_table", + decision="BLOCK", + reason="blocked", + rule_ids=[], + policy_version="v1", + receipt_id=None, + ) + + event = handler.event_queue.get_nowait() + assert event["type"] == "policy_alert" + assert "receipt_id" not in event + + # =========================================================================== # SSEOutputHandler._elapsed # =========================================================================== diff --git a/tests/unit/chat/ui/test_tunnel.py b/tests/unit/chat/ui/test_tunnel.py index 88c7d0059..63e66dfb4 100644 --- a/tests/unit/chat/ui/test_tunnel.py +++ b/tests/unit/chat/ui/test_tunnel.py @@ -75,6 +75,109 @@ def test_start_without_ngrok(self): assert status["active"] is False assert status["error"] is not None assert "ngrok" in status["error"].lower() + # Should mention install instructions + assert ( + "install" in status["error"].lower() + or "ngrok.com/download" in status["error"].lower() + ) + + def test_failed_start_preserves_error_in_status(self, monkeypatch): + """After a failed start, get_status() must still return the diagnostic. + + Regression test: stop() clears _error as part of its normal cleanup + (so a user-initiated stop doesn't leave a stale error). But when + a start fails, we want to preserve the error across stop() so the + caller sees WHY it failed -- not a confusing ``error: null``. + """ + from gaia.ui import tunnel as tunnel_mod + + manager = TunnelManager(port=4200) + manager._find_ngrok = lambda: "/fake/ngrok" + monkeypatch.setattr( + tunnel_mod, + "_check_ngrok_authtoken_configured", + lambda: True, + ) + + # Skip public-IP fetch (would hit the network) + async def _noop_public_ip(self): + self._public_ip = None + + monkeypatch.setattr(TunnelManager, "_fetch_public_ip", _noop_public_ip) + + # Skip the stale-ngrok kill + async def _noop_kill(self): + return None + + monkeypatch.setattr(TunnelManager, "_kill_stale_ngrok", _noop_kill) + + # Simulate a subprocess.Popen that "died immediately" so the + # poll-api path reports a friendly error. + class _DeadProcess: + def __init__(self, *_a, **_kw): + self.stdout = None + self.stderr = None + self.stdin = None + + def poll(self): + return 1 # exited with non-zero + + def terminate(self): + pass + + def wait(self, timeout=None): + return 1 + + def kill(self): + pass + + import subprocess as _sp + + monkeypatch.setattr(_sp, "Popen", _DeadProcess) + + # Also short-circuit the drain helper since our fake has no pipes + monkeypatch.setattr( + TunnelManager, + "_drain_ngrok_output", + lambda self: "authentication failed ERR_NGROK_107 " + "properly formed, but it is invalid", + ) + + status = asyncio.run(manager.start()) + + assert status["active"] is False + assert status["url"] is None + # The crux of the test: error must survive stop() cleanup. + assert status["error"] is not None + assert ( + "rejected" in status["error"].lower() + or "revoked" in status["error"].lower() + or "invalid" in status["error"].lower() + ) + + def test_start_without_authtoken(self, monkeypatch): + """start() surfaces a friendly message when the authtoken isn't set.""" + from gaia.ui import tunnel as tunnel_mod + + manager = TunnelManager(port=4200) + manager._find_ngrok = lambda: "/fake/ngrok" + # Pretend the authtoken preflight fails (no config file found) + monkeypatch.setattr( + tunnel_mod, + "_check_ngrok_authtoken_configured", + lambda: False, + ) + + status = asyncio.run(manager.start()) + assert status["active"] is False + # Pin the exact hint constant the user should see. Asserting + # against the constant (rather than substring-matching a URL in + # the message) keeps the test stronger AND avoids tripping + # CodeQL's py/incomplete-url-substring-sanitization rule on a + # URL pattern that is only ever a help-link in user-facing prose. + from gaia.ui.tunnel import _NGROK_AUTHTOKEN_HINT + + assert status["error"] == _NGROK_AUTHTOKEN_HINT def test_stop_when_not_running(self): """stop() is safe to call when tunnel is not running.""" @@ -99,3 +202,289 @@ def poll(self): status = asyncio.run(manager.start()) assert status["active"] is True assert status["url"] == "https://test.ngrok-free.app" + + +# โ”€โ”€ Friendly error-parser tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class TestParseNgrokError: + """_parse_ngrok_error translates raw ngrok output into actionable hints.""" + + def test_empty_stderr(self): + from gaia.ui.tunnel import _parse_ngrok_error + + msg = _parse_ngrok_error("") + assert "exited without output" in msg.lower() + assert "ngrok http 4200" in msg + + def test_authtoken_error(self): + from gaia.ui.tunnel import _NGROK_AUTHTOKEN_HINT, _parse_ngrok_error + + # ERR_NGROK_4018 (malformed/missing authtoken) โ†’ fixed hint. + # Assert exact-equality with the constant so CodeQL's + # incomplete-url-substring-sanitization rule has nothing to flag, + # AND the test fails loudly if the prose ever drifts. + msg = _parse_ngrok_error( + "ERROR: authentication failed: The authtoken you specified is " + "invalid. (ERR_NGROK_4018)" + ) + assert msg == _NGROK_AUTHTOKEN_HINT + + def test_authtoken_error_by_code(self): + from gaia.ui.tunnel import _parse_ngrok_error + + msg = _parse_ngrok_error("ERR_NGROK_4018") + assert "authtoken" in msg.lower() + + def test_authtoken_rejected_err_107(self): + """ERR_NGROK_107 is well-formed-but-rejected, distinct from missing.""" + from gaia.ui.tunnel import ( + _NGROK_AUTHTOKEN_HINT, + _NGROK_AUTHTOKEN_REJECTED_HINT, + _parse_ngrok_error, + ) + + msg = _parse_ngrok_error( + "authentication failed: The authtoken you specified is " + "properly formed, but it is invalid. ERR_NGROK_107" + ) + # Pin the exact rejected-hint constant. This is the crux of the + # test: we route an ERR_NGROK_107 to the rejected hint, NOT the + # missing hint (those are user-confusingly different). + assert msg == _NGROK_AUTHTOKEN_REJECTED_HINT + assert msg != _NGROK_AUTHTOKEN_HINT + + def test_authtoken_rejected_by_revoked_phrase(self): + from gaia.ui.tunnel import _parse_ngrok_error + + msg = _parse_ngrok_error( + "You are using ngrok link and this credential was explicitly " "revoked" + ) + assert "rejected" in msg.lower() or "revoked" in msg.lower() + + def test_session_limit_error(self): + from gaia.ui.tunnel import _NGROK_SESSION_LIMIT_HINT, _parse_ngrok_error + + msg = _parse_ngrok_error( + "ERROR: Your account is limited to 1 simultaneous ngrok agent " + "sessions. (ERR_NGROK_108)" + ) + # Exact-equality assertion against the constant โ€” see note in + # test_authtoken_error for why this is preferable to substring + # checks on URLs in user-facing prose. + assert msg == _NGROK_SESSION_LIMIT_HINT + + def test_network_error(self): + from gaia.ui.tunnel import _parse_ngrok_error + + msg = _parse_ngrok_error("dial tcp: lookup tunnel.ngrok.com: no such host") + assert "internet" in msg.lower() or "network" in msg.lower() + + def test_port_conflict(self): + from gaia.ui.tunnel import _parse_ngrok_error + + msg = _parse_ngrok_error( + "failed to bind: listen tcp 127.0.0.1:4040: bind: address " "already in use" + ) + assert "4040" in msg or "in use" in msg.lower() + + def test_unknown_error_falls_back_to_first_line(self): + from gaia.ui.tunnel import _parse_ngrok_error + + msg = _parse_ngrok_error( + "something unusual happened\nadditional context on line 2" + ) + assert "something unusual happened" in msg + # Should NOT include the second line (first line only). + assert "line 2" not in msg + + def test_tls_certificate_alone_does_not_match(self): + """``certificate`` alone is too generic โ€” only ``certificate``+``verify``. + + Regression: an earlier version had + ``if "x509" in low or "certificate" in low and "verify" in low`` + which (due to operator precedence) parsed as + ``x509 OR (certificate AND verify)``. After explicit parens this + behaviour is unchanged but locked in: a ``certificate`` substring + without the ``verify`` partner falls through to the generic fallback. + """ + from gaia.ui.tunnel import _parse_ngrok_error + + msg = _parse_ngrok_error("error: server returned a stale certificate") + # Falls through to "ngrok failed to start: ..." rather than the TLS hint. + assert "system clock" not in msg.lower() + assert "proxy" not in msg.lower() + + def test_tls_x509_matches(self): + from gaia.ui.tunnel import _parse_ngrok_error + + msg = _parse_ngrok_error("x509: certificate signed by unknown authority") + assert "system clock" in msg.lower() or "proxy" in msg.lower() + + def test_connection_refused_without_ngrok_host_falls_through(self): + """Generic ``connection refused`` shouldn't be mis-attributed to ngrok. + + The network-error block parenthesises the + ``connection refused AND tunnel.ngrok.com`` clause so a + ``connection refused`` to some other host doesn't get the + ngrok-specific "couldn't reach servers" hint. + """ + from gaia.ui.tunnel import _parse_ngrok_error + + msg = _parse_ngrok_error("dial tcp 127.0.0.1:9000: connection refused") + # ``dial tcp`` itself does match the network branch, so we test the + # narrower invariant: the message we surface mentions internet/network + # (correct generic guidance) rather than misleading the user about + # ngrok-specific connectivity. The substring filter exists so that + # if the message ever reorders to land in a different branch, this + # test catches the regression. + assert ( + "ngrok's servers" in msg + or "internet" in msg.lower() + or "network" in msg.lower() + ) + + def test_connection_refused_lookalike_host_does_not_match(self): + """A hostile string that *contains* ``tunnel.ngrok.com`` as a substring + must NOT trip the ngrok-specific network branch. + + Locks in the word-boundary regex used by ``_parse_ngrok_error`` so a + future refactor back to a naked ``in`` check (which CodeQL flagged as + py/incomplete-url-substring-sanitization) is caught. + """ + from gaia.ui.tunnel import _parse_ngrok_error + + # ``connection refused`` *and* the literal ``tunnel.ngrok.com`` substring + # appears, but only as a misleading subdomain of an attacker-controlled + # host. The match must NOT fire โ€” the message that actually surfaces is + # the generic fallback (``ngrok failed to start: ...``). + msg = _parse_ngrok_error( + "evil.tunnel.ngrok.com.attacker.tld: connection refused" + ) + assert "internet connection" not in msg.lower() + assert "ngrok failed to start" in msg + + +class TestMaskNgrokSecrets: + """``_mask_ngrok_secrets`` redacts plausible authtokens before logging.""" + + def test_authtoken_field_is_masked(self): + from gaia.ui.tunnel import _mask_ngrok_secrets + + masked = _mask_ngrok_secrets( + "config: authtoken: 2abcdefghijklmnopqrstuvwxyz_zyxwvutsrqponmlkjihgfedcba" + ) + assert "2abcdefghij" not in masked + assert "[REDACTED]" in masked + + def test_long_opaque_token_is_masked_anywhere(self): + from gaia.ui.tunnel import _mask_ngrok_secrets + + # An ngrok-shaped long token appearing inline (e.g. echoed in stderr + # without the ``authtoken:`` prefix) must still be redacted. + masked = _mask_ngrok_secrets( + "rejected token: 2ABCDEFGHIJKLMNOPQRSTUVWXYZ_zyxwvutsrqponmlkjihgfedcba " + "please retry" + ) + assert "2ABCDEFGHIJ" not in masked + assert "[REDACTED]" in masked + # Non-secret context is preserved. + assert "please retry" in masked + + def test_safe_input_unchanged(self): + from gaia.ui.tunnel import _mask_ngrok_secrets + + text = "ngrok exited cleanly: no authtoken issues" + # No secret-shaped substring โ†’ string passes through verbatim. + assert _mask_ngrok_secrets(text) == text + + +class TestCheckNgrokAuthtokenConfigured: + """Tests for ``_check_ngrok_authtoken_configured``. + + The check decides whether to abort start() with a "configure your + authtoken" hint. False positives are cheap (ngrok will surface its own + error). False negatives block working setups, so each input shape that + real users have is exercised here. + """ + + def test_env_var_takes_precedence(self, monkeypatch, tmp_path): + """``$NGROK_AUTHTOKEN`` should short-circuit the file probes. + + ngrok v3 honours the env var directly โ€” a user with valid env-var + auth and no config file is fully working, but the file-only probe + would falsely report "not configured" and block startup. + """ + from gaia.ui import tunnel as tunnel_mod + + monkeypatch.setenv("NGROK_AUTHTOKEN", "valid-token-from-env") + # Point file probes at a non-existent path so they all return False. + monkeypatch.setattr( + tunnel_mod, + "_ngrok_config_candidates", + lambda: [tmp_path / "nope.yml"], + ) + assert tunnel_mod._check_ngrok_authtoken_configured() is True + + def test_empty_env_var_does_not_count(self, monkeypatch, tmp_path): + """An empty/whitespace env var must NOT register as configured.""" + from gaia.ui import tunnel as tunnel_mod + + monkeypatch.setenv("NGROK_AUTHTOKEN", " ") + monkeypatch.setattr( + tunnel_mod, + "_ngrok_config_candidates", + lambda: [tmp_path / "nope.yml"], + ) + assert tunnel_mod._check_ngrok_authtoken_configured() is False + + def test_v2_flat_authtoken_in_config(self, monkeypatch, tmp_path): + """v2 layout: ``authtoken: xxx`` at column 0.""" + from gaia.ui import tunnel as tunnel_mod + + monkeypatch.delenv("NGROK_AUTHTOKEN", raising=False) + cfg = tmp_path / "ngrok.yml" + cfg.write_text("authtoken: 2abc-token-v2-flat\nregion: us\n") + monkeypatch.setattr(tunnel_mod, "_ngrok_config_candidates", lambda: [cfg]) + assert tunnel_mod._check_ngrok_authtoken_configured() is True + + def test_v3_nested_authtoken_in_config(self, monkeypatch, tmp_path): + """v3 layout: ``authtoken`` indented under ``agent:`` block. + + Locks in that nested layouts are still detected โ€” the line-strip + scan tolerates any indentation, but a future refactor to a + column-sensitive parser would silently break this. + """ + from gaia.ui import tunnel as tunnel_mod + + monkeypatch.delenv("NGROK_AUTHTOKEN", raising=False) + cfg = tmp_path / "ngrok.yml" + cfg.write_text( + "version: 3\n" + "agent:\n" + " authtoken: 2xyz-token-v3-nested\n" + " region: us\n" + ) + monkeypatch.setattr(tunnel_mod, "_ngrok_config_candidates", lambda: [cfg]) + assert tunnel_mod._check_ngrok_authtoken_configured() is True + + def test_empty_authtoken_value_rejected(self, monkeypatch, tmp_path): + """``authtoken:`` with no value (or quoted empty) shouldn't count.""" + from gaia.ui import tunnel as tunnel_mod + + monkeypatch.delenv("NGROK_AUTHTOKEN", raising=False) + cfg = tmp_path / "ngrok.yml" + cfg.write_text("authtoken: ''\n") + monkeypatch.setattr(tunnel_mod, "_ngrok_config_candidates", lambda: [cfg]) + assert tunnel_mod._check_ngrok_authtoken_configured() is False + + def test_no_config_files_returns_false(self, monkeypatch, tmp_path): + from gaia.ui import tunnel as tunnel_mod + + monkeypatch.delenv("NGROK_AUTHTOKEN", raising=False) + monkeypatch.setattr( + tunnel_mod, + "_ngrok_config_candidates", + lambda: [tmp_path / "missing.yml"], + ) + assert tunnel_mod._check_ngrok_authtoken_configured() is False diff --git a/tests/unit/chat/ui/test_tunnel_auth.py b/tests/unit/chat/ui/test_tunnel_auth.py index ecc6d5b7f..4aa8847ea 100644 --- a/tests/unit/chat/ui/test_tunnel_auth.py +++ b/tests/unit/chat/ui/test_tunnel_auth.py @@ -206,3 +206,238 @@ def test_requests_pass_after_tunnel_stopped(self, app): # Now request should pass without auth resp = client.get("/api/sessions") assert resp.status_code == 200 + + +# โ”€โ”€ Tests: cookie-based auth (set by serve_spa ?token= bootstrap) โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class TestCookieAuth: + """Remote requests can authenticate via the gaia_tunnel_token cookie.""" + + def test_valid_cookie_allows_request(self, app): + """A request with the correct cookie is allowed through.""" + token = _activate_tunnel(app) + client = TestClient(app, cookies={"gaia_tunnel_token": token}) + resp = client.get("/api/sessions") + assert resp.status_code == 200 + + def test_wrong_cookie_rejected(self, app): + """A request with an incorrect cookie value is rejected.""" + _activate_tunnel(app) + client = TestClient(app, cookies={"gaia_tunnel_token": "bogus"}) + resp = client.get("/api/sessions") + assert resp.status_code == 401 + assert "Invalid tunnel" in resp.json()["detail"] + + def test_cookie_fallback_when_header_missing(self, app): + """Cookie is accepted when Authorization header is absent.""" + token = _activate_tunnel(app) + client = TestClient(app, cookies={"gaia_tunnel_token": token}) + resp = client.get("/api/system/status") + assert resp.status_code == 200 + + def test_header_and_cookie_both_valid(self, app): + """Valid header wins / both-valid also succeeds.""" + token = _activate_tunnel(app) + client = TestClient(app, cookies={"gaia_tunnel_token": token}) + resp = client.get( + "/api/sessions", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + + def test_valid_cookie_with_invalid_header(self, app): + """Invalid Bearer header with valid cookie: header takes precedence -> 401. + + This is intentional: we read the header first, and an explicitly + invalid header should surface a 401 rather than being silently + overridden by a cookie from an earlier session. + """ + token = _activate_tunnel(app) + client = TestClient(app, cookies={"gaia_tunnel_token": token}) + resp = client.get( + "/api/sessions", + headers={"Authorization": "Bearer not-the-right-token"}, + ) + assert resp.status_code == 401 + + +# โ”€โ”€ Tests: serve_spa cookie bootstrap (?token= -> Set-Cookie) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class TestSpaCookieBootstrap: + """serve_spa sets gaia_tunnel_token cookie when ?token= is present. + + Bootstrap response is a 303 redirect (with the cookie attached) to the + same path with the token stripped from the query string. This ensures + the token never lingers in the browser address bar, history, or + outbound ``Referer`` headers. Cookies use ``SameSite=Strict`` to + prevent cross-site attachment (CSRF defence-in-depth). + """ + + @pytest.fixture + def app_with_frontend(self, tmp_path): + """App with a minimal webui dist so serve_spa is registered.""" + dist = tmp_path / "dist" + (dist / "assets").mkdir(parents=True) + (dist / "index.html").write_text("gaia") + return create_app(db_path=":memory:", webui_dist=str(dist)) + + def test_valid_token_query_redirects_and_sets_cookie(self, app_with_frontend): + """Opening /?token= redirects to / with HttpOnly cookie set.""" + token = _activate_tunnel(app_with_frontend) + client = TestClient(app_with_frontend) + resp = client.get(f"/?token={token}", follow_redirects=False) + # 303 See Other: token-stripping redirect to the bare path. + assert resp.status_code == 303 + # Redirect target must be the same path with NO ?token=. + location = resp.headers.get("location", "") + assert location == "/", f"Expected '/', got {location!r}" + # Cookie attached to the redirect response. + set_cookie = resp.headers.get("set-cookie", "") + assert "gaia_tunnel_token" in set_cookie + assert token in set_cookie + assert "HttpOnly" in set_cookie + # SameSite=Strict โ€” cookie must NOT cross-site (CSRF defence). + assert "samesite=strict" in set_cookie.lower() + # TestClient parses the cookie into the jar. + assert client.cookies.get("gaia_tunnel_token") == token + + def test_token_strip_preserves_other_query_params(self, app_with_frontend): + """``?token=X&session=Y`` redirect must keep ``?session=Y``, drop ``token``.""" + token = _activate_tunnel(app_with_frontend) + client = TestClient(app_with_frontend) + resp = client.get(f"/?session=abc123&token={token}", follow_redirects=False) + assert resp.status_code == 303 + location = resp.headers.get("location", "") + assert "token=" not in location + assert "session=abc123" in location + + def test_invalid_token_query_does_not_set_cookie(self, app_with_frontend): + """Opening /?token= serves the index normally, no cookie.""" + _activate_tunnel(app_with_frontend) + client = TestClient(app_with_frontend) + resp = client.get("/?token=not-the-token") + assert resp.status_code == 200 + set_cookie = resp.headers.get("set-cookie", "") + assert "gaia_tunnel_token" not in set_cookie + + def test_no_token_query_does_not_set_cookie(self, app_with_frontend): + """Opening / without a token query does NOT set the cookie.""" + _activate_tunnel(app_with_frontend) + client = TestClient(app_with_frontend) + resp = client.get("/") + assert resp.status_code == 200 + set_cookie = resp.headers.get("set-cookie", "") + assert "gaia_tunnel_token" not in set_cookie + + def test_token_query_when_tunnel_inactive_does_not_set_cookie( + self, app_with_frontend + ): + """Bootstrap only happens when the tunnel is actually active.""" + # Don't activate -- tunnel is inactive by default. + client = TestClient(app_with_frontend) + resp = client.get("/?token=anything") + assert resp.status_code == 200 + set_cookie = resp.headers.get("set-cookie", "") + assert "gaia_tunnel_token" not in set_cookie + + def test_token_on_static_asset_does_NOT_set_cookie(self, app_with_frontend): + """A static-asset path (e.g. ``/index.html?token=...``) must NOT bootstrap. + + Security: the cookie-bootstrap path runs only on the SPA-index branch, + so a request to any real static file (favicon, JS, CSS) ignores the + ``?token=`` entirely. This shrinks the surface where a cookie can + be planted to the single legitimate landing path. + """ + token = _activate_tunnel(app_with_frontend) + client = TestClient(app_with_frontend) + # index.html exists in the fake dist directory and is reachable as + # a static asset (the ``:path`` route resolves it via _sanitize_static_path). + resp = client.get(f"/index.html?token={token}") + # Static-file branch returns the file directly (200), no redirect. + assert resp.status_code == 200 + set_cookie = resp.headers.get("set-cookie", "") + assert "gaia_tunnel_token" not in set_cookie + + def test_referrer_policy_no_referrer_on_index(self, app_with_frontend): + """Index responses carry ``Referrer-Policy: no-referrer``. + + Defence-in-depth: even if a token transiently appears in the URL + (between QR-scan landing and the redirect), no outbound request + the page makes will leak it via the ``Referer`` header. + """ + _activate_tunnel(app_with_frontend) + client = TestClient(app_with_frontend) + resp = client.get("/") + assert resp.status_code == 200 + assert resp.headers.get("referrer-policy", "").lower() == "no-referrer" + + def test_bootstrap_then_subsequent_api_call_succeeds(self, app_with_frontend): + """End-to-end: GET /?token= redirects + sets cookie, /api/sessions works.""" + token = _activate_tunnel(app_with_frontend) + client = TestClient(app_with_frontend) + + # Step 1: visit bootstrap URL -- should redirect (303) and set cookie. + resp = client.get(f"/?token={token}", follow_redirects=False) + assert resp.status_code == 303 + assert client.cookies.get("gaia_tunnel_token") == token + + # Step 2: subsequent API call reuses the cookie -- must succeed. + resp = client.get("/api/sessions") + assert resp.status_code == 200, ( + f"Expected 200 after cookie bootstrap, got {resp.status_code}: " + f"{resp.text}" + ) + + +# โ”€โ”€ Tests: spoof-resistant localhost bypass โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class TestForwardedHeaderSpoofRejected: + """Localhost bypass MUST require the absence of ``X-Forwarded-*`` headers. + + Without this gate, a remote attacker through the ngrok tunnel could + send ``X-Forwarded-For: 127.0.0.1`` and -- if the framework rewrites + ``request.client.host`` based on the header -- impersonate the + Electron desktop app, bypassing tunnel auth entirely. + + These tests pin the contract regardless of whether the framework + happens to do that rewrite today. + """ + + def test_forwarded_for_blocks_bypass(self, app): + """Even from a localhost peer, ``X-Forwarded-For`` forces auth.""" + _activate_tunnel(app) + client = TestClient(app) + # TestClient peer is "testclient", not in _LOCAL_HOSTS, so the + # bypass would not apply anyway -- but adding X-Forwarded-For + # is the realistic shape an attacker would use, and we lock in + # that auth is still required (not silently skipped). + resp = client.get( + "/api/sessions", + headers={"X-Forwarded-For": "127.0.0.1"}, + ) + assert ( + resp.status_code == 401 + ), "Spoofed X-Forwarded-For: 127.0.0.1 must NOT bypass tunnel auth" + + def test_forwarded_host_blocks_bypass(self, app): + """``X-Forwarded-Host`` set to a tunnel host also forces auth.""" + _activate_tunnel(app) + client = TestClient(app) + resp = client.get( + "/api/sessions", + headers={"X-Forwarded-Host": "fake.ngrok-free.app"}, + ) + assert resp.status_code == 401 + + def test_forwarded_proto_blocks_bypass(self, app): + """``X-Forwarded-Proto`` is enough to force auth too.""" + _activate_tunnel(app) + client = TestClient(app) + resp = client.get( + "/api/sessions", + headers={"X-Forwarded-Proto": "https"}, + ) + assert resp.status_code == 401 diff --git a/tests/unit/connectors/__init__.py b/tests/unit/connectors/__init__.py new file mode 100644 index 000000000..53bd49073 --- /dev/null +++ b/tests/unit/connectors/__init__.py @@ -0,0 +1,2 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT diff --git a/tests/unit/connectors/conftest.py b/tests/unit/connectors/conftest.py new file mode 100644 index 000000000..11188afc7 --- /dev/null +++ b/tests/unit/connectors/conftest.py @@ -0,0 +1,74 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Connections-test fixtures. + +Autouse fixtures here apply to every test under ``tests/unit/connectors/`` +and ensure each test runs against a deterministic in-memory keyring backend +and a clean per-test access-token cache. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _autouse_in_memory_keyring(in_memory_keyring): # noqa: F811 + """ + Force every connections test through the session-scoped in-memory keyring. + + Linux CI runners do not ship SecretService and the production-default + ``keyrings.alt`` fallback writes plaintext; ``gaia.connectors.store`` + explicitly refuses that backend, so without this fixture every test would + raise on first ``save_connection`` or first ``load_connection``. + + Depends on the session-scoped ``in_memory_keyring`` fixture from + ``tests/conftest.py``. Clears the backing dict between tests so state + from a previous test does not leak. + """ + # Some tests temporarily install an alternate backend (e.g. PlaintextKeyring + # to assert refusal). Re-install the in-memory backend at the start of + # each test so subsequent tests see the deterministic fixture. + import keyring + + keyring.set_keyring(in_memory_keyring) + in_memory_keyring._store.clear() + yield in_memory_keyring + in_memory_keyring._store.clear() + + +@pytest.fixture(autouse=True) +def _autouse_reset_token_cache(): + """ + Reset the module-level token cache between tests. + + The cache is a process-wide singleton; without resetting it, AC6's + "10 concurrent calls = 1 refresh round-trip" test would observe a + cached token from an earlier test. Imports lazily so this fixture + file does not pull in ``httpx`` at collection time. + """ + try: + from gaia.connectors import tokens + except ImportError: + # Module not yet importable during early TDD iterations. + yield + return + + if hasattr(tokens, "_cache"): + tokens._cache.clear() + yield + if hasattr(tokens, "_cache"): + tokens._cache.clear() + + +@pytest.fixture(autouse=True) +def _autouse_isolate_home(tmp_path, monkeypatch): + """ + Redirect ``Path.home()`` for every grants/mcp_servers reader+writer + to a per-test ``tmp_path`` so connector tests can never contaminate + the developer's real ``~/.gaia/`` files. Belt-and-braces alongside + the explicit per-file ``fake_home`` fixtures. + """ + monkeypatch.setattr("gaia.connectors.grants.Path.home", lambda: tmp_path) + monkeypatch.setattr("gaia.connectors.mcp_server.Path.home", lambda: tmp_path) diff --git a/tests/unit/connectors/test_agent_bridge.py b/tests/unit/connectors/test_agent_bridge.py new file mode 100644 index 000000000..d855ee261 --- /dev/null +++ b/tests/unit/connectors/test_agent_bridge.py @@ -0,0 +1,188 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +T-X1-bridge: syncโ†’async bridge under ``ThreadPoolExecutor``. + +Per plan amendment A15, this test must explicitly use +``ThreadPoolExecutor`` because that's the production path: + + Agent.process_query (sync, ThreadPoolExecutor worker) + โ””โ”€โ†’ tool body + โ””โ”€โ†’ get_access_token_sync(...) # sync + โ””โ”€โ†’ asyncio.run(get_access_token(...)) # async + โ””โ”€โ†’ tokens.get_or_refresh + โ””โ”€โ†’ httpx.AsyncClient + +The contextvar set by ``Agent.process_query`` (via ``_agent_context``) must +flow through ``asyncio.run``'s ``contextvars.copy_context()`` to the async +side. Tests that call ``get_access_token_sync`` from the main thread are +not exercising the production bridge. +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor + +import httpx +import pytest +import respx + +from gaia.connectors import ( + AuthRequiredError, + get_access_token_sync, + grant_agent, +) +from gaia.connectors.context import _agent_context, current_agent_id +from gaia.connectors.providers import _registry +from gaia.connectors.store import save_connection + + +@pytest.fixture +def google_provider(monkeypatch, tmp_path): + monkeypatch.setenv("GAIA_GOOGLE_CLIENT_ID", "test.apps.example") + monkeypatch.setattr("gaia.connectors.grants.Path.home", lambda: tmp_path) + _registry.clear() + from gaia.connectors.providers import get as get_provider + + return get_provider("google") + + +@pytest.fixture +def seeded(google_provider): + save_connection( + provider="google", + account_email="alice@example.com", + refresh_token="seed-rt", + scopes=["gmail.readonly"], + client_id_hash=google_provider.client_id_hash, + ) + return google_provider + + +def _ok_token(): + return httpx.Response( + 200, json={"access_token": "BEARER", "expires_in": 3600, "scope": "x"} + ) + + +class TestThreadPoolBridge: + """The agent runtime runs ``process_query`` in a ThreadPoolExecutor + worker; the contextvar set inside that worker must propagate into the + inner ``asyncio.run`` context.""" + + @respx.mock + def test_contextvar_propagates_via_asyncio_run(self, seeded): + respx.post("https://oauth2.googleapis.com/token").mock(return_value=_ok_token()) + grant_agent("google", "builtin:chat", ["gmail.readonly"]) + + results: dict = {} + + def worker(): + with _agent_context("builtin:chat"): + # Sanity: the ctx is set in this thread. + results["before"] = current_agent_id() + results["token"] = get_access_token_sync( + provider="google", scopes=["gmail.readonly"] + ) + + with ThreadPoolExecutor(max_workers=2) as pool: + pool.submit(worker).result(timeout=5.0) + + assert results["before"] == "builtin:chat" + assert results["token"] == "BEARER" + + @respx.mock + def test_no_grant_raises_in_thread_pool(self, seeded): + # Same setup but no grant for builtin:chat. + respx.post("https://oauth2.googleapis.com/token").mock(return_value=_ok_token()) + + captured = {} + + def worker(): + with _agent_context("builtin:chat"): + try: + get_access_token_sync(provider="google", scopes=["gmail.readonly"]) + except AuthRequiredError as e: + captured["err"] = e + + with ThreadPoolExecutor(max_workers=2) as pool: + pool.submit(worker).result(timeout=5.0) + + err = captured.get("err") + assert err is not None + assert err.reason is AuthRequiredError.Reason.AGENT_NOT_GRANTED + assert err.agent_id == "builtin:chat" + assert err.provider == "google" + + @respx.mock + def test_kwarg_overrides_contextvar(self, seeded): + # Plan: kwarg agent_id wins over the contextvar (explicit over implicit). + respx.post("https://oauth2.googleapis.com/token").mock(return_value=_ok_token()) + grant_agent("google", "explicit:agent", ["gmail.readonly"]) + + results = {} + + def worker(): + with _agent_context("builtin:chat"): + # Pass an explicit different agent_id โ€” it must win. + results["token"] = get_access_token_sync( + provider="google", + scopes=["gmail.readonly"], + agent_id="explicit:agent", + ) + + with ThreadPoolExecutor(max_workers=2) as pool: + pool.submit(worker).result(timeout=5.0) + + assert results["token"] == "BEARER" + + +class TestThreadIsolation: + """A15: contextvar must not leak across threads โ€” a worker that did + NOT enter ``_agent_context`` sees ``current_agent_id() is None``.""" + + def test_worker_without_context_sees_none(self): + observed: list = [] + + def child(): + observed.append(current_agent_id()) + + with _agent_context("builtin:chat"): + with ThreadPoolExecutor(max_workers=1) as pool: + pool.submit(child).result(timeout=2.0) + + assert observed == [None] + + +class TestSequentialAgentInvocations: + """ + Two sequential agent invocations through the syncโ†’async bridge each + return a valid token, and the second uses the in-thread cache when + the first thread's token is still valid. + + Cross-thread *concurrent* refresh is an explicit non-guarantee in v1: + AC6 ("N concurrent calls = 1 refresh round-trip") is scoped to a + single ``asyncio`` event loop, because ``asyncio.Lock`` is per-loop. + Multiple threads each running ``asyncio.run`` will each create their + own event loop and may each fire a refresh round-trip independently + โ€” correct but not optimal. See ``docs/security/connections.mdx`` + "Cross-process / cross-thread races". + """ + + @respx.mock + def test_two_sequential_invocations_in_thread_pool(self, seeded): + respx.post("https://oauth2.googleapis.com/token").mock(return_value=_ok_token()) + grant_agent("google", "builtin:chat", ["gmail.readonly"]) + + def worker(): + with _agent_context("builtin:chat"): + return get_access_token_sync( + provider="google", scopes=["gmail.readonly"] + ) + + with ThreadPoolExecutor(max_workers=1) as pool: + tok1 = pool.submit(worker).result(timeout=5.0) + tok2 = pool.submit(worker).result(timeout=5.0) + + assert tok1 == "BEARER" + assert tok2 == "BEARER" diff --git a/tests/unit/connectors/test_api.py b/tests/unit/connectors/test_api.py new file mode 100644 index 000000000..5a6292c99 --- /dev/null +++ b/tests/unit/connectors/test_api.py @@ -0,0 +1,176 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +T-9a (AC8, AC9): public API surface tests for ``gaia.connectors.api``. + +Coverage: +- ``get_access_token`` agent_id resolution: explicit kwarg โ†’ contextvar โ†’ + None. +- ``agent_id=None`` skips the per-agent grant check (CLI debug path). +- ``agent_id`` set with no grant โ†’ ``AuthRequiredError(AGENT_NOT_GRANTED)``. +- Granted scopes that don't cover the OAuth grant โ†’ ``AuthRequiredError( + CONNECTION_MISSING_SCOPES)``. +- ``start_authorization`` and ``complete_authorization`` exposed at + package level. +- ``list_connections``, ``get_connection``, ``revoke_connection``, + ``grant_agent``, ``revoke_agent_grant``, ``list_agent_grants`` all + importable and callable. +""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from gaia.connectors import ( + AuthRequiredError, + get_access_token, + grant_agent, + list_agent_grants, + list_connections, + revoke_agent_grant, + revoke_connection, +) +from gaia.connectors.context import _agent_context +from gaia.connectors.providers import _registry +from gaia.connectors.store import save_connection + + +@pytest.fixture +def google_provider(monkeypatch, tmp_path): + monkeypatch.setenv("GAIA_GOOGLE_CLIENT_ID", "test.apps.example") + monkeypatch.setattr("gaia.connectors.grants.Path.home", lambda: tmp_path) + _registry.clear() + from gaia.connectors.providers import get as get_provider + + return get_provider("google") + + +@pytest.fixture +def seeded(google_provider): + save_connection( + provider="google", + account_email="alice@example.com", + refresh_token="seed-rt", + scopes=["gmail.readonly"], + client_id_hash=google_provider.client_id_hash, + ) + return google_provider + + +def _ok_token(): + return httpx.Response( + 200, + json={"access_token": "ACCESS-1", "expires_in": 3600, "scope": "x"}, + ) + + +class TestGetAccessTokenAgentResolution: + @respx.mock + async def test_explicit_agent_id_kwarg_used_directly(self, seeded): + respx.post("https://oauth2.googleapis.com/token").mock(return_value=_ok_token()) + grant_agent("google", "builtin:chat", ["gmail.readonly"]) + token = await get_access_token( + provider="google", + scopes=["gmail.readonly"], + agent_id="builtin:chat", + ) + assert token == "ACCESS-1" + + @respx.mock + async def test_agent_id_resolved_from_contextvar(self, seeded): + respx.post("https://oauth2.googleapis.com/token").mock(return_value=_ok_token()) + grant_agent("google", "builtin:chat", ["gmail.readonly"]) + with _agent_context("builtin:chat"): + token = await get_access_token(provider="google", scopes=["gmail.readonly"]) + assert token == "ACCESS-1" + + @respx.mock + async def test_agent_id_none_skips_grant_check(self, seeded): + # AC8 explicit opt-out: agent_id=None bypasses the per-agent + # grant check (CLI/debugging path). NOT a silent fallback โ€” + # it's documented and tested. + respx.post("https://oauth2.googleapis.com/token").mock(return_value=_ok_token()) + token = await get_access_token( + provider="google", scopes=["gmail.readonly"], agent_id=None + ) + assert token == "ACCESS-1" + + +class TestGrantEnforcement: + @respx.mock + async def test_no_grant_raises_agent_not_granted(self, seeded): + respx.post("https://oauth2.googleapis.com/token").mock(return_value=_ok_token()) + with pytest.raises(AuthRequiredError) as exc: + await get_access_token( + provider="google", + scopes=["gmail.readonly"], + agent_id="builtin:chat", + ) + assert exc.value.reason is AuthRequiredError.Reason.AGENT_NOT_GRANTED + assert exc.value.agent_id == "builtin:chat" + assert exc.value.provider == "google" + + @respx.mock + async def test_partial_grant_raises_agent_not_granted(self, seeded): + # Agent granted only readonly; tool requests send too. + respx.post("https://oauth2.googleapis.com/token").mock(return_value=_ok_token()) + grant_agent("google", "builtin:chat", ["gmail.readonly"]) + with pytest.raises(AuthRequiredError) as exc: + await get_access_token( + provider="google", + scopes=["gmail.send"], + agent_id="builtin:chat", + ) + assert exc.value.reason is AuthRequiredError.Reason.AGENT_NOT_GRANTED + + +class TestScopeCoverage: + @respx.mock + async def test_oauth_grant_missing_scope_raises_missing(self, google_provider): + # OAuth connection has only readonly; agent tool requests send. + save_connection( + provider="google", + account_email="a@example.com", + refresh_token="rt", + scopes=["gmail.readonly"], + client_id_hash=google_provider.client_id_hash, + ) + # Agent IS granted gmail.send, but the OAuth connection is not. + grant_agent("google", "builtin:chat", ["gmail.send"]) + + respx.post("https://oauth2.googleapis.com/token").mock(return_value=_ok_token()) + with pytest.raises(AuthRequiredError) as exc: + await get_access_token( + provider="google", + scopes=["gmail.send"], + agent_id="builtin:chat", + ) + assert exc.value.reason is AuthRequiredError.Reason.CONNECTION_MISSING_SCOPES + assert "gmail.send" in exc.value.missing_scopes + + +class TestPublicSurface: + def test_grant_round_trip_via_public_api(self, google_provider): + grant_agent("google", "builtin:chat", ["gmail.readonly"]) + listing = list_agent_grants("google") + assert listing["builtin:chat"] == ["gmail.readonly"] + + def test_revoke_agent_grant_via_public_api(self, google_provider): + grant_agent("google", "builtin:chat", ["s"]) + revoke_agent_grant("google", "builtin:chat") + assert list_agent_grants("google") == {} + + def test_list_connections_via_public_api(self, seeded): + rows = list_connections() + providers = {row["provider"] for row in rows} + assert "google" in providers + # The returned shape includes metadata but never the refresh token. + google_row = next(row for row in rows if row["provider"] == "google") + assert "refresh_token" not in google_row + assert google_row["account_email"] == "alice@example.com" + + def test_revoke_connection_via_public_api(self, seeded): + revoke_connection("google") + assert list_connections() == [] diff --git a/tests/unit/connectors/test_cli.py b/tests/unit/connectors/test_cli.py new file mode 100644 index 000000000..70114cce1 --- /dev/null +++ b/tests/unit/connectors/test_cli.py @@ -0,0 +1,140 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +T-CLI: ``gaia connectors`` subcommand tests. + +Covers the thin wrappers in ``src/gaia/connectors/cli.py`` that delegate +to ``gaia.connectors.api``. The actual flow / token / grant logic is +tested elsewhere; these tests verify wiring + output shape + exit codes. +""" + +from __future__ import annotations + +import json + +import pytest + +from gaia.connectors import cli as connections_cli +from gaia.connectors.providers import _registry + + +@pytest.fixture(autouse=True) +def fake_home(tmp_path, monkeypatch): + """Isolated grants/mcp_servers dirs per test.""" + monkeypatch.setattr("gaia.connectors.grants.Path.home", lambda: tmp_path) + monkeypatch.setattr("gaia.connectors.mcp_server.Path.home", lambda: tmp_path) + monkeypatch.setenv("GAIA_GOOGLE_CLIENT_ID", "test.apps.example") + _registry.clear() + yield + + +def _seed_google(account_email: str) -> None: + """Helper: write a Google keyring blob (the source of truth for + ``configured`` after the state.json removal).""" + from gaia.connectors.providers import get as get_provider + from gaia.connectors.store import save_connection + + save_connection( + provider="google", + account_email=account_email, + refresh_token="seed", + scopes=["s"], + client_id_hash=get_provider("google").client_id_hash, + ) + + +def _run(*argv) -> tuple[int, str, str]: + import sys + from io import StringIO + + out = StringIO() + err = StringIO() + saved_out, saved_err = sys.stdout, sys.stderr + sys.stdout, sys.stderr = out, err + try: + rc = connections_cli.main(list(argv)) + except SystemExit as e: + rc = e.code if isinstance(e.code, int) else 1 + finally: + sys.stdout, sys.stderr = saved_out, saved_err + return rc, out.getvalue(), err.getvalue() + + +class TestStatus: + def test_status_empty(self): + # list/status shows catalog entries; google is always in the catalog + rc, out, _err = _run("connectors", "status") + assert rc == 0 + assert "google" in out + assert "not configured" in out + + def test_status_seeded(self): + _seed_google("alice@example.com") + rc, out, _err = _run("connectors", "status") + assert rc == 0 + assert "alice@example.com" in out + assert "google" in out + + def test_status_json(self): + sentinel_token = "TOKEN-MUST-NOT-LEAK-12345" + rc, out, _err = _run("connectors", "status", "--json") + assert rc == 0 + rows = json.loads(out) + assert any(row["id"] == "google" for row in rows) + # Credentials must not appear in the output. + assert sentinel_token not in out + assert "refresh_token" not in out + + +class TestGrants: + def test_grants_grant_then_list(self): + rc, _out, _err = _run( + "connectors", + "grants", + "grant", + "google", + "builtin:chat", + "--scopes", + "gmail.readonly", + ) + assert rc == 0 + + rc2, out2, _err2 = _run("connectors", "grants", "list", "google") + assert rc2 == 0 + assert "builtin:chat" in out2 + assert "gmail.readonly" in out2 + + def test_grants_revoke(self): + _run( + "connectors", + "grants", + "grant", + "google", + "builtin:chat", + "--scopes", + "gmail.readonly", + ) + rc, _out, _err = _run( + "connectors", "grants", "revoke", "google", "builtin:chat" + ) + assert rc == 0 + rc2, out2, _err2 = _run("connectors", "grants", "list", "google") + assert "No grants" in out2 or "builtin:chat" not in out2 + + def test_grants_list_empty_default_provider(self): + rc, out, _err = _run("connectors", "grants", "list") + assert rc == 0 + assert "No grants" in out + + +class TestDisconnect: + def test_disconnect_idempotent(self): + rc, _out, _err = _run("connectors", "disconnect", "google") + # Idempotent โ€” works even when nothing to disconnect. + assert rc == 0 + + +class TestMissingSubcommand: + def test_no_subcommand_returns_exit_2(self): + rc, _out, _err = _run("connectors") + assert rc == 2 diff --git a/tests/unit/connectors/test_context.py b/tests/unit/connectors/test_context.py new file mode 100644 index 000000000..29e082826 --- /dev/null +++ b/tests/unit/connectors/test_context.py @@ -0,0 +1,127 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Tests for ``gaia.connectors.context`` โ€” the agent-id contextvar plumbing. + +Per A9 of the plan, ``_agent_context`` is **PRIVATE** (leading underscore, +not re-exported from the package). A malicious tool body cannot import it +to forge an agent identity. The agent runtime imports it via the private +path ``from gaia.connectors.context import _agent_context``. + +``current_agent_id`` IS public โ€” tools may read the current agent id but +not set it. +""" + +from __future__ import annotations + +import asyncio +import threading + +from gaia.connectors.context import _agent_context, current_agent_id + + +class TestBasicSetAndRestore: + def test_outside_context_returns_none(self): + assert current_agent_id() is None + + def test_inside_context_returns_id(self): + with _agent_context("builtin:chat"): + assert current_agent_id() == "builtin:chat" + + def test_context_restored_on_exit(self): + assert current_agent_id() is None + with _agent_context("builtin:chat"): + pass + assert current_agent_id() is None + + def test_nested_contexts_restore_correctly(self): + with _agent_context("builtin:chat"): + assert current_agent_id() == "builtin:chat" + with _agent_context("custom:abc:inbox"): + assert current_agent_id() == "custom:abc:inbox" + # Outer context is preserved on inner-block exit. + assert current_agent_id() == "builtin:chat" + assert current_agent_id() is None + + def test_exception_in_block_still_restores_context(self): + try: + with _agent_context("builtin:chat"): + raise RuntimeError("boom") + except RuntimeError: + pass + assert current_agent_id() is None + + +class TestNotPubliclyExported: + """Per A9: only ``_agent_context`` (private) sets the contextvar; the + package surface does NOT re-export it. A tool body that tries + ``from gaia.connectors import agent_context`` fails.""" + + def test_not_in_package_init(self): + import gaia.connectors as conn + + assert not hasattr(conn, "agent_context") + + def test_not_in_api_module(self): + from gaia.connectors import api + + assert not hasattr(api, "agent_context") + + def test_current_agent_id_is_public(self): + # Reading is allowed; setting is private. + import gaia.connectors.context as ctx + + assert hasattr(ctx, "current_agent_id") + assert callable(ctx.current_agent_id) + + +class TestThreadIsolation: + """ContextVars are thread-local in CPython. Verify that setting the + context in the main thread does NOT leak into a worker thread that did + not enter the context manager. + """ + + def test_contextvar_does_not_leak_across_threads(self): + observed: list[str | None] = [] + + def worker(): + observed.append(current_agent_id()) + + with _agent_context("builtin:chat"): + t = threading.Thread(target=worker) + t.start() + t.join() + + assert observed == [None] + + +class TestAsyncioPropagation: + """``asyncio`` tasks inherit the parent's context (via copy_context). + This is what makes the sync agent body โ†’ ``asyncio.run`` โ†’ async + refresh path resolve agent_id from the contextvar. + """ + + async def test_context_propagates_to_async_task(self): + observed: list[str | None] = [] + + async def child(): + observed.append(current_agent_id()) + + with _agent_context("builtin:chat"): + await child() + + assert observed == ["builtin:chat"] + + def test_asyncio_run_inherits_caller_thread_context(self): + # This mirrors the real syncโ†’async bridge: agent runtime sets the + # context, calls get_access_token_sync, which calls asyncio.run. + # The new event loop must inherit the calling thread's contextvars. + observed: list[str | None] = [] + + async def fetch(): + observed.append(current_agent_id()) + + with _agent_context("builtin:chat"): + asyncio.run(fetch()) + + assert observed == ["builtin:chat"] diff --git a/tests/unit/connectors/test_e2e_smoke.py b/tests/unit/connectors/test_e2e_smoke.py new file mode 100644 index 000000000..f0d587f26 --- /dev/null +++ b/tests/unit/connectors/test_e2e_smoke.py @@ -0,0 +1,239 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +T-9 E2E smoke tests โ€” connectors framework end-to-end. + +These tests exercise the full vertical slice: CLI โ†’ handler โ†’ state store +โ†’ grants ledger โ†’ router, using only in-memory / tmp-path fakes for the +keyring and filesystem. They verify that the three caller surfaces +(CLI, SDK, HTTP router) are consistent after each operation. +""" + +from __future__ import annotations + +import json + +import pytest + +from gaia.connectors import cli as connectors_cli +from gaia.connectors.providers import _registry as _oauth_provider_registry + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Shared helpers +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def _run(*argv) -> tuple[int, str, str]: + import sys + from io import StringIO + + out, err = StringIO(), StringIO() + saved_out, saved_err = sys.stdout, sys.stderr + sys.stdout, sys.stderr = out, err + try: + rc = connectors_cli.main(list(argv)) + except SystemExit as e: + rc = e.code if isinstance(e.code, int) else 1 + finally: + sys.stdout, sys.stderr = saved_out, saved_err + return rc, out.getvalue(), err.getvalue() + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Fixtures +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@pytest.fixture(autouse=True) +def isolated_env(tmp_path, monkeypatch): + """Isolate filesystem and env for every smoke test.""" + monkeypatch.setattr("gaia.connectors.grants.Path.home", lambda: tmp_path) + monkeypatch.setattr("gaia.connectors.mcp_server.Path.home", lambda: tmp_path) + monkeypatch.setenv("GAIA_GOOGLE_CLIENT_ID", "test.apps.example") + # Clear the OAuth provider cache (not the catalog registry). + _oauth_provider_registry.clear() + yield + + +def _seed_google_connection(account_email: str, scopes=("openid",)) -> None: + """Helper: write a Google keyring blob the same way the OAuth flow + would, so live readers (CLI status, router catalog) see the + connector as configured. Replaces the old ``set_connector_state`` + seeding pattern now that the keyring blob is the source of truth. + """ + from gaia.connectors.providers import get as get_provider + from gaia.connectors.store import save_connection + + provider = get_provider("google") + save_connection( + provider="google", + account_email=account_email, + refresh_token="seed-refresh", + scopes=list(scopes), + client_id_hash=provider.client_id_hash, + ) + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Smoke: catalog is populated and CLI reflects it +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class TestCatalogSmoke: + def test_status_lists_google(self): + """CLI status lists google connector from catalog.""" + rc, out, _ = _run("connectors", "status") + assert rc == 0 + assert "google" in out + + def test_status_json_has_connectors(self): + """JSON mode returns a non-empty list.""" + rc, out, _ = _run("connectors", "status", "--json") + assert rc == 0 + rows = json.loads(out) + assert isinstance(rows, list) + assert len(rows) > 0 + ids = {r["id"] for r in rows} + assert "google" in ids + + def test_status_json_no_secrets(self): + """Connector status JSON must not contain any token/secret fields.""" + rc, out, _ = _run("connectors", "status", "--json") + assert rc == 0 + assert "refresh_token" not in out + assert "access_token" not in out + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Smoke: grants ledger round-trip via CLI +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class TestGrantsSmoke: + def test_grant_and_list(self): + """Grant a scope then verify it appears in the list.""" + rc, _, _ = _run( + "connectors", + "grants", + "grant", + "google", + "builtin:chat", + "--scopes", + "https://www.googleapis.com/auth/gmail.readonly", + ) + assert rc == 0 + + rc2, out2, _ = _run("connectors", "grants", "list", "google") + assert rc2 == 0 + assert "builtin:chat" in out2 + assert "gmail.readonly" in out2 + + def test_revoke_clears_grant(self): + """Revoke removes the grant from the ledger.""" + _run( + "connectors", + "grants", + "grant", + "google", + "builtin:chat", + "--scopes", + "gmail.readonly", + ) + rc, _, _ = _run("connectors", "grants", "revoke", "google", "builtin:chat") + assert rc == 0 + + rc2, out2, _ = _run("connectors", "grants", "list", "google") + assert rc2 == 0 + assert "builtin:chat" not in out2 + + def test_grants_empty_by_default(self): + """Fresh install has no grants.""" + rc, out, _ = _run("connectors", "grants", "list") + assert rc == 0 + assert "No grants" in out + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Smoke: state store + CLI consistency +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class TestStateSyncSmoke: + def test_seeded_state_appears_in_cli_status(self): + """A keyring-saved connection is reflected in CLI status.""" + _seed_google_connection("smoke@example.com") + rc, out, _ = _run("connectors", "status") + assert rc == 0 + assert "smoke@example.com" in out + + def test_seeded_state_appears_in_json(self): + """JSON status output reflects keyring-saved connection.""" + _seed_google_connection("json@example.com") + rc, out, _ = _run("connectors", "status", "--json") + assert rc == 0 + rows = json.loads(out) + google = next((r for r in rows if r["id"] == "google"), None) + assert google is not None + assert google["configured"] is True + assert google["account_id"] == "json@example.com" + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Smoke: disconnect is idempotent +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class TestDisconnectSmoke: + def test_disconnect_unknown_does_not_crash(self): + """Disconnect on an unconfigured connector exits 0 (idempotent).""" + rc, _, _ = _run("connectors", "disconnect", "google") + assert rc == 0 + + def test_disconnect_clears_state(self): + """Disconnect removes a previously seeded keyring entry.""" + from gaia.connectors.store import peek_connection + + _seed_google_connection("bye@example.com") + assert peek_connection("google") is not None + + rc, _, _ = _run("connectors", "disconnect", "google") + assert rc == 0 + + blob = peek_connection("google") + assert blob is None, f"Expected entry cleared after disconnect, got: {blob}" + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Smoke: router reflects CLI operations +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class TestRouterSyncSmoke: + def test_router_lists_catalog_after_cli_configure(self, ui_api_client): + """A keyring-saved connection is visible through the HTTP router.""" + _seed_google_connection("router@example.com") + r = ui_api_client.get("/api/connectors") + assert r.status_code == 200 + data = r.json() + assert "connectors" in data + google = next((c for c in data["connectors"] if c["id"] == "google"), None) + assert google is not None + assert google["configured"] is True + assert google["account_id"] == "router@example.com" + + def test_router_grants_match_cli_grants(self, ui_api_client): + """Grants written by CLI are visible through the router grants endpoint.""" + from gaia.connectors.grants import grant_agent + + grant_agent( + "google", + "builtin:chat", + ["https://www.googleapis.com/auth/gmail.readonly"], + ) + r = ui_api_client.get("/api/connectors/google/grants") + assert r.status_code == 200 + grants = r.json()["grants"] + assert "builtin:chat" in grants + assert ( + "https://www.googleapis.com/auth/gmail.readonly" in grants["builtin:chat"] + ) diff --git a/tests/unit/connectors/test_errors.py b/tests/unit/connectors/test_errors.py new file mode 100644 index 000000000..0399420a0 --- /dev/null +++ b/tests/unit/connectors/test_errors.py @@ -0,0 +1,163 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Tests for ``gaia.connectors.errors``. + +Acceptance: every error type subclasses ``ConnectorsError``, AuthRequiredError +exposes a ``Reason`` enum with exactly the four documented values, and every +error message names what failed / what to do / where to look (per CLAUDE.md +"fail loudly" rule). +""" + +from __future__ import annotations + +import pytest + +from gaia.connectors.errors import ( + AuthRequiredError, + ConfigurationError, + ConnectionRevokedError, + ConnectorsError, + ConsentDeniedError, + FlowInProgressError, + FlowTimeoutError, + ScopeMismatchError, +) + + +class TestHierarchy: + def test_every_error_is_a_connections_error(self): + assert issubclass(AuthRequiredError, ConnectorsError) + assert issubclass(ConnectionRevokedError, ConnectorsError) + assert issubclass(ScopeMismatchError, ConnectorsError) + assert issubclass(ConsentDeniedError, ConnectorsError) + assert issubclass(FlowTimeoutError, ConnectorsError) + assert issubclass(FlowInProgressError, ConnectorsError) + assert issubclass(ConfigurationError, ConnectorsError) + + def test_connections_error_is_an_exception(self): + assert issubclass(ConnectorsError, Exception) + + +class TestAuthRequiredErrorReason: + def test_reason_enum_has_exactly_four_values(self): + values = {r.value for r in AuthRequiredError.Reason} + assert values == { + "not_connected", + "agent_not_granted", + "connection_missing_scopes", + "reauth_required", + } + + def test_reason_enum_is_string_serializable(self): + # Router serializes reasons into JSON; enum must coerce to str cleanly. + assert str(AuthRequiredError.Reason.NOT_CONNECTED.value) == "not_connected" + + def test_construction_records_reason_and_metadata(self): + err = AuthRequiredError( + AuthRequiredError.Reason.AGENT_NOT_GRANTED, + provider="google", + agent_id="builtin:chat", + ) + assert err.reason is AuthRequiredError.Reason.AGENT_NOT_GRANTED + assert err.provider == "google" + assert err.agent_id == "builtin:chat" + + def test_message_names_what_to_do(self): + # Per CLAUDE.md, every error message names: what failed, what to do, + # where to look. AGENT_NOT_GRANTED messages must mention granting. + err = AuthRequiredError( + AuthRequiredError.Reason.AGENT_NOT_GRANTED, + provider="google", + agent_id="inbox_zero", + ) + msg = str(err).lower() + assert "google" in msg + assert "grant" in msg + + def test_not_connected_reason_directs_to_connect(self): + err = AuthRequiredError( + AuthRequiredError.Reason.NOT_CONNECTED, + provider="google", + ) + msg = str(err).lower() + assert "connect" in msg + assert "google" in msg + + def test_reauth_required_reason_mentions_reauthorize(self): + err = AuthRequiredError( + AuthRequiredError.Reason.REAUTH_REQUIRED, + provider="google", + ) + msg = str(err).lower() + # Acceptable: "reauth", "re-auth", "reauthorize", "re-authorize", + # "reconnect", or "authenticate again". Must direct user to act. + assert any(token in msg for token in ("reauth", "re-auth", "reconnect")) + + +class TestScopeMismatchError: + def test_required_and_granted_attributes_set(self): + err = ScopeMismatchError( + required=["gmail.readonly", "gmail.send"], + granted=["gmail.readonly"], + provider="google", + ) + assert err.required == ["gmail.readonly", "gmail.send"] + assert err.granted == ["gmail.readonly"] + assert err.provider == "google" + + def test_message_names_missing_scopes(self): + err = ScopeMismatchError( + required=["gmail.send"], + granted=["gmail.readonly"], + provider="google", + ) + assert "gmail.send" in str(err) + + def test_missing_scopes_property(self): + err = ScopeMismatchError( + required=["a", "b", "c"], + granted=["a"], + provider="google", + ) + assert sorted(err.missing_scopes) == ["b", "c"] + + +class TestConnectionRevokedError: + def test_provider_attribute_set(self): + err = ConnectionRevokedError(provider="google") + assert err.provider == "google" + + def test_message_directs_to_reconnect(self): + err = ConnectionRevokedError(provider="google") + msg = str(err).lower() + assert "google" in msg + assert any(token in msg for token in ("reconnect", "reauth", "re-auth")) + + +class TestConsentDeniedError: + def test_subclass(self): + # OAuth ?error=access_denied surfaces here. + with pytest.raises(ConnectorsError): + raise ConsentDeniedError("user denied consent") + + +class TestFlowTimeoutAndInProgress: + def test_flow_timeout_subclass(self): + with pytest.raises(ConnectorsError): + raise FlowTimeoutError("flow exceeded 120s") + + def test_flow_in_progress_subclass(self): + with pytest.raises(ConnectorsError): + raise FlowInProgressError("a flow is already pending") + + +class TestConfigurationError: + def test_message_names_env_var_when_provided(self): + err = ConfigurationError( + "GAIA_GOOGLE_CLIENT_ID is not set; see " + "docs/runbooks/google-oauth-client.md" + ) + s = str(err) + assert "GAIA_GOOGLE_CLIENT_ID" in s + assert "docs/runbooks/google-oauth-client.md" in s diff --git a/tests/unit/connectors/test_flow.py b/tests/unit/connectors/test_flow.py new file mode 100644 index 000000000..73efb95e7 --- /dev/null +++ b/tests/unit/connectors/test_flow.py @@ -0,0 +1,275 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +T-7a (AC3, A8): OAuth flow + loopback callback server. + +Coverage: +- ``start_authorization`` returns ``{flow_id, authorization_url}`` and binds + a loopback ``aiohttp.web`` server on an ephemeral port. +- A successful redirect to ``/callback?code=...&state=...`` exchanges the + code via the token endpoint and resolves the future. +- A8: explicit ``None`` guard before ``hmac.compare_digest`` โ€” a request + without ``state`` returns 400, not 500 from a TypeError. +- A8: success HTML page is a static string literal โ€” XSS payloads in the + query string never appear in the response body. +- A8: ``webbrowser.open`` is dispatched to ``run_in_executor`` so it does + not block the event loop. +- ``?error=access_denied`` resolves the flow with ``ConsentDeniedError``. +- 120s timeout fires ``FlowTimeoutError`` and tears down the runner. +""" + +from __future__ import annotations + +import asyncio +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest +import respx + +from gaia.connectors.errors import ( + ConsentDeniedError, + FlowTimeoutError, +) +from gaia.connectors.flow import ( + _SUCCESS_HTML, + cancel_flow, + complete_authorization, + start_authorization, +) +from gaia.connectors.providers import _registry + + +@pytest.fixture +def google_provider(monkeypatch): + monkeypatch.setenv("GAIA_GOOGLE_CLIENT_ID", "test.apps.example") + _registry.clear() + from gaia.connectors.providers import get as get_provider + + return get_provider("google") + + +@pytest.fixture(autouse=True) +def _no_browser(monkeypatch): + """Replace webbrowser.open so tests don't actually launch a browser.""" + monkeypatch.setattr("webbrowser.open", lambda *_, **__: True) + + +def _mock_token_endpoint(): + """Mock the Google token endpoint and pass-through 127.0.0.1. + + Without the pass_through() call respx would intercept the loopback + callback round-trip and raise AllMockedAssertionError on first + request. The token endpoint stays mocked because it's external HTTPS. + """ + respx.post("https://oauth2.googleapis.com/token").mock( + return_value=httpx.Response( + 200, + json={ + "access_token": "fresh-access", + "refresh_token": "fresh-refresh", + "expires_in": 3600, + "scope": "openid", + "id_token": ( + # JWT payload {"email": "alice@example.com"}; signature + # is a placeholder โ€” flow.py decodes only the email + # claim, not the signature. + "header." + "eyJlbWFpbCI6ICJhbGljZUBleGFtcGxlLmNvbSJ9" + ".sig" + ), + }, + ) + ) + respx.route(host="127.0.0.1").pass_through() + + +class TestSuccessPath: + @respx.mock + async def test_callback_completes_flow(self, google_provider): + _mock_token_endpoint() + info = await start_authorization("google", scopes=["openid"]) + assert "authorization_url" in info + assert "flow_id" in info + assert info["authorization_url"].startswith(google_provider.auth_url) + + params = parse_qs(urlparse(info["authorization_url"]).query) + redirect_uri = params["redirect_uri"][0] + state = params["state"][0] + + async with httpx.AsyncClient() as c: + resp = await c.get(f"{redirect_uri}?code=test-code&state={state}") + assert resp.status_code == 200 + assert _SUCCESS_HTML in resp.text + + result = await asyncio.wait_for( + complete_authorization(info["flow_id"]), timeout=2.0 + ) + assert result["account_email"] == "alice@example.com" + assert result["scopes"] == ["openid"] + + +class TestStateValidation: + @respx.mock + async def test_missing_state_returns_400(self, google_provider): + _mock_token_endpoint() + info = await start_authorization("google", scopes=["openid"]) + params = parse_qs(urlparse(info["authorization_url"]).query) + redirect_uri = params["redirect_uri"][0] + + try: + async with httpx.AsyncClient() as c: + resp = await c.get(f"{redirect_uri}?code=test-code") + assert resp.status_code == 400 + finally: + await cancel_flow(info["flow_id"]) + + @respx.mock + async def test_mismatched_state_returns_400(self, google_provider): + _mock_token_endpoint() + info = await start_authorization("google", scopes=["openid"]) + params = parse_qs(urlparse(info["authorization_url"]).query) + redirect_uri = params["redirect_uri"][0] + + try: + async with httpx.AsyncClient() as c: + resp = await c.get(f"{redirect_uri}?code=test-code&state=WRONG-STATE") + assert resp.status_code == 400 + finally: + await cancel_flow(info["flow_id"]) + + +class TestXssDefense: + """A8: success HTML must be a static literal โ€” no echoed input.""" + + @respx.mock + async def test_xss_payload_in_state_not_reflected(self, google_provider): + _mock_token_endpoint() + info = await start_authorization("google", scopes=["openid"]) + params = parse_qs(urlparse(info["authorization_url"]).query) + redirect_uri = params["redirect_uri"][0] + + try: + xss = "" + async with httpx.AsyncClient() as c: + resp = await c.get(f"{redirect_uri}?code=test-code&state={xss}") + assert resp.status_code == 400 + assert "