OpenCode workspace host + MCP sync + iOS fixes (#27)
* Add multi-user auth and per-user control sessions * Add mission store abstraction and auth UX polish * Fix unused warnings in tooling * Fix Bugbot review issues - Prevent username enumeration by using generic error message - Add pagination support to InMemoryMissionStore::list_missions - Improve config error when JWT_SECRET missing but DASHBOARD_PASSWORD set * Trim stored username in comparison for consistency * Fix mission cleanup to also remove orphaned tree data * Refactor Open Agent as OpenCode workspace host * Remove chromiumoxide and pin @types/react * Pin idna_adapter for MSRV compatibility * Add host-mcp bin target * Use isolated Playwright MCP sessions * Allow Playwright MCP as root * Fix iOS dashboard warnings * Add autoFocus to username field in multi-user login mode Mirrors the iOS implementation behavior where username field is focused when multi-user auth mode is active. * Fix Bugbot review issues - Add conditional ellipsis for tool descriptions (only when > 32 chars) - Add serde(default) to JWT usr field for backward compatibility * Fix empty user ID fallback in multi-user auth Add effective_user_id helper that falls back to username when id is empty, preventing session sharing and token verification issues. * Fix parallel mission history preservation Load existing mission history into runner before starting parallel execution to prevent losing conversation context. * Fix desktop stream controls layout overflow on iPad - Add frame(maxWidth: .infinity) constraints to ensure controls stay within bounds on wide displays - Add alignment: .leading to VStacks for consistent layout - Add Spacer() to buttons row to prevent spreading - Increase label width to 55 for consistent FPS/Quality alignment - Add alignment: .trailing to value text frames * Fix queued user messages not persisted to mission history When a user message was queued (sent while another task was running), it was not being added to the history or persisted to the database. This caused queued messages to be lost from mission history. Added the same persistence logic used for initial messages to the queued message handling code path.
This commit is contained in:
@@ -9,8 +9,8 @@ Minimal autonomous coding agent in Rust with **full machine access** (not sandbo
|
||||
| Backend (Rust) | `src/` | HTTP API + OpenCode integration |
|
||||
| Dashboard (Next.js) | `dashboard/` | Web UI (uses **Bun**, not npm) |
|
||||
| iOS Dashboard | `ios_dashboard/` | Native iOS app (Swift/SwiftUI) |
|
||||
| MCP configs | `.open_agent/mcp/config.json` | Model Context Protocol servers |
|
||||
| Providers | `.open_agent/providers.json` | Provider configuration |
|
||||
| MCP configs | `.openagent/mcp/config.json` | Model Context Protocol servers |
|
||||
| Providers | `.openagent/providers.json` | Provider configuration |
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -35,7 +35,7 @@ bun run build # Production build
|
||||
# - bun run <script> (not npm run <script>)
|
||||
|
||||
# Deployment
|
||||
ssh root@95.216.112.253 'cd /root/open_agent && git pull && cargo build --release && cp target/release/open_agent /usr/local/bin/ && cp target/release/desktop-mcp /usr/local/bin/ && systemctl restart open_agent'
|
||||
ssh root@95.216.112.253 'cd /root/open_agent && git pull && cargo build --release && cp target/release/open_agent /usr/local/bin/ && cp target/release/desktop-mcp /usr/local/bin/ && cp target/release/host-mcp /usr/local/bin/ && systemctl restart open_agent'
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -50,23 +50,84 @@ Dashboard → Open Agent API → OpenCode Server → Anthropic API (Claude Max)
|
||||
|
||||
```
|
||||
src/
|
||||
├── agents/ # Agent system
|
||||
│ └── opencode.rs # OpenCodeAgent (delegates to OpenCode server)
|
||||
├── budget/ # Cost tracking, pricing
|
||||
│ ├── benchmarks.rs # Model capability scores
|
||||
│ ├── pricing.rs # Model pricing
|
||||
│ └── resolver.rs # Model family auto-upgrade system
|
||||
├── memory/ # Supabase + pgvector persistence
|
||||
│ ├── supabase.rs # Database client
|
||||
│ ├── context.rs # ContextBuilder, SessionContext
|
||||
│ ├── retriever.rs # Semantic search
|
||||
│ └── writer.rs # Event recording
|
||||
├── mcp/ # MCP server registry + config
|
||||
├── opencode/ # OpenCode client
|
||||
├── tools/ # Desktop MCP tools
|
||||
├── task/ # Task types + verification
|
||||
├── config.rs # Config + env vars
|
||||
└── api/ # HTTP routes (axum)
|
||||
├── agents/ # Agent system
|
||||
│ ├── mod.rs # Agent trait, OrchestratorAgent trait, LeafCapability enum
|
||||
│ ├── opencode.rs # OpenCodeAgent (delegates to OpenCode server)
|
||||
│ ├── context.rs # AgentContext with LLM, tools, memory
|
||||
│ ├── improvements.rs # Blocker detection, tool failure tracking, smart truncation
|
||||
│ ├── tree.rs # AgentRef, AgentTree for hierarchy
|
||||
│ ├── tuning.rs # TuningParams for agent behavior
|
||||
│ └── types.rs # AgentError, AgentId, AgentResult, AgentType, Complexity
|
||||
├── api/ # HTTP routes (axum)
|
||||
│ ├── mod.rs # Endpoint registry
|
||||
│ ├── routes.rs # Core handlers, AppState, serve()
|
||||
│ ├── auth.rs # JWT authentication
|
||||
│ ├── control.rs # Global interactive control session (SSE streaming)
|
||||
│ ├── console.rs # WebSocket console
|
||||
│ ├── desktop_stream.rs # WebSocket desktop stream (VNC-style)
|
||||
│ ├── fs.rs # Remote file explorer (list, upload, download, mkdir, rm)
|
||||
│ ├── mcp.rs # MCP server management endpoints
|
||||
│ ├── mission_runner.rs # Background mission execution
|
||||
│ ├── providers.rs # Provider/model listing
|
||||
│ ├── ssh_util.rs # SSH utilities for remote connections
|
||||
│ └── types.rs # Request/response types
|
||||
├── budget/ # Cost tracking, pricing, model selection
|
||||
│ ├── mod.rs # Budget type, SharedBenchmarkRegistry, SharedModelResolver
|
||||
│ ├── benchmarks.rs # Model capability scores from benchmarks
|
||||
│ ├── pricing.rs # Model pricing (cents per token)
|
||||
│ ├── resolver.rs # Model family auto-upgrade system
|
||||
│ ├── allocation.rs # Budget allocation strategies
|
||||
│ ├── compatibility.rs # Model compatibility checks
|
||||
│ ├── learned.rs # Self-improving model selection from task outcomes
|
||||
│ ├── budget.rs # Budget tracking implementation
|
||||
│ └── retry.rs # Retry strategies with backoff
|
||||
├── llm/ # LLM client abstraction
|
||||
│ ├── mod.rs # OpenRouterClient, ToolDefinition, FunctionDefinition
|
||||
│ ├── openrouter.rs # OpenRouter API client
|
||||
│ └── error.rs # LLM-specific errors
|
||||
├── mcp/ # Model Context Protocol server registry
|
||||
│ ├── mod.rs # McpRegistry
|
||||
│ ├── config.rs # MCP configuration loading
|
||||
│ ├── registry.rs # Server discovery and management
|
||||
│ └── types.rs # MCP message types
|
||||
├── memory/ # Supabase + pgvector persistence
|
||||
│ ├── mod.rs # MemorySystem, init_memory()
|
||||
│ ├── supabase.rs # Database client, learned stats queries
|
||||
│ ├── context.rs # ContextBuilder, SessionContext
|
||||
│ ├── retriever.rs # Semantic search, run/event retrieval
|
||||
│ ├── writer.rs # Event recording, run management
|
||||
│ ├── embed.rs # Embedding generation
|
||||
│ └── types.rs # Memory event types
|
||||
├── opencode/ # OpenCode client
|
||||
│ └── mod.rs # OpenCode server communication
|
||||
├── task/ # Task types + verification
|
||||
│ ├── mod.rs # Task exports
|
||||
│ ├── task.rs # Task struct, TaskAnalysis
|
||||
│ ├── subtask.rs # Subtask breakdown
|
||||
│ ├── deliverables.rs # Expected deliverables
|
||||
│ └── verification.rs # VerificationCriteria enum
|
||||
├── tools/ # Tool system (agent's "hands and eyes")
|
||||
│ ├── mod.rs # Tool trait, ToolRegistry, PathResolution
|
||||
│ ├── file_ops.rs # read_file, write_file, delete_file
|
||||
│ ├── directory.rs # list_directory, search_files
|
||||
│ ├── index.rs # index_files, search_file_index (performance optimization)
|
||||
│ ├── terminal.rs # run_command (shell execution)
|
||||
│ ├── search.rs # grep_search
|
||||
│ ├── web.rs # web_search, fetch_url
|
||||
│ ├── git.rs # git_status, git_diff, git_commit, git_log
|
||||
│ ├── github.rs # github_clone, github_list_repos, github_get_file, github_search_code
|
||||
│ ├── browser.rs # Browser automation (conditional on BROWSER_ENABLED)
|
||||
│ ├── desktop.rs # Desktop automation via i3/Xvfb (conditional on DESKTOP_ENABLED)
|
||||
│ ├── composite.rs # High-level workflows: analyze_codebase, deep_search, prepare_project, debug_error
|
||||
│ ├── ui.rs # Frontend tool UI schemas (ui_optionList, ui_dataTable)
|
||||
│ ├── storage.rs # upload_image (Supabase storage)
|
||||
│ ├── memory.rs # search_memory, store_fact (shared memory tools)
|
||||
│ └── mission.rs # complete_mission (agent task completion)
|
||||
├── bin/
|
||||
│ └── desktop_mcp.rs # Standalone MCP server for desktop tools
|
||||
├── config.rs # Config struct, environment variable loading
|
||||
├── lib.rs # Library exports
|
||||
└── main.rs # Server entry point
|
||||
```
|
||||
|
||||
## OpenCode Configuration
|
||||
@@ -83,14 +144,20 @@ OPENCODE_PERMISSIVE=true
|
||||
**Desktop Tools with OpenCode:**
|
||||
To enable desktop tools (i3, Xvfb, screenshots):
|
||||
|
||||
1. Build the MCP server: `cargo build --release --bin desktop-mcp`
|
||||
2. Ensure `opencode.json` is in the project root with the desktop MCP config
|
||||
1. Build the MCP servers: `cargo build --release --bin desktop-mcp --bin host-mcp`
|
||||
2. Workspace `opencode.json` files are generated automatically under `workspaces/`
|
||||
from `.openagent/mcp/config.json` (override by editing MCP configs via the UI).
|
||||
3. OpenCode will automatically load the tools from the MCP server
|
||||
|
||||
The `opencode.json` configures MCP servers for desktop and browser automation:
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"host": {
|
||||
"type": "local",
|
||||
"command": ["./target/release/host-mcp"],
|
||||
"enabled": true
|
||||
},
|
||||
"desktop": {
|
||||
"type": "local",
|
||||
"command": ["./target/release/desktop-mcp"],
|
||||
@@ -118,16 +185,80 @@ Use Claude models via your Claude Max subscription:
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Core Task Endpoints
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `POST` | `/api/task` | Submit task |
|
||||
| `GET` | `/api/task/{id}` | Get status |
|
||||
| `GET` | `/api/task/{id}/stream` | SSE progress |
|
||||
| `GET` | `/api/health` | Health check |
|
||||
| `POST` | `/api/task/{id}/stop` | Cancel task |
|
||||
| `GET` | `/api/tasks` | List all tasks |
|
||||
|
||||
### Control Session (Global Interactive)
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `POST` | `/api/control/message` | Send message to agent |
|
||||
| `POST` | `/api/control/tool_result` | Submit tool result |
|
||||
| `GET` | `/api/control/stream` | SSE event stream |
|
||||
| `GET` | `/api/models` | List available models |
|
||||
| `GET` | `/api/providers` | List available providers |
|
||||
| `POST` | `/api/control/cancel` | Cancel current operation |
|
||||
| `GET` | `/api/control/tree` | Get state tree snapshot |
|
||||
| `GET` | `/api/control/progress` | Get progress snapshot |
|
||||
|
||||
### Mission Management
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/control/missions` | List missions |
|
||||
| `POST` | `/api/control/missions` | Create mission |
|
||||
| `GET` | `/api/control/missions/current` | Get current mission |
|
||||
| `GET` | `/api/control/missions/{id}` | Get mission details |
|
||||
| `POST` | `/api/control/missions/{id}/load` | Load mission |
|
||||
| `POST` | `/api/control/missions/{id}/cancel` | Cancel mission |
|
||||
| `POST` | `/api/control/missions/{id}/resume` | Resume mission |
|
||||
| `DELETE` | `/api/control/missions/{id}` | Delete mission |
|
||||
|
||||
### Memory Endpoints
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/runs` | List archived runs |
|
||||
| `GET` | `/api/runs/{id}` | Get run details |
|
||||
| `GET` | `/api/runs/{id}/events` | Get run events |
|
||||
| `GET` | `/api/memory/search` | Search memory |
|
||||
|
||||
### File System (Remote Explorer)
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/fs/list` | List directory |
|
||||
| `GET` | `/api/fs/download` | Download file |
|
||||
| `POST` | `/api/fs/upload` | Upload file |
|
||||
| `POST` | `/api/fs/mkdir` | Create directory |
|
||||
| `POST` | `/api/fs/rm` | Remove file/dir |
|
||||
|
||||
### MCP & Tools Management
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/mcp` | List MCP servers |
|
||||
| `POST` | `/api/mcp` | Add MCP server |
|
||||
| `DELETE` | `/api/mcp/{id}` | Remove MCP |
|
||||
| `POST` | `/api/mcp/{id}/enable` | Enable MCP |
|
||||
| `POST` | `/api/mcp/{id}/disable` | Disable MCP |
|
||||
| `GET` | `/api/tools` | List all tools |
|
||||
| `POST` | `/api/tools/{name}/toggle` | Toggle tool |
|
||||
|
||||
### Model Management
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/providers` | List providers |
|
||||
| `GET` | `/api/models` | List models |
|
||||
| `POST` | `/api/models/refresh` | Refresh model data |
|
||||
| `GET` | `/api/models/families` | List model families |
|
||||
| `GET` | `/api/models/performance` | Get learned performance stats |
|
||||
|
||||
### System
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/health` | Health check |
|
||||
| `GET` | `/api/stats` | System statistics |
|
||||
| `POST` | `/api/auth/login` | Authenticate |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -152,6 +283,8 @@ Use Claude models via your Claude Max subscription:
|
||||
| `SUPABASE_URL` | - | Supabase project URL |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | - | Service role key |
|
||||
| `OPENROUTER_API_KEY` | - | Only needed for memory embeddings |
|
||||
| `BROWSER_ENABLED` | `false` | Enable browser automation tools |
|
||||
| `DESKTOP_ENABLED` | `false` | Enable desktop automation tools |
|
||||
|
||||
## Secrets
|
||||
|
||||
@@ -227,6 +360,7 @@ pub fn do_thing() -> Result<T, MyError> {
|
||||
| Dashboard URL | `https://agent.thomas.md` |
|
||||
| Binary | `/usr/local/bin/open_agent` |
|
||||
| Desktop MCP | `/usr/local/bin/desktop-mcp` |
|
||||
| Host MCP | `/usr/local/bin/host-mcp` |
|
||||
| Env file | `/etc/open_agent/open_agent.env` |
|
||||
| Service | `systemctl status open_agent` |
|
||||
|
||||
@@ -241,4 +375,4 @@ pub fn do_thing() -> Result<T, MyError> {
|
||||
|
||||
### After Significant Changes
|
||||
- Update `.cursor/rules/` if architecture changes
|
||||
- Update `CLAUDE.md` for new env vars or commands
|
||||
- Update `AGENTS.md` (root) and `.claude/CLAUDE.md` for new env vars or commands
|
||||
|
||||
400
AGENTS.md
Normal file
400
AGENTS.md
Normal file
@@ -0,0 +1,400 @@
|
||||
# Open Agent
|
||||
|
||||
Minimal autonomous coding agent in Rust with **full machine access** (not sandboxed).
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Component | Location | Purpose |
|
||||
|-----------|----------|---------|
|
||||
| Backend (Rust) | `src/` | HTTP API + OpenCode integration |
|
||||
| Dashboard (Next.js) | `dashboard/` | Web UI (uses **Bun**, not npm) |
|
||||
| iOS Dashboard | `ios_dashboard/` | Native iOS app (Swift/SwiftUI) |
|
||||
| MCP configs | `.openagent/mcp/config.json` | Model Context Protocol servers |
|
||||
| Providers | `.openagent/providers.json` | Provider configuration |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cargo build --release # Build
|
||||
cargo run --release # Run server (port 3000)
|
||||
RUST_LOG=debug cargo run # Debug mode
|
||||
cargo test # Run tests
|
||||
cargo fmt # Format code
|
||||
cargo clippy # Lint
|
||||
|
||||
# Dashboard (uses Bun, NOT npm/yarn/pnpm)
|
||||
cd dashboard
|
||||
bun install # Install deps (NEVER use npm install)
|
||||
bun dev # Dev server (port 3001)
|
||||
bun run build # Production build
|
||||
|
||||
# IMPORTANT: Always use bun for dashboard, never npm
|
||||
# - bun install (not npm install)
|
||||
# - bun add <pkg> (not npm install <pkg>)
|
||||
# - bun run <script> (not npm run <script>)
|
||||
|
||||
# Deployment
|
||||
ssh root@95.216.112.253 'cd /root/open_agent && git pull && cargo build --release && cp target/release/open_agent /usr/local/bin/ && cp target/release/desktop-mcp /usr/local/bin/ && cp target/release/host-mcp /usr/local/bin/ && systemctl restart open_agent'
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Open Agent uses OpenCode as its execution backend, enabling Claude Max subscription usage.
|
||||
|
||||
```
|
||||
Dashboard → Open Agent API → OpenCode Server → Anthropic API (Claude Max)
|
||||
```
|
||||
|
||||
### Module Map
|
||||
|
||||
```
|
||||
src/
|
||||
├── agents/ # Agent system
|
||||
│ ├── mod.rs # Agent trait, OrchestratorAgent trait, LeafCapability enum
|
||||
│ ├── opencode.rs # OpenCodeAgent (delegates to OpenCode server)
|
||||
│ ├── context.rs # AgentContext with LLM, tools, memory
|
||||
│ ├── improvements.rs # Blocker detection, tool failure tracking, smart truncation
|
||||
│ ├── tree.rs # AgentRef, AgentTree for hierarchy
|
||||
│ ├── tuning.rs # TuningParams for agent behavior
|
||||
│ └── types.rs # AgentError, AgentId, AgentResult, AgentType, Complexity
|
||||
├── api/ # HTTP routes (axum)
|
||||
│ ├── mod.rs # Endpoint registry
|
||||
│ ├── routes.rs # Core handlers, AppState, serve()
|
||||
│ ├── auth.rs # JWT authentication
|
||||
│ ├── control.rs # Global interactive control session (SSE streaming)
|
||||
│ ├── console.rs # WebSocket console
|
||||
│ ├── desktop_stream.rs # WebSocket desktop stream (VNC-style)
|
||||
│ ├── fs.rs # Remote file explorer (list, upload, download, mkdir, rm)
|
||||
│ ├── mcp.rs # MCP server management endpoints
|
||||
│ ├── mission_runner.rs # Background mission execution
|
||||
│ ├── providers.rs # Provider/model listing
|
||||
│ ├── ssh_util.rs # SSH utilities for remote connections
|
||||
│ └── types.rs # Request/response types
|
||||
├── budget/ # Cost tracking, pricing, model selection
|
||||
│ ├── mod.rs # Budget type, SharedBenchmarkRegistry, SharedModelResolver
|
||||
│ ├── benchmarks.rs # Model capability scores from benchmarks
|
||||
│ ├── pricing.rs # Model pricing (cents per token)
|
||||
│ ├── resolver.rs # Model family auto-upgrade system
|
||||
│ ├── allocation.rs # Budget allocation strategies
|
||||
│ ├── compatibility.rs # Model compatibility checks
|
||||
│ ├── learned.rs # Self-improving model selection from task outcomes
|
||||
│ ├── budget.rs # Budget tracking implementation
|
||||
│ └── retry.rs # Retry strategies with backoff
|
||||
├── llm/ # LLM client abstraction
|
||||
│ ├── mod.rs # OpenRouterClient, ToolDefinition, FunctionDefinition
|
||||
│ ├── openrouter.rs # OpenRouter API client
|
||||
│ └── error.rs # LLM-specific errors
|
||||
├── mcp/ # Model Context Protocol server registry
|
||||
│ ├── mod.rs # McpRegistry
|
||||
│ ├── config.rs # MCP configuration loading
|
||||
│ ├── registry.rs # Server discovery and management
|
||||
│ └── types.rs # MCP message types
|
||||
├── memory/ # Supabase + pgvector persistence
|
||||
│ ├── mod.rs # MemorySystem, init_memory()
|
||||
│ ├── supabase.rs # Database client, learned stats queries
|
||||
│ ├── context.rs # ContextBuilder, SessionContext
|
||||
│ ├── retriever.rs # Semantic search, run/event retrieval
|
||||
│ ├── writer.rs # Event recording, run management
|
||||
│ ├── embed.rs # Embedding generation
|
||||
│ └── types.rs # Memory event types
|
||||
├── opencode/ # OpenCode client
|
||||
│ └── mod.rs # OpenCode server communication
|
||||
├── task/ # Task types + verification
|
||||
│ ├── mod.rs # Task exports
|
||||
│ ├── task.rs # Task struct, TaskAnalysis
|
||||
│ ├── subtask.rs # Subtask breakdown
|
||||
│ ├── deliverables.rs # Expected deliverables
|
||||
│ └── verification.rs # VerificationCriteria enum
|
||||
├── tools/ # Tool system (agent's "hands and eyes")
|
||||
│ ├── mod.rs # Tool trait, ToolRegistry, PathResolution
|
||||
│ ├── file_ops.rs # read_file, write_file, delete_file
|
||||
│ ├── directory.rs # list_directory, search_files
|
||||
│ ├── index.rs # index_files, search_file_index (performance optimization)
|
||||
│ ├── terminal.rs # run_command (shell execution)
|
||||
│ ├── search.rs # grep_search
|
||||
│ ├── web.rs # web_search, fetch_url
|
||||
│ ├── git.rs # git_status, git_diff, git_commit, git_log
|
||||
│ ├── github.rs # github_clone, github_list_repos, github_get_file, github_search_code
|
||||
│ ├── browser.rs # Browser automation (conditional on BROWSER_ENABLED)
|
||||
│ ├── desktop.rs # Desktop automation via i3/Xvfb (conditional on DESKTOP_ENABLED)
|
||||
│ ├── composite.rs # High-level workflows: analyze_codebase, deep_search, prepare_project, debug_error
|
||||
│ ├── ui.rs # Frontend tool UI schemas (ui_optionList, ui_dataTable)
|
||||
│ ├── storage.rs # upload_image (Supabase storage)
|
||||
│ ├── memory.rs # search_memory, store_fact (shared memory tools)
|
||||
│ └── mission.rs # complete_mission (agent task completion)
|
||||
├── bin/
|
||||
│ └── desktop_mcp.rs # Standalone MCP server for desktop tools
|
||||
├── config.rs # Config struct, environment variable loading
|
||||
├── lib.rs # Library exports
|
||||
└── main.rs # Server entry point
|
||||
```
|
||||
|
||||
## OpenCode Configuration
|
||||
|
||||
OpenCode is required for task execution. It connects to an OpenCode server that handles LLM interactions.
|
||||
|
||||
```bash
|
||||
# Optional configuration (defaults shown)
|
||||
OPENCODE_BASE_URL=http://127.0.0.1:4096
|
||||
OPENCODE_AGENT=build
|
||||
OPENCODE_PERMISSIVE=true
|
||||
```
|
||||
|
||||
**Desktop Tools with OpenCode:**
|
||||
To enable desktop tools (i3, Xvfb, screenshots):
|
||||
|
||||
1. Build the MCP servers: `cargo build --release --bin desktop-mcp --bin host-mcp`
|
||||
2. Workspace `opencode.json` files are generated automatically under `workspaces/`
|
||||
from `.openagent/mcp/config.json` (override by editing MCP configs via the UI).
|
||||
3. OpenCode will automatically load the tools from the MCP server
|
||||
|
||||
The `opencode.json` configures MCP servers for desktop and browser automation:
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"host": {
|
||||
"type": "local",
|
||||
"command": ["./target/release/host-mcp"],
|
||||
"enabled": true
|
||||
},
|
||||
"desktop": {
|
||||
"type": "local",
|
||||
"command": ["./target/release/desktop-mcp"],
|
||||
"enabled": true
|
||||
},
|
||||
"playwright": {
|
||||
"type": "local",
|
||||
"command": ["npx", "@playwright/mcp@latest", "--isolated", "--no-sandbox"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use `--isolated` for Playwright so multiple sessions can run in parallel without profile conflicts, and `--no-sandbox` when running as root.
|
||||
|
||||
**Available MCP Tools:**
|
||||
- **Desktop tools** (i3/Xvfb): `desktop_start_session`, `desktop_screenshot`, `desktop_click`, `desktop_type`, `desktop_i3_command`, etc.
|
||||
- **Playwright tools**: `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_screenshot`, etc.
|
||||
|
||||
## Model Preferences
|
||||
|
||||
Use Claude models via your Claude Max subscription:
|
||||
- `claude-opus-4-5-20251101` - Most capable, recommended
|
||||
- `claude-sonnet-4-20250514` - Good balance of speed/capability (default)
|
||||
- `claude-3-5-haiku-20241022` - Fastest, most economical
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Core Task Endpoints
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `POST` | `/api/task` | Submit task |
|
||||
| `GET` | `/api/task/{id}` | Get status |
|
||||
| `GET` | `/api/task/{id}/stream` | SSE progress |
|
||||
| `POST` | `/api/task/{id}/stop` | Cancel task |
|
||||
| `GET` | `/api/tasks` | List all tasks |
|
||||
|
||||
### Control Session (Global Interactive)
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `POST` | `/api/control/message` | Send message to agent |
|
||||
| `POST` | `/api/control/tool_result` | Submit tool result |
|
||||
| `GET` | `/api/control/stream` | SSE event stream |
|
||||
| `POST` | `/api/control/cancel` | Cancel current operation |
|
||||
| `GET` | `/api/control/tree` | Get state tree snapshot |
|
||||
| `GET` | `/api/control/progress` | Get progress snapshot |
|
||||
|
||||
### Mission Management
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/control/missions` | List missions |
|
||||
| `POST` | `/api/control/missions` | Create mission |
|
||||
| `GET` | `/api/control/missions/current` | Get current mission |
|
||||
| `GET` | `/api/control/missions/{id}` | Get mission details |
|
||||
| `GET` | `/api/control/missions/{id}/tree` | Get mission tree |
|
||||
| `POST` | `/api/control/missions/{id}/load` | Load mission |
|
||||
| `POST` | `/api/control/missions/{id}/status` | Set mission status |
|
||||
| `POST` | `/api/control/missions/{id}/cancel` | Cancel mission |
|
||||
| `POST` | `/api/control/missions/{id}/resume` | Resume mission |
|
||||
| `POST` | `/api/control/missions/{id}/parallel` | Start parallel execution |
|
||||
| `DELETE` | `/api/control/missions/{id}` | Delete mission |
|
||||
| `POST` | `/api/control/missions/cleanup` | Cleanup empty missions |
|
||||
| `GET` | `/api/control/running` | List running missions |
|
||||
| `GET` | `/api/control/parallel/config` | Get parallel config |
|
||||
|
||||
### Memory Endpoints
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/runs` | List archived runs |
|
||||
| `GET` | `/api/runs/{id}` | Get run details |
|
||||
| `GET` | `/api/runs/{id}/events` | Get run events |
|
||||
| `GET` | `/api/runs/{id}/tasks` | Get run tasks |
|
||||
| `GET` | `/api/memory/search` | Search memory |
|
||||
|
||||
### File System (Remote Explorer)
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/fs/list` | List directory |
|
||||
| `GET` | `/api/fs/download` | Download file |
|
||||
| `POST` | `/api/fs/upload` | Upload file |
|
||||
| `POST` | `/api/fs/upload-chunk` | Chunked upload |
|
||||
| `POST` | `/api/fs/upload-finalize` | Finalize upload |
|
||||
| `POST` | `/api/fs/download-url` | Download from URL |
|
||||
| `POST` | `/api/fs/mkdir` | Create directory |
|
||||
| `POST` | `/api/fs/rm` | Remove file/dir |
|
||||
|
||||
### MCP Management
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/mcp` | List MCP servers |
|
||||
| `POST` | `/api/mcp` | Add MCP server |
|
||||
| `POST` | `/api/mcp/refresh` | Refresh all MCPs |
|
||||
| `GET` | `/api/mcp/{id}` | Get MCP details |
|
||||
| `DELETE` | `/api/mcp/{id}` | Remove MCP |
|
||||
| `POST` | `/api/mcp/{id}/enable` | Enable MCP |
|
||||
| `POST` | `/api/mcp/{id}/disable` | Disable MCP |
|
||||
| `POST` | `/api/mcp/{id}/refresh` | Refresh MCP |
|
||||
| `GET` | `/api/tools` | List all tools |
|
||||
| `POST` | `/api/tools/{name}/toggle` | Toggle tool |
|
||||
|
||||
### Model Management
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/providers` | List providers |
|
||||
| `GET` | `/api/models` | List models |
|
||||
| `POST` | `/api/models/refresh` | Refresh model data |
|
||||
| `GET` | `/api/models/families` | List model families |
|
||||
| `GET` | `/api/models/performance` | Get learned performance stats |
|
||||
|
||||
### System
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/health` | Health check |
|
||||
| `GET` | `/api/stats` | System statistics |
|
||||
| `POST` | `/api/auth/login` | Authenticate |
|
||||
| `GET` | `/api/console/ws` | WebSocket console |
|
||||
| `GET` | `/api/desktop/stream` | WebSocket desktop stream |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Production Auth
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DEV_MODE` | `true` bypasses auth |
|
||||
| `DASHBOARD_PASSWORD` | Password for dashboard login |
|
||||
| `JWT_SECRET` | HMAC secret for JWT signing |
|
||||
|
||||
### Optional
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DEFAULT_MODEL` | `claude-sonnet-4-20250514` | Default LLM model |
|
||||
| `OPENCODE_BASE_URL` | `http://127.0.0.1:4096` | OpenCode server URL |
|
||||
| `OPENCODE_AGENT` | - | OpenCode agent name |
|
||||
| `OPENCODE_PERMISSIVE` | `true` | Auto-allow OpenCode permissions |
|
||||
| `WORKING_DIR` | `/root` (prod), `.` (dev) | Working directory |
|
||||
| `HOST` | `127.0.0.1` | Bind address |
|
||||
| `PORT` | `3000` | Server port |
|
||||
| `MAX_ITERATIONS` | `50` | Max agent loop iterations |
|
||||
| `SUPABASE_URL` | - | Supabase project URL |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | - | Service role key |
|
||||
| `OPENROUTER_API_KEY` | - | Only needed for memory embeddings |
|
||||
| `BROWSER_ENABLED` | `false` | Enable browser automation tools |
|
||||
| `DESKTOP_ENABLED` | `false` | Enable desktop automation tools |
|
||||
|
||||
## Secrets
|
||||
|
||||
Use `secrets.json` (gitignored) for local development. Template: `secrets.json.example`
|
||||
|
||||
```bash
|
||||
# Read secrets
|
||||
jq -r '.openrouter.api_key' secrets.json
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Never paste secret values into code, comments, or docs
|
||||
- Read secrets from environment variables at runtime
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Rust - Provability-First Design
|
||||
|
||||
Code should be written as if we want to **formally prove it correct later**. This means:
|
||||
|
||||
1. **Never panic** - always return `Result<T, E>`
|
||||
2. **Exhaustive matches** - no `_` catch-all patterns in enums (forces handling new variants)
|
||||
3. **Document invariants** as `/// Precondition:` and `/// Postcondition:` comments
|
||||
4. **Pure functions** - separate pure logic from IO where possible
|
||||
5. **Algebraic types** - prefer enums with exhaustive matching over stringly-typed data
|
||||
6. Costs are in **cents (u64)** - never use floats for money
|
||||
|
||||
```rust
|
||||
// Use thiserror for error types
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MyError {
|
||||
#[error("description: {0}")]
|
||||
Variant(String),
|
||||
}
|
||||
|
||||
// Propagate with ?
|
||||
pub fn do_thing() -> Result<T, MyError> {
|
||||
let x = fallible_op()?;
|
||||
Ok(x)
|
||||
}
|
||||
```
|
||||
|
||||
### Adding a New Tool
|
||||
|
||||
1. Add to `src/tools/` (new file or extend existing)
|
||||
2. Implement `Tool` trait: `name()`, `description()`, `parameters_schema()`, `execute()`
|
||||
3. Register in `src/tools/mod.rs` → `ToolRegistry::with_options()`
|
||||
4. Tool parameters use serde_json schema format
|
||||
5. Document pre/postconditions for provability
|
||||
|
||||
### Dashboard (Next.js + Bun)
|
||||
- Package manager: **Bun** (not npm/yarn/pnpm)
|
||||
- Icons: **Lucide React** (`lucide-react`)
|
||||
- API base: `process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000'`
|
||||
- Auth: JWT stored in `sessionStorage`
|
||||
|
||||
### Design System - "Quiet Luxury + Liquid Glass"
|
||||
- **Dark-first** aesthetic (dark mode is default)
|
||||
- No pure black - use deep charcoal (#121214)
|
||||
- Elevation via color, not shadows
|
||||
- Use `white/[opacity]` for text (e.g., `text-white/80`)
|
||||
- Accent color: indigo-500 (#6366F1)
|
||||
- Borders: very subtle (0.06-0.08 opacity)
|
||||
- No bounce animations, use `ease-out`
|
||||
|
||||
## Production
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Host | `95.216.112.253` |
|
||||
| SSH | `ssh -i ~/.ssh/cursor root@95.216.112.253` |
|
||||
| Backend URL | `https://agent-backend.thomas.md` |
|
||||
| Dashboard URL | `https://agent.thomas.md` |
|
||||
| Binary | `/usr/local/bin/open_agent` |
|
||||
| Desktop MCP | `/usr/local/bin/desktop-mcp` |
|
||||
| Host MCP | `/usr/local/bin/host-mcp` |
|
||||
| Env file | `/etc/open_agent/open_agent.env` |
|
||||
| Service | `systemctl status open_agent` |
|
||||
|
||||
**SSH Key:** Use `~/.ssh/cursor` key for production server access.
|
||||
|
||||
## Adding New Components
|
||||
|
||||
### New API Endpoint
|
||||
1. Add handler in `src/api/`
|
||||
2. Register route in `src/api/routes.rs`
|
||||
3. Update this doc
|
||||
|
||||
### New Tool
|
||||
1. Implement `Tool` trait in `src/tools/`
|
||||
2. Register in `ToolRegistry::with_options()` in `src/tools/mod.rs`
|
||||
3. Update this doc
|
||||
|
||||
### After Significant Changes
|
||||
- Update `.cursor/rules/` if architecture changes
|
||||
- Update `AGENTS.md` and `.claude/CLAUDE.md` for new env vars or commands
|
||||
@@ -53,7 +53,8 @@ base64 = "0.22"
|
||||
bytes = "1"
|
||||
portable-pty = "0.9"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
chromiumoxide = { version = "0.8.0", features = ["tokio-runtime"] }
|
||||
# Keep MSRV-compatible idna_adapter for the production builder (rustc 1.75).
|
||||
idna_adapter = "=1.1.0"
|
||||
|
||||
[[bin]]
|
||||
name = "open_agent"
|
||||
@@ -63,5 +64,9 @@ path = "src/main.rs"
|
||||
name = "desktop-mcp"
|
||||
path = "src/bin/desktop_mcp.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "host-mcp"
|
||||
path = "src/bin/host_mcp.rs"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
|
||||
21
README.md
21
README.md
@@ -45,13 +45,12 @@ cargo run --release
|
||||
|
||||
The server starts on `http://127.0.0.1:3000` by default.
|
||||
|
||||
### OpenCode Backend (External Agent)
|
||||
### OpenCode Backend (Required)
|
||||
|
||||
Open Agent can delegate execution to an OpenCode server instead of using its built-in agent loop.
|
||||
Open Agent delegates execution to an OpenCode server. OpenCode must be running and reachable.
|
||||
|
||||
```bash
|
||||
# Point to a running OpenCode server
|
||||
export AGENT_BACKEND="opencode"
|
||||
export OPENCODE_BASE_URL="http://127.0.0.1:4096"
|
||||
|
||||
# Optional: choose OpenCode agent (build/plan/etc)
|
||||
@@ -142,6 +141,20 @@ curl http://localhost:3000/api/health
|
||||
| `HOST` | `127.0.0.1` | Server bind address |
|
||||
| `PORT` | `3000` | Server port |
|
||||
| `MAX_ITERATIONS` | `50` | Max agent loop iterations |
|
||||
| `OPEN_AGENT_USERS` | (optional) | JSON array of multi-user accounts (enables multi-user auth) |
|
||||
| `DASHBOARD_PASSWORD` | (optional) | Single-tenant dashboard password (legacy auth) |
|
||||
| `JWT_SECRET` | (required for auth) | HMAC secret for JWT signing |
|
||||
|
||||
### Multi-user Auth
|
||||
|
||||
Provide `OPEN_AGENT_USERS` to enable multi-user login:
|
||||
|
||||
```bash
|
||||
export JWT_SECRET="your-secret"
|
||||
export OPEN_AGENT_USERS='[{"id":"alice","username":"alice","password":"..."}]'
|
||||
```
|
||||
|
||||
When multi-user auth is enabled, the memory system is disabled to avoid cross-user leakage.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -204,7 +217,7 @@ export OPENROUTER_API_KEY="sk-or-v1-..."
|
||||
cargo run --release --bin calibrate -- --workspace ./.open_agent_calibration --model openai/gpt-4.1-mini --write-tuning
|
||||
```
|
||||
|
||||
This writes a tuning file at `./.open_agent_calibration/.open_agent/tuning.json`. Move/copy it to your real workspace as `./.open_agent/tuning.json` to enable it.
|
||||
This writes a tuning file at `./.open_agent_calibration/.openagent/tuning.json`. Move/copy it to your real workspace as `./.openagent/tuning.json` to enable it.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -20,4 +20,4 @@ Configure the backend URL via:
|
||||
|
||||
## Auth
|
||||
|
||||
If the backend reports `auth_required=true` from `GET /api/health`, the dashboard will prompt for a password and store a JWT in `sessionStorage`.
|
||||
If the backend reports `auth_required=true` from `GET /api/health`, the dashboard will prompt for credentials and store a JWT in `sessionStorage`. In multi-user mode (`auth_mode=multi_user`), it asks for username + password.
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.10",
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.10",
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
disableMcp,
|
||||
refreshMcp,
|
||||
refreshAllMcps,
|
||||
toggleTool,
|
||||
type McpServerState,
|
||||
type McpStatus,
|
||||
type ToolInfo,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
Power,
|
||||
ChevronLeft,
|
||||
Plug,
|
||||
Wrench,
|
||||
Settings,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
@@ -684,60 +682,25 @@ function ConfigureMcpModal({
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsTab({
|
||||
tools,
|
||||
onToggle,
|
||||
}: {
|
||||
tools: ToolInfo[];
|
||||
onToggle: (name: string, enabled: boolean) => void;
|
||||
}) {
|
||||
const builtinTools = tools.filter((t) => t.source === "builtin");
|
||||
function ToolsTab({ tools }: { tools: ToolInfo[] }) {
|
||||
const mcpTools = tools.filter(
|
||||
(t) => typeof t.source === "object" && "mcp" in t.source
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Built-in tools */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-medium text-white">
|
||||
Built-in Tools ({builtinTools.length})
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{builtinTools.map((tool) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="flex items-center justify-between rounded-xl bg-white/[0.02] border border-white/[0.04] hover:bg-white/[0.04] hover:border-white/[0.08] p-4 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/[0.04]">
|
||||
<Wrench className="h-4 w-4 text-white/40" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-white">{tool.name}</p>
|
||||
<p
|
||||
className="truncate text-xs text-white/40 max-w-[150px]"
|
||||
title={tool.description}
|
||||
>
|
||||
{tool.description.slice(0, 35)}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={tool.enabled}
|
||||
onChange={() => onToggle(tool.name, !tool.enabled)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4 text-sm text-white/60">
|
||||
Tools are provided by MCP servers and surfaced to OpenCode. Enable or
|
||||
disable an MCP in the Installed tab to control availability.
|
||||
</div>
|
||||
|
||||
{/* MCP tools */}
|
||||
{mcpTools.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-medium text-white">
|
||||
MCP Tools ({mcpTools.length})
|
||||
</h3>
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-medium text-white">
|
||||
MCP Tools ({mcpTools.length})
|
||||
</h3>
|
||||
{mcpTools.length === 0 ? (
|
||||
<p className="text-sm text-white/40">No MCP tools discovered yet.</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{mcpTools.map((tool) => (
|
||||
<div
|
||||
@@ -756,19 +719,23 @@ function ToolsTab({
|
||||
from{" "}
|
||||
{typeof tool.source === "object" && "mcp" in tool.source
|
||||
? tool.source.mcp.name
|
||||
: "builtin"}
|
||||
: "unknown"}
|
||||
</p>
|
||||
<p
|
||||
className="truncate text-[11px] text-white/30 max-w-[150px]"
|
||||
title={tool.description}
|
||||
>
|
||||
{tool.description.length > 32
|
||||
? `${tool.description.slice(0, 32)}...`
|
||||
: tool.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={tool.enabled}
|
||||
onChange={() => onToggle(tool.name, !tool.enabled)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -911,17 +878,6 @@ export default function ModulesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTool = async (name: string, enabled: boolean) => {
|
||||
try {
|
||||
await toggleTool(name, enabled);
|
||||
toast.success(`${enabled ? "Enabled" : "Disabled"} ${name}`);
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle tool:", error);
|
||||
toast.error(`Failed to toggle ${name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshAll = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
@@ -1050,7 +1006,7 @@ export default function ModulesPage() {
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<ToolsTab tools={filteredTools} onToggle={handleToggleTool} />
|
||||
<ToolsTab tools={filteredTools} />
|
||||
)}
|
||||
|
||||
{/* Detail panel (overlay) */}
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { login, getHealth } from '@/lib/api';
|
||||
import { clearJwt, getValidJwt, setJwt, signalAuthSuccess } from '@/lib/auth';
|
||||
import { clearJwt, getStoredUsername, getValidJwt, setJwt, setStoredUsername, signalAuthSuccess } from '@/lib/auth';
|
||||
import { Lock } from 'lucide-react';
|
||||
|
||||
export function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [authRequired, setAuthRequired] = useState(false);
|
||||
const [isAuthed, setIsAuthed] = useState(true);
|
||||
const [authMode, setAuthMode] = useState<'disabled' | 'single_tenant' | 'multi_user'>('single_tenant');
|
||||
const [username, setUsername] = useState(getStoredUsername() ?? '');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -23,6 +25,9 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
if (!mounted) return;
|
||||
|
||||
setAuthRequired(Boolean(health.auth_required));
|
||||
if (health.auth_mode) {
|
||||
setAuthMode(health.auth_mode);
|
||||
}
|
||||
if (!health.auth_required) {
|
||||
setIsAuthed(true);
|
||||
} else {
|
||||
@@ -54,16 +59,27 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (authMode === 'multi_user' && !username.trim()) {
|
||||
setError('Username is required');
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await login(password);
|
||||
const res = await login(password, authMode === 'multi_user' ? username : undefined);
|
||||
setJwt(res.token, res.exp);
|
||||
if (authMode === 'multi_user') {
|
||||
setStoredUsername(username);
|
||||
}
|
||||
setIsAuthed(true);
|
||||
setPassword('');
|
||||
signalAuthSuccess();
|
||||
} catch {
|
||||
setError('Invalid password');
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError(authMode === 'multi_user' ? 'Invalid username or password' : 'Invalid password');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -89,19 +105,36 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Authenticate</h2>
|
||||
<p className="text-xs text-white/50">
|
||||
Enter the dashboard password to continue
|
||||
{authMode === 'multi_user'
|
||||
? 'Sign in with your username and password'
|
||||
: 'Enter the dashboard password to continue'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{authMode === 'multi_user' && (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
autoFocus={authMode === 'multi_user'}
|
||||
spellCheck={false}
|
||||
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-4 py-3 text-sm text-white placeholder-white/30 focus:border-indigo-500/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
autoFocus
|
||||
autoFocus={authMode !== 'multi_user'}
|
||||
className="w-full rounded-lg border border-white/[0.06] bg-white/[0.02] px-4 py-3 text-sm text-white placeholder-white/30 focus:border-indigo-500/50 focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
@@ -114,7 +147,7 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!password || isSubmitting}
|
||||
disabled={!password || (authMode === 'multi_user' && !username.trim()) || isSubmitting}
|
||||
className="w-full rounded-lg bg-indigo-500 hover:bg-indigo-600 px-4 py-3 text-sm font-medium text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Signing in…' : 'Sign in'}
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface HealthResponse {
|
||||
version: string;
|
||||
dev_mode: boolean;
|
||||
auth_required: boolean;
|
||||
auth_mode: "disabled" | "single_tenant" | "multi_user";
|
||||
max_iterations: number;
|
||||
}
|
||||
|
||||
@@ -84,13 +85,20 @@ export async function getHealth(): Promise<HealthResponse> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function login(password: string): Promise<LoginResponse> {
|
||||
export async function login(password: string, username?: string): Promise<LoginResponse> {
|
||||
const payload: { password: string; username?: string } = { password };
|
||||
if (username && username.trim().length > 0) {
|
||||
payload.username = username.trim();
|
||||
}
|
||||
const res = await fetch(apiUrl("/api/auth/login"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to login");
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text || "Failed to login");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const TOKEN_KEY = 'openagent.jwt';
|
||||
const EXP_KEY = 'openagent.jwt_exp';
|
||||
const USERNAME_KEY = 'openagent.username';
|
||||
|
||||
export function getStoredJwt(): { token: string; exp: number } | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
@@ -34,6 +35,19 @@ export function setJwt(token: string, exp: number): void {
|
||||
localStorage.setItem(EXP_KEY, String(exp));
|
||||
}
|
||||
|
||||
export function getStoredUsername(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const username = localStorage.getItem(USERNAME_KEY);
|
||||
return username && username.trim().length > 0 ? username : null;
|
||||
}
|
||||
|
||||
export function setStoredUsername(username: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const trimmed = username.trim();
|
||||
if (trimmed.length === 0) return;
|
||||
localStorage.setItem(USERNAME_KEY, trimmed);
|
||||
}
|
||||
|
||||
export function clearJwt(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
|
||||
@@ -52,11 +52,13 @@ struct ContentView: View {
|
||||
struct LoginView: View {
|
||||
let onLogin: () -> Void
|
||||
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var serverURL: String
|
||||
|
||||
@FocusState private var isUsernameFocused: Bool
|
||||
@FocusState private var isPasswordFocused: Bool
|
||||
|
||||
private let api = APIService.shared
|
||||
@@ -64,6 +66,7 @@ struct LoginView: View {
|
||||
init(onLogin: @escaping () -> Void) {
|
||||
self.onLogin = onLogin
|
||||
_serverURL = State(initialValue: APIService.shared.baseURL)
|
||||
_username = State(initialValue: UserDefaults.standard.string(forKey: "last_username") ?? "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -134,6 +137,28 @@ struct LoginView: View {
|
||||
.stroke(Theme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
if api.authMode == .multiUser {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Username")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
|
||||
TextField("Enter username", text: $username)
|
||||
.textFieldStyle(.plain)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.focused($isUsernameFocused)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(Color.white.opacity(0.05))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(isUsernameFocused ? Theme.accent.opacity(0.5) : Theme.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Password field
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -174,13 +199,27 @@ struct LoginView: View {
|
||||
"Sign In",
|
||||
icon: "arrow.right",
|
||||
isLoading: isLoading,
|
||||
isDisabled: password.isEmpty
|
||||
isDisabled: password.isEmpty || (api.authMode == .multiUser && username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
) {
|
||||
login()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.onAppear {
|
||||
if api.authMode == .multiUser {
|
||||
isUsernameFocused = true
|
||||
} else {
|
||||
isPasswordFocused = true
|
||||
}
|
||||
}
|
||||
.onChange(of: api.authMode) { _, newMode in
|
||||
if newMode == .multiUser {
|
||||
isUsernameFocused = true
|
||||
} else {
|
||||
isPasswordFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
@@ -199,11 +238,33 @@ struct LoginView: View {
|
||||
|
||||
Task {
|
||||
do {
|
||||
let _ = try await api.login(password: password)
|
||||
let usernameValue = api.authMode == .multiUser ? username : nil
|
||||
let _ = try await api.login(password: password, username: usernameValue)
|
||||
if api.authMode == .multiUser {
|
||||
let trimmed = username.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
UserDefaults.standard.set(trimmed, forKey: "last_username")
|
||||
}
|
||||
}
|
||||
HapticService.success()
|
||||
onLogin()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
if let apiError = error as? APIError {
|
||||
switch apiError {
|
||||
case .httpError(let code, _):
|
||||
if code == 401 {
|
||||
errorMessage = api.authMode == .multiUser ? "Invalid username or password" : "Invalid password"
|
||||
} else {
|
||||
errorMessage = apiError.errorDescription
|
||||
}
|
||||
case .unauthorized:
|
||||
errorMessage = api.authMode == .multiUser ? "Invalid username or password" : "Invalid password"
|
||||
default:
|
||||
errorMessage = apiError.errorDescription
|
||||
}
|
||||
} else {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
HapticService.error()
|
||||
}
|
||||
isLoading = false
|
||||
|
||||
@@ -30,13 +30,21 @@ final class APIService {
|
||||
}
|
||||
|
||||
var authRequired: Bool = false
|
||||
var authMode: AuthMode = .singleTenant
|
||||
|
||||
enum AuthMode: String {
|
||||
case disabled = "disabled"
|
||||
case singleTenant = "single_tenant"
|
||||
case multiUser = "multi_user"
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Authentication
|
||||
|
||||
func login(password: String) async throws -> Bool {
|
||||
func login(password: String, username: String? = nil) async throws -> Bool {
|
||||
struct LoginRequest: Encodable {
|
||||
let password: String
|
||||
let username: String?
|
||||
}
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
@@ -44,7 +52,7 @@ final class APIService {
|
||||
let exp: Int
|
||||
}
|
||||
|
||||
let response: LoginResponse = try await post("/api/auth/login", body: LoginRequest(password: password), authenticated: false)
|
||||
let response: LoginResponse = try await post("/api/auth/login", body: LoginRequest(password: password, username: username), authenticated: false)
|
||||
jwtToken = response.token
|
||||
return true
|
||||
}
|
||||
@@ -57,15 +65,22 @@ final class APIService {
|
||||
struct HealthResponse: Decodable {
|
||||
let status: String
|
||||
let authRequired: Bool
|
||||
let authMode: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status
|
||||
case authRequired = "auth_required"
|
||||
case authMode = "auth_mode"
|
||||
}
|
||||
}
|
||||
|
||||
let response: HealthResponse = try await get("/api/health", authenticated: false)
|
||||
authRequired = response.authRequired
|
||||
if let modeRaw = response.authMode, let mode = AuthMode(rawValue: modeRaw) {
|
||||
authMode = mode
|
||||
} else {
|
||||
authMode = authRequired ? .singleTenant : .disabled
|
||||
}
|
||||
return response.status == "ok"
|
||||
}
|
||||
|
||||
|
||||
@@ -338,10 +338,17 @@ final class DesktopStreamService: NSObject {
|
||||
guard let sample = sampleBuffer else { return }
|
||||
|
||||
// Enqueue to layer
|
||||
if layer.status == .failed {
|
||||
layer.flush()
|
||||
if #available(iOS 18.0, *) {
|
||||
if layer.sampleBufferRenderer.status == .failed {
|
||||
layer.sampleBufferRenderer.flush()
|
||||
}
|
||||
layer.sampleBufferRenderer.enqueue(sample)
|
||||
} else {
|
||||
if layer.status == .failed {
|
||||
layer.flush()
|
||||
}
|
||||
layer.enqueue(sample)
|
||||
}
|
||||
layer.enqueue(sample)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -154,8 +154,6 @@ struct ToolUIDataTableView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let sampleTable = ToolUIDataTable.Column(id: "model", label: "Model", width: nil)
|
||||
|
||||
VStack {
|
||||
// Preview would go here
|
||||
Text("Data Table Preview")
|
||||
|
||||
@@ -860,7 +860,7 @@ struct ControlView: View {
|
||||
// Track successful (non-error) events separately from all events
|
||||
let receivedSuccessfulEvent = OSAllocatedUnfairLock(initialState: false)
|
||||
|
||||
let streamCompleted = await withCheckedContinuation { continuation in
|
||||
_ = await withCheckedContinuation { continuation in
|
||||
let innerTask = api.streamControl { eventType, data in
|
||||
// Only count non-error events as successful for backoff reset
|
||||
if eventType != "error" {
|
||||
@@ -1488,6 +1488,7 @@ private struct ThinkingBubble: View {
|
||||
@State private var isExpanded: Bool = true
|
||||
@State private var elapsedSeconds: Int = 0
|
||||
@State private var hasAutoCollapsed = false
|
||||
@State private var timerTask: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -1549,7 +1550,20 @@ private struct ThinkingBubble: View {
|
||||
.onAppear {
|
||||
startTimer()
|
||||
}
|
||||
.onDisappear {
|
||||
timerTask?.cancel()
|
||||
timerTask = nil
|
||||
}
|
||||
.onChange(of: message.thinkingDone) { _, done in
|
||||
if done {
|
||||
timerTask?.cancel()
|
||||
timerTask = nil
|
||||
|
||||
if let startTime = message.thinkingStartTime {
|
||||
elapsedSeconds = Int(Date().timeIntervalSince(startTime))
|
||||
}
|
||||
}
|
||||
|
||||
if done && !hasAutoCollapsed {
|
||||
// Don't auto-collapse for extended thinking (> 30 seconds)
|
||||
// User may want to review what the agent was thinking about
|
||||
@@ -1580,6 +1594,9 @@ private struct ThinkingBubble: View {
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timerTask?.cancel()
|
||||
timerTask = nil
|
||||
|
||||
guard !message.thinkingDone else {
|
||||
// Calculate elapsed from start time
|
||||
if let startTime = message.thinkingStartTime {
|
||||
@@ -1587,15 +1604,16 @@ private struct ThinkingBubble: View {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Update every second while thinking
|
||||
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
|
||||
if message.thinkingDone {
|
||||
timer.invalidate()
|
||||
} else if let startTime = message.thinkingStartTime {
|
||||
elapsedSeconds = Int(Date().timeIntervalSince(startTime))
|
||||
} else {
|
||||
elapsedSeconds += 1
|
||||
timerTask = Task { @MainActor in
|
||||
while !Task.isCancelled {
|
||||
if let startTime = message.thinkingStartTime {
|
||||
elapsedSeconds = Int(Date().timeIntervalSince(startTime))
|
||||
} else {
|
||||
elapsedSeconds += 1
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ struct DesktopStreamView: View {
|
||||
// MARK: - Controls
|
||||
|
||||
private var controlsView: some View {
|
||||
VStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Play/Pause, PiP, and reconnect buttons
|
||||
HStack(spacing: 16) {
|
||||
// Play/Pause
|
||||
@@ -242,16 +242,19 @@ struct DesktopStreamView: View {
|
||||
.disabled(!streamService.isConnected || !streamService.isPipReady)
|
||||
.opacity(streamService.isConnected && streamService.isPipReady ? 1 : 0.5)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Quality and FPS sliders
|
||||
VStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// FPS slider
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
Text("FPS")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
.frame(width: 50, alignment: .leading)
|
||||
.frame(width: 55, alignment: .leading)
|
||||
|
||||
Slider(value: Binding(
|
||||
get: { Double(streamService.fps) },
|
||||
@@ -262,15 +265,16 @@ struct DesktopStreamView: View {
|
||||
Text("\(streamService.fps)")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.frame(width: 30)
|
||||
.frame(width: 30, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Quality slider
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
Text("Quality")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(Theme.textMuted)
|
||||
.frame(width: 50, alignment: .leading)
|
||||
.frame(width: 55, alignment: .leading)
|
||||
|
||||
Slider(value: Binding(
|
||||
get: { Double(streamService.quality) },
|
||||
@@ -281,12 +285,15 @@ struct DesktopStreamView: View {
|
||||
Text("\(streamService.quality)%")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.frame(width: 40)
|
||||
.frame(width: 40, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,7 +291,9 @@ struct TerminalView: View {
|
||||
break
|
||||
}
|
||||
// Continue receiving
|
||||
receiveMessages()
|
||||
Task { @MainActor in
|
||||
receiveMessages()
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
Task { @MainActor in
|
||||
|
||||
@@ -72,6 +72,8 @@ The app connects to the Open Agent backend. Configure the server URL:
|
||||
- Default: `https://agent-backend.thomas.md`
|
||||
- Can be changed in the login screen
|
||||
|
||||
In multi-user mode, the login screen also asks for a username.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"host": {
|
||||
"type": "local",
|
||||
"command": ["./target/release/host-mcp"],
|
||||
"enabled": true
|
||||
},
|
||||
"desktop": {
|
||||
"type": "local",
|
||||
"command": ["./target/release/desktop-mcp"],
|
||||
@@ -11,7 +16,7 @@
|
||||
},
|
||||
"playwright": {
|
||||
"type": "local",
|
||||
"command": ["npx", "@playwright/mcp@latest"],
|
||||
"command": ["npx", "@playwright/mcp@latest", "--isolated", "--no-sandbox"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ impl AgentContext {
|
||||
Self {
|
||||
config: self.config.clone(),
|
||||
llm: Arc::clone(&self.llm),
|
||||
tools: ToolRegistry::new(), // Fresh tools for isolation
|
||||
tools: ToolRegistry::empty(), // No built-in tools in OpenCode-only mode
|
||||
pricing: Arc::clone(&self.pricing),
|
||||
working_dir: self.working_dir.clone(),
|
||||
max_split_depth: self.max_split_depth.saturating_sub(1),
|
||||
|
||||
@@ -20,7 +20,7 @@ impl TuningParams {
|
||||
|
||||
/// Save tuning parameters to the working directory.
|
||||
pub async fn save_to_working_dir(&self, working_dir: &Path) -> anyhow::Result<PathBuf> {
|
||||
let dir = working_dir.join(".open_agent");
|
||||
let dir = working_dir.join(".openagent");
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
let path = dir.join("tuning.json");
|
||||
let content = serde_json::to_string_pretty(self)?;
|
||||
|
||||
144
src/api/auth.rs
144
src/api/auth.rs
@@ -21,18 +21,27 @@ use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};
|
||||
|
||||
use super::routes::AppState;
|
||||
use super::types::{LoginRequest, LoginResponse};
|
||||
use crate::config::Config;
|
||||
use crate::config::{AuthMode, Config, UserAccount};
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct Claims {
|
||||
/// Subject (we only need a stable sentinel)
|
||||
sub: String,
|
||||
/// Username (for display/auditing)
|
||||
#[serde(default)]
|
||||
usr: String,
|
||||
/// Issued-at unix seconds
|
||||
iat: i64,
|
||||
/// Expiration unix seconds
|
||||
exp: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthUser {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
let a_bytes = a.as_bytes();
|
||||
let b_bytes = b.as_bytes();
|
||||
@@ -46,11 +55,12 @@ fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
diff == 0
|
||||
}
|
||||
|
||||
fn issue_jwt(secret: &str, ttl_days: i64) -> anyhow::Result<(String, i64)> {
|
||||
fn issue_jwt(secret: &str, ttl_days: i64, user: &AuthUser) -> anyhow::Result<(String, i64)> {
|
||||
let now = Utc::now();
|
||||
let exp = now + Duration::days(ttl_days.max(1));
|
||||
let claims = Claims {
|
||||
sub: "open_agent_dashboard".to_string(),
|
||||
sub: user.id.clone(),
|
||||
usr: user.username.clone(),
|
||||
iat: now.timestamp(),
|
||||
exp: exp.timestamp(),
|
||||
};
|
||||
@@ -84,24 +94,82 @@ pub fn verify_token_for_config(token: &str, config: &Config) -> bool {
|
||||
Some(s) => s,
|
||||
None => return false,
|
||||
};
|
||||
verify_jwt(token, secret).is_ok()
|
||||
let Ok(claims) = verify_jwt(token, secret) else {
|
||||
return false;
|
||||
};
|
||||
match config.auth.auth_mode(config.dev_mode) {
|
||||
AuthMode::MultiUser => user_for_claims(&claims, &config.auth.users).is_some(),
|
||||
AuthMode::SingleTenant => true,
|
||||
AuthMode::Disabled => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<std::sync::Arc<AppState>>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, (StatusCode, String)> {
|
||||
// If dev_mode is enabled, we still allow login, but it won't be required.
|
||||
let expected = state
|
||||
.config
|
||||
.auth
|
||||
.dashboard_password
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
let auth_mode = state.config.auth.auth_mode(state.config.dev_mode);
|
||||
let user = match auth_mode {
|
||||
AuthMode::MultiUser => {
|
||||
let username = req.username.as_deref().unwrap_or("").trim();
|
||||
if username.is_empty() {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Username required".to_string()));
|
||||
}
|
||||
// Find user and verify password. Use a single generic error message
|
||||
// for both invalid username and invalid password to prevent username enumeration.
|
||||
let account = state
|
||||
.config
|
||||
.auth
|
||||
.users
|
||||
.iter()
|
||||
.find(|u| u.username.trim() == username);
|
||||
|
||||
if expected.is_empty() || !constant_time_eq(req.password.trim(), expected) {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid password".to_string()));
|
||||
}
|
||||
let valid = match account {
|
||||
Some(acc) => {
|
||||
!acc.password.trim().is_empty()
|
||||
&& constant_time_eq(req.password.trim(), acc.password.trim())
|
||||
}
|
||||
None => {
|
||||
// Perform a dummy comparison to prevent timing attacks
|
||||
let _ = constant_time_eq(req.password.trim(), "dummy_password_for_timing");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !valid {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid username or password".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let account = account.unwrap();
|
||||
let effective_id = effective_user_id(account);
|
||||
|
||||
AuthUser {
|
||||
id: effective_id,
|
||||
username: account.username.clone(),
|
||||
}
|
||||
}
|
||||
AuthMode::SingleTenant | AuthMode::Disabled => {
|
||||
// If dev_mode is enabled, we still allow login, but it won't be required.
|
||||
let expected = state
|
||||
.config
|
||||
.auth
|
||||
.dashboard_password
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
|
||||
if expected.is_empty() || !constant_time_eq(req.password.trim(), expected) {
|
||||
return Err((StatusCode::UNAUTHORIZED, "Invalid password".to_string()));
|
||||
}
|
||||
|
||||
AuthUser {
|
||||
id: "default".to_string(),
|
||||
username: "default".to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let secret = state.config.auth.jwt_secret.as_deref().ok_or_else(|| {
|
||||
(
|
||||
@@ -110,7 +178,7 @@ pub async fn login(
|
||||
)
|
||||
})?;
|
||||
|
||||
let (token, exp) = issue_jwt(secret, state.config.auth.jwt_ttl_days)
|
||||
let (token, exp) = issue_jwt(secret, state.config.auth.jwt_ttl_days, &user)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(LoginResponse { token, exp }))
|
||||
@@ -118,11 +186,15 @@ pub async fn login(
|
||||
|
||||
pub async fn require_auth(
|
||||
State(state): State<std::sync::Arc<AppState>>,
|
||||
req: Request<Body>,
|
||||
mut req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Dev mode => no auth checks.
|
||||
if state.config.dev_mode {
|
||||
req.extensions_mut().insert(AuthUser {
|
||||
id: "dev".to_string(),
|
||||
username: "dev".to_string(),
|
||||
});
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
@@ -154,7 +226,45 @@ pub async fn require_auth(
|
||||
}
|
||||
|
||||
match verify_jwt(token, secret) {
|
||||
Ok(_claims) => next.run(req).await,
|
||||
Ok(claims) => {
|
||||
let user = match state.config.auth.auth_mode(state.config.dev_mode) {
|
||||
AuthMode::MultiUser => match user_for_claims(&claims, &state.config.auth.users) {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
return (StatusCode::UNAUTHORIZED, "Invalid user").into_response();
|
||||
}
|
||||
},
|
||||
AuthMode::SingleTenant => AuthUser {
|
||||
id: claims.sub,
|
||||
username: claims.usr,
|
||||
},
|
||||
AuthMode::Disabled => AuthUser {
|
||||
id: "default".to_string(),
|
||||
username: "default".to_string(),
|
||||
},
|
||||
};
|
||||
req.extensions_mut().insert(user);
|
||||
next.run(req).await
|
||||
}
|
||||
Err(_) => (StatusCode::UNAUTHORIZED, "Invalid or expired token").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the effective user ID (id if non-empty, otherwise username).
|
||||
fn effective_user_id(user: &UserAccount) -> String {
|
||||
if user.id.is_empty() {
|
||||
user.username.clone()
|
||||
} else {
|
||||
user.id.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn user_for_claims(claims: &Claims, users: &[UserAccount]) -> Option<AuthUser> {
|
||||
users
|
||||
.iter()
|
||||
.find(|u| effective_user_id(u) == claims.sub)
|
||||
.map(|u| AuthUser {
|
||||
id: effective_user_id(u),
|
||||
username: u.username.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
1435
src/api/control.rs
1435
src/api/control.rs
File diff suppressed because it is too large
Load Diff
@@ -95,7 +95,7 @@ enum ClientCommand {
|
||||
}
|
||||
|
||||
/// Handle the WebSocket connection for desktop streaming
|
||||
async fn handle_desktop_stream(mut socket: WebSocket, params: StreamParams) {
|
||||
async fn handle_desktop_stream(socket: WebSocket, params: StreamParams) {
|
||||
let x11_display = params.display;
|
||||
let fps = params.fps.unwrap_or(10).clamp(1, 30);
|
||||
let quality = params.quality.unwrap_or(70).clamp(10, 100);
|
||||
@@ -131,9 +131,8 @@ async fn handle_desktop_stream(mut socket: WebSocket, params: StreamParams) {
|
||||
|
||||
// Streaming state
|
||||
let mut paused = false;
|
||||
let mut current_fps = fps;
|
||||
let mut current_quality = quality;
|
||||
let mut frame_interval = Duration::from_millis(1000 / current_fps as u64);
|
||||
let mut frame_interval = Duration::from_millis(1000 / fps as u64);
|
||||
|
||||
// Main streaming loop
|
||||
let mut stream_task = tokio::spawn(async move {
|
||||
@@ -152,11 +151,13 @@ async fn handle_desktop_stream(mut socket: WebSocket, params: StreamParams) {
|
||||
tracing::debug!("Stream resumed");
|
||||
}
|
||||
ClientCommand::SetFps { fps: new_fps } => {
|
||||
current_fps = new_fps.clamp(1, 30);
|
||||
frame_interval = Duration::from_millis(1000 / current_fps as u64);
|
||||
tracing::debug!(fps = current_fps, "FPS changed");
|
||||
let clamped = new_fps.clamp(1, 30);
|
||||
frame_interval = Duration::from_millis(1000 / clamped as u64);
|
||||
tracing::debug!(fps = clamped, "FPS changed");
|
||||
}
|
||||
ClientCommand::SetQuality { quality: new_quality } => {
|
||||
ClientCommand::SetQuality {
|
||||
quality: new_quality,
|
||||
} => {
|
||||
current_quality = new_quality.clamp(10, 100);
|
||||
tracing::debug!(quality = current_quality, "Quality changed");
|
||||
}
|
||||
|
||||
@@ -500,6 +500,9 @@ pub async fn upload_chunk(
|
||||
Query(q): Query<ChunkUploadQuery>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
if q.path.trim().is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "Invalid path".to_string()));
|
||||
}
|
||||
// Sanitize upload_id to prevent path traversal attacks
|
||||
let safe_upload_id = sanitize_path_component(&q.upload_id);
|
||||
if safe_upload_id.is_empty() {
|
||||
|
||||
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::mcp::{AddMcpRequest, McpServerState};
|
||||
use crate::workspace;
|
||||
|
||||
use super::routes::AppState;
|
||||
|
||||
@@ -40,12 +41,13 @@ pub async fn add_mcp(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<AddMcpRequest>,
|
||||
) -> Result<Json<McpServerState>, (StatusCode, String)> {
|
||||
state
|
||||
let added = state
|
||||
.mcp
|
||||
.add(req)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||
let _ = workspace::sync_all_workspaces(&state.config, &state.mcp).await;
|
||||
Ok(Json(added))
|
||||
}
|
||||
|
||||
/// Remove an MCP server.
|
||||
@@ -57,8 +59,9 @@ pub async fn remove_mcp(
|
||||
.mcp
|
||||
.remove(id)
|
||||
.await
|
||||
.map(|_| Json(serde_json::json!({ "success": true })))
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||
let _ = workspace::sync_all_workspaces(&state.config, &state.mcp).await;
|
||||
Ok(Json(serde_json::json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// Enable an MCP server.
|
||||
@@ -66,12 +69,13 @@ pub async fn enable_mcp(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<McpServerState>, (StatusCode, String)> {
|
||||
state
|
||||
let updated = state
|
||||
.mcp
|
||||
.enable(id)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||
let _ = workspace::sync_all_workspaces(&state.config, &state.mcp).await;
|
||||
Ok(Json(updated))
|
||||
}
|
||||
|
||||
/// Disable an MCP server.
|
||||
@@ -79,12 +83,13 @@ pub async fn disable_mcp(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<McpServerState>, (StatusCode, String)> {
|
||||
state
|
||||
let updated = state
|
||||
.mcp
|
||||
.disable(id)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||
let _ = workspace::sync_all_workspaces(&state.config, &state.mcp).await;
|
||||
Ok(Json(updated))
|
||||
}
|
||||
|
||||
/// Refresh an MCP server (reconnect and discover tools).
|
||||
@@ -146,17 +151,6 @@ pub enum ToolSource {
|
||||
pub async fn list_tools(State(state): State<Arc<AppState>>) -> Json<Vec<ToolInfo>> {
|
||||
let mut tools = Vec::new();
|
||||
|
||||
// Add built-in tools from the ToolRegistry
|
||||
let builtin_tools = crate::tools::ToolRegistry::new().list_tools();
|
||||
for t in builtin_tools {
|
||||
tools.push(ToolInfo {
|
||||
name: t.name.clone(),
|
||||
description: t.description.clone(),
|
||||
source: ToolSource::Builtin,
|
||||
enabled: state.mcp.is_tool_enabled(&t.name).await,
|
||||
});
|
||||
}
|
||||
|
||||
// Add MCP tools
|
||||
let mcp_tools = state.mcp.list_tools().await;
|
||||
let mcp_states = state.mcp.list().await;
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
//! - Working directory (isolated per mission)
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -27,6 +26,7 @@ use crate::mcp::McpRegistry;
|
||||
use crate::memory::{ContextBuilder, MemorySystem};
|
||||
use crate::task::{extract_deliverables, DeliverableSet, VerificationCriteria};
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::workspace;
|
||||
|
||||
use super::control::{
|
||||
AgentEvent, AgentTreeNode, ControlStatus, ExecutionProgress, FrontendToolHub,
|
||||
@@ -108,25 +108,6 @@ pub struct MissionRunner {
|
||||
pub explicitly_completed: bool,
|
||||
}
|
||||
|
||||
/// Compute the working directory path for a mission.
|
||||
/// Format: /root/work/mission-{first 8 chars of UUID}/
|
||||
pub fn mission_working_dir(base_work_dir: &std::path::Path, mission_id: Uuid) -> PathBuf {
|
||||
let short_id = &mission_id.to_string()[..8];
|
||||
base_work_dir.join(format!("mission-{}", short_id))
|
||||
}
|
||||
|
||||
/// Ensure the mission working directory exists with proper structure.
|
||||
/// Creates: mission-xxx/, mission-xxx/output/, mission-xxx/temp/
|
||||
pub fn ensure_mission_dir(
|
||||
base_work_dir: &std::path::Path,
|
||||
mission_id: Uuid,
|
||||
) -> std::io::Result<PathBuf> {
|
||||
let dir = mission_working_dir(base_work_dir, mission_id);
|
||||
std::fs::create_dir_all(dir.join("output"))?;
|
||||
std::fs::create_dir_all(dir.join("temp"))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
impl MissionRunner {
|
||||
/// Create a new mission runner.
|
||||
pub fn new(mission_id: Uuid, model_override: Option<String>) -> Self {
|
||||
@@ -436,7 +417,7 @@ async fn run_mission_turn(
|
||||
convo.push_str("User:\n");
|
||||
convo.push_str(&user_message);
|
||||
convo.push_str(&deliverable_reminder);
|
||||
convo.push_str("\n\nInstructions:\n- Continue the conversation helpfully.\n- You may use tools to gather information or make changes.\n- When appropriate, use Tool UI tools (ui_*) for structured output or to ask for user selections.\n- For large data processing tasks (>10KB), use run_command to execute Python scripts rather than processing inline.\n- USE information already provided in the message - do not ask for URLs, paths, or details that were already given.\n- When you have fully completed the user's goal or determined it cannot be completed, use the complete_mission tool to mark the mission status.");
|
||||
convo.push_str("\n\nInstructions:\n- Continue the conversation helpfully.\n- Use available tools to gather information or make changes.\n- For large data processing tasks (>10KB), prefer executing scripts rather than inline processing.\n- USE information already provided in the message - do not ask for URLs, paths, or details that were already given.\n- When you have fully completed the user's goal or determined it cannot be completed, state that clearly in your final response.");
|
||||
convo.push_str(multi_step_instructions);
|
||||
convo.push_str("\n");
|
||||
|
||||
@@ -458,30 +439,24 @@ async fn run_mission_turn(
|
||||
// Create LLM client
|
||||
let llm = Arc::new(OpenRouterClient::new(config.api_key.clone()));
|
||||
|
||||
// Ensure mission working directory exists
|
||||
// This creates /root/work/mission-{short_id}/ with output/ and temp/ subdirs
|
||||
let base_work_dir = config.working_dir.join("work");
|
||||
let mission_work_dir = match ensure_mission_dir(&base_work_dir, mission_id) {
|
||||
Ok(dir) => {
|
||||
tracing::info!(
|
||||
"Mission {} working directory: {}",
|
||||
mission_id,
|
||||
dir.display()
|
||||
);
|
||||
dir
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to create mission directory, using default: {}", e);
|
||||
config.working_dir.clone()
|
||||
}
|
||||
};
|
||||
// Ensure mission workspace exists and is configured for OpenCode.
|
||||
let mission_work_dir =
|
||||
match workspace::prepare_mission_workspace(&config, &mcp, mission_id).await {
|
||||
Ok(dir) => {
|
||||
tracing::info!(
|
||||
"Mission {} workspace directory: {}",
|
||||
mission_id,
|
||||
dir.display()
|
||||
);
|
||||
dir
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to prepare mission workspace, using default: {}", e);
|
||||
config.working_dir.clone()
|
||||
}
|
||||
};
|
||||
|
||||
// Create shared memory reference for memory tools
|
||||
let shared_memory: Option<crate::tools::memory::SharedMemory> = memory
|
||||
.as_ref()
|
||||
.map(|m| Arc::new(tokio::sync::RwLock::new(Some(m.clone()))));
|
||||
|
||||
let tools = ToolRegistry::with_options(mission_control.clone(), shared_memory);
|
||||
let tools = ToolRegistry::empty();
|
||||
let mut ctx = AgentContext::with_memory(
|
||||
config.clone(),
|
||||
llm,
|
||||
|
||||
@@ -51,9 +51,13 @@ pub struct ProvidersConfig {
|
||||
|
||||
/// Load providers configuration from file.
|
||||
fn load_providers_config(working_dir: &str) -> ProvidersConfig {
|
||||
let config_path = format!("{}/.open_agent/providers.json", working_dir);
|
||||
let config_path = format!("{}/.openagent/providers.json", working_dir);
|
||||
let legacy_path = format!("{}/.open_agent/providers.json", working_dir);
|
||||
|
||||
match std::fs::read_to_string(&config_path) {
|
||||
let contents =
|
||||
std::fs::read_to_string(&config_path).or_else(|_| std::fs::read_to_string(&legacy_path));
|
||||
|
||||
match contents {
|
||||
Ok(contents) => match serde_json::from_str(&contents) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
@@ -63,8 +67,9 @@ fn load_providers_config(working_dir: &str) -> ProvidersConfig {
|
||||
},
|
||||
Err(_) => {
|
||||
tracing::info!(
|
||||
"No providers.json found at {}. Using defaults.",
|
||||
config_path
|
||||
"No providers.json found at {} or {}. Using defaults.",
|
||||
config_path,
|
||||
legacy_path
|
||||
);
|
||||
default_providers_config()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use tokio::sync::RwLock;
|
||||
|
||||
use axum::middleware;
|
||||
use axum::{
|
||||
extract::{DefaultBodyLimit, Path, Query, State},
|
||||
extract::{DefaultBodyLimit, Extension, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{
|
||||
sse::{Event, Sse},
|
||||
@@ -23,13 +23,14 @@ use uuid::Uuid;
|
||||
|
||||
use crate::agents::{AgentContext, AgentRef, OpenCodeAgent};
|
||||
use crate::budget::ModelPricing;
|
||||
use crate::config::Config;
|
||||
use crate::config::{AuthMode, Config};
|
||||
use crate::llm::OpenRouterClient;
|
||||
use crate::mcp::McpRegistry;
|
||||
use crate::memory::{self, MemorySystem};
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::workspace;
|
||||
|
||||
use super::auth;
|
||||
use super::auth::{self, AuthUser};
|
||||
use super::console;
|
||||
use super::control;
|
||||
use super::desktop_stream;
|
||||
@@ -40,13 +41,13 @@ use super::types::*;
|
||||
/// Shared application state.
|
||||
pub struct AppState {
|
||||
pub config: Config,
|
||||
pub tasks: RwLock<HashMap<Uuid, TaskState>>,
|
||||
pub tasks: RwLock<HashMap<String, HashMap<Uuid, TaskState>>>,
|
||||
/// The agent used for task execution
|
||||
pub root_agent: AgentRef,
|
||||
/// Memory system (optional)
|
||||
pub memory: Option<MemorySystem>,
|
||||
/// Global interactive control session
|
||||
pub control: control::ControlState,
|
||||
pub control: control::ControlHub,
|
||||
/// MCP server registry
|
||||
pub mcp: Arc<McpRegistry>,
|
||||
/// Benchmark registry for task-aware model selection
|
||||
@@ -60,8 +61,14 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
|
||||
// Always use OpenCode backend
|
||||
let root_agent: AgentRef = Arc::new(OpenCodeAgent::new(config.clone()));
|
||||
|
||||
// Initialize memory system (optional - needs Supabase config)
|
||||
let memory = memory::init_memory(&config.memory, &config.api_key).await;
|
||||
// Initialize memory system (optional - needs Supabase config).
|
||||
// Disable memory in multi-user mode to avoid cross-user leakage.
|
||||
let memory = if matches!(config.auth.auth_mode(config.dev_mode), AuthMode::MultiUser) {
|
||||
tracing::warn!("Multi-user auth enabled: disabling memory system");
|
||||
None
|
||||
} else {
|
||||
memory::init_memory(&config.memory, &config.api_key).await
|
||||
};
|
||||
|
||||
// Initialize MCP registry
|
||||
let mcp = Arc::new(McpRegistry::new(&config.working_dir).await);
|
||||
@@ -80,7 +87,7 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
|
||||
let resolver = crate::budget::load_resolver(&config.working_dir.to_string_lossy());
|
||||
|
||||
// Spawn the single global control session actor.
|
||||
let control_state = control::spawn_control_session(
|
||||
let control_state = control::ControlHub::new(
|
||||
config.clone(),
|
||||
Arc::clone(&root_agent),
|
||||
memory.clone(),
|
||||
@@ -265,33 +272,43 @@ async fn shutdown_signal(state: Arc<AppState>) {
|
||||
|
||||
tracing::info!("Shutdown signal received, marking running missions as interrupted...");
|
||||
|
||||
// Send graceful shutdown command to control session
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
if let Err(e) = state
|
||||
.control
|
||||
.cmd_tx
|
||||
.send(control::ControlCommand::GracefulShutdown { respond: tx })
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to send shutdown command: {}", e);
|
||||
// Send graceful shutdown command to all control sessions
|
||||
let sessions = state.control.all_sessions().await;
|
||||
if sessions.is_empty() {
|
||||
tracing::info!("No active control sessions to shut down");
|
||||
return;
|
||||
}
|
||||
|
||||
match rx.await {
|
||||
Ok(interrupted_ids) => {
|
||||
if interrupted_ids.is_empty() {
|
||||
tracing::info!("No running missions to interrupt");
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Marked {} missions as interrupted: {:?}",
|
||||
interrupted_ids.len(),
|
||||
interrupted_ids
|
||||
);
|
||||
let mut all_interrupted: Vec<Uuid> = Vec::new();
|
||||
for control in sessions {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
if let Err(e) = control
|
||||
.cmd_tx
|
||||
.send(control::ControlCommand::GracefulShutdown { respond: tx })
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to send shutdown command: {}", e);
|
||||
continue;
|
||||
}
|
||||
|
||||
match rx.await {
|
||||
Ok(mut interrupted_ids) => {
|
||||
all_interrupted.append(&mut interrupted_ids);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to receive shutdown response: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to receive shutdown response: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
if all_interrupted.is_empty() {
|
||||
tracing::info!("No running missions to interrupt");
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Marked {} missions as interrupted: {:?}",
|
||||
all_interrupted.len(),
|
||||
all_interrupted
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!("Graceful shutdown complete");
|
||||
@@ -299,32 +316,51 @@ async fn shutdown_signal(state: Arc<AppState>) {
|
||||
|
||||
/// Health check endpoint.
|
||||
async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
|
||||
let auth_mode = match state.config.auth.auth_mode(state.config.dev_mode) {
|
||||
AuthMode::Disabled => "disabled",
|
||||
AuthMode::SingleTenant => "single_tenant",
|
||||
AuthMode::MultiUser => "multi_user",
|
||||
};
|
||||
Json(HealthResponse {
|
||||
status: "ok".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
dev_mode: state.config.dev_mode,
|
||||
auth_required: state.config.auth.auth_required(state.config.dev_mode),
|
||||
auth_mode: auth_mode.to_string(),
|
||||
max_iterations: state.config.max_iterations,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get system statistics.
|
||||
async fn get_stats(State(state): State<Arc<AppState>>) -> Json<StatsResponse> {
|
||||
async fn get_stats(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
) -> Json<StatsResponse> {
|
||||
let tasks = state.tasks.read().await;
|
||||
let user_tasks = tasks.get(&user.id);
|
||||
|
||||
let total_tasks = tasks.len();
|
||||
let active_tasks = tasks
|
||||
.values()
|
||||
.filter(|t| t.status == TaskStatus::Running)
|
||||
.count();
|
||||
let completed_tasks = tasks
|
||||
.values()
|
||||
.filter(|t| t.status == TaskStatus::Completed)
|
||||
.count();
|
||||
let failed_tasks = tasks
|
||||
.values()
|
||||
.filter(|t| t.status == TaskStatus::Failed)
|
||||
.count();
|
||||
let total_tasks = user_tasks.map(|t| t.len()).unwrap_or(0);
|
||||
let active_tasks = user_tasks
|
||||
.map(|t| {
|
||||
t.values()
|
||||
.filter(|s| s.status == TaskStatus::Running)
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let completed_tasks = user_tasks
|
||||
.map(|t| {
|
||||
t.values()
|
||||
.filter(|s| s.status == TaskStatus::Completed)
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let failed_tasks = user_tasks
|
||||
.map(|t| {
|
||||
t.values()
|
||||
.filter(|s| s.status == TaskStatus::Failed)
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
// Calculate total cost from runs in database
|
||||
let total_cost_cents = if let Some(mem) = &state.memory {
|
||||
@@ -351,9 +387,15 @@ async fn get_stats(State(state): State<Arc<AppState>>) -> Json<StatsResponse> {
|
||||
}
|
||||
|
||||
/// List all tasks.
|
||||
async fn list_tasks(State(state): State<Arc<AppState>>) -> Json<Vec<TaskState>> {
|
||||
async fn list_tasks(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
) -> Json<Vec<TaskState>> {
|
||||
let tasks = state.tasks.read().await;
|
||||
let mut task_list: Vec<_> = tasks.values().cloned().collect();
|
||||
let mut task_list: Vec<_> = tasks
|
||||
.get(&user.id)
|
||||
.map(|t| t.values().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
// Sort by most recent first (by ID since UUIDs are time-ordered)
|
||||
task_list.sort_by(|a, b| b.id.cmp(&a.id));
|
||||
Json(task_list)
|
||||
@@ -362,11 +404,13 @@ async fn list_tasks(State(state): State<Arc<AppState>>) -> Json<Vec<TaskState>>
|
||||
/// Stop a running task.
|
||||
async fn stop_task(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let mut tasks = state.tasks.write().await;
|
||||
let user_tasks = tasks.entry(user.id).or_default();
|
||||
|
||||
if let Some(task) = tasks.get_mut(&id) {
|
||||
if let Some(task) = user_tasks.get_mut(&id) {
|
||||
if task.status == TaskStatus::Running {
|
||||
task.status = TaskStatus::Cancelled;
|
||||
task.result = Some("Task was cancelled by user".to_string());
|
||||
@@ -388,6 +432,7 @@ async fn stop_task(
|
||||
/// Create a new task.
|
||||
async fn create_task(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
Json(req): Json<CreateTaskRequest>,
|
||||
) -> Result<Json<CreateTaskResponse>, (StatusCode, String)> {
|
||||
let id = Uuid::new_v4();
|
||||
@@ -408,19 +453,27 @@ async fn create_task(
|
||||
// Store task
|
||||
{
|
||||
let mut tasks = state.tasks.write().await;
|
||||
tasks.insert(id, task_state);
|
||||
tasks
|
||||
.entry(user.id.clone())
|
||||
.or_default()
|
||||
.insert(id, task_state);
|
||||
}
|
||||
|
||||
// Spawn background task to run the agent
|
||||
let state_clone = Arc::clone(&state);
|
||||
let task_description = req.task.clone();
|
||||
let working_dir = req
|
||||
.working_dir
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| state.config.working_dir.clone());
|
||||
let working_dir = req.working_dir.map(std::path::PathBuf::from);
|
||||
|
||||
tokio::spawn(async move {
|
||||
run_agent_task(state_clone, id, task_description, model, working_dir).await;
|
||||
run_agent_task(
|
||||
state_clone,
|
||||
user.id,
|
||||
id,
|
||||
task_description,
|
||||
model,
|
||||
working_dir,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
Ok(Json(CreateTaskResponse {
|
||||
@@ -432,16 +485,19 @@ async fn create_task(
|
||||
/// Run the agent for a task (background).
|
||||
async fn run_agent_task(
|
||||
state: Arc<AppState>,
|
||||
user_id: String,
|
||||
task_id: Uuid,
|
||||
task_description: String,
|
||||
requested_model: String,
|
||||
working_dir: std::path::PathBuf,
|
||||
working_dir: Option<std::path::PathBuf>,
|
||||
) {
|
||||
// Update status to running
|
||||
{
|
||||
let mut tasks = state.tasks.write().await;
|
||||
if let Some(task_state) = tasks.get_mut(&task_id) {
|
||||
task_state.status = TaskStatus::Running;
|
||||
if let Some(user_tasks) = tasks.get_mut(&user_id) {
|
||||
if let Some(task_state) = user_tasks.get_mut(&task_id) {
|
||||
task_state.status = TaskStatus::Running;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,9 +511,11 @@ async fn run_agent_task(
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let mut tasks = state.tasks.write().await;
|
||||
if let Some(task_state) = tasks.get_mut(&task_id) {
|
||||
task_state.status = TaskStatus::Failed;
|
||||
task_state.result = Some(format!("Failed to create task: {}", e));
|
||||
if let Some(user_tasks) = tasks.get_mut(&user_id) {
|
||||
if let Some(task_state) = user_tasks.get_mut(&task_id) {
|
||||
task_state.status = TaskStatus::Failed;
|
||||
task_state.result = Some(format!("Failed to create task: {}", e));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -468,9 +526,28 @@ async fn run_agent_task(
|
||||
task.analysis_mut().requested_model = Some(requested_model);
|
||||
}
|
||||
|
||||
// Prepare workspace for this task (or use a provided custom dir)
|
||||
let working_dir = if let Some(dir) = working_dir {
|
||||
match workspace::prepare_custom_workspace(&state.config, &state.mcp, dir).await {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to prepare custom workspace: {}", e);
|
||||
state.config.working_dir.clone()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match workspace::prepare_task_workspace(&state.config, &state.mcp, task_id).await {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to prepare task workspace: {}", e);
|
||||
state.config.working_dir.clone()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create context with the specified working directory and memory
|
||||
let llm = Arc::new(OpenRouterClient::new(state.config.api_key.clone()));
|
||||
let tools = ToolRegistry::new();
|
||||
let tools = ToolRegistry::empty();
|
||||
let pricing = Arc::new(ModelPricing::new());
|
||||
|
||||
let mut ctx = AgentContext::with_memory(
|
||||
@@ -605,47 +682,50 @@ async fn run_agent_task(
|
||||
// Update task with result
|
||||
{
|
||||
let mut tasks = state.tasks.write().await;
|
||||
if let Some(task_state) = tasks.get_mut(&task_id) {
|
||||
// Extract iterations and tools from result data
|
||||
// Note: RootAgent wraps executor data under "execution" field
|
||||
if let Some(data) = &result.data {
|
||||
// Try to get execution data (may be nested under "execution" from RootAgent)
|
||||
let exec_data = data.get("execution").unwrap_or(data);
|
||||
if let Some(user_tasks) = tasks.get_mut(&user_id) {
|
||||
if let Some(task_state) = user_tasks.get_mut(&task_id) {
|
||||
// Extract iterations and tools from result data
|
||||
// Note: RootAgent wraps executor data under "execution" field
|
||||
if let Some(data) = &result.data {
|
||||
// Try to get execution data (may be nested under "execution" from RootAgent)
|
||||
let exec_data = data.get("execution").unwrap_or(data);
|
||||
|
||||
// Update iterations count from execution signals
|
||||
if let Some(signals) = exec_data.get("execution_signals") {
|
||||
if let Some(iterations) = signals.get("iterations").and_then(|v| v.as_u64()) {
|
||||
task_state.iterations = iterations as usize;
|
||||
// Update iterations count from execution signals
|
||||
if let Some(signals) = exec_data.get("execution_signals") {
|
||||
if let Some(iterations) = signals.get("iterations").and_then(|v| v.as_u64())
|
||||
{
|
||||
task_state.iterations = iterations as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add log entries for tools used
|
||||
if let Some(tools_used) = exec_data.get("tools_used") {
|
||||
if let Some(arr) = tools_used.as_array() {
|
||||
for tool in arr {
|
||||
task_state.log.push(TaskLogEntry {
|
||||
timestamp: "0".to_string(),
|
||||
entry_type: LogEntryType::ToolCall,
|
||||
content: tool.as_str().unwrap_or("").to_string(),
|
||||
});
|
||||
// Add log entries for tools used
|
||||
if let Some(tools_used) = exec_data.get("tools_used") {
|
||||
if let Some(arr) = tools_used.as_array() {
|
||||
for tool in arr {
|
||||
task_state.log.push(TaskLogEntry {
|
||||
timestamp: "0".to_string(),
|
||||
entry_type: LogEntryType::ToolCall,
|
||||
content: tool.as_str().unwrap_or("").to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add final response log
|
||||
task_state.log.push(TaskLogEntry {
|
||||
timestamp: "0".to_string(),
|
||||
entry_type: LogEntryType::Response,
|
||||
content: result.output.clone(),
|
||||
});
|
||||
// Add final response log
|
||||
task_state.log.push(TaskLogEntry {
|
||||
timestamp: "0".to_string(),
|
||||
entry_type: LogEntryType::Response,
|
||||
content: result.output.clone(),
|
||||
});
|
||||
|
||||
if result.success {
|
||||
task_state.status = TaskStatus::Completed;
|
||||
task_state.result = Some(result.output);
|
||||
} else {
|
||||
task_state.status = TaskStatus::Failed;
|
||||
task_state.result = Some(format!("Error: {}", result.output));
|
||||
if result.success {
|
||||
task_state.status = TaskStatus::Completed;
|
||||
task_state.result = Some(result.output);
|
||||
} else {
|
||||
task_state.status = TaskStatus::Failed;
|
||||
task_state.result = Some(format!("Error: {}", result.output));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -654,13 +734,13 @@ async fn run_agent_task(
|
||||
/// Get task status and result.
|
||||
async fn get_task(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<TaskState>, (StatusCode, String)> {
|
||||
let tasks = state.tasks.read().await;
|
||||
|
||||
tasks
|
||||
.get(&id)
|
||||
.cloned()
|
||||
.get(&user.id)
|
||||
.and_then(|t| t.get(&id).cloned())
|
||||
.map(Json)
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Task {} not found", id)))
|
||||
}
|
||||
@@ -668,13 +748,18 @@ async fn get_task(
|
||||
/// Stream task progress via SSE.
|
||||
async fn stream_task(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, (StatusCode, String)>
|
||||
{
|
||||
// Check task exists
|
||||
{
|
||||
let tasks = state.tasks.read().await;
|
||||
if !tasks.contains_key(&id) {
|
||||
if !tasks
|
||||
.get(&user.id)
|
||||
.map(|t| t.contains_key(&id))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err((StatusCode::NOT_FOUND, format!("Task {} not found", id)));
|
||||
}
|
||||
}
|
||||
@@ -686,7 +771,8 @@ async fn stream_task(
|
||||
loop {
|
||||
let (status, log_entries, result) = {
|
||||
let tasks = state.tasks.read().await;
|
||||
if let Some(task) = tasks.get(&id) {
|
||||
let user_tasks = tasks.get(&user.id);
|
||||
if let Some(task) = user_tasks.and_then(|t| t.get(&id)) {
|
||||
(task.status.clone(), task.log.clone(), task.result.clone())
|
||||
} else {
|
||||
break;
|
||||
@@ -738,6 +824,17 @@ async fn list_runs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<ListRunsQuery>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
if state.memory.is_none() {
|
||||
return Ok(Json(serde_json::json!({
|
||||
"runs": [],
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
})));
|
||||
}
|
||||
|
||||
let mem = state.memory.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -745,9 +842,6 @@ async fn list_runs(
|
||||
)
|
||||
})?;
|
||||
|
||||
let limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
let runs = mem
|
||||
.retriever
|
||||
.list_runs(limit, offset)
|
||||
@@ -766,6 +860,9 @@ async fn get_run(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
if state.memory.is_none() {
|
||||
return Err((StatusCode::NOT_FOUND, "Run not found".to_string()));
|
||||
}
|
||||
let mem = state.memory.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -789,6 +886,12 @@ async fn get_run_events(
|
||||
Path(id): Path<Uuid>,
|
||||
Query(params): Query<ListRunsQuery>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
if state.memory.is_none() {
|
||||
return Ok(Json(serde_json::json!({
|
||||
"run_id": id,
|
||||
"events": []
|
||||
})));
|
||||
}
|
||||
let mem = state.memory.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -813,6 +916,12 @@ async fn get_run_tasks(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
if state.memory.is_none() {
|
||||
return Ok(Json(serde_json::json!({
|
||||
"run_id": id,
|
||||
"tasks": []
|
||||
})));
|
||||
}
|
||||
let mem = state.memory.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -845,6 +954,12 @@ async fn search_memory(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<SearchMemoryQuery>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
if state.memory.is_none() {
|
||||
return Ok(Json(serde_json::json!({
|
||||
"query": params.q,
|
||||
"results": []
|
||||
})));
|
||||
}
|
||||
let mem = state.memory.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
|
||||
@@ -146,6 +146,9 @@ pub struct HealthResponse {
|
||||
/// Whether auth is required for API requests (dev_mode=false)
|
||||
pub auth_required: bool,
|
||||
|
||||
/// Authentication mode ("disabled", "single_tenant", "multi_user")
|
||||
pub auth_mode: String,
|
||||
|
||||
/// Maximum iterations per agent (from MAX_ITERATIONS env var)
|
||||
pub max_iterations: usize,
|
||||
}
|
||||
@@ -153,6 +156,8 @@ pub struct HealthResponse {
|
||||
/// Login request for dashboard auth.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
|
||||
277
src/bin/host_mcp.rs
Normal file
277
src/bin/host_mcp.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
//! MCP Server for core host tools (filesystem + command execution).
|
||||
//!
|
||||
//! Exposes a minimal set of Open Agent tools to OpenCode via MCP.
|
||||
//! Communicates over stdio using JSON-RPC 2.0.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use open_agent::tools;
|
||||
use open_agent::tools::Tool;
|
||||
|
||||
// =============================================================================
|
||||
// JSON-RPC Types
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JsonRpcRequest {
|
||||
#[allow(dead_code)]
|
||||
jsonrpc: String,
|
||||
#[serde(default)]
|
||||
id: Value,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JsonRpcResponse {
|
||||
jsonrpc: String,
|
||||
id: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JsonRpcError {
|
||||
code: i32,
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
data: Option<Value>,
|
||||
}
|
||||
|
||||
impl JsonRpcResponse {
|
||||
fn success(id: Value, result: Value) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn error(id: Value, code: i32, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id,
|
||||
result: None,
|
||||
error: Some(JsonRpcError {
|
||||
code,
|
||||
message: message.into(),
|
||||
data: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MCP Types
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ToolDefinition {
|
||||
name: String,
|
||||
description: String,
|
||||
#[serde(rename = "inputSchema")]
|
||||
input_schema: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ToolResult {
|
||||
content: Vec<ToolContent>,
|
||||
#[serde(rename = "isError")]
|
||||
is_error: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum ToolContent {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Registry
|
||||
// =============================================================================
|
||||
|
||||
fn working_dir() -> PathBuf {
|
||||
std::env::var("OPEN_AGENT_WORKSPACE")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
|
||||
}
|
||||
|
||||
fn tool_set() -> HashMap<String, Arc<dyn Tool>> {
|
||||
let mut tools: HashMap<String, Arc<dyn Tool>> = HashMap::new();
|
||||
|
||||
tools.insert("read_file".to_string(), Arc::new(tools::ReadFile));
|
||||
tools.insert(
|
||||
"write_file".to_string(),
|
||||
Arc::new(tools::WriteFile),
|
||||
);
|
||||
tools.insert(
|
||||
"delete_file".to_string(),
|
||||
Arc::new(tools::DeleteFile),
|
||||
);
|
||||
tools.insert(
|
||||
"list_directory".to_string(),
|
||||
Arc::new(tools::ListDirectory),
|
||||
);
|
||||
tools.insert(
|
||||
"search_files".to_string(),
|
||||
Arc::new(tools::SearchFiles),
|
||||
);
|
||||
tools.insert("grep_search".to_string(), Arc::new(tools::GrepSearch));
|
||||
tools.insert("run_command".to_string(), Arc::new(tools::RunCommand));
|
||||
tools.insert("git_status".to_string(), Arc::new(tools::GitStatus));
|
||||
tools.insert("git_diff".to_string(), Arc::new(tools::GitDiff));
|
||||
tools.insert("git_commit".to_string(), Arc::new(tools::GitCommit));
|
||||
tools.insert("git_log".to_string(), Arc::new(tools::GitLog));
|
||||
tools.insert("web_search".to_string(), Arc::new(tools::WebSearch));
|
||||
tools.insert("fetch_url".to_string(), Arc::new(tools::FetchUrl));
|
||||
|
||||
tools
|
||||
}
|
||||
|
||||
fn tool_definitions(tools: &HashMap<String, Arc<dyn Tool>>) -> Vec<ToolDefinition> {
|
||||
let mut defs = Vec::new();
|
||||
for tool in tools.values() {
|
||||
defs.push(ToolDefinition {
|
||||
name: tool.name().to_string(),
|
||||
description: tool.description().to_string(),
|
||||
input_schema: tool.parameters_schema(),
|
||||
});
|
||||
}
|
||||
defs.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
defs
|
||||
}
|
||||
|
||||
fn execute_tool(
|
||||
runtime: &tokio::runtime::Runtime,
|
||||
tools: &HashMap<String, Arc<dyn Tool>>,
|
||||
name: &str,
|
||||
args: &Value,
|
||||
working_dir: &Path,
|
||||
) -> ToolResult {
|
||||
let Some(tool) = tools.get(name) else {
|
||||
return ToolResult {
|
||||
content: vec![ToolContent::Text {
|
||||
text: format!("Unknown tool: {}", name),
|
||||
}],
|
||||
is_error: true,
|
||||
};
|
||||
};
|
||||
|
||||
let result = runtime.block_on(tool.execute(args.clone(), working_dir));
|
||||
match result {
|
||||
Ok(text) => ToolResult {
|
||||
content: vec![ToolContent::Text { text }],
|
||||
is_error: false,
|
||||
},
|
||||
Err(e) => ToolResult {
|
||||
content: vec![ToolContent::Text {
|
||||
text: format!("Tool error: {}", e),
|
||||
}],
|
||||
is_error: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request(
|
||||
request: &JsonRpcRequest,
|
||||
runtime: &tokio::runtime::Runtime,
|
||||
tools: &HashMap<String, Arc<dyn Tool>>,
|
||||
working_dir: &Path,
|
||||
) -> Option<JsonRpcResponse> {
|
||||
match request.method.as_str() {
|
||||
"initialize" => Some(JsonRpcResponse::success(
|
||||
request.id.clone(),
|
||||
json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": {
|
||||
"name": "host-mcp",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
},
|
||||
"capabilities": {
|
||||
"tools": {
|
||||
"listChanged": false
|
||||
}
|
||||
}
|
||||
}),
|
||||
)),
|
||||
"notifications/initialized" | "initialized" => None,
|
||||
"tools/list" => {
|
||||
let defs = tool_definitions(tools);
|
||||
Some(JsonRpcResponse::success(request.id.clone(), json!({ "tools": defs })))
|
||||
}
|
||||
"tools/call" => {
|
||||
let name = request
|
||||
.params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let args = request
|
||||
.params
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or(json!({}));
|
||||
let result = execute_tool(runtime, tools, name, &args, working_dir);
|
||||
Some(JsonRpcResponse::success(request.id.clone(), json!(result)))
|
||||
}
|
||||
_ => Some(JsonRpcResponse::error(
|
||||
request.id.clone(),
|
||||
-32601,
|
||||
format!("Method not found: {}", request.method),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
eprintln!("[host-mcp] Starting MCP server for host tools...");
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Failed to start tokio runtime");
|
||||
|
||||
let tools = tool_set();
|
||||
let workspace = working_dir();
|
||||
|
||||
let stdin = std::io::stdin();
|
||||
let mut stdout = std::io::stdout();
|
||||
let reader = BufReader::new(stdin.lock());
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let request: JsonRpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
let response = JsonRpcResponse::error(Value::Null, -32700, e.to_string());
|
||||
let _ = writeln!(stdout, "{}", serde_json::to_string(&response).unwrap());
|
||||
let _ = stdout.flush();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(response) = handle_request(&request, &runtime, &tools, &workspace) {
|
||||
if let Ok(resp) = serde_json::to_string(&response) {
|
||||
let _ = writeln!(stdout, "{}", resp);
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
106
src/config.rs
106
src/config.rs
@@ -10,6 +10,7 @@
|
||||
//! - `OPENCODE_BASE_URL` - Optional. Base URL for OpenCode server. Defaults to `http://127.0.0.1:4096`.
|
||||
//! - `OPENCODE_AGENT` - Optional. OpenCode agent name (e.g., `build`, `plan`).
|
||||
//! - `OPENCODE_PERMISSIVE` - Optional. If true, auto-allows all permissions for OpenCode sessions (default: true).
|
||||
//! - `OPEN_AGENT_USERS` - Optional. JSON array of user accounts for multi-user auth.
|
||||
//! - `CONSOLE_SSH_HOST` - Optional. Host for dashboard console/file explorer SSH (default: 127.0.0.1).
|
||||
//! - `CONSOLE_SSH_PORT` - Optional. SSH port (default: 22).
|
||||
//! - `CONSOLE_SSH_USER` - Optional. SSH user (default: root).
|
||||
@@ -25,6 +26,7 @@
|
||||
//! and search anywhere on the machine. The `WORKING_DIR` is just the default for relative paths.
|
||||
|
||||
use base64::Engine;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -306,7 +308,7 @@ impl ConsoleSshConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// API auth configuration (single-tenant).
|
||||
/// API auth configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthConfig {
|
||||
/// Password required by the dashboard to obtain a JWT.
|
||||
@@ -317,6 +319,9 @@ pub struct AuthConfig {
|
||||
|
||||
/// JWT validity in days.
|
||||
pub jwt_ttl_days: i64,
|
||||
|
||||
/// Multi-user accounts (if set, overrides dashboard_password auth).
|
||||
pub users: Vec<UserAccount>,
|
||||
}
|
||||
|
||||
impl Default for AuthConfig {
|
||||
@@ -325,14 +330,50 @@ impl Default for AuthConfig {
|
||||
dashboard_password: None,
|
||||
jwt_secret: None,
|
||||
jwt_ttl_days: 30,
|
||||
users: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication mode for the server.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthMode {
|
||||
Disabled,
|
||||
SingleTenant,
|
||||
MultiUser,
|
||||
}
|
||||
|
||||
/// User account for multi-user auth.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct UserAccount {
|
||||
/// Stable identifier for the user (defaults to username).
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
/// Whether auth is required for API requests.
|
||||
pub fn auth_required(&self, dev_mode: bool) -> bool {
|
||||
!dev_mode && self.dashboard_password.is_some() && self.jwt_secret.is_some()
|
||||
matches!(
|
||||
self.auth_mode(dev_mode),
|
||||
AuthMode::SingleTenant | AuthMode::MultiUser
|
||||
)
|
||||
}
|
||||
|
||||
/// Determine the current auth mode.
|
||||
pub fn auth_mode(&self, dev_mode: bool) -> AuthMode {
|
||||
if dev_mode {
|
||||
return AuthMode::Disabled;
|
||||
}
|
||||
if !self.users.is_empty() {
|
||||
return AuthMode::MultiUser;
|
||||
}
|
||||
if self.dashboard_password.is_some() && self.jwt_secret.is_some() {
|
||||
return AuthMode::SingleTenant;
|
||||
}
|
||||
AuthMode::Disabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +454,25 @@ impl Config {
|
||||
// In debug builds, default to dev_mode=true; in release, default to false.
|
||||
.unwrap_or(cfg!(debug_assertions));
|
||||
|
||||
let users = std::env::var("OPEN_AGENT_USERS")
|
||||
.ok()
|
||||
.filter(|raw| !raw.trim().is_empty())
|
||||
.map(|raw| {
|
||||
serde_json::from_str::<Vec<UserAccount>>(&raw).map_err(|e| {
|
||||
ConfigError::InvalidValue("OPEN_AGENT_USERS".to_string(), e.to_string())
|
||||
})
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|mut user| {
|
||||
if user.id.trim().is_empty() {
|
||||
user.id = user.username.clone();
|
||||
}
|
||||
user
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let auth = AuthConfig {
|
||||
dashboard_password: std::env::var("DASHBOARD_PASSWORD").ok(),
|
||||
jwt_secret: std::env::var("JWT_SECRET").ok(),
|
||||
@@ -425,15 +485,47 @@ impl Config {
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(30),
|
||||
users,
|
||||
};
|
||||
|
||||
// In non-dev mode, require auth secrets to be set.
|
||||
if !dev_mode {
|
||||
if auth.dashboard_password.is_none() {
|
||||
return Err(ConfigError::MissingEnvVar("DASHBOARD_PASSWORD".to_string()));
|
||||
}
|
||||
if auth.jwt_secret.is_none() {
|
||||
return Err(ConfigError::MissingEnvVar("JWT_SECRET".to_string()));
|
||||
match auth.auth_mode(dev_mode) {
|
||||
AuthMode::MultiUser => {
|
||||
if auth.users.is_empty() {
|
||||
return Err(ConfigError::MissingEnvVar("OPEN_AGENT_USERS".to_string()));
|
||||
}
|
||||
if auth.jwt_secret.is_none() {
|
||||
return Err(ConfigError::MissingEnvVar("JWT_SECRET".to_string()));
|
||||
}
|
||||
if auth
|
||||
.users
|
||||
.iter()
|
||||
.any(|u| u.username.trim().is_empty() || u.password.trim().is_empty())
|
||||
{
|
||||
return Err(ConfigError::InvalidValue(
|
||||
"OPEN_AGENT_USERS".to_string(),
|
||||
"username/password must be non-empty".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
AuthMode::SingleTenant => {
|
||||
if auth.dashboard_password.is_none() {
|
||||
return Err(ConfigError::MissingEnvVar("DASHBOARD_PASSWORD".to_string()));
|
||||
}
|
||||
if auth.jwt_secret.is_none() {
|
||||
return Err(ConfigError::MissingEnvVar("JWT_SECRET".to_string()));
|
||||
}
|
||||
}
|
||||
AuthMode::Disabled => {
|
||||
// Provide a more specific error message when partial config exists
|
||||
if auth.dashboard_password.is_some() && auth.jwt_secret.is_none() {
|
||||
return Err(ConfigError::MissingEnvVar("JWT_SECRET".to_string()));
|
||||
}
|
||||
return Err(ConfigError::MissingEnvVar(
|
||||
"DASHBOARD_PASSWORD or OPEN_AGENT_USERS".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ pub mod memory;
|
||||
pub mod opencode;
|
||||
pub mod task;
|
||||
pub mod tools;
|
||||
pub mod workspace;
|
||||
|
||||
pub use config::Config;
|
||||
pub use config::MemoryConfig;
|
||||
|
||||
@@ -18,14 +18,25 @@ pub struct McpConfigStore {
|
||||
impl McpConfigStore {
|
||||
/// Create a new config store, loading from disk if available.
|
||||
pub async fn new(working_dir: &Path) -> Self {
|
||||
let config_dir = working_dir.join(".open_agent").join("mcp");
|
||||
let config_dir = working_dir.join(".openagent").join("mcp");
|
||||
let config_path = config_dir.join("config.json");
|
||||
let legacy_path = working_dir
|
||||
.join(".open_agent")
|
||||
.join("mcp")
|
||||
.join("config.json");
|
||||
|
||||
let configs = if config_path.exists() {
|
||||
match tokio::fs::read_to_string(&config_path).await {
|
||||
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
tokio::fs::read_to_string(&config_path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|content| serde_json::from_str(&content).ok())
|
||||
.unwrap_or_default()
|
||||
} else if legacy_path.exists() {
|
||||
tokio::fs::read_to_string(&legacy_path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|content| serde_json::from_str(&content).ok())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! MCP (Model Context Protocol) management module.
|
||||
//!
|
||||
//! Allows dynamic addition/removal of MCP servers and their tools without restarting.
|
||||
//! Configurations are persisted to `{working_dir}/.open_agent/mcp/config.json`.
|
||||
//! Configurations are persisted to `{working_dir}/.openagent/mcp/config.json`.
|
||||
|
||||
mod config;
|
||||
mod registry;
|
||||
|
||||
@@ -62,7 +62,8 @@ impl McpRegistry {
|
||||
let config_store = Arc::new(McpConfigStore::new(working_dir).await);
|
||||
|
||||
// Initialize states from configs
|
||||
let configs = config_store.list().await;
|
||||
let mut configs = config_store.list().await;
|
||||
configs = Self::ensure_defaults(&config_store, configs, working_dir).await;
|
||||
let mut states = HashMap::new();
|
||||
for config in configs {
|
||||
states.insert(config.id, McpServerState::from_config(config));
|
||||
@@ -85,6 +86,119 @@ impl McpRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the raw MCP configs (for workspace opencode.json generation).
|
||||
pub async fn list_configs(&self) -> Vec<McpServerConfig> {
|
||||
self.config_store.list().await
|
||||
}
|
||||
|
||||
fn default_configs(working_dir: &Path) -> Vec<McpServerConfig> {
|
||||
let mut desktop_env = HashMap::new();
|
||||
if let Ok(res) = std::env::var("DESKTOP_RESOLUTION") {
|
||||
if !res.trim().is_empty() {
|
||||
desktop_env.insert("DESKTOP_RESOLUTION".to_string(), res);
|
||||
}
|
||||
} else {
|
||||
desktop_env.insert("DESKTOP_RESOLUTION".to_string(), "1920x1080".to_string());
|
||||
}
|
||||
|
||||
let repo_desktop = working_dir
|
||||
.join("target")
|
||||
.join("release")
|
||||
.join("desktop-mcp");
|
||||
let desktop_command = if repo_desktop.exists() {
|
||||
repo_desktop.to_string_lossy().to_string()
|
||||
} else {
|
||||
"desktop-mcp".to_string()
|
||||
};
|
||||
let desktop = McpServerConfig::new_stdio(
|
||||
"desktop".to_string(),
|
||||
desktop_command,
|
||||
Vec::new(),
|
||||
desktop_env,
|
||||
);
|
||||
let repo_host = working_dir.join("target").join("release").join("host-mcp");
|
||||
let host_command = if repo_host.exists() {
|
||||
repo_host.to_string_lossy().to_string()
|
||||
} else {
|
||||
"host-mcp".to_string()
|
||||
};
|
||||
let host = McpServerConfig::new_stdio(
|
||||
"host".to_string(),
|
||||
host_command,
|
||||
Vec::new(),
|
||||
HashMap::new(),
|
||||
);
|
||||
let playwright = McpServerConfig::new_stdio(
|
||||
"playwright".to_string(),
|
||||
"npx".to_string(),
|
||||
vec![
|
||||
"@playwright/mcp@latest".to_string(),
|
||||
"--isolated".to_string(),
|
||||
"--no-sandbox".to_string(),
|
||||
],
|
||||
HashMap::new(),
|
||||
);
|
||||
vec![host, desktop, playwright]
|
||||
}
|
||||
|
||||
async fn ensure_defaults(
|
||||
config_store: &McpConfigStore,
|
||||
mut configs: Vec<McpServerConfig>,
|
||||
working_dir: &Path,
|
||||
) -> Vec<McpServerConfig> {
|
||||
let defaults = Self::default_configs(working_dir);
|
||||
for config in defaults {
|
||||
if configs.iter().any(|c| c.name == config.name) {
|
||||
continue;
|
||||
}
|
||||
match config_store.add(config.clone()).await {
|
||||
Ok(saved) => configs.push(saved),
|
||||
Err(e) => tracing::warn!("Failed to add default MCP {}: {}", config.name, e),
|
||||
}
|
||||
}
|
||||
// Ensure Playwright MCP runs in isolated mode and without sandboxing
|
||||
// so it can launch browsers under root.
|
||||
for config in configs.iter_mut() {
|
||||
if config.name != "playwright" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let missing_flags: Vec<&str> = match &config.transport {
|
||||
McpTransport::Stdio { args, .. } => ["--isolated", "--no-sandbox"]
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|flag| !args.iter().any(|arg| arg == *flag))
|
||||
.collect(),
|
||||
McpTransport::Http { .. } => Vec::new(),
|
||||
};
|
||||
|
||||
if missing_flags.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let McpTransport::Stdio { args, .. } = &mut config.transport {
|
||||
for flag in &missing_flags {
|
||||
args.push((*flag).to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let id = config.id;
|
||||
let _ = config_store
|
||||
.update(id, |c| {
|
||||
if let McpTransport::Stdio { args, .. } = &mut c.transport {
|
||||
for flag in ["--isolated", "--no-sandbox"] {
|
||||
if !args.iter().any(|arg| arg == flag) {
|
||||
args.push(flag.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
configs
|
||||
}
|
||||
|
||||
/// Get the next request ID for JSON-RPC
|
||||
fn next_request_id(&self) -> u64 {
|
||||
self.request_id.fetch_add(1, Ordering::SeqCst)
|
||||
|
||||
@@ -112,6 +112,7 @@ struct EmbeddingRequest {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EmbeddingResponse {
|
||||
data: Vec<EmbeddingData>,
|
||||
#[allow(dead_code)]
|
||||
#[serde(default)]
|
||||
usage: Option<EmbeddingUsage>,
|
||||
}
|
||||
@@ -123,7 +124,10 @@ struct EmbeddingData {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct EmbeddingUsage {
|
||||
#[allow(dead_code)]
|
||||
prompt_tokens: u32,
|
||||
#[allow(dead_code)]
|
||||
total_tokens: u32,
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
//! OpenCode server, with real-time event streaming.
|
||||
|
||||
use anyhow::Context;
|
||||
use std::collections::HashMap;
|
||||
use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -140,11 +140,8 @@ impl OpenCodeClient {
|
||||
let event_str = buffer[..pos].to_string();
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
if let Some(event) = parse_sse_event(
|
||||
&event_str,
|
||||
&session_id_clone,
|
||||
&mut sse_state,
|
||||
)
|
||||
if let Some(event) =
|
||||
parse_sse_event(&event_str, &session_id_clone, &mut sse_state)
|
||||
{
|
||||
let is_complete =
|
||||
matches!(event, OpenCodeEvent::MessageComplete { .. });
|
||||
@@ -345,10 +342,7 @@ fn looks_like_user_prompt(content: &str) -> bool {
|
||||
|| trimmed.contains("\nInstructions:\n")
|
||||
}
|
||||
|
||||
fn handle_part_update(
|
||||
props: &serde_json::Value,
|
||||
state: &mut SseState,
|
||||
) -> Option<OpenCodeEvent> {
|
||||
fn handle_part_update(props: &serde_json::Value, state: &mut SseState) -> Option<OpenCodeEvent> {
|
||||
let part = props.get("part")?;
|
||||
let part_type = part.get("type").and_then(|v| v.as_str())?;
|
||||
if !matches!(part_type, "text" | "reasoning" | "thinking") {
|
||||
|
||||
@@ -108,6 +108,7 @@ impl ProxyConfig {
|
||||
}
|
||||
|
||||
/// Create the proxy extension directory with configured credentials
|
||||
#[allow(dead_code)]
|
||||
fn create_extension(&self) -> anyhow::Result<PathBuf> {
|
||||
let ext_dir = std::env::temp_dir().join("open_agent_proxy_ext");
|
||||
std::fs::create_dir_all(&ext_dir)?;
|
||||
@@ -422,6 +423,7 @@ async fn start_gost_forwarder(proxy: &ProxyConfig) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
/// Start a virtual X11 display using Xvfb
|
||||
#[allow(dead_code)]
|
||||
async fn start_virtual_display() -> anyhow::Result<String> {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use tokio::process::Command;
|
||||
|
||||
@@ -68,11 +68,6 @@ impl Tool for ReadFile {
|
||||
Ok(text) => text,
|
||||
Err(_) => {
|
||||
// Binary file detected - don't try to display content
|
||||
let ext = resolution
|
||||
.resolved
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
return Ok(format!(
|
||||
"Binary file detected: {} ({} bytes)\n\n\
|
||||
Cannot display binary content directly. For this file type:\n\
|
||||
|
||||
@@ -359,9 +359,14 @@ For private repos, set GH_TOKEN env var with a Personal Access Token."
|
||||
""
|
||||
};
|
||||
|
||||
let url_line = repo
|
||||
.url
|
||||
.as_deref()
|
||||
.map(|u| format!(" URL: {}\n", u))
|
||||
.unwrap_or_default();
|
||||
output.push_str(&format!(
|
||||
"- **{}**{} ({}, ⭐{})\n {}\n",
|
||||
repo.name, archived, lang, stars, desc
|
||||
"- **{}**{} ({}, ⭐{})\n {}\n{}",
|
||||
repo.name, archived, lang, stars, desc, url_line
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -132,10 +132,12 @@ IMPORTANT: Use 'blocked' or 'not_feasible' instead of producing fake/placeholder
|
||||
"failed" => MissionStatusValue::Failed,
|
||||
"blocked" => MissionStatusValue::Blocked,
|
||||
"not_feasible" => MissionStatusValue::NotFeasible,
|
||||
other => return Err(anyhow::anyhow!(
|
||||
other => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid status '{}'. Must be 'completed', 'failed', 'blocked', or 'not_feasible'.",
|
||||
other
|
||||
)),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let Some(control) = &self.control else {
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
//! This encourages agents to stay within their assigned workspace while preserving
|
||||
//! flexibility for tasks that require broader access.
|
||||
|
||||
mod browser;
|
||||
mod composite;
|
||||
mod desktop;
|
||||
mod directory;
|
||||
@@ -28,6 +27,13 @@ mod terminal;
|
||||
mod ui;
|
||||
mod web;
|
||||
|
||||
pub use directory::{ListDirectory, SearchFiles};
|
||||
pub use file_ops::{DeleteFile, ReadFile, WriteFile};
|
||||
pub use git::{GitCommit, GitDiff, GitLog, GitStatus};
|
||||
pub use search::GrepSearch;
|
||||
pub use terminal::RunCommand;
|
||||
pub use web::{FetchUrl, WebSearch};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -155,6 +161,13 @@ impl ToolRegistry {
|
||||
Self::with_options(None, None)
|
||||
}
|
||||
|
||||
/// Create an empty registry (no built-in tools).
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
tools: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new registry with all default tools and optional mission control.
|
||||
pub fn with_mission_control(mission_control: Option<mission::MissionControl>) -> Self {
|
||||
Self::with_options(mission_control, None)
|
||||
@@ -238,42 +251,6 @@ impl ToolRegistry {
|
||||
);
|
||||
tools.insert("debug_error".to_string(), Arc::new(composite::DebugError));
|
||||
|
||||
// Browser automation (conditional on BROWSER_ENABLED)
|
||||
let browser_enabled = std::env::var("BROWSER_ENABLED").unwrap_or_default();
|
||||
tracing::info!("BROWSER_ENABLED env var = '{}'", browser_enabled);
|
||||
if browser_enabled.to_lowercase() == "true" || browser_enabled == "1" {
|
||||
tracing::info!("Registering browser automation tools");
|
||||
tools.insert(
|
||||
"browser_navigate".to_string(),
|
||||
Arc::new(browser::BrowserNavigate),
|
||||
);
|
||||
tools.insert(
|
||||
"browser_screenshot".to_string(),
|
||||
Arc::new(browser::BrowserScreenshot),
|
||||
);
|
||||
tools.insert(
|
||||
"browser_get_content".to_string(),
|
||||
Arc::new(browser::BrowserGetContent),
|
||||
);
|
||||
tools.insert("browser_click".to_string(), Arc::new(browser::BrowserClick));
|
||||
tools.insert("browser_type".to_string(), Arc::new(browser::BrowserType));
|
||||
tools.insert(
|
||||
"browser_evaluate".to_string(),
|
||||
Arc::new(browser::BrowserEvaluate),
|
||||
);
|
||||
tools.insert("browser_wait".to_string(), Arc::new(browser::BrowserWait));
|
||||
tools.insert("browser_close".to_string(), Arc::new(browser::BrowserClose));
|
||||
tools.insert(
|
||||
"browser_list_elements".to_string(),
|
||||
Arc::new(browser::BrowserListElements),
|
||||
);
|
||||
tracing::info!(
|
||||
"Registry {}: Added 9 browser tools, total now: {}",
|
||||
registry_id,
|
||||
tools.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop automation (conditional on DESKTOP_ENABLED)
|
||||
if std::env::var("DESKTOP_ENABLED")
|
||||
.map(|v| v.to_lowercase() == "true" || v == "1")
|
||||
|
||||
@@ -138,10 +138,11 @@ async fn search_tavily(api_key: &str, query: &str, max_results: u32) -> anyhow::
|
||||
// Format individual results
|
||||
for (i, result) in tavily_response.results.iter().enumerate() {
|
||||
output.push_str(&format!(
|
||||
"### {}. {}\n**URL:** {}\n\n{}\n\n",
|
||||
"### {}. {}\n**URL:** {}\n**Score:** {:.2}\n\n{}\n\n",
|
||||
i + 1,
|
||||
result.title,
|
||||
result.url,
|
||||
result.score,
|
||||
result.content
|
||||
));
|
||||
}
|
||||
|
||||
179
src/workspace.rs
Normal file
179
src/workspace.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
//! Workspace management for OpenCode sessions.
|
||||
//!
|
||||
//! Open Agent acts as a workspace host for OpenCode. This module creates
|
||||
//! per-task/mission workspace directories and writes `opencode.json`
|
||||
//! with the currently configured MCP servers.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::mcp::{McpRegistry, McpServerConfig, McpTransport};
|
||||
|
||||
fn sanitize_key(name: &str) -> String {
|
||||
name.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
.replace('-', "_")
|
||||
}
|
||||
|
||||
fn unique_key(base: &str, used: &mut std::collections::HashSet<String>) -> String {
|
||||
if !used.contains(base) {
|
||||
used.insert(base.to_string());
|
||||
return base.to_string();
|
||||
}
|
||||
let mut i = 2;
|
||||
loop {
|
||||
let candidate = format!("{}_{}", base, i);
|
||||
if !used.contains(&candidate) {
|
||||
used.insert(candidate.clone());
|
||||
return candidate;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Root directory for Open Agent config data (versioned with repo).
|
||||
pub fn config_root(working_dir: &Path) -> PathBuf {
|
||||
working_dir.join(".openagent")
|
||||
}
|
||||
|
||||
/// Root directory for workspace folders.
|
||||
pub fn workspaces_root(working_dir: &Path) -> PathBuf {
|
||||
working_dir.join("workspaces")
|
||||
}
|
||||
|
||||
/// Workspace directory for a mission.
|
||||
pub fn mission_workspace_dir(working_dir: &Path, mission_id: Uuid) -> PathBuf {
|
||||
let short_id = &mission_id.to_string()[..8];
|
||||
workspaces_root(working_dir).join(format!("mission-{}", short_id))
|
||||
}
|
||||
|
||||
/// Workspace directory for a task.
|
||||
pub fn task_workspace_dir(working_dir: &Path, task_id: Uuid) -> PathBuf {
|
||||
let short_id = &task_id.to_string()[..8];
|
||||
workspaces_root(working_dir).join(format!("task-{}", short_id))
|
||||
}
|
||||
|
||||
fn opencode_entry_from_mcp(config: &McpServerConfig, workspace_dir: &Path) -> serde_json::Value {
|
||||
match &config.transport {
|
||||
McpTransport::Http { endpoint } => json!({
|
||||
"type": "http",
|
||||
"endpoint": endpoint,
|
||||
"enabled": config.enabled,
|
||||
}),
|
||||
McpTransport::Stdio { command, args, env } => {
|
||||
let mut entry = serde_json::Map::new();
|
||||
entry.insert("type".to_string(), json!("local"));
|
||||
let mut cmd = vec![command.clone()];
|
||||
cmd.extend(args.clone());
|
||||
entry.insert("command".to_string(), json!(cmd));
|
||||
entry.insert("enabled".to_string(), json!(config.enabled));
|
||||
let mut merged_env = env.clone();
|
||||
merged_env
|
||||
.entry("OPEN_AGENT_WORKSPACE".to_string())
|
||||
.or_insert_with(|| workspace_dir.to_string_lossy().to_string());
|
||||
if !merged_env.is_empty() {
|
||||
entry.insert("environment".to_string(), json!(merged_env));
|
||||
}
|
||||
serde_json::Value::Object(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_opencode_config(
|
||||
workspace_dir: &Path,
|
||||
mcp_configs: Vec<McpServerConfig>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut mcp_map = serde_json::Map::new();
|
||||
let mut used = std::collections::HashSet::new();
|
||||
|
||||
for config in mcp_configs.into_iter().filter(|c| c.enabled) {
|
||||
let base = sanitize_key(&config.name);
|
||||
let key = unique_key(&base, &mut used);
|
||||
mcp_map.insert(key, opencode_entry_from_mcp(&config, workspace_dir));
|
||||
}
|
||||
|
||||
let config_json = json!({
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": mcp_map,
|
||||
});
|
||||
|
||||
let config_path = workspace_dir.join("opencode.json");
|
||||
tokio::fs::write(config_path, serde_json::to_string_pretty(&config_json)?).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn prepare_workspace_dir(path: &Path) -> anyhow::Result<PathBuf> {
|
||||
tokio::fs::create_dir_all(path.join("output")).await?;
|
||||
tokio::fs::create_dir_all(path.join("temp")).await?;
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Prepare a custom workspace directory and write `opencode.json`.
|
||||
pub async fn prepare_custom_workspace(
|
||||
_config: &Config,
|
||||
mcp: &McpRegistry,
|
||||
workspace_dir: PathBuf,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
prepare_workspace_dir(&workspace_dir).await?;
|
||||
let mcp_configs = mcp.list_configs().await;
|
||||
write_opencode_config(&workspace_dir, mcp_configs).await?;
|
||||
Ok(workspace_dir)
|
||||
}
|
||||
|
||||
/// Prepare a workspace directory for a mission and write `opencode.json`.
|
||||
pub async fn prepare_mission_workspace(
|
||||
config: &Config,
|
||||
mcp: &McpRegistry,
|
||||
mission_id: Uuid,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
let dir = mission_workspace_dir(&config.working_dir, mission_id);
|
||||
prepare_workspace_dir(&dir).await?;
|
||||
let mcp_configs = mcp.list_configs().await;
|
||||
write_opencode_config(&dir, mcp_configs).await?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Prepare a workspace directory for a task and write `opencode.json`.
|
||||
pub async fn prepare_task_workspace(
|
||||
config: &Config,
|
||||
mcp: &McpRegistry,
|
||||
task_id: Uuid,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
let dir = task_workspace_dir(&config.working_dir, task_id);
|
||||
prepare_workspace_dir(&dir).await?;
|
||||
let mcp_configs = mcp.list_configs().await;
|
||||
write_opencode_config(&dir, mcp_configs).await?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Regenerate `opencode.json` for all workspace directories.
|
||||
pub async fn sync_all_workspaces(config: &Config, mcp: &McpRegistry) -> anyhow::Result<usize> {
|
||||
let root = workspaces_root(&config.working_dir);
|
||||
if !root.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut count = 0;
|
||||
let mcp_configs = mcp.list_configs().await;
|
||||
|
||||
let mut entries = tokio::fs::read_dir(&root).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if write_opencode_config(&path, mcp_configs.clone())
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
Reference in New Issue
Block a user