diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 00ba2fc8669b..5d902fbe554c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -40,7 +40,7 @@ ## Project-Specific Context - This is a Rust project using cargo workspaces -- Core crates: `goose` (agent logic), `goose-cli` (CLI), `goose-server` (backend), `goose-mcp` (MCP servers) +- Core crates: `goose` (agent logic and ACP server), `goose-cli` (CLI), `goose-mcp` (MCP servers) - Error handling: Use `anyhow::Result`, not `unwrap()` in production code - Async runtime: tokio - MCP protocol implementations require extra scrutiny diff --git a/.github/workflows/docs-update-recipe-ref.yml b/.github/workflows/docs-update-recipe-ref.yml deleted file mode 100644 index e0c9e86a85b0..000000000000 --- a/.github/workflows/docs-update-recipe-ref.yml +++ /dev/null @@ -1,292 +0,0 @@ -# Automatically updates the Recipe Reference Guide when recipe struct fields, -# validation rules, or schema constraints change between releases. -# -# Triggers: Manual (for testing) or on release (production) -# Testing: Use dry_run mode to review outputs without creating PRs -# See: documentation/automation/recipe-schema-tracking/TESTING.md - -name: Update Recipe Documentation - -on: - workflow_dispatch: # Manual trigger for testing - inputs: - old_version: - description: 'Previous version (e.g., v1.14.0). Leave empty to auto-detect.' - required: false - type: string - new_version: - description: 'New version (e.g., v1.15.0). Leave empty to use HEAD.' - required: false - type: string - dry_run: - description: 'Dry run mode - generate files but do not create PR' - required: false - type: boolean - default: true - - release: - types: [published] - -permissions: - contents: write # Create branches and commit files - pull-requests: write # Create PRs - -jobs: - update-docs: - name: Update Recipe Documentation - runs-on: ubuntu-latest - - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - - steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 # Fetch all history for version comparison - fetch-tags: true # Fetch all tags so we can checkout version tags - - - name: Fetch upstream tags (for forks) - if: github.repository != 'aaif-goose/goose' - run: | - # Add upstream remote and fetch tags (only needed when testing in forks) - git remote add upstream https://github.com/aaif-goose/goose.git || git remote set-url upstream https://github.com/aaif-goose/goose.git - git fetch upstream --tags --force - echo "✅ Fetched tags from upstream (fork mode)" - echo "Total tags available: $(git tag | wc -l)" - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y jq ripgrep - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: '20' - - - name: Install goose CLI - run: | - mkdir -p /home/runner/.local/bin - curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh \ - | CONFIGURE=false GOOSE_BIN_DIR=/home/runner/.local/bin bash - echo "/home/runner/.local/bin" >> $GITHUB_PATH - goose --version - - - name: Configure goose for CI - env: - GOOSE_PROVIDER: ${{ vars.GOOSE_PROVIDER || 'openai' }} - GOOSE_MODEL: ${{ vars.GOOSE_MODEL || 'gpt-4o' }} - run: | - mkdir -p ~/.config/goose - cat < ~/.config/goose/config.yaml - GOOSE_PROVIDER: $GOOSE_PROVIDER - GOOSE_MODEL: $GOOSE_MODEL - keyring: false - EOF - echo "✅ Created goose config:" - cat ~/.config/goose/config.yaml - - - name: Determine versions to compare - id: versions - env: - GH_TOKEN: ${{ github.token }} - INPUT_OLD_VERSION: ${{ github.event.inputs.old_version }} - INPUT_NEW_VERSION: ${{ github.event.inputs.new_version }} - EVENT_NAME: ${{ github.event_name }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: | - get_previous_release() { - gh release list --limit 2 --json tagName --jq '.[].tagName' | sed -n '2p' - } - - if [ -n "$INPUT_OLD_VERSION" ]; then - OLD_VERSION="$INPUT_OLD_VERSION" - else - OLD_VERSION=$(get_previous_release) - fi - - if [ -n "$INPUT_NEW_VERSION" ]; then - NEW_VERSION="$INPUT_NEW_VERSION" - elif [ "$EVENT_NAME" = "release" ]; then - NEW_VERSION="$RELEASE_TAG" - else - NEW_VERSION="HEAD" # For testing unreleased changes - fi - - if [ -z "$OLD_VERSION" ] || [ -z "$NEW_VERSION" ]; then - echo "Error: Could not determine versions to compare" - exit 1 - fi - - echo "old_version=$OLD_VERSION" >> $GITHUB_OUTPUT - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "OLD_VERSION=$OLD_VERSION" >> $GITHUB_ENV - echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV - - echo "✅ Comparing $OLD_VERSION → $NEW_VERSION" - - - name: Extract and compare schemas - id: extract - timeout-minutes: 15 - working-directory: documentation/automation/recipe-schema-tracking - env: - GOOSE_REPO: ${{ github.workspace }} - run: | - set -o pipefail # Ensure pipeline failures are caught - - mkdir -p output - ./scripts/run-pipeline.sh "$OLD_VERSION" "$NEW_VERSION" 2>&1 | tee output/pipeline.log - - HAS_CHANGES=$(jq -r '.has_changes' output/validation-changes.json) - echo "has_changes=$HAS_CHANGES" >> $GITHUB_OUTPUT - - if [ "$HAS_CHANGES" = "false" ]; then - echo "✅ No changes detected" - else - echo "✅ Changes detected" - fi - - - name: Update recipe-reference.md (AI synthesis) - if: steps.extract.outputs.has_changes == 'true' - timeout-minutes: 10 - working-directory: documentation/automation/recipe-schema-tracking/output - env: - RECIPE_REF_PATH: ${{ github.workspace }}/documentation/docs/guides/recipes/recipe-reference.md - run: | - echo "🔍 Environment diagnostics:" - echo " GOOSE_PROVIDER: $GOOSE_PROVIDER" - echo " GOOSE_MODEL: $GOOSE_MODEL" - echo " OPENAI_API_KEY: ${OPENAI_API_KEY:0:8}..." # Show first 8 chars only - echo " RECIPE_REF_PATH: $RECIPE_REF_PATH" - echo " HOME: $HOME" - echo " PATH: $PATH" - echo "" - echo "📁 Goose config file:" - cat ~/.config/goose/config.yaml || echo "Config file not found!" - echo "" - echo "📁 Current directory:" - pwd - ls -la - echo "" - echo "🤖 Step 1: Running validation changes synthesis..." - goose run --recipe ../recipes/synthesize-validation-changes.yaml - - echo "" - echo "🤖 Step 2: Applying changes to recipe-reference.md..." - goose run --recipe ../recipes/update-recipe-reference.yaml - - - name: Upload automation outputs - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: recipe-docs-update-${{ steps.versions.outputs.old_version }}-to-${{ steps.versions.outputs.new_version }} - path: | - documentation/automation/recipe-schema-tracking/output/*.json - documentation/automation/recipe-schema-tracking/output/*.md - documentation/automation/recipe-schema-tracking/output/*.log - retention-days: 30 - - - name: Create Pull Request - if: | - steps.extract.outputs.has_changes == 'true' && - (github.event.inputs.dry_run != 'true' || github.event_name == 'release') - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - with: - branch: docs/auto-recipe-reference-${{ steps.versions.outputs.new_version }} - delete-branch: true - - commit-message: | - docs: Update recipe reference for ${{ steps.versions.outputs.new_version }} - - Automated update based on schema changes between ${{ steps.versions.outputs.old_version }} and ${{ steps.versions.outputs.new_version }}. - - title: "docs: Update Recipe Reference Guide for ${{ steps.versions.outputs.new_version }}" - body: | - ## Summary - - This PR updates the Recipe Reference Guide based on schema and validation changes detected between **${{ steps.versions.outputs.old_version }}** and **${{ steps.versions.outputs.new_version }}**. - - ### Type of Change - - [x] Documentation - - ### AI Assistance - - [x] This PR was created or reviewed with AI assistance - - #### 🤖 Automation Details - - - **Workflow**: `docs-update-recipe-ref.yml` - - **Triggered by**: ${{ github.event_name }} - - **Previous version**: ${{ steps.versions.outputs.old_version }} - - **New version**: ${{ steps.versions.outputs.new_version }} - - #### 📋 Changes Detected - - Review the workflow artifacts for detailed change analysis: - - `validation-changes.json` - Structured diff of changes - - `validation-changes.md` - Human-readable change documentation - - `update-summary.md` - Summary of documentation updates applied - - Download artifacts from the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). - - ### ✅ Review Checklist - - - [ ] Verify all schema changes are accurately documented - - [ ] Check that examples are updated correctly - - [ ] Ensure validation rules are clearly explained - - [ ] Confirm no unintended changes were made - - [ ] Do changes require additional updates in this or other recipe topics? - - ### 🔗 Related - - - Release: ${{ github.event.release.html_url || 'N/A' }} - - --- - - *This PR was automatically generated by the Recipe Documentation Automation workflow.* - - labels: | - documentation - automated - recipe-reference - - - name: Workflow summary - if: always() - env: - OLD_VERSION: ${{ steps.versions.outputs.old_version }} - NEW_VERSION: ${{ steps.versions.outputs.new_version }} - HAS_CHANGES: ${{ steps.extract.outputs.has_changes }} - DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} - run: | - echo "## 📊 Recipe Documentation Update Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Version Comparison**: $OLD_VERSION → $NEW_VERSION" >> $GITHUB_STEP_SUMMARY - echo "**Changes Detected**: $HAS_CHANGES" >> $GITHUB_STEP_SUMMARY - echo "**Dry Run Mode**: $DRY_RUN" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ "$HAS_CHANGES" = "true" ]; then - echo "### ✅ Documentation Updated" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "The Recipe Reference Guide has been updated to reflect changes in $NEW_VERSION." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ "$DRY_RUN" = "true" ]; then - echo "**Note**: Running in dry-run mode - no PR was created. Review the artifacts to see the generated changes." >> $GITHUB_STEP_SUMMARY - else - echo "A pull request has been created with the documentation updates." >> $GITHUB_STEP_SUMMARY - fi - else - echo "### ℹ️ No Changes Needed" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "No recipe schema or validation changes were detected between $OLD_VERSION and $NEW_VERSION." >> $GITHUB_STEP_SUMMARY - fi - - echo "" >> $GITHUB_STEP_SUMMARY - echo "### 📦 Artifacts" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Download the workflow artifacts to review:" >> $GITHUB_STEP_SUMMARY - echo "- Extracted schemas and validation structures" >> $GITHUB_STEP_SUMMARY - echo "- Change detection results" >> $GITHUB_STEP_SUMMARY - echo "- Human-readable change documentation" >> $GITHUB_STEP_SUMMARY - echo "- Documentation update summary" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/goose-issue-solver.yml b/.github/workflows/goose-issue-solver.yml index b75a77d9b6f3..8ef17abb30c8 100644 --- a/.github/workflows/goose-issue-solver.yml +++ b/.github/workflows/goose-issue-solver.yml @@ -30,7 +30,7 @@ env: - Deletion is a feature: ask "can I delete code instead of adding?" - Complete changes: update ALL usages when changing a type. No exceptions. - When changing message types, update ALL provider format functions. - - When changing server routes, run `just generate-openapi`. + - When changing ACP request/response types, run `just generate-acp-types`. - Your context degrades. The TODO is your memory. Update it after each step. Types: diff --git a/BUILDING_LINUX.md b/BUILDING_LINUX.md index 4e1b9c4114e4..0b242a359f69 100644 --- a/BUILDING_LINUX.md +++ b/BUILDING_LINUX.md @@ -59,13 +59,7 @@ cd goose Build Goose CLI: ```bash -cargo build --release -p goose-cli -``` - -Build Goose Server: - -```bash -cargo build --release -p goose-server +cargo build --release -p goose-cli --bin goose ``` This command should give you a list of possible packages in the @@ -80,9 +74,9 @@ cargo test -p cd ui/desktop pnpm install -# Copy the server binary to the expected location +# Copy the goose binary to the expected location mkdir -p src/bin -cp ../../target/release/goosed src/bin/ +cp ../../target/release/goose src/bin/ ``` ### 4. Build the Application @@ -143,10 +137,10 @@ cd /path/to/goose/ui/desktop/out/goose-linux-x64 ./goose 2>&1 | grep -v "GLib-GObject" | grep -v "browser_main_loop" ``` -#### Server Binary Not Found -If you see "Could not find goosed binary", ensure you've: -1. Built the Rust backend: `cargo build --release -p goose-server` -2. Copied it to the right location: `cp ../../target/release/goosed src/bin/` +#### Goose Binary Not Found +If you see "Goose binary not found", ensure you've: +1. Built the Rust binary: `cargo build --release -p goose-cli --bin goose` +2. Copied it to the right location: `cp ../../target/release/goose src/bin/` 3. Rebuilt the application: `pnpm run make` ### Distribution-Specific Notes @@ -177,7 +171,7 @@ Building as Snap packages is not currently supported but may be added in the fut For active development: -1. **Backend changes**: Rebuild with `cargo build --release -p goose-server` and copy the binary +1. **Backend changes**: Rebuild with `cargo build --release -p goose-cli --bin goose` and copy the binary 2. **Frontend changes**: Use `pnpm run start` for hot reload during development 3. **Full rebuild**: Run the complete build process above diff --git a/CUSTOM_DISTROS.md b/CUSTOM_DISTROS.md index ad59da656e8e..74ace5fc5b27 100644 --- a/CUSTOM_DISTROS.md +++ b/CUSTOM_DISTROS.md @@ -26,8 +26,8 @@ goose's architecture is designed for extensibility. Organizations can create "re │ │ │ ▼ ▼ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ goose-server (goosed) │ -│ REST API for all goose functionality │ +│ goose serve (ACP) │ +│ ACP HTTP/WebSocket server for custom clients │ └─────────────────────────────────────────────────────────────────┘ │ ▼ @@ -49,7 +49,7 @@ goose's architecture is designed for extensibility. Organizations can create "re | Bundle custom MCP extensions | `config.yaml` extensions section, `ui/desktop/src/built-in-extensions.json`, `ui/desktop/src/components/settings/extensions/bundled-extensions.json` | Medium | | Modify system prompts | `crates/goose/src/prompts/` | Low | | Customize desktop branding | `ui/desktop/` (icons, names, colors) | Medium | -| Build a new UI (web, mobile) | Integrate with `goose-server` REST API | High | +| Build a new UI (web, mobile) | Integrate with `goose serve` over ACP | High | | Create guided workflows | Recipes (YAML-based task definitions) | Low | | Build complex multi-step workflows | Recipes with sub-recipes and subagents | Medium | @@ -304,40 +304,32 @@ export GOOSE_BUNDLE_NAME="InsightStream-goose" **Goal**: Create an entirely new frontend while leveraging goose's backend. -goose provides two integration options for building custom UIs: +goose provides two ACP transport options for building custom clients: -### Option 1: REST API (goose-server) +### Option 1: ACP HTTP/WebSocket (`goose serve`) -Use goose-server for HTTP-based integrations (web apps, simple clients): +Use `goose serve` for process-separated integrations such as web apps, desktop shells, and other clients: ```bash # Start the server -./target/release/goosed +GOOSE_SERVER__SECRET_KEY='a-long-random-secret' goose serve -# API available at http://localhost:3000 +# ACP endpoint available at http://localhost:3284/acp ``` -**Reference the OpenAPI spec** at `ui/desktop/openapi.json` for available endpoints: -- Session management -- Message streaming -- Extension control -- Configuration +HTTP clients authenticate with the `X-Secret-Key` header. Browser WebSocket clients use the same secret as a `?token=` query parameter because the browser WebSocket API cannot set custom headers: -**Key endpoints** for a minimal integration: - -``` -POST /sessions # Create a new session -POST /sessions/{id}/messages # Send a message (streaming response) -GET /sessions/{id} # Get session state -GET /extensions # List available extensions -POST /extensions/{name}/enable # Enable an extension +```text +ws://localhost:3284/acp?token=a-long-random-secret ``` -**Handle streaming responses** - goose uses Server-Sent Events (SSE) for real-time responses. +For browser clients served from a non-loopback origin, pass the exact UI origin with `--allowed-origin`. When you pass `--allowed-origin`, it replaces the default loopback origin allowlist, so include every origin the client needs. + +For the ACP protocol and client flow, see [Agent Client Protocol clients](documentation/docs/guides/acp-clients.md). -### Option 2: Agent Client Protocol (ACP) +### Option 2: Agent Client Protocol (ACP) over stdio -For richer integrations (IDEs, desktop apps, embedded agents), use the **Agent Client Protocol (ACP)**—a standardized JSON-RPC protocol for AI agent communication over stdio or other transports. +For richer local integrations (IDEs and embedded agents), run goose as an ACP agent over stdio. ACP provides: - **Bidirectional communication**: Agents can request permissions, stream updates, and receive cancellations @@ -410,11 +402,6 @@ For the full ACP specification, see the [Agent Client Protocol documentation](ht ### Technical Details -**REST API (goose-server)**: -- Server implementation: `crates/goose-server/src/routes/` -- OpenAPI generation: `just generate-openapi` -- API client example: `ui/desktop/src/api/` (generated TypeScript client) - **ACP**: - ACP server implementation: `crates/goose/src/acp/server.rs` - CLI integration: `crates/goose-cli/src/cli.rs` (Command::Acp) @@ -655,10 +642,10 @@ prompt: | - find_patterns: Find similar features to model after ``` -The AI can then invoke these sub-recipes using the `subagent` tool: +Recipes that define `sub_recipes` get the Summon extension automatically. The AI can invoke sub-recipes through Summon's `delegate` tool: ``` -subagent(subrecipe: "find_files", parameters: {"search_term": "authentication"}) +delegate(source: "find_files", parameters: {"search_term": "authentication"}) ``` ### Subagents: Dynamic Task Delegation @@ -678,28 +665,27 @@ prompt: | To complete this task: 1. Spawn a subagent to analyze the frontend code: - subagent(instructions: "Analyze all React components in src/components/ - and list their props and state management patterns") + delegate(instructions: "Analyze all React components in src/components/ and list their props and state management patterns") 2. Spawn another subagent for the backend: - subagent(instructions: "Document all API endpoints in src/api/ - including their request/response schemas") + delegate(instructions: "Document all API endpoints in src/api/ including their request/response schemas") 3. Synthesize findings from both subagents into a unified report. ``` #### Parallel Subagent Execution -Multiple subagent calls in the same message execute in parallel: +Use `async: true` to run delegates in parallel, then collect each result with `load(source: "")`: ```yaml prompt: | - Run these analyses in parallel by making all subagent calls at once: + Run these analyses in parallel: - subagent(instructions: "Count lines of code by language") - subagent(instructions: "Find all TODO comments") - subagent(instructions: "List external dependencies") + delegate(instructions: "Count lines of code by language", async: true) + delegate(instructions: "Find all TODO comments", async: true) + delegate(instructions: "List external dependencies", async: true) + Use load(source: "") for each returned task id. Then combine the results into a codebase health report. ``` @@ -711,22 +697,18 @@ Customize model, provider, or behavior per subagent: prompt: | Use a faster model for simple tasks: - subagent( + delegate( instructions: "List all files modified in the last week", - settings: { - model: "gpt-4o-mini", - max_turns: 3 - } + model: "gpt-4o-mini", + max_turns: 3 ) Use the full model for complex analysis: - subagent( + delegate( instructions: "Review this code for security vulnerabilities", - settings: { - model: "claude-sonnet-4-20250514", - temperature: 0.1 - } + model: "claude-sonnet-4-20250514", + temperature: 0.1 ) ``` @@ -738,7 +720,7 @@ Limit which extensions a subagent can access: prompt: | Create a sandboxed subagent with only file reading capabilities: - subagent( + delegate( instructions: "Analyze the README files in this project", extensions: ["developer"] # Only developer extension, no network access ) @@ -819,7 +801,7 @@ prompt: | 2. **Parallelize independent tasks** - Multiple subagent calls in one message run concurrently 3. **Use `sequential_when_repeated: true`** - For tasks that shouldn't run in parallel (e.g., database migrations) 4. **Scope extensions appropriately** - Give subagents only the tools they need -5. **Use summary mode (default)** - Subagents return concise summaries; use `summary: false` only when you need full conversation history +5. **Use background delegation for independent work** - Pass `async: true` to `delegate`, then collect results with `load(source: "")` 6. **Handle failures gracefully** - Design workflows to continue even if one subagent fails ### Technical Details diff --git a/Cargo.lock b/Cargo.lock index 67be7c29c892..76356a585f90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2509,18 +2509,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "config" -version = "0.15.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139" -dependencies = [ - "pathdiff", - "serde_core", - "toml 1.1.2+spec-1.1.0", - "winnow 1.0.3", -] - [[package]] name = "console" version = "0.16.4" @@ -5288,45 +5276,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "goose-server" -version = "1.41.0" -dependencies = [ - "anyhow", - "axum", - "axum-server", - "base64 0.22.1", - "bytes", - "chrono", - "clap", - "config", - "futures", - "goose", - "goose-mcp", - "goose-providers", - "hex", - "http 1.4.2", - "rand 0.10.2", - "reqwest 0.13.4", - "rmcp", - "rustls", - "serde", - "serde_json", - "serde_path_to_error", - "serde_yaml", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tokio-util", - "tower", - "tower-http 0.7.0", - "tracing", - "tracing-subscriber", - "utoipa 4.2.3", - "uuid", - "winreg", -] - [[package]] name = "goose-test" version = "1.41.0" @@ -12889,7 +12838,6 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "regex", "syn 2.0.118", ] @@ -13709,16 +13657,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" -dependencies = [ - "cfg-if 1.0.4", - "windows-sys 0.61.2", -] - [[package]] name = "winsafe" version = "0.0.19" diff --git a/Justfile b/Justfile index 63de5dff3d2a..be971a398c63 100644 --- a/Justfile +++ b/Justfile @@ -21,8 +21,6 @@ release-binary: @echo "Building release version..." cargo build --release -p goose-cli --bin goose @just copy-binary - @echo "Generating OpenAPI schema..." - cargo run -p goose-server --bin generate_schema # Build Windows executable on a Windows host [unix] @@ -149,11 +147,6 @@ run-server: @echo "Running external ACP backend..." GOOSE_SERVER__SECRET_KEY="${GOOSE_SERVER__SECRET_KEY:-test}" cargo run -p goose-cli --bin goose -- serve --platform desktop --host 127.0.0.1 --port 3000 -# Generate OpenAPI specification without starting the UI -generate-openapi: - @echo "Generating OpenAPI schema..." - cargo run -p goose-server --bin generate_schema - # Check if generated ACP schema and TypeScript types are up-to-date check-acp-schema: generate-acp-types #!/usr/bin/env bash @@ -297,7 +290,6 @@ bump-version version: @cd ui/desktop && npm pkg set "version={{ version }}" # update Cargo.lock after bumping versions in Cargo.toml @cargo update --workspace - @just set-openapi-version {{ version }} # rebuild canonical model registry and mapping report from models.dev build-canonical-models: @@ -312,14 +304,10 @@ prepare-release version: Cargo.lock \ ui/desktop/package.json \ ui/pnpm-lock.yaml \ - ui/desktop/openapi.json \ crates/goose-provider-types/src/canonical/data/canonical_models.json \ crates/goose-provider-types/src/canonical/data/provider_metadata.json @git commit --message "chore(release): release version {{ version }}" -set-openapi-version version: - @jq '.info.version |= "{{ version }}"' ui/desktop/openapi.json > ui/desktop/openapi.json.tmp && mv ui/desktop/openapi.json.tmp ui/desktop/openapi.json - # extract version from Cargo.toml get-tag-version: @uvx --from=toml-cli toml get --toml-path=Cargo.toml "workspace.package.version" @@ -354,7 +342,6 @@ set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] ### profile = --release or "" for debug ### allparam = OR/AND/ANY/NONE --workspace --all-features --all-targets win-bld profile allparam: - cargo run {{profile}} -p goose-server --bin generate_schema cargo build {{profile}} {{allparam}} ### Build just debug diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 46c7d133b4c6..427dd8035171 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -1435,7 +1435,6 @@ async fn handle_serve_command(args: ServeCommandArgs) -> Result<()> { config_dir: Paths::config_dir(), goose_platform: platform.into(), additional_source_roots, - scheduler: None, })); let env_secret = std::env::var(GOOSE_SERVER_SECRET_KEY_ENV) .ok() diff --git a/crates/goose-sdk-types/src/lib.rs b/crates/goose-sdk-types/src/lib.rs index 5b5021306945..03583546056d 100644 --- a/crates/goose-sdk-types/src/lib.rs +++ b/crates/goose-sdk-types/src/lib.rs @@ -1,8 +1,7 @@ -//! Shared types for the Goose SDK. +//! Shared Goose ACP wire types. //! -//! These wire types are used by both the ACP client/server path and the -//! in-process uniffi bindings, keeping a single source of truth for Goose's -//! custom `_goose/*` JSON-RPC methods. +//! These wire types keep a single source of truth for Goose's custom +//! `_goose/*` JSON-RPC methods. pub mod custom_notifications; pub mod custom_requests; diff --git a/crates/goose-server/ALLOWLIST.md b/crates/goose-server/ALLOWLIST.md deleted file mode 100644 index 5634adfdb6dd..000000000000 --- a/crates/goose-server/ALLOWLIST.md +++ /dev/null @@ -1,60 +0,0 @@ -IMPORTANT: currently GOOSE_ALLOWLIST is used in main.ts in ui/desktop, and not in goose-server. The following is for reference when it is used on the server side for launch time enforcement. - -# goose Extension Allowlist - -The allowlist feature provides a security mechanism for controlling which MCP commands can be used by goose. -By default, goose will let you run any MCP via any command, which isn't always desired. - -## How It Works - -1. When enabled, goose will only allow execution of commands that match entries in the allowlist -2. Commands not in the allowlist will be rejected with an error message -3. The allowlist is fetched from a URL specified by the `GOOSE_ALLOWLIST` environment variable and cached while running. - -## Setup - -Set the `GOOSE_ALLOWLIST` environment variable to the URL of your allowlist YAML file: - -```bash -export GOOSE_ALLOWLIST=https://example.com/goose-allowlist.yaml -``` - -If this environment variable is not set, no allowlist restrictions will be applied (all commands will be allowed). - -## Bypassing the Allowlist - -In certain development or testing scenarios, you may need to bypass the allowlist restrictions. You can do this by setting the `GOOSE_ALLOWLIST_BYPASS` environment variable to `true`: - -```bash -# For the GUI, you can have it show a warning instead of blocking (but it will always show a warning): -export GOOSE_ALLOWLIST_WARNING=true -``` - - -When this environment variable is set to `true` (case-insensitive), the allowlist check will be bypassed and all commands will be allowed, even if the `GOOSE_ALLOWLIST` environment variable is set. - -## Allowlist File Format - -The allowlist file should be a YAML file with the following structure: - -```yaml -extensions: - - id: extension-id-1 - command: command-name-1 - - id: extension-id-2 - command: command-name-2 -``` - -Example: - -```yaml -extensions: - - id: slack - command: uvx mcp_slack - - id: github - command: uvx mcp_github - - id: jira - command: uvx mcp_jira -``` - -Note that the command should be the full command to launch the MCP (environment variables are provided for context by goose). Additional arguments will be rejected (to avoid injection attacks) \ No newline at end of file diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml deleted file mode 100644 index 50062cbe7358..000000000000 --- a/crates/goose-server/Cargo.toml +++ /dev/null @@ -1,103 +0,0 @@ -[package] -name = "goose-server" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -description.workspace = true - -[lints] -workspace = true - -[features] -default = [ - "code-mode", - "local-inference", - "aws-providers", - "telemetry", - "nostr", - "otel", - "rustls-tls", - "system-keyring", -] -code-mode = ["goose/code-mode"] -local-inference = ["goose/local-inference"] -aws-providers = ["goose/aws-providers"] -cuda = ["goose/cuda", "local-inference"] -vulkan = ["goose/vulkan", "local-inference"] -mlx = ["goose/mlx", "local-inference"] -telemetry = ["goose/telemetry"] -nostr = ["goose/nostr"] -otel = ["goose/otel"] -system-keyring = ["goose/system-keyring"] -portable-default = ["rustls-tls", "aws-providers", "telemetry", "otel"] -rustls-tls = [ - "reqwest/rustls", - "axum-server/tls-rustls", - "dep:rustls", - "goose/rustls-tls", - "goose-mcp/rustls-tls", - "goose-providers/rustls-tls", -] -native-tls = [ - "reqwest/native-tls", - "axum-server/tls-openssl", - "goose/native-tls", - "goose-mcp/native-tls", - "goose-providers/native-tls", -] - -[dependencies] -goose = { path = "../goose", default-features = false } -goose-mcp = { path = "../goose-mcp", default-features = false } -goose-providers = { path = "../goose-providers", default-features = false } -rmcp = { workspace = true } -axum = { workspace = true, features = ["ws", "macros"] } -tokio = { workspace = true } -chrono = { workspace = true } -tower-http = { workspace = true, features = ["cors"] } -serde = { workspace = true } -serde_json = { workspace = true, features = ["preserve_order"] } -futures = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json", "time"] } -tokio-stream = { workspace = true } -anyhow = { workspace = true } -bytes = { workspace = true } -http = { workspace = true } -base64 = { workspace = true } -config = { version = "0.15", default-features = false, features = ["toml"] } -thiserror = { workspace = true } -clap = { workspace = true } -serde_yaml = { workspace = true } -utoipa = { workspace = true, features = ["axum_extras", "chrono"] } -reqwest = { workspace = true, features = ["json", "blocking", "multipart", "system-proxy"] } -tokio-util = { workspace = true } -serde_path_to_error = { version = "0.1.8", default-features = false } -rand = { workspace = true } -hex = { version = "0.4.3", default-features = false, features = ["std"] } -rustls = { workspace = true, optional = true } -uuid = { workspace = true } -axum-server = { version = "0.8", default-features = false } - -[target.'cfg(windows)'.dependencies] -winreg = { version = "0.56", default-features = false } - -[[bin]] -name = "goosed" -path = "src/main.rs" - -[[bin]] -name = "generate_schema" -path = "src/bin/generate_schema.rs" - -[dev-dependencies] -tower = { version = "0.5.2", default-features = false } - -[package.metadata.cargo-machete] -ignored = [ - # Used only in windows - "winreg", -] diff --git a/crates/goose-server/build.rs b/crates/goose-server/build.rs deleted file mode 100644 index 23a0fa399a66..000000000000 --- a/crates/goose-server/build.rs +++ /dev/null @@ -1,4 +0,0 @@ -// We'll generate the schema at runtime since we need access to the complete application context -fn main() { - println!("cargo:rerun-if-changed=src/"); -} diff --git a/crates/goose-server/src/auth.rs b/crates/goose-server/src/auth.rs deleted file mode 100644 index c450b6427aae..000000000000 --- a/crates/goose-server/src/auth.rs +++ /dev/null @@ -1,31 +0,0 @@ -use axum::{ - extract::{Request, State}, - http::StatusCode, - middleware::Next, - response::Response, -}; -pub use goose::acp::transport::auth::check_acp_token; -use goose::acp::transport::auth::token_matches; - -pub async fn check_token( - State(state): State, - request: Request, - next: Next, -) -> Result { - if request.uri().path() == "/status" - || request.uri().path() == "/mcp-app-proxy" - || request.uri().path() == "/mcp-app-guest" - { - return Ok(next.run(request).await); - } - let secret_key = request - .headers() - .get("X-Secret-Key") - .and_then(|value| value.to_str().ok()); - - if token_matches(secret_key, &state) { - Ok(next.run(request).await) - } else { - Err(StatusCode::UNAUTHORIZED) - } -} diff --git a/crates/goose-server/src/bin/generate_schema.rs b/crates/goose-server/src/bin/generate_schema.rs deleted file mode 100644 index ec5bd33aad95..000000000000 --- a/crates/goose-server/src/bin/generate_schema.rs +++ /dev/null @@ -1,30 +0,0 @@ -use goose_server::openapi; -use std::env; -use std::fs; -use std::path::PathBuf; - -fn main() { - let schema = openapi::generate_schema(); - - let package_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); - let output_path = PathBuf::from(package_dir) - .join("..") - .join("..") - .join("ui") - .join("desktop") - .join("openapi.json"); - - // Ensure parent directory exists - if let Some(parent) = output_path.parent() { - fs::create_dir_all(parent).unwrap(); - } - - fs::write(&output_path, format!("{schema}\n")).unwrap(); - eprintln!( - "Successfully generated OpenAPI schema at {}", - output_path.canonicalize().unwrap().display() - ); - - // Output the schema to stdout for piping - println!("{}", schema); -} diff --git a/crates/goose-server/src/commands/agent.rs b/crates/goose-server/src/commands/agent.rs deleted file mode 100644 index e5c597fb39d6..000000000000 --- a/crates/goose-server/src/commands/agent.rs +++ /dev/null @@ -1,146 +0,0 @@ -use crate::configuration; -use crate::state; -use anyhow::Result; -use axum::middleware; -use axum_server::Handle; -use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig}; -use goose::acp::transport::create_authenticated_acp_router; -use goose::agents::GoosePlatform; -use goose::config::paths::Paths; -use goose_server::auth::check_token; -use std::sync::Arc; -use tower_http::cors::{Any, CorsLayer}; -use tracing::info; - -fn boot_marker(message: &str) { - eprintln!("GOOSED_BOOT: {message}"); -} - -#[cfg(unix)] -async fn shutdown_signal() { - use tokio::signal::unix::{signal, SignalKind}; - - let mut sigint = signal(SignalKind::interrupt()).expect("failed to install SIGINT handler"); - let mut sigterm = signal(SignalKind::terminate()).expect("failed to install SIGTERM handler"); - - tokio::select! { - _ = sigint.recv() => {}, - _ = sigterm.recv() => {}, - } -} - -#[cfg(not(unix))] -async fn shutdown_signal() { - let _ = tokio::signal::ctrl_c().await; -} - -pub async fn run() -> Result<()> { - // Install the rustls crypto provider early, before any spawned tasks (tunnel, etc.) - // try to open TLS connections. Both `ring` and `aws-lc-rs` - // features are enabled on rustls (via different transitive deps), so rustls - // cannot auto-detect a provider — we must pick one explicitly. - #[cfg(feature = "rustls-tls")] - let _ = rustls::crypto::ring::default_provider().install_default(); - - boot_marker("main entered"); - crate::logging::setup_logging(Some("goosed"))?; - - let settings = configuration::Settings::new()?; - - let secret_key = std::env::var("GOOSE_SERVER__SECRET_KEY") - .unwrap_or_else(|_| hex::encode(rand::random::<[u8; 32]>())); - - boot_marker("appstate init start"); - let app_state = state::AppState::new(settings.tls).await?; - - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); - - // TODO(acp-migration): When ui/desktop launches `goose serve` directly, - // move any goosed-only ACP setup into the goose serve path before deleting - // this bridge. In particular, verify everything ACP currently gets from - // goosed startup/AppState initialization, including builtin extension - // registration and the desktop platform identity. - let acp_server = Arc::new(AcpServer::new(AcpServerFactoryConfig { - builtins: vec!["developer".to_string()], - data_dir: Paths::data_dir(), - config_dir: Paths::config_dir(), - goose_platform: GoosePlatform::GooseDesktop, - additional_source_roots: Vec::new(), - scheduler: Some(app_state.scheduler()), - })); - - let rest_router = crate::routes::configure(app_state.clone(), secret_key.clone()) - .layer(middleware::from_fn_with_state( - secret_key.clone(), - check_token, - )) - .layer(cors); - let acp_router = create_authenticated_acp_router(acp_server, secret_key.clone()); - - let app = rest_router.merge(acp_router); - - let addr = settings.socket_addr(); - - if settings.tls { - #[cfg(any(feature = "rustls-tls", feature = "native-tls"))] - { - boot_marker("tls setup start"); - let tls_setup = goose::acp::transport::tls::setup_tls( - settings.tls_cert_path.as_deref(), - settings.tls_key_path.as_deref(), - ) - .await?; - - let handle = Handle::new(); - let shutdown_handle = handle.clone(); - tokio::spawn(async move { - shutdown_signal().await; - shutdown_handle.graceful_shutdown(None); - }); - - info!("listening on https://{}", addr); - boot_marker("listening"); - - #[cfg(feature = "rustls-tls")] - axum_server::bind_rustls(addr, tls_setup.config) - .handle(handle) - .serve(app.into_make_service()) - .await?; - - #[cfg(feature = "native-tls")] - axum_server::bind_openssl(addr, tls_setup.config) - .handle(handle) - .serve(app.into_make_service()) - .await?; - } - - #[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))] - { - anyhow::bail!( - "TLS was requested but no TLS backend is enabled. \ - Enable the `rustls-tls` or `native-tls` feature." - ); - } - } else { - boot_marker("tcp bind start"); - let listener = tokio::net::TcpListener::bind(addr).await?; - - info!("listening on http://{}", addr); - boot_marker("listening"); - - axum::serve(listener, app) - .with_graceful_shutdown(async { shutdown_signal().await }) - .await?; - } - - #[cfg(feature = "otel")] - if goose::otel::otlp::is_otlp_initialized() { - goose::otel::otlp::shutdown_otlp(); - } - - info!("server shutdown complete"); - Ok(()) -} diff --git a/crates/goose-server/src/commands/mod.rs b/crates/goose-server/src/commands/mod.rs deleted file mode 100644 index f17bc55db89b..000000000000 --- a/crates/goose-server/src/commands/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod agent; diff --git a/crates/goose-server/src/configuration.rs b/crates/goose-server/src/configuration.rs deleted file mode 100644 index c6f458367187..000000000000 --- a/crates/goose-server/src/configuration.rs +++ /dev/null @@ -1,102 +0,0 @@ -use crate::error::{to_env_var, ConfigError}; -use config::{Config, Environment}; -use serde::Deserialize; -use std::net::SocketAddr; - -#[derive(Debug, Default, Deserialize)] -pub struct Settings { - #[serde(default = "default_host")] - pub host: String, - #[serde(default = "default_port")] - pub port: u16, - #[serde(default = "default_tls")] - pub tls: bool, - pub tls_cert_path: Option, - pub tls_key_path: Option, -} - -impl Settings { - pub fn socket_addr(&self) -> SocketAddr { - format!("{}:{}", self.host, self.port) - .parse() - .expect("Failed to parse socket address") - } - - pub fn new() -> Result { - Self::load_and_validate() - } - - fn load_and_validate() -> Result { - // Start with default configuration - let config = Config::builder() - // Server defaults - .set_default("host", default_host())? - .set_default("port", default_port())? - .set_default("tls", default_tls())? - // Layer on the environment variables - .add_source( - Environment::with_prefix("GOOSE") - .prefix_separator("_") - .separator("__") - .try_parsing(true), - ) - .build()?; - - // Try to deserialize the configuration - let result: Result = config.try_deserialize(); - - // Handle missing field errors specially - match result { - Ok(settings) => Ok(settings), - Err(err) => { - tracing::debug!("Configuration error: {:?}", &err); - - // Handle both NotFound and missing field message variants - let error_str = err.to_string(); - if error_str.starts_with("missing field") { - // Extract field name from error message "missing field `type`" - let field = error_str - .trim_start_matches("missing field `") - .trim_end_matches("`"); - let env_var = to_env_var(field); - Err(ConfigError::MissingEnvVar { env_var }) - } else if let config::ConfigError::NotFound(field) = &err { - let env_var = to_env_var(field); - Err(ConfigError::MissingEnvVar { env_var }) - } else { - Err(ConfigError::Other(err)) - } - } - } - } -} - -fn default_host() -> String { - "127.0.0.1".to_string() -} - -fn default_port() -> u16 { - 3000 -} - -fn default_tls() -> bool { - true -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_socket_addr_conversion() { - let server_settings = Settings { - host: "127.0.0.1".to_string(), - port: 3000, - tls: true, - tls_cert_path: None, - tls_key_path: None, - }; - let addr = server_settings.socket_addr(); - assert_eq!(addr.to_string(), "127.0.0.1:3000"); - } -} diff --git a/crates/goose-server/src/error.rs b/crates/goose-server/src/error.rs deleted file mode 100644 index 5f38f85f1c9b..000000000000 --- a/crates/goose-server/src/error.rs +++ /dev/null @@ -1,40 +0,0 @@ -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum ConfigError { - #[error("Missing required environment variable: {env_var}")] - MissingEnvVar { env_var: String }, - #[error("Configuration error: {0}")] - Other(#[from] config::ConfigError), -} - -// Helper function to format environment variable names -pub(crate) fn to_env_var(field_path: &str) -> String { - // Handle nested fields by converting dots to double underscores - // If the field is in the provider object, we need to prefix it appropriately - let normalized_path = if field_path == "type" { - "provider.type".to_string() - } else if field_path.starts_with("provider.") { - field_path.to_string() - } else { - format!("provider.{}", field_path) - }; - - format!( - "GOOSE_{}", - normalized_path.replace('.', "__").to_uppercase() - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_env_var_conversion() { - assert_eq!(to_env_var("type"), "GOOSE_PROVIDER__TYPE"); - assert_eq!(to_env_var("api_key"), "GOOSE_PROVIDER__API_KEY"); - assert_eq!(to_env_var("provider.host"), "GOOSE_PROVIDER__HOST"); - assert_eq!(to_env_var("provider.api_key"), "GOOSE_PROVIDER__API_KEY"); - } -} diff --git a/crates/goose-server/src/lib.rs b/crates/goose-server/src/lib.rs deleted file mode 100644 index 551a2fdbd71d..000000000000 --- a/crates/goose-server/src/lib.rs +++ /dev/null @@ -1,18 +0,0 @@ -#[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))] -compile_error!("At least one of `rustls-tls` or `native-tls` features must be enabled"); - -#[cfg(all(feature = "rustls-tls", feature = "native-tls"))] -compile_error!("Features `rustls-tls` and `native-tls` are mutually exclusive"); - -pub mod auth; -pub mod configuration; -pub mod error; -pub mod openapi; -pub mod routes; -pub mod session_event_bus; -pub mod state; -#[cfg(any(feature = "rustls-tls", feature = "native-tls"))] -pub mod tls; -// Re-export commonly used items -pub use openapi::*; -pub use state::*; diff --git a/crates/goose-server/src/logging.rs b/crates/goose-server/src/logging.rs deleted file mode 100644 index 499cd118453d..000000000000 --- a/crates/goose-server/src/logging.rs +++ /dev/null @@ -1,19 +0,0 @@ -use anyhow::Result; -use goose::providers::utils::init_goose_request_log; -use tracing_subscriber::util::SubscriberInitExt; - -/// Sets up the logging infrastructure for the server. -/// Logs go to a JSON file and a pretty console layer on stderr. -pub fn setup_logging(name: Option<&str>) -> Result<()> { - init_goose_request_log()?; - let config = goose::logging::LoggingConfig { - component: "server", - name, - extra_directives: &["goose_server=info", "tower_http=info"], - console: true, - json: false, - }; - let subscriber = goose::logging::build_logging_subscriber(&config)?; - subscriber.try_init()?; - Ok(()) -} diff --git a/crates/goose-server/src/main.rs b/crates/goose-server/src/main.rs deleted file mode 100644 index cfde78440a0b..000000000000 --- a/crates/goose-server/src/main.rs +++ /dev/null @@ -1,106 +0,0 @@ -mod commands; -mod configuration; -mod error; -mod logging; -mod openapi; -mod routes; -mod session_event_bus; -mod state; -use std::path::PathBuf; -use std::{backtrace::Backtrace, panic::PanicHookInfo}; - -use clap::{Parser, Subcommand}; -use goose::agents::validate_extensions; -use goose_mcp::{ - mcp_server_runner::{serve, McpCommand}, - AutoVisualiserRouter, ComputerControllerServer, MemoryServer, TutorialServer, -}; - -#[derive(Parser)] -#[command(author, version, about, long_about = None)] -#[command(propagate_version = true)] -struct Cli { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Run the agent server - Agent, - /// Run the MCP server - Mcp { - #[arg(value_parser = clap::value_parser!(McpCommand))] - server: McpCommand, - }, - /// Validate a bundled-extensions JSON file - #[command(name = "validate-extensions")] - ValidateExtensions { - /// Path to the bundled-extensions JSON file - path: PathBuf, - }, -} - -fn boot_marker(message: &str) { - eprintln!("GOOSED_BOOT: {message}"); -} - -fn install_panic_hook() { - let default_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |panic_info: &PanicHookInfo<'_>| { - let location = panic_info - .location() - .map(|location| format!("{}:{}", location.file(), location.line())) - .unwrap_or_else(|| "unknown".to_string()); - - let payload = panic_info - .payload() - .downcast_ref::<&str>() - .map(|msg| (*msg).to_string()) - .or_else(|| panic_info.payload().downcast_ref::().cloned()) - .unwrap_or_else(|| "unknown panic payload".to_string()); - - eprintln!("GOOSED_BOOT: panic at {location}: {payload}"); - eprintln!("GOOSED_BOOT: backtrace:\n{}", Backtrace::force_capture()); - - default_hook(panic_info); - })); -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - install_panic_hook(); - boot_marker("main entered"); - - let cli = Cli::parse(); - boot_marker(&format!( - "command parsed: {:?}", - std::mem::discriminant(&cli.command) - )); - - match cli.command { - Commands::Agent => { - commands::agent::run().await?; - } - Commands::Mcp { server } => { - logging::setup_logging(Some(&format!("mcp-{}", server.name())))?; - match server { - McpCommand::AutoVisualiser => serve(AutoVisualiserRouter::new()).await?, - McpCommand::ComputerController => serve(ComputerControllerServer::new()).await?, - McpCommand::Memory => serve(MemoryServer::new()).await?, - McpCommand::Tutorial => serve(TutorialServer::new()).await?, - } - } - Commands::ValidateExtensions { path } => { - match validate_extensions::validate_bundled_extensions(&path) { - Ok(msg) => println!("{msg}"), - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - } - } - } - - Ok(()) -} diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs deleted file mode 100644 index fa43e6f75c82..000000000000 --- a/crates/goose-server/src/openapi.rs +++ /dev/null @@ -1,676 +0,0 @@ -use goose::agents::extension::Envs; -use goose::agents::extension::ToolInfo; -use goose::agents::ExtensionConfig; -use goose::config::permission::PermissionLevel; -use goose::config::ExtensionEntry; -use goose::conversation::token_usage::Usage; -use goose::conversation::Conversation; -use goose::download_manager::{DownloadProgress, DownloadStatus}; -use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata, ProviderType}; -use goose::session::{ - DiagnosticsConfig, DiagnosticsError, DiagnosticsExtensions, DiagnosticsLevel, DiagnosticsLogs, - DiagnosticsPrompt, DiagnosticsReport, DiagnosticsScheduledRecipe, DiagnosticsTextFile, Session, - SessionType, SystemInfo, -}; -use goose_providers::model::ModelConfig; -use goose_providers::permission::Permission; -use goose_providers::permission::PrincipalType; -use goose_providers::thinking::ThinkingEffort; -use rmcp::model::{ - Annotations, Content, EmbeddedResource, Icon, IconTheme, ImageContent, JsonObject, - RawAudioContent, RawContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, - ResourceContents, Role, TaskSupport, TextContent, Tool, ToolAnnotations, ToolExecution, -}; -use utoipa::{OpenApi, ToSchema}; - -use goose::config::declarative_providers::{ - DeclarativeProviderConfig, EnvVarConfig, LoadedProvider, ProviderEngine, -}; -use goose::conversation::message::{ - ActionRequired, ActionRequiredData, FrontendToolRequest, InferenceMetadata, Message, - MessageContent, MessageMetadata, MessageUsage, RedactedThinkingContent, - SystemNotificationContent, SystemNotificationType, ThinkingContent, TokenState, - ToolConfirmationRequest, ToolRequest, ToolResponse, -}; -use goose::providers::base::CostSource; - -use crate::routes::recipe_utils::RecipeManifest; -use crate::routes::reply::MessageEvent; -use utoipa::openapi::schema::{ - AdditionalProperties, AnyOfBuilder, ArrayBuilder, ObjectBuilder, OneOfBuilder, Schema, - SchemaFormat, SchemaType, -}; -use utoipa::openapi::{AllOfBuilder, Ref, RefOr}; - -macro_rules! derive_utoipa { - ($inner_type:ident as $schema_name:ident) => { - pub struct $schema_name {} - - impl<'__s> ToSchema<'__s> for $schema_name { - fn schema() -> (&'__s str, utoipa::openapi::RefOr) { - let settings = rmcp::schemars::generate::SchemaSettings::openapi3(); - let generator = settings.into_generator(); - let schema = generator.into_root_schema_for::<$inner_type>(); - let schema = convert_schemars_to_utoipa(schema); - (stringify!($inner_type), schema) - } - - fn aliases() -> Vec<(&'__s str, utoipa::openapi::schema::Schema)> { - Vec::new() - } - } - }; - ($inner_type:ident as $schema_name:ident => $output_name:expr) => { - pub struct $schema_name {} - - impl<'__s> ToSchema<'__s> for $schema_name { - fn schema() -> (&'__s str, utoipa::openapi::RefOr) { - let settings = rmcp::schemars::generate::SchemaSettings::openapi3(); - let generator = settings.into_generator(); - let schema = generator.into_root_schema_for::<$inner_type>(); - let schema = convert_schemars_to_utoipa(schema); - ($output_name, schema) - } - - fn aliases() -> Vec<(&'__s str, utoipa::openapi::schema::Schema)> { - Vec::new() - } - } - }; -} - -fn convert_schemars_to_utoipa(schema: rmcp::schemars::Schema) -> RefOr { - if let Some(true) = schema.as_bool() { - return RefOr::T(Schema::Object(ObjectBuilder::new().build())); - } - - if let Some(false) = schema.as_bool() { - return RefOr::T(Schema::Object(ObjectBuilder::new().build())); - } - - if let Some(obj) = schema.as_object() { - return convert_json_object_to_utoipa(obj); - } - - RefOr::T(Schema::Object(ObjectBuilder::new().build())) -} - -fn convert_json_object_to_utoipa( - obj: &serde_json::Map, -) -> RefOr { - use serde_json::Value; - - if let Some(Value::String(reference)) = obj.get("$ref") { - return RefOr::Ref(Ref::new(reference.clone())); - } - - if let Some(Value::Array(one_of)) = obj.get("oneOf") { - let mut builder = OneOfBuilder::new(); - for item in one_of { - if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) { - builder = builder.item(convert_schemars_to_utoipa(schema)); - } - } - return RefOr::T(Schema::OneOf(builder.build())); - } - - // Handle the discriminated union pattern from schemars: an object with - // `type`, `properties`, `required` AND `allOf` (e.g. each variant of a - // `#[serde(tag = "type")]` enum). We merge the inline object (which carries - // the discriminator property) with the `allOf` refs into a single `allOf`. - if let Some(Value::Array(all_of)) = obj.get("allOf") { - let has_inline_properties = obj.contains_key("properties") || obj.contains_key("type"); - if has_inline_properties { - let mut builder = AllOfBuilder::new(); - // Build an object schema from the inline properties/required - let mut obj_without_allof = obj.clone(); - obj_without_allof.remove("allOf"); - builder = builder.item(convert_json_object_to_utoipa(&obj_without_allof)); - for item in all_of { - if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) { - builder = builder.item(convert_schemars_to_utoipa(schema)); - } - } - return RefOr::T(Schema::AllOf(builder.build())); - } - - let mut builder = AllOfBuilder::new(); - for item in all_of { - if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) { - builder = builder.item(convert_schemars_to_utoipa(schema)); - } - } - return RefOr::T(Schema::AllOf(builder.build())); - } - - if let Some(Value::Array(any_of)) = obj.get("anyOf") { - let mut builder = AnyOfBuilder::new(); - for item in any_of { - if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) { - builder = builder.item(convert_schemars_to_utoipa(schema)); - } - } - return RefOr::T(Schema::AnyOf(builder.build())); - } - - match obj.get("type") { - Some(Value::String(type_str)) => convert_typed_schema(type_str, obj), - Some(Value::Array(types)) => { - let mut builder = AnyOfBuilder::new(); - for type_val in types { - if let Value::String(type_str) = type_val { - builder = builder.item(convert_typed_schema(type_str, obj)); - } - } - RefOr::T(Schema::AnyOf(builder.build())) - } - None => RefOr::T(Schema::Object(ObjectBuilder::new().build())), - _ => RefOr::T(Schema::Object(ObjectBuilder::new().build())), - } -} - -fn convert_typed_schema( - type_str: &str, - obj: &serde_json::Map, -) -> RefOr { - use serde_json::Value; - - match type_str { - "object" => { - let mut object_builder = ObjectBuilder::new(); - - if let Some(Value::Object(properties)) = obj.get("properties") { - for (name, prop_value) in properties { - if let Ok(prop_schema) = rmcp::schemars::Schema::try_from(prop_value.clone()) { - let prop = convert_schemars_to_utoipa(prop_schema); - object_builder = object_builder.property(name, prop); - } - } - } - - if let Some(Value::Array(required)) = obj.get("required") { - for req in required { - if let Value::String(field_name) = req { - object_builder = object_builder.required(field_name); - } - } - } - - if let Some(additional) = obj.get("additionalProperties") { - match additional { - Value::Bool(false) => { - object_builder = object_builder - .additional_properties(Some(AdditionalProperties::FreeForm(false))); - } - Value::Bool(true) => { - object_builder = object_builder - .additional_properties(Some(AdditionalProperties::FreeForm(true))); - } - _ => { - if let Ok(schema) = rmcp::schemars::Schema::try_from(additional.clone()) { - let schema = convert_schemars_to_utoipa(schema); - object_builder = object_builder - .additional_properties(Some(AdditionalProperties::RefOr(schema))); - } - } - } - } - - RefOr::T(Schema::Object(object_builder.build())) - } - "array" => { - let mut array_builder = ArrayBuilder::new(); - - if let Some(items) = obj.get("items") { - match items { - Value::Object(_) | Value::Bool(_) => { - if let Ok(item_schema) = rmcp::schemars::Schema::try_from(items.clone()) { - let item_schema = convert_schemars_to_utoipa(item_schema); - array_builder = array_builder.items(item_schema); - } - } - Value::Array(item_schemas) => { - let mut any_of = AnyOfBuilder::new(); - for item in item_schemas { - if let Ok(schema) = rmcp::schemars::Schema::try_from(item.clone()) { - any_of = any_of.item(convert_schemars_to_utoipa(schema)); - } - } - let any_of_schema = RefOr::T(Schema::AnyOf(any_of.build())); - array_builder = array_builder.items(any_of_schema); - } - _ => {} - } - } - - if let Some(Value::Number(min_items)) = obj.get("minItems") { - if let Some(min) = min_items.as_u64() { - array_builder = array_builder.min_items(Some(min as usize)); - } - } - if let Some(Value::Number(max_items)) = obj.get("maxItems") { - if let Some(max) = max_items.as_u64() { - array_builder = array_builder.max_items(Some(max as usize)); - } - } - - RefOr::T(Schema::Array(array_builder.build())) - } - "string" => { - let mut object_builder = ObjectBuilder::new().schema_type(SchemaType::String); - - if let Some(Value::Array(enum_values)) = obj.get("enum") { - let values: Vec = enum_values - .iter() - .filter_map(|v| { - if let Value::String(s) = v { - Some(Value::String(s.clone())) - } else { - None - } - }) - .collect(); - if !values.is_empty() { - object_builder = object_builder.enum_values(Some(values)); - } - } - - if let Some(Value::Number(min_length)) = obj.get("minLength") { - if let Some(min) = min_length.as_u64() { - object_builder = object_builder.min_length(Some(min as usize)); - } - } - if let Some(Value::Number(max_length)) = obj.get("maxLength") { - if let Some(max) = max_length.as_u64() { - object_builder = object_builder.max_length(Some(max as usize)); - } - } - if let Some(Value::String(pattern)) = obj.get("pattern") { - object_builder = object_builder.pattern(Some(pattern.clone())); - } - if let Some(Value::String(format)) = obj.get("format") { - object_builder = object_builder.format(Some(SchemaFormat::Custom(format.clone()))); - } - - RefOr::T(Schema::Object(object_builder.build())) - } - "number" => { - let mut object_builder = ObjectBuilder::new().schema_type(SchemaType::Number); - - if let Some(Value::Number(minimum)) = obj.get("minimum") { - if let Some(min) = minimum.as_f64() { - object_builder = object_builder.minimum(Some(min)); - } - } - if let Some(Value::Number(maximum)) = obj.get("maximum") { - if let Some(max) = maximum.as_f64() { - object_builder = object_builder.maximum(Some(max)); - } - } - if let Some(Value::Number(exclusive_minimum)) = obj.get("exclusiveMinimum") { - if let Some(min) = exclusive_minimum.as_f64() { - object_builder = object_builder.exclusive_minimum(Some(min)); - } - } - if let Some(Value::Number(exclusive_maximum)) = obj.get("exclusiveMaximum") { - if let Some(max) = exclusive_maximum.as_f64() { - object_builder = object_builder.exclusive_maximum(Some(max)); - } - } - if let Some(Value::Number(multiple_of)) = obj.get("multipleOf") { - if let Some(mult) = multiple_of.as_f64() { - object_builder = object_builder.multiple_of(Some(mult)); - } - } - - RefOr::T(Schema::Object(object_builder.build())) - } - "integer" => { - let mut object_builder = ObjectBuilder::new().schema_type(SchemaType::Integer); - - if let Some(Value::Number(minimum)) = obj.get("minimum") { - if let Some(min) = minimum.as_f64() { - object_builder = object_builder.minimum(Some(min)); - } - } - if let Some(Value::Number(maximum)) = obj.get("maximum") { - if let Some(max) = maximum.as_f64() { - object_builder = object_builder.maximum(Some(max)); - } - } - if let Some(Value::Number(exclusive_minimum)) = obj.get("exclusiveMinimum") { - if let Some(min) = exclusive_minimum.as_f64() { - object_builder = object_builder.exclusive_minimum(Some(min)); - } - } - if let Some(Value::Number(exclusive_maximum)) = obj.get("exclusiveMaximum") { - if let Some(max) = exclusive_maximum.as_f64() { - object_builder = object_builder.exclusive_maximum(Some(max)); - } - } - if let Some(Value::Number(multiple_of)) = obj.get("multipleOf") { - if let Some(mult) = multiple_of.as_f64() { - object_builder = object_builder.multiple_of(Some(mult)); - } - } - - RefOr::T(Schema::Object(object_builder.build())) - } - "boolean" => RefOr::T(Schema::Object( - ObjectBuilder::new() - .schema_type(SchemaType::Boolean) - .build(), - )), - "null" => RefOr::T(Schema::Object( - ObjectBuilder::new().schema_type(SchemaType::String).build(), - )), - _ => RefOr::T(Schema::Object(ObjectBuilder::new().build())), - } -} - -derive_utoipa!(Role as RoleSchema); -derive_utoipa!(Content as ContentSchema); -derive_utoipa!(RawContent as ContentBlockSchema => "ContentBlock"); -derive_utoipa!(EmbeddedResource as EmbeddedResourceSchema); -derive_utoipa!(ImageContent as ImageContentSchema); -derive_utoipa!(TextContent as TextContentSchema); -derive_utoipa!(RawTextContent as RawTextContentSchema); -derive_utoipa!(RawImageContent as RawImageContentSchema); -derive_utoipa!(RawAudioContent as RawAudioContentSchema); -derive_utoipa!(RawEmbeddedResource as RawEmbeddedResourceSchema); -derive_utoipa!(RawResource as RawResourceSchema); -derive_utoipa!(Tool as ToolSchema); -derive_utoipa!(ToolAnnotations as ToolAnnotationsSchema); -derive_utoipa!(ToolExecution as ToolExecutionSchema); -derive_utoipa!(TaskSupport as TaskSupportSchema); -derive_utoipa!(Annotations as AnnotationsSchema); -derive_utoipa!(ResourceContents as ResourceContentsSchema); -derive_utoipa!(JsonObject as JsonObjectSchema); -derive_utoipa!(Icon as IconSchema); -derive_utoipa!(IconTheme as IconThemeSchema); - -#[derive(OpenApi)] -#[openapi( - paths( - super::routes::status::status, - super::routes::status::system_info, - super::routes::status::diagnostics, - super::routes::config_management::validate_config, - super::routes::config_management::upsert_config, - super::routes::config_management::remove_config, - super::routes::config_management::read_config, - super::routes::config_management::add_extension, - super::routes::config_management::remove_extension, - super::routes::config_management::get_extensions, - super::routes::config_management::read_all_config, - super::routes::config_management::list_provider_secrets, - super::routes::config_management::delete_provider_secret, - super::routes::config_management::providers, - super::routes::config_management::get_provider_models, - super::routes::config_management::get_provider_model_info, - super::routes::config_management::get_slash_commands, - super::routes::config_management::create_custom_provider, - super::routes::config_management::get_custom_provider, - super::routes::config_management::update_custom_provider, - super::routes::config_management::remove_custom_provider, - super::routes::config_management::get_provider_catalog, - super::routes::config_management::get_provider_catalog_template, - super::routes::config_management::cleanup_provider_cache, - super::routes::config_management::check_provider, - super::routes::config_management::set_config_provider, - super::routes::config_management::get_canonical_model_info, - super::routes::prompts::get_prompts, - super::routes::prompts::get_prompt, - super::routes::prompts::save_prompt, - super::routes::prompts::reset_prompt, - super::routes::agent::start_agent, - super::routes::agent::resume_agent, - super::routes::agent::stop_agent, - super::routes::agent::restart_agent, - super::routes::agent::update_working_dir, - super::routes::agent::get_tools, - super::routes::agent::update_from_session, - super::routes::agent::agent_add_extension, - super::routes::agent::agent_remove_extension, - super::routes::agent::update_agent_provider, - super::routes::agent::update_session, - super::routes::action_required::confirm_tool_action, - super::routes::reply::reply, - super::routes::session_events::session_events, - super::routes::session_events::session_reply, - super::routes::session_events::session_cancel, - super::routes::session::get_session, - super::routes::session::update_session_name, - super::routes::session::update_session_user_recipe_values, - super::routes::session::fork_session, - super::routes::session::get_session_extensions, - super::routes::schedule::create_schedule, - super::routes::schedule::list_schedules, - super::routes::schedule::delete_schedule, - super::routes::schedule::update_schedule, - super::routes::schedule::run_now_handler, - super::routes::schedule::pause_schedule, - super::routes::schedule::unpause_schedule, - super::routes::schedule::kill_running_job, - super::routes::schedule::inspect_running_job, - super::routes::schedule::sessions_handler, - super::routes::recipe::encode_recipe, - super::routes::recipe::decode_recipe, - super::routes::recipe::scan_recipe, - super::routes::recipe::list_recipes, - super::routes::recipe::delete_recipe, - super::routes::recipe::schedule_recipe, - super::routes::recipe::set_recipe_slash_command, - super::routes::recipe::save_recipe, - super::routes::recipe::parse_recipe, - super::routes::recipe::recipe_to_yaml, - super::routes::telemetry::send_telemetry_event, - super::routes::dictation::transcribe_dictation, - super::routes::dictation::get_dictation_config, - ), - components(schemas( - super::routes::config_management::UpsertConfigQuery, - super::routes::config_management::ConfigKeyQuery, - super::routes::config_management::ConfigResponse, - super::routes::config_management::ProvidersResponse, - super::routes::config_management::ProviderDetails, - super::routes::config_management::ProviderSecretsResponse, - super::routes::config_management::ProviderSecret, - super::routes::config_management::ProviderSecretStorage, - super::routes::config_management::ProviderSecretStatus, - super::routes::config_management::SlashCommandsResponse, - super::routes::config_management::SlashCommand, - super::routes::config_management::CommandType, - super::routes::config_management::ExtensionResponse, - super::routes::config_management::ExtensionQuery, - super::routes::config_management::UpdateCustomProviderRequest, - goose::providers::catalog::ProviderCatalogEntry, - goose::providers::catalog::ProviderTemplate, - goose::providers::catalog::ModelTemplate, - goose::providers::catalog::ModelCapabilities, - super::routes::config_management::CreateCustomProviderResponse, - super::routes::config_management::CheckProviderRequest, - super::routes::config_management::SetProviderRequest, - super::routes::config_management::ModelInfoQuery, - super::routes::config_management::ModelInfoResponse, - super::routes::config_management::ModelInfoData, - super::routes::prompts::PromptsListResponse, - super::routes::prompts::PromptContentResponse, - super::routes::prompts::SavePromptRequest, - goose::prompt_template::Template, - super::routes::action_required::ConfirmToolActionRequest, - super::routes::reply::ChatRequest, - super::routes::session_events::SessionReplyRequest, - super::routes::session_events::SessionReplyResponse, - super::routes::session_events::CancelRequest, - super::routes::session::UpdateSessionNameRequest, - super::routes::session::UpdateSessionUserRecipeValuesRequest, - super::routes::session::UpdateSessionUserRecipeValuesResponse, - super::routes::session::ForkRequest, - super::routes::session::ForkResponse, - super::routes::session::SessionExtensionsResponse, - Message, - MessageContent, - MessageMetadata, - InferenceMetadata, - MessageUsage, - CostSource, - TokenState, - Usage, - ContentSchema, - EmbeddedResourceSchema, - ImageContentSchema, - AnnotationsSchema, - TextContentSchema, - RawTextContentSchema, - RawImageContentSchema, - RawAudioContentSchema, - RawEmbeddedResourceSchema, - RawResourceSchema, - ToolResponse, - ToolRequest, - ToolConfirmationRequest, - ActionRequired, - ActionRequiredData, - ThinkingContent, - RedactedThinkingContent, - FrontendToolRequest, - ResourceContentsSchema, - SystemNotificationType, - SystemNotificationContent, - MessageEvent, - JsonObjectSchema, - RoleSchema, - ProviderMetadata, - ProviderType, - LoadedProvider, - ProviderEngine, - DeclarativeProviderConfig, - EnvVarConfig, - ExtensionEntry, - ExtensionConfig, - ConfigKey, - Envs, - RecipeManifest, - ToolSchema, - ToolAnnotationsSchema, - ToolExecutionSchema, - TaskSupportSchema, - ToolInfo, - PermissionLevel, - Permission, - PrincipalType, - ModelInfo, - ModelConfig, - ThinkingEffort, - super::routes::config_management::ProviderModelInfoQuery, - Session, - goose_providers::goose_mode::GooseMode, - SessionType, - SystemInfo, - DiagnosticsConfig, - DiagnosticsError, - DiagnosticsExtensions, - DiagnosticsLevel, - DiagnosticsLogs, - DiagnosticsPrompt, - DiagnosticsReport, - DiagnosticsScheduledRecipe, - DiagnosticsTextFile, - Conversation, - IconSchema, - IconThemeSchema, - goose::session::extension_data::ExtensionData, - super::routes::schedule::CreateScheduleRequest, - super::routes::schedule::UpdateScheduleRequest, - super::routes::schedule::KillJobResponse, - super::routes::schedule::InspectJobResponse, - goose::scheduler::ScheduledJob, - super::routes::schedule::RunNowResponse, - super::routes::schedule::ListSchedulesResponse, - super::routes::schedule::SessionsQuery, - super::routes::schedule::SessionDisplayInfo, - super::routes::recipe::EncodeRecipeRequest, - super::routes::recipe::EncodeRecipeResponse, - super::routes::recipe::DecodeRecipeRequest, - super::routes::recipe::DecodeRecipeResponse, - super::routes::recipe::ScanRecipeRequest, - super::routes::recipe::ScanRecipeResponse, - super::routes::recipe::ListRecipeResponse, - super::routes::recipe::ScheduleRecipeRequest, - super::routes::recipe::SetSlashCommandRequest, - super::routes::recipe::DeleteRecipeRequest, - super::routes::recipe::SaveRecipeRequest, - super::routes::recipe::SaveRecipeResponse, - super::routes::errors::ErrorResponse, - super::routes::recipe::ParseRecipeRequest, - super::routes::recipe::ParseRecipeResponse, - super::routes::recipe::RecipeToYamlRequest, - super::routes::recipe::RecipeToYamlResponse, - goose::recipe::Recipe, - goose::recipe::Author, - goose::recipe::Settings, - goose::recipe::RecipeParameter, - goose::recipe::RecipeParameterInputType, - goose::recipe::RecipeParameterRequirement, - goose::recipe::Response, - goose::recipe::SubRecipe, - goose::agents::types::RetryConfig, - goose::agents::types::SuccessCheck, - super::routes::agent::UpdateProviderRequest, - super::routes::agent::UpdateSessionRequest, - super::routes::agent::GetToolsQuery, - ContentBlockSchema, - super::routes::agent::StartAgentRequest, - super::routes::agent::ResumeAgentRequest, - super::routes::agent::StopAgentRequest, - super::routes::agent::RestartAgentRequest, - super::routes::agent::UpdateWorkingDirRequest, - super::routes::agent::UpdateFromSessionRequest, - super::routes::agent::AddExtensionRequest, - super::routes::agent::RemoveExtensionRequest, - super::routes::agent::ResumeAgentResponse, - super::routes::agent::RestartAgentResponse, - goose::agents::ExtensionLoadResult, - super::routes::telemetry::TelemetryEventRequest, - goose::goose_apps::GooseApp, - goose::goose_apps::WindowProps, - goose::goose_apps::McpAppResource, - goose::goose_apps::CspMetadata, - goose::goose_apps::PermissionsMetadata, - goose::goose_apps::UiMetadata, - goose::goose_apps::ResourceMetadata, - super::routes::dictation::TranscribeRequest, - super::routes::dictation::TranscribeResponse, - goose::dictation::providers::DictationProvider, - super::routes::dictation::DictationProviderStatus, - DownloadProgress, - DownloadStatus, - )) -)] -pub struct ApiDoc; - -#[cfg(feature = "local-inference")] -#[derive(OpenApi)] -#[openapi( - paths( - super::routes::dictation::list_models, - super::routes::dictation::download_model, - super::routes::dictation::get_download_progress, - super::routes::dictation::cancel_download, - super::routes::dictation::delete_model, - ), - components(schemas(super::routes::dictation::WhisperModelResponse,)) -)] -pub struct LocalInferenceApiDoc; - -#[allow(dead_code)] // Used by generate_schema binary -pub fn generate_schema() -> String { - #[allow(unused_mut)] - let mut api_doc = ApiDoc::openapi(); - - #[cfg(feature = "local-inference")] - api_doc.merge(LocalInferenceApiDoc::openapi()); - - serde_json::to_string_pretty(&api_doc).unwrap() -} diff --git a/crates/goose-server/src/routes/action_required.rs b/crates/goose-server/src/routes/action_required.rs deleted file mode 100644 index 1301f41c4016..000000000000 --- a/crates/goose-server/src/routes/action_required.rs +++ /dev/null @@ -1,100 +0,0 @@ -use crate::routes::errors::ErrorResponse; -use crate::state::AppState; -use axum::{extract::State, routing::post, Json, Router}; -use goose::permission::permission_confirmation::PrincipalType; -use goose::permission::{Permission, PermissionConfirmation}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::sync::Arc; -use utoipa::ToSchema; - -#[derive(Debug, Deserialize, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ConfirmToolActionRequest { - id: String, - #[serde(default = "default_principal_type")] - principal_type: PrincipalType, - action: Permission, - session_id: String, -} - -fn default_principal_type() -> PrincipalType { - PrincipalType::Tool -} - -#[utoipa::path( - post, - path = "/action-required/tool-confirmation", - request_body = ConfirmToolActionRequest, - responses( - (status = 200, description = "Tool confirmation action is confirmed", body = Value), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn confirm_tool_action( - State(state): State>, - Json(request): Json, -) -> Result, ErrorResponse> { - let agent = state.get_agent_for_route(request.session_id).await?; - - agent - .handle_confirmation( - request.id.clone(), - PermissionConfirmation { - principal_type: request.principal_type, - permission: request.action, - }, - ) - .await; - - Ok(Json(Value::Object(serde_json::Map::new()))) -} - -pub fn routes(state: Arc) -> Router { - Router::new() - .route( - "/action-required/tool-confirmation", - post(confirm_tool_action), - ) - .with_state(state) -} - -#[cfg(test)] -mod tests { - use super::*; - - mod integration_tests { - use super::*; - use axum::{body::Body, http::Request}; - use http::StatusCode; - use tower::ServiceExt; - - #[tokio::test(flavor = "multi_thread")] - async fn test_tool_confirmation_endpoint() { - let state = AppState::new(true).await.unwrap(); - - let app = routes(state); - - let request = Request::builder() - .uri("/action-required/tool-confirmation") - .method("POST") - .header("content-type", "application/json") - .header("x-secret-key", "test-secret") - .body(Body::from( - serde_json::to_string(&ConfirmToolActionRequest { - id: "test-id".to_string(), - principal_type: PrincipalType::Tool, - action: Permission::AllowOnce, - session_id: "test-session".to_string(), - }) - .unwrap(), - )) - .unwrap(); - - let response = app.oneshot(request).await.unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - } - } -} diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs deleted file mode 100644 index 155c621f1bff..000000000000 --- a/crates/goose-server/src/routes/agent.rs +++ /dev/null @@ -1,1025 +0,0 @@ -use crate::routes::config_management::resolve_provider_model_info; -use crate::routes::errors::ErrorResponse; -use crate::routes::recipe_utils::{ - apply_recipe_to_agent, build_recipe_with_parameter_values, load_recipe_by_id, validate_recipe, -}; -use crate::state::AppState; -use axum::response::IntoResponse; -use axum::{ - extract::{Query, State}, - http::StatusCode, - routing::{get, post}, - Json, Router, -}; -use goose::agents::{Container, ExtensionLoadResult}; - -use goose::agents::ExtensionConfig; -use goose::config::resolve_extensions_for_new_session; -use goose::config::{Config, GooseMode}; -use goose::providers::create; -use goose::recipe::Recipe; -use goose::recipe_deeplink; -use goose::session::session_manager::SessionType; -use goose::session::{EnabledExtensionsState, ExtensionState, Session}; -use goose::{ - agents::{extension::ToolInfo, extension_manager::get_parameter_names}, - config::permission::PermissionLevel, -}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use std::sync::Arc; -use tracing::error; - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct UpdateFromSessionRequest { - session_id: String, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct UpdateProviderRequest { - provider: String, - model: Option, - session_id: String, - context_limit: Option, - request_params: Option>, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct UpdateSessionRequest { - session_id: String, - goose_mode: Option, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct GetToolsQuery { - extension_name: Option, - session_id: String, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct StartAgentRequest { - working_dir: String, - #[serde(default)] - recipe: Option, - #[serde(default)] - recipe_id: Option, - #[serde(default)] - recipe_deeplink: Option, - #[serde(default)] - extension_overrides: Option>, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct StopAgentRequest { - session_id: String, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct RestartAgentRequest { - session_id: String, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct UpdateWorkingDirRequest { - session_id: String, - working_dir: String, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct ResumeAgentRequest { - session_id: String, - load_model_and_extensions: bool, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct AddExtensionRequest { - session_id: String, - config: ExtensionConfig, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct RemoveExtensionRequest { - name: String, - session_id: String, -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct SetContainerRequest { - session_id: String, - container_id: Option, -} - -#[derive(Serialize, utoipa::ToSchema)] -pub struct ResumeAgentResponse { - pub session: Session, - #[serde(skip_serializing_if = "Option::is_none")] - pub extension_results: Option>, -} - -#[derive(Serialize, utoipa::ToSchema)] -pub struct RestartAgentResponse { - pub extension_results: Vec, -} - -#[utoipa::path( - post, - path = "/agent/start", - request_body = StartAgentRequest, - responses( - (status = 200, description = "Agent started successfully", body = Session), - (status = 400, description = "Bad request", body = ErrorResponse), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 500, description = "Internal server error", body = ErrorResponse) - ) -)] -#[allow(clippy::too_many_lines)] -async fn start_agent( - State(state): State>, - Json(payload): Json, -) -> Result, ErrorResponse> { - #[cfg(feature = "telemetry")] - goose::posthog::set_session_context("desktop", false); - - let StartAgentRequest { - working_dir, - recipe, - recipe_id, - recipe_deeplink, - extension_overrides, - } = payload; - - let original_recipe = if let Some(deeplink) = recipe_deeplink { - match recipe_deeplink::decode(&deeplink) { - Ok(recipe) => Some(recipe), - Err(err) => { - error!("Failed to decode recipe deeplink: {}", err); - #[cfg(feature = "telemetry")] - goose::posthog::emit_error("recipe_deeplink_decode_failed", &err.to_string()); - return Err(ErrorResponse { - message: err.to_string(), - status: StatusCode::BAD_REQUEST, - }); - } - } - } else if let Some(id) = recipe_id { - match load_recipe_by_id(state.as_ref(), &id).await { - Ok(recipe) => Some(recipe), - Err(err) => return Err(err), - } - } else { - recipe - }; - - if let Some(ref recipe) = original_recipe { - if let Err(err) = validate_recipe(recipe) { - return Err(ErrorResponse { - message: err.message, - status: err.status, - }); - } - } - - let name = "New Chat".to_string(); - - let manager = state.session_manager(); - let config = Config::global(); - let current_mode = config.get_goose_mode().unwrap_or_default(); - - let mut session = manager - .create_session( - PathBuf::from(&working_dir), - name, - SessionType::User, - current_mode, - ) - .await - .map_err(|err| { - error!("Failed to create session: {}", err); - #[cfg(feature = "telemetry")] - goose::posthog::emit_error("session_create_failed", &err.to_string()); - ErrorResponse { - message: format!("Failed to create session: {}", err), - status: StatusCode::BAD_REQUEST, - } - })?; - - let recipe_extensions = original_recipe - .as_ref() - .and_then(|r| r.extensions.as_deref()); - let has_extension_overrides = extension_overrides.is_some(); - let mut extensions_to_use = - resolve_extensions_for_new_session(recipe_extensions, extension_overrides); - if recipe_extensions.is_none() && !has_extension_overrides { - extensions_to_use.extend(goose::plugins::mcp_servers::enabled_plugin_mcp_servers( - Some(&PathBuf::from(&working_dir)), - )); - } - - let mut extension_data = session.extension_data.clone(); - let extensions_state = EnabledExtensionsState::new(extensions_to_use); - if let Err(e) = extensions_state.to_extension_data(&mut extension_data) { - tracing::warn!("Failed to initialize session with extensions: {}", e); - } else { - manager - .update(&session.id) - .extension_data(extension_data.clone()) - .apply() - .await - .map_err(|err| { - error!("Failed to save initial extension state: {}", err); - ErrorResponse { - message: format!("Failed to save initial extension state: {}", err), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - })?; - } - - if let Some(recipe) = original_recipe { - let mut update = manager.update(&session.id).recipe(Some(recipe.clone())); - - if let Some(ref settings) = recipe.settings { - if let Some(ref provider) = settings.goose_provider { - update = update.provider_name(provider); - - if let Some(ref model) = settings.goose_model { - if let Ok(model_config) = - goose::model_config::model_config_from_user_config(provider, model) - { - update = update.model_config(model_config); - } - } - } - } - - update.apply().await.map_err(|err| { - error!("Failed to update session with recipe: {}", err); - ErrorResponse { - message: format!("Failed to update session with recipe: {}", err), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - })?; - } - - // Refetch session to get all updates - session = manager - .get_session(&session.id, false) - .await - .map_err(|err| { - error!("Failed to get updated session: {}", err); - ErrorResponse { - message: format!("Failed to get updated session: {}", err), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - })?; - - // Eagerly start loading extensions in the background - let session_for_spawn = session.clone(); - let state_for_spawn = state.clone(); - let session_id_for_task = session.id.clone(); - let task = tokio::spawn(async move { - match state_for_spawn - .get_agent(session_for_spawn.id.clone()) - .await - { - Ok(agent) => { - let results = agent.load_extensions_from_session(&session_for_spawn).await; - tracing::debug!( - "Background extension loading completed for session {}", - session_for_spawn.id - ); - results - } - Err(e) => { - tracing::warn!( - "Failed to create agent for background extension loading: {}", - e - ); - vec![] - } - } - }); - - state - .set_extension_loading_task(session_id_for_task, task) - .await; - - Ok(Json(session)) -} - -#[utoipa::path( - post, - path = "/agent/resume", - request_body = ResumeAgentRequest, - responses( - (status = 200, description = "Agent started successfully", body = ResumeAgentResponse), - (status = 400, description = "Bad request - invalid working directory"), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 500, description = "Internal server error") - ) -)] -async fn resume_agent( - State(state): State>, - Json(payload): Json, -) -> Result, ErrorResponse> { - #[cfg(feature = "telemetry")] - goose::posthog::set_session_context("desktop", true); - - let session = state - .session_manager() - .get_session(&payload.session_id, true) - .await - .map_err(|err| { - error!("Failed to resume session {}: {}", payload.session_id, err); - #[cfg(feature = "telemetry")] - goose::posthog::emit_error("session_resume_failed", &err.to_string()); - ErrorResponse { - message: format!("Failed to resume session: {}", err), - status: StatusCode::NOT_FOUND, - } - })?; - - let (extension_results, session) = if payload.load_model_and_extensions { - let agent = state - .get_agent_for_route(payload.session_id.clone()) - .await - .map_err(|code| ErrorResponse { - message: "Failed to get agent for route".into(), - status: code, - })?; - - if !state.has_extension_loading_task(&payload.session_id).await { - let session_for_task = session.clone(); - let agent_for_task = agent.clone(); - let session_id_for_task = payload.session_id.clone(); - let task = tokio::spawn(async move { - agent_for_task - .load_extensions_from_session(&session_for_task) - .await - }); - state - .set_extension_loading_task(session_id_for_task, task) - .await; - } - - let provider_changed = agent - .restore_provider_from_session(&session) - .await - .map_err(|e| ErrorResponse { - message: e.to_string(), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - let session = if provider_changed { - state - .session_manager() - .get_session(&payload.session_id, true) - .await - .map_err(|err| ErrorResponse { - message: format!("Failed to re-fetch session: {}", err), - status: StatusCode::INTERNAL_SERVER_ERROR, - })? - } else { - session - }; - - let extension_results = match state.take_extension_loading_task(&payload.session_id).await { - Ok(Some(results)) => { - tracing::debug!( - "Using background extension loading results for session {}", - payload.session_id - ); - state - .remove_extension_loading_task(&payload.session_id) - .await; - results - } - Ok(None) => { - tracing::debug!( - "Extension loading task for session {} was already consumed", - payload.session_id - ); - vec![] - } - Err(e) => { - state - .remove_extension_loading_task(&payload.session_id) - .await; - tracing::warn!( - "Background extension loading failed for session {}, retrying synchronously: {}", - payload.session_id, - e - ); - agent.load_extensions_from_session(&session).await - } - }; - - (Some(extension_results), session) - } else { - (None, session) - }; - - Ok(Json(ResumeAgentResponse { - session, - extension_results, - })) -} - -#[utoipa::path( - post, - path = "/agent/update_from_session", - request_body = UpdateFromSessionRequest, - responses( - (status = 200, description = "Update agent from session data successfully"), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Agent not initialized"), - ), -)] -async fn update_from_session( - State(state): State>, - Json(payload): Json, -) -> Result { - let agent = state - .get_agent_for_route(payload.session_id.clone()) - .await - .map_err(|status| ErrorResponse { - message: format!("Failed to get agent: {}", status), - status, - })?; - let session = state - .session_manager() - .get_session(&payload.session_id, false) - .await - .map_err(|err| ErrorResponse { - message: format!("Failed to get session: {}", err), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - if let Some(recipe) = session.recipe { - if session.session_type == SessionType::Scheduled { - if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await { - agent - .extend_system_prompt("recipe".to_string(), prompt) - .await; - } - } else { - match build_recipe_with_parameter_values( - &recipe, - session.user_recipe_values.unwrap_or_default(), - ) - .await - { - Ok(Some(recipe)) => { - if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await { - agent - .extend_system_prompt("recipe".to_string(), prompt) - .await; - } - } - Ok(None) => { - // Recipe has missing parameters - } - Err(e) => { - return Err(ErrorResponse { - message: e.to_string(), - status: StatusCode::INTERNAL_SERVER_ERROR, - }); - } - } - } - } - - Ok(StatusCode::OK) -} - -#[utoipa::path( - get, - path = "/agent/tools", - params( - ("extension_name" = Option, Query, description = "Optional extension name to filter tools"), - ("session_id" = String, Query, description = "Required session ID to scope tools to a specific session") - ), - responses( - (status = 200, description = "Tools retrieved successfully", body = Vec), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Agent not initialized"), - (status = 500, description = "Internal server error") - ) -)] -async fn get_tools( - State(state): State>, - Query(query): Query, -) -> Result>, StatusCode> { - let session_id = query.session_id; - let agent = state.get_agent_for_route(session_id.clone()).await?; - let goose_mode = agent.goose_mode().await; - let permission_manager = agent.config.permission_manager.clone(); - - let mut tools: Vec = agent - .list_tools(&session_id, query.extension_name) - .await - .into_iter() - .map(|tool| { - let permission = permission_manager - .get_user_permission(&tool.name) - .or_else(|| { - if goose_mode == GooseMode::SmartApprove { - permission_manager.get_smart_approve_permission(&tool.name) - } else if goose_mode == GooseMode::Approve { - Some(PermissionLevel::AskBefore) - } else { - None - } - }); - - ToolInfo::new( - &tool.name, - tool.description - .as_ref() - .map(|d| d.as_ref()) - .unwrap_or_default(), - get_parameter_names(&tool), - permission, - ) - .with_input_schema(serde_json::Value::Object( - tool.input_schema.as_ref().clone(), - )) - }) - .collect::>(); - tools.sort_by(|a, b| a.name.cmp(&b.name)); - - Ok(Json(tools)) -} - -#[utoipa::path( - post, - path = "/agent/update_provider", - request_body = UpdateProviderRequest, - responses( - (status = 200, description = "Provider updated successfully"), - (status = 400, description = "Bad request - missing or invalid parameters"), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Agent not initialized"), - (status = 500, description = "Internal server error") - ) -)] -async fn update_agent_provider( - State(state): State>, - Json(payload): Json, -) -> Result<(), impl IntoResponse> { - let agent = state - .get_agent_for_route(payload.session_id.clone()) - .await - .map_err(|e| (e, "No agent for session id".to_owned()))?; - - let config = Config::global(); - let model = match payload.model.or_else(|| config.get_goose_model().ok()) { - Some(m) => m, - None => { - return Err((StatusCode::BAD_REQUEST, "No model specified".to_owned())); - } - }; - - let mut model_config = - goose::model_config::model_config_from_user_config(&payload.provider, &model) - .map_err(|e| { - ( - StatusCode::BAD_REQUEST, - format!("Invalid model config: {}", e), - ) - })? - .with_context_limit(payload.context_limit); - - if let Some(request_params) = payload.request_params { - model_config = model_config.with_merged_request_params(request_params); - } - let model_info = resolve_provider_model_info(&payload.provider, &model) - .await - .map_err(|e| (e.status, e.message))?; - model_config.reasoning = Some(model_info.reasoning); - - let extensions = - EnabledExtensionsState::for_session(state.session_manager(), &payload.session_id, config) - .await; - - let new_provider = create(&payload.provider, extensions).await.map_err(|e| { - ( - StatusCode::BAD_REQUEST, - format!("Failed to create {} provider: {}", &payload.provider, e), - ) - })?; - - agent - .update_provider(new_provider, model_config, &payload.session_id) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to update provider: {}", e), - ) - })?; - - // Propagate session mode to the new provider - let mode = agent.goose_mode().await; - agent - .update_goose_mode(mode, &payload.session_id) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to propagate mode to provider: {}", e), - ) - })?; - - Ok(()) -} - -#[utoipa::path( - post, - path = "/agent/update_session", - request_body = UpdateSessionRequest, - responses( - (status = 200, description = "Session updated"), - (status = 400, description = "Invalid request"), - (status = 500, description = "Internal error") - ) -)] -async fn update_session( - State(state): State>, - Json(payload): Json, -) -> Result<(), (StatusCode, String)> { - let agent = state - .get_agent_for_route(payload.session_id.clone()) - .await - .map_err(|e| (e, "No agent for session id".to_owned()))?; - - if let Some(mode_str) = payload.goose_mode { - let mode: GooseMode = mode_str.parse().map_err(|_| { - ( - StatusCode::BAD_REQUEST, - format!("Invalid mode: {}", mode_str), - ) - })?; - - agent - .update_goose_mode(mode, &payload.session_id) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to update mode: {}", e), - ) - })?; - } - - Ok(()) -} - -#[utoipa::path( - post, - path = "/agent/add_extension", - request_body = AddExtensionRequest, - responses( - (status = 200, description = "Extension added", body = String), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Agent not initialized"), - (status = 500, description = "Internal server error") - ) -)] -async fn agent_add_extension( - State(state): State>, - Json(request): Json, -) -> Result { - #[cfg(feature = "telemetry")] - let extension_name = request.config.name(); - - ensure_extensions_loaded(&state, &request.session_id).await?; - - let agent = state.get_agent(request.session_id.clone()).await?; - - agent - .add_extension(request.config, &request.session_id) - .await - .map_err(|e| { - #[cfg(feature = "telemetry")] - goose::posthog::emit_error( - "extension_add_failed", - &format!("{}: {}", extension_name, e), - ); - ErrorResponse::internal(format!("Failed to add extension: {}", e)) - })?; - - Ok(StatusCode::OK) -} - -#[utoipa::path( - post, - path = "/agent/remove_extension", - request_body = RemoveExtensionRequest, - responses( - (status = 200, description = "Extension removed", body = String), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Agent not initialized"), - (status = 500, description = "Internal server error") - ) -)] -async fn agent_remove_extension( - State(state): State>, - Json(request): Json, -) -> Result { - ensure_extensions_loaded(&state, &request.session_id).await?; - - let agent = state.get_agent(request.session_id.clone()).await?; - - agent - .remove_extension(&request.name, &request.session_id) - .await - .map_err(|e| { - error!("Failed to remove extension: {}", e); - ErrorResponse { - message: format!("Failed to remove extension: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - })?; - - Ok(StatusCode::OK) -} - -#[utoipa::path( - post, - path = "/agent/set_container", - request_body = SetContainerRequest, - responses( - (status = 200, description = "Container set successfully"), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 424, description = "Agent not initialized"), - (status = 500, description = "Internal server error") - ) -)] -async fn set_container( - State(state): State>, - Json(request): Json, -) -> Result { - let agent = state.get_agent(request.session_id.clone()).await?; - - let container = request.container_id.map(Container::new); - agent.set_container(container).await; - - Ok(StatusCode::OK) -} - -#[utoipa::path( - post, - path = "/agent/stop", - request_body = StopAgentRequest, - responses( - (status = 200, description = "Agent stopped successfully", body = String), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 404, description = "Session not found"), - (status = 500, description = "Internal server error") - ) -)] -async fn stop_agent( - State(state): State>, - Json(payload): Json, -) -> Result { - let session_id = payload.session_id; - state - .agent_manager - .remove_session(&session_id) - .await - .map_err(|e| ErrorResponse { - message: format!("Failed to stop agent for session {}: {}", session_id, e), - status: StatusCode::NOT_FOUND, - })?; - - Ok(StatusCode::OK) -} - -async fn restart_agent_internal( - state: &Arc, - session_id: &str, - session: &Session, -) -> Result, ErrorResponse> { - state - .agent_manager - .remove_session_if_loaded(session_id) - .await - .map_err(|e| ErrorResponse { - message: format!("Failed to remove in-memory agent for session {session_id}: {e}"), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - let agent = state - .get_agent_for_route(session_id.to_string()) - .await - .map_err(|code| ErrorResponse { - message: "Failed to create new agent during restart".into(), - status: code, - })?; - - let provider_future = agent.restore_provider_from_session(session); - let extensions_future = agent.load_extensions_from_session(session); - - let (provider_result, extension_results) = tokio::join!(provider_future, extensions_future); - provider_result.map_err(|e| ErrorResponse { - message: e.to_string(), - status: StatusCode::INTERNAL_SERVER_ERROR, - })?; - - if let Some(ref recipe) = session.recipe { - if session.session_type == SessionType::Scheduled { - if let Some(prompt) = apply_recipe_to_agent(&agent, recipe, true).await { - agent - .extend_system_prompt("recipe".to_string(), prompt) - .await; - } - } else { - match build_recipe_with_parameter_values( - recipe, - session.user_recipe_values.clone().unwrap_or_default(), - ) - .await - { - Ok(Some(recipe)) => { - if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await { - agent - .extend_system_prompt("recipe".to_string(), prompt) - .await; - } - } - Ok(None) => { - // Recipe has missing parameters - } - Err(e) => { - return Err(ErrorResponse { - message: e.to_string(), - status: StatusCode::INTERNAL_SERVER_ERROR, - }); - } - } - } - } - - Ok(extension_results) -} - -#[utoipa::path( - post, - path = "/agent/restart", - request_body = RestartAgentRequest, - responses( - (status = 200, description = "Agent restarted successfully", body = RestartAgentResponse), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 404, description = "Session not found"), - (status = 500, description = "Internal server error") - ) -)] -async fn restart_agent( - State(state): State>, - Json(payload): Json, -) -> Result, ErrorResponse> { - let session_id = payload.session_id.clone(); - - let session = state - .session_manager() - .get_session(&session_id, false) - .await - .map_err(|err| { - error!("Failed to get session during restart: {}", err); - ErrorResponse { - message: format!("Failed to get session: {}", err), - status: StatusCode::NOT_FOUND, - } - })?; - - let extension_results = restart_agent_internal(&state, &session_id, &session).await?; - - Ok(Json(RestartAgentResponse { extension_results })) -} - -#[utoipa::path( - post, - path = "/agent/update_working_dir", - request_body = UpdateWorkingDirRequest, - responses( - (status = 200, description = "Working directory updated and agent restarted successfully"), - (status = 400, description = "Bad request - invalid directory path"), - (status = 401, description = "Unauthorized - invalid secret key"), - (status = 404, description = "Session not found"), - (status = 500, description = "Internal server error") - ) -)] -async fn update_working_dir( - State(state): State>, - Json(payload): Json, -) -> Result { - let session_id = payload.session_id.clone(); - let working_dir = payload.working_dir.trim(); - - if working_dir.is_empty() { - return Err(ErrorResponse { - message: "Working directory cannot be empty".into(), - status: StatusCode::BAD_REQUEST, - }); - } - - let path = PathBuf::from(working_dir); - if !path.exists() || !path.is_dir() { - return Err(ErrorResponse { - message: "Invalid directory path".into(), - status: StatusCode::BAD_REQUEST, - }); - } - - // Update the session's working directory - state - .session_manager() - .update(&session_id) - .working_dir(path) - .apply() - .await - .map_err(|e| { - error!("Failed to update session working directory: {}", e); - ErrorResponse { - message: format!("Failed to update working directory: {}", e), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - })?; - - // Get the updated session and restart the agent - let session = state - .session_manager() - .get_session(&session_id, false) - .await - .map_err(|err| { - error!("Failed to get session after working dir update: {}", err); - ErrorResponse { - message: format!("Failed to get session: {}", err), - status: StatusCode::NOT_FOUND, - } - })?; - - restart_agent_internal(&state, &session_id, &session).await?; - - Ok(StatusCode::OK) -} - -async fn ensure_extensions_loaded(state: &AppState, session_id: &str) -> Result<(), ErrorResponse> { - match state.take_extension_loading_task(session_id).await { - Ok(Some(_)) => { - tracing::debug!( - "Awaited background extension loading for session {} before serving request", - session_id - ); - state.remove_extension_loading_task(session_id).await; - Ok(()) - } - Ok(None) => Ok(()), - Err(e) => { - state.remove_extension_loading_task(session_id).await; - tracing::warn!( - "Background extension loading failed for session {}, retrying synchronously: {}", - session_id, - e - ); - let session = state - .session_manager() - .get_session(session_id, false) - .await - .map_err(|err| ErrorResponse { - message: format!( - "Failed to get session after extension loading failed: {}", - err - ), - status: StatusCode::NOT_FOUND, - })?; - let agent = state - .get_agent(session_id.to_string()) - .await - .map_err(|err| { - ErrorResponse::internal(format!( - "Failed to get agent after extension loading failed: {}", - err - )) - })?; - agent.load_extensions_from_session(&session).await; - Ok(()) - } - } -} - -pub fn routes(state: Arc) -> Router { - Router::new() - .route("/agent/start", post(start_agent)) - .route("/agent/resume", post(resume_agent)) - .route("/agent/restart", post(restart_agent)) - .route("/agent/update_working_dir", post(update_working_dir)) - .route("/agent/tools", get(get_tools)) - .route("/agent/update_provider", post(update_agent_provider)) - .route("/agent/update_session", post(update_session)) - .route("/agent/update_from_session", post(update_from_session)) - .route("/agent/add_extension", post(agent_add_extension)) - .route("/agent/remove_extension", post(agent_remove_extension)) - .route("/agent/set_container", post(set_container)) - .route("/agent/stop", post(stop_agent)) - .with_state(state) -} diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs deleted file mode 100644 index 828caea0ffd7..000000000000 --- a/crates/goose-server/src/routes/config_management.rs +++ /dev/null @@ -1,963 +0,0 @@ -use crate::routes::errors::ErrorResponse; -use crate::routes::utils::check_provider_configured; -use crate::state::AppState; -use axum::routing::put; -use axum::{ - extract::Path, - routing::{delete, get, post}, - Json, Router, -}; -use goose::config::declarative_providers::LoadedProvider; -use goose::config::paths::Paths; -use goose::config::ExtensionEntry; -use goose::config::{Config, ConfigError}; -use goose::custom_requests::SourceType; -use goose::providers::base::{ModelInfo, ProviderMetadata, ProviderType}; -use goose::providers::canonical::maybe_get_canonical_model; -use goose::providers::catalog::{ - get_provider_template, get_providers_by_format, ProviderCatalogEntry, ProviderFormat, - ProviderTemplate, -}; -use goose::providers::create_with_default_model; -use goose::providers::providers as get_providers; -use goose::{ - agents::execute_commands, agents::ExtensionConfig, slash_commands::recipe_slash_command, -}; -use goose_providers::model::ModelConfig; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use serde_yaml; -use std::{collections::HashMap, sync::Arc}; -use utoipa::ToSchema; - -#[derive(Serialize, ToSchema)] -pub struct ExtensionResponse { - pub extensions: Vec, - #[serde(default)] - pub warnings: Vec, -} - -#[derive(Deserialize, ToSchema)] -pub struct ExtensionQuery { - pub name: String, - pub config: ExtensionConfig, - pub enabled: bool, -} - -#[derive(Deserialize, ToSchema)] -pub struct UpsertConfigQuery { - pub key: String, - pub value: Value, - pub is_secret: bool, -} - -#[derive(Deserialize, Serialize, ToSchema)] -pub struct ConfigKeyQuery { - pub key: String, - pub is_secret: bool, -} - -#[derive(Serialize, ToSchema)] -pub struct ConfigResponse { - pub config: HashMap, -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct ProviderDetails { - pub name: String, - pub metadata: ProviderMetadata, - pub is_configured: bool, - pub provider_type: ProviderType, - #[serde(skip_serializing_if = "Option::is_none")] - pub saved_model: Option, -} - -#[derive(Serialize, ToSchema)] -pub struct ProvidersResponse { - pub providers: Vec, -} - -#[derive(Deserialize, ToSchema)] -pub struct UpdateCustomProviderRequest { - pub engine: String, - pub display_name: String, - pub api_url: String, - pub api_key: String, - pub models: Vec, - pub supports_streaming: Option, - pub headers: Option>, - #[serde(default = "default_requires_auth")] - pub requires_auth: bool, - #[serde(default)] - pub catalog_provider_id: Option, - #[serde(default)] - pub base_path: Option, - #[serde(default)] - pub preserves_thinking: Option, -} - -fn default_requires_auth() -> bool { - true -} - -fn normalize_custom_provider_api_key(api_key: String) -> Option { - let api_key = api_key.trim().to_string(); - (!api_key.is_empty()).then_some(api_key) -} - -#[derive(Deserialize, ToSchema)] -pub struct CheckProviderRequest { - pub provider: String, -} - -#[derive(Deserialize, ToSchema)] -pub struct SetProviderRequest { - pub provider: String, - pub model: String, -} - -#[derive(Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct MaskedSecret { - pub masked_value: String, -} - -#[derive(Serialize, ToSchema)] -#[serde(untagged)] -pub enum ConfigValueResponse { - Value(Value), - MaskedValue(MaskedSecret), -} - -pub use goose::providers::provider_secrets::{ - ProviderSecret, ProviderSecretStatus, ProviderSecretStorage, -}; - -#[derive(Debug, Serialize, ToSchema)] -pub struct ProviderSecretsResponse { - pub secrets: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub enum CommandType { - Builtin, - Recipe, - Skill, - Agent, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct SlashCommand { - pub command: String, - pub help: String, - pub command_type: CommandType, -} -#[derive(Serialize, ToSchema)] -pub struct SlashCommandsResponse { - pub commands: Vec, -} - -#[utoipa::path( - post, - path = "/config/upsert", - request_body = UpsertConfigQuery, - responses( - (status = 200, description = "Configuration value upserted successfully", body = String), - (status = 500, description = "Internal server error") - ) -)] -pub async fn upsert_config( - Json(query): Json, -) -> Result, ErrorResponse> { - let config = Config::global(); - - if query.key == "GOOSE_PROVIDER" { - if let Some(name) = query.value.as_str() { - let model = goose::config::get_provider_entry(config, name) - .map(|e| e.model) - .or_else(|| config.get_goose_model().ok()) - .unwrap_or_default(); - goose::config::set_active_provider(config, name, &model)?; - return Ok(Json(Value::String(format!("Upserted key {}", query.key)))); - } - } - if query.key == "GOOSE_MODEL" { - if let Some(model) = query.value.as_str() { - if let Ok(provider) = config.get_goose_provider() { - goose::config::set_active_provider(config, &provider, model)?; - return Ok(Json(Value::String(format!("Upserted key {}", query.key)))); - } - } - } - - config.set(&query.key, &query.value, query.is_secret)?; - Ok(Json(Value::String(format!("Upserted key {}", query.key)))) -} - -#[utoipa::path( - post, - path = "/config/remove", - request_body = ConfigKeyQuery, - responses( - (status = 200, description = "Configuration value removed successfully", body = String), - (status = 404, description = "Configuration key not found"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn remove_config( - Json(query): Json, -) -> Result, ErrorResponse> { - let config = Config::global(); - - if query.is_secret { - config.delete_secret(&query.key)?; - } else if query.key == "GOOSE_PROVIDER" || query.key == "active_provider" { - config.delete("active_provider")?; - config.delete("GOOSE_PROVIDER")?; - } else if query.key == "GOOSE_MODEL" { - if let Ok(provider) = config.get_goose_provider() { - goose::config::set_active_provider(config, &provider, "")?; - } - config.delete("GOOSE_MODEL")?; - } else { - config.delete(&query.key)?; - } - - Ok(Json(format!("Removed key {}", query.key))) -} - -const SECRET_MASK_SHOW_LEN: usize = 8; - -fn mask_secret(secret: Value) -> String { - let as_string = match secret { - Value::String(s) => s, - _ => serde_json::to_string(&secret).unwrap_or_else(|_| secret.to_string()), - }; - - let chars: Vec<_> = as_string.chars().collect(); - let show_len = std::cmp::min(chars.len() / 2, SECRET_MASK_SHOW_LEN); - let visible: String = chars.iter().take(show_len).collect(); - let mask = "*".repeat(chars.len() - show_len); - - format!("{}{}", visible, mask) -} - -#[utoipa::path( - get, - path = "/config/provider-secrets", - responses( - (status = 200, description = "Provider secrets retrieved successfully", body = ProviderSecretsResponse), - (status = 500, description = "Internal server error") - ) -)] -pub async fn list_provider_secrets() -> Result, ErrorResponse> { - let secrets = goose::providers::provider_secrets::list_provider_secrets().await?; - Ok(Json(ProviderSecretsResponse { secrets })) -} - -#[utoipa::path( - delete, - path = "/config/provider-secrets/{id}", - params( - ("id" = String, Path, description = "Provider secret identifier") - ), - responses( - (status = 200, description = "Provider secret deleted successfully", body = String), - (status = 400, description = "Invalid provider secret identifier"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn delete_provider_secret(Path(id): Path) -> Result, ErrorResponse> { - use goose::providers::provider_secrets::DeleteProviderSecretError; - - match goose::providers::provider_secrets::delete_provider_secret(&id).await { - Ok(()) => Ok(Json(format!("Deleted provider secret {}", id))), - Err(DeleteProviderSecretError::InvalidId(id)) => Err(ErrorResponse::bad_request(format!( - "Invalid provider secret id: '{}'", - id - ))), - Err(DeleteProviderSecretError::Config(e)) => Err(e.into()), - Err(DeleteProviderSecretError::Other(e)) => Err(ErrorResponse::internal(e.to_string())), - } -} - -#[utoipa::path( - post, - path = "/config/read", - request_body = ConfigKeyQuery, - responses( - (status = 200, description = "Configuration value retrieved successfully", body = Value), - (status = 500, description = "Unable to get the configuration value"), - ) -)] -pub async fn read_config( - Json(query): Json, -) -> Result, ErrorResponse> { - let config = Config::global(); - - if query.key == "GOOSE_PROVIDER" || query.key == "active_provider" { - if let Ok(val) = config.get_goose_provider() { - return Ok(Json(ConfigValueResponse::Value(Value::String(val)))); - } - return Ok(Json(ConfigValueResponse::Value(Value::Null))); - } - if query.key == "GOOSE_MODEL" { - if let Ok(val) = config.get_goose_model() { - return Ok(Json(ConfigValueResponse::Value(Value::String(val)))); - } - return Ok(Json(ConfigValueResponse::Value(Value::Null))); - } - - let response_value = match config.get(&query.key, query.is_secret) { - Ok(value) => { - if query.is_secret { - ConfigValueResponse::MaskedValue(MaskedSecret { - masked_value: mask_secret(value), - }) - } else { - ConfigValueResponse::Value(value) - } - } - Err(ConfigError::NotFound(_)) => ConfigValueResponse::Value(Value::Null), - Err(e) => return Err(e.into()), - }; - Ok(Json(response_value)) -} - -#[utoipa::path( - get, - path = "/config/extensions", - responses( - (status = 200, description = "All extensions retrieved successfully", body = ExtensionResponse), - (status = 500, description = "Internal server error") - ) -)] -pub async fn get_extensions() -> Result, ErrorResponse> { - let extensions = goose::config::get_all_extensions() - .into_iter() - .filter(|ext| !goose::agents::extension_manager::is_hidden_extension(&ext.config.name())) - .collect(); - let warnings = goose::config::get_warnings(); - Ok(Json(ExtensionResponse { - extensions, - warnings, - })) -} - -#[utoipa::path( - post, - path = "/config/extensions", - request_body = ExtensionQuery, - responses( - (status = 200, description = "Extension added or updated successfully", body = String), - (status = 400, description = "Invalid request"), - (status = 422, description = "Could not serialize config.yaml"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn add_extension( - Json(extension_query): Json, -) -> Result, ErrorResponse> { - let extensions = goose::config::get_all_extensions(); - let key = goose::config::extensions::name_to_key(&extension_query.name); - - let is_update = extensions.iter().any(|e| e.config.key() == key); - - goose::config::set_extension(ExtensionEntry { - enabled: extension_query.enabled, - config: extension_query.config, - }); - - if is_update { - Ok(Json(format!("Updated extension {}", extension_query.name))) - } else { - Ok(Json(format!("Added extension {}", extension_query.name))) - } -} - -#[utoipa::path( - delete, - path = "/config/extensions/{name}", - responses( - (status = 200, description = "Extension removed successfully", body = String), - (status = 404, description = "Extension not found"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn remove_extension(Path(name): Path) -> Result, ErrorResponse> { - let key = goose::config::extensions::name_to_key(&name); - goose::config::remove_extension(&key); - Ok(Json(format!("Removed extension {}", name))) -} - -#[utoipa::path( - get, - path = "/config", - responses( - (status = 200, description = "All configuration values retrieved successfully", body = ConfigResponse) - ) -)] -pub async fn read_all_config() -> Result, ErrorResponse> { - let config = Config::global(); - let values = config - .all_values() - .map_err(|e| ErrorResponse::unprocessable(e.to_string()))?; - Ok(Json(ConfigResponse { config: values })) -} - -#[utoipa::path( - get, - path = "/config/providers", - responses( - (status = 200, description = "All configuration values retrieved successfully", body = [ProviderDetails]) - ) -)] -pub async fn providers() -> Result>, ErrorResponse> { - let config = Config::global(); - let providers = get_providers().await; - let providers_response: Vec = providers - .into_iter() - .map(|(metadata, provider_type)| { - let is_configured = check_provider_configured(&metadata, provider_type); - let saved_model = goose::config::get_provider_entry(config, &metadata.name) - .map(|e| e.model) - .filter(|m| !m.is_empty()); - - ProviderDetails { - name: metadata.name.clone(), - metadata, - is_configured, - provider_type, - saved_model, - } - }) - .collect(); - - Ok(Json(providers_response)) -} - -#[utoipa::path( - get, - path = "/config/providers/{name}/models", - params( - ("name" = String, Path, description = "Provider name (e.g., openai)") - ), - responses( - (status = 200, description = "Models fetched successfully", body = [ModelInfo]), - (status = 400, description = "Unknown provider, provider not configured, or authentication error"), - (status = 429, description = "Rate limit exceeded"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn get_provider_models( - Path(name): Path, -) -> Result>, ErrorResponse> { - let all = get_providers().await.into_iter().collect::>(); - let Some((metadata, provider_type)) = all.into_iter().find(|(m, _)| m.name == name) else { - return Err(ErrorResponse::bad_request(format!( - "Unknown provider: {}", - name - ))); - }; - if !check_provider_configured(&metadata, provider_type) { - return Err(ErrorResponse::bad_request(format!( - "Provider '{}' is not configured", - name - ))); - } - - let provider = goose::providers::create(&name, Vec::new()).await?; - - let models_result = provider - .fetch_recommended_model_info(goose::model_config::global_toolshim()) - .await; - - match models_result { - Ok(models) => Ok(Json(models)), - Err(provider_error) => Err(provider_error.into()), - } -} - -#[derive(Deserialize, ToSchema)] -pub struct ProviderModelInfoQuery { - pub model: String, -} - -pub async fn resolve_provider_model_info( - name: &str, - model: &str, -) -> Result { - let all = get_providers().await.into_iter().collect::>(); - let Some((metadata, provider_type)) = all.into_iter().find(|(m, _)| m.name == name) else { - return Err(ErrorResponse::bad_request(format!( - "Unknown provider: {}", - name - ))); - }; - if !check_provider_configured(&metadata, provider_type) { - return Err(ErrorResponse::bad_request(format!( - "Provider '{}' is not configured", - name - ))); - } - - let entry = goose::providers::get_from_registry(name).await?; - let model_config = entry.normalize_model_config(ModelConfig::new(model))?; - let provider = goose::providers::create(name, Vec::new()).await?; - match provider.fetch_model_info(model).await { - Ok(mut info) => { - if let Some(limit) = model_config.context_limit { - info.context_limit = limit; - } - Ok(info) - } - Err(error) => { - let mut info = ModelInfo::new(model, model_config.context_limit()); - info.reasoning = model_config.is_reasoning_model(); - tracing::debug!( - provider = name, - model, - error = %error, - "Falling back to local model metadata" - ); - Ok(info) - } - } -} - -#[utoipa::path( - post, - path = "/config/providers/{name}/model-info", - params( - ("name" = String, Path, description = "Provider name (e.g., openai)") - ), - request_body = ProviderModelInfoQuery, - responses( - (status = 200, description = "Model metadata fetched successfully", body = ModelInfo), - (status = 400, description = "Unknown provider, provider not configured, or authentication error"), - (status = 429, description = "Rate limit exceeded"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn get_provider_model_info( - Path(name): Path, - Json(query): Json, -) -> Result, ErrorResponse> { - resolve_provider_model_info(&name, &query.model) - .await - .map(Json) -} - -#[derive(Deserialize, utoipa::IntoParams)] -pub struct SlashCommandsQuery { - /// Optional working directory to discover local skills from - pub working_dir: Option, -} - -#[utoipa::path( - get, - path = "/config/slash_commands", - params(SlashCommandsQuery), - responses( - (status = 200, description = "Slash commands retrieved successfully", body = SlashCommandsResponse) - ) -)] -pub async fn get_slash_commands( - axum::extract::Query(query): axum::extract::Query, -) -> Result, ErrorResponse> { - let mut commands: Vec<_> = recipe_slash_command::list_commands() - .iter() - .map(|command| SlashCommand { - command: command.command.clone(), - help: command.recipe_path.clone(), - command_type: CommandType::Recipe, - }) - .collect(); - - for cmd_def in execute_commands::list_commands() { - commands.push(SlashCommand { - command: cmd_def.name.to_string(), - help: cmd_def.description.to_string(), - command_type: CommandType::Builtin, - }); - } - - let working_dir = query.working_dir.map(std::path::PathBuf::from); - for source in goose::skills::list_installed_skills(working_dir.as_deref()) { - commands.push(SlashCommand { - command: source.name, - help: source.description, - command_type: CommandType::Skill, - }); - } - - let discover_dir = working_dir - .as_deref() - .unwrap_or_else(|| std::path::Path::new(".")); - for source in - goose::agents::platform_extensions::summon::discover_filesystem_sources(discover_dir) - { - if matches!( - source.source_type, - SourceType::Agent | SourceType::Recipe | SourceType::Subrecipe - ) && !source.content.is_empty() - { - commands.push(SlashCommand { - command: source.name, - help: source.description, - command_type: CommandType::Agent, - }); - } - } - - Ok(Json(SlashCommandsResponse { commands })) -} - -#[derive(Serialize, ToSchema)] -pub struct ModelInfoData { - pub provider: String, - pub model: String, - pub context_limit: usize, - pub max_output_tokens: Option, - pub reasoning: bool, - pub input_token_cost: Option, - pub output_token_cost: Option, - pub cache_read_token_cost: Option, - pub cache_write_token_cost: Option, - pub currency: String, -} - -#[derive(Serialize, ToSchema)] -pub struct ModelInfoResponse { - pub model_info: Option, - pub source: String, -} - -#[derive(Deserialize, ToSchema)] -pub struct ModelInfoQuery { - pub provider: String, - pub model: String, -} - -#[utoipa::path( - post, - path = "/config/canonical-model-info", - request_body = ModelInfoQuery, - responses( - (status = 200, description = "Model information retrieved successfully", body = ModelInfoResponse) - ) -)] -pub async fn get_canonical_model_info( - Json(query): Json, -) -> Json { - let canonical_model = maybe_get_canonical_model(&query.provider, &query.model); - - let model_info = canonical_model.map(|canonical_model| ModelInfoData { - provider: query.provider.clone(), - model: query.model.clone(), - context_limit: canonical_model.limit.context, - max_output_tokens: canonical_model.limit.output, - reasoning: canonical_model - .reasoning - .unwrap_or_else(|| ModelConfig::new(&query.model).is_reasoning_model()), - // Costs are per million tokens - client handles division for display - input_token_cost: canonical_model.cost.input, - output_token_cost: canonical_model.cost.output, - cache_read_token_cost: canonical_model.cost.cache_read, - cache_write_token_cost: canonical_model.cost.cache_write, - currency: "$".to_string(), - }); - - Json(ModelInfoResponse { - model_info, - source: "canonical".to_string(), - }) -} - -#[utoipa::path( - get, - path = "/config/validate", - responses( - (status = 200, description = "Config validation result", body = String), - (status = 422, description = "Config file is corrupted") - ) -)] -pub async fn validate_config() -> Result, ErrorResponse> { - let config_path = Paths::config_dir().join("config.yaml"); - - if !config_path.exists() { - return Ok(Json("Config file does not exist".to_string())); - } - - let content = std::fs::read_to_string(&config_path)?; - serde_yaml::from_str::(&content) - .map_err(|e| ErrorResponse::unprocessable(format!("Config file is corrupted: {}", e)))?; - - Ok(Json("Config file is valid".to_string())) -} -#[derive(Serialize, ToSchema)] -pub struct CreateCustomProviderResponse { - pub provider_name: String, -} - -#[utoipa::path( - post, - path = "/config/custom-providers", - request_body = UpdateCustomProviderRequest, - responses( - (status = 200, description = "Custom provider created successfully", body = CreateCustomProviderResponse), - (status = 400, description = "Invalid request"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn create_custom_provider( - Json(request): Json, -) -> Result, ErrorResponse> { - let config = goose::config::declarative_providers::create_custom_provider( - goose::config::declarative_providers::CreateCustomProviderParams { - engine: request.engine, - display_name: request.display_name, - api_url: request.api_url, - api_key: normalize_custom_provider_api_key(request.api_key), - models: request.models, - supports_streaming: request.supports_streaming, - headers: request.headers, - requires_auth: request.requires_auth, - catalog_provider_id: request.catalog_provider_id, - base_path: request.base_path, - preserves_thinking: request.preserves_thinking, - }, - )?; - - goose::providers::refresh_custom_providers().await?; - - Ok(Json(CreateCustomProviderResponse { - provider_name: config.id().to_string(), - })) -} - -#[utoipa::path( - get, - path = "/config/custom-providers/{id}", - responses( - (status = 200, description = "Custom provider retrieved successfully", body = LoadedProvider), - (status = 404, description = "Provider not found"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn get_custom_provider( - Path(id): Path, -) -> Result, ErrorResponse> { - let loaded_provider = goose::config::declarative_providers::load_provider(id.as_str()) - .map_err(|e| { - ErrorResponse::not_found(format!("Custom provider '{}' not found: {}", id, e)) - })?; - - Ok(Json(loaded_provider)) -} - -#[utoipa::path( - delete, - path = "/config/custom-providers/{id}", - responses( - (status = 200, description = "Custom provider removed successfully", body = String), - (status = 404, description = "Provider not found"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn remove_custom_provider(Path(id): Path) -> Result, ErrorResponse> { - goose::config::declarative_providers::remove_custom_provider(&id)?; - - goose::providers::refresh_custom_providers().await?; - - Ok(Json(format!("Removed custom provider: {}", id))) -} - -#[utoipa::path( - post, - path = "/config/providers/{name}/cleanup", - params( - ("name" = String, Path, description = "Provider name (e.g., githubcopilot)") - ), - responses( - (status = 200, description = "Provider cache cleaned up successfully", body = String), - (status = 500, description = "Internal server error") - ) -)] -pub async fn cleanup_provider_cache( - Path(name): Path, -) -> Result, ErrorResponse> { - goose::providers::cleanup_provider(&name).await?; - Ok(Json(format!("Cleaned up provider cache: {}", name))) -} - -#[utoipa::path( - put, - path = "/config/custom-providers/{id}", - request_body = UpdateCustomProviderRequest, - responses( - (status = 200, description = "Custom provider updated successfully", body = String), - (status = 404, description = "Provider not found"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn update_custom_provider( - Path(id): Path, - Json(request): Json, -) -> Result, ErrorResponse> { - goose::config::declarative_providers::update_custom_provider( - goose::config::declarative_providers::UpdateCustomProviderParams { - id: id.clone(), - engine: request.engine, - display_name: request.display_name, - api_url: request.api_url, - api_key: normalize_custom_provider_api_key(request.api_key), - models: request.models, - supports_streaming: request.supports_streaming, - headers: request.headers, - requires_auth: request.requires_auth, - catalog_provider_id: request.catalog_provider_id, - base_path: request.base_path, - preserves_thinking: request.preserves_thinking, - }, - )?; - - goose::providers::refresh_custom_providers().await?; - - Ok(Json(format!("Updated custom provider: {}", id))) -} - -#[utoipa::path( - post, - path = "/config/check_provider", - request_body = CheckProviderRequest, -)] -pub async fn check_provider( - Json(CheckProviderRequest { provider }): Json, -) -> Result<(), ErrorResponse> { - create_with_default_model(&provider, Vec::new()) - .await - .map_err(|err| { - ErrorResponse::bad_request(format!("Provider '{}' check failed: {}", provider, err)) - })?; - Ok(()) -} - -#[utoipa::path( - post, - path = "/config/set_provider", - request_body = SetProviderRequest, -)] -pub async fn set_config_provider( - Json(SetProviderRequest { provider, model }): Json, -) -> Result<(), ErrorResponse> { - create_with_default_model(&provider, Vec::new()) - .await - .and_then(|_| { - let config = Config::global(); - goose::config::set_active_provider(config, &provider, &model) - .map_err(|e| anyhow::anyhow!(e)) - }) - .map_err(|err| { - ErrorResponse::bad_request(format!( - "Failed to set provider to '{}' with model '{}': {}", - provider, model, err - )) - })?; - Ok(()) -} - -#[utoipa::path( - get, - path = "/config/provider-catalog", - params( - ("format" = Option, Query, description = "Filter by provider format (openai, anthropic, ollama)") - ), - responses( - (status = 200, description = "Provider catalog retrieved successfully", body = [ProviderCatalogEntry]), - (status = 400, description = "Invalid format parameter") - ) -)] -pub async fn get_provider_catalog( - axum::extract::Query(params): axum::extract::Query>, -) -> Result>, ErrorResponse> { - let format_str = params.get("format").map(|s| s.as_str()).unwrap_or("openai"); - - let format = format_str.parse::().map_err(|_| { - ErrorResponse::bad_request(format!( - "Invalid format '{}'. Must be one of: openai, anthropic, ollama", - format_str - )) - })?; - - let providers = get_providers_by_format(format).await; - Ok(Json(providers)) -} - -#[utoipa::path( - get, - path = "/config/provider-catalog/{id}", - params( - ("id" = String, Path, description = "Provider ID from models.dev") - ), - responses( - (status = 200, description = "Provider template retrieved successfully", body = ProviderTemplate), - (status = 404, description = "Provider not found in catalog") - ) -)] -pub async fn get_provider_catalog_template( - Path(id): Path, -) -> Result, ErrorResponse> { - let template = get_provider_template(&id).ok_or_else(|| { - ErrorResponse::not_found(format!("Provider '{}' not found in catalog", id)) - })?; - - Ok(Json(template)) -} - -pub fn routes(state: Arc) -> Router { - Router::new() - .route("/config", get(read_all_config)) - .route("/config/upsert", post(upsert_config)) - .route("/config/remove", post(remove_config)) - .route("/config/read", post(read_config)) - .route("/config/provider-secrets", get(list_provider_secrets)) - .route( - "/config/provider-secrets/{id}", - delete(delete_provider_secret), - ) - .route("/config/extensions", get(get_extensions)) - .route("/config/extensions", post(add_extension)) - .route("/config/extensions/{name}", delete(remove_extension)) - .route("/config/providers", get(providers)) - .route("/config/providers/{name}/models", get(get_provider_models)) - .route( - "/config/providers/{name}/model-info", - post(get_provider_model_info), - ) - .route("/config/provider-catalog", get(get_provider_catalog)) - .route( - "/config/provider-catalog/{id}", - get(get_provider_catalog_template), - ) - .route( - "/config/providers/{name}/cleanup", - post(cleanup_provider_cache), - ) - .route("/config/slash_commands", get(get_slash_commands)) - .route( - "/config/canonical-model-info", - post(get_canonical_model_info), - ) - .route("/config/validate", get(validate_config)) - .route("/config/custom-providers", post(create_custom_provider)) - .route( - "/config/custom-providers/{id}", - delete(remove_custom_provider), - ) - .route("/config/custom-providers/{id}", put(update_custom_provider)) - .route("/config/custom-providers/{id}", get(get_custom_provider)) - .route("/config/check_provider", post(check_provider)) - .route("/config/set_provider", post(set_config_provider)) - .with_state(state) -} diff --git a/crates/goose-server/src/routes/dictation.rs b/crates/goose-server/src/routes/dictation.rs deleted file mode 100644 index 78602223e4a6..000000000000 --- a/crates/goose-server/src/routes/dictation.rs +++ /dev/null @@ -1,374 +0,0 @@ -use crate::routes::errors::ErrorResponse; -use crate::state::AppState; -use axum::{ - extract::DefaultBodyLimit, - http::StatusCode, - routing::{get, post}, - Json, Router, -}; -#[cfg(feature = "local-inference")] -use axum::{extract::Path, routing::delete}; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -#[cfg(feature = "local-inference")] -use goose::dictation::providers::transcribe_local; -use goose::dictation::providers::{ - all_providers, is_configured, transcribe_with_provider, DictationProvider, -}; -#[cfg(feature = "local-inference")] -use goose::dictation::whisper; -#[cfg(feature = "local-inference")] -use goose::download_manager::{get_download_manager, DownloadProgress}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; -use utoipa::ToSchema; - -const MAX_AUDIO_SIZE_BYTES: usize = 50 * 1024 * 1024; - -#[cfg(feature = "local-inference")] -#[derive(Debug, Serialize, ToSchema)] -pub struct WhisperModelResponse { - #[serde(flatten)] - #[schema(inline)] - model: &'static whisper::WhisperModel, - downloaded: bool, - recommended: bool, -} - -#[derive(Debug, Deserialize, ToSchema)] -pub struct TranscribeRequest { - /// Base64 encoded audio data - pub audio: String, - /// MIME type of the audio (e.g., "audio/webm", "audio/wav") - pub mime_type: String, - /// Transcription provider to use - pub provider: DictationProvider, -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct TranscribeResponse { - /// Transcribed text from the audio - pub text: String, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct DictationProviderStatus { - /// Whether the provider is fully configured and ready to use - pub configured: bool, - /// Custom host URL if configured (only for providers that support it) - #[serde(skip_serializing_if = "Option::is_none")] - pub host: Option, - /// Description of what this provider does - pub description: String, - /// Whether this provider uses the main provider config (true) or has its own key (false) - pub uses_provider_config: bool, - /// Path to settings if uses_provider_config is true - #[serde(skip_serializing_if = "Option::is_none")] - pub settings_path: Option, - /// Config key name if uses_provider_config is false - #[serde(skip_serializing_if = "Option::is_none")] - pub config_key: Option, -} - -fn validate_audio(audio: &str, mime_type: &str) -> Result<(Vec, &'static str), ErrorResponse> { - let audio_bytes = BASE64 - .decode(audio) - .map_err(|_| ErrorResponse::bad_request("Invalid base64 audio data"))?; - - let extension = match mime_type { - "audio/webm" | "audio/webm;codecs=opus" => "webm", - "audio/mp4" => "mp4", - "audio/mpeg" | "audio/mpga" => "mp3", - "audio/m4a" => "m4a", - "audio/wav" | "audio/x-wav" => "wav", - _ => { - return Err(ErrorResponse { - message: format!("Unsupported audio format: {}", mime_type), - status: StatusCode::UNSUPPORTED_MEDIA_TYPE, - }); - } - }; - - Ok((audio_bytes, extension)) -} - -fn convert_error(e: anyhow::Error) -> ErrorResponse { - let error_msg = e.to_string(); - - if error_msg.contains("Invalid API key") { - ErrorResponse { - message: error_msg, - status: StatusCode::UNAUTHORIZED, - } - } else if error_msg.contains("Rate limit exceeded") || error_msg.contains("quota") { - ErrorResponse { - message: error_msg, - status: StatusCode::TOO_MANY_REQUESTS, - } - } else if error_msg.contains("not configured") { - ErrorResponse { - message: error_msg, - status: StatusCode::PRECONDITION_FAILED, - } - } else if error_msg.contains("timeout") { - ErrorResponse { - message: error_msg, - status: StatusCode::GATEWAY_TIMEOUT, - } - } else if error_msg.contains("API error") { - ErrorResponse { - message: error_msg, - status: StatusCode::BAD_GATEWAY, - } - } else { - ErrorResponse::internal(error_msg) - } -} - -#[utoipa::path( - post, - path = "/dictation/transcribe", - request_body = TranscribeRequest, - responses( - (status = 200, description = "Audio transcribed successfully", body = TranscribeResponse), - (status = 400, description = "Invalid request (bad base64 or unsupported format)"), - (status = 401, description = "Invalid API key"), - (status = 412, description = "Provider not configured"), - (status = 413, description = "Audio file too large (max 50MB)"), - (status = 429, description = "Rate limit exceeded"), - (status = 500, description = "Internal server error"), - (status = 502, description = "Provider API error"), - (status = 503, description = "Service unavailable"), - (status = 504, description = "Request timeout") - ) -)] -pub async fn transcribe_dictation( - Json(request): Json, -) -> Result, ErrorResponse> { - let (audio_bytes, extension) = validate_audio(&request.audio, &request.mime_type)?; - - let text = match request.provider { - DictationProvider::OpenAI => transcribe_with_provider( - DictationProvider::OpenAI, - "model".to_string(), - "whisper-1".to_string(), - audio_bytes, - extension, - &request.mime_type, - ) - .await - .map_err(convert_error)?, - DictationProvider::Groq => transcribe_with_provider( - DictationProvider::Groq, - "model".to_string(), - "whisper-large-v3-turbo".to_string(), - audio_bytes, - extension, - &request.mime_type, - ) - .await - .map_err(convert_error)?, - DictationProvider::ElevenLabs => transcribe_with_provider( - DictationProvider::ElevenLabs, - "model_id".to_string(), - "scribe_v1".to_string(), - audio_bytes, - extension, - &request.mime_type, - ) - .await - .map_err(convert_error)?, - #[cfg(feature = "local-inference")] - DictationProvider::Local => transcribe_local(audio_bytes).await.map_err(convert_error)?, - }; - - Ok(Json(TranscribeResponse { text })) -} - -#[utoipa::path( - get, - path = "/dictation/config", - responses( - (status = 200, description = "Audio transcription provider configurations", body = HashMap) - ) -)] -pub async fn get_dictation_config( -) -> Result>, ErrorResponse> { - let config = goose::config::Config::global(); - let mut providers = HashMap::new(); - - for def in all_providers() { - let provider = def.provider; - let configured = is_configured(provider); - - let host = if let Some(host_key) = def.host_key { - config - .get(host_key, false) - .ok() - .and_then(|v| v.as_str().map(|s| s.to_string())) - } else { - None - }; - - providers.insert( - provider, - DictationProviderStatus { - configured, - host, - description: def.description.to_string(), - uses_provider_config: def.uses_provider_config, - settings_path: def.settings_path.map(|s| s.to_string()), - config_key: if !def.uses_provider_config { - Some(def.config_key.to_string()) - } else { - None - }, - }, - ); - } - - Ok(Json(providers)) -} - -#[cfg(feature = "local-inference")] -#[utoipa::path( - get, - path = "/dictation/models", - responses( - (status = 200, description = "List of available Whisper models", body = Vec) - ) -)] -pub async fn list_models() -> Result>, ErrorResponse> { - let recommended_id = whisper::recommend_model(); - let models = whisper::available_models() - .iter() - .map(|m| WhisperModelResponse { - model: m, - downloaded: m.is_downloaded(), - recommended: m.id == recommended_id, - }) - .collect(); - - Ok(Json(models)) -} - -#[cfg(feature = "local-inference")] -#[utoipa::path( - post, - path = "/dictation/models/{model_id}/download", - responses( - (status = 202, description = "Download started"), - (status = 400, description = "Download already in progress"), - (status = 500, description = "Internal server error") - ) -)] -pub async fn download_model(Path(model_id): Path) -> Result { - let model = whisper::get_model(&model_id) - .ok_or_else(|| ErrorResponse::bad_request("Model not found"))?; - - let manager = get_download_manager(); - let model_id_for_config = model.id.to_string(); - manager - .download_model( - model.id.to_string(), - model.url.to_string(), - model.local_path(), - Some(Box::new(move || { - let _ = goose::config::Config::global() - .set_param(whisper::LOCAL_WHISPER_MODEL_CONFIG_KEY, model_id_for_config); - })), - ) - .await - .map_err(convert_error)?; - - Ok(StatusCode::ACCEPTED) -} - -#[cfg(feature = "local-inference")] -#[utoipa::path( - get, - path = "/dictation/models/{model_id}/download", - responses( - (status = 200, description = "Download progress", body = DownloadProgress), - (status = 404, description = "Download not found") - ) -)] -pub async fn get_download_progress( - Path(model_id): Path, -) -> Result, ErrorResponse> { - let manager = get_download_manager(); - let progress = manager - .get_progress(&model_id) - .ok_or_else(|| ErrorResponse::bad_request("Download not found"))?; - - Ok(Json(progress)) -} - -#[cfg(feature = "local-inference")] -#[utoipa::path( - delete, - path = "/dictation/models/{model_id}/download", - responses( - (status = 200, description = "Download cancelled"), - (status = 404, description = "Download not found") - ) -)] -pub async fn cancel_download(Path(model_id): Path) -> Result { - let manager = get_download_manager(); - manager.cancel_download(&model_id).map_err(convert_error)?; - Ok(StatusCode::OK) -} - -#[cfg(feature = "local-inference")] -#[utoipa::path( - delete, - path = "/dictation/models/{model_id}", - responses( - (status = 200, description = "Model deleted"), - (status = 404, description = "Model not found or not downloaded"), - (status = 500, description = "Failed to delete model") - ) -)] -pub async fn delete_model(Path(model_id): Path) -> Result { - let model = whisper::get_model(&model_id) - .ok_or_else(|| ErrorResponse::bad_request("Model not found"))?; - - let path = model.local_path(); - - if !path.exists() { - return Err(ErrorResponse::bad_request("Model not downloaded")); - } - - tokio::fs::remove_file(&path) - .await - .map_err(|e| ErrorResponse::internal(format!("Failed to delete model: {}", e)))?; - - Ok(StatusCode::OK) -} - -pub fn routes(state: Arc) -> Router { - let router = Router::new() - .route("/dictation/transcribe", post(transcribe_dictation)) - .route("/dictation/config", get(get_dictation_config)); - - #[cfg(feature = "local-inference")] - let router = router - .route("/dictation/models", get(list_models)) - .route( - "/dictation/models/{model_id}/download", - post(download_model), - ) - .route( - "/dictation/models/{model_id}/download", - get(get_download_progress), - ) - .route( - "/dictation/models/{model_id}/download", - delete(cancel_download), - ) - .route("/dictation/models/{model_id}", delete(delete_model)); - - router - .layer(DefaultBodyLimit::max(MAX_AUDIO_SIZE_BYTES)) - .with_state(state) -} diff --git a/crates/goose-server/src/routes/errors.rs b/crates/goose-server/src/routes/errors.rs deleted file mode 100644 index ab78df291ad3..000000000000 --- a/crates/goose-server/src/routes/errors.rs +++ /dev/null @@ -1,129 +0,0 @@ -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use goose::config::ConfigError; -use goose_providers::errors::ProviderError; -use serde::Serialize; -use utoipa::ToSchema; - -#[derive(Debug, Serialize, ToSchema)] -pub struct ErrorResponse { - pub message: String, - #[serde(skip)] - pub status: StatusCode, -} - -impl ErrorResponse { - pub(crate) fn internal(message: impl Into) -> Self { - Self { - message: message.into(), - status: StatusCode::INTERNAL_SERVER_ERROR, - } - } - - pub(crate) fn bad_request(message: impl Into) -> Self { - Self { - message: message.into(), - status: StatusCode::BAD_REQUEST, - } - } - - pub(crate) fn not_found(message: impl Into) -> Self { - Self { - message: message.into(), - status: StatusCode::NOT_FOUND, - } - } - - pub(crate) fn unprocessable(message: impl Into) -> Self { - Self { - message: message.into(), - status: StatusCode::UNPROCESSABLE_ENTITY, - } - } -} - -impl IntoResponse for ErrorResponse { - fn into_response(self) -> Response { - if self.status.is_server_error() { - tracing::error!(status = %self.status, message = %self.message, "server error response"); - } else if self.status.is_client_error() { - tracing::warn!(status = %self.status, message = %self.message, "client error response"); - } - - let body = Json(serde_json::json!({ - "message": self.message, - })); - - (self.status, body).into_response() - } -} - -impl From for ErrorResponse { - fn from(err: anyhow::Error) -> Self { - Self::internal(err.to_string()) - } -} - -impl From for ErrorResponse { - fn from(err: ConfigError) -> Self { - match err { - ConfigError::NotFound(key) => Self::not_found(format!("Config key not found: {}", key)), - _ => Self::internal(err.to_string()), - } - } -} - -impl From for ErrorResponse { - fn from(status: StatusCode) -> Self { - let message = status.canonical_reason().unwrap_or("Unknown error"); - Self { - message: message.to_string(), - status, - } - } -} - -impl From for ErrorResponse { - fn from(err: std::io::Error) -> Self { - Self::internal(format!("IO error: {}", err)) - } -} - -impl From for ErrorResponse { - fn from(err: serde_json::Error) -> Self { - Self::internal(format!("JSON serialization error: {}", err)) - } -} - -impl From for ErrorResponse { - fn from(err: serde_yaml::Error) -> Self { - Self::unprocessable(format!("YAML parsing error: {}", err)) - } -} - -impl From for ErrorResponse { - fn from(err: ProviderError) -> Self { - let (status, message) = match err { - ProviderError::Authentication(_) => ( - StatusCode::BAD_REQUEST, - format!("Authentication failed: {}", err), - ), - ProviderError::UsageError(_) => { - (StatusCode::BAD_REQUEST, format!("Usage error: {}", err)) - } - ProviderError::RateLimitExceeded { .. } => ( - StatusCode::TOO_MANY_REQUESTS, - format!("Rate limit exceeded: {}", err), - ), - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Provider error: {}", err), - ), - }; - - Self { message, status } - } -} diff --git a/crates/goose-server/src/routes/mcp_app_proxy.rs b/crates/goose-server/src/routes/mcp_app_proxy.rs deleted file mode 100644 index 2a4a7502cf0d..000000000000 --- a/crates/goose-server/src/routes/mcp_app_proxy.rs +++ /dev/null @@ -1,295 +0,0 @@ -use axum::{ - extract::Query, - http::{header, StatusCode}, - response::{Html, IntoResponse, Response}, - routing::{get, post}, - Json, Router, -}; -use serde::Deserialize; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Instant; -use tokio::sync::RwLock; -use uuid::Uuid; - -const GUEST_HTML_TTL_SECS: u64 = 300; // 5 minutes -const GUEST_HTML_MAX_ENTRIES: usize = 64; - -/// In-memory store for guest HTML content. -/// Maps nonce -> (html_content, csp_string, created_at) -/// Entries are consumed on first read and evicted after TTL. -type GuestHtmlStore = Arc>>; - -#[derive(Deserialize)] -struct ProxyQuery { - secret: String, - /// Comma-separated list of domains for connect-src (fetch, XHR, WebSocket) - connect_domains: Option, - /// Comma-separated list of domains for resource loading (scripts, styles, images, fonts, media) - resource_domains: Option, - /// Comma-separated list of origins for nested iframes (frame-src) - frame_domains: Option, - /// Comma-separated list of allowed base URIs (base-uri) - base_uri_domains: Option, - /// Comma-separated list of domains for script-src (external scripts like SDKs) - script_domains: Option, -} - -#[derive(Deserialize)] -struct GuestQuery { - secret: String, - nonce: String, -} - -#[derive(Deserialize)] -struct StoreGuestBody { - secret: String, - html: String, - /// CSP string to apply to the guest page - csp: Option, -} - -const MCP_APP_PROXY_HTML: &str = include_str!("templates/mcp_app_proxy.html"); - -/// Build the outer sandbox CSP based on declared domains. -/// -/// This CSP acts as a ceiling - the inner guest UI iframe cannot exceed these -/// permissions, even if it tried. This is the single source of truth for -/// security policy enforcement. -/// -/// Based on the MCP Apps specification (ext-apps SEP): -/// -fn build_outer_csp( - connect_domains: &[String], - resource_domains: &[String], - frame_domains: &[String], - base_uri_domains: &[String], - script_domains: &[String], -) -> String { - let resources = if resource_domains.is_empty() { - String::new() - } else { - format!(" {}", resource_domains.join(" ")) - }; - - let scripts = if script_domains.is_empty() { - String::new() - } else { - format!(" {}", script_domains.join(" ")) - }; - - let connections = if connect_domains.is_empty() { - String::new() - } else { - format!(" {}", connect_domains.join(" ")) - }; - - // frame-src needs 'self' so the proxy can load the guest iframe from /mcp-app-guest - let frame_src = if frame_domains.is_empty() { - "frame-src 'self'".to_string() - } else { - format!("frame-src 'self' {}", frame_domains.join(" ")) - }; - - let base_uris = if base_uri_domains.is_empty() { - String::new() - } else { - format!(" {}", base_uri_domains.join(" ")) - }; - - format!( - "default-src 'none'; \ - script-src 'self' 'unsafe-inline'{resources}{scripts}; \ - script-src-elem 'self' 'unsafe-inline'{resources}{scripts}; \ - style-src 'self' 'unsafe-inline'{resources}; \ - style-src-elem 'self' 'unsafe-inline'{resources}; \ - connect-src 'self'{connections}; \ - img-src 'self' data: blob:{resources}; \ - font-src 'self'{resources}; \ - media-src 'self' data: blob:{resources}; \ - {frame_src}; \ - object-src 'none'; \ - base-uri 'self'{base_uris}" - ) -} - -/// Parse comma-separated domains, filtering out empty strings -fn parse_domains(domains: Option<&String>) -> Vec { - domains - .map(|d| { - d.split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() - }) - .unwrap_or_default() -} - -#[derive(Clone)] -struct AppState { - secret_key: String, - guest_store: GuestHtmlStore, -} - -#[utoipa::path( - get, - path = "/mcp-app-proxy", - params( - ("secret" = String, Query, description = "Secret key for authentication"), - ("connect_domains" = Option, Query, description = "Comma-separated domains for connect-src"), - ("resource_domains" = Option, Query, description = "Comma-separated domains for resource loading"), - ("frame_domains" = Option, Query, description = "Comma-separated origins for nested iframes (frame-src)"), - ("base_uri_domains" = Option, Query, description = "Comma-separated allowed base URIs (base-uri)"), - ("script_domains" = Option, Query, description = "Comma-separated domains for script-src") - ), - responses( - (status = 200, description = "MCP App proxy HTML page", content_type = "text/html"), - (status = 401, description = "Unauthorized - invalid or missing secret"), - ) -)] -async fn mcp_app_proxy( - axum::extract::State(state): axum::extract::State, - Query(params): Query, -) -> Response { - if params.secret != state.secret_key { - return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); - } - - // Parse domains from query params - let connect_domains = parse_domains(params.connect_domains.as_ref()); - let resource_domains = parse_domains(params.resource_domains.as_ref()); - let frame_domains = parse_domains(params.frame_domains.as_ref()); - let base_uri_domains = parse_domains(params.base_uri_domains.as_ref()); - let script_domains = parse_domains(params.script_domains.as_ref()); - - // Build the outer CSP based on declared domains - let csp = build_outer_csp( - &connect_domains, - &resource_domains, - &frame_domains, - &base_uri_domains, - &script_domains, - ); - - // Replace the CSP placeholder in the HTML template - let html = MCP_APP_PROXY_HTML.replace("{{OUTER_CSP}}", &csp); - - ( - [ - (header::CONTENT_TYPE, "text/html; charset=utf-8"), - ( - header::HeaderName::from_static("referrer-policy"), - "no-referrer", - ), - ], - Html(html), - ) - .into_response() -} - -/// Store guest HTML and return a nonce for retrieval. -/// The proxy page calls this via fetch, then sets the guest iframe src to /mcp-app-guest?nonce=... -async fn store_guest_html( - axum::extract::State(state): axum::extract::State, - Json(body): Json, -) -> Response { - if body.secret != state.secret_key { - return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); - } - - let nonce = Uuid::new_v4().to_string(); - let csp = body.csp.unwrap_or_default(); - - { - let mut store = state.guest_store.write().await; - - // Evict expired entries - let cutoff = Instant::now() - std::time::Duration::from_secs(GUEST_HTML_TTL_SECS); - store.retain(|_, (_, _, created)| *created > cutoff); - - // If still at capacity, drop the oldest entry - if store.len() >= GUEST_HTML_MAX_ENTRIES { - if let Some(oldest_key) = store - .iter() - .min_by_key(|(_, (_, _, created))| *created) - .map(|(k, _)| k.clone()) - { - store.remove(&oldest_key); - } - } - - store.insert(nonce.clone(), (body.html, csp, Instant::now())); - } - - ( - StatusCode::OK, - [(header::CONTENT_TYPE, "application/json")], - format!(r#"{{"nonce":"{}"}}"#, nonce), - ) - .into_response() -} - -/// Serve stored guest HTML with a real HTTPS URL. -/// This gives the guest iframe `window.location.protocol === "https:"`, -/// which is required by SDKs like Square Web Payments that check for secure context. -async fn serve_guest_html( - axum::extract::State(state): axum::extract::State, - Query(params): Query, -) -> Response { - if params.secret != state.secret_key { - return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); - } - - // Consume the entry (one-time use) - let entry = { - let mut store = state.guest_store.write().await; - store.remove(¶ms.nonce) - }; - - match entry { - Some((html, csp, _created)) => { - let mut response = Html(html).into_response(); - let headers = response.headers_mut(); - // Use strict-origin so third-party SDKs (e.g. Square Web Payments) - // receive the origin in their requests, which they need for auth. - // no-referrer would cause 401s from SDK servers. - headers.insert( - header::HeaderName::from_static("referrer-policy"), - header::HeaderValue::from_static("strict-origin"), - ); - if !csp.is_empty() { - match csp.parse::() { - Ok(csp_value) => { - headers.insert(header::CONTENT_SECURITY_POLICY, csp_value); - } - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - "Invalid characters in Content-Security-Policy value", - ) - .into_response(); - } - } - } - response - } - None => ( - StatusCode::NOT_FOUND, - "Guest content not found or already consumed", - ) - .into_response(), - } -} - -pub fn routes(secret_key: String) -> Router { - let state = AppState { - secret_key, - guest_store: Arc::new(RwLock::new(HashMap::new())), - }; - - Router::new() - .route("/mcp-app-proxy", get(mcp_app_proxy)) - .route("/mcp-app-guest", get(serve_guest_html)) - .route("/mcp-app-guest", post(store_guest_html)) - .with_state(state) -} diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs deleted file mode 100644 index 57e641f8123f..000000000000 --- a/crates/goose-server/src/routes/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -pub mod action_required; -pub mod agent; -pub mod config_management; -pub mod dictation; -pub mod errors; -pub mod mcp_app_proxy; -pub mod prompts; -pub mod recipe; -pub mod recipe_utils; -pub mod reply; -pub mod schedule; -pub mod session; -pub mod session_events; -pub mod status; -pub mod telemetry; -pub mod utils; - -use std::sync::Arc; - -use axum::Router; - -// Function to configure all routes -pub fn configure(state: Arc, secret_key: String) -> Router { - Router::new() - .merge(status::routes(state.clone())) - .merge(reply::routes(state.clone())) - .merge(action_required::routes(state.clone())) - .merge(agent::routes(state.clone())) - .merge(config_management::routes(state.clone())) - .merge(prompts::routes()) - .merge(recipe::routes(state.clone())) - .merge(session::routes(state.clone())) - .merge(schedule::routes(state.clone())) - .merge(telemetry::routes(state.clone())) - .merge(mcp_app_proxy::routes(secret_key)) - .merge(session_events::routes(state.clone())) - .merge(dictation::routes(state.clone())) -} diff --git a/crates/goose-server/src/routes/prompts.rs b/crates/goose-server/src/routes/prompts.rs deleted file mode 100644 index b807dc591031..000000000000 --- a/crates/goose-server/src/routes/prompts.rs +++ /dev/null @@ -1,132 +0,0 @@ -use crate::routes::errors::ErrorResponse; -use axum::{ - extract::Path, - routing::{delete, get, put}, - Json, Router, -}; -use goose::prompt_template::{ - get_template, list_templates, reset_template, save_template, Template, -}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -#[derive(Serialize, ToSchema)] -pub struct PromptsListResponse { - pub prompts: Vec