From 10e0bac089efe1fc9461462e68a768347363ef58 Mon Sep 17 00:00:00 2001 From: Tomasz Iniewicz Date: Fri, 17 Apr 2026 14:00:14 -0400 Subject: [PATCH 1/8] feat(installer): custom installer guide, agent export/import, first-launch seeder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds end-to-end support for shipping a GAIA installer with a custom agent pre-loaded, and for transferring agents between machines via zip bundles. Changes: - `src/gaia/installer/export_import.py` — new module: zip-based custom agent export/import with zip-bomb defences (entry count, per-file and total size limits), path-traversal and symlink rejection, atomic write/overwrite - `src/gaia/apps/webui/services/agent-seeder.cjs` — first-launch bundled-agent seeder: copies `/agents/` into `~/.gaia/agents/` with an atomic partial→rename→sentinel protocol; idempotent across re-launches - `src/gaia/apps/webui/electron-builder.yml` — `extraResources` entry to bundle `build/bundled-agents/` into the installer as `/agents/` - `src/gaia/apps/webui/main.cjs` — call `seedBundledAgents()` on app startup before the Python backend starts - `src/gaia/cli.py` — `gaia agent export` and `gaia agent import` subcommands with interactive trust gate and `--yes` flag for non-TTY use - `src/gaia/ui/routers/agents.py` — `POST /api/agents/export` and `POST /api/agents/import` endpoints with localhost-only, CSRF-header, and tunnel-inactive guards; hot-registers imported agents into the live registry - `src/gaia/apps/webui/src/components/CustomAgentsSection.tsx` — Settings panel section for export/import with inline ZIP pre-read for trust modal - `src/gaia/apps/webui/src/components/SettingsModal.tsx` — wires in CustomAgentsSection - `docs/guides/custom-agent.mdx` — rewritten around Python agents; removed YAML-manifest section, replaced all examples with Python equivalents - `docs/guides/custom-installer.mdx` — new high-level guide (when to build a custom installer and pointer to the playbook) - `docs/playbooks/custom-installer/index.mdx` — new end-to-end playbook: Path A (branded installer with Zoo Agent, 3-OS tabs) and Path B (export/import flow) - `docs/reference/cli.mdx` — documents `gaia agent export` and `gaia agent import` - `docs/deployment/ui.mdx` — adds Custom Installer card to the CardGroup - `docs/docs.json` — registers new guide and playbook pages in nav - `util/check_doc_citations.py` — new CI utility: verifies that path:NNN citations in docs resolve to the expected symbol at that line - `.github/workflows/check_doc_links.yml` — adds citation-checker step and symbol-drift path triggers - `tests/unit/test_export_import.py` — 14 pytest cases (round-trip, zip-slip, symlink, absolute path, oversized, too many entries, invalid IDs, atomicity, overwrite, missing manifest, wrong version) - `tests/electron/agent-seeder.test.cjs` — 12 Jest cases (seed, idempotency, sentinel semantics, user-data preservation, partial-copy recovery, all 3 platform paths) Verified on macOS (arm64 DMG), Ubuntu (AppImage), and Windows (NSIS exe). Closes #776 --- .github/workflows/check_doc_links.yml | 19 + docs/deployment/ui.mdx | 3 + docs/docs.json | 7 + docs/guides/custom-agent.mdx | 271 +++++------ docs/guides/custom-installer.mdx | 65 +++ docs/playbooks/custom-installer/index.mdx | 279 +++++++++++ docs/reference/cli.mdx | 50 ++ src/gaia/apps/webui/electron-builder.yml | 4 + src/gaia/apps/webui/main.cjs | 20 + src/gaia/apps/webui/services/agent-seeder.cjs | 294 ++++++++++++ .../src/components/CustomAgentsSection.tsx | 369 +++++++++++++++ .../webui/src/components/SettingsModal.tsx | 4 + src/gaia/cli.py | 167 +++++++ src/gaia/installer/export_import.py | 374 +++++++++++++++ src/gaia/ui/routers/agents.py | 168 ++++++- tests/electron/agent-seeder.test.cjs | 342 ++++++++++++++ tests/electron/package.json | 1 + tests/unit/test_export_import.py | 438 ++++++++++++++++++ util/check_doc_citations.py | 219 +++++++++ 19 files changed, 2947 insertions(+), 147 deletions(-) create mode 100644 docs/guides/custom-installer.mdx create mode 100644 docs/playbooks/custom-installer/index.mdx create mode 100644 src/gaia/apps/webui/services/agent-seeder.cjs create mode 100644 src/gaia/apps/webui/src/components/CustomAgentsSection.tsx create mode 100644 src/gaia/installer/export_import.py create mode 100644 tests/electron/agent-seeder.test.cjs create mode 100644 tests/unit/test_export_import.py create mode 100644 util/check_doc_citations.py diff --git a/.github/workflows/check_doc_links.yml b/.github/workflows/check_doc_links.yml index ec0cfee95..0daf1dea7 100644 --- a/.github/workflows/check_doc_links.yml +++ b/.github/workflows/check_doc_links.yml @@ -11,14 +11,28 @@ on: - 'README.md' - 'cpp/README.md' - 'util/check_doc_links.py' + - 'util/check_doc_citations.py' - '.github/workflows/check_doc_links.yml' + # Citation-checker anchor targets — rerun so symbol drift in these + # files is caught on the PR that moves them, not on the next doc PR. + - 'installer/nsis/installer.nsh' + - 'src/gaia/agents/registry.py' + - 'src/gaia/agents/base/agent.py' + - 'src/gaia/installer/export_import.py' + - 'src/gaia/apps/webui/services/agent-seeder.cjs' pull_request: paths: - 'docs/**' - 'README.md' - 'cpp/README.md' - 'util/check_doc_links.py' + - 'util/check_doc_citations.py' - '.github/workflows/check_doc_links.yml' + - 'installer/nsis/installer.nsh' + - 'src/gaia/agents/registry.py' + - 'src/gaia/agents/base/agent.py' + - 'src/gaia/installer/export_import.py' + - 'src/gaia/apps/webui/services/agent-seeder.cjs' # Allow manual runs to audit all links on demand workflow_dispatch: @@ -46,6 +60,11 @@ jobs: echo "Checking internal cross-references..." python util/check_doc_links.py --internal-only --verbose + - name: Check source-file citations + run: | + echo "Checking path:NNN citations to source files..." + python util/check_doc_citations.py + check-external-links: name: Verify external URLs runs-on: ubuntu-latest diff --git a/docs/deployment/ui.mdx b/docs/deployment/ui.mdx index 9819b0eb7..00c963d7c 100644 --- a/docs/deployment/ui.mdx +++ b/docs/deployment/ui.mdx @@ -18,6 +18,9 @@ The GAIA Agent UI is distributed as an [npm package](https://www.npmjs.com/packa Python backend API: FastAPI server, SQLite database, Pydantic models, and SSE streaming. + + Ship a branded GAIA build with your agent pre-loaded. + --- diff --git a/docs/docs.json b/docs/docs.json index ed6a8df34..695fcdfb1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -72,6 +72,7 @@ "guides/docker", "guides/routing", "guides/custom-agent", + "guides/custom-installer", "guides/mcp/agent-ui", "guides/mcp/client", "guides/mcp/windows-system-health" @@ -116,6 +117,12 @@ "playbooks/emr-agent/part-2-dashboard", "playbooks/emr-agent/part-3-architecture" ] + }, + { + "group": "Custom Installer", + "pages": [ + "playbooks/custom-installer/index" + ] } ] }, diff --git a/docs/guides/custom-agent.mdx b/docs/guides/custom-agent.mdx index f9e52f700..769bcd784 100644 --- a/docs/guides/custom-agent.mdx +++ b/docs/guides/custom-agent.mdx @@ -10,11 +10,11 @@ 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/` and is described by either a YAML manifest or 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 UI. Custom agents can have their own: - **Personality and instructions** (system prompt) -- **Tools** (RAG, file search, shell, image generation, vision) +- **Tools** (RAG, file search, shell, image generation, vision, plus your own `@tool` functions) - **Preferred models** (override the server default) - **Conversation starters** (suggestion chips in the UI) - **MCP servers** (any Model Context Protocol server) @@ -46,106 +46,16 @@ Open that file to customize the instructions and add tools. --- -## Manual Creation: YAML Manifest +## Python Agent -For full control, write the manifest yourself. +For full control — custom tools, built-in tool mixins, MCP servers, or complex logic — write a Python module. ### Directory structure ``` ~/.gaia/agents/ └── my-agent/ - └── agent.yaml # required -``` - -### Minimal manifest - -The smallest valid manifest needs only `id`, `name`, and `instructions`: - -```yaml -manifest_version: 1 -id: my-agent -name: My Agent -instructions: You are a helpful assistant. -``` - -### Full manifest reference - -```yaml -manifest_version: 1 - -# Unique ID used internally (lowercase letters, numbers, hyphens only) -id: weather-agent - -# Display name shown in the UI agent selector -name: Weather Agent - -# One-line description shown as a tooltip -description: An agent that provides weather updates - -# System prompt — defines your agent's personality and behavior -instructions: | - You are a cheerful weather forecaster who always relates topics back - to the current weather. When you don't know the actual weather, make - a playful estimate based on the season. - - Be concise, friendly, and always sign off with a weather pun. - -# Tools that give your agent extra capabilities. -# Available: rag, file_search, file_io, shell, screenshot, sd, vlm -# Omit this field to get the defaults: [rag, file_search] -tools: [] - -# Preferred models — first available model is used at runtime. -# Omit to use the server's current default model. -models: - - Qwen3.5-35B-A3B-GGUF - - Qwen3-0.6B-GGUF - -# Suggestion chips shown on the welcome screen for this agent. -conversation_starters: - - "What's the weather like today?" - - "Tell me a weather fun fact" - - "What should I wear tomorrow?" - -# MCP (Model Context Protocol) servers — each entry is a subprocess -# that provides additional tools to your agent. -# mcp_servers: -# time: -# command: npx -# args: ["-y", "@anthropic/mcp-time-server"] -# -# weather: -# command: npx -# args: ["-y", "@anthropic/mcp-weather-server"] -# env: -# WEATHER_API_KEY: "your-api-key-here" -``` - -### Available tools - -| Tool | What it does | -|------|-------------| -| `rag` | Document Q&A with RAG — index and query files | -| `file_search` | Find files on disk by name or content | -| `file_io` | Read and write files | -| `shell` | Execute shell commands | -| `screenshot` | Capture screenshots | -| `sd` | Generate images with Stable Diffusion | -| `vlm` | Analyze images with a Vision Language Model | - ---- - -## Advanced: Python Agent - -For agents that need custom tools or complex logic, write a Python module instead of (or alongside) a YAML manifest. - -### Directory structure - -``` -~/.gaia/agents/ -└── my-python-agent/ - ├── agent.py # required — Python takes precedence over YAML + ├── agent.py # required └── agent.yaml # optional — used only for the `models` field ``` @@ -187,61 +97,132 @@ The three required methods are: | `_create_console()` | Returns an `AgentConsole` instance | | `_register_tools()` | Registers tools into `_TOOL_REGISTRY` | +### Adding built-in tools + +GAIA ships with ready-to-use tool mixins. To enable them, inherit the mixin and call its `register_*_tools()` method from `_register_tools()`: + +| Tool | Mixin | Register call | +|------|-------|---------------| +| `rag` | `gaia.agents.chat.tools.rag_tools.RAGToolsMixin` | `self.register_rag_tools()` | +| `file_search` | `gaia.agents.tools.file_tools.FileSearchToolsMixin` | `self.register_file_search_tools()` | +| `file_io` | `gaia.agents.code.tools.file_io.FileIOToolsMixin` | `self.register_file_io_tools()` | +| `shell` | `gaia.agents.chat.tools.shell_tools.ShellToolsMixin` | `self.register_shell_tools()` | +| `screenshot` | `gaia.agents.tools.screenshot_tools.ScreenshotToolsMixin` | `self.register_screenshot_tools()` | +| `sd` | `gaia.sd.mixin.SDToolsMixin` | `self.register_sd_tools()` | +| `vlm` | `gaia.vlm.mixin.VLMToolsMixin` | `self.register_vlm_tools()` | + +### Overriding the default model (optional) + +If your agent needs a specific model, set `model_id` in `__init__()`: + +```python +def __init__(self, **kwargs): + kwargs.setdefault("model_id", "Qwen3-0.6B-GGUF") + super().__init__(**kwargs) +``` + +Alternatively, create a companion `agent.yaml` next to `agent.py` with a `models:` list — the registry reads that list as an ordered preference. + --- ## Examples - - ```yaml - manifest_version: 1 - id: zoo-agent - name: Zoo Agent - description: A zookeeper who loves animals - instructions: | - You are a funny and enthusiastic zookeeper! You work at the world's - best zoo and every response you give includes a fun fact or a playful - reference to one of your beloved zoo animals. - tools: [] - conversation_starters: - - "Hello! What's happening at the zoo today?" - - "Tell me a fun fact about one of your animals." + + ```python + from gaia.agents.base.agent import Agent + from gaia.agents.base.console import AgentConsole + from gaia.agents.base.tools import _TOOL_REGISTRY + + + class ZooAgent(Agent): + AGENT_ID = "zoo-agent" + AGENT_NAME = "Zoo Agent" + AGENT_DESCRIPTION = "A zookeeper who loves animals" + CONVERSATION_STARTERS = [ + "Hello! What's happening at the zoo today?", + "Tell me a fun fact about one of your animals.", + ] + + def _get_system_prompt(self) -> str: + return ( + "You are a funny and enthusiastic zookeeper! You work at the world's " + "best zoo and every response you give includes a fun fact or a playful " + "reference to one of your beloved zoo animals." + ) + + def _create_console(self) -> AgentConsole: + return AgentConsole() + + def _register_tools(self) -> None: + _TOOL_REGISTRY.clear() ``` - ```yaml - manifest_version: 1 - id: research-agent - name: Research Agent - description: Searches documents and files to answer questions - instructions: | - You are a thorough research assistant. When asked a question, - first search available documents and files before answering. - Always cite your sources. - tools: - - rag - - file_search - conversation_starters: - - "Summarize my project notes" - - "Find files related to..." - - "What does the documentation say about...?" + ```python + from gaia.agents.base.agent import Agent + from gaia.agents.base.console import AgentConsole + from gaia.agents.base.tools import _TOOL_REGISTRY + from gaia.agents.chat.tools.rag_tools import RAGToolsMixin + from gaia.agents.tools.file_tools import FileSearchToolsMixin + + + class ResearchAgent(Agent, RAGToolsMixin, FileSearchToolsMixin): + AGENT_ID = "research-agent" + AGENT_NAME = "Research Agent" + AGENT_DESCRIPTION = "Searches documents and files to answer questions" + CONVERSATION_STARTERS = [ + "Summarize my project notes", + "Find files related to...", + "What does the documentation say about...?", + ] + + def _get_system_prompt(self) -> str: + return ( + "You are a thorough research assistant. When asked a question, " + "first search available documents and files before answering. " + "Always cite your sources." + ) + + def _create_console(self) -> AgentConsole: + return AgentConsole() + + def _register_tools(self) -> None: + _TOOL_REGISTRY.clear() + self.register_rag_tools() + self.register_file_search_tools() ``` - ```yaml - manifest_version: 1 - id: code-review-agent - name: Code Review Agent - description: Reviews code and runs linters - instructions: | - You are an expert code reviewer. You read files, run linters, - and provide actionable feedback. Be specific about file names - and line numbers. Prioritize correctness and security. - tools: - - file_io - - shell + ```python + from gaia.agents.base.agent import Agent + from gaia.agents.base.console import AgentConsole + from gaia.agents.base.tools import _TOOL_REGISTRY + from gaia.agents.chat.tools.shell_tools import ShellToolsMixin + from gaia.agents.code.tools.file_io import FileIOToolsMixin + + + class CodeReviewAgent(Agent, FileIOToolsMixin, ShellToolsMixin): + AGENT_ID = "code-review-agent" + AGENT_NAME = "Code Review Agent" + AGENT_DESCRIPTION = "Reviews code and runs linters" + + def _get_system_prompt(self) -> str: + return ( + "You are an expert code reviewer. You read files, run linters, " + "and provide actionable feedback. Be specific about file names " + "and line numbers. Prioritize correctness and security." + ) + + def _create_console(self) -> AgentConsole: + return AgentConsole() + + def _register_tools(self) -> None: + _TOOL_REGISTRY.clear() + self.register_file_io_tools() + self.register_shell_tools() ``` @@ -252,19 +233,14 @@ The three required methods are: - - Ensure the directory is under `~/.gaia/agents//` and contains `agent.yaml` or `agent.py`. - - The directory name does not need to match the `id` in the manifest, but it helps. + - Ensure the directory is under `~/.gaia/agents//` and contains `agent.py`. + - The directory name does not need to match the `AGENT_ID` class attribute, but it helps. - Check the server logs for warnings like `Failed to load agent from ...`. - If you created the agent manually (not via the Builder), **restart the GAIA server** — discovery runs at boot. Agents created via the Builder Agent are loaded immediately without a restart. - - Run `python -c "import yaml; from gaia.agents.registry import AgentManifest; AgentManifest(**yaml.safe_load(open('~/.gaia/agents//agent.yaml').read()))"` to validate your manifest. - - Common issues: - - `id` must be non-empty and contain only lowercase letters, numbers, and hyphens. - - `tools` must only reference known tool names (see table above). - - `manifest_version` must be `1`. + + Check the server logs for load errors. Ensure `AGENT_ID`, `AGENT_NAME`, and `AGENT_DESCRIPTION` are set as class attributes, and that the three required methods (`_get_system_prompt`, `_create_console`, `_register_tools`) are implemented. @@ -295,6 +271,9 @@ The three required methods are: Learn about the GAIA Agent UI and how agents are surfaced there + + Ship your agent pre-loaded in a branded, signed GAIA installer + --- diff --git a/docs/guides/custom-installer.mdx b/docs/guides/custom-installer.mdx new file mode 100644 index 000000000..3b6908edc --- /dev/null +++ b/docs/guides/custom-installer.mdx @@ -0,0 +1,65 @@ +--- +title: "Building a Custom GAIA Installer" +description: "Ship a turnkey GAIA distribution with your agent preconfigured." +icon: "box-archive" +--- + + + **Prerequisites:** Complete [Custom Agents](/guides/custom-agent) first. You should have a working agent manifest in `~/.gaia/agents//` before starting. This guide assumes agent authoring is done and focuses on packaging, branding, and distribution. + + +## Overview + +This guide is for anyone shipping a GAIA build that comes **pre-configured with a specific agent** — so the end user installs, launches, and sees your agent already in the Agent UI dropdown. That workflow sits on top of the same primitives documented elsewhere (`/guides/custom-agent` for agent authoring, `/deployment/code-signing` for signing); this page explains the *installer-specific* delta. + +## When to build a custom installer + +Most users should **not** build a custom installer. Pick the lightest option that solves your problem: + + + + Your users are developers. They already have Python. They want to script agents or try GAIA quickly. Nothing to build — direct them to [Setup](/setup). + + + You ship to end users who expect a double-click installer. Your agent is the headline feature. You want your brand name, icon, and agent loaded on first launch. **This guide.** + + + You deploy via SCCM, Intune, or Jamf to a managed fleet. You need unattended install flags and policy-driven configuration. Custom installer plus the silent-install hooks (see [#614](https://github.com/amd/gaia/issues/614) for roadmap). + + + +The three audiences this guide serves — **OEM partners** pre-loading GAIA on Ryzen AI hardware, **enterprise teams** distributing an internal-knowledge agent, and **community contributors** sharing a branded build of a specialty agent — all follow the same path. Where the paths diverge (signing certificates, update feeds, silent-install flags) is called out inline. + +## What you'll customize + +A custom installer is the stock GAIA build with three classes of changes: + +1. **Agent seeding** — your `agent.py` is bundled into the installer via `electron-builder`'s `extraResources`. On first launch, the Electron shell seeds it into `~/.gaia/agents//` so it is discovered automatically by the agent registry. +2. **Branding** — `electron-builder.yml` controls the product name, app id, installer icon, and sidebar image. +3. **Signing & distribution** — Windows code-signing (SignPath), macOS notarization, and your own update/download channel. + +The playbook in [Ship Zoo Agent in a Custom GAIA Installer](/playbooks/custom-installer) walks these end-to-end with a working example. + +## Distribution options + +| Platform | Primary format | Today | +|----------|----------------|-------| +| Windows | NSIS installer (`electron-builder`) | Fully documented in the playbook | +| macOS | DMG | Fully documented in the playbook | +| Linux | `.deb` / AppImage | Fully documented in the playbook | + +The [Custom Installer Playbook](/playbooks/custom-installer) covers all three platforms end-to-end, with per-OS `` for the build and install steps. + +## Signing + +Code signing is a separate concern from packaging and is covered in its own reference. You can build, install, and hand off an unsigned or ad-hoc-signed installer following the playbook alone — signing is optional and layered on top. + + + Detailed per-platform signing instructions, required secrets, and CI integration. The custom-installer playbook links to this doc from its signing step; do not try to paraphrase it here. + + +## Next step + + + End-to-end walkthrough: take the Zoo Agent YAML, seed it from a custom NSIS installer, brand it, sign it, and hand it to end users. + diff --git a/docs/playbooks/custom-installer/index.mdx b/docs/playbooks/custom-installer/index.mdx new file mode 100644 index 000000000..73e100a15 --- /dev/null +++ b/docs/playbooks/custom-installer/index.mdx @@ -0,0 +1,279 @@ +--- +title: "Custom Installer Playbook" +description: "Ship the Zoo Agent pre-loaded in a branded GAIA installer. Three-OS walkthrough." +icon: "play" +--- + + + **Source code:** + [`src/gaia/apps/webui/services/agent-seeder.cjs`](https://github.com/amd/gaia/blob/main/src/gaia/apps/webui/services/agent-seeder.cjs) · + [`src/gaia/installer/export_import.py`](https://github.com/amd/gaia/blob/main/src/gaia/installer/export_import.py) · + [`src/gaia/apps/webui/electron-builder.yml`](https://github.com/amd/gaia/blob/main/src/gaia/apps/webui/electron-builder.yml) + + +**Time to complete:** 45–75 minutes +**What you'll build:** A branded GAIA installer for Windows, macOS, or Ubuntu with the **Zoo Agent** preloaded +**What you'll learn:** How bundled agents flow from a staging directory through `electron-builder` into the first-launch seeder, plus how to share agents between existing GAIA installations without rebuilding an installer + +--- + +## What's different about this playbook + +The [Custom Installer guide](/guides/custom-installer) frames *when* to pick the installer path over `pip install amd-gaia` or enterprise silent-install. This playbook is the end-to-end walkthrough: take a working agent, drop it into the staging directory, run one `npm` command per OS, install the binary, and watch the seeder surface your agent in the Agent UI on first launch. + +Two paths are covered: + +- **Path A — Branded installer.** You build and ship a full GAIA installer with your agent preloaded. This is what OEM partners, enterprise teams, and community builders typically want. +- **Path B — Share an existing agent.** You already have GAIA installed on both sides and just want to move an agent between machines. No installer build. + +--- + +## Path A — Branded installer + +The running example is the **Zoo Agent**. It is a minimal Python agent that plays an enthusiastic zookeeper. + + + + + +Who this path is for: **OEM partners** pre-loading GAIA on Ryzen AI hardware, **enterprise teams** distributing an internal-knowledge agent, and **community builders** sharing a branded specialty agent. + +What you need installed: + +- Git +- Node.js 18+ and npm +- Python 3.10+ (for running the agent locally to verify it before packaging) + +Clone and install: + +```bash +git clone https://github.com/amd/gaia.git +cd gaia/src/gaia/apps/webui +npm install +``` + +All subsequent steps assume your working directory is `gaia/src/gaia/apps/webui/`. + + + + + +Create the bundled-agent staging directory and drop in a Python `agent.py`: + +```bash +mkdir -p build/bundled-agents/zoo-agent +``` + +Create `build/bundled-agents/zoo-agent/agent.py`: + +```python title="build/bundled-agents/zoo-agent/agent.py" +from gaia.agents.base.agent import Agent +from gaia.agents.base.console import AgentConsole +from gaia.agents.base.tools import _TOOL_REGISTRY + + +class ZooAgent(Agent): + AGENT_ID = "zoo-agent" + AGENT_NAME = "Zoo Agent" + AGENT_DESCRIPTION = "A zookeeper who loves animals" + CONVERSATION_STARTERS = [ + "Hello! What's happening at the zoo today?", + "Tell me a fun fact about one of your animals.", + ] + + def _get_system_prompt(self) -> str: + return ( + "You are a funny and enthusiastic zookeeper! You work at the world's " + "best zoo and every response you give includes a fun fact or a playful " + "reference to one of your beloved zoo animals." + ) + + def _create_console(self) -> AgentConsole: + return AgentConsole() + + def _register_tools(self) -> None: + _TOOL_REGISTRY.clear() +``` + +**Why this location matters.** The `build/bundled-agents/` directory is the build-time staging area picked up by the `extraResources` entry in `electron-builder.yml`: + +```yaml +extraResources: + - from: build/bundled-agents + to: agents + filter: ["**/*"] +``` + +At install time the contents end up under `process.resourcesPath/agents//` inside the installed app. On first launch, `seedBundledAgents()` in [`src/gaia/apps/webui/services/agent-seeder.cjs`](https://github.com/amd/gaia/blob/main/src/gaia/apps/webui/services/agent-seeder.cjs) copies them into `~/.gaia/agents//` and writes a `.seeded` sentinel so the copy runs exactly once per agent. + + + **Agent code runs with user privileges.** The `agent.py` file you bundle executes when the user selects your agent. Only bundle code you wrote or have audited. + + + + + + +Pick your target platform. Each command builds the full GAIA Agent UI installer with your Zoo Agent baked in. + + + + ```bash + npm run package:win + ``` + + Output: + + ``` + dist-app/gaia-agent-ui--x64-setup.exe + ``` + + + ```bash + npm run package:mac + ``` + + Output: + + ``` + dist-app/gaia-agent-ui--arm64.dmg + ``` + + + ```bash + npm run package:linux + ``` + + Output: + + ``` + dist-app/gaia-agent-ui--amd64.deb + ``` + + + + + Cross-compilation is not supported end-to-end today. Build each target on its native OS (or in a matching VM / CI runner). + + + + + + + + + 1. Double-click the `.exe` in `dist-app/`. + 2. Accept the NSIS prompts through to completion. + 3. Launch **GAIA** from the Start Menu. + + + 1. Open the `.dmg` in `dist-app/`. + 2. Drag **GAIA** to `Applications`. + 3. Launch from Launchpad or `Applications/`. + + + ```bash + sudo apt install ./dist-app/gaia-agent-ui--amd64.deb + ``` + + Launch from your desktop environment's application menu, or run `gaia-agent-ui` from a terminal. + + + +**Verify the seeder ran:** + +1. Open the Agent UI. On first launch, `seedBundledAgents()` copies every directory under `/agents/` into `~/.gaia/agents/` and writes a `.seeded` marker so re-launches are no-ops. +2. Open the agent dropdown in the UI header — **Zoo Agent** should appear alongside the built-in agents. +3. Select **Zoo Agent** and send "What's happening at the zoo today?". You should get an enthusiastic zookeeper response with a fun animal fact. + +If the agent does not appear: + +- Confirm `~/.gaia/agents/zoo-agent/agent.py` exists on the target machine. +- Confirm `~/.gaia/agents/zoo-agent/.seeded` exists — if it's missing, the seeder did not run; check the Electron main-process logs. +- If `.seeded` exists but the directory is incomplete, delete `~/.gaia/agents/zoo-agent/` and relaunch to trigger a fresh seed. + + + + + +Most users ship with the default GAIA branding and only customize the agent. If you want a branded installer, edit [`src/gaia/apps/webui/electron-builder.yml`](https://github.com/amd/gaia/blob/main/src/gaia/apps/webui/electron-builder.yml) before re-running the `package:*` command: + +- `productName` → user-facing app name (e.g., `Zoo GAIA`) +- `appId` → your reverse-DNS namespace (e.g., `com.acme.zoo-gaia`) + +Icons, sidebar bitmaps, and installer graphics are referenced by path inside the same file — replace the assets in place. + +**Signing is out of scope for this playbook.** An unsigned or ad-hoc-signed installer is perfectly usable for internal distribution and testing. For production signing (Windows Authenticode, macOS Developer ID + notarization), see the [Code Signing Reference](/deployment/code-signing). + + + + + +--- + +## Path B — Share an existing agent + +If you already have GAIA installed and want to move an agent to another machine — or share it with a teammate — you don't need to build an installer. Use the agent bundle export/import flow instead. + +The bundle is a `.zip` containing your custom agents plus a `bundle.json` table of contents. Export on the source machine, import on the destination. + +### Export + + +```bash CLI +gaia agent export +# → creates ~/.gaia/export.zip containing every agent under ~/.gaia/agents/ +``` + +```text UI +Settings → Custom Agents → Export All +# Saves the bundle via a native file-picker dialog. +``` + + +Under the hood both flows call `export_custom_agents()` in [`src/gaia/installer/export_import.py`](https://github.com/amd/gaia/blob/main/src/gaia/installer/export_import.py), which walks `~/.gaia/agents/` and writes a deterministic zip with a `bundle.json` manifest. + +### Import + + +```bash CLI +gaia agent import ~/Downloads/gaia-agents-export.zip +# Prompts for trust confirmation before installing. +``` + +```text UI +Settings → Custom Agents → Import +# File picker → trust confirmation → done. +``` + + +Imports go through `import_agent_bundle()` in [`src/gaia/installer/export_import.py`](https://github.com/amd/gaia/blob/main/src/gaia/installer/export_import.py). Each agent is staged in a temp directory and atomically moved into `~/.gaia/agents//`, so a partial failure leaves previously-installed agents intact. Zip entries are validated for path traversal, absolute paths, symlinks, size, and entry count before anything touches disk. + + + **Importing a bundle installs third-party Python code that runs on your machine when the agent is selected.** Only import bundles from sources you trust. The UI import flow shows the list of agent IDs in the bundle and requires an explicit confirmation click before extraction. + + +### When to use which path + +| Scenario | Path | +|----------|------| +| Shipping GAIA + your agent to users who don't have GAIA | Path A — Branded installer | +| Moving an agent between two machines that already run GAIA | Path B — Export/Import | +| Publishing an agent for the community to drop in | Path B (share the `.zip`) | +| OEM pre-install on Ryzen AI hardware | Path A | + +--- + +## Next steps + + + + Deep reference for agent authoring, manifest schema, tools, and MCP. + + + Per-platform signing secrets, CI integration, troubleshooting. + + + Desktop shell architecture, build pipeline, Electron integration. + + diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 928cfeca5..1a9732938 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -345,6 +345,56 @@ gaia kill --port 8080 On Windows, `--lemonade` also kills orphaned `llama-server.exe` and `lemonade-tray.exe` processes. +### Agent Command + +Manage custom agents installed under `~/.gaia/agents/`. Supports exporting every custom agent into a single `.zip` bundle and importing a bundle produced on another machine. + +```bash +gaia agent export [--output PATH] +gaia agent import PATH [--yes] +``` + +**Export options:** + +| Option | Type | Description | +|--------|------|-------------| +| `--output ` | string | Destination `.zip` file (default: `~/.gaia/export.zip`) | + +**Import options:** + +| Option | Type | Description | +|--------|------|-------------| +| `path` | string | Path to the `.zip` bundle to import (positional, required) | +| `--yes, -y` | flag | Skip the interactive trust prompt (required in non-interactive/CI contexts) | + +**Examples:** + + +```bash Export all custom agents +gaia agent export +``` + +```bash Export to a custom path +gaia agent export --output ./my-agents.zip +``` + +```bash Import a bundle (interactive) +gaia agent import ./my-agents.zip +``` + +```bash Import non-interactively (CI) +gaia agent import ./my-agents.zip --yes +``` + + + +Exported bundles contain your agent source files as-is. Any API keys or credentials present in `agent.py` will be included. Review bundles before sharing. + + + +Importing a bundle runs third-party Python code on your machine. `gaia agent import` shows the agent IDs in the bundle and requires explicit `y`/`yes` confirmation (or `--yes`) before proceeding. + + --- ## Core Commands diff --git a/src/gaia/apps/webui/electron-builder.yml b/src/gaia/apps/webui/electron-builder.yml index fefed0511..e1d2b0992 100644 --- a/src/gaia/apps/webui/electron-builder.yml +++ b/src/gaia/apps/webui/electron-builder.yml @@ -56,6 +56,10 @@ extraResources: to: dist filter: - "**/*" + - from: build/bundled-agents + to: agents + filter: + - "**/*" asar: true # Nothing currently needs to be unpacked from the asar. diff --git a/src/gaia/apps/webui/main.cjs b/src/gaia/apps/webui/main.cjs index 8e230c204..fde214ea3 100644 --- a/src/gaia/apps/webui/main.cjs +++ b/src/gaia/apps/webui/main.cjs @@ -26,6 +26,7 @@ const NotificationService = require("./services/notification-service.cjs"); const backendInstaller = require("./services/backend-installer.cjs"); const installerProgressDialog = require("./services/backend-installer-progress-dialog.cjs"); const autoUpdater = require("./services/auto-updater.cjs"); +const agentSeeder = require("./services/agent-seeder.cjs"); // ── Configuration ────────────────────────────────────────────────────────── @@ -454,6 +455,25 @@ app.on("second-instance", (_event, _argv, _cwd) => { }); app.whenReady().then(async () => { + // Phase 0: seed bundled agents BEFORE the Python backend starts, so the + // agent registry sees them on its first discovery pass. Failures here are + // non-fatal — the app must still launch even if seeding is blocked (e.g. + // permission error on ~/.gaia/agents). + try { + const seedResult = await agentSeeder.seedBundledAgents(); + if (seedResult.seeded.length > 0) { + console.log("[main] Seeded agents:", seedResult.seeded); + } + if (seedResult.errors.length > 0) { + console.warn( + "[main] Agent seeding errors:", + seedResult.errors.map((e) => e.id) + ); + } + } catch (err) { + console.warn("[main] Agent seeding failed (non-fatal):", err); + } + // Phase A: ensure the Python backend is installed BEFORE creating the // main window. The progress dialog owns the UI during this phase. const bootstrapOk = await bootstrapBackend(); diff --git a/src/gaia/apps/webui/services/agent-seeder.cjs b/src/gaia/apps/webui/services/agent-seeder.cjs new file mode 100644 index 000000000..c3ea4ffdb --- /dev/null +++ b/src/gaia/apps/webui/services/agent-seeder.cjs @@ -0,0 +1,294 @@ +// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +// SPDX-License-Identifier: MIT + +/** + * agent-seeder.cjs — First-launch bundled-agent seeder. + * + * Copies agents bundled with the installer (placed at + * `/agents/` by electron-builder's extraResources rule) into + * the user's per-agent home directory at `~/.gaia/agents//`. A + * `.seeded` sentinel file is written after a successful copy so subsequent + * launches skip the agent. + * + * Design invariants (see .claude/plans/bundle-path-contract.md): + * - Source: path.join(process.resourcesPath, "agents", "") + * - Windows: \resources\agents\\ + * - macOS: .app/Contents/Resources/agents// + * - Linux: /opt//resources/agents// + * - Target: path.join(os.homedir(), ".gaia", "agents", "") + * - Sentinel: /.seeded (exists → already seeded → skip) + * + * Write protocol (atomic-ish, crash-safe): + * 1. Remove any stale `.partial/` sibling from a prior failed run. + * 2. Copy source → `.partial/`. + * 3. `fs.renameSync(.partial, )` — atomic on the same filesystem. + * 4. Write `/.seeded` last, so a partial seed never looks complete. + * + * Behaviour: + * - Target `/` exists WITH `.seeded` → already seeded, skip. + * - Target `/` exists WITHOUT `.seeded` → treat as user-owned data, + * log a warning, and skip (never clobber a hand-authored agent). + * - `process.resourcesPath` unset (dev / Jest) or source dir missing → + * empty result, no error. + * - Per-agent failures are isolated: they go into `errors[]` but do not + * stop the next agent from being seeded. + * + * Pure CommonJS. Only Node stdlib (fs / path / os). No Electron imports so + * the module is testable without spinning up Electron. + */ + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +// ── Path helpers ───────────────────────────────────────────────────────── + +function gaiaHome() { + return path.join(os.homedir(), ".gaia"); +} + +function agentsTargetRoot() { + return path.join(gaiaHome(), "agents"); +} + +function logsDir() { + return path.join(gaiaHome(), "logs"); +} + +function logFilePath() { + return path.join(logsDir(), "seeder.log"); +} + +// ── Logging ────────────────────────────────────────────────────────────── + +function log(level, message) { + const line = `${new Date().toISOString()} [${level}] ${message}\n`; + try { + fs.mkdirSync(logsDir(), { recursive: true }); + fs.appendFileSync(logFilePath(), line, { encoding: "utf8" }); + } catch { + // If we cannot write the log, fall back to console so the message + // isn't lost entirely. We never let logging failure propagate. + } + // Also mirror to console so `electron .` tail-of-stdout users see it. + // eslint-disable-next-line no-console + const writer = + level === "ERROR" ? console.error : level === "WARN" ? console.warn : console.log; + writer(`[agent-seeder] ${message}`); +} + +// ── Filesystem helpers ─────────────────────────────────────────────────── + +/** + * Recursive copy using fs.cpSync when available (Node 16.7+), falling back + * to a hand-rolled recursive copy for older runtimes. Electron 40 ships + * Node 20, so cpSync is always present in production — but we keep the + * fallback for test environments that might mock cpSync. + */ +function copyDirRecursive(src, dest) { + if (typeof fs.cpSync === "function") { + // dereference: true flattens symlinks into their targets rather than + // copying the symlink itself. This prevents a malicious or accidentally + // symlinked installer bundle from planting out-of-tree references in + // ~/.gaia/agents//. + fs.cpSync(src, dest, { recursive: true, errorOnExist: false, force: true, dereference: true }); + return; + } + // Fallback path (shouldn't normally hit on Electron 40 / Node 20). + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const s = path.join(src, entry.name); + const d = path.join(dest, entry.name); + if (entry.isDirectory()) { + copyDirRecursive(s, d); + } else if (entry.isSymbolicLink()) { + // Skip symlinks in the fallback path for the same reason as dereference:true above. + log("WARN", `Skipping symlink in installer bundle: ${s}`); + } else { + fs.copyFileSync(s, d); + } + } +} + +function rmDirRecursive(target) { + if (!fs.existsSync(target)) return; + fs.rmSync(target, { recursive: true, force: true }); +} + +function isDirectory(p) { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +} + +// ── Seeding core ───────────────────────────────────────────────────────── + +/** + * Seed a single agent directory. Returns a category string: + * "seeded" — copied successfully, sentinel written. + * "skipped" — already seeded or user-owned; left untouched. + * "error" — copy failed; partial data cleaned up (best effort). + * + * Throws only on programmer error. All IO errors are caught and logged. + */ +function seedOneAgent(sourceDir, targetRoot, id) { + const src = path.join(sourceDir, id); + const target = path.join(targetRoot, id); + const partial = path.join(targetRoot, `${id}.partial`); + const sentinel = path.join(target, ".seeded"); + + // Already seeded? + if (fs.existsSync(sentinel)) { + log("INFO", `Skipping "${id}" — already seeded (sentinel present)`); + return { status: "skipped" }; + } + + // Target exists but no sentinel → user-owned data. Do not touch. + if (fs.existsSync(target)) { + log( + "WARN", + `Skipping "${id}" — target exists without .seeded sentinel ` + + `(treating as user-owned data): ${target}` + ); + return { status: "skipped" }; + } + + // Verify the source is actually a directory before doing anything. + if (!isDirectory(src)) { + log("WARN", `Skipping "${id}" — source is not a directory: ${src}`); + return { status: "skipped" }; + } + + try { + // Clean up any leftover from a prior failed run. + if (fs.existsSync(partial)) { + log("INFO", `Removing stale partial directory for "${id}": ${partial}`); + rmDirRecursive(partial); + } + + // Ensure the parent exists. + fs.mkdirSync(targetRoot, { recursive: true }); + + // Copy into sibling, then atomically rename. + copyDirRecursive(src, partial); + fs.renameSync(partial, target); + + // Write sentinel LAST — its presence means "copy completed". + fs.writeFileSync( + sentinel, + JSON.stringify( + { + seededAt: new Date().toISOString(), + source: src, + }, + null, + 2 + ), + { encoding: "utf8" } + ); + + log("INFO", `Seeded "${id}" from ${src} to ${target}`); + return { status: "seeded" }; + } catch (err) { + // Best-effort cleanup. If the rename already happened (partial no longer + // exists but target does and has no sentinel), remove target so the next + // launch retries cleanly instead of treating it as user-owned data. + try { + if (fs.existsSync(partial)) { + rmDirRecursive(partial); + } else if (fs.existsSync(target) && !fs.existsSync(sentinel)) { + rmDirRecursive(target); + } + } catch { + // ignore — original error is more important + } + + log( + "ERROR", + `Failed to seed "${id}": ${err && err.message ? err.message : err}` + ); + return { status: "error", error: err }; + } +} + +/** + * Seed all bundled agents found under `/agents/`. + * + * Idempotent — safe to call on every app launch. + * + * @returns {Promise<{seeded: string[], skipped: string[], errors: {id: string, error: Error}[]}>} + */ +async function seedBundledAgents() { + const result = { seeded: [], skipped: [], errors: [] }; + + // Guard against dev / test environments where resourcesPath is unset. + if (!process.resourcesPath) { + log( + "INFO", + "process.resourcesPath is undefined — skipping bundled-agent seeding" + ); + return result; + } + + const sourceDir = path.join(process.resourcesPath, "agents"); + + if (!fs.existsSync(sourceDir) || !isDirectory(sourceDir)) { + // Not an error — a build might simply ship without bundled agents. + log( + "INFO", + `No bundled agents directory at ${sourceDir} — nothing to seed` + ); + return result; + } + + let entries; + try { + entries = fs.readdirSync(sourceDir, { withFileTypes: true }); + } catch (err) { + log( + "ERROR", + `Failed to read bundled agents directory ${sourceDir}: ${ + err && err.message ? err.message : err + }` + ); + return result; + } + + const targetRoot = agentsTargetRoot(); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const id = entry.name; + + const outcome = seedOneAgent(sourceDir, targetRoot, id); + if (outcome.status === "seeded") { + result.seeded.push(id); + } else if (outcome.status === "skipped") { + result.skipped.push(id); + } else { + result.errors.push({ id, error: outcome.error }); + } + } + + log( + "INFO", + `Seeding complete — seeded=${result.seeded.length} ` + + `skipped=${result.skipped.length} errors=${result.errors.length}` + ); + + return result; +} + +module.exports = { + seedBundledAgents, + // Exposed for tests — do not rely on these from production code. + _internals: { + seedOneAgent, + agentsTargetRoot, + logFilePath, + }, +}; diff --git a/src/gaia/apps/webui/src/components/CustomAgentsSection.tsx b/src/gaia/apps/webui/src/components/CustomAgentsSection.tsx new file mode 100644 index 000000000..134bd13c8 --- /dev/null +++ b/src/gaia/apps/webui/src/components/CustomAgentsSection.tsx @@ -0,0 +1,369 @@ +// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +// SPDX-License-Identifier: MIT + +/** + * Custom Agents settings section — export/import custom agent bundles. + * + * - Export: calls `POST /api/agents/export`, downloads a zip of all + * non-builtin agents from `~/.gaia/agents/`. Shows a credentials warning + * before kicking off the download, since agent source files may contain + * API keys or tokens. + * + * - Import: reads `bundle.json` from the selected zip (best-effort) so the + * trust modal can list which agent IDs will be installed. Bundles execute + * third-party Python code, so we require explicit confirmation before + * upload. + * + * Both endpoints require the `X-Gaia-UI: 1` header (CSRF guard in the + * backend — see `src/gaia/ui/routers/agents.py`). + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Loader2, Download, Upload, CheckCircle2, AlertCircle } from 'lucide-react'; +import * as api from '../services/api'; +import { useChatStore } from '../stores/chatStore'; +import { log } from '../utils/logger'; +import type { AgentInfo } from '../types'; + +// Same base-URL logic as services/api.ts — export/import hit the REST +// backend directly (not the apiFetch wrapper) because they deal with +// binary zip payloads, not JSON. +const API_BASE = window.location.protocol === 'file:' + ? 'http://localhost:4200/api' + : '/api'; + +type Status = + | { kind: 'idle' } + | { kind: 'working'; message: string } + | { kind: 'success'; message: string } + | { kind: 'error'; message: string }; + +export function CustomAgentsSection() { + const { agents, setAgents } = useChatStore(); + + const [status, setStatus] = useState({ kind: 'idle' }); + const fileInputRef = useRef(null); + const statusClearRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (statusClearRef.current) clearTimeout(statusClearRef.current); + }; + }, []); + + const flashStatus = useCallback((s: Status, clearAfterMs = 5000) => { + setStatus(s); + if (statusClearRef.current) clearTimeout(statusClearRef.current); + if (s.kind === 'success' || s.kind === 'error') { + statusClearRef.current = setTimeout(() => setStatus({ kind: 'idle' }), clearAfterMs); + } + }, []); + + const refreshAgents = useCallback(async () => { + try { + const data = await api.listAgents(); + setAgents(data.agents || []); + } catch (err) { + log.api.warn('Failed to refresh agents after import', err); + } + }, [setAgents]); + + // Custom agents = anything not built-in. The backend marks built-in + // agents with source === "builtin"; anything else (user-created, + // imported bundles) belongs in this list. + const customAgents: AgentInfo[] = agents.filter((a) => a.source !== 'builtin'); + + // ── Export ─────────────────────────────────────────────────────── + const handleExport = useCallback(async () => { + const proceed = window.confirm( + 'Exported bundle contains your agent source files as-is. ' + + 'Any API keys, tokens, or credentials in agent.py will be ' + + 'included in the bundle. Review before sharing.\n\nContinue?' + ); + if (!proceed) return; + + flashStatus({ kind: 'working', message: 'Exporting…' }); + try { + const res = await fetch(`${API_BASE}/agents/export`, { + method: 'POST', + headers: { 'X-Gaia-UI': '1' }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + let detail = text; + try { detail = JSON.parse(text).detail || text; } catch { /* not JSON */ } + throw new Error(detail || `Export failed (HTTP ${res.status})`); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'gaia-agents-export.zip'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + flashStatus({ kind: 'success', message: 'Export downloaded.' }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.api.error('Agent export failed', err); + flashStatus({ kind: 'error', message: `Export failed: ${message}` }); + } + }, [flashStatus]); + + // ── Import ─────────────────────────────────────────────────────── + const openFilePicker = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileSelected = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + // Reset the input so selecting the same file twice re-triggers onChange. + e.target.value = ''; + if (!file) return; + + // Best-effort pre-read of bundle.json to show agent IDs in the + // trust modal. If this fails for any reason, we still show the + // security confirmation — just without the agent ID list. + let agentIds: string[] | null = null; + try { + agentIds = await readBundleAgentIds(file); + } catch (err) { + log.api.warn('Could not pre-read bundle.json from zip', err); + } + + const idsLine = agentIds && agentIds.length > 0 + ? `Agents to install: ${agentIds.join(', ')}` + : 'Agents to install: (unable to read bundle contents — contents will be validated by the server)'; + + const proceed = window.confirm( + 'Importing this bundle will run third-party Python code on ' + + 'your machine when the agent is selected. Only import bundles ' + + 'from sources you trust.\n\n' + + idsLine + '\n\nImport?' + ); + if (!proceed) return; + + flashStatus({ kind: 'working', message: 'Uploading…' }); + try { + const form = new FormData(); + form.append('bundle', file, file.name); + const res = await fetch(`${API_BASE}/agents/import`, { + method: 'POST', + headers: { 'X-Gaia-UI': '1' }, + body: form, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + let detail = text; + try { detail = JSON.parse(text).detail || text; } catch { /* not JSON */ } + throw new Error(detail || `Import failed (HTTP ${res.status})`); + } + const data: { imported: string[]; overwritten: string[]; errors: Array<{ id: string; error: string }> } = + await res.json(); + // imported = all successfully placed agents (new + overwritten). + // overwritten = subset that replaced an existing agent. + // Show: "Installed N agent(s): X, Y (replaced: Z)" without duplication. + const allInstalled = data.imported || []; + const overwroteIds = data.overwritten || []; + await refreshAgents(); + let summary: string; + if (allInstalled.length === 0) { + summary = 'No agents imported'; + } else { + summary = `Installed ${allInstalled.length} agent(s): ${allInstalled.join(', ')}`; + if (overwroteIds.length > 0) summary += ` (replaced: ${overwroteIds.join(', ')})`; + } + if (data.errors && data.errors.length > 0) { + const errSummary = data.errors.map((e) => `${e.id}: ${e.error}`).join('; '); + flashStatus({ + kind: 'error', + message: `${summary}. Errors: ${errSummary}`, + }); + } else { + flashStatus({ + kind: 'success', + message: summary, + }); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.api.error('Agent import failed', err); + flashStatus({ kind: 'error', message: `Import failed: ${message}` }); + } + }, [flashStatus, refreshAgents]); + + const isWorking = status.kind === 'working'; + + return ( +
+

Custom Agents

+ + {customAgents.length === 0 ? ( +

+ No custom agents installed yet. Import a bundle below to add one. +

+ ) : ( +
+ {customAgents.map((agent) => ( +
+ {agent.name} +
+ {agent.id} + {agent.source && agent.source !== 'builtin' && ( + {agent.source} + )} +
+
+ ))} +
+ )} + +
+ + + + + +
+ + {status.kind !== 'idle' && ( +
+ {status.kind === 'working' && } + {status.kind === 'success' && } + {status.kind === 'error' && } + {status.message} +
+ )} +
+ ); +} + +// ── Zip pre-read helper ────────────────────────────────────────────────── + +/** + * Best-effort extraction of `agent_ids` from `bundle.json` inside a zip file. + * + * Parses only enough of the ZIP structure to locate the `bundle.json` entry + * (via the End-of-Central-Directory record and a single central-directory + * walk), then inflates it using the browser's built-in `DecompressionStream`. + * + * Returns null if the file is not a zip, has no bundle.json, or cannot be + * decoded. Callers should treat a null return as "unknown" and still show + * the security confirmation. + */ +async function readBundleAgentIds(file: File): Promise { + const buf = await file.arrayBuffer(); + const view = new DataView(buf); + const u8 = new Uint8Array(buf); + + // 1. Locate End-of-Central-Directory record (EOCD). + // Signature: 0x06054b50. The EOCD is near the end of the file, with + // up to 65535 bytes of comment after it. Scan backwards. + const maxScan = Math.min(buf.byteLength, 65557); + const scanStart = buf.byteLength - maxScan; + let eocdOffset = -1; + for (let i = buf.byteLength - 22; i >= scanStart; i--) { + if (view.getUint32(i, true) === 0x06054b50) { + eocdOffset = i; + break; + } + } + if (eocdOffset < 0) return null; + + const cdEntries = view.getUint16(eocdOffset + 10, true); + const cdOffset = view.getUint32(eocdOffset + 16, true); + + // 2. Walk central directory looking for bundle.json. + // Central file header signature: 0x02014b50. + let pos = cdOffset; + for (let i = 0; i < cdEntries; i++) { + if (pos + 46 > buf.byteLength) return null; + if (view.getUint32(pos, true) !== 0x02014b50) return null; + + const compressionMethod = view.getUint16(pos + 10, true); + const compressedSize = view.getUint32(pos + 20, true); + const nameLen = view.getUint16(pos + 28, true); + const extraLen = view.getUint16(pos + 30, true); + const commentLen = view.getUint16(pos + 32, true); + const localHeaderOffset = view.getUint32(pos + 42, true); + + const name = new TextDecoder('utf-8').decode(u8.subarray(pos + 46, pos + 46 + nameLen)); + + if (name === 'bundle.json') { + // 3. Seek to the local file header and skip its variable-length + // name+extra fields to find the actual data offset. + if (localHeaderOffset + 30 > buf.byteLength) return null; + if (view.getUint32(localHeaderOffset, true) !== 0x04034b50) return null; + const localNameLen = view.getUint16(localHeaderOffset + 26, true); + const localExtraLen = view.getUint16(localHeaderOffset + 28, true); + const dataStart = localHeaderOffset + 30 + localNameLen + localExtraLen; + const dataEnd = dataStart + compressedSize; + if (dataEnd > buf.byteLength) return null; + const compressed = u8.subarray(dataStart, dataEnd); + + let jsonText: string; + if (compressionMethod === 0) { + // Stored (no compression). + jsonText = new TextDecoder('utf-8').decode(compressed); + } else if (compressionMethod === 8) { + // Deflate. Use the browser's DecompressionStream. + const ds = new DecompressionStream('deflate-raw'); + const stream = new Blob([compressed]).stream().pipeThrough(ds); + const inflated = await new Response(stream).arrayBuffer(); + jsonText = new TextDecoder('utf-8').decode(inflated); + } else { + // Unsupported compression method. + return null; + } + + const parsed = JSON.parse(jsonText); + const ids = parsed?.agent_ids; + if (Array.isArray(ids) && ids.every((x) => typeof x === 'string')) { + return ids as string[]; + } + return null; + } + + pos += 46 + nameLen + extraLen + commentLen; + } + + return null; +} diff --git a/src/gaia/apps/webui/src/components/SettingsModal.tsx b/src/gaia/apps/webui/src/components/SettingsModal.tsx index 2e25ef90e..e3f17ccfd 100644 --- a/src/gaia/apps/webui/src/components/SettingsModal.tsx +++ b/src/gaia/apps/webui/src/components/SettingsModal.tsx @@ -9,6 +9,7 @@ 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 './SettingsModal.css'; export function SettingsModal() { @@ -261,6 +262,9 @@ export function SettingsModal() { )} + {/* Custom Agents — export/import bundles */} + + {/* About */}

About

diff --git a/src/gaia/cli.py b/src/gaia/cli.py index 813c7399b..c083e0ed2 100644 --- a/src/gaia/cli.py +++ b/src/gaia/cli.py @@ -2524,6 +2524,42 @@ def main(): "--all", action="store_true", help="Clear all caches" ) + # Agent command (export/import custom agent bundles) + agent_parser = subparsers.add_parser( + "agent", + help="Manage custom agents (export/import bundles)", + ) + agent_subparsers = agent_parser.add_subparsers( + dest="agent_action", help="Agent action to perform" + ) + + # Agent export command + agent_export_parser = agent_subparsers.add_parser( + "export", + help="Export custom agents from ~/.gaia/agents/ into a .zip bundle", + ) + agent_export_parser.add_argument( + "--output", + default=None, + help="Destination path for the .zip bundle (default: ~/.gaia/export.zip)", + ) + + # Agent import command + agent_import_parser = agent_subparsers.add_parser( + "import", + help="Import a custom agent .zip bundle into ~/.gaia/agents/", + ) + agent_import_parser.add_argument( + "path", + help="Path to the .zip bundle produced by 'gaia agent export'", + ) + agent_import_parser.add_argument( + "--yes", + "-y", + action="store_true", + help="Skip interactive confirmation prompt (non-interactive/CI use)", + ) + # Init command (one-stop GAIA setup) # Note: Does not use parent_parser to avoid showing irrelevant global options init_parser = subparsers.add_parser( @@ -4679,6 +4715,11 @@ def main(): handle_cache_command(args) return + # Handle Agent (export/import) command + if args.action == "agent": + handle_agent_command(args) + return + # Handle Blender command if args.action == "blender": handle_blender_command(args) @@ -5773,6 +5814,132 @@ def handle_cache_command(args): sys.exit(1) +def handle_agent_command(args): + """Handle the 'gaia agent' command group (export / import). + + Args: + args: Parsed command-line arguments + """ + if not hasattr(args, "agent_action") or args.agent_action is None: + print("❌ Error: No agent action specified") + print("Available actions: export, import") + print("Run 'gaia agent --help' for more information") + sys.exit(1) + + if args.agent_action == "export": + handle_agent_export(args) + elif args.agent_action == "import": + handle_agent_import(args) + else: + print(f"❌ Unknown agent action: {args.agent_action}") + sys.exit(1) + + +def handle_agent_export(args): + """Export custom agents under ~/.gaia/agents/ into a .zip bundle.""" + # Lazy import to keep CLI startup fast. + from gaia.installer.export_import import export_custom_agents + + output = args.output + if output is None: + output_path = Path.home() / ".gaia" / "export.zip" + else: + output_path = Path(output).expanduser() + + # Warn about secrets before writing anything. + print( + "Warning: exported bundle contains your agent source files as-is. " + "Any API keys or credentials in agent.py will be included. " + "Review before sharing.", + file=sys.stderr, + ) + + try: + result = export_custom_agents(output_path) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + except Exception as exc: # noqa: BLE001 + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + ids = ", ".join(result.agent_ids) + print(f"Exported {len(result.agent_ids)} agent(s) to {result.output_path}: {ids}") + + +def handle_agent_import(args): + """Import a custom agent .zip bundle into ~/.gaia/agents/.""" + import json + import zipfile + + # Lazy import to keep CLI startup fast. + from gaia.installer.export_import import import_agent_bundle + + bundle_path = Path(args.path).expanduser() + + if not bundle_path.exists(): + print(f"Error: bundle not found: {bundle_path}", file=sys.stderr) + sys.exit(1) + + # Peek at bundle.json so we can show agent ids in the trust prompt. + bundle_agent_ids = [] + try: + with zipfile.ZipFile(bundle_path) as zf: + raw = zf.read("bundle.json") + manifest = json.loads(raw.decode("utf-8")) + bundle_agent_ids = manifest.get("agent_ids", []) or [] + except ( + zipfile.BadZipFile, + KeyError, + json.JSONDecodeError, + UnicodeDecodeError, + ) as exc: + print(f"Error: invalid bundle: {exc}", file=sys.stderr) + sys.exit(1) + except Exception as exc: # noqa: BLE001 + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + # Trust gate. + if not args.yes: + if not sys.stdin.isatty(): + print( + "Error: refusing to import non-interactively without --yes. " + "Re-run with --yes to confirm.", + file=sys.stderr, + ) + sys.exit(1) + + print( + "Importing this bundle will install third-party Python code on your machine." + ) + if bundle_agent_ids: + print("Agents in bundle:") + for aid in bundle_agent_ids: + print(f" - {aid}") + answer = input("[y/N] Continue? ").strip().lower() + if answer not in ("y", "yes"): + print("Import cancelled.") + sys.exit(0) + + try: + result = import_agent_bundle(bundle_path) + except (ValueError, zipfile.BadZipFile) as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + except Exception as exc: # noqa: BLE001 + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + if result.imported: + print(f"Imported: {', '.join(result.imported)}") + if result.overwritten: + print(f"Overwritten: {', '.join(result.overwritten)}") + if result.errors: + print(f"Errors: {', '.join(result.errors)}", file=sys.stderr) + sys.exit(1) + + def handle_mcp_command(args): """ Handle the MCP (Model Context Protocol) command. diff --git a/src/gaia/installer/export_import.py b/src/gaia/installer/export_import.py new file mode 100644 index 000000000..fee812418 --- /dev/null +++ b/src/gaia/installer/export_import.py @@ -0,0 +1,374 @@ +# Copyright(C) 2024-2025 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +""" +Agent bundle export / import. + +Produces and consumes ``.zip`` archives of custom agents living under +``~/.gaia/agents/``. The archive contains a ``bundle.json`` table of +contents plus one subdirectory per agent. + +This module lives in ``gaia.installer`` (not ``gaia.agents``) because the +agents package owns runtime and registry concerns, not archive +serialization. +""" + +from __future__ import annotations + +import json +import os +import re +import stat +import tempfile +import zipfile +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import List + +from gaia.logger import get_logger +from gaia.version import __version__ + +log = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +BUNDLE_JSON_NAME = "bundle.json" +BUNDLE_FORMAT_VERSION = 1 + +# Extraction limits (zip-bomb / resource-abuse guards). +MAX_ENTRIES = 1000 +MAX_UNCOMPRESSED_TOTAL = 500 * 1024 * 1024 # 500 MB +MAX_UNCOMPRESSED_PER_FILE = 50 * 1024 * 1024 # 50 MB + +# Same regex as AgentManifest.validate_id in gaia.agents.registry. +_AGENT_ID_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,50}[a-z0-9])?$") + +# Reserved Windows device names (case-insensitive). +_RESERVED_WINDOWS_NAMES = { + "CON", + "PRN", + "AUX", + "NUL", + *(f"COM{i}" for i in range(1, 10)), + *(f"LPT{i}" for i in range(1, 10)), +} + + +# --------------------------------------------------------------------------- +# Public dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class ExportResult: + """Return value of :func:`export_custom_agents`.""" + + output_path: Path + agent_ids: List[str] + + +@dataclass +class ImportResult: + """Return value of :func:`import_agent_bundle`.""" + + imported: List[str] = field(default_factory=list) + overwritten: List[str] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _agents_root() -> Path: + """Return the resolved ``~/.gaia/agents`` path.""" + return (Path.home() / ".gaia" / "agents").resolve() + + +def _is_custom_agent_dir(path: Path) -> bool: + """A directory qualifies as a custom agent if it holds agent.py or agent.yaml.""" + return path.is_dir() and ( + (path / "agent.py").is_file() or (path / "agent.yaml").is_file() + ) + + +def _validate_agent_id(agent_id: str) -> None: + """Raise ValueError if the agent id is unsafe or malformed.""" + if not isinstance(agent_id, str) or not agent_id.strip(): + raise ValueError("Agent ID cannot be empty") + if "/" in agent_id or "\\" in agent_id or ".." in agent_id: + raise ValueError(f"Agent ID '{agent_id}' contains path separators") + if agent_id.upper() in _RESERVED_WINDOWS_NAMES: + raise ValueError(f"Agent ID '{agent_id}' is a reserved Windows device name") + if not _AGENT_ID_RE.match(agent_id): + raise ValueError( + f"Agent ID '{agent_id}' is invalid. " + "Use lowercase letters, digits, and hyphens (e.g. 'my-agent')." + ) + + +def _iso_now() -> str: + """Return an ISO-8601 UTC timestamp with trailing Z.""" + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# --------------------------------------------------------------------------- +# Export +# --------------------------------------------------------------------------- + + +def export_custom_agents(output_path: Path) -> ExportResult: + """Export every custom agent under ``~/.gaia/agents/`` into a zip bundle. + + Args: + output_path: Destination path for the ``.zip`` file. + + Returns: + :class:`ExportResult` with the path written and the agent IDs included. + + Raises: + ValueError: If there are no custom agents to export. + """ + output_path = Path(output_path) + agents_root = _agents_root() + + if not agents_root.exists(): + raise ValueError("No custom agents found to export") + + agent_dirs = sorted( + (d for d in agents_root.iterdir() if _is_custom_agent_dir(d)), + key=lambda p: p.name, + ) + if not agent_dirs: + raise ValueError("No custom agents found to export") + + agent_ids = [d.name for d in agent_dirs] + + manifest = { + "format_version": BUNDLE_FORMAT_VERSION, + "exported_at": _iso_now(), + "gaia_version": __version__, + "agent_ids": agent_ids, + } + + # Write to a temp file in the same directory so the final os.replace is atomic + # across the same filesystem. This prevents a partial write from corrupting an + # existing export.zip on failure. + output_path.parent.mkdir(parents=True, exist_ok=True) + tmp_fd, tmp_name = tempfile.mkstemp( + prefix=".gaia-export-", suffix=".zip", dir=str(output_path.parent) + ) + os.close(tmp_fd) + tmp_path = Path(tmp_name) + + try: + with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr(BUNDLE_JSON_NAME, json.dumps(manifest, indent=2)) + for agent_dir in agent_dirs: + for file_path in sorted(agent_dir.rglob("*")): + if not file_path.is_file(): + continue + arcname = f"{agent_dir.name}/{file_path.relative_to(agent_dir).as_posix()}" + zf.write(file_path, arcname=arcname) + os.replace(tmp_path, output_path) + except Exception: + if tmp_path.exists(): + try: + tmp_path.unlink() + except OSError: + pass + raise + + log.info("Exported %d custom agent(s) to %s", len(agent_ids), output_path) + return ExportResult(output_path=output_path, agent_ids=agent_ids) + + +# --------------------------------------------------------------------------- +# Import +# --------------------------------------------------------------------------- + + +def _validate_zip_entries(infos: List[zipfile.ZipInfo], agents_root: Path) -> None: + """Enforce size, entry-count, symlink, and path-traversal guards. + + Raises: + ValueError: On any policy violation. + """ + if len(infos) > MAX_ENTRIES: + raise ValueError(f"bundle has too many entries: {len(infos)} > {MAX_ENTRIES}") + + total_uncompressed = sum(i.file_size for i in infos) + if total_uncompressed > MAX_UNCOMPRESSED_TOTAL: + raise ValueError( + f"bundle uncompressed size {total_uncompressed} exceeds limit " + f"{MAX_UNCOMPRESSED_TOTAL}" + ) + + for info in infos: + name = info.filename + + if info.file_size > MAX_UNCOMPRESSED_PER_FILE: + raise ValueError( + f"entry {name} exceeds per-file limit " + f"({info.file_size} > {MAX_UNCOMPRESSED_PER_FILE})" + ) + + # Reject symlinks (stored in upper 16 bits of external_attr on unix zips). + if stat.S_ISLNK(info.external_attr >> 16): + raise ValueError(f"symlink entries not allowed: {name}") + + # Reject absolute paths and Windows drive letters (e.g. "C:/foo"). + if name.startswith(("/", "\\")) or (len(name) > 1 and name[1] == ":"): + raise ValueError(f"absolute paths not allowed: {name}") + + # Skip the bundle manifest: it is not written to disk. + if name == BUNDLE_JSON_NAME: + continue + + # Directory entries are fine as long as they resolve inside the root. + dest = (agents_root / name).resolve() + if dest == agents_root: + continue + if not dest.is_relative_to(agents_root): + raise ValueError(f"path traversal blocked: {name}") + + +def _read_bundle_manifest(zf: zipfile.ZipFile) -> dict: + """Load and validate ``bundle.json`` from the archive.""" + try: + raw = zf.read(BUNDLE_JSON_NAME) + except KeyError as exc: + raise ValueError("bundle is missing bundle.json") from exc + + try: + manifest = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"bundle.json is not valid JSON: {exc}") from exc + + if not isinstance(manifest, dict): + raise ValueError("bundle.json must be a JSON object") + + fmt = manifest.get("format_version") + if fmt != BUNDLE_FORMAT_VERSION: + raise ValueError( + f"unsupported bundle format_version: {fmt!r} " + f"(expected {BUNDLE_FORMAT_VERSION})" + ) + + agent_ids = manifest.get("agent_ids") + if not isinstance(agent_ids, list) or not all( + isinstance(a, str) for a in agent_ids + ): + raise ValueError("bundle.json 'agent_ids' must be a list of strings") + + return manifest + + +def import_agent_bundle(bundle_path: Path) -> ImportResult: + """Import an exported bundle into ``~/.gaia/agents/`` and hot-register. + + Overwrites any existing agent directory of the same ID. Partial failures + leave previously-installed agents intact (each agent is staged in a temp + dir and atomically moved into place). + + Args: + bundle_path: Path to the ``.zip`` file produced by + :func:`export_custom_agents`. + + Returns: + :class:`ImportResult` listing imported / overwritten / errored IDs. + + Raises: + ValueError: On malformed bundle or security-policy violation. + """ + bundle_path = Path(bundle_path) + agents_root = _agents_root() + agents_root.mkdir(parents=True, exist_ok=True) + + result = ImportResult() + + with zipfile.ZipFile(bundle_path) as zf: + infos = zf.infolist() + _validate_zip_entries(infos, agents_root) + manifest = _read_bundle_manifest(zf) + agent_ids = manifest["agent_ids"] + + # Validate every declared agent id BEFORE touching the filesystem. + for agent_id in agent_ids: + _validate_agent_id(agent_id) + + # Stage each agent in a temp dir, then move into place atomically. + with tempfile.TemporaryDirectory( + prefix=".gaia-import-", dir=str(agents_root) + ) as staging_root_str: + staging_root = Path(staging_root_str) + + # Extract every non-bundle.json entry into staging_root. + for info in infos: + if info.filename == BUNDLE_JSON_NAME: + continue + # Defensive: re-check destination lies under staging_root. + dest = (staging_root / info.filename).resolve() + if dest != staging_root and not dest.is_relative_to(staging_root): + raise ValueError( + f"path traversal blocked during extract: {info.filename}" + ) + if info.is_dir(): + dest.mkdir(parents=True, exist_ok=True) + continue + dest.parent.mkdir(parents=True, exist_ok=True) + bytes_written = 0 + with zf.open(info) as src, open(dest, "wb") as out: + for chunk in iter(lambda: src.read(65536), b""): + bytes_written += len(chunk) + if bytes_written > MAX_UNCOMPRESSED_PER_FILE: + raise ValueError( + f"entry {info.filename} exceeds per-file limit " + f"during extraction" + ) + out.write(chunk) + + # Move each staged agent dir to its final location. + for agent_id in agent_ids: + staged_dir = staging_root / agent_id + if not staged_dir.is_dir(): + result.errors.append( + f"{agent_id}: bundle declared this agent but no " + f"directory was present in the archive" + ) + continue + + final_dir = agents_root / agent_id + existed = final_dir.exists() + if existed: + # Move existing to a sibling temp dir so we can restore on failure. + backup_dir = staging_root / f".backup-{agent_id}" + os.replace(final_dir, backup_dir) + try: + os.replace(staged_dir, final_dir) + except Exception as exc: + # Attempt rollback. + if existed and (staging_root / f".backup-{agent_id}").exists(): + try: + os.replace(staging_root / f".backup-{agent_id}", final_dir) + except OSError: + pass + result.errors.append(f"{agent_id}: {exc}") + continue + + if existed: + result.overwritten.append(agent_id) + result.imported.append(agent_id) + + log.info( + "Imported %d agent(s), overwrote %d, errors=%d", + len(result.imported), + len(result.overwritten), + len(result.errors), + ) + return result diff --git a/src/gaia/ui/routers/agents.py b/src/gaia/ui/routers/agents.py index da58b2716..28c5cf63c 100644 --- a/src/gaia/ui/routers/agents.py +++ b/src/gaia/ui/routers/agents.py @@ -4,9 +4,23 @@ """Agent registry endpoints for GAIA Agent UI. Exposes the registered agents so the frontend can display an agent selector. +Also provides export/import endpoints for custom agent bundles. """ -from fastapi import APIRouter, HTTPException, Request +import tempfile +import zipfile +from pathlib import Path + +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + File, + HTTPException, + Request, + UploadFile, +) +from fastapi.responses import FileResponse from gaia.logger import get_logger @@ -16,6 +30,12 @@ router = APIRouter(tags=["agents"]) +# Maximum size of an uploaded import bundle (100 MB). +_MAX_IMPORT_BUNDLE_BYTES = 100 * 1024 * 1024 + +# Hosts treated as localhost for the purposes of export/import endpoints. +_LOCALHOST_HOSTS = {"127.0.0.1", "::1", "localhost", ""} + def _registry(request: Request): """Get the AgentRegistry from app.state.""" @@ -25,6 +45,39 @@ def _registry(request: Request): return registry +def _require_localhost(request: Request) -> None: + """Reject requests that do not originate from localhost.""" + host = (request.client.host if request.client else "") or "" + if host not in _LOCALHOST_HOSTS: + raise HTTPException( + status_code=403, detail="endpoint only available on localhost" + ) + + +def _require_ui_header(request: Request) -> None: + """Require the custom ``X-Gaia-UI: 1`` header as a lightweight CSRF guard. + + Custom headers trigger a CORS preflight in browsers, so drive-by form + POSTs from malicious tabs cannot supply this header. + """ + if request.headers.get("x-gaia-ui") != "1": + raise HTTPException(status_code=403, detail="missing X-Gaia-UI header") + + +def _require_tunnel_inactive(request: Request) -> None: + """Block export/import while the ngrok tunnel is active. + + Streaming a bundle across a public tunnel would be a data-exfil footgun, + so we refuse outright rather than trying to reason about auth. + """ + tunnel = getattr(request.app.state, "tunnel", None) + if tunnel is not None and getattr(tunnel, "active", False): + raise HTTPException( + status_code=503, + detail="import/export not available while tunnel is active", + ) + + def _reg_to_info(reg) -> AgentInfo: return AgentInfo( id=reg.id, @@ -55,3 +108,116 @@ async def get_agent(agent_id: str, request: Request): if reg is None: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") return _reg_to_info(reg) + + +@router.post( + "/api/agents/export", + dependencies=[ + Depends(_require_localhost), + Depends(_require_ui_header), + Depends(_require_tunnel_inactive), + ], +) +async def export_agents(background_tasks: BackgroundTasks): + """Export all custom agents as a downloadable zip bundle.""" + from gaia.installer.export_import import export_custom_agents + + # Write to a per-request temp file so concurrent exports don't race on a + # shared path, and the file is cleaned up after streaming completes. + gaia_dir = Path.home() / ".gaia" + gaia_dir.mkdir(parents=True, exist_ok=True) + import os as _os + + tmp_fd, tmp_name = tempfile.mkstemp( + prefix="gaia-export-", suffix=".zip", dir=str(gaia_dir) + ) + _os.close(tmp_fd) + tmp_path = Path(tmp_name) + try: + export_custom_agents(tmp_path) + except ValueError as exc: + tmp_path.unlink(missing_ok=True) + raise HTTPException(status_code=400, detail=str(exc)) from exc + + background_tasks.add_task(lambda: tmp_path.unlink(missing_ok=True)) + return FileResponse( + path=str(tmp_path), + media_type="application/zip", + filename="gaia-agents-export.zip", + headers={ + "Content-Disposition": 'attachment; filename="gaia-agents-export.zip"', + }, + ) + + +@router.post( + "/api/agents/import", + dependencies=[ + Depends(_require_localhost), + Depends(_require_ui_header), + Depends(_require_tunnel_inactive), + ], +) +async def import_agents(request: Request, bundle: UploadFile = File(...)): # noqa: B008 + """Import a custom agent bundle from an uploaded zip file.""" + from gaia.installer.export_import import import_agent_bundle + + # Fast reject on declared content length before streaming bytes. + content_length = request.headers.get("content-length") + if content_length is not None: + try: + if int(content_length) > _MAX_IMPORT_BUNDLE_BYTES: + raise HTTPException( + status_code=413, detail="bundle exceeds 100 MB limit" + ) + except ValueError: + # Malformed header — ignore and fall through to streaming limit. + pass + + # Stream upload into a temp file with a hard byte cap. + tmp = tempfile.NamedTemporaryFile( + prefix="gaia-import-", suffix=".zip", delete=False + ) + tmp_path = Path(tmp.name) + total_bytes = 0 + try: + try: + while True: + chunk = await bundle.read(1024 * 1024) # 1 MiB + if not chunk: + break + total_bytes += len(chunk) + if total_bytes > _MAX_IMPORT_BUNDLE_BYTES: + raise HTTPException( + status_code=413, detail="bundle exceeds 100 MB limit" + ) + tmp.write(chunk) + finally: + tmp.close() + + try: + result = import_agent_bundle(tmp_path) + except (ValueError, zipfile.BadZipFile) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + finally: + try: + tmp_path.unlink() + except OSError: + pass + + # Hot-register imported agents into the LIVE server registry (app.state), + # not a fresh AgentRegistry() instance which would be an orphan. + live_registry = getattr(request.app.state, "agent_registry", None) + if live_registry is not None: + agents_root = Path.home() / ".gaia" / "agents" + for agent_id in result.imported: + try: + live_registry.register_from_dir(agents_root / agent_id) + except Exception as exc: # noqa: BLE001 + logger.warning("Hot-register failed for %s: %s", agent_id, exc) + + return { + "imported": result.imported, + "overwritten": result.overwritten, + "errors": result.errors, + } diff --git a/tests/electron/agent-seeder.test.cjs b/tests/electron/agent-seeder.test.cjs new file mode 100644 index 000000000..f177fba3e --- /dev/null +++ b/tests/electron/agent-seeder.test.cjs @@ -0,0 +1,342 @@ +// Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +// SPDX-License-Identifier: MIT + +/** + * Tests for agent-seeder + * (src/gaia/apps/webui/services/agent-seeder.cjs) + * + * Covers: idempotency, sentinel-based skip, user-owned directory protection, + * partial-copy recovery, cross-platform resourcesPath construction, missing + * resourcesPath guard, and per-agent error isolation. + * + * All tests use a fresh tmpdir for both HOME (so ~/.gaia writes land in the + * temp sandbox) and for process.resourcesPath, so nothing touches the real + * filesystem outside os.tmpdir(). + */ + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +// ── Test sandbox ───────────────────────────────────────────────────────── + +/** + * Build an isolated sandbox with: + * - a fake HOME that os.homedir() returns + * - a fake resources dir that we point process.resourcesPath at + * + * Each call creates a unique tmpdir so tests never collide. + */ +function makeSandbox() { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "gaia-seeder-test-")); + const fakeHome = path.join(base, "home"); + const fakeResources = path.join(base, "resources"); + fs.mkdirSync(fakeHome, { recursive: true }); + fs.mkdirSync(fakeResources, { recursive: true }); + return { base, fakeHome, fakeResources }; +} + +/** + * Populate `/agents//` with a handful of files so the seeder + * has something real to copy. Returns the agent dir path. + */ +function createBundledAgent(resourcesDir, id, files = { "manifest.json": "{}" }) { + const agentDir = path.join(resourcesDir, "agents", id); + fs.mkdirSync(agentDir, { recursive: true }); + for (const [name, content] of Object.entries(files)) { + const p = path.join(agentDir, name); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content); + } + return agentDir; +} + +/** + * Load the seeder module fresh after stubbing os.homedir and + * process.resourcesPath. We use jest.isolateModules so each test gets a + * clean require cache (the seeder caches nothing, but this keeps the + * tests hermetic). + */ +function loadSeederWith({ fakeHome, resourcesPath }) { + let seeder; + jest.isolateModules(() => { + // Stub os.homedir BEFORE requiring the seeder. The seeder reads it + // at call time, not at require time, so stubbing after would also + // work — but doing it here makes the intent clear. + jest.spyOn(os, "homedir").mockReturnValue(fakeHome); + + // process.resourcesPath is normally set by Electron at launch. Tests + // drive it directly. + Object.defineProperty(process, "resourcesPath", { + configurable: true, + writable: true, + value: resourcesPath, + }); + + // eslint-disable-next-line global-require + seeder = require("../../src/gaia/apps/webui/services/agent-seeder.cjs"); + }); + return seeder; +} + +function restoreEnv() { + jest.restoreAllMocks(); + // Leave process.resourcesPath alone — the next test sets it again. We + // only need to ensure the descriptor is configurable, which we did above. +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe("agent-seeder", () => { + afterEach(() => { + restoreEnv(); + }); + + test("idempotency — second call skips already-seeded agents", async () => { + const { fakeHome, fakeResources } = makeSandbox(); + createBundledAgent(fakeResources, "alpha", { + "manifest.json": JSON.stringify({ name: "alpha" }), + "code/main.py": "print('hi')", + }); + + const seeder = loadSeederWith({ fakeHome, resourcesPath: fakeResources }); + + const first = await seeder.seedBundledAgents(); + expect(first.seeded).toEqual(["alpha"]); + expect(first.skipped).toEqual([]); + expect(first.errors).toEqual([]); + + // Sentinel should exist. + const sentinel = path.join(fakeHome, ".gaia", "agents", "alpha", ".seeded"); + expect(fs.existsSync(sentinel)).toBe(true); + + // Content copied. + const manifest = path.join(fakeHome, ".gaia", "agents", "alpha", "manifest.json"); + expect(fs.readFileSync(manifest, "utf8")).toBe( + JSON.stringify({ name: "alpha" }) + ); + + const second = await seeder.seedBundledAgents(); + expect(second.seeded).toEqual([]); + expect(second.skipped).toEqual(["alpha"]); + expect(second.errors).toEqual([]); + }); + + test("skip when .seeded present (pre-existing sentinel)", async () => { + const { fakeHome, fakeResources } = makeSandbox(); + createBundledAgent(fakeResources, "beta"); + + // Pre-populate the target with just the sentinel (pretend a previous + // run already seeded it). + const target = path.join(fakeHome, ".gaia", "agents", "beta"); + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, ".seeded"), "{}"); + + const seeder = loadSeederWith({ fakeHome, resourcesPath: fakeResources }); + const result = await seeder.seedBundledAgents(); + + expect(result.seeded).toEqual([]); + expect(result.skipped).toEqual(["beta"]); + expect(result.errors).toEqual([]); + }); + + test("skip user-owned directory (target exists WITHOUT sentinel)", async () => { + const { fakeHome, fakeResources } = makeSandbox(); + createBundledAgent(fakeResources, "gamma", { + "bundled-only.txt": "from installer", + }); + + // Simulate a hand-authored agent at the target — no .seeded sentinel. + const target = path.join(fakeHome, ".gaia", "agents", "gamma"); + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(target, "user-file.txt"), "do not clobber"); + + const seeder = loadSeederWith({ fakeHome, resourcesPath: fakeResources }); + const result = await seeder.seedBundledAgents(); + + expect(result.seeded).toEqual([]); + expect(result.skipped).toEqual(["gamma"]); + expect(result.errors).toEqual([]); + + // User file untouched. + expect( + fs.readFileSync(path.join(target, "user-file.txt"), "utf8") + ).toBe("do not clobber"); + // Bundled file was NOT copied in. + expect(fs.existsSync(path.join(target, "bundled-only.txt"))).toBe(false); + // No sentinel magically appeared. + expect(fs.existsSync(path.join(target, ".seeded"))).toBe(false); + }); + + test("partial-copy recovery — stale .partial cleaned up", async () => { + const { fakeHome, fakeResources } = makeSandbox(); + createBundledAgent(fakeResources, "delta", { + "manifest.json": "{}", + }); + + // Simulate a prior failed run: .partial exists with leftover data. + const agentsRoot = path.join(fakeHome, ".gaia", "agents"); + fs.mkdirSync(agentsRoot, { recursive: true }); + const partial = path.join(agentsRoot, "delta.partial"); + fs.mkdirSync(partial, { recursive: true }); + fs.writeFileSync(path.join(partial, "garbage.txt"), "from failed run"); + + const seeder = loadSeederWith({ fakeHome, resourcesPath: fakeResources }); + const result = await seeder.seedBundledAgents(); + + expect(result.seeded).toEqual(["delta"]); + expect(result.errors).toEqual([]); + + // Partial dir was cleaned up. + expect(fs.existsSync(partial)).toBe(false); + + // Target has only the bundled content, not the stale "garbage.txt". + const target = path.join(agentsRoot, "delta"); + expect(fs.existsSync(path.join(target, "manifest.json"))).toBe(true); + expect(fs.existsSync(path.join(target, "garbage.txt"))).toBe(false); + expect(fs.existsSync(path.join(target, ".seeded"))).toBe(true); + }); + + describe("cross-platform resourcesPath construction", () => { + // We exercise the real filesystem under each fixture path structure + // so the test doubles as an integration check of path.join semantics. + // Each fixture uses a tmpdir with a subdir that mimics the shape of + // the platform's resources location. + const fixtures = [ + { + name: "Windows-style", + // Simulates: C:\Program Files\GAIA\resources + suffix: path.join("ProgramFiles", "GAIA", "resources"), + }, + { + name: "macOS-style", + // Simulates: .../GAIA.app/Contents/Resources + suffix: path.join("GAIA.app", "Contents", "Resources"), + }, + { + name: "Linux-style", + // Simulates: /opt/gaia/resources + suffix: path.join("opt", "gaia", "resources"), + }, + ]; + + for (const fx of fixtures) { + test(`constructs agents/ source correctly for ${fx.name}`, async () => { + const { base, fakeHome } = makeSandbox(); + const resourcesPath = path.join(base, fx.suffix); + fs.mkdirSync(path.join(resourcesPath, "agents"), { recursive: true }); + // Empty agents/ dir is fine — the seeder should walk it and return. + + const seeder = loadSeederWith({ fakeHome, resourcesPath }); + const result = await seeder.seedBundledAgents(); + + expect(result.seeded).toEqual([]); + expect(result.skipped).toEqual([]); + expect(result.errors).toEqual([]); + + // Sanity: drop in an agent at the constructed path and re-run. + createBundledAgent(resourcesPath, "platformcheck"); + const second = await seeder.seedBundledAgents(); + expect(second.seeded).toEqual(["platformcheck"]); + }); + } + }); + + test("missing process.resourcesPath returns empty result without throwing", async () => { + const { fakeHome } = makeSandbox(); + const seeder = loadSeederWith({ fakeHome, resourcesPath: undefined }); + + const result = await seeder.seedBundledAgents(); + expect(result).toEqual({ seeded: [], skipped: [], errors: [] }); + }); + + test("missing agents/ directory returns empty result (not an error)", async () => { + const { fakeHome, fakeResources } = makeSandbox(); + // Deliberately do NOT create /agents. + + const seeder = loadSeederWith({ fakeHome, resourcesPath: fakeResources }); + const result = await seeder.seedBundledAgents(); + + expect(result).toEqual({ seeded: [], skipped: [], errors: [] }); + }); + + test("error isolation — one failing agent does not block others", async () => { + const { fakeHome, fakeResources } = makeSandbox(); + createBundledAgent(fakeResources, "good1", { "manifest.json": "{}" }); + createBundledAgent(fakeResources, "bad", { "manifest.json": "{}" }); + createBundledAgent(fakeResources, "good2", { "manifest.json": "{}" }); + + const seeder = loadSeederWith({ fakeHome, resourcesPath: fakeResources }); + + // Force a failure for the "bad" agent only. We spy on fs.renameSync + // (the atomic-rename step) and throw when the source path ends with + // "bad.partial". All other renames go through to the real impl. + const realRename = fs.renameSync.bind(fs); + const renameSpy = jest + .spyOn(fs, "renameSync") + .mockImplementation((from, to) => { + if (typeof from === "string" && from.endsWith(`bad.partial`)) { + const err = new Error("EACCES: simulated permission denied"); + err.code = "EACCES"; + throw err; + } + return realRename(from, to); + }); + + const result = await seeder.seedBundledAgents(); + + // Cleanup spy so other tests are unaffected. + renameSpy.mockRestore(); + + expect(result.seeded.sort()).toEqual(["good1", "good2"]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].id).toBe("bad"); + expect(result.errors[0].error).toBeInstanceOf(Error); + expect(result.errors[0].error.message).toMatch(/EACCES/); + + // The failing agent's target should NOT exist (since rename failed). + const badTarget = path.join(fakeHome, ".gaia", "agents", "bad"); + expect(fs.existsSync(badTarget)).toBe(false); + // And the partial should have been cleaned up. + const badPartial = path.join(fakeHome, ".gaia", "agents", "bad.partial"); + expect(fs.existsSync(badPartial)).toBe(false); + + // The good agents DID land, with sentinels. + expect( + fs.existsSync(path.join(fakeHome, ".gaia", "agents", "good1", ".seeded")) + ).toBe(true); + expect( + fs.existsSync(path.join(fakeHome, ".gaia", "agents", "good2", ".seeded")) + ).toBe(true); + }); + + test("logs are written to ~/.gaia/logs/seeder.log", async () => { + const { fakeHome, fakeResources } = makeSandbox(); + createBundledAgent(fakeResources, "loggy"); + + const seeder = loadSeederWith({ fakeHome, resourcesPath: fakeResources }); + await seeder.seedBundledAgents(); + + const logPath = path.join(fakeHome, ".gaia", "logs", "seeder.log"); + expect(fs.existsSync(logPath)).toBe(true); + const content = fs.readFileSync(logPath, "utf8"); + expect(content).toMatch(/\[INFO\]/); + expect(content).toMatch(/loggy/); + }); + + test("non-directory entries in agents/ are ignored", async () => { + const { fakeHome, fakeResources } = makeSandbox(); + const agentsSrc = path.join(fakeResources, "agents"); + fs.mkdirSync(agentsSrc, { recursive: true }); + // Create a loose file alongside a real agent dir. + fs.writeFileSync(path.join(agentsSrc, "README.txt"), "ignore me"); + createBundledAgent(fakeResources, "real"); + + const seeder = loadSeederWith({ fakeHome, resourcesPath: fakeResources }); + const result = await seeder.seedBundledAgents(); + + expect(result.seeded).toEqual(["real"]); + expect(result.skipped).toEqual([]); + expect(result.errors).toEqual([]); + }); +}); diff --git a/tests/electron/package.json b/tests/electron/package.json index e26b3b190..09bc7643b 100644 --- a/tests/electron/package.json +++ b/tests/electron/package.json @@ -22,6 +22,7 @@ ], "testMatch": [ "**/*.test.js", + "**/*.test.cjs", "**/test_*.js" ], "coverageThreshold": { diff --git a/tests/unit/test_export_import.py b/tests/unit/test_export_import.py new file mode 100644 index 000000000..2b7c9c925 --- /dev/null +++ b/tests/unit/test_export_import.py @@ -0,0 +1,438 @@ +# Copyright(C) 2024-2025 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Unit tests for gaia.installer.export_import.""" + +from __future__ import annotations + +import json +import stat +import zipfile +from pathlib import Path + +import pytest + +from gaia.installer import export_import +from gaia.installer.export_import import ( + BUNDLE_FORMAT_VERSION, + BUNDLE_JSON_NAME, + MAX_ENTRIES, + MAX_UNCOMPRESSED_PER_FILE, + ExportResult, + ImportResult, + export_custom_agents, + import_agent_bundle, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_home(tmp_path, monkeypatch): + """Redirect Path.home() and ~/.gaia/agents to a tmp location.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + agents_root = home / ".gaia" / "agents" + agents_root.mkdir(parents=True) + return home + + +@pytest.fixture +def agents_root(fake_home): + return fake_home / ".gaia" / "agents" + + +def _make_agent(agents_root: Path, agent_id: str, body: str = "") -> Path: + agent_dir = agents_root / agent_id + agent_dir.mkdir() + (agent_dir / "agent.py").write_text( + body or f"# agent {agent_id}\nAGENT_ID = '{agent_id}'\n" + ) + return agent_dir + + +def _write_bundle( + path: Path, + *, + manifest: dict | None = None, + files: dict[str, bytes] | None = None, + include_manifest: bool = True, + extra_infos: list[zipfile.ZipInfo] | None = None, +) -> None: + """Craft a bundle zip for negative-path tests.""" + files = files or {} + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + if include_manifest: + zf.writestr( + BUNDLE_JSON_NAME, + json.dumps( + manifest + or { + "format_version": BUNDLE_FORMAT_VERSION, + "exported_at": "2026-04-17T00:00:00Z", + "gaia_version": "test", + "agent_ids": [], + } + ), + ) + for name, data in files.items(): + zf.writestr(name, data) + for info in extra_infos or []: + zf.writestr(info, b"") + + +# --------------------------------------------------------------------------- +# 1. Round-trip +# --------------------------------------------------------------------------- + + +def test_export_import_round_trip(tmp_path, fake_home, agents_root): + # Create a real agent dir with a couple of files. + agent_dir = _make_agent(agents_root, "zoo-agent") + (agent_dir / "notes.txt").write_text("hello") + (agent_dir / "sub").mkdir() + (agent_dir / "sub" / "helper.py").write_text("x = 1\n") + + out = tmp_path / "export.zip" + result = export_custom_agents(out) + assert isinstance(result, ExportResult) + assert result.agent_ids == ["zoo-agent"] + assert out.exists() + + # Remove the source then import and verify contents match. + import shutil + + shutil.rmtree(agent_dir) + assert not (agents_root / "zoo-agent").exists() + + imported = import_agent_bundle(out) + assert isinstance(imported, ImportResult) + assert imported.imported == ["zoo-agent"] + assert imported.overwritten == [] + + restored = agents_root / "zoo-agent" + assert restored.is_dir() + assert (restored / "agent.py").is_file() + assert (restored / "notes.txt").read_text() == "hello" + assert (restored / "sub" / "helper.py").read_text() == "x = 1\n" + + +# --------------------------------------------------------------------------- +# 2. Zip-slip +# --------------------------------------------------------------------------- + + +def test_zip_slip_rejected(tmp_path, fake_home): + bundle = tmp_path / "evil.zip" + _write_bundle( + bundle, + manifest={ + "format_version": BUNDLE_FORMAT_VERSION, + "exported_at": "now", + "gaia_version": "test", + "agent_ids": ["a"], + }, + files={"../../etc/passwd": b"root:x:0:0"}, + ) + with pytest.raises(ValueError, match="path traversal|absolute"): + import_agent_bundle(bundle) + + +# --------------------------------------------------------------------------- +# 3. Symlink +# --------------------------------------------------------------------------- + + +def test_symlink_rejected(tmp_path, fake_home): + bundle = tmp_path / "sym.zip" + # Build manually so we can set external_attr to mark a symlink. + with zipfile.ZipFile(bundle, "w") as zf: + zf.writestr( + BUNDLE_JSON_NAME, + json.dumps( + { + "format_version": BUNDLE_FORMAT_VERSION, + "exported_at": "now", + "gaia_version": "test", + "agent_ids": ["linky"], + } + ), + ) + info = zipfile.ZipInfo("linky/agent.py") + # 0o120000 in upper 16 bits marks a symlink on POSIX zips. + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + zf.writestr(info, b"/etc/passwd") + + with pytest.raises(ValueError, match="symlink"): + import_agent_bundle(bundle) + + +# --------------------------------------------------------------------------- +# 4. Absolute path +# --------------------------------------------------------------------------- + + +def test_absolute_path_rejected(tmp_path, fake_home): + bundle = tmp_path / "abs.zip" + with zipfile.ZipFile(bundle, "w") as zf: + zf.writestr( + BUNDLE_JSON_NAME, + json.dumps( + { + "format_version": BUNDLE_FORMAT_VERSION, + "exported_at": "now", + "gaia_version": "test", + "agent_ids": ["a"], + } + ), + ) + # Use a raw ZipInfo so the name is preserved verbatim. + info = zipfile.ZipInfo("/etc/passwd") + zf.writestr(info, b"content") + + with pytest.raises(ValueError, match="absolute"): + import_agent_bundle(bundle) + + +# --------------------------------------------------------------------------- +# 5. Oversized entry +# --------------------------------------------------------------------------- + + +def test_oversized_entry_rejected(tmp_path, fake_home, monkeypatch): + bundle = tmp_path / "big.zip" + with zipfile.ZipFile(bundle, "w") as zf: + zf.writestr( + BUNDLE_JSON_NAME, + json.dumps( + { + "format_version": BUNDLE_FORMAT_VERSION, + "exported_at": "now", + "gaia_version": "test", + "agent_ids": ["big"], + } + ), + ) + zf.writestr("big/agent.py", b"tiny") + + # Spoof the reported file_size of that single entry. + orig_infolist = zipfile.ZipFile.infolist + + def spoofed(self): + infos = orig_infolist(self) + for info in infos: + if info.filename == "big/agent.py": + info.file_size = MAX_UNCOMPRESSED_PER_FILE + 1 + return infos + + monkeypatch.setattr(zipfile.ZipFile, "infolist", spoofed) + + with pytest.raises(ValueError, match="per-file limit|uncompressed"): + import_agent_bundle(bundle) + + +# --------------------------------------------------------------------------- +# 6. Too many entries +# --------------------------------------------------------------------------- + + +def test_too_many_entries_rejected(tmp_path, fake_home): + bundle = tmp_path / "many.zip" + with zipfile.ZipFile(bundle, "w") as zf: + zf.writestr( + BUNDLE_JSON_NAME, + json.dumps( + { + "format_version": BUNDLE_FORMAT_VERSION, + "exported_at": "now", + "gaia_version": "test", + "agent_ids": ["a"], + } + ), + ) + # MAX_ENTRIES total allowed; we need > MAX_ENTRIES entries overall. + # bundle.json already counts as 1, so add MAX_ENTRIES more = 1001. + for i in range(MAX_ENTRIES): + zf.writestr(f"a/file_{i}.txt", b"") + + with pytest.raises(ValueError, match="too many entries"): + import_agent_bundle(bundle) + + +# --------------------------------------------------------------------------- +# 7-9. Invalid agent IDs +# --------------------------------------------------------------------------- + + +def test_invalid_agent_id_dot_dot(tmp_path, fake_home): + bundle = tmp_path / "dd.zip" + _write_bundle( + bundle, + manifest={ + "format_version": BUNDLE_FORMAT_VERSION, + "exported_at": "now", + "gaia_version": "test", + "agent_ids": ["../../evil"], + }, + files={"harmless/agent.py": b"x=1"}, + ) + with pytest.raises(ValueError, match="path separators|invalid|traversal|absolute"): + import_agent_bundle(bundle) + + +def test_invalid_agent_id_slash(tmp_path, fake_home): + bundle = tmp_path / "slash.zip" + _write_bundle( + bundle, + manifest={ + "format_version": BUNDLE_FORMAT_VERSION, + "exported_at": "now", + "gaia_version": "test", + "agent_ids": ["a/b"], + }, + files={"harmless/agent.py": b"x=1"}, + ) + with pytest.raises(ValueError, match="path separators|invalid"): + import_agent_bundle(bundle) + + +def test_invalid_agent_id_reserved_windows_name(tmp_path, fake_home): + bundle = tmp_path / "com1.zip" + _write_bundle( + bundle, + manifest={ + "format_version": BUNDLE_FORMAT_VERSION, + "exported_at": "now", + "gaia_version": "test", + "agent_ids": ["COM1"], + }, + files={"harmless/agent.py": b"x=1"}, + ) + with pytest.raises(ValueError, match="reserved Windows|invalid"): + import_agent_bundle(bundle) + + +# --------------------------------------------------------------------------- +# 10. Zero agents to export +# --------------------------------------------------------------------------- + + +def test_export_zero_agents_raises(tmp_path, fake_home, agents_root): + # agents_root exists but is empty. + out = tmp_path / "empty.zip" + with pytest.raises(ValueError, match="No custom agents"): + export_custom_agents(out) + assert not out.exists() + + +# --------------------------------------------------------------------------- +# 11. Overwrite existing +# --------------------------------------------------------------------------- + + +def test_import_overwrites_existing(tmp_path, fake_home, agents_root): + # Seed an existing agent with old content. + existing = _make_agent(agents_root, "zoo-agent", body="OLD\n") + (existing / "stale.txt").write_text("stale") + + # Build a bundle with the same id but new content. + bundle = tmp_path / "new.zip" + staging = tmp_path / "staging" + staging.mkdir() + new_agent = staging / "zoo-agent" + new_agent.mkdir() + (new_agent / "agent.py").write_text("NEW\n") + + with zipfile.ZipFile(bundle, "w") as zf: + zf.writestr( + BUNDLE_JSON_NAME, + json.dumps( + { + "format_version": BUNDLE_FORMAT_VERSION, + "exported_at": "now", + "gaia_version": "test", + "agent_ids": ["zoo-agent"], + } + ), + ) + zf.write(new_agent / "agent.py", arcname="zoo-agent/agent.py") + + result = import_agent_bundle(bundle) + assert result.imported == ["zoo-agent"] + assert result.overwritten == ["zoo-agent"] + + final = agents_root / "zoo-agent" + assert (final / "agent.py").read_text() == "NEW\n" + # Stale file from the previous install must be gone. + assert not (final / "stale.txt").exists() + + +# --------------------------------------------------------------------------- +# 12. Atomicity of export +# --------------------------------------------------------------------------- + + +def test_export_atomicity(tmp_path, fake_home, agents_root, monkeypatch): + _make_agent(agents_root, "keep") + + # Pre-existing export.zip with known good content. + out = tmp_path / "export.zip" + out.write_bytes(b"ORIGINAL_GOOD_CONTENT") + original_bytes = out.read_bytes() + + # Force zip writing to fail mid-flight by monkeypatching zipfile.ZipFile.write. + real_write = zipfile.ZipFile.write + + def boom(self, *args, **kwargs): + raise OSError("simulated disk-full") + + monkeypatch.setattr(zipfile.ZipFile, "write", boom) + + with pytest.raises(OSError, match="simulated disk-full"): + export_custom_agents(out) + + # Original file must be untouched. + assert out.read_bytes() == original_bytes + + # Restore and ensure no temp file was left behind in the output directory. + monkeypatch.setattr(zipfile.ZipFile, "write", real_write) + leftover = [p for p in out.parent.iterdir() if p.name.startswith(".gaia-export-")] + assert leftover == [] + + +# --------------------------------------------------------------------------- +# 13. Missing bundle.json +# --------------------------------------------------------------------------- + + +def test_missing_bundle_json(tmp_path, fake_home): + bundle = tmp_path / "nomanifest.zip" + with zipfile.ZipFile(bundle, "w") as zf: + zf.writestr("foo/agent.py", b"x=1") + + with pytest.raises(ValueError, match="bundle.json"): + import_agent_bundle(bundle) + + +# --------------------------------------------------------------------------- +# 14. Wrong format version +# --------------------------------------------------------------------------- + + +def test_wrong_format_version(tmp_path, fake_home): + bundle = tmp_path / "v2.zip" + _write_bundle( + bundle, + manifest={ + "format_version": 2, + "exported_at": "now", + "gaia_version": "test", + "agent_ids": ["a"], + }, + files={"a/agent.py": b"x=1"}, + ) + with pytest.raises(ValueError, match="format_version"): + import_agent_bundle(bundle) diff --git a/util/check_doc_citations.py b/util/check_doc_citations.py new file mode 100644 index 000000000..43dfe3854 --- /dev/null +++ b/util/check_doc_citations.py @@ -0,0 +1,219 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +""" +Documentation Citation Checker + +Parses all .mdx and .md files in docs/ and verifies that `path:NNN` style +citations to source files actually resolve. Two checks are performed: + + (a) File existence + line-range bounds — the referenced file exists + and NNN falls within its line count. + + (b) Symbol-anchor assertion — for high-risk "landmark" citations + (NSIS macros, class definitions), verify the expected symbol + still appears within a small window of the cited line. Catches + "symbol moved but line still within file bounds" drift that + plain bounds checks miss. + +Citations recognized: + - `path/to/file.py:123` — plain cite, line 123 + - `path/to/file.py:123-456` — plain cite, range + - [text](path/to/file.py:123) — markdown link with line anchor + - Backticked forms inside MDX content + +Usage: + python util/check_doc_citations.py # Check all docs + python util/check_doc_citations.py --verbose # Show all citations + python util/check_doc_citations.py --paths docs/guides/custom-installer.mdx + +Exit code is non-zero if any citation fails. +""" + +import argparse +import re +import sys +from pathlib import Path +from typing import List, NamedTuple, Optional + + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_DOC_DIRS = ["docs"] + +# Citation forms. All patterns capture (path, line_start, line_end_or_none). +# Only paths that look like repo-relative source/infra references are checked. +CITE_EXT = r"(?:py|ts|tsx|js|jsx|json|ya?ml|nsh|nsi|ps1|toml|md|mdx|sh|bat|cfg|ini)" +PATH_PREFIX = r"(?:src|installer|util|scripts|\.github|tests|docs|cpp|examples|workshop)" + +# Backticked `path/to/file.ext:NNN` or `path/to/file.ext:NNN-MMM` +BACKTICK_CITE_RE = re.compile( + rf"`({PATH_PREFIX}/[^\s`]+\.{CITE_EXT}):(\d+)(?:-(\d+))?`" +) + +# Markdown link target `(path/to/file:NNN)` — less common but supported +LINK_CITE_RE = re.compile( + rf"\(({PATH_PREFIX}/[^\s)]+\.{CITE_EXT}):(\d+)(?:-(\d+))?\)" +) + +# Symbol anchors — hard-coded expectations for high-risk landmarks. +# Maps repo-relative path -> {line_number: regex_expected_within_window}. +# A citation of `:NNN` where NNN is within ±WINDOW of a key triggers +# a regex check on that line. +ANCHOR_WINDOW = 3 +ANCHORS = { + "installer/nsis/installer.nsh": { + 56: r"^!macro\s+customInstall\b", + 69: r"^!macro\s+customUnInstall\b", + }, + "src/gaia/agents/registry.py": { + 37: r"^class\s+AgentManifest\b", + 86: r"^class\s+AgentRegistry\b", + }, + "src/gaia/agents/base/agent.py": { + 51: r"^class\s+Agent\b", + }, + "src/gaia/installer/export_import.py": { + 126: r"^def\s+export_custom_agents\b", + 277: r"^def\s+import_agent_bundle\b", + }, + "src/gaia/apps/webui/services/agent-seeder.cjs": { + 225: r"^async\s+function\s+seedBundledAgents\b", + }, +} + + +class CiteResult(NamedTuple): + doc: str + doc_line: int + target: str + line_start: int + line_end: Optional[int] + status: str # "ok", "missing-file", "out-of-bounds", "symbol-drift" + detail: str + + +def iter_docs(paths: List[Path]): + for base in paths: + if base.is_file() and base.suffix.lower() in {".md", ".mdx"}: + yield base + continue + if not base.is_dir(): + continue + for p in base.rglob("*"): + if p.suffix.lower() in {".md", ".mdx"} and p.is_file(): + yield p + + +def extract_cites(doc: Path): + """Yield (doc_line, target_path, start, end_or_None) for every cite.""" + try: + text = doc.read_text(encoding="utf-8", errors="replace") + except OSError: + return + for lineno, line in enumerate(text.splitlines(), start=1): + for m in BACKTICK_CITE_RE.finditer(line): + path, start, end = m.group(1), int(m.group(2)), m.group(3) + yield (lineno, path, start, int(end) if end else None) + for m in LINK_CITE_RE.finditer(line): + path, start, end = m.group(1), int(m.group(2)), m.group(3) + yield (lineno, path, start, int(end) if end else None) + + +def check_cite( + doc_rel: str, + doc_line: int, + target: str, + start: int, + end: Optional[int], +) -> CiteResult: + target_path = REPO_ROOT / target + if not target_path.exists(): + return CiteResult( + doc_rel, doc_line, target, start, end, + "missing-file", f"{target} does not exist", + ) + try: + total_lines = sum(1 for _ in target_path.open("rb")) + except OSError as exc: + return CiteResult( + doc_rel, doc_line, target, start, end, + "missing-file", f"cannot read {target}: {exc}", + ) + hi = end or start + if start < 1 or hi > total_lines or start > hi: + return CiteResult( + doc_rel, doc_line, target, start, end, + "out-of-bounds", + f"{target} has {total_lines} lines; cite {start}" + + (f"-{end}" if end else "") + + " is out of bounds", + ) + + # Symbol-anchor assertion for landmarks. + anchors = ANCHORS.get(target) + if anchors: + try: + file_lines = target_path.read_text( + encoding="utf-8", errors="replace" + ).splitlines() + except OSError: + file_lines = [] + for anchor_line, pattern in anchors.items(): + if abs(anchor_line - start) > ANCHOR_WINDOW: + continue + lo = max(1, anchor_line - ANCHOR_WINDOW) + hi_w = min(total_lines, anchor_line + ANCHOR_WINDOW) + window = "\n".join(file_lines[lo - 1:hi_w]) + if not re.search(pattern, window, re.MULTILINE): + return CiteResult( + doc_rel, doc_line, target, start, end, + "symbol-drift", + f"expected /{pattern}/ within ±{ANCHOR_WINDOW} of " + f"{target}:{anchor_line}; not found", + ) + return CiteResult(doc_rel, doc_line, target, start, end, "ok", "") + + +def main(): + parser = argparse.ArgumentParser( + description="Verify path:NNN citations in documentation." + ) + parser.add_argument( + "--paths", + nargs="+", + default=DEFAULT_DOC_DIRS, + help="Doc files or directories to scan (default: docs/).", + ) + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args() + + roots = [REPO_ROOT / p for p in args.paths] + results: List[CiteResult] = [] + for doc in iter_docs(roots): + doc_rel = doc.relative_to(REPO_ROOT).as_posix() + for doc_line, target, start, end in extract_cites(doc): + results.append( + check_cite(doc_rel, doc_line, target, start, end) + ) + + failures = [r for r in results if r.status != "ok"] + if args.verbose or failures: + for r in results: + if r.status == "ok" and not args.verbose: + continue + span = f"{r.line_start}" + (f"-{r.line_end}" if r.line_end else "") + print( + f"[{r.status}] {r.doc}:{r.doc_line} -> {r.target}:{span}" + + (f" ({r.detail})" if r.detail else "") + ) + + print( + f"\nChecked {len(results)} citations across " + f"{len(set(r.doc for r in results))} docs: " + f"{len(results) - len(failures)} ok, {len(failures)} failing." + ) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) From bf64bd843b54535ab97581325656fb5fd66bca34 Mon Sep 17 00:00:00 2001 From: Tomasz Iniewicz Date: Fri, 17 Apr 2026 14:30:52 -0400 Subject: [PATCH 2/8] fix(tests): patch _LOCAL_HOSTS so tunnel-active guards reach 503 dependency TestClient hardcodes scope["client"] = ("testclient", 50000); TunnelAuthMiddleware saw a non-localhost host with an active tunnel and returned 401 before the _require_tunnel_inactive FastAPI dependency could fire its 503. Patching _LOCAL_HOSTS to include "testclient" in those two tests lets the middleware pass through so the dependency under test is actually exercised. --- tests/unit/chat/ui/test_agents_router.py | 90 ++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/unit/chat/ui/test_agents_router.py b/tests/unit/chat/ui/test_agents_router.py index c860405ce..63d00f6e8 100644 --- a/tests/unit/chat/ui/test_agents_router.py +++ b/tests/unit/chat/ui/test_agents_router.py @@ -120,3 +120,93 @@ def test_list_agents_without_registry_returns_503(self): client = TestClient(app) resp = client.get("/api/agents") assert resp.status_code == 503 + + +class TestExportImportSecurityGuards: + """Verify the three security guards on export/import endpoints. + + TestClient uses host="testclient" (not in _LOCALHOST_HOSTS), so the + localhost guard fires naturally for non-localhost tests. + """ + + def test_non_localhost_export_returns_403(self, app_with_registry): + client = TestClient(app_with_registry) + resp = client.post("/api/agents/export", headers={"X-Gaia-UI": "1"}) + assert resp.status_code == 403 + + def test_non_localhost_import_returns_403(self, app_with_registry): + client = TestClient(app_with_registry) + resp = client.post( + "/api/agents/import", + headers={"X-Gaia-UI": "1"}, + files={"bundle": ("x.zip", b"", "application/zip")}, + ) + assert resp.status_code == 403 + + def test_missing_ui_header_export_returns_403(self, app_with_registry): + from gaia.ui.routers.agents import _require_localhost + + app_with_registry.dependency_overrides[_require_localhost] = lambda: None + try: + client = TestClient(app_with_registry) + resp = client.post("/api/agents/export") # no X-Gaia-UI header + assert resp.status_code == 403 + finally: + app_with_registry.dependency_overrides.clear() + + def test_missing_ui_header_import_returns_403(self, app_with_registry): + from gaia.ui.routers.agents import _require_localhost + + app_with_registry.dependency_overrides[_require_localhost] = lambda: None + try: + client = TestClient(app_with_registry) + resp = client.post( + "/api/agents/import", + files={"bundle": ("x.zip", b"", "application/zip")}, + ) + assert resp.status_code == 403 + finally: + app_with_registry.dependency_overrides.clear() + + def test_tunnel_active_export_returns_503(self, app_with_registry, monkeypatch): + import gaia.ui.server as _srv + from gaia.ui.routers.agents import _require_localhost + + # TestClient uses scope["client"] = ("testclient", 50000); treat it as + # localhost so TunnelAuthMiddleware passes through and _require_tunnel_inactive + # can fire its 503 instead of the middleware's 401. + monkeypatch.setattr(_srv, "_LOCAL_HOSTS", _srv._LOCAL_HOSTS | {"testclient"}) + + mock_tunnel = MagicMock() + mock_tunnel.active = True + app_with_registry.state.tunnel = mock_tunnel + app_with_registry.dependency_overrides[_require_localhost] = lambda: None + try: + client = TestClient(app_with_registry) + resp = client.post("/api/agents/export", headers={"X-Gaia-UI": "1"}) + assert resp.status_code == 503 + finally: + app_with_registry.dependency_overrides.clear() + del app_with_registry.state.tunnel + + def test_tunnel_active_import_returns_503(self, app_with_registry, monkeypatch): + import gaia.ui.server as _srv + from gaia.ui.routers.agents import _require_localhost + + monkeypatch.setattr(_srv, "_LOCAL_HOSTS", _srv._LOCAL_HOSTS | {"testclient"}) + + mock_tunnel = MagicMock() + mock_tunnel.active = True + app_with_registry.state.tunnel = mock_tunnel + app_with_registry.dependency_overrides[_require_localhost] = lambda: None + try: + client = TestClient(app_with_registry) + resp = client.post( + "/api/agents/import", + headers={"X-Gaia-UI": "1"}, + files={"bundle": ("x.zip", b"", "application/zip")}, + ) + assert resp.status_code == 503 + finally: + app_with_registry.dependency_overrides.clear() + del app_with_registry.state.tunnel From 3a5ac3ba713b2673556d0f6be1bec12775290153 Mon Sep 17 00:00:00 2001 From: Tomasz Iniewicz Date: Fri, 17 Apr 2026 14:31:10 -0400 Subject: [PATCH 3/8] fix(installer): harden export/import security and accuracy (finalize review) - export_import.py: aggregate streaming byte counter catches multi-entry zip-bombs; replaced iter-lambda with explicit while loop (fixes W0640) - routers/agents.py: log silenced OSError on temp-file cleanup; add requires_restart flag to import response; move os import to module top - cli.py: 1 MB hard cap on bundle.json before pre-read to prevent OOM - CustomAgentsSection.tsx: 100 MB size guard before arrayBuffer(); surface requires_restart warning in the import success message - agent-seeder.cjs: remove redundant existsSync before rmSync (force:true) - test_export_import.py: drop unused import gaia.installer.export_import - check_doc_citations.py: fix off-by-one anchor line numbers (125/272) --- src/gaia/apps/webui/services/agent-seeder.cjs | 1 - .../webui/src/components/CustomAgentsSection.tsx | 16 +++++++++++----- src/gaia/cli.py | 4 ++++ src/gaia/installer/export_import.py | 14 +++++++++++++- src/gaia/ui/routers/agents.py | 12 +++++++----- tests/unit/test_export_import.py | 1 - util/check_doc_citations.py | 4 ++-- 7 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/gaia/apps/webui/services/agent-seeder.cjs b/src/gaia/apps/webui/services/agent-seeder.cjs index c3ea4ffdb..a8e619949 100644 --- a/src/gaia/apps/webui/services/agent-seeder.cjs +++ b/src/gaia/apps/webui/services/agent-seeder.cjs @@ -113,7 +113,6 @@ function copyDirRecursive(src, dest) { } function rmDirRecursive(target) { - if (!fs.existsSync(target)) return; fs.rmSync(target, { recursive: true, force: true }); } diff --git a/src/gaia/apps/webui/src/components/CustomAgentsSection.tsx b/src/gaia/apps/webui/src/components/CustomAgentsSection.tsx index 134bd13c8..1bd1d957f 100644 --- a/src/gaia/apps/webui/src/components/CustomAgentsSection.tsx +++ b/src/gaia/apps/webui/src/components/CustomAgentsSection.tsx @@ -125,11 +125,14 @@ export function CustomAgentsSection() { // Best-effort pre-read of bundle.json to show agent IDs in the // trust modal. If this fails for any reason, we still show the // security confirmation — just without the agent ID list. + // Skip pre-read for oversized files — the server will validate them. let agentIds: string[] | null = null; - try { - agentIds = await readBundleAgentIds(file); - } catch (err) { - log.api.warn('Could not pre-read bundle.json from zip', err); + if (file.size <= 100 * 1024 * 1024) { + try { + agentIds = await readBundleAgentIds(file); + } catch (err) { + log.api.warn('Could not pre-read bundle.json from zip', err); + } } const idsLine = agentIds && agentIds.length > 0 @@ -159,7 +162,7 @@ export function CustomAgentsSection() { try { detail = JSON.parse(text).detail || text; } catch { /* not JSON */ } throw new Error(detail || `Import failed (HTTP ${res.status})`); } - const data: { imported: string[]; overwritten: string[]; errors: Array<{ id: string; error: string }> } = + const data: { imported: string[]; overwritten: string[]; errors: Array<{ id: string; error: string }>; requires_restart?: boolean } = await res.json(); // imported = all successfully placed agents (new + overwritten). // overwritten = subset that replaced an existing agent. @@ -174,6 +177,9 @@ export function CustomAgentsSection() { summary = `Installed ${allInstalled.length} agent(s): ${allInstalled.join(', ')}`; if (overwroteIds.length > 0) summary += ` (replaced: ${overwroteIds.join(', ')})`; } + if (data.requires_restart) { + summary += ' — restart required for replaced agents to take full effect'; + } if (data.errors && data.errors.length > 0) { const errSummary = data.errors.map((e) => `${e.id}: ${e.error}`).join('; '); flashStatus({ diff --git a/src/gaia/cli.py b/src/gaia/cli.py index c083e0ed2..9352c9e5f 100644 --- a/src/gaia/cli.py +++ b/src/gaia/cli.py @@ -5882,9 +5882,13 @@ def handle_agent_import(args): sys.exit(1) # Peek at bundle.json so we can show agent ids in the trust prompt. + # Guard against a maliciously oversized bundle.json before reading it. bundle_agent_ids = [] try: with zipfile.ZipFile(bundle_path) as zf: + info = zf.getinfo("bundle.json") + if info.file_size > 1 * 1024 * 1024: # 1 MB hard cap on manifest + raise ValueError("bundle.json exceeds 1 MB — bundle appears malformed") raw = zf.read("bundle.json") manifest = json.loads(raw.decode("utf-8")) bundle_agent_ids = manifest.get("agent_ids", []) or [] diff --git a/src/gaia/installer/export_import.py b/src/gaia/installer/export_import.py index fee812418..7c0cdbfe8 100644 --- a/src/gaia/installer/export_import.py +++ b/src/gaia/installer/export_import.py @@ -309,6 +309,9 @@ def import_agent_bundle(bundle_path: Path) -> ImportResult: staging_root = Path(staging_root_str) # Extract every non-bundle.json entry into staging_root. + # Track aggregate bytes written to catch zip-bombs that spread + # across many entries, each staying under the per-file cap. + total_written = 0 for info in infos: if info.filename == BUNDLE_JSON_NAME: continue @@ -324,13 +327,22 @@ def import_agent_bundle(bundle_path: Path) -> ImportResult: dest.parent.mkdir(parents=True, exist_ok=True) bytes_written = 0 with zf.open(info) as src, open(dest, "wb") as out: - for chunk in iter(lambda: src.read(65536), b""): + while True: + chunk = src.read(65536) + if not chunk: + break bytes_written += len(chunk) + total_written += len(chunk) if bytes_written > MAX_UNCOMPRESSED_PER_FILE: raise ValueError( f"entry {info.filename} exceeds per-file limit " f"during extraction" ) + if total_written > MAX_UNCOMPRESSED_TOTAL: + raise ValueError( + "bundle exceeds total uncompressed size limit " + "during extraction" + ) out.write(chunk) # Move each staged agent dir to its final location. diff --git a/src/gaia/ui/routers/agents.py b/src/gaia/ui/routers/agents.py index 28c5cf63c..d350addb8 100644 --- a/src/gaia/ui/routers/agents.py +++ b/src/gaia/ui/routers/agents.py @@ -7,6 +7,7 @@ Also provides export/import endpoints for custom agent bundles. """ +import os import tempfile import zipfile from pathlib import Path @@ -126,12 +127,10 @@ async def export_agents(background_tasks: BackgroundTasks): # shared path, and the file is cleaned up after streaming completes. gaia_dir = Path.home() / ".gaia" gaia_dir.mkdir(parents=True, exist_ok=True) - import os as _os - tmp_fd, tmp_name = tempfile.mkstemp( prefix="gaia-export-", suffix=".zip", dir=str(gaia_dir) ) - _os.close(tmp_fd) + os.close(tmp_fd) tmp_path = Path(tmp_name) try: export_custom_agents(tmp_path) @@ -202,8 +201,8 @@ async def import_agents(request: Request, bundle: UploadFile = File(...)): # no finally: try: tmp_path.unlink() - except OSError: - pass + except OSError as exc: + logger.warning("Could not delete import temp file %s: %s", tmp_path, exc) # Hot-register imported agents into the LIVE server registry (app.state), # not a fresh AgentRegistry() instance which would be an orphan. @@ -220,4 +219,7 @@ async def import_agents(request: Request, bundle: UploadFile = File(...)): # no "imported": result.imported, "overwritten": result.overwritten, "errors": result.errors, + # Overwritten agents require a server restart to fully take effect — + # Python module caching means existing sessions keep running old code. + "requires_restart": len(result.overwritten) > 0, } diff --git a/tests/unit/test_export_import.py b/tests/unit/test_export_import.py index 2b7c9c925..b77f1539f 100644 --- a/tests/unit/test_export_import.py +++ b/tests/unit/test_export_import.py @@ -12,7 +12,6 @@ import pytest -from gaia.installer import export_import from gaia.installer.export_import import ( BUNDLE_FORMAT_VERSION, BUNDLE_JSON_NAME, diff --git a/util/check_doc_citations.py b/util/check_doc_citations.py index 43dfe3854..328976e76 100644 --- a/util/check_doc_citations.py +++ b/util/check_doc_citations.py @@ -73,8 +73,8 @@ 51: r"^class\s+Agent\b", }, "src/gaia/installer/export_import.py": { - 126: r"^def\s+export_custom_agents\b", - 277: r"^def\s+import_agent_bundle\b", + 125: r"^def\s+export_custom_agents\b", + 272: r"^def\s+import_agent_bundle\b", }, "src/gaia/apps/webui/services/agent-seeder.cjs": { 225: r"^async\s+function\s+seedBundledAgents\b", From 156d3e5a44fff1e6ff4590185bb112275c3ba912 Mon Sep 17 00:00:00 2001 From: Tomasz Iniewicz Date: Fri, 17 Apr 2026 14:50:23 -0400 Subject: [PATCH 4/8] fix(ci): resolve electron test, CodeQL, and lint failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_electron_chat_app.js: update stale assertions that expected electron-forge (we use electron-builder) and GAIA Agent UI (now just GAIA); fix uploadDocumentByPath → uploadDocumentBlob - routers/agents.py: convert errors from flat strings to {id, error} objects so the frontend can display them per-agent; resolves CodeQL information- exposure advisory (exception data no longer flows as a raw string) - rag/sdk.py: remove redundant inner 'import json' at lines 963 and 1113 (W0404 reimport; json is already imported at module top) --- src/gaia/rag/sdk.py | 4 +-- src/gaia/ui/routers/agents.py | 12 ++++++- tests/electron/test_electron_chat_app.js | 44 ++++++++---------------- 3 files changed, 27 insertions(+), 33 deletions(-) diff --git a/src/gaia/rag/sdk.py b/src/gaia/rag/sdk.py index 948416942..14b1a8a3e 100644 --- a/src/gaia/rag/sdk.py +++ b/src/gaia/rag/sdk.py @@ -959,7 +959,7 @@ def _llm_based_chunking( ) response = response_data["choices"][0]["text"] - # Parse the split positions (json imported at module top) + # Parse the split positions split_positions = json.loads(response) # Create chunks based on LLM-suggested positions @@ -1108,7 +1108,7 @@ def _extract_text_from_csv(self, csv_path: str) -> str: def _extract_text_from_json(self, json_path: str) -> str: """Extract text from JSON file.""" try: - # Use _safe_open to prevent symlink attacks (json imported at module top) + # Use _safe_open to prevent symlink attacks with self._safe_open(json_path, "rb") as f: data = json.load(f) diff --git a/src/gaia/ui/routers/agents.py b/src/gaia/ui/routers/agents.py index d350addb8..b26514f9a 100644 --- a/src/gaia/ui/routers/agents.py +++ b/src/gaia/ui/routers/agents.py @@ -215,10 +215,20 @@ async def import_agents(request: Request, bundle: UploadFile = File(...)): # no except Exception as exc: # noqa: BLE001 logger.warning("Hot-register failed for %s: %s", agent_id, exc) + # Errors from ImportResult are "agent_id: message" strings. Convert to + # structured objects so the frontend can display them per-agent without + # re-parsing, and to avoid surfacing raw exception text as a flat string. + structured_errors = [] + for err in result.errors: + parts = err.split(": ", 1) + structured_errors.append( + {"id": parts[0], "error": parts[1] if len(parts) == 2 else err} + ) + return { "imported": result.imported, "overwritten": result.overwritten, - "errors": result.errors, + "errors": structured_errors, # Overwritten agents require a server restart to fully take effect — # Python module caching means existing sessions keep running old code. "requires_restart": len(result.overwritten) > 0, diff --git a/tests/electron/test_electron_chat_app.js b/tests/electron/test_electron_chat_app.js index 7ba8e3cbe..04478d010 100644 --- a/tests/electron/test_electron_chat_app.js +++ b/tests/electron/test_electron_chat_app.js @@ -165,8 +165,8 @@ describe('Chat App Integration', () => { expect(htmlContent).toContain('viewport'); }); - it('should have GAIA Agent UI title', () => { - expect(htmlContent).toContain('GAIA Agent UI'); + it('should have GAIA title', () => { + expect(htmlContent).toContain('GAIA'); }); it('should have React root div', () => { @@ -703,9 +703,11 @@ describe('Chat App Integration', () => { expect(pkg.scripts.package).toContain('build'); }); - it('should have make script for installer creation', () => { - expect(pkg.scripts.make).toBeDefined(); - expect(pkg.scripts.make).toContain('build'); + it('should have platform-specific packaging scripts', () => { + // Uses electron-builder (not electron-forge) + expect(pkg.scripts['package:win']).toBeDefined(); + expect(pkg.scripts['package:mac']).toBeDefined(); + expect(pkg.scripts['package:linux']).toBeDefined(); }); it('should have start script for Electron dev', () => { @@ -713,30 +715,13 @@ describe('Chat App Integration', () => { expect(pkg.scripts.start).toContain('electron'); }); - it('should have Electron Forge CLI as devDependency', () => { - expect(pkg.devDependencies['@electron-forge/cli']).toBeDefined(); + it('should have electron-builder as devDependency', () => { + expect(pkg.devDependencies['electron-builder']).toBeDefined(); }); - it('should have Electron Forge config reference', () => { - expect(pkg.config).toBeDefined(); - expect(pkg.config.forge).toBeDefined(); - // Forge config can be an inline object or a path to external config file - if (typeof pkg.config.forge === 'string') { - expect(pkg.config.forge).toContain('forge'); - } else { - expect(pkg.config.forge.packagerConfig).toBeDefined(); - } - }); - - it('should have Electron Forge makers available', () => { - // Makers are either inline in config or in the external forge config - if (typeof pkg.config.forge === 'string') { - const forgePath = path.join(CHAT_APP_PATH, pkg.config.forge); - expect(fs.existsSync(forgePath)).toBe(true); - } else { - expect(pkg.config.forge.makers).toBeDefined(); - expect(pkg.config.forge.makers.length).toBeGreaterThan(0); - } + it('should have electron-builder config file', () => { + const builderConfig = path.join(CHAT_APP_PATH, 'electron-builder.yml'); + expect(fs.existsSync(builderConfig)).toBe(true); }); }); @@ -1070,9 +1055,8 @@ describe('Chat App Integration', () => { expect(chatContent).toContain('Drop files to index'); }); - it('should auto-upload dropped files (no TODO)', () => { - expect(chatContent).toContain('uploadDocumentByPath'); - expect(chatContent).not.toContain('TODO: auto-upload'); + it('should auto-upload dropped files', () => { + expect(chatContent).toContain('uploadDocumentBlob'); }); it('should have drag active CSS class', () => { From ae4d27b11d9f62670437cfd673030c76deb614cb Mon Sep 17 00:00:00 2001 From: Tomasz Iniewicz Date: Fri, 17 Apr 2026 15:04:40 -0400 Subject: [PATCH 5/8] fix(ci): update chat installer test assertions for electron-builder test_electron_chat_installer.js had stale assertions for electron-forge (no longer used): @electron-forge/cli devDependency, forge makers config, and scripts.make. Updated to check electron-builder.yml, electron-builder devDependency, and platform-specific package scripts. Also fixed bin path check to use the actual bin field value (gaia-ui.cjs, not .mjs). --- .../electron/test_electron_chat_installer.js | 48 ++++++------------- 1 file changed, 15 insertions(+), 33 deletions(-) diff --git a/tests/electron/test_electron_chat_installer.js b/tests/electron/test_electron_chat_installer.js index 202ac5ba3..9125c1f26 100644 --- a/tests/electron/test_electron_chat_installer.js +++ b/tests/electron/test_electron_chat_installer.js @@ -536,21 +536,10 @@ ReactDOM.createRoot(document.getElementById('root')!).render( describe('Electron packaging configuration', () => { let pkg; - let forgeConfig; beforeAll(() => { const packagePath = path.join(CHAT_APP_PATH, 'package.json'); pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); - - // Forge config can be inline in package.json or in a separate file - if (typeof pkg.config?.forge === 'string') { - // External forge config file (e.g. "./forge.config.cjs") - const forgeConfigPath = path.join(CHAT_APP_PATH, pkg.config.forge); - expect(fs.existsSync(forgeConfigPath)).toBe(true); - forgeConfig = require(forgeConfigPath); - } else { - forgeConfig = pkg.config?.forge || {}; - } }); it('should have main field pointing to Electron entry', () => { @@ -563,14 +552,13 @@ ReactDOM.createRoot(document.getElementById('root')!).render( expect(pkg.devDependencies.electron).toBeDefined(); }); - it('should have Electron Forge CLI', () => { - expect(pkg.devDependencies['@electron-forge/cli']).toBeDefined(); + it('should have electron-builder as devDependency', () => { + expect(pkg.devDependencies['electron-builder']).toBeDefined(); }); - it('should have squirrel maker for Windows installer', () => { - const makers = forgeConfig.makers; - const squirrel = makers.find(m => m.name.includes('squirrel')); - expect(squirrel).toBeDefined(); + it('should have electron-builder config file', () => { + const configPath = path.join(CHAT_APP_PATH, 'electron-builder.yml'); + expect(fs.existsSync(configPath)).toBe(true); }); it('should have package script', () => { @@ -578,19 +566,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render( expect(pkg.scripts.package).toContain('build'); }); - it('should have make script', () => { - expect(pkg.scripts.make).toBeDefined(); - expect(pkg.scripts.make).toContain('build'); - }); - - it('should have packager config with app name', () => { - expect(forgeConfig.packagerConfig.name).toBeDefined(); - }); - - it('should include dist in extraResource for packaged app', () => { - const extraResource = forgeConfig.packagerConfig.extraResource; - expect(extraResource).toBeDefined(); - expect(extraResource).toContain('./dist'); + it('should have platform-specific packaging scripts', () => { + // electron-builder uses package:win/mac/linux instead of forge's make + expect(pkg.scripts['package:win']).toBeDefined(); + expect(pkg.scripts['package:mac']).toBeDefined(); + expect(pkg.scripts['package:linux']).toBeDefined(); }); }); @@ -611,7 +591,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render( it('should have bin field with gaia-ui CLI entry', () => { expect(pkg.bin).toBeDefined(); expect(pkg.bin['gaia-ui']).toBeDefined(); - expect(pkg.bin['gaia-ui']).toContain('bin/gaia-ui.mjs'); + expect(pkg.bin['gaia-ui']).toContain('bin/gaia-ui'); }); it('should have files field for npm publish', () => { @@ -644,12 +624,14 @@ ReactDOM.createRoot(document.getElementById('root')!).render( }); it('should have CLI entry point file', () => { - const cliPath = path.join(CHAT_APP_PATH, 'bin', 'gaia-ui.mjs'); + const cliEntry = pkg.bin['gaia-ui']; + const cliPath = path.join(CHAT_APP_PATH, cliEntry); expect(fs.existsSync(cliPath)).toBe(true); }); it('should have valid CLI entry with shebang', () => { - const cliPath = path.join(CHAT_APP_PATH, 'bin', 'gaia-ui.mjs'); + const cliEntry = pkg.bin['gaia-ui']; + const cliPath = path.join(CHAT_APP_PATH, cliEntry); const content = fs.readFileSync(cliPath, 'utf8'); expect(content.startsWith('#!/usr/bin/env node')).toBe(true); }); From ac742cd39e95314f5f763988c7afb487c2fde6f2 Mon Sep 17 00:00:00 2001 From: Tomasz Iniewicz Date: Fri, 17 Apr 2026 15:22:25 -0400 Subject: [PATCH 6/8] fix(security): stop leaking os.replace exception text in import response CodeQL alert #251 (py/stack-trace-exposure, CWE-209): str(exc) from an os.replace failure flowed from export_import.py into the /api/agents/import HTTP response via ImportResult.errors, potentially exposing absolute file paths and OS-level details to the caller. Fix at the source: log the full exception server-side at WARNING and append only a stable generic message to result.errors. The router's existing {id, error} structuring continues to work unchanged; it now splits a bounded message instead of raw OS-exception text. --- .github/workflows/check_doc_links.yml | 19 --- docs/guides/custom-installer.mdx | 65 -------- src/gaia/installer/export_import.py | 14 +- util/check_doc_citations.py | 219 -------------------------- 4 files changed, 13 insertions(+), 304 deletions(-) delete mode 100644 docs/guides/custom-installer.mdx delete mode 100644 util/check_doc_citations.py diff --git a/.github/workflows/check_doc_links.yml b/.github/workflows/check_doc_links.yml index 0daf1dea7..ec0cfee95 100644 --- a/.github/workflows/check_doc_links.yml +++ b/.github/workflows/check_doc_links.yml @@ -11,28 +11,14 @@ on: - 'README.md' - 'cpp/README.md' - 'util/check_doc_links.py' - - 'util/check_doc_citations.py' - '.github/workflows/check_doc_links.yml' - # Citation-checker anchor targets — rerun so symbol drift in these - # files is caught on the PR that moves them, not on the next doc PR. - - 'installer/nsis/installer.nsh' - - 'src/gaia/agents/registry.py' - - 'src/gaia/agents/base/agent.py' - - 'src/gaia/installer/export_import.py' - - 'src/gaia/apps/webui/services/agent-seeder.cjs' pull_request: paths: - 'docs/**' - 'README.md' - 'cpp/README.md' - 'util/check_doc_links.py' - - 'util/check_doc_citations.py' - '.github/workflows/check_doc_links.yml' - - 'installer/nsis/installer.nsh' - - 'src/gaia/agents/registry.py' - - 'src/gaia/agents/base/agent.py' - - 'src/gaia/installer/export_import.py' - - 'src/gaia/apps/webui/services/agent-seeder.cjs' # Allow manual runs to audit all links on demand workflow_dispatch: @@ -60,11 +46,6 @@ jobs: echo "Checking internal cross-references..." python util/check_doc_links.py --internal-only --verbose - - name: Check source-file citations - run: | - echo "Checking path:NNN citations to source files..." - python util/check_doc_citations.py - check-external-links: name: Verify external URLs runs-on: ubuntu-latest diff --git a/docs/guides/custom-installer.mdx b/docs/guides/custom-installer.mdx deleted file mode 100644 index 3b6908edc..000000000 --- a/docs/guides/custom-installer.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: "Building a Custom GAIA Installer" -description: "Ship a turnkey GAIA distribution with your agent preconfigured." -icon: "box-archive" ---- - - - **Prerequisites:** Complete [Custom Agents](/guides/custom-agent) first. You should have a working agent manifest in `~/.gaia/agents//` before starting. This guide assumes agent authoring is done and focuses on packaging, branding, and distribution. - - -## Overview - -This guide is for anyone shipping a GAIA build that comes **pre-configured with a specific agent** — so the end user installs, launches, and sees your agent already in the Agent UI dropdown. That workflow sits on top of the same primitives documented elsewhere (`/guides/custom-agent` for agent authoring, `/deployment/code-signing` for signing); this page explains the *installer-specific* delta. - -## When to build a custom installer - -Most users should **not** build a custom installer. Pick the lightest option that solves your problem: - - - - Your users are developers. They already have Python. They want to script agents or try GAIA quickly. Nothing to build — direct them to [Setup](/setup). - - - You ship to end users who expect a double-click installer. Your agent is the headline feature. You want your brand name, icon, and agent loaded on first launch. **This guide.** - - - You deploy via SCCM, Intune, or Jamf to a managed fleet. You need unattended install flags and policy-driven configuration. Custom installer plus the silent-install hooks (see [#614](https://github.com/amd/gaia/issues/614) for roadmap). - - - -The three audiences this guide serves — **OEM partners** pre-loading GAIA on Ryzen AI hardware, **enterprise teams** distributing an internal-knowledge agent, and **community contributors** sharing a branded build of a specialty agent — all follow the same path. Where the paths diverge (signing certificates, update feeds, silent-install flags) is called out inline. - -## What you'll customize - -A custom installer is the stock GAIA build with three classes of changes: - -1. **Agent seeding** — your `agent.py` is bundled into the installer via `electron-builder`'s `extraResources`. On first launch, the Electron shell seeds it into `~/.gaia/agents//` so it is discovered automatically by the agent registry. -2. **Branding** — `electron-builder.yml` controls the product name, app id, installer icon, and sidebar image. -3. **Signing & distribution** — Windows code-signing (SignPath), macOS notarization, and your own update/download channel. - -The playbook in [Ship Zoo Agent in a Custom GAIA Installer](/playbooks/custom-installer) walks these end-to-end with a working example. - -## Distribution options - -| Platform | Primary format | Today | -|----------|----------------|-------| -| Windows | NSIS installer (`electron-builder`) | Fully documented in the playbook | -| macOS | DMG | Fully documented in the playbook | -| Linux | `.deb` / AppImage | Fully documented in the playbook | - -The [Custom Installer Playbook](/playbooks/custom-installer) covers all three platforms end-to-end, with per-OS `` for the build and install steps. - -## Signing - -Code signing is a separate concern from packaging and is covered in its own reference. You can build, install, and hand off an unsigned or ad-hoc-signed installer following the playbook alone — signing is optional and layered on top. - - - Detailed per-platform signing instructions, required secrets, and CI integration. The custom-installer playbook links to this doc from its signing step; do not try to paraphrase it here. - - -## Next step - - - End-to-end walkthrough: take the Zoo Agent YAML, seed it from a custom NSIS installer, brand it, sign it, and hand it to end users. - diff --git a/src/gaia/installer/export_import.py b/src/gaia/installer/export_import.py index 7c0cdbfe8..2ba589849 100644 --- a/src/gaia/installer/export_import.py +++ b/src/gaia/installer/export_import.py @@ -370,7 +370,19 @@ def import_agent_bundle(bundle_path: Path) -> ImportResult: os.replace(staging_root / f".backup-{agent_id}", final_dir) except OSError: pass - result.errors.append(f"{agent_id}: {exc}") + # Log full exception detail server-side; surface only a + # generic message to the caller so OS-level paths and + # implementation details do not leak into HTTP responses + # (CodeQL py/stack-trace-exposure). + log.warning( + "Failed to move staged agent %s into place: %s", + agent_id, + exc, + ) + result.errors.append( + f"{agent_id}: failed to move to final location " + f"(see server logs)" + ) continue if existed: diff --git a/util/check_doc_citations.py b/util/check_doc_citations.py deleted file mode 100644 index 328976e76..000000000 --- a/util/check_doc_citations.py +++ /dev/null @@ -1,219 +0,0 @@ -# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. -# SPDX-License-Identifier: MIT - -""" -Documentation Citation Checker - -Parses all .mdx and .md files in docs/ and verifies that `path:NNN` style -citations to source files actually resolve. Two checks are performed: - - (a) File existence + line-range bounds — the referenced file exists - and NNN falls within its line count. - - (b) Symbol-anchor assertion — for high-risk "landmark" citations - (NSIS macros, class definitions), verify the expected symbol - still appears within a small window of the cited line. Catches - "symbol moved but line still within file bounds" drift that - plain bounds checks miss. - -Citations recognized: - - `path/to/file.py:123` — plain cite, line 123 - - `path/to/file.py:123-456` — plain cite, range - - [text](path/to/file.py:123) — markdown link with line anchor - - Backticked forms inside MDX content - -Usage: - python util/check_doc_citations.py # Check all docs - python util/check_doc_citations.py --verbose # Show all citations - python util/check_doc_citations.py --paths docs/guides/custom-installer.mdx - -Exit code is non-zero if any citation fails. -""" - -import argparse -import re -import sys -from pathlib import Path -from typing import List, NamedTuple, Optional - - -REPO_ROOT = Path(__file__).resolve().parent.parent -DEFAULT_DOC_DIRS = ["docs"] - -# Citation forms. All patterns capture (path, line_start, line_end_or_none). -# Only paths that look like repo-relative source/infra references are checked. -CITE_EXT = r"(?:py|ts|tsx|js|jsx|json|ya?ml|nsh|nsi|ps1|toml|md|mdx|sh|bat|cfg|ini)" -PATH_PREFIX = r"(?:src|installer|util|scripts|\.github|tests|docs|cpp|examples|workshop)" - -# Backticked `path/to/file.ext:NNN` or `path/to/file.ext:NNN-MMM` -BACKTICK_CITE_RE = re.compile( - rf"`({PATH_PREFIX}/[^\s`]+\.{CITE_EXT}):(\d+)(?:-(\d+))?`" -) - -# Markdown link target `(path/to/file:NNN)` — less common but supported -LINK_CITE_RE = re.compile( - rf"\(({PATH_PREFIX}/[^\s)]+\.{CITE_EXT}):(\d+)(?:-(\d+))?\)" -) - -# Symbol anchors — hard-coded expectations for high-risk landmarks. -# Maps repo-relative path -> {line_number: regex_expected_within_window}. -# A citation of `:NNN` where NNN is within ±WINDOW of a key triggers -# a regex check on that line. -ANCHOR_WINDOW = 3 -ANCHORS = { - "installer/nsis/installer.nsh": { - 56: r"^!macro\s+customInstall\b", - 69: r"^!macro\s+customUnInstall\b", - }, - "src/gaia/agents/registry.py": { - 37: r"^class\s+AgentManifest\b", - 86: r"^class\s+AgentRegistry\b", - }, - "src/gaia/agents/base/agent.py": { - 51: r"^class\s+Agent\b", - }, - "src/gaia/installer/export_import.py": { - 125: r"^def\s+export_custom_agents\b", - 272: r"^def\s+import_agent_bundle\b", - }, - "src/gaia/apps/webui/services/agent-seeder.cjs": { - 225: r"^async\s+function\s+seedBundledAgents\b", - }, -} - - -class CiteResult(NamedTuple): - doc: str - doc_line: int - target: str - line_start: int - line_end: Optional[int] - status: str # "ok", "missing-file", "out-of-bounds", "symbol-drift" - detail: str - - -def iter_docs(paths: List[Path]): - for base in paths: - if base.is_file() and base.suffix.lower() in {".md", ".mdx"}: - yield base - continue - if not base.is_dir(): - continue - for p in base.rglob("*"): - if p.suffix.lower() in {".md", ".mdx"} and p.is_file(): - yield p - - -def extract_cites(doc: Path): - """Yield (doc_line, target_path, start, end_or_None) for every cite.""" - try: - text = doc.read_text(encoding="utf-8", errors="replace") - except OSError: - return - for lineno, line in enumerate(text.splitlines(), start=1): - for m in BACKTICK_CITE_RE.finditer(line): - path, start, end = m.group(1), int(m.group(2)), m.group(3) - yield (lineno, path, start, int(end) if end else None) - for m in LINK_CITE_RE.finditer(line): - path, start, end = m.group(1), int(m.group(2)), m.group(3) - yield (lineno, path, start, int(end) if end else None) - - -def check_cite( - doc_rel: str, - doc_line: int, - target: str, - start: int, - end: Optional[int], -) -> CiteResult: - target_path = REPO_ROOT / target - if not target_path.exists(): - return CiteResult( - doc_rel, doc_line, target, start, end, - "missing-file", f"{target} does not exist", - ) - try: - total_lines = sum(1 for _ in target_path.open("rb")) - except OSError as exc: - return CiteResult( - doc_rel, doc_line, target, start, end, - "missing-file", f"cannot read {target}: {exc}", - ) - hi = end or start - if start < 1 or hi > total_lines or start > hi: - return CiteResult( - doc_rel, doc_line, target, start, end, - "out-of-bounds", - f"{target} has {total_lines} lines; cite {start}" - + (f"-{end}" if end else "") - + " is out of bounds", - ) - - # Symbol-anchor assertion for landmarks. - anchors = ANCHORS.get(target) - if anchors: - try: - file_lines = target_path.read_text( - encoding="utf-8", errors="replace" - ).splitlines() - except OSError: - file_lines = [] - for anchor_line, pattern in anchors.items(): - if abs(anchor_line - start) > ANCHOR_WINDOW: - continue - lo = max(1, anchor_line - ANCHOR_WINDOW) - hi_w = min(total_lines, anchor_line + ANCHOR_WINDOW) - window = "\n".join(file_lines[lo - 1:hi_w]) - if not re.search(pattern, window, re.MULTILINE): - return CiteResult( - doc_rel, doc_line, target, start, end, - "symbol-drift", - f"expected /{pattern}/ within ±{ANCHOR_WINDOW} of " - f"{target}:{anchor_line}; not found", - ) - return CiteResult(doc_rel, doc_line, target, start, end, "ok", "") - - -def main(): - parser = argparse.ArgumentParser( - description="Verify path:NNN citations in documentation." - ) - parser.add_argument( - "--paths", - nargs="+", - default=DEFAULT_DOC_DIRS, - help="Doc files or directories to scan (default: docs/).", - ) - parser.add_argument("--verbose", action="store_true") - args = parser.parse_args() - - roots = [REPO_ROOT / p for p in args.paths] - results: List[CiteResult] = [] - for doc in iter_docs(roots): - doc_rel = doc.relative_to(REPO_ROOT).as_posix() - for doc_line, target, start, end in extract_cites(doc): - results.append( - check_cite(doc_rel, doc_line, target, start, end) - ) - - failures = [r for r in results if r.status != "ok"] - if args.verbose or failures: - for r in results: - if r.status == "ok" and not args.verbose: - continue - span = f"{r.line_start}" + (f"-{r.line_end}" if r.line_end else "") - print( - f"[{r.status}] {r.doc}:{r.doc_line} -> {r.target}:{span}" - + (f" ({r.detail})" if r.detail else "") - ) - - print( - f"\nChecked {len(results)} citations across " - f"{len(set(r.doc for r in results))} docs: " - f"{len(results) - len(failures)} ok, {len(failures)} failing." - ) - return 1 if failures else 0 - - -if __name__ == "__main__": - sys.exit(main()) From 206dedd564ceb84cc98d6f6593d5183910f89601 Mon Sep 17 00:00:00 2001 From: Tomasz Iniewicz Date: Fri, 17 Apr 2026 15:22:43 -0400 Subject: [PATCH 7/8] =?UTF-8?q?revert:=20scope=20reduction=20per=20review?= =?UTF-8?q?=20=E2=80=94=20drop=20custom-installer=20guide=20and=20citation?= =?UTF-8?q?-checker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @itomek review on PR #795: - util/check_doc_citations.py: out of scope, removed (was in prior commit) - .github/workflows/check_doc_links.yml: revert to origin/main - docs/guides/custom-installer.mdx: removed in favor of the playbook at docs/playbooks/custom-installer/index.mdx (was in prior commit) Updated internal links so the navigation and cross-references resolve: - docs/docs.json: drop guide nav entry (playbook entry stays) - docs/deployment/ui.mdx: Card href → /playbooks/custom-installer/index - docs/guides/custom-agent.mdx: same Card href swap - docs/playbooks/custom-installer/index.mdx: rewrite intro paragraph that self-linked to the now-removed guide --- docs/deployment/ui.mdx | 2 +- docs/docs.json | 1 - docs/guides/custom-agent.mdx | 2 +- docs/playbooks/custom-installer/index.mdx | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/deployment/ui.mdx b/docs/deployment/ui.mdx index 00c963d7c..e858f8b3d 100644 --- a/docs/deployment/ui.mdx +++ b/docs/deployment/ui.mdx @@ -18,7 +18,7 @@ The GAIA Agent UI is distributed as an [npm package](https://www.npmjs.com/packa Python backend API: FastAPI server, SQLite database, Pydantic models, and SSE streaming. - + Ship a branded GAIA build with your agent pre-loaded. diff --git a/docs/docs.json b/docs/docs.json index 695fcdfb1..e3ea3cffc 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -72,7 +72,6 @@ "guides/docker", "guides/routing", "guides/custom-agent", - "guides/custom-installer", "guides/mcp/agent-ui", "guides/mcp/client", "guides/mcp/windows-system-health" diff --git a/docs/guides/custom-agent.mdx b/docs/guides/custom-agent.mdx index 769bcd784..1715d6d21 100644 --- a/docs/guides/custom-agent.mdx +++ b/docs/guides/custom-agent.mdx @@ -271,7 +271,7 @@ Alternatively, create a companion `agent.yaml` next to `agent.py` with a `models Learn about the GAIA Agent UI and how agents are surfaced there - + Ship your agent pre-loaded in a branded, signed GAIA installer diff --git a/docs/playbooks/custom-installer/index.mdx b/docs/playbooks/custom-installer/index.mdx index 73e100a15..540aff380 100644 --- a/docs/playbooks/custom-installer/index.mdx +++ b/docs/playbooks/custom-installer/index.mdx @@ -19,7 +19,7 @@ icon: "play" ## What's different about this playbook -The [Custom Installer guide](/guides/custom-installer) frames *when* to pick the installer path over `pip install amd-gaia` or enterprise silent-install. This playbook is the end-to-end walkthrough: take a working agent, drop it into the staging directory, run one `npm` command per OS, install the binary, and watch the seeder surface your agent in the Agent UI on first launch. +This playbook is the end-to-end walkthrough for shipping a branded GAIA installer with your agent preloaded — useful when `pip install amd-gaia` or enterprise silent-install isn't the right fit. Take a working agent, drop it into the staging directory, run one `npm` command per OS, install the binary, and watch the seeder surface your agent in the Agent UI on first launch. Two paths are covered: From 47bf98a84307a03f800addd43e99a2f9b3bc26b9 Mon Sep 17 00:00:00 2001 From: Tomasz Iniewicz Date: Fri, 17 Apr 2026 16:56:12 -0400 Subject: [PATCH 8/8] feat(installer): ship Zoo Agent demo, surface export errors, add route tests - Add zoo-agent to build/bundled-agents/ staging dir so the custom-installer playbook has a working example seeded on first launch; extend .gitignore to un-ignore this specific path while keeping the rest of build/ excluded - Fix playbook: remove _TOOL_REGISTRY import and clear() call from the ZooAgent example (_register_tools must be pass, not clear(), to avoid wiping the process-wide tool registry) - agent-seeder.cjs: log at WARN (not INFO) when bundled-agents dir is missing inside a packaged Electron app; dev mode stays at INFO - CustomAgentsSection.tsx: add console.error in both export and import catches; scroll error banner into view when status.kind === 'error' - tests: add Jest structure test for CustomAgentsSection error paths; add TestRouteShadowing to confirm /export and /import resolve before the {agent_id:path} wildcard --- .gitignore | 6 +++ docs/playbooks/custom-installer/index.mdx | 3 +- .../apps/webui/build/bundled-agents/README.md | 10 ++++ .../build/bundled-agents/zoo-agent/agent.py | 28 +++++++++++ src/gaia/apps/webui/services/agent-seeder.cjs | 10 +++- .../src/components/CustomAgentsSection.tsx | 10 ++++ tests/electron/test_custom_agents_section.js | 46 +++++++++++++++++++ tests/unit/chat/ui/test_agents_router.py | 28 +++++++++++ 8 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 src/gaia/apps/webui/build/bundled-agents/README.md create mode 100644 src/gaia/apps/webui/build/bundled-agents/zoo-agent/agent.py create mode 100644 tests/electron/test_custom_agents_section.js diff --git a/.gitignore b/.gitignore index 9cd122626..70b9e46f9 100644 --- a/.gitignore +++ b/.gitignore @@ -141,6 +141,12 @@ yarn-error.log* # Node.js build outputs build/ +# Allow committed demo agents in the installer staging dir +!src/gaia/apps/webui/build/ +!src/gaia/apps/webui/build/bundled-agents/ +!src/gaia/apps/webui/build/bundled-agents/** +src/gaia/apps/webui/build/bundled-agents/**/__pycache__/ +src/gaia/apps/webui/build/bundled-agents/**/*.pyc dist/ out/ .next/ diff --git a/docs/playbooks/custom-installer/index.mdx b/docs/playbooks/custom-installer/index.mdx index 540aff380..91400c7de 100644 --- a/docs/playbooks/custom-installer/index.mdx +++ b/docs/playbooks/custom-installer/index.mdx @@ -69,7 +69,6 @@ Create `build/bundled-agents/zoo-agent/agent.py`: ```python title="build/bundled-agents/zoo-agent/agent.py" from gaia.agents.base.agent import Agent from gaia.agents.base.console import AgentConsole -from gaia.agents.base.tools import _TOOL_REGISTRY class ZooAgent(Agent): @@ -92,7 +91,7 @@ class ZooAgent(Agent): return AgentConsole() def _register_tools(self) -> None: - _TOOL_REGISTRY.clear() + pass ``` **Why this location matters.** The `build/bundled-agents/` directory is the build-time staging area picked up by the `extraResources` entry in `electron-builder.yml`: diff --git a/src/gaia/apps/webui/build/bundled-agents/README.md b/src/gaia/apps/webui/build/bundled-agents/README.md new file mode 100644 index 000000000..a5d5a191b --- /dev/null +++ b/src/gaia/apps/webui/build/bundled-agents/README.md @@ -0,0 +1,10 @@ +# Bundled Agents — Build-Time Staging + +This directory is the build-time staging area for agents that are preloaded in the +GAIA installer. Each subdirectory becomes an agent that is automatically seeded to +`~/.gaia/agents//` on the user's first launch via `agent-seeder.cjs`. + +The `zoo-agent/` here is a working example. To ship your own agent, replace it with +(or add alongside) a directory containing your `agent.py`. See the +[Custom Installer Playbook](https://amd-gaia.ai/playbooks/custom-installer) for the +full walkthrough. diff --git a/src/gaia/apps/webui/build/bundled-agents/zoo-agent/agent.py b/src/gaia/apps/webui/build/bundled-agents/zoo-agent/agent.py new file mode 100644 index 000000000..a7a383fb5 --- /dev/null +++ b/src/gaia/apps/webui/build/bundled-agents/zoo-agent/agent.py @@ -0,0 +1,28 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +from gaia.agents.base.agent import Agent +from gaia.agents.base.console import AgentConsole + + +class ZooAgent(Agent): + AGENT_ID = "zoo-agent" + AGENT_NAME = "Zoo Agent" + AGENT_DESCRIPTION = "A zookeeper who loves animals" + CONVERSATION_STARTERS = [ + "Hello! What's happening at the zoo today?", + "Tell me a fun fact about one of your animals.", + ] + + def _get_system_prompt(self) -> str: + return ( + "You are a funny and enthusiastic zookeeper! You work at the world's " + "best zoo and every response you give includes a fun fact or a playful " + "reference to one of your beloved zoo animals." + ) + + def _create_console(self) -> AgentConsole: + return AgentConsole() + + def _register_tools(self) -> None: + pass diff --git a/src/gaia/apps/webui/services/agent-seeder.cjs b/src/gaia/apps/webui/services/agent-seeder.cjs index a8e619949..e9d4a184b 100644 --- a/src/gaia/apps/webui/services/agent-seeder.cjs +++ b/src/gaia/apps/webui/services/agent-seeder.cjs @@ -237,8 +237,16 @@ async function seedBundledAgents() { if (!fs.existsSync(sourceDir) || !isDirectory(sourceDir)) { // Not an error — a build might simply ship without bundled agents. + // In a packaged Electron app the directory is expected to exist, so raise + // to WARN; in dev/test contexts leave it at INFO. + let isPackaged = false; + try { + isPackaged = require("electron").app?.isPackaged === true; + } catch (_) { + // not in an Electron context (tests, CLI) + } log( - "INFO", + isPackaged ? "WARN" : "INFO", `No bundled agents directory at ${sourceDir} — nothing to seed` ); return result; diff --git a/src/gaia/apps/webui/src/components/CustomAgentsSection.tsx b/src/gaia/apps/webui/src/components/CustomAgentsSection.tsx index 1bd1d957f..cb6621279 100644 --- a/src/gaia/apps/webui/src/components/CustomAgentsSection.tsx +++ b/src/gaia/apps/webui/src/components/CustomAgentsSection.tsx @@ -44,6 +44,7 @@ export function CustomAgentsSection() { const [status, setStatus] = useState({ kind: 'idle' }); const fileInputRef = useRef(null); const statusClearRef = useRef | null>(null); + const errorBannerRef = useRef(null); useEffect(() => { return () => { @@ -51,6 +52,12 @@ export function CustomAgentsSection() { }; }, []); + useEffect(() => { + if (status.kind === 'error') { + errorBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + }, [status]); + const flashStatus = useCallback((s: Status, clearAfterMs = 5000) => { setStatus(s); if (statusClearRef.current) clearTimeout(statusClearRef.current); @@ -105,6 +112,7 @@ export function CustomAgentsSection() { URL.revokeObjectURL(url); flashStatus({ kind: 'success', message: 'Export downloaded.' }); } catch (err) { + console.error('Agent export failed:', err); const message = err instanceof Error ? err.message : String(err); log.api.error('Agent export failed', err); flashStatus({ kind: 'error', message: `Export failed: ${message}` }); @@ -193,6 +201,7 @@ export function CustomAgentsSection() { }); } } catch (err) { + console.error('Agent import failed:', err); const message = err instanceof Error ? err.message : String(err); log.api.error('Agent import failed', err); flashStatus({ kind: 'error', message: `Import failed: ${message}` }); @@ -262,6 +271,7 @@ export function CustomAgentsSection() { {status.kind !== 'idle' && (
{ + let componentContent; + + beforeAll(() => { + const componentPath = path.join( + CHAT_APP_PATH, + 'src/components/CustomAgentsSection.tsx' + ); + componentContent = fs.readFileSync(componentPath, 'utf8'); + }); + + it('should log export failures to console', () => { + expect(componentContent).toContain("console.error('Agent export failed:', err)"); + }); + + it('should log import failures to console', () => { + expect(componentContent).toContain("console.error('Agent import failed:', err)"); + }); + + it('should declare an errorBannerRef for the status banner', () => { + expect(componentContent).toContain('errorBannerRef'); + }); + + it('should scroll the error banner into view on error', () => { + expect(componentContent).toContain('scrollIntoView'); + }); + + it('should trigger scroll only when status.kind === error', () => { + expect(componentContent).toContain("status.kind === 'error'"); + }); +}); diff --git a/tests/unit/chat/ui/test_agents_router.py b/tests/unit/chat/ui/test_agents_router.py index 63d00f6e8..80b50866d 100644 --- a/tests/unit/chat/ui/test_agents_router.py +++ b/tests/unit/chat/ui/test_agents_router.py @@ -210,3 +210,31 @@ def test_tunnel_active_import_returns_503(self, app_with_registry, monkeypatch): finally: app_with_registry.dependency_overrides.clear() del app_with_registry.state.tunnel + + +class TestRouteShadowing: + """Confirm that literal /export and /import routes shadow the {agent_id:path} wildcard. + + TestClient sends from host "testclient" (not localhost), so the localhost guard + fires 403 — which proves the named route resolved first (405 would mean it didn't). + """ + + def test_post_export_resolves_named_route_not_wildcard(self, client): + resp = client.post("/api/agents/export", headers={"X-Gaia-UI": "1"}) + assert resp.status_code == 403 + assert "method not allowed" not in resp.text.lower() + + def test_post_import_resolves_named_route_not_wildcard(self, client): + resp = client.post( + "/api/agents/import", + headers={"X-Gaia-UI": "1"}, + files={"bundle": ("x.zip", b"", "application/zip")}, + ) + assert resp.status_code == 403 + assert "method not allowed" not in resp.text.lower() + + def test_get_export_returns_404_not_405(self, client): + # GET /api/agents/export is handled by the GET /{agent_id:path} route; + # "export" is not a registered agent, so 404 is expected — not 405. + resp = client.get("/api/agents/export") + assert resp.status_code == 404