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:
Thomas Marchand
2026-01-04 13:04:05 -08:00
committed by GitHub
parent 8cf3211110
commit a3d3437b1d
46 changed files with 2877 additions and 914 deletions

View File

@@ -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
View 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

View File

@@ -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"

View File

@@ -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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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",

View File

@@ -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) */}

View File

@@ -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'}

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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)
}
}

View File

@@ -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")

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -291,7 +291,9 @@ struct TerminalView: View {
break
}
// Continue receiving
receiveMessages()
Task { @MainActor in
receiveMessages()
}
case .failure(let error):
Task { @MainActor in

View File

@@ -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
```

View File

@@ -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
}
}

View File

@@ -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),

View File

@@ -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)?;

View File

@@ -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(),
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}

View File

@@ -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() {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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()
}

View File

@@ -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,

View File

@@ -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
View 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();
}
}
}
}

View File

@@ -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(),
));
}
}
}

View File

@@ -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;

View File

@@ -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()
};

View File

@@ -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;

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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") {

View File

@@ -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;

View File

@@ -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\

View File

@@ -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
));
}

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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
View 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)
}