Add files using upload-large-folder tool
Browse files- .dockerignore +39 -0
- .env.sample +37 -0
- .gitattributes +6 -35
- .gitignore +45 -0
- .husky/pre-commit +1 -0
- .npmignore +51 -0
- .openhands/setup.sh +40 -0
- .prettierrc.json +3 -0
- .release-please-manifest.json +3 -0
- .screenshots/progress-event-fix.png +0 -0
- AGENTS.md +0 -0
- CHANGELOG.md +29 -0
- LICENSE +21 -0
- README.md +170 -0
- README.windows.md +29 -0
- __mocks__/zustand.ts +60 -0
- bin/agent-canvas.mjs +173 -0
- config/defaults.json +45 -0
- docker/Dockerfile +170 -0
- docker/entrypoint.sh +479 -0
- docs/SELF_HOSTING.md +286 -0
- docs/TESTING_MATRIX.md +97 -0
- docs/architecture.md +82 -0
- electron-builder.config.mjs +441 -0
- eslint.config.js +392 -0
- global.d.ts +18 -0
- hero.ts +65 -0
- package-lock.json +0 -0
- package.json +254 -0
- playwright.config.ts +85 -0
- playwright.live.config.ts +74 -0
- playwright.mock-llm-docker.config.ts +239 -0
- playwright.mock-llm.config.ts +198 -0
- react-router.config.ts +94 -0
- release-please-config.json +27 -0
- specs/backend-management.md +12 -0
- specs/canvas-extensions.md +283 -0
- specs/llm-defaults.md +10 -0
- specs/mcp-settings.md +28 -0
- specs/workspace-upload-path.md +16 -0
- stryker.config.mjs +22 -0
- tailwind.config.js +59 -0
- test-utils.tsx +232 -0
- tools/canvas_ui_tool.py +158 -0
- tsconfig.json +36 -0
- tsconfig.lib.json +34 -0
- vercel.json +4 -0
- vite-env.d.ts +8 -0
- vite.config.ts +497 -0
- vitest.setup.ts +222 -0
.dockerignore
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
|
| 4 |
+
# Build outputs (rebuilt in Docker)
|
| 5 |
+
build/
|
| 6 |
+
dist/
|
| 7 |
+
|
| 8 |
+
# Development files
|
| 9 |
+
.git/
|
| 10 |
+
.github/
|
| 11 |
+
.openhands/
|
| 12 |
+
.agents/
|
| 13 |
+
.pr/
|
| 14 |
+
|
| 15 |
+
# Test files
|
| 16 |
+
__tests__/
|
| 17 |
+
tests/
|
| 18 |
+
__mocks__/
|
| 19 |
+
test-results/
|
| 20 |
+
playwright-report*/
|
| 21 |
+
coverage/
|
| 22 |
+
|
| 23 |
+
# IDE / OS
|
| 24 |
+
.vscode/
|
| 25 |
+
.idea/
|
| 26 |
+
*.swp
|
| 27 |
+
*.swo
|
| 28 |
+
.DS_Store
|
| 29 |
+
Thumbs.db
|
| 30 |
+
|
| 31 |
+
# Environment
|
| 32 |
+
.env
|
| 33 |
+
.env.*
|
| 34 |
+
!.env.sample
|
| 35 |
+
|
| 36 |
+
# Misc
|
| 37 |
+
artifacts/
|
| 38 |
+
*.tgz
|
| 39 |
+
*.log
|
.env.sample
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OpenHands Agent Server target
|
| 2 |
+
# These defaults assume you are manually pointing the frontend at a backend on
|
| 3 |
+
# 127.0.0.1:8000. The recommended local workflow is `npm run dev`, which starts
|
| 4 |
+
# an isolated local backend for this checkout. Use `npm run dev:frontend` only
|
| 5 |
+
# when you intentionally want to point at a separately managed backend.
|
| 6 |
+
VITE_BACKEND_HOST="127.0.0.1:8000" # Host:port used by the Vite dev proxy
|
| 7 |
+
VITE_BACKEND_BASE_URL="http://127.0.0.1:8000" # Base URL used by browser-side direct requests
|
| 8 |
+
# To drive a containerized ACP agent-server, point this at the published port,
|
| 9 |
+
# e.g. VITE_BACKEND_BASE_URL="http://localhost:8010" — see examples/acp-docker/
|
| 10 |
+
# and docs/ACP_AGENTS.md → "Running ACP agents in a Docker container".
|
| 11 |
+
# VITE_SESSION_API_KEY="" # Set to the same value as backend SESSION_API_KEY or OH_SESSION_API_KEYS_0 when auth is enabled
|
| 12 |
+
# VITE_WORKING_DIR="/workspace/project/agent-canvas" # Base dir for per-conversation working_dirs. Each conversation's working_dir is <VITE_WORKING_DIR>/<id_hex>. Defaults to <OH_CANVAS_SAFE_STATE_DIR>/workspaces, which is the sibling of the agent server's <state_dir>/conversations/ persistence dir — both share the same <id_hex> per conversation.
|
| 13 |
+
# VITE_ENABLE_BROWSER_TOOLS="true" # Set to false to omit BrowserToolSet from new conversations
|
| 14 |
+
|
| 15 |
+
# Frontend dev server
|
| 16 |
+
VITE_FRONTEND_PORT="3001" # Port to run the frontend application
|
| 17 |
+
VITE_USE_TLS="false" # Use HTTPS/WSS for proxied backend connections
|
| 18 |
+
VITE_INSECURE_SKIP_VERIFY="false" # Skip TLS certificate verification for proxied backend requests
|
| 19 |
+
# VITE_BASE_PATH="/canvas" # Build/serve the SPA under a subpath. Leave unset for root-local development.
|
| 20 |
+
|
| 21 |
+
# Public PostHog client key compiled into source builds. Precompiled library
|
| 22 |
+
# consumers can instead pass apiKey/apiHost/uiHost to AgentServerUIProviders.
|
| 23 |
+
# VITE_POSTHOG_API_KEY="phc_..."
|
| 24 |
+
# VITE_DO_NOT_TRACK="1" # Disable all telemetry, including anonymous install counts
|
| 25 |
+
|
| 26 |
+
# Mocking / test helpers
|
| 27 |
+
VITE_MOCK_API="false" # Enable/disable API mocking with MSW
|
| 28 |
+
|
| 29 |
+
# `npm run dev` (scripts/dev-safe.mjs) — isolated local backend via uvx
|
| 30 |
+
# OH_CANVAS_SAFE_BACKEND_PORT="18000" # Port the spawned agent-server listens on
|
| 31 |
+
# OH_CANVAS_SAFE_VSCODE_PORT="18001" # Port forwarded to the embedded VS Code (defaults to backend port + 1)
|
| 32 |
+
# OH_CANVAS_SAFE_STATE_DIR="$HOME/.openhands/agent-canvas" # Where conversations and bash events are stored (tmux sockets live under /tmp)
|
| 33 |
+
|
| 34 |
+
# Agent server version selection (uvx) — listed in order of precedence (highest first)
|
| 35 |
+
# OH_AGENT_SERVER_LOCAL_PATH="" # Absolute path to a local software-agent-sdk checkout. Runs the local checkout via uvx with editable openhands-sdk/tools/workspace installs so source edits are picked up on restart. Highest precedence.
|
| 36 |
+
# OH_AGENT_SERVER_GIT_REF="" # Git commit SHA or branch name (e.g., "main", "abc1234"). Takes precedence over version.
|
| 37 |
+
# OH_AGENT_SERVER_VERSION="" # Specific PyPI version (e.g., "1.18.0"). Uses latest release if unset.
|
.gitattributes
CHANGED
|
@@ -1,35 +1,6 @@
|
|
| 1 |
-
*
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
*.
|
| 5 |
-
*.
|
| 6 |
-
*.
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
* text=auto eol=lf
|
| 2 |
+
|
| 3 |
+
# Icon binaries — never text-normalize (a mangled byte corrupts the file).
|
| 4 |
+
*.png binary
|
| 5 |
+
*.ico binary
|
| 6 |
+
*.icns binary
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# i18n translation files make by script using `make build`
|
| 2 |
+
public/locales/**/*
|
| 3 |
+
src/i18n/declaration.ts
|
| 4 |
+
.env
|
| 5 |
+
.envrc
|
| 6 |
+
node_modules/
|
| 7 |
+
build/
|
| 8 |
+
logs/
|
| 9 |
+
/.tmp/live-e2e-state/
|
| 10 |
+
# Scratch space for local E2E runs. Ignore the whole directory: besides the
|
| 11 |
+
# mock-llm state dirs, the suite also drops an automation DB and an unignored
|
| 12 |
+
# `secrets.json` here, which should never be committable.
|
| 13 |
+
/.tmp/
|
| 14 |
+
dist/
|
| 15 |
+
__pycache__
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
/test-results/
|
| 19 |
+
/test-results-live/
|
| 20 |
+
/test-results-mock-llm/
|
| 21 |
+
/test-results-mock-llm-docker/
|
| 22 |
+
/.mock-llm-markers/
|
| 23 |
+
/playwright-report/
|
| 24 |
+
/playwright-report-live/
|
| 25 |
+
/playwright-report-mock-llm/
|
| 26 |
+
/playwright-report-mock-llm-docker/
|
| 27 |
+
/blob-report/
|
| 28 |
+
/playwright/.cache/
|
| 29 |
+
/.stryker-tmp/
|
| 30 |
+
/reports/
|
| 31 |
+
.react-router/
|
| 32 |
+
ralph/
|
| 33 |
+
.vercel
|
| 34 |
+
*.tgz
|
| 35 |
+
|
| 36 |
+
# Local runtime artifacts produced by static-server / agent-server runs
|
| 37 |
+
workspace/
|
| 38 |
+
build-fresh/
|
| 39 |
+
|
| 40 |
+
# Electron desktop build outputs
|
| 41 |
+
/dist-electron/
|
| 42 |
+
# Downloaded uv binary (populated by `npm run download-uv` before packaging)
|
| 43 |
+
/resources/bin/
|
| 44 |
+
# Downloaded Node.js distribution (populated by `npm run download-node` before packaging)
|
| 45 |
+
/resources/node/
|
.husky/pre-commit
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
npx lint-staged
|
.npmignore
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# npm ignore file - overrides .gitignore for npm publish
|
| 2 |
+
# We need to INCLUDE build/ and dist/ which are in .gitignore
|
| 3 |
+
|
| 4 |
+
# Source files (not needed in package)
|
| 5 |
+
src/
|
| 6 |
+
__tests__/
|
| 7 |
+
tests/
|
| 8 |
+
*.test.ts
|
| 9 |
+
*.test.tsx
|
| 10 |
+
*.spec.ts
|
| 11 |
+
|
| 12 |
+
# Development config
|
| 13 |
+
.github/
|
| 14 |
+
.husky/
|
| 15 |
+
.openhands/
|
| 16 |
+
.agents/
|
| 17 |
+
.vscode/
|
| 18 |
+
*.config.ts
|
| 19 |
+
*.config.js
|
| 20 |
+
*.config.mjs
|
| 21 |
+
tsconfig*.json
|
| 22 |
+
eslint.config.mjs
|
| 23 |
+
.eslintrc*
|
| 24 |
+
.prettierrc*
|
| 25 |
+
vitest.setup.ts
|
| 26 |
+
|
| 27 |
+
# Development docs
|
| 28 |
+
DEVELOPMENT.md
|
| 29 |
+
AGENTS.md
|
| 30 |
+
CHANGELOG.md
|
| 31 |
+
|
| 32 |
+
# Generated files we don't need
|
| 33 |
+
public/locales/
|
| 34 |
+
src/i18n/declaration.ts
|
| 35 |
+
.env*
|
| 36 |
+
.envrc
|
| 37 |
+
|
| 38 |
+
# Test artifacts
|
| 39 |
+
/test-results/
|
| 40 |
+
/playwright-report/
|
| 41 |
+
/blob-report/
|
| 42 |
+
/playwright/
|
| 43 |
+
coverage/
|
| 44 |
+
|
| 45 |
+
# Misc
|
| 46 |
+
node_modules/
|
| 47 |
+
*.tgz
|
| 48 |
+
.react-router/
|
| 49 |
+
.vercel
|
| 50 |
+
ralph/
|
| 51 |
+
artifacts/
|
.openhands/setup.sh
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
|
| 3 |
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 4 |
+
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
| 5 |
+
ENV_FILE="$REPO_ROOT/.env"
|
| 6 |
+
|
| 7 |
+
cd "$REPO_ROOT" || exit 1
|
| 8 |
+
|
| 9 |
+
# Install uv if not present (required for running agent-server via uvx)
|
| 10 |
+
if ! command -v uvx &> /dev/null; then
|
| 11 |
+
echo "Installing uv..."
|
| 12 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
| 13 |
+
|
| 14 |
+
# Add uv to PATH for the rest of this script
|
| 15 |
+
export PATH="$HOME/.local/bin:$PATH"
|
| 16 |
+
|
| 17 |
+
# Verify installation
|
| 18 |
+
if ! command -v uvx &> /dev/null; then
|
| 19 |
+
echo "Warning: uv installed but uvx not found in PATH."
|
| 20 |
+
echo "You may need to add ~/.local/bin to your PATH:"
|
| 21 |
+
echo ' export PATH="$HOME/.local/bin:$PATH"'
|
| 22 |
+
fi
|
| 23 |
+
fi
|
| 24 |
+
|
| 25 |
+
# Always run npm ci to ensure dependencies are installed and up-to-date.
|
| 26 |
+
# This is idempotent and ensures hooks (like on_stop.sh) have access to
|
| 27 |
+
# npm scripts like lint and test.
|
| 28 |
+
npm ci
|
| 29 |
+
|
| 30 |
+
if [ ! -f "$ENV_FILE" ]; then
|
| 31 |
+
cp .env.sample "$ENV_FILE"
|
| 32 |
+
fi
|
| 33 |
+
|
| 34 |
+
if ! grep -Eq '^[[:space:]]*VITE_WORKING_DIR=' "$ENV_FILE"; then
|
| 35 |
+
printf '\nVITE_WORKING_DIR="%s"\n' "$REPO_ROOT" >> "$ENV_FILE"
|
| 36 |
+
fi
|
| 37 |
+
|
| 38 |
+
if [ ! -f src/i18n/declaration.ts ]; then
|
| 39 |
+
npm run make-i18n
|
| 40 |
+
fi
|
.prettierrc.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"trailingComma": "all"
|
| 3 |
+
}
|
.release-please-manifest.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
".": "1.18.0"
|
| 3 |
+
}
|
.screenshots/progress-event-fix.png
ADDED
|
AGENTS.md
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
All notable changes to this project will be documented in this file.
|
| 4 |
+
|
| 5 |
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
| 6 |
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
| 7 |
+
|
| 8 |
+
## [Unreleased]
|
| 9 |
+
|
| 10 |
+
## [1.0.0-alpha.2] - 2025-05-11
|
| 11 |
+
|
| 12 |
+
### Added
|
| 13 |
+
|
| 14 |
+
- Initial npm package release of `@openhands/agent-canvas`
|
| 15 |
+
- CLI entry point (`npx @openhands/agent-canvas`) to run full stack locally
|
| 16 |
+
- Library build mode with component barrel exports
|
| 17 |
+
- Subpath exports for modular imports:
|
| 18 |
+
- `@openhands/agent-canvas/browser`
|
| 19 |
+
- `@openhands/agent-canvas/conversation`
|
| 20 |
+
- `@openhands/agent-canvas/files`
|
| 21 |
+
- `@openhands/agent-canvas/settings`
|
| 22 |
+
- `@openhands/agent-canvas/sidebar`
|
| 23 |
+
- `@openhands/agent-canvas/terminal`
|
| 24 |
+
- `@openhands/agent-canvas/i18n`
|
| 25 |
+
- TypeScript type declarations
|
| 26 |
+
- GitHub Actions workflow for automated npm publishing (OIDC trusted publishing)
|
| 27 |
+
|
| 28 |
+
[Unreleased]: https://github.com/OpenHands/agent-canvas/compare/v1.0.0-alpha.2...HEAD
|
| 29 |
+
[1.0.0-alpha.2]: https://github.com/OpenHands/agent-canvas/releases/tag/v1.0.0-alpha.2
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
The MIT License (MIT)
|
| 2 |
+
|
| 3 |
+
Copyright © 2025 OpenHands contributors
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the “Software”), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
tags:
|
| 4 |
+
- agent
|
| 5 |
+
- coding-agent
|
| 6 |
+
- cli
|
| 7 |
+
- llm
|
| 8 |
+
- tool-use
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
<a name="readme-top"></a>
|
| 12 |
+
|
| 13 |
+
> **This is a source mirror.** This Hugging Face repository is not a model checkpoint — it's a read-only mirror of the [`OpenHands/OpenHands`](https://github.com/OpenHands/OpenHands) GitHub repository, the OpenHands AI-driven development platform (source of the official `openhands` pip package). It is hosted here for visibility on the Hub; the canonical repository, issue tracker, and pull requests all live on GitHub. Please file issues and contribute there, not here.
|
| 14 |
+
|
| 15 |
+
<div align="center">
|
| 16 |
+
<img src="https://assets.openhands.dev/logo-whitebackground.png" alt="OpenHands logo" width="340">
|
| 17 |
+
<h1 align="center" style="border-bottom: none">Agent Canvas</h1>
|
| 18 |
+
<p align="center">
|
| 19 |
+
<strong>The self-hosted developer control center for coding agents and automations.</strong>
|
| 20 |
+
</p>
|
| 21 |
+
<p align="center">
|
| 22 |
+
Run OpenHands, Claude Code, Codex, Gemini, or any ACP-compatible agent across local, remote, and cloud backends.
|
| 23 |
+
</p>
|
| 24 |
+
</div>
|
| 25 |
+
<div align="center">
|
| 26 |
+
<a href="https://github.com/OpenHands/incubator-program"><img src="https://img.shields.io/badge/status-beta-blue?style=for-the-badge" alt="Project status beta"></a>
|
| 27 |
+
<a href="https://github.com/OpenHands/OpenHands/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/OpenHands/OpenHands/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
| 28 |
+
<a href="https://www.npmjs.com/package/@openhands/agent-canvas"><img src="https://img.shields.io/npm/v/%40openhands%2Fagent-canvas?style=for-the-badge&logo=npm" alt="npm version"></a>
|
| 29 |
+
<a href="https://docs.openhands.dev/openhands/usage/agent-canvas/backends"><img src="https://img.shields.io/badge/Documentation-000?logo=googledocs&logoColor=FFE165&style=for-the-badge" alt="Documentation"></a>
|
| 30 |
+
<a href="https://go.openhands.dev/slack"><img src="https://img.shields.io/badge/Slack-Join%20the%20community-611f69?logo=slack&logoColor=white&style=for-the-badge" alt="Join us on Slack"></a>
|
| 31 |
+
</div>
|
| 32 |
+
<div align="center">
|
| 33 |
+
<a href="#quickstart">Quickstart</a> |
|
| 34 |
+
<a href="./docs/README.md">Docs</a> |
|
| 35 |
+
<a href="./docs/SELF_HOSTING.md">Self-Hosting</a> |
|
| 36 |
+
<a href="https://docs.openhands.dev/openhands/usage/agent-canvas/acp-agents">ACP Agents</a> |
|
| 37 |
+
<a href="https://docs.openhands.dev/openhands/usage/agent-canvas/prebuilt-automations">Automations</a> |
|
| 38 |
+
<a href="https://go.openhands.dev/slack">Slack</a>
|
| 39 |
+
</div>
|
| 40 |
+
<p align="center">
|
| 41 |
+
<img src="https://assets.openhands.dev/screenshot/automation-preview.png" alt="Agent Canvas automation preview" width="100%">
|
| 42 |
+
</p>
|
| 43 |
+
<hr>
|
| 44 |
+
|
| 45 |
+
OpenHands Agent Canvas turns your coding agents into a self-hosted, always-on engineering team. It's a developer control center for starting conversations and automating everyday tasks — like generating reports that publish to Slack or automatically decomposing GitHub issues into tasks.
|
| 46 |
+
|
| 47 |
+
It runs locally on your machine by default, but can connect to multiple “agent backends”, e.g. running agents in Docker containers, on VMs, or within your company infrastructure. You can optionally choose to run agents on OpenHands Cloud or OpenHands Enterprise infrastructure.
|
| 48 |
+
|
| 49 |
+
Agent Canvas runs the open source OpenHands agent out-of-the-box, but can use any third-party agent like Claude Code and Codex.
|
| 50 |
+
|
| 51 |
+
| | |
|
| 52 |
+
| -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
| 53 |
+
| [**Self-host your way**](https://docs.openhands.dev/openhands/usage/agent-canvas/backend-setup/vm) | Run agents locally, in Docker, on VMs, or anywhere you can run an agent server backend |
|
| 54 |
+
| [**Switch between different backends**](https://docs.openhands.dev/openhands/usage/agent-canvas/backends) | Switch between local, remote, and cloud agents without losing focus |
|
| 55 |
+
| [**Create automations**](https://docs.openhands.dev/openhands/usage/agent-canvas/prebuilt-automations) | Create automations and workflows that integrate with Slack, GitHub, Linear, and more. Run on a schedule or in response to webhook events |
|
| 56 |
+
| [**Integrate with the tools you use**](https://docs.openhands.dev/openhands/usage/agent-canvas/prebuilt-automations) | Connect your automations with third-party services like Slack, GitHub, Notion, and more to automate workflows |
|
| 57 |
+
| [**Bring your own model**](https://docs.openhands.dev/openhands/usage/settings/llm-settings#llm-profiles) | Use with any LLM |
|
| 58 |
+
| [**Use with any agent**](https://docs.openhands.dev/openhands/usage/agent-canvas/acp-agents) | Use with OpenHands, Claude Code, Codex, Gemini, or any agent with Agent-Client Protocol (ACP). |
|
| 59 |
+
|
| 60 |
+
If you have questions or feedback, please open a GitHub issue or join the [#proj-agent-canvas channel in Slack](https://openhands.dev/joinslack).
|
| 61 |
+
|
| 62 |
+
## Quickstart
|
| 63 |
+
|
| 64 |
+
You can install OpenHands to run agents on any machine: on your laptop, on a dedicated computer like a Mac Mini,
|
| 65 |
+
or on a server in the cloud.
|
| 66 |
+
|
| 67 |
+
The most powerful way to run OpenHands is on a server in the cloud. This allows your agents to continue running
|
| 68 |
+
even when your laptop is shut, and makes it easier to trigger your agents through third-party services
|
| 69 |
+
like Slack, GitHub, and Datadog. See [SELF_HOSTING.md](docs/SELF_HOSTING.md) for details, especially with respect to security hardening.
|
| 70 |
+
|
| 71 |
+
Notably, you can run the backend in _multiple different environments_, and switch between
|
| 72 |
+
them from the same Agent Canvas frontend. E.g. you can share an Agent Server with your team for agents doing
|
| 73 |
+
code review and dependency updates, then have your personal agents running on your laptop.
|
| 74 |
+
|
| 75 |
+
### Option 1: Without a Sandbox
|
| 76 |
+
|
| 77 |
+
> [!WARNING]
|
| 78 |
+
> This runs the agent-server directly on the machine you're installing on — the agent will have full access to your filesystem!
|
| 79 |
+
|
| 80 |
+
**Prerequisites**: Node.js 22.12.x or later, `uv`
|
| 81 |
+
|
| 82 |
+
```sh
|
| 83 |
+
npm install -g @openhands/agent-canvas
|
| 84 |
+
agent-canvas
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
The `agent-canvas` command starts the full local stack by default. You can also split it when you want to run pieces separately:
|
| 88 |
+
|
| 89 |
+
```sh
|
| 90 |
+
agent-canvas --frontend-only # static frontend + ingress only
|
| 91 |
+
agent-canvas --backend-only # agent server + automation backend + ingress only
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
### Option 2: With a Docker Sandbox
|
| 95 |
+
|
| 96 |
+
**Prerequisites**:
|
| 97 |
+
|
| 98 |
+
- Docker: Docker Desktop on macOS/Windows, or Docker Engine/Docker Desktop on Linux.
|
| 99 |
+
- A host directory for `PROJECTS_PATH` containing the project folders you want the agent to access. Create it before starting the container.
|
| 100 |
+
|
| 101 |
+
**macOS / Linux:**
|
| 102 |
+
|
| 103 |
+
```sh
|
| 104 |
+
export PROJECTS_PATH="$HOME/projects" # directory containing your project folders
|
| 105 |
+
mkdir -p "$PROJECTS_PATH" "$HOME/.openhands"
|
| 106 |
+
|
| 107 |
+
docker run -it --rm \
|
| 108 |
+
-p 8000:8000 \
|
| 109 |
+
-v "$HOME/.openhands:/home/openhands/.openhands" \
|
| 110 |
+
-v "${PROJECTS_PATH}:/projects" \
|
| 111 |
+
ghcr.io/openhands/agent-canvas:1.18.0 # x-release-please-version
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
**Windows (PowerShell / Windows Terminal):** See [README.windows.md](./README.windows.md) for the equivalent commands.
|
| 115 |
+
|
| 116 |
+
The agent will be able to access any project under `PROJECTS_PATH`.
|
| 117 |
+
|
| 118 |
+
### Option 3: From Source
|
| 119 |
+
|
| 120 |
+
> [!WARNING]
|
| 121 |
+
> This runs the agent-server directly on the machine you're installing on — the agent will have full access to your filesystem!
|
| 122 |
+
|
| 123 |
+
**Prerequisites**: Node.js 22.12.x or later, `npm`, `uv` (for running the agent server via `uvx`)
|
| 124 |
+
|
| 125 |
+
```sh
|
| 126 |
+
git clone https://github.com/OpenHands/OpenHands.git
|
| 127 |
+
cd OpenHands
|
| 128 |
+
npm install
|
| 129 |
+
npm run dev
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
---
|
| 133 |
+
|
| 134 |
+
Access the UI at [http://localhost:8000](http://localhost:8000) for the npm/source launchers, or [http://localhost:8000/canvas](http://localhost:8000/canvas) for the Docker image. You can add additional backends directly from the UI.
|
| 135 |
+
|
| 136 |
+
# Architecture
|
| 137 |
+
|
| 138 |
+
Agent Canvas is powered by the [OpenHands Agent Server](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-agent-server/openhands/agent_server), a REST API for running multiple agents on a single machine. Each Agent Server runs on a single host/port; the Agent Canvas can connect to multiple Agent Servers and easily flip between them.
|
| 139 |
+
|
| 140 |
+
You can run an Agent Server anywhere:
|
| 141 |
+
|
| 142 |
+
- Directly on your laptop (be careful!)
|
| 143 |
+
- On a dedicated machine like a Mac Mini
|
| 144 |
+
- On a virtual machine in the cloud
|
| 145 |
+
- Inside OpenHands Cloud (our commercial offering)
|
| 146 |
+
|
| 147 |
+
The Agent Server is often paired with an [Automation Server](https://github.com/OpenHands/automation), which lets you set up agents that run on a schedule or in response to events.
|
| 148 |
+
|
| 149 |
+
<img width="1456" height="1258" alt="image" src="https://github.com/user-attachments/assets/cb6de6f5-ac30-4d04-a76a-b5c259f0c163" />
|
| 150 |
+
|
| 151 |
+
### Repository boundaries
|
| 152 |
+
|
| 153 |
+
Agent Canvas is part of a multi-repository OpenHands system. Changes should go to the repository that owns the behavior:
|
| 154 |
+
|
| 155 |
+
| Repository | Responsibility |
|
| 156 |
+
|---|---|
|
| 157 |
+
| [`OpenHands/OpenHands`](https://github.com/OpenHands/OpenHands) | Agent Canvas frontend, user-facing control center, backend selection, and local-stack orchestration. |
|
| 158 |
+
| [`OpenHands/software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk) | Python SDK, Agent Server, agents, tools, conversations, workspaces, events, and the canonical server API. |
|
| 159 |
+
| [`OpenHands/typescript-client`](https://github.com/OpenHands/typescript-client) | Browser-compatible TypeScript client for the Agent Server API. |
|
| 160 |
+
| [`OpenHands/automation`](https://github.com/OpenHands/automation) | Automation definitions, scheduling, webhooks, run history, and dispatching. |
|
| 161 |
+
|
| 162 |
+
The Agent Server API is implemented by the SDK and consumed through the TypeScript client by Agent Canvas. The automation service decides when work runs and dispatches conversations to the Agent Server/SDK, which decides what runs. See [`AGENTS.md`](./AGENTS.md) for contributor-specific boundaries and the required custom code-review guide.
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
## More documentation
|
| 166 |
+
|
| 167 |
+
- [Documentation index](./docs/README.md)
|
| 168 |
+
- [Architecture overview](./docs/architecture.md)
|
| 169 |
+
- [Development guide](./docs/DEVELOPMENT.md)
|
| 170 |
+
- [Self-hosting guide](./docs/SELF_HOSTING.md)
|
README.windows.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Windows quickstart (PowerShell)
|
| 2 |
+
|
| 3 |
+
This doc contains **Windows-specific** command syntax for running Agent Canvas with the **Docker sandbox**.
|
| 4 |
+
|
| 5 |
+
For the main install options and overall context, see [README.md](./README.md).
|
| 6 |
+
|
| 7 |
+
## Option 2: With a Docker Sandbox (Windows)
|
| 8 |
+
|
| 9 |
+
**Prerequisites**:
|
| 10 |
+
|
| 11 |
+
- Docker Desktop for Windows
|
| 12 |
+
- A host directory for `PROJECTS_PATH` containing the project folders you want the agent to access (create it before starting the container)
|
| 13 |
+
|
| 14 |
+
```powershell
|
| 15 |
+
docker pull ghcr.io/openhands/agent-canvas:1.18.0 # x-release-please-version
|
| 16 |
+
|
| 17 |
+
$env:PROJECTS_PATH = Join-Path $HOME "projects" # directory containing your project folders
|
| 18 |
+
New-Item -ItemType Directory -Force -Path $env:PROJECTS_PATH, (Join-Path $env:USERPROFILE ".openhands") | Out-Null
|
| 19 |
+
|
| 20 |
+
docker run -it --rm `
|
| 21 |
+
-p 8000:8000 `
|
| 22 |
+
-v "$($env:USERPROFILE)\.openhands:/home/openhands/.openhands" `
|
| 23 |
+
-v "$($env:PROJECTS_PATH):/projects" `
|
| 24 |
+
ghcr.io/openhands/agent-canvas:1.18.0 # x-release-please-version
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
Open [http://localhost:8000/canvas](http://localhost:8000/canvas) in your browser.
|
| 28 |
+
|
| 29 |
+
The agent will be able to access any project under `PROJECTS_PATH`.
|
__mocks__/zustand.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { act } from "@testing-library/react";
|
| 2 |
+
import { vi, afterEach } from "vitest";
|
| 3 |
+
import type * as ZustandExportedTypes from "zustand";
|
| 4 |
+
|
| 5 |
+
export * from "zustand";
|
| 6 |
+
|
| 7 |
+
const { create: actualCreate, createStore: actualCreateStore } =
|
| 8 |
+
await vi.importActual<typeof ZustandExportedTypes>("zustand");
|
| 9 |
+
|
| 10 |
+
// a variable to hold reset functions for all stores declared in the app
|
| 11 |
+
export const storeResetFns = new Set<() => void>();
|
| 12 |
+
|
| 13 |
+
const createUncurried = <T>(
|
| 14 |
+
stateCreator: ZustandExportedTypes.StateCreator<T>,
|
| 15 |
+
) => {
|
| 16 |
+
const store = actualCreate(stateCreator);
|
| 17 |
+
const initialState = store.getInitialState();
|
| 18 |
+
storeResetFns.add(() => {
|
| 19 |
+
store.setState(initialState, true);
|
| 20 |
+
});
|
| 21 |
+
return store;
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
// when creating a store, we get its initial state, create a reset function and add it in the set
|
| 25 |
+
export const create = (<T>(
|
| 26 |
+
stateCreator: ZustandExportedTypes.StateCreator<T>,
|
| 27 |
+
) =>
|
| 28 |
+
// to support curried version of create
|
| 29 |
+
typeof stateCreator === "function"
|
| 30 |
+
? createUncurried(stateCreator)
|
| 31 |
+
: createUncurried) as typeof ZustandExportedTypes.create;
|
| 32 |
+
|
| 33 |
+
const createStoreUncurried = <T>(
|
| 34 |
+
stateCreator: ZustandExportedTypes.StateCreator<T>,
|
| 35 |
+
) => {
|
| 36 |
+
const store = actualCreateStore(stateCreator);
|
| 37 |
+
const initialState = store.getInitialState();
|
| 38 |
+
storeResetFns.add(() => {
|
| 39 |
+
store.setState(initialState, true);
|
| 40 |
+
});
|
| 41 |
+
return store;
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
// when creating a store, we get its initial state, create a reset function and add it in the set
|
| 45 |
+
export const createStore = (<T>(
|
| 46 |
+
stateCreator: ZustandExportedTypes.StateCreator<T>,
|
| 47 |
+
) =>
|
| 48 |
+
// to support curried version of createStore
|
| 49 |
+
typeof stateCreator === "function"
|
| 50 |
+
? createStoreUncurried(stateCreator)
|
| 51 |
+
: createStoreUncurried) as typeof ZustandExportedTypes.createStore;
|
| 52 |
+
|
| 53 |
+
// reset all stores after each test run
|
| 54 |
+
afterEach(() => {
|
| 55 |
+
act(() => {
|
| 56 |
+
storeResetFns.forEach((resetFn) => {
|
| 57 |
+
resetFn();
|
| 58 |
+
});
|
| 59 |
+
});
|
| 60 |
+
});
|
bin/agent-canvas.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
/**
|
| 3 |
+
* CLI entry point for @openhands/agent-canvas
|
| 4 |
+
*
|
| 5 |
+
* Runs the full Agent Canvas stack locally by default:
|
| 6 |
+
* - Agent-server via uvx
|
| 7 |
+
* - Automation backend via uvx
|
| 8 |
+
* - Pre-built static frontend
|
| 9 |
+
*
|
| 10 |
+
* This is the production equivalent of `npm run dev` - it runs the full stack
|
| 11 |
+
* but serves pre-built static assets instead of the Vite dev server.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { existsSync, readFileSync } from "node:fs";
|
| 15 |
+
import { join, dirname } from "node:path";
|
| 16 |
+
import { fileURLToPath } from "node:url";
|
| 17 |
+
|
| 18 |
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
| 19 |
+
const PKG_JSON = join(__dirname, "..", "package.json");
|
| 20 |
+
// Build output is in build/ (not build/client/) - see react-router.config.ts unpackClientDirectory
|
| 21 |
+
const BUILD_DIR = join(__dirname, "..", "build");
|
| 22 |
+
|
| 23 |
+
const DEFAULTS_JSON = join(__dirname, "..", "config", "defaults.json");
|
| 24 |
+
|
| 25 |
+
// Check for version/help/info/public flags first
|
| 26 |
+
const args = process.argv.slice(2);
|
| 27 |
+
if (args.includes("-v") || args.includes("--version")) {
|
| 28 |
+
const { version } = JSON.parse(readFileSync(PKG_JSON, "utf-8"));
|
| 29 |
+
console.log(version);
|
| 30 |
+
process.exit(0);
|
| 31 |
+
}
|
| 32 |
+
if (args.includes("--info")) {
|
| 33 |
+
const { version } = JSON.parse(readFileSync(PKG_JSON, "utf-8"));
|
| 34 |
+
const defaults = JSON.parse(readFileSync(DEFAULTS_JSON, "utf-8"));
|
| 35 |
+
console.log(`@openhands/agent-canvas ${version}
|
| 36 |
+
|
| 37 |
+
Default stack versions:
|
| 38 |
+
agent-server: ${defaults.versions.agentServer}
|
| 39 |
+
automation: ${defaults.versions.automation}
|
| 40 |
+
|
| 41 |
+
Compatibility:
|
| 42 |
+
agent-server: >= ${defaults.compatibility.minimumAgentServer}
|
| 43 |
+
|
| 44 |
+
Default ports:
|
| 45 |
+
ingress: ${defaults.ports.proxy}
|
| 46 |
+
agent-server: ${defaults.ports.agentServer}
|
| 47 |
+
automation: ${defaults.ports.automation}
|
| 48 |
+
|
| 49 |
+
Override versions via environment variables:
|
| 50 |
+
OH_AGENT_SERVER_VERSION, OH_AGENT_SERVER_GIT_REF, OH_AGENT_SERVER_LOCAL_PATH
|
| 51 |
+
OH_AUTOMATION_VERSION, OH_AUTOMATION_GIT_REF`);
|
| 52 |
+
process.exit(0);
|
| 53 |
+
}
|
| 54 |
+
const isPublic = args.includes("--public");
|
| 55 |
+
const isFrontendOnly = args.includes("--frontend-only");
|
| 56 |
+
const isBackendOnly = args.includes("--backend-only");
|
| 57 |
+
|
| 58 |
+
if (args.includes("-h") || args.includes("--help")) {
|
| 59 |
+
console.log(`
|
| 60 |
+
@openhands/agent-canvas - Run the Agent Canvas UI with agent-server
|
| 61 |
+
|
| 62 |
+
Runs the full stack with agent-server and automation backend via uvx,
|
| 63 |
+
and serves pre-built static frontend assets.
|
| 64 |
+
|
| 65 |
+
USAGE:
|
| 66 |
+
npx @openhands/agent-canvas [options]
|
| 67 |
+
|
| 68 |
+
AUTH MODES:
|
| 69 |
+
By default the server auto-generates an API key that is injected into
|
| 70 |
+
the frontend at startup — no login required.
|
| 71 |
+
|
| 72 |
+
--public Enable public mode. Requires LOCAL_BACKEND_API_KEY env var.
|
| 73 |
+
The key protects the server but is NOT injected into the
|
| 74 |
+
frontend. Users must paste it when the UI loads.
|
| 75 |
+
|
| 76 |
+
OPTIONS:
|
| 77 |
+
-p, --port <port> Ingress port (default: 8000)
|
| 78 |
+
--public Enable public mode (see above)
|
| 79 |
+
--frontend-only Start only the static frontend behind ingress
|
| 80 |
+
--backend-only Start only agent-server + automation behind ingress
|
| 81 |
+
-v, --version Show version number
|
| 82 |
+
--info Show version and default stack configuration
|
| 83 |
+
-h, --help Show this help message
|
| 84 |
+
|
| 85 |
+
ENVIRONMENT VARIABLES:
|
| 86 |
+
LOCAL_BACKEND_API_KEY API key for the server. Required in --public
|
| 87 |
+
mode; optional otherwise (auto-generated if
|
| 88 |
+
omitted, persisted across restarts).
|
| 89 |
+
OH_SECRET_KEY Secret key for encrypting settings
|
| 90 |
+
OH_AGENT_SERVER_GIT_REF Git ref for agent-server
|
| 91 |
+
OH_AGENT_SERVER_LOCAL_PATH Path to local SDK checkout (for development)
|
| 92 |
+
OH_AGENT_SERVER_VERSION Specific PyPI version for agent-server
|
| 93 |
+
|
| 94 |
+
Note: LLM settings are configured through the web UI settings page,
|
| 95 |
+
not environment variables.
|
| 96 |
+
|
| 97 |
+
EXAMPLES:
|
| 98 |
+
# Start full stack (local mode, auto-generated key)
|
| 99 |
+
npx @openhands/agent-canvas
|
| 100 |
+
|
| 101 |
+
# Pin a specific key (local mode, key auto-injected into frontend)
|
| 102 |
+
LOCAL_BACKEND_API_KEY=my-key npx @openhands/agent-canvas
|
| 103 |
+
|
| 104 |
+
# Public mode — users must enter the API key in the browser
|
| 105 |
+
LOCAL_BACKEND_API_KEY=my-secret npx @openhands/agent-canvas --public
|
| 106 |
+
|
| 107 |
+
# Use a specific port
|
| 108 |
+
npx @openhands/agent-canvas --port 3000
|
| 109 |
+
|
| 110 |
+
# Start only the static frontend behind ingress
|
| 111 |
+
npx @openhands/agent-canvas --frontend-only
|
| 112 |
+
|
| 113 |
+
# Start only the agent-server and automation backend behind ingress
|
| 114 |
+
npx @openhands/agent-canvas --backend-only
|
| 115 |
+
|
| 116 |
+
# Show default stack versions and ports
|
| 117 |
+
npx @openhands/agent-canvas --info
|
| 118 |
+
|
| 119 |
+
# Use local SDK checkout for development
|
| 120 |
+
OH_AGENT_SERVER_LOCAL_PATH=/path/to/sdk npx @openhands/agent-canvas
|
| 121 |
+
`);
|
| 122 |
+
process.exit(0);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
if (isFrontendOnly && isBackendOnly) {
|
| 126 |
+
console.error(
|
| 127 |
+
"Error: --frontend-only and --backend-only cannot be used together",
|
| 128 |
+
);
|
| 129 |
+
process.exit(1);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
if (isFrontendOnly && isPublic) {
|
| 133 |
+
console.error("Error: --public cannot be used with --frontend-only");
|
| 134 |
+
process.exit(1);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Check build exists before doing anything else unless no frontend will run.
|
| 138 |
+
if (!isBackendOnly && !existsSync(BUILD_DIR)) {
|
| 139 |
+
console.error(`
|
| 140 |
+
Error: No build found at ${BUILD_DIR}
|
| 141 |
+
|
| 142 |
+
This package needs to be built first. If you installed from npm,
|
| 143 |
+
this is a packaging error. If running from source:
|
| 144 |
+
|
| 145 |
+
npm install
|
| 146 |
+
npm run build
|
| 147 |
+
`);
|
| 148 |
+
process.exit(1);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
let main;
|
| 152 |
+
try {
|
| 153 |
+
({ main } = await import("../scripts/dev-with-automation.mjs"));
|
| 154 |
+
} catch (err) {
|
| 155 |
+
console.error("Failed to load required scripts. Try reinstalling:");
|
| 156 |
+
console.error(" npm install -g @openhands/agent-canvas@latest");
|
| 157 |
+
console.error(`\nError: ${err.message}`);
|
| 158 |
+
process.exit(1);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
main({
|
| 162 |
+
bannerTitle: "Agent Canvas",
|
| 163 |
+
staticMode: true,
|
| 164 |
+
staticDir: BUILD_DIR,
|
| 165 |
+
mode: "agent-canvas",
|
| 166 |
+
isPublic,
|
| 167 |
+
}).catch((err) => {
|
| 168 |
+
console.error(`Fatal error: ${err.message}`);
|
| 169 |
+
if (err.stack) {
|
| 170 |
+
console.error(err.stack);
|
| 171 |
+
}
|
| 172 |
+
process.exit(1);
|
| 173 |
+
});
|
config/defaults.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment": "Single source of truth for version pins, ports, paths, and defaults shared across the npm and Docker install paths. Read by scripts/dev-safe.mjs, scripts/dev-with-automation.mjs, docker/entrypoint.sh (via generated defaults.env), and .github/workflows/docker.yml.",
|
| 3 |
+
"versions": {
|
| 4 |
+
"agentServer": "1.46.0",
|
| 5 |
+
"agentCanvas": "1.18.0",
|
| 6 |
+
"automation": "1.11.1"
|
| 7 |
+
},
|
| 8 |
+
"compatibility": {
|
| 9 |
+
"minimumAgentServer": "1.28.0"
|
| 10 |
+
},
|
| 11 |
+
"constraints": {
|
| 12 |
+
"_comment": "Temporary upper bound on the agent-client-protocol (acp) transitive dependency. openhands-sdk 1.35.0 requires agent-client-protocol>=0.10.1 with no upper bound, but acp 0.11.0 reordered the ACP prompt() arguments and breaks the SDK's ACP client (a PromptRequest validation error surfaces as 'ACP error: 2 validation errors for PromptRequest'). Pin acp <0.11 until a fixed openhands-sdk ships. Consumed by scripts/dev-safe.mjs (agent-server uvx install) and .github/workflows/mock-llm-e2e.yml (mock ACP server venv).",
|
| 13 |
+
"agentClientProtocol": "agent-client-protocol<0.11"
|
| 14 |
+
},
|
| 15 |
+
"images": {
|
| 16 |
+
"agentServer": "ghcr.io/openhands/agent-server",
|
| 17 |
+
"agentCanvas": "ghcr.io/openhands/agent-canvas"
|
| 18 |
+
},
|
| 19 |
+
"ports": {
|
| 20 |
+
"agentServer": 18000,
|
| 21 |
+
"automation": 18001,
|
| 22 |
+
"proxy": 8000,
|
| 23 |
+
"_vscodeComment": "Port the bundled editor binds to inside the container; agent-server's own default. Only the Docker entrypoint reads this — the npm launchers derive the editor port from the agent-server port so several stacks can run side by side. It is never published: the editor is reached through paths.vscodeBasePath on the proxy port.",
|
| 24 |
+
"vscode": 8001
|
| 25 |
+
},
|
| 26 |
+
"paths": {
|
| 27 |
+
"stateSubdir": "agent-canvas",
|
| 28 |
+
"conversations": "agent-canvas/conversations",
|
| 29 |
+
"bashEvents": "agent-canvas/bash_events",
|
| 30 |
+
"automationDb": "automation/automations.db",
|
| 31 |
+
"canvasBasePath": "/canvas",
|
| 32 |
+
"_vscodeBasePathComment": "Path prefix the bundled editor is served under, on the same origin as the canvas. Passed to agent-server as OH_VSCODE_BASE_PATH (it launches openvscode-server with --server-base-path and advertises the prefix in /api/vscode/url), and registered as an ingress route to the VSCode port. Keeping one value on both sides is what lets a single-origin deployment reach the editor without publishing a second port. Tradeoff: a path prefix routes but does not isolate, so the editor shares the canvas's browser origin and therefore its localStorage — see docs/SELF_HOSTING.md and OpenHands/OpenHands#16492.",
|
| 33 |
+
"vscodeBasePath": "/vscode"
|
| 34 |
+
},
|
| 35 |
+
"packages": {
|
| 36 |
+
"agentServer": "openhands-agent-server",
|
| 37 |
+
"automation": "openhands-automation",
|
| 38 |
+
"tools": "openhands-tools",
|
| 39 |
+
"workspace": "openhands-workspace"
|
| 40 |
+
},
|
| 41 |
+
"telemetry": {
|
| 42 |
+
"posthogApiKey": "phc_kBtz5nKmxVRRQ7HtPwr2QX9eMC5j65zE86QKocVNwb4U",
|
| 43 |
+
"posthogHost": "https://us.i.posthog.com"
|
| 44 |
+
}
|
| 45 |
+
}
|
docker/Dockerfile
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1.7
|
| 2 |
+
|
| 3 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 4 |
+
# agent-canvas all-in-one Docker image
|
| 5 |
+
#
|
| 6 |
+
# Combines three services into a single image:
|
| 7 |
+
# 1. Agent Server — from ghcr.io/openhands/agent-server (upstream SDK image)
|
| 8 |
+
# 2. Automation — installed via pip from openhands-automation
|
| 9 |
+
# 3. Frontend — agent-canvas static build served by Node.js
|
| 10 |
+
#
|
| 11 |
+
# The entrypoint starts all three services and an ingress proxy that unifies
|
| 12 |
+
# them behind a single port (default 8000):
|
| 13 |
+
# /api/automation/* → automation backend (:18001)
|
| 14 |
+
# /api/*, /sockets → agent server (:18000)
|
| 15 |
+
# /* (default) → static frontend + SPA fallback
|
| 16 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 17 |
+
|
| 18 |
+
# ── Build args ────────────────────────────────────────────────────────────────
|
| 19 |
+
# No hardcoded defaults — values are derived from config/defaults.json.
|
| 20 |
+
# CI passes these via --build-arg; for local builds use:
|
| 21 |
+
# node scripts/docker-build.mjs (recommended, reads JSON for you)
|
| 22 |
+
# docker build --build-arg AGENT_SERVER_IMAGE=... --build-arg AUTOMATION_VERSION=... -f docker/Dockerfile .
|
| 23 |
+
ARG AGENT_SERVER_IMAGE
|
| 24 |
+
ARG AUTOMATION_VERSION
|
| 25 |
+
|
| 26 |
+
# ── Stage 1: Build frontend ──────────────────────────────────────────────────
|
| 27 |
+
FROM node:24-slim AS frontend-build
|
| 28 |
+
|
| 29 |
+
WORKDIR /build
|
| 30 |
+
|
| 31 |
+
# Cache-friendly: package files first
|
| 32 |
+
COPY package.json package-lock.json ./
|
| 33 |
+
RUN npm ci
|
| 34 |
+
|
| 35 |
+
# Copy everything needed for the build
|
| 36 |
+
COPY . .
|
| 37 |
+
|
| 38 |
+
# Build the static frontend.
|
| 39 |
+
# VITE_POSTHOG_API_KEY is a public client key baked into the bundle. CI passes
|
| 40 |
+
# production for tagged releases and staging for PR/main images.
|
| 41 |
+
# VITE_BASE_PATH is baked into React Router/Vite so the Docker image serves
|
| 42 |
+
# Canvas under the Cloud subpath instead of a dedicated hostname.
|
| 43 |
+
ARG VITE_POSTHOG_API_KEY=""
|
| 44 |
+
ARG VITE_BASE_PATH="/canvas"
|
| 45 |
+
ENV VITE_POSTHOG_API_KEY=${VITE_POSTHOG_API_KEY}
|
| 46 |
+
ENV VITE_BASE_PATH=${VITE_BASE_PATH}
|
| 47 |
+
RUN npm run build
|
| 48 |
+
|
| 49 |
+
# ── Stage 1b: Generate shell-sourceable defaults from config/defaults.json ──
|
| 50 |
+
# This avoids needing jq/python at container runtime to parse the JSON.
|
| 51 |
+
FROM node:24-slim AS config-gen
|
| 52 |
+
COPY config/defaults.json /tmp/
|
| 53 |
+
RUN node -e " \
|
| 54 |
+
const c = JSON.parse(require('fs').readFileSync('/tmp/defaults.json','utf-8')); \
|
| 55 |
+
const lines = [ \
|
| 56 |
+
'CONFIG_AGENT_SERVER_PORT=' + c.ports.agentServer, \
|
| 57 |
+
'CONFIG_AUTOMATION_PORT=' + c.ports.automation, \
|
| 58 |
+
'CONFIG_PROXY_PORT=' + c.ports.proxy, \
|
| 59 |
+
'CONFIG_VSCODE_PORT=' + c.ports.vscode, \
|
| 60 |
+
'CONFIG_STATE_SUBDIR=' + c.paths.stateSubdir, \
|
| 61 |
+
'CONFIG_CONVERSATIONS=' + c.paths.conversations, \
|
| 62 |
+
'CONFIG_BASH_EVENTS=' + c.paths.bashEvents, \
|
| 63 |
+
'CONFIG_AUTOMATION_DB=' + c.paths.automationDb, \
|
| 64 |
+
'CONFIG_CANVAS_BASE_PATH=' + c.paths.canvasBasePath, \
|
| 65 |
+
'CONFIG_VSCODE_BASE_PATH=' + c.paths.vscodeBasePath, \
|
| 66 |
+
'CONFIG_POSTHOG_API_KEY=' + c.telemetry.posthogApiKey, \
|
| 67 |
+
'CONFIG_POSTHOG_HOST=' + c.telemetry.posthogHost, \
|
| 68 |
+
]; \
|
| 69 |
+
require('fs').writeFileSync('/tmp/defaults.env', lines.join('\n') + '\n'); \
|
| 70 |
+
"
|
| 71 |
+
|
| 72 |
+
# ── Stage 2: Combined image ──────────────────────────────────────────────────
|
| 73 |
+
FROM ${AGENT_SERVER_IMAGE} AS final
|
| 74 |
+
|
| 75 |
+
ARG AUTOMATION_VERSION
|
| 76 |
+
ARG AGENT_SERVER_SDK_GIT_REF=""
|
| 77 |
+
ARG OPENHANDS_BUILD_GIT_SHA=unknown
|
| 78 |
+
ARG OPENHANDS_BUILD_GIT_REF=unknown
|
| 79 |
+
ARG VITE_BASE_PATH="/canvas"
|
| 80 |
+
ARG VITE_POSTHOG_API_KEY=""
|
| 81 |
+
|
| 82 |
+
ARG AGENT_CANVAS_VERSION=dev
|
| 83 |
+
LABEL org.opencontainers.image.title="agent-canvas"
|
| 84 |
+
LABEL org.opencontainers.image.description="All-in-one agent-canvas: Agent Server + Automation + Frontend"
|
| 85 |
+
LABEL org.opencontainers.image.source="https://github.com/OpenHands/OpenHands"
|
| 86 |
+
LABEL org.opencontainers.image.version="${AGENT_CANVAS_VERSION}"
|
| 87 |
+
LABEL org.opencontainers.image.revision="${OPENHANDS_BUILD_GIT_SHA}"
|
| 88 |
+
|
| 89 |
+
ENV AGENT_CANVAS_BUILD_GIT_SHA=${OPENHANDS_BUILD_GIT_SHA}
|
| 90 |
+
ENV AGENT_CANVAS_BUILD_GIT_REF=${OPENHANDS_BUILD_GIT_REF}
|
| 91 |
+
ENV AGENT_CANVAS_BASE_PATH=${VITE_BASE_PATH}
|
| 92 |
+
ENV VITE_POSTHOG_API_KEY=${VITE_POSTHOG_API_KEY}
|
| 93 |
+
|
| 94 |
+
USER root
|
| 95 |
+
|
| 96 |
+
# Install system deps required by automation's transitive dependencies
|
| 97 |
+
# (asyncpg needs libpq, which the agent-server base image may not include).
|
| 98 |
+
RUN if command -v apt-get >/dev/null 2>&1; then \
|
| 99 |
+
apt-get update && \
|
| 100 |
+
apt-get install -y --no-install-recommends libpq-dev && \
|
| 101 |
+
rm -rf /var/lib/apt/lists/*; \
|
| 102 |
+
fi
|
| 103 |
+
|
| 104 |
+
# Install automation server via pip.
|
| 105 |
+
# Shared deps (openhands-sdk, fastapi, uvicorn, pydantic, httpx, …) are
|
| 106 |
+
# already satisfied by the agent-server base image, so only automation-
|
| 107 |
+
# specific packages (asyncpg, sqlalchemy, boto3, gcloud, …) are added.
|
| 108 |
+
RUN uv pip install --system "openhands-automation==${AUTOMATION_VERSION}" 2>/dev/null \
|
| 109 |
+
|| pip install --no-cache-dir "openhands-automation==${AUTOMATION_VERSION}"
|
| 110 |
+
|
| 111 |
+
RUN if [ -n "${AGENT_SERVER_SDK_GIT_REF}" ]; then \
|
| 112 |
+
BASE_GIT_URL="git+https://github.com/OpenHands/software-agent-sdk@${AGENT_SERVER_SDK_GIT_REF}" && \
|
| 113 |
+
uv pip install --system --force-reinstall \
|
| 114 |
+
"${BASE_GIT_URL}#subdirectory=openhands-sdk" \
|
| 115 |
+
"${BASE_GIT_URL}#subdirectory=openhands-tools" \
|
| 116 |
+
"${BASE_GIT_URL}#subdirectory=openhands-workspace"; \
|
| 117 |
+
fi
|
| 118 |
+
|
| 119 |
+
# Copy the frontend build output.
|
| 120 |
+
# react-router.config.ts unpacks build/client/ into build/ for non-Vercel builds.
|
| 121 |
+
COPY --from=frontend-build /build/build /opt/agent-canvas/frontend
|
| 122 |
+
|
| 123 |
+
# Copy the static-server scripts and their production runtime deps.
|
| 124 |
+
COPY scripts/static-server.mjs /opt/agent-canvas/static-server.mjs
|
| 125 |
+
COPY scripts/proxy-utils.mjs /opt/agent-canvas/proxy-utils.mjs
|
| 126 |
+
COPY --from=frontend-build /build/node_modules/httpxy /opt/agent-canvas/node_modules/httpxy
|
| 127 |
+
COPY --from=frontend-build /build/node_modules/sirv /opt/agent-canvas/node_modules/sirv
|
| 128 |
+
COPY --from=frontend-build /build/node_modules/@polka /opt/agent-canvas/node_modules/@polka
|
| 129 |
+
COPY --from=frontend-build /build/node_modules/mrmime /opt/agent-canvas/node_modules/mrmime
|
| 130 |
+
COPY --from=frontend-build /build/node_modules/totalist /opt/agent-canvas/node_modules/totalist
|
| 131 |
+
|
| 132 |
+
# Copy the runtime-services-info builder (entrypoint.sh runs it as a CLI to
|
| 133 |
+
# emit the agent's <RUNTIME_SERVICES> block; same builder the dev stack uses).
|
| 134 |
+
COPY scripts/runtime-services-info.mjs /opt/agent-canvas/runtime-services-info.mjs
|
| 135 |
+
|
| 136 |
+
# Persisted conversations created before the client_tools migration still
|
| 137 |
+
# import canvas_ui_tool by qualname. Keep the compatibility module available;
|
| 138 |
+
# new conversations define canvas_ui_control through a JSON client tool.
|
| 139 |
+
COPY tools/ /opt/agent-canvas/tools/
|
| 140 |
+
|
| 141 |
+
# Copy generated defaults.env (from config/defaults.json via config-gen stage)
|
| 142 |
+
COPY --from=config-gen /tmp/defaults.env /opt/agent-canvas/defaults.env
|
| 143 |
+
|
| 144 |
+
# Copy the entrypoint
|
| 145 |
+
COPY docker/entrypoint.sh /opt/agent-canvas/entrypoint.sh
|
| 146 |
+
RUN chmod +x /opt/agent-canvas/entrypoint.sh
|
| 147 |
+
|
| 148 |
+
# Pre-create persistence directories with correct ownership so the
|
| 149 |
+
# openhands user can write to them even when Docker creates anonymous
|
| 150 |
+
# volumes (which default to root).
|
| 151 |
+
RUN mkdir -p /home/openhands/.openhands/agent-canvas/conversations \
|
| 152 |
+
/home/openhands/.openhands/agent-canvas/bash_events \
|
| 153 |
+
/home/openhands/.openhands/automation \
|
| 154 |
+
/projects && \
|
| 155 |
+
chown -R openhands:openhands /home/openhands/.openhands /projects
|
| 156 |
+
|
| 157 |
+
USER openhands
|
| 158 |
+
|
| 159 |
+
# Persistence volumes:
|
| 160 |
+
# /home/openhands/.openhands — settings, secrets, conversations, automation DB
|
| 161 |
+
# /projects — user code the agent can read/edit
|
| 162 |
+
# Bind-mount these for data to survive container restarts:
|
| 163 |
+
# docker run -v ~/.openhands:/home/openhands/.openhands -v ~/projects:/projects ...
|
| 164 |
+
VOLUME ["/home/openhands/.openhands", "/projects"]
|
| 165 |
+
|
| 166 |
+
# The entrypoint starts all services and the ingress proxy.
|
| 167 |
+
# Port 8000 is the unified entry point.
|
| 168 |
+
EXPOSE 8000
|
| 169 |
+
|
| 170 |
+
ENTRYPOINT ["tini", "--", "/opt/agent-canvas/entrypoint.sh"]
|
docker/entrypoint.sh
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 3 |
+
# agent-canvas all-in-one entrypoint
|
| 4 |
+
#
|
| 5 |
+
# Starts three services (plus an optional fourth):
|
| 6 |
+
# 1. Agent Server on port $AGENT_SERVER_PORT (default 18000)
|
| 7 |
+
# 2. Automation on port $AUTOMATION_PORT (default 18001)
|
| 8 |
+
# 3. Static server on port $PORT (default 8000)
|
| 9 |
+
# Routes /api/automation/* → automation, /api/* → agent-server,
|
| 10 |
+
# and serves the frontend static build for everything else.
|
| 11 |
+
# 4. (Optional) Public-mode static server on $PUBLIC_MODE_PORT
|
| 12 |
+
# Same frontend, but with --auth-required (no baked session key).
|
| 13 |
+
# Used by auth-mode E2E tests. Only started when PUBLIC_MODE_PORT is set.
|
| 14 |
+
#
|
| 15 |
+
# Environment variables:
|
| 16 |
+
# PORT – Unified entry point port (default: 8000)
|
| 17 |
+
# AGENT_SERVER_PORT – Internal agent-server port (default: 18000)
|
| 18 |
+
# AUTOMATION_PORT – Internal automation port (default: 18001)
|
| 19 |
+
# AGENT_CANVAS_BASE_PATH – Static frontend mount path (default: /canvas)
|
| 20 |
+
# VSCODE_PORT – Internal editor port (default: 8001). The image does
|
| 21 |
+
# not EXPOSE it and the editor is reached through
|
| 22 |
+
# VSCODE_BASE_PATH on $PORT, but openvscode-server
|
| 23 |
+
# binds 0.0.0.0, so `docker run --network host` does
|
| 24 |
+
# leave it directly reachable with only its connection
|
| 25 |
+
# token in front of it.
|
| 26 |
+
# VSCODE_BASE_PATH – Path prefix the editor is served under on $PORT
|
| 27 |
+
# (default: /vscode). Exported to agent-server as
|
| 28 |
+
# OH_VSCODE_BASE_PATH and routed by the static server.
|
| 29 |
+
# agent-server's own OH_VSCODE_PORT / OH_VSCODE_BASE_PATH
|
| 30 |
+
# take precedence over these aliases; whichever is set,
|
| 31 |
+
# one effective pair drives both the editor process and
|
| 32 |
+
# the proxy route.
|
| 33 |
+
# PUBLIC_MODE_PORT – If set, starts a second static server on this port
|
| 34 |
+
# with --auth-required (no session key injected)
|
| 35 |
+
# OH_SECRET_KEY – Secret key for settings encryption (auto-generated
|
| 36 |
+
# and persisted if not provided)
|
| 37 |
+
# OPENHANDS_AUTOMATION_API_KEY – Override automation backend auth key
|
| 38 |
+
# (defaults to session API key — both backends
|
| 39 |
+
# use the same `X-Session-API-Key` header)
|
| 40 |
+
# AUTOMATION_AGENT_SERVER_URL – URL the automation service uses to reach the
|
| 41 |
+
# agent-server (default: http://127.0.0.1:AGENT_SERVER_PORT).
|
| 42 |
+
# Setting this enables local-mode auth so the session
|
| 43 |
+
# API key is validated internally instead of against the
|
| 44 |
+
# OpenHands cloud API.
|
| 45 |
+
# FILE_STORE – Storage backend for automation tarballs (default: local).
|
| 46 |
+
# Without this the automation backend may fall back to
|
| 47 |
+
# S3/GCS which fails without cloud credentials.
|
| 48 |
+
# LOCAL_STORAGE_PATH – Directory for local file storage (default: ~/.openhands/storage)
|
| 49 |
+
# AUTOMATION_BASE_URL – Publicly-reachable base URL for the automation
|
| 50 |
+
# service, used in callback URLs and injected into
|
| 51 |
+
# sandboxes (default: http://127.0.0.1:$PORT).
|
| 52 |
+
# Override in production when the external URL differs.
|
| 53 |
+
# AUTOMATION_WORKSPACE_BASE – Directory for automation run workspaces
|
| 54 |
+
# (default: ~/.openhands/workspaces)
|
| 55 |
+
# Any agent-server or automation env vars are passed through.
|
| 56 |
+
# ═══════════════════════════════════════════════════════════════════════════════
|
| 57 |
+
set -uo pipefail
|
| 58 |
+
|
| 59 |
+
log() { printf '[agent-canvas] %s\n' "$*"; }
|
| 60 |
+
log_error() { printf '[agent-canvas] ERROR: %s\n' "$*" >&2; }
|
| 61 |
+
|
| 62 |
+
# ── Load centralized defaults (generated from config/defaults.json at build) ─
|
| 63 |
+
# shellcheck source=/dev/null
|
| 64 |
+
if [ -f /opt/agent-canvas/defaults.env ]; then
|
| 65 |
+
# shellcheck disable=SC1091
|
| 66 |
+
. /opt/agent-canvas/defaults.env
|
| 67 |
+
fi
|
| 68 |
+
|
| 69 |
+
PORT="${PORT:-${CONFIG_PROXY_PORT:-8000}}"
|
| 70 |
+
AGENT_SERVER_PORT="${AGENT_SERVER_PORT:-${CONFIG_AGENT_SERVER_PORT:-18000}}"
|
| 71 |
+
AUTOMATION_PORT="${AUTOMATION_PORT:-${CONFIG_AUTOMATION_PORT:-18001}}"
|
| 72 |
+
|
| 73 |
+
# The bundled editor is reached through a path prefix on the proxy port rather
|
| 74 |
+
# than a published port of its own. The same prefix has to reach agent-server
|
| 75 |
+
# (it launches openvscode-server with --server-base-path and advertises the
|
| 76 |
+
# prefix from /api/vscode/url) and the static-server route table below, or the
|
| 77 |
+
# advertised URL and the route serving it disagree.
|
| 78 |
+
#
|
| 79 |
+
# Two env var names reach the same setting: OH_VSCODE_PORT / OH_VSCODE_BASE_PATH
|
| 80 |
+
# are agent-server's own documented variables, which a deployment may already
|
| 81 |
+
# set and which this entrypoint passes through like any other OH_* var, while
|
| 82 |
+
# VSCODE_PORT / VSCODE_BASE_PATH are this image's aliases. They collapse to one
|
| 83 |
+
# effective pair here, before anything reads them — resolving them
|
| 84 |
+
# independently would let `OH_VSCODE_BASE_PATH=/editor` move the editor without
|
| 85 |
+
# moving the route, leaving the button pointing at a path the proxy never
|
| 86 |
+
# serves.
|
| 87 |
+
# >>> vscode-config: this block is extracted and executed by
|
| 88 |
+
# >>> __tests__/scripts/docker-vscode-route-sync.test.ts — keep the markers.
|
| 89 |
+
# The canvas mount is resolved here rather than alongside the ports above
|
| 90 |
+
# because the collision guard below compares the two prefixes: keeping both
|
| 91 |
+
# inside the extracted block is what lets that comparison be tested against the
|
| 92 |
+
# real defaults instead of only against values a test injects.
|
| 93 |
+
AGENT_CANVAS_BASE_PATH="${AGENT_CANVAS_BASE_PATH:-${CONFIG_CANVAS_BASE_PATH:-/canvas}}"
|
| 94 |
+
VSCODE_PORT="${OH_VSCODE_PORT:-${VSCODE_PORT:-${CONFIG_VSCODE_PORT:-8001}}}"
|
| 95 |
+
VSCODE_BASE_PATH="${OH_VSCODE_BASE_PATH:-${VSCODE_BASE_PATH:-${CONFIG_VSCODE_BASE_PATH:-/vscode}}}"
|
| 96 |
+
|
| 97 |
+
# Accept "editor", "/editor" and "/editor/" alike: agent-server strips the
|
| 98 |
+
# slashes when it builds the advertised URL, the static-server route table
|
| 99 |
+
# needs the leading one, so settle on one spelling rather than one per use site.
|
| 100 |
+
normalize_base_path() {
|
| 101 |
+
local p="$1"
|
| 102 |
+
while [ "${p#/}" != "$p" ]; do p="${p#/}"; done
|
| 103 |
+
while [ "${p%/}" != "$p" ]; do p="${p%/}"; done
|
| 104 |
+
printf '/%s' "$p"
|
| 105 |
+
}
|
| 106 |
+
VSCODE_BASE_PATH="$(normalize_base_path "$VSCODE_BASE_PATH")"
|
| 107 |
+
if [ "$VSCODE_BASE_PATH" = "/" ]; then
|
| 108 |
+
log_error "VSCODE_BASE_PATH resolved to the site root — that would route the whole origin to the editor instead of the canvas. Set a prefix such as /vscode."
|
| 109 |
+
exit 1
|
| 110 |
+
fi
|
| 111 |
+
|
| 112 |
+
# The canvas mount gets the same treatment, for the same reason and with the
|
| 113 |
+
# same function. static-server normalizes whatever `--base-path` it is handed
|
| 114 |
+
# (`canvas` and `/canvas/` both mount at `/canvas`), so comparing a normalized
|
| 115 |
+
# editor prefix against a raw canvas one below would let `AGENT_CANVAS_BASE_PATH=canvas`
|
| 116 |
+
# with `OH_VSCODE_BASE_PATH=/canvas` past the collision guard and then land both
|
| 117 |
+
# on `/canvas` — where the editor route, registered after the SPA mount, takes
|
| 118 |
+
# the application over. Normalizing here rather than at the comparison keeps the
|
| 119 |
+
# value passed to `--base-path` further down identical to the one guarded.
|
| 120 |
+
AGENT_CANVAS_BASE_PATH="$(normalize_base_path "$AGENT_CANVAS_BASE_PATH")"
|
| 121 |
+
|
| 122 |
+
# static-server keys its route table by prefix and the editor route is
|
| 123 |
+
# registered last, so a prefix that collides with an earlier route silently
|
| 124 |
+
# replaces it rather than failing: OH_VSCODE_BASE_PATH=/api would send every
|
| 125 |
+
# API call to the editor port. Reject collisions and anything that is not a
|
| 126 |
+
# plain single-segment path — '=' would be mis-split by the --route parser
|
| 127 |
+
# (it cuts at the first '='), and whitespace, '?', '#' or '..' have no
|
| 128 |
+
# meaningful reading as a route prefix.
|
| 129 |
+
VSCODE_PATH_SEGMENT="${VSCODE_BASE_PATH#/}"
|
| 130 |
+
case "$VSCODE_PATH_SEGMENT" in
|
| 131 |
+
*/*)
|
| 132 |
+
log_error "VSCODE_BASE_PATH must be a single path segment (got '$VSCODE_BASE_PATH'). Use a prefix such as /vscode."
|
| 133 |
+
exit 1
|
| 134 |
+
;;
|
| 135 |
+
.|..)
|
| 136 |
+
log_error "VSCODE_BASE_PATH must not be a relative path segment (got '$VSCODE_BASE_PATH'). Use a prefix such as /vscode."
|
| 137 |
+
exit 1
|
| 138 |
+
;;
|
| 139 |
+
*[!A-Za-z0-9._-]*)
|
| 140 |
+
log_error "VSCODE_BASE_PATH may only contain letters, digits, '.', '_' and '-' (got '$VSCODE_BASE_PATH'). Use a prefix such as /vscode."
|
| 141 |
+
exit 1
|
| 142 |
+
;;
|
| 143 |
+
esac
|
| 144 |
+
for reserved in /api /sockets /server_info /alive /health /ready /docs /redoc /openapi.json "${AGENT_CANVAS_BASE_PATH:-}"; do
|
| 145 |
+
if [ -n "$reserved" ] && [ "$VSCODE_BASE_PATH" = "$reserved" ]; then
|
| 146 |
+
log_error "VSCODE_BASE_PATH '$VSCODE_BASE_PATH' collides with an existing route and would take it over. Set a different prefix, such as /vscode."
|
| 147 |
+
exit 1
|
| 148 |
+
fi
|
| 149 |
+
done
|
| 150 |
+
|
| 151 |
+
# The port ends up in a proxy target URL, so a non-numeric value fails at the
|
| 152 |
+
# first editor request instead of at startup. Catch it here.
|
| 153 |
+
case "$VSCODE_PORT" in
|
| 154 |
+
''|*[!0-9]*)
|
| 155 |
+
log_error "VSCODE_PORT must be a number (got '$VSCODE_PORT')."
|
| 156 |
+
exit 1
|
| 157 |
+
;;
|
| 158 |
+
esac
|
| 159 |
+
|
| 160 |
+
export OH_VSCODE_PORT="$VSCODE_PORT"
|
| 161 |
+
export OH_VSCODE_BASE_PATH="$VSCODE_BASE_PATH"
|
| 162 |
+
# The single route string every static-server instance registers. Derived from
|
| 163 |
+
# the exported pair above so the advertised URL and the route cannot diverge.
|
| 164 |
+
VSCODE_ROUTE="${VSCODE_BASE_PATH}=http://127.0.0.1:${VSCODE_PORT}"
|
| 165 |
+
# <<< vscode-config
|
| 166 |
+
|
| 167 |
+
# Persistence paths — keep settings, conversations, bash history under a
|
| 168 |
+
# single well-known directory that the VOLUME directive exposes.
|
| 169 |
+
OPENHANDS_DIR="${HOME}/.openhands"
|
| 170 |
+
STATE_DIR="${OPENHANDS_DIR}/${CONFIG_STATE_SUBDIR:-agent-canvas}"
|
| 171 |
+
export OH_PERSISTENCE_DIR="${OH_PERSISTENCE_DIR:-${OPENHANDS_DIR}}"
|
| 172 |
+
export OH_CONVERSATIONS_PATH="${OH_CONVERSATIONS_PATH:-${OPENHANDS_DIR}/${CONFIG_CONVERSATIONS:-agent-canvas/conversations}}"
|
| 173 |
+
export OH_BASH_EVENTS_DIR="${OH_BASH_EVENTS_DIR:-${OPENHANDS_DIR}/${CONFIG_BASH_EVENTS:-agent-canvas/bash_events}}"
|
| 174 |
+
|
| 175 |
+
# OH_SECRET_KEY is required for settings/secrets encryption. Without it the
|
| 176 |
+
# agent-server refuses to return encrypted secrets → conversation creation
|
| 177 |
+
# fails with a 503. Auto-generate and persist (just like the session API key)
|
| 178 |
+
# so the image never runs with a known default.
|
| 179 |
+
SECRET_KEY_FILE="${STATE_DIR}/secret-key.txt"
|
| 180 |
+
if [ -z "${OH_SECRET_KEY:-}" ]; then
|
| 181 |
+
if [ -f "$SECRET_KEY_FILE" ]; then
|
| 182 |
+
OH_SECRET_KEY="$(cat "$SECRET_KEY_FILE")"
|
| 183 |
+
else
|
| 184 |
+
OH_SECRET_KEY="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
|
| 185 |
+
mkdir -p "$(dirname "$SECRET_KEY_FILE")"
|
| 186 |
+
printf '%s' "$OH_SECRET_KEY" > "$SECRET_KEY_FILE"
|
| 187 |
+
chmod 600 "$SECRET_KEY_FILE"
|
| 188 |
+
log "Generated OH_SECRET_KEY (persisted to $SECRET_KEY_FILE)"
|
| 189 |
+
fi
|
| 190 |
+
fi
|
| 191 |
+
export OH_SECRET_KEY
|
| 192 |
+
|
| 193 |
+
# API key — generate one if not provided so the image doesn't run wide-open
|
| 194 |
+
# by default. LOCAL_BACKEND_API_KEY is the single user-facing env var.
|
| 195 |
+
# Persisted so restarts reuse the same key.
|
| 196 |
+
API_KEY_FILE="${STATE_DIR}/api-key.txt"
|
| 197 |
+
|
| 198 |
+
if [ -z "${LOCAL_BACKEND_API_KEY:-}" ] && [ -z "${OH_SESSION_API_KEYS_0:-}" ]; then
|
| 199 |
+
if [ -f "$API_KEY_FILE" ]; then
|
| 200 |
+
LOCAL_BACKEND_API_KEY="$(cat "$API_KEY_FILE")"
|
| 201 |
+
else
|
| 202 |
+
LOCAL_BACKEND_API_KEY="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
|
| 203 |
+
mkdir -p "$(dirname "$API_KEY_FILE")"
|
| 204 |
+
printf '%s' "$LOCAL_BACKEND_API_KEY" > "$API_KEY_FILE"
|
| 205 |
+
chmod 600 "$API_KEY_FILE"
|
| 206 |
+
log "Generated API key (persisted to $API_KEY_FILE)"
|
| 207 |
+
fi
|
| 208 |
+
export OH_SESSION_API_KEYS_0="$LOCAL_BACKEND_API_KEY"
|
| 209 |
+
fi
|
| 210 |
+
|
| 211 |
+
# Both backends share the same API key value and the same `X-Session-API-Key`
|
| 212 |
+
# header for authentication. Default OPENHANDS_AUTOMATION_API_KEY to the
|
| 213 |
+
# API key so a single credential secures the whole stack.
|
| 214 |
+
EFFECTIVE_SESSION_KEY="${OH_SESSION_API_KEYS_0:-${LOCAL_BACKEND_API_KEY:-}}"
|
| 215 |
+
if [ -z "$EFFECTIVE_SESSION_KEY" ]; then
|
| 216 |
+
log "ERROR: No session API key available — cannot configure automation auth"
|
| 217 |
+
exit 1
|
| 218 |
+
fi
|
| 219 |
+
export OPENHANDS_AUTOMATION_API_KEY="${OPENHANDS_AUTOMATION_API_KEY:-${EFFECTIVE_SESSION_KEY}}"
|
| 220 |
+
export AUTOMATION_LOCAL_API_KEY="${AUTOMATION_LOCAL_API_KEY:-${EFFECTIVE_SESSION_KEY}}"
|
| 221 |
+
export AUTOMATION_AGENT_SERVER_API_KEY="${AUTOMATION_AGENT_SERVER_API_KEY:-${EFFECTIVE_SESSION_KEY}}"
|
| 222 |
+
export OPENHANDS_REMOTE_WS_READY_REQUIRED="${OPENHANDS_REMOTE_WS_READY_REQUIRED:-false}"
|
| 223 |
+
if [ -z "${AUTOMATION_POSTHOG_API_KEY:-}" ]; then
|
| 224 |
+
if [ -n "${VITE_POSTHOG_API_KEY:-}" ]; then
|
| 225 |
+
export AUTOMATION_POSTHOG_API_KEY="$VITE_POSTHOG_API_KEY"
|
| 226 |
+
elif [ "${VITE_DO_NOT_TRACK:-}" != "1" ]; then
|
| 227 |
+
export AUTOMATION_POSTHOG_API_KEY="${CONFIG_POSTHOG_API_KEY:-}"
|
| 228 |
+
fi
|
| 229 |
+
fi
|
| 230 |
+
if [ -n "${AUTOMATION_POSTHOG_API_KEY:-}" ]; then
|
| 231 |
+
export AUTOMATION_POSTHOG_HOST="${AUTOMATION_POSTHOG_HOST:-${VITE_POSTHOG_HOST:-${CONFIG_POSTHOG_HOST:-}}}"
|
| 232 |
+
fi
|
| 233 |
+
|
| 234 |
+
# Configure product analytics for the agent-server. The SDK uses its own
|
| 235 |
+
# OH_TELEMETRY_* variables, so mirror the same Canvas/PostHog defaults used by
|
| 236 |
+
# the frontend and automation backend while preserving explicit operator
|
| 237 |
+
# overrides. Consent stays in persisted settings, where the backend/UI owns it.
|
| 238 |
+
if [ "${VITE_DO_NOT_TRACK:-}" = "1" ]; then
|
| 239 |
+
export DO_NOT_TRACK="${DO_NOT_TRACK:-1}"
|
| 240 |
+
fi
|
| 241 |
+
|
| 242 |
+
if [ -z "${OH_TELEMETRY_POSTHOG_API_KEY:-}" ]; then
|
| 243 |
+
if [ -n "${VITE_POSTHOG_API_KEY:-}" ]; then
|
| 244 |
+
export OH_TELEMETRY_POSTHOG_API_KEY="$VITE_POSTHOG_API_KEY"
|
| 245 |
+
elif [ "${DO_NOT_TRACK:-}" != "1" ]; then
|
| 246 |
+
export OH_TELEMETRY_POSTHOG_API_KEY="${CONFIG_POSTHOG_API_KEY:-}"
|
| 247 |
+
fi
|
| 248 |
+
fi
|
| 249 |
+
|
| 250 |
+
if [ -z "${OH_TELEMETRY_EXPORTER:-}" ] && [ -n "${OH_TELEMETRY_POSTHOG_API_KEY:-}" ]; then
|
| 251 |
+
export OH_TELEMETRY_EXPORTER="posthog"
|
| 252 |
+
fi
|
| 253 |
+
|
| 254 |
+
if [ "${OH_TELEMETRY_EXPORTER:-}" = "posthog" ] && [ -n "${OH_TELEMETRY_POSTHOG_API_KEY:-}" ]; then
|
| 255 |
+
export OH_TELEMETRY_POSTHOG_HOST="${OH_TELEMETRY_POSTHOG_HOST:-${VITE_POSTHOG_HOST:-${CONFIG_POSTHOG_HOST:-}}}"
|
| 256 |
+
fi
|
| 257 |
+
|
| 258 |
+
# AGENT_SERVER_URL — needed by automation sandbox callbacks.
|
| 259 |
+
export AGENT_SERVER_URL="${AGENT_SERVER_URL:-http://127.0.0.1:${AGENT_SERVER_PORT}}"
|
| 260 |
+
|
| 261 |
+
# AUTOMATION_AGENT_SERVER_URL — the URL the automation service uses to reach
|
| 262 |
+
# the agent-server REST API (tarball upload, bash dispatch, auth key minting).
|
| 263 |
+
# When set, ServiceSettings.is_local_mode returns True, enabling local API key
|
| 264 |
+
# authentication. Without this, the automation server falls back to validating
|
| 265 |
+
# keys against the OpenHands cloud API (app.all-hands.dev), which returns 401
|
| 266 |
+
# for locally-generated session keys.
|
| 267 |
+
export AUTOMATION_AGENT_SERVER_URL="${AUTOMATION_AGENT_SERVER_URL:-http://127.0.0.1:${AGENT_SERVER_PORT}}"
|
| 268 |
+
|
| 269 |
+
# Keep the legacy canvas_ui_tool module importable when the agent-server restores
|
| 270 |
+
# conversations whose persisted metadata still references its module qualname.
|
| 271 |
+
# It is also imported at startup below (--import-modules) so its builtin
|
| 272 |
+
# FinishTool registration lets automation runs resolve the tool on their
|
| 273 |
+
# remote conversations (see the note at the bottom of tools/canvas_ui_tool.py).
|
| 274 |
+
export OH_EXTRA_PYTHON_PATH="${OH_EXTRA_PYTHON_PATH:-/opt/agent-canvas/tools}"
|
| 275 |
+
AGENT_SERVER_IMPORT_MODULES="canvas_ui_tool"
|
| 276 |
+
|
| 277 |
+
# Track child PIDs so we can clean up on exit.
|
| 278 |
+
PIDS=()
|
| 279 |
+
|
| 280 |
+
cleanup() {
|
| 281 |
+
log "Shutting down..."
|
| 282 |
+
for pid in "${PIDS[@]}"; do
|
| 283 |
+
kill "$pid" 2>/dev/null || true
|
| 284 |
+
done
|
| 285 |
+
wait 2>/dev/null || true
|
| 286 |
+
exit 0
|
| 287 |
+
}
|
| 288 |
+
trap cleanup EXIT SIGINT SIGTERM
|
| 289 |
+
|
| 290 |
+
# ── 1. Start Agent Server ────────────────────────────────────────────────────
|
| 291 |
+
log "Starting agent-server on port $AGENT_SERVER_PORT..."
|
| 292 |
+
|
| 293 |
+
if command -v openhands-agent-server >/dev/null 2>&1; then
|
| 294 |
+
# Binary build (production image)
|
| 295 |
+
openhands-agent-server --port "$AGENT_SERVER_PORT" \
|
| 296 |
+
--import-modules "$AGENT_SERVER_IMPORT_MODULES" &
|
| 297 |
+
elif [ -x /agent-server/.venv/bin/python ]; then
|
| 298 |
+
# Source build (development image)
|
| 299 |
+
/agent-server/.venv/bin/python -m openhands.agent_server --port "$AGENT_SERVER_PORT" \
|
| 300 |
+
--import-modules "$AGENT_SERVER_IMPORT_MODULES" &
|
| 301 |
+
else
|
| 302 |
+
log_error "Cannot find agent-server binary or source venv."
|
| 303 |
+
exit 1
|
| 304 |
+
fi
|
| 305 |
+
PIDS+=($!)
|
| 306 |
+
|
| 307 |
+
# ── 2. Start Automation Server ───────────────────────────────────────────────
|
| 308 |
+
log "Starting automation server on port $AUTOMATION_PORT..."
|
| 309 |
+
|
| 310 |
+
# File storage — use local filesystem unless the user has configured cloud
|
| 311 |
+
# storage. Without FILE_STORE=local the automation backend may fall back
|
| 312 |
+
# to a cloud provider (S3/GCS) which will fail without credentials, causing
|
| 313 |
+
# tarball-based presets (preset/prompt, preset/plugin) to silently error.
|
| 314 |
+
export FILE_STORE="${FILE_STORE:-local}"
|
| 315 |
+
export LOCAL_STORAGE_PATH="${LOCAL_STORAGE_PATH:-${OPENHANDS_DIR}/storage}"
|
| 316 |
+
mkdir -p "$LOCAL_STORAGE_PATH"
|
| 317 |
+
|
| 318 |
+
# AUTOMATION_BASE_URL — the publicly-reachable base URL for the automation
|
| 319 |
+
# service. Appended to callback URLs and injected into each sandbox as
|
| 320 |
+
# AUTOMATION_API_URL. Defaults to the unified ingress.
|
| 321 |
+
export AUTOMATION_BASE_URL="${AUTOMATION_BASE_URL:-http://127.0.0.1:${PORT}}"
|
| 322 |
+
|
| 323 |
+
# AUTOMATION_WORKSPACE_BASE — where automation runs unpack tarballs.
|
| 324 |
+
export AUTOMATION_WORKSPACE_BASE="${AUTOMATION_WORKSPACE_BASE:-${OPENHANDS_DIR}/workspaces}"
|
| 325 |
+
mkdir -p "$AUTOMATION_WORKSPACE_BASE"
|
| 326 |
+
|
| 327 |
+
# Default to SQLite so the automation server works out of the box without
|
| 328 |
+
# an external PostgreSQL instance. Users can override AUTOMATION_DB_URL to
|
| 329 |
+
# point at a real Postgres for production deployments.
|
| 330 |
+
if [ -z "${AUTOMATION_DB_URL:-}" ]; then
|
| 331 |
+
AUTOMATION_DB_FILE="${OPENHANDS_DIR}/${CONFIG_AUTOMATION_DB:-automation/automations.db}"
|
| 332 |
+
mkdir -p "$(dirname "$AUTOMATION_DB_FILE")"
|
| 333 |
+
export AUTOMATION_DB_URL="sqlite+aiosqlite:///${AUTOMATION_DB_FILE}"
|
| 334 |
+
log "Using SQLite database: $AUTOMATION_DB_URL"
|
| 335 |
+
fi
|
| 336 |
+
|
| 337 |
+
# The automation server uses uvicorn. Set AUTOMATION_PORT via its CLI.
|
| 338 |
+
if command -v uvicorn >/dev/null 2>&1; then
|
| 339 |
+
uvicorn openhands.automation.app:app \
|
| 340 |
+
--host 0.0.0.0 \
|
| 341 |
+
--port "$AUTOMATION_PORT" &
|
| 342 |
+
PIDS+=($!)
|
| 343 |
+
elif python -c "import openhands.automation" 2>/dev/null; then
|
| 344 |
+
python -m uvicorn openhands.automation.app:app \
|
| 345 |
+
--host 0.0.0.0 \
|
| 346 |
+
--port "$AUTOMATION_PORT" &
|
| 347 |
+
PIDS+=($!)
|
| 348 |
+
else
|
| 349 |
+
log "WARNING: Automation server not found, skipping."
|
| 350 |
+
fi
|
| 351 |
+
|
| 352 |
+
# ── 3. Wait for backends to be ready ─────────────────────────────────────────
|
| 353 |
+
wait_for_port() {
|
| 354 |
+
local port=$1 name=$2 max_wait=${3:-30}
|
| 355 |
+
local elapsed=0
|
| 356 |
+
while ! (echo >/dev/tcp/127.0.0.1/"$port") 2>/dev/null; do
|
| 357 |
+
sleep 1
|
| 358 |
+
elapsed=$((elapsed + 1))
|
| 359 |
+
if [ "$elapsed" -ge "$max_wait" ]; then
|
| 360 |
+
log "WARNING: $name on port $port did not become ready within ${max_wait}s"
|
| 361 |
+
return 1
|
| 362 |
+
fi
|
| 363 |
+
done
|
| 364 |
+
log "$name is ready on port $port"
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
wait_for_port "$AGENT_SERVER_PORT" "Agent Server" 60 &
|
| 368 |
+
WAIT_PID1=$!
|
| 369 |
+
wait_for_port "$AUTOMATION_PORT" "Automation Server" 60 &
|
| 370 |
+
WAIT_PID2=$!
|
| 371 |
+
wait "$WAIT_PID1" "$WAIT_PID2"
|
| 372 |
+
|
| 373 |
+
# ── 4. Start static server (frontend + proxy) ────────────────────────────────
|
| 374 |
+
log "Starting frontend + proxy on port $PORT..."
|
| 375 |
+
|
| 376 |
+
# Describe the local runtime services so the frontend can populate the agent's
|
| 377 |
+
# <RUNTIME_SERVICES> system-prompt block (without it the agent does not know how
|
| 378 |
+
# to reach the local automation backend and falls back to the cloud API). These
|
| 379 |
+
# URLs are runtime config (overridable at `docker run`), so build the JSON here
|
| 380 |
+
# from the sandbox-facing URLs the entrypoint already exports. static-server.mjs
|
| 381 |
+
# appends it to /server_info as runtime_services and also injects the legacy
|
| 382 |
+
# window global for older frontend bundles.
|
| 383 |
+
RUNTIME_SERVICES_INFO="$(node /opt/agent-canvas/runtime-services-info.mjs \
|
| 384 |
+
--mode docker \
|
| 385 |
+
--agent-host-alias 127.0.0.1 \
|
| 386 |
+
--agent-server-url "$AGENT_SERVER_URL" \
|
| 387 |
+
--automation-url "$AUTOMATION_BASE_URL")"
|
| 388 |
+
|
| 389 |
+
# EFFECTIVE_SESSION_KEY is set above from LOCAL_BACKEND_API_KEY or the persisted api-key.txt
|
| 390 |
+
node /opt/agent-canvas/static-server.mjs \
|
| 391 |
+
--port "$PORT" \
|
| 392 |
+
--host :: \
|
| 393 |
+
--dir /opt/agent-canvas/frontend \
|
| 394 |
+
--base-path "$AGENT_CANVAS_BASE_PATH" \
|
| 395 |
+
--session-api-key "$EFFECTIVE_SESSION_KEY" \
|
| 396 |
+
--runtime-services-info "$RUNTIME_SERVICES_INFO" \
|
| 397 |
+
--route "/api/automation=http://127.0.0.1:${AUTOMATION_PORT}" \
|
| 398 |
+
--route "/api=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 399 |
+
--route "/server_info=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 400 |
+
--route "/sockets=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 401 |
+
--route "/alive=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 402 |
+
--route "/health=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 403 |
+
--route "/ready=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 404 |
+
--route "/docs=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 405 |
+
--route "/redoc=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 406 |
+
--route "/openapi.json=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 407 |
+
--route "$VSCODE_ROUTE" \
|
| 408 |
+
--vscode-base-path "$VSCODE_BASE_PATH" \
|
| 409 |
+
--no-referrer-prefix "$VSCODE_BASE_PATH" &
|
| 410 |
+
STATIC_PID=$!
|
| 411 |
+
PIDS+=("$STATIC_PID")
|
| 412 |
+
|
| 413 |
+
# ── 5. (Optional) Public-mode static server ─────────────────────────────────
|
| 414 |
+
# When PUBLIC_MODE_PORT is set, start a second static-server instance that
|
| 415 |
+
# serves the same frontend WITHOUT injecting the session key into the HTML
|
| 416 |
+
# (--auth-required). This is used by auth-mode E2E tests to verify the
|
| 417 |
+
# ApiKeyEntryScreen gate, key rotation recovery, etc.
|
| 418 |
+
#
|
| 419 |
+
# Neither the editor route nor --vscode-base-path is registered here, and the
|
| 420 |
+
# pair is deliberate: the route is what would serve the editor, and the flag is
|
| 421 |
+
# what tells the frontend this origin can. Omitting only the route would leave
|
| 422 |
+
# the control rendering and falling through to the SPA, because the agent-server
|
| 423 |
+
# it shares with the main instance still reports the editor as available.
|
| 424 |
+
#
|
| 425 |
+
# --auth-required only
|
| 426 |
+
# controls whether the session key is injected into the served HTML; the
|
| 427 |
+
# dispatcher matches routes before it reaches that flag, so proxied paths are
|
| 428 |
+
# not gated by it. The routes above are safe on that footing because
|
| 429 |
+
# agent-server enforces the session key itself, but the editor's own
|
| 430 |
+
# credential is the connection token agent-server puts in the query string —
|
| 431 |
+
# and agent-server derives that token from session_api_keys[0], so it is the
|
| 432 |
+
# same secret that authenticates /api. Registering the route here would put
|
| 433 |
+
# that secret in a browser-navigable URL on the origin that exists precisely
|
| 434 |
+
# to test the unauthenticated case, where it would persist in history and
|
| 435 |
+
# leak by Referer from the workbench's own subresources.
|
| 436 |
+
#
|
| 437 |
+
# The token's scope is upstream's to fix and is tracked in
|
| 438 |
+
# OpenHands/software-agent-sdk#4317; if the editor gets a credential of its own,
|
| 439 |
+
# this exclusion and the --no-referrer-prefix below can both be revisited.
|
| 440 |
+
if [ -n "${PUBLIC_MODE_PORT:-}" ]; then
|
| 441 |
+
log "Starting public-mode frontend on port $PUBLIC_MODE_PORT (--auth-required)..."
|
| 442 |
+
node /opt/agent-canvas/static-server.mjs \
|
| 443 |
+
--port "$PUBLIC_MODE_PORT" \
|
| 444 |
+
--host :: \
|
| 445 |
+
--dir /opt/agent-canvas/frontend \
|
| 446 |
+
--base-path "$AGENT_CANVAS_BASE_PATH" \
|
| 447 |
+
--auth-required \
|
| 448 |
+
--runtime-services-info "$RUNTIME_SERVICES_INFO" \
|
| 449 |
+
--route "/api/automation=http://127.0.0.1:${AUTOMATION_PORT}" \
|
| 450 |
+
--route "/api=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 451 |
+
--route "/server_info=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 452 |
+
--route "/sockets=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 453 |
+
--route "/alive=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 454 |
+
--route "/health=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 455 |
+
--route "/ready=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 456 |
+
--route "/docs=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 457 |
+
--route "/redoc=http://127.0.0.1:${AGENT_SERVER_PORT}" \
|
| 458 |
+
--route "/openapi.json=http://127.0.0.1:${AGENT_SERVER_PORT}" &
|
| 459 |
+
PIDS+=($!)
|
| 460 |
+
fi
|
| 461 |
+
|
| 462 |
+
log "All services started. Unified entry point: http://0.0.0.0:${PORT}/"
|
| 463 |
+
|
| 464 |
+
# Keep the container alive while the static-server (ingress) is running.
|
| 465 |
+
# Backend crashes (agent-server, automation) are tolerated — the proxy
|
| 466 |
+
# returns 502 for downed routes, matching the non-Docker path where each
|
| 467 |
+
# service is an independent host process.
|
| 468 |
+
#
|
| 469 |
+
# Pattern: `sleep & wait $!` makes `wait` (a bash builtin) the foreground
|
| 470 |
+
# operation. Unlike a bare `sleep`, the builtin `wait` is interrupted
|
| 471 |
+
# immediately when a trapped signal (SIGTERM/SIGINT) arrives, so cleanup()
|
| 472 |
+
# fires without delay. cleanup() calls `exit 0` to terminate after the
|
| 473 |
+
# trap returns. The loop re-checks the static-server PID every 10 s so the
|
| 474 |
+
# container exits promptly if the ingress process dies on its own.
|
| 475 |
+
while kill -0 "$STATIC_PID" 2>/dev/null; do
|
| 476 |
+
sleep 10 & wait $!
|
| 477 |
+
done
|
| 478 |
+
log_error "Static server (PID $STATIC_PID) exited"
|
| 479 |
+
exit 1
|
docs/SELF_HOSTING.md
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Self-Hosting Agent Canvas on a Virtual Machine
|
| 2 |
+
|
| 3 |
+
This guide walks through running Agent Canvas on a virtual machine (VM) so you
|
| 4 |
+
can reach it from anywhere via a browser.
|
| 5 |
+
|
| 6 |
+
> [!WARNING]
|
| 7 |
+
> Agent Canvas drives an agent that can read and write the filesystem of the
|
| 8 |
+
> machine it runs on, execute shell commands, and reach the network. Anyone who
|
| 9 |
+
> can talk to the agent server can do the same. **Treat the VM as you would any
|
| 10 |
+
> machine that holds production credentials**, and lock it down before exposing
|
| 11 |
+
> it to the public internet.
|
| 12 |
+
|
| 13 |
+
## Quickstart
|
| 14 |
+
|
| 15 |
+
1. **Provision a machine** — a cloud VM or dedicated hardware (Mac Mini, NUC, etc.)
|
| 16 |
+
2. **Secure the machine** — lock down the network firewall
|
| 17 |
+
3. **Run Agent Canvas** — generate a key with `openssl rand -base64 32`, then `export LOCAL_BACKEND_API_KEY=<key>` and `npx @openhands/agent-canvas --public`
|
| 18 |
+
4. **(Optional) Get a domain** — point a domain at the machine with nginx + Let's Encrypt for TLS
|
| 19 |
+
5. **(Optional) Connect locally** — add the remote as a backend in your local Agent Canvas
|
| 20 |
+
|
| 21 |
+
## Details
|
| 22 |
+
|
| 23 |
+
The deployment model:
|
| 24 |
+
|
| 25 |
+
```mermaid
|
| 26 |
+
flowchart LR
|
| 27 |
+
user(["🧑 You"])
|
| 28 |
+
subgraph vm["Your VM (single host)"]
|
| 29 |
+
direction LR
|
| 30 |
+
nginx["nginx :443<br/>(TLS)"]
|
| 31 |
+
ingress["Ingress proxy<br/>127.0.0.1:8000"]
|
| 32 |
+
static["Static server<br/>:3001"]
|
| 33 |
+
agent["Agent server<br/>:18000<br/>(LOCAL_BACKEND_API_KEY)"]
|
| 34 |
+
automation["Automation backend<br/>:18001"]
|
| 35 |
+
nginx --> ingress
|
| 36 |
+
ingress -- "/*" --> static
|
| 37 |
+
ingress -- "/api/*, /sockets" --> agent
|
| 38 |
+
ingress -- "/api/automation/*" --> automation
|
| 39 |
+
end
|
| 40 |
+
user -- "HTTPS / 443" --> nginx
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
`npx @openhands/agent-canvas --public` spins up the static frontend server,
|
| 44 |
+
the agent server, and the automation backend, fronted by an ingress proxy on
|
| 45 |
+
`127.0.0.1:8000` that routes by path. nginx only needs to know about that
|
| 46 |
+
single ingress port.
|
| 47 |
+
|
| 48 |
+
The `--public` flag enables **public mode**: the API key is _not_ baked into
|
| 49 |
+
the frontend. Instead, users see an API key entry screen when they first load
|
| 50 |
+
the UI and must paste the `LOCAL_BACKEND_API_KEY` to proceed.
|
| 51 |
+
|
| 52 |
+
The defenses layered on top of this:
|
| 53 |
+
|
| 54 |
+
1. **Cloud / network firewall (step 2)** — by default nothing inbound is
|
| 55 |
+
reachable except SSH from your IP. If you do step 4, you additionally open
|
| 56 |
+
80 and 443 (ideally still restricted to your IP allow-list on 443).
|
| 57 |
+
2. **`LOCAL_BACKEND_API_KEY` + public mode (step 3)** — every `/api/*` call
|
| 58 |
+
must carry a matching `X-Session-API-Key` header, and the UI requires
|
| 59 |
+
users to enter the key before they can interact with the agent.
|
| 60 |
+
|
| 61 |
+
## 1. Provision a machine
|
| 62 |
+
|
| 63 |
+
Any always-on Linux (or macOS) host with a stable network connection will do:
|
| 64 |
+
|
| 65 |
+
- **A cloud VM** — DigitalOcean, AWS EC2, GCP, Hetzner, Linode, etc.
|
| 66 |
+
Ubuntu 24.04 LTS is a good default. 2 vCPU / 4 GB RAM is plenty for a
|
| 67 |
+
single user.
|
| 68 |
+
- **Dedicated hardware** — a Mac Mini, an Intel NUC, a spare laptop.
|
| 69 |
+
Keep in mind that anything reachable from your LAN is part of the threat
|
| 70 |
+
model.
|
| 71 |
+
|
| 72 |
+
## 2. Secure the machine
|
| 73 |
+
|
| 74 |
+
> [!IMPORTANT]
|
| 75 |
+
> Do this **before** you start the agent server for the first time.
|
| 76 |
+
|
| 77 |
+
The default posture should be: **nothing inbound is reachable from the public
|
| 78 |
+
internet** except SSH (and only from your own IP). All services bind to
|
| 79 |
+
`127.0.0.1` (see step 3), but the network firewall is what guarantees no one
|
| 80 |
+
else can reach them even if something binds wrong.
|
| 81 |
+
|
| 82 |
+
Restrict inbound traffic at the cloud-provider / network level (DigitalOcean
|
| 83 |
+
Cloud Firewall, AWS Security Group, GCP firewall rule, etc.):
|
| 84 |
+
|
| 85 |
+
- **Inbound 22 (SSH)** — restrict to your own IP / VPN CIDR.
|
| 86 |
+
- **Everything else** — drop. The ingress port (`:8000`), agent server
|
| 87 |
+
(`:18000`), automation backend (`:18001`), and static server (`:3001`)
|
| 88 |
+
must not be reachable from outside the host.
|
| 89 |
+
|
| 90 |
+
At this point your machine is reachable only over SSH. That's enough to run
|
| 91 |
+
the agent (step 3) and access the UI through an SSH tunnel. If you also want
|
| 92 |
+
to reach it from a browser without tunneling, you'll open ports 80 and 443
|
| 93 |
+
in step 4.
|
| 94 |
+
|
| 95 |
+
> [!NOTE]
|
| 96 |
+
> **The bundled editor shares the canvas's browser origin.** OpenVSCode is
|
| 97 |
+
> served under a path prefix (`/vscode` by default) on the proxy port rather
|
| 98 |
+
> than on a published port of its own — that is what keeps the deployment to a
|
| 99 |
+
> single port, but a path prefix routes requests, it does not isolate them.
|
| 100 |
+
> Script running anywhere on that origin, including editor content reached
|
| 101 |
+
> through an extension or a compromised asset, can read the canvas's
|
| 102 |
+
> `localStorage`, which holds the SESSION API key of _every_ backend registered
|
| 103 |
+
> in that browser. Tracked in
|
| 104 |
+
> [#16492](https://github.com/OpenHands/OpenHands/issues/16492).
|
| 105 |
+
|
| 106 |
+
## 3. Run Agent Canvas
|
| 107 |
+
|
| 108 |
+
Install the prerequisites on the machine. On Ubuntu:
|
| 109 |
+
|
| 110 |
+
```bash
|
| 111 |
+
apt-get update
|
| 112 |
+
apt-get install -y curl git
|
| 113 |
+
# Node.js 22.x (use nvm, asdf, or NodeSource — whatever you prefer)
|
| 114 |
+
# uv (for the agent-server uvx runtime):
|
| 115 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
On macOS (Mac Mini, etc.) install Node and `uv` via `brew` instead.
|
| 119 |
+
|
| 120 |
+
Start Agent Canvas in public mode:
|
| 121 |
+
|
| 122 |
+
```bash
|
| 123 |
+
export LOCAL_BACKEND_API_KEY=$(openssl rand -base64 32) # generate once; store securely
|
| 124 |
+
npx @openhands/agent-canvas --public
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
Using `export` keeps the key out of the process list (`ps aux`). The
|
| 128 |
+
`openssl rand -base64 32` command generates a cryptographically random
|
| 129 |
+
256-bit key — copy the printed value somewhere safe before proceeding.
|
| 130 |
+
|
| 131 |
+
This single command downloads the latest release, starts the agent server,
|
| 132 |
+
the automation backend, and the static frontend, and fronts them with an
|
| 133 |
+
ingress proxy on `127.0.0.1:8000`.
|
| 134 |
+
|
| 135 |
+
To keep the service running after your SSH session ends, use a process manager.
|
| 136 |
+
|
| 137 |
+
**Option A — tmux (quick):**
|
| 138 |
+
|
| 139 |
+
```bash
|
| 140 |
+
export LOCAL_BACKEND_API_KEY=<your-saved-key>
|
| 141 |
+
tmux new-session -d -s canvas 'npx @openhands/agent-canvas --public'
|
| 142 |
+
# Reconnect later with: tmux attach -t canvas
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
**Option B — systemd (recommended for long-term deployments):**
|
| 146 |
+
|
| 147 |
+
Create `/etc/systemd/system/agent-canvas.service`:
|
| 148 |
+
|
| 149 |
+
```ini
|
| 150 |
+
[Unit]
|
| 151 |
+
Description=Agent Canvas
|
| 152 |
+
After=network.target
|
| 153 |
+
|
| 154 |
+
[Service]
|
| 155 |
+
Environment=LOCAL_BACKEND_API_KEY=<your-key>
|
| 156 |
+
ExecStart=npx @openhands/agent-canvas --public
|
| 157 |
+
Restart=on-failure
|
| 158 |
+
RestartSec=5
|
| 159 |
+
|
| 160 |
+
[Install]
|
| 161 |
+
WantedBy=multi-user.target
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
Then enable and start the unit:
|
| 165 |
+
|
| 166 |
+
```bash
|
| 167 |
+
sudo systemctl daemon-reload
|
| 168 |
+
sudo systemctl enable --now agent-canvas
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
> [!WARNING]
|
| 172 |
+
> The agent server runs **directly on the host** with full access to the
|
| 173 |
+
> machine's filesystem, environment, and network. The firewall (step 2) and
|
| 174 |
+
> the `LOCAL_BACKEND_API_KEY` are what stop a stranger from getting that
|
| 175 |
+
> same access.
|
| 176 |
+
|
| 177 |
+
The `--public` flag means anyone who opens the UI must enter the API key
|
| 178 |
+
before they can use it. Without `--public`, the key is auto-injected into
|
| 179 |
+
the frontend (convenient for local-only use, but unsafe for a
|
| 180 |
+
publicly-reachable deployment).
|
| 181 |
+
|
| 182 |
+
## 4. (Optional) Get a domain and put nginx + Let's Encrypt in front
|
| 183 |
+
|
| 184 |
+
If you want to reach the UI from a browser without an SSH tunnel — for
|
| 185 |
+
example, from a phone or a machine you can't easily forward ports from —
|
| 186 |
+
point a domain at the host and front it with nginx + TLS. nginx terminates
|
| 187 |
+
TLS and forwards to the ingress on `127.0.0.1:8000`.
|
| 188 |
+
|
| 189 |
+
### Point a domain at the machine
|
| 190 |
+
|
| 191 |
+
Create an `A` record pointing to the machine's public IPv4 — for example
|
| 192 |
+
`canvas.example.com`. Verify DNS has propagated:
|
| 193 |
+
|
| 194 |
+
```bash
|
| 195 |
+
dig +short canvas.example.com
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
### Open ports 80 and 443
|
| 199 |
+
|
| 200 |
+
Go back to your network firewall and additionally allow inbound:
|
| 201 |
+
|
| 202 |
+
- **Inbound 80 (HTTP)** — open to `0.0.0.0/0` (required for Let's Encrypt
|
| 203 |
+
HTTP-01 challenges). nginx will redirect all traffic to HTTPS.
|
| 204 |
+
- **Inbound 443 (HTTPS)** — restrict to your own IP / VPN CIDR if you can.
|
| 205 |
+
If you need it world-open (e.g. you roam often), `LOCAL_BACKEND_API_KEY`
|
| 206 |
+
is your primary defense.
|
| 207 |
+
|
| 208 |
+
### Install nginx and certbot
|
| 209 |
+
|
| 210 |
+
```bash
|
| 211 |
+
apt-get install -y nginx certbot python3-certbot-nginx
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
### nginx site config
|
| 215 |
+
|
| 216 |
+
Drop this at `/etc/nginx/sites-available/canvas.example.com`, replacing
|
| 217 |
+
`canvas.example.com` with your domain:
|
| 218 |
+
|
| 219 |
+
```nginx
|
| 220 |
+
server {
|
| 221 |
+
listen 80;
|
| 222 |
+
listen [::]:80;
|
| 223 |
+
server_name canvas.example.com;
|
| 224 |
+
|
| 225 |
+
location /.well-known/acme-challenge/ {
|
| 226 |
+
root /var/www/html;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
location / {
|
| 230 |
+
proxy_pass http://127.0.0.1:8000;
|
| 231 |
+
proxy_http_version 1.1;
|
| 232 |
+
proxy_set_header Host $host;
|
| 233 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 234 |
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
| 235 |
+
proxy_set_header X-Forwarded-Proto $scheme;
|
| 236 |
+
|
| 237 |
+
# WebSocket / SSE support — required for live agent events.
|
| 238 |
+
proxy_set_header Upgrade $http_upgrade;
|
| 239 |
+
proxy_set_header Connection "upgrade";
|
| 240 |
+
proxy_read_timeout 3600s;
|
| 241 |
+
proxy_send_timeout 3600s;
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
```
|
| 245 |
+
|
| 246 |
+
Enable, test, and issue a certificate:
|
| 247 |
+
|
| 248 |
+
```bash
|
| 249 |
+
ln -sf /etc/nginx/sites-available/canvas.example.com \
|
| 250 |
+
/etc/nginx/sites-enabled/canvas.example.com
|
| 251 |
+
nginx -t && systemctl reload nginx
|
| 252 |
+
|
| 253 |
+
certbot --nginx -d canvas.example.com \
|
| 254 |
+
--non-interactive --agree-tos \
|
| 255 |
+
--email you@example.com \
|
| 256 |
+
--redirect
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
`certbot` adds the `listen 443 ssl` block, a 301 redirect from HTTP to
|
| 260 |
+
HTTPS, and installs a systemd timer for auto-renewal.
|
| 261 |
+
|
| 262 |
+
### Verify
|
| 263 |
+
|
| 264 |
+
```bash
|
| 265 |
+
curl -I https://canvas.example.com/ # → 200 (shows API key entry screen)
|
| 266 |
+
curl -I http://canvas.example.com/ # → 301 to https
|
| 267 |
+
```
|
| 268 |
+
|
| 269 |
+
If you see `502 Bad Gateway`, the app on `127.0.0.1:8000` is down — check
|
| 270 |
+
whether the `npx` process is still running.
|
| 271 |
+
|
| 272 |
+
Open `https://canvas.example.com/` in a browser, enter your
|
| 273 |
+
`LOCAL_BACKEND_API_KEY`, and confirm that you land in Agent Canvas.
|
| 274 |
+
|
| 275 |
+
## 5. (Optional) Connect your local Agent Canvas to the remote machine
|
| 276 |
+
|
| 277 |
+
If you already run Agent Canvas locally, you can register the remote machine
|
| 278 |
+
as an additional backend and switch between local and remote from the UI.
|
| 279 |
+
|
| 280 |
+
1. In your local Agent Canvas, open **Manage backends** → **Add a backend**:
|
| 281 |
+
- **Host Name** — anything memorable, e.g. `my-vm`.
|
| 282 |
+
- **Host** — the URL from step 4, e.g. `https://canvas.example.com`.
|
| 283 |
+
If using an SSH tunnel instead, use `http://localhost:8000`.
|
| 284 |
+
- **Session API key** — the `LOCAL_BACKEND_API_KEY` you chose in step 3.
|
| 285 |
+
2. Save. The new backend should show as "Connected". Pick it from the
|
| 286 |
+
backend switcher to talk to the remote machine.
|
docs/TESTING_MATRIX.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Agent Canvas — Testing Matrix
|
| 2 |
+
|
| 3 |
+
**Priority key:** P0 = must pass before any release · P1 = must pass before GA · P2 = best-effort
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Install × OS × Agent
|
| 8 |
+
|
| 9 |
+
Each cell = smoke test: install → onboard → start conversation → agent replies.
|
| 10 |
+
|
| 11 |
+
| | **macOS** | **Linux** | **Windows** |
|
| 12 |
+
|---|:---:|:---:|:---:|
|
| 13 |
+
| **npm — OpenHands** | ☐ | ☐ | ☐ |
|
| 14 |
+
| **npm — Claude Code** | ☐ | ☐ | ☐ |
|
| 15 |
+
| **npm — Codex** | ☐ | ☐ | ☐ |
|
| 16 |
+
| **npm — Gemini CLI** | ☐ | ☐ | ☐ |
|
| 17 |
+
| **npm — Custom ACP** | ☐ | ☐ | ☐ |
|
| 18 |
+
| **Docker — OpenHands** | ☐ | ☐ | ☐ |
|
| 19 |
+
| **Docker — Claude Code** | ☐ | ☐ | ☐ |
|
| 20 |
+
| **Docker — Codex** | ☐ | ☐ | ☐ |
|
| 21 |
+
| **Docker — Gemini CLI** | ☐ | ☐ | ☐ |
|
| 22 |
+
| **Docker — Custom ACP** | ☐ | ☐ | ☐ |
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## Automations × Install × Agent
|
| 27 |
+
|
| 28 |
+
Requires full stack (automation backend running).
|
| 29 |
+
|
| 30 |
+
| | **npm** | **Docker** |
|
| 31 |
+
|---|:---:|:---:|
|
| 32 |
+
| **OpenHands** | ✅ P0 | ✅ P0 |
|
| 33 |
+
| **Claude Code** | ✅ P1 | ✅ P1 |
|
| 34 |
+
| **Codex** | ✅ P1 | ✅ P1 |
|
| 35 |
+
| **Gemini CLI** | ✅ P2 | ✅ P2 |
|
| 36 |
+
|
| 37 |
+
Each cell = create automation → dispatch run → run reaches COMPLETED → conversation link works.
|
| 38 |
+
|
| 39 |
+
---
|
| 40 |
+
|
| 41 |
+
## Auth Modes
|
| 42 |
+
|
| 43 |
+
| | **npm** | **Docker** |
|
| 44 |
+
|---|:---:|:---:|
|
| 45 |
+
| **Local (auto-generated key)** | ✅ P0 | ✅ P0 |
|
| 46 |
+
| **Public (`--public` + user key)** | ✅ P1 | ✅ P1 |
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## Feature Checklist
|
| 51 |
+
|
| 52 |
+
### npm
|
| 53 |
+
|
| 54 |
+
| Feature | OpenHands | Claude Code | Codex | Gemini CLI |
|
| 55 |
+
|---|:---:|:---:|:---:|:---:|
|
| 56 |
+
| Onboarding | ☐ | ☐ | ☐ | ☐ |
|
| 57 |
+
| Conversation — start, resume, history | ☐ | ☐ | ☐ | ☐ |
|
| 58 |
+
| Terminal tool | ☐ | ☐ | ☐ | ☐ |
|
| 59 |
+
| File editor tool | ☐ | ☐ | ☐ | ☐ |
|
| 60 |
+
| Browser tool | ☐ | ☐ | ☐ | ☐ |
|
| 61 |
+
| LLM profiles — create / switch | ☐ | — | — | — |
|
| 62 |
+
| Secrets — add / delete / forwarded | ☐ | ☐ | ☐ | ☐ |
|
| 63 |
+
| Automations — create, dispatch, COMPLETED | ☐ | ☐ | ☐ | ☐ |
|
| 64 |
+
| Files tab + Changes/diff tab | ☐ | ☐ | ☐ | ☐ |
|
| 65 |
+
| MCP server install | ☐ | ☐ | ☐ | ☐ |
|
| 66 |
+
| Image upload in chat | ☐ | ☐ | ☐ | ☐ |
|
| 67 |
+
| Key rotation | ☐ | ☐ | ☐ | ☐ |
|
| 68 |
+
|
| 69 |
+
### Docker
|
| 70 |
+
|
| 71 |
+
| Feature | OpenHands | Claude Code | Codex | Gemini CLI |
|
| 72 |
+
|---|:---:|:---:|:---:|:---:|
|
| 73 |
+
| Onboarding | ☐ | ☐ | ☐ | ☐ |
|
| 74 |
+
| Conversation — start, resume, history | ☐ | ☐ | ☐ | ☐ |
|
| 75 |
+
| Terminal tool | ☐ | ☐ | ☐ | ☐ |
|
| 76 |
+
| File editor tool | ☐ | ☐ | ☐ | ☐ |
|
| 77 |
+
| Browser tool | ☐ | ☐ | ☐ | ☐ |
|
| 78 |
+
| LLM profiles — create / switch | ☐ | — | — | — |
|
| 79 |
+
| Secrets — add / delete / forwarded | ☐ | ☐ | ☐ | ☐ |
|
| 80 |
+
| Automations — create, dispatch, COMPLETED | ☐ | ☐ | ☐ | ☐ |
|
| 81 |
+
| Files tab + Changes/diff tab | ☐ | ☐ | ☐ | ☐ |
|
| 82 |
+
| MCP server install | ☐ | ☐ | ☐ | ☐ |
|
| 83 |
+
| Image upload in chat | ☐ | ☐ | ☐ | ☐ |
|
| 84 |
+
| Key rotation | ☐ | ☐ | ☐ | ☐ |
|
| 85 |
+
|
| 86 |
+
---
|
| 87 |
+
|
| 88 |
+
## Automated Coverage
|
| 89 |
+
|
| 90 |
+
| Suite | Install | OS | Agents | Automations |
|
| 91 |
+
|---|---|---|---|---|
|
| 92 |
+
| `vitest` (unit) | — | Linux | — | partial |
|
| 93 |
+
| `test:e2e:mock-llm` | npm | Linux | OpenHands, ACP (mock) | ✅ full |
|
| 94 |
+
| `test:e2e:mock-llm:docker` | Docker | Linux | OpenHands, ACP (mock) | ✅ full |
|
| 95 |
+
| `test:e2e:live` | npm | Linux | OpenHands | ❌ |
|
| 96 |
+
|
| 97 |
+
**Not yet covered by CI:** real ACP credentials (Claude Code / Codex / Gemini), macOS, public auth mode, subscription login paths, Windows.
|
docs/architecture.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Agent Canvas architecture
|
| 2 |
+
|
| 3 |
+
Agent Canvas is a React and TypeScript frontend for running and monitoring OpenHands agents across local, remote, and hosted environments. It is adapted from the OpenHands frontend to talk directly to the OpenHands Agent Server and related automation services.
|
| 4 |
+
|
| 5 |
+
## System boundaries
|
| 6 |
+
|
| 7 |
+
Agent Canvas is responsible for:
|
| 8 |
+
|
| 9 |
+
- Rendering the agent conversation, terminal, browser, files, settings, and automation UI.
|
| 10 |
+
- Managing frontend state for conversations, backend selection, settings, profiles, and local metadata.
|
| 11 |
+
- Translating UI actions into OpenHands Agent Server API calls.
|
| 12 |
+
- Packaging the UI both as a standalone app and as library entrypoints for host applications.
|
| 13 |
+
|
| 14 |
+
Agent Canvas is not responsible for:
|
| 15 |
+
|
| 16 |
+
- Executing agent actions directly.
|
| 17 |
+
- Providing the sandbox or workspace isolation layer.
|
| 18 |
+
- Hosting LLM provider credentials outside the configured backend.
|
| 19 |
+
- Running scheduled or event-triggered automations without an automation backend.
|
| 20 |
+
|
| 21 |
+
## Runtime services
|
| 22 |
+
|
| 23 |
+
The primary backend is the [OpenHands Agent Server](https://github.com/OpenHands/software-agent-sdk/tree/main/openhands-agent-server/openhands/agent_server). Agent Canvas can connect to one or more Agent Server instances and switch between them from the UI.
|
| 24 |
+
|
| 25 |
+
Optional runtime services include:
|
| 26 |
+
|
| 27 |
+
- An ingress service that routes frontend, Agent Server, and automation traffic behind one local origin.
|
| 28 |
+
- An Automation Server for scheduled or event-triggered agent runs.
|
| 29 |
+
- OpenHands Cloud APIs for hosted sandbox and organization workflows.
|
| 30 |
+
|
| 31 |
+
Agent Canvas stack launchers expose runtime service information through the backend `/server_info.runtime_services` field. The frontend forwards that backend-provided information into new conversations as an agent context suffix so agents can use the correct URLs instead of guessing ports.
|
| 32 |
+
|
| 33 |
+
## Frontend modules
|
| 34 |
+
|
| 35 |
+
The most important source areas are:
|
| 36 |
+
|
| 37 |
+
- `src/api/`: service adapters for Agent Server, cloud, settings, git, skills, automations, and backend registry behavior.
|
| 38 |
+
- `src/components/`: route and feature UI, including conversation, chat, browser, files, settings, backend, automation, and onboarding components.
|
| 39 |
+
- `src/hooks/`: reusable React Query, state, and feature hooks.
|
| 40 |
+
- `src/stores/`: Zustand state stores for conversation and UI state.
|
| 41 |
+
- `src/i18n/`: translation resources and generated bundles.
|
| 42 |
+
- `src/mocks/`: MSW handlers for mock-mode development and tests.
|
| 43 |
+
- `bin/` and `scripts/`: CLI and development stack launchers.
|
| 44 |
+
|
| 45 |
+
## Runtime modes
|
| 46 |
+
|
| 47 |
+
Agent Canvas supports several modes:
|
| 48 |
+
|
| 49 |
+
| Mode | Purpose |
|
| 50 |
+
|---|---|
|
| 51 |
+
| `npm run dev` | Starts the full local stack directly on the host: agent-server and automation backend via `uvx`, the Vite dev server, and an ingress proxy. The agent has host filesystem access; use only in trusted environments. |
|
| 52 |
+
| `npm run dev:minimal` | Starts just the agent-server plus Vite dev server, without the automation backend. |
|
| 53 |
+
| `npm run dev:static` | Same as `dev`, but serves a production build of the frontend instead of the Vite dev server. |
|
| 54 |
+
| `npm run dev:mock` | Runs the frontend against MSW mocks for UI development and tests. |
|
| 55 |
+
| `npm run build` | Builds the standalone application. |
|
| 56 |
+
| `npm run build:lib` | Builds library entrypoints for embedding Agent Canvas components. |
|
| 57 |
+
|
| 58 |
+
## Packaging and distribution
|
| 59 |
+
|
| 60 |
+
The npm package is `@openhands/agent-canvas`. The package exposes:
|
| 61 |
+
|
| 62 |
+
- The `agent-canvas` binary for launching a local stack.
|
| 63 |
+
- A standalone app build.
|
| 64 |
+
- Library entrypoints for browser, conversation, files, settings, sidebar, terminal, and i18n modules.
|
| 65 |
+
|
| 66 |
+
Tagged releases are published through the `Publish to npm` GitHub Actions workflow using npm trusted publishing and provenance.
|
| 67 |
+
|
| 68 |
+
## Quality gates
|
| 69 |
+
|
| 70 |
+
The main CI workflow installs dependencies with `npm ci` and runs:
|
| 71 |
+
|
| 72 |
+
- Typecheck, ESLint, and Prettier through `npm run lint`.
|
| 73 |
+
- Unit and component tests through `npm test`.
|
| 74 |
+
- Standalone app build through `npm run build`.
|
| 75 |
+
- Library build through `npm run build:lib`.
|
| 76 |
+
- Package verification through `npm pack --dry-run`.
|
| 77 |
+
|
| 78 |
+
Additional workflows run optional live end-to-end QA.
|
| 79 |
+
|
| 80 |
+
## Security posture
|
| 81 |
+
|
| 82 |
+
Local operation can give agents access to user workspaces. The README and self-hosting guide call out this risk and recommend Docker sandbox mode for laptop usage. Self-hosted deployments should use normal server hardening practices, authentication, HTTPS, firewall rules, and careful workspace scoping.
|
electron-builder.config.mjs
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* electron-builder configuration for the Agent Canvas desktop app.
|
| 3 |
+
*
|
| 4 |
+
* `directories.app: 'electron'` tells electron-builder to use electron/package.json
|
| 5 |
+
* as the app manifest (with `"main": "main.mjs"`). This sidesteps the root
|
| 6 |
+
* package.json's `"main": "./dist/index.cjs"` without any afterPack patching.
|
| 7 |
+
*
|
| 8 |
+
* NODE_MODULES NOTE:
|
| 9 |
+
*
|
| 10 |
+
* Even with zero `dependencies` in electron/package.json, electron-builder's
|
| 11 |
+
* "search for node modules" routine walks UP from directories.app looking
|
| 12 |
+
* for the first node_modules in scope. It runs `npm list --json` in the
|
| 13 |
+
* project root, gets the full hoisted tree (~342 dirs, ~600 MB: Vite,
|
| 14 |
+
* React, Monaco, HeroUI, etc.), and copies all of it into the packaged
|
| 15 |
+
* app at Resources/app/node_modules/.
|
| 16 |
+
*
|
| 17 |
+
* Almost none of those packages are imported at desktop runtime — main.mjs
|
| 18 |
+
* and the launcher (dev-with-automation.mjs) use only Node built-ins. The
|
| 19 |
+
* exception is the two child-process servers: static-server.mjs imports
|
| 20 |
+
* `sirv` and ingress.mjs (via proxy-utils.mjs) imports `httpxy`. Node
|
| 21 |
+
* resolves those bare specifiers by walking UP from the script's own path,
|
| 22 |
+
* so an app tested from dist-electron/ inside a repo checkout accidentally
|
| 23 |
+
* resolves them against the repo's node_modules and works — while the same
|
| 24 |
+
* app in /Applications crashes both servers with ERR_MODULE_NOT_FOUND and
|
| 25 |
+
* the splash times out waiting for port 8000. We can't disable the search
|
| 26 |
+
* from the config (it's hardcoded in app-builder-lib), and creating an
|
| 27 |
+
* empty electron/node_modules/ doesn't help because `npm list` from the
|
| 28 |
+
* project root still reports the full hoisted tree.
|
| 29 |
+
*
|
| 30 |
+
* The fix is the `afterPack` hook below: after electron-builder has
|
| 31 |
+
* finished copying files, we rm -rf the bundled Resources/app/node_modules/
|
| 32 |
+
* directory, then copy back the dependency closure of RUNTIME_PACKAGES
|
| 33 |
+
* (~200 KB). The build wastes a few seconds copying files we immediately
|
| 34 |
+
* delete, but the final artifact is correctly tiny (~10 MB vs ~600 MB).
|
| 35 |
+
*
|
| 36 |
+
* Packaged app layout (Resources/app/ = electron/ contents):
|
| 37 |
+
* main.mjs ← Electron entry point
|
| 38 |
+
* loading.html ← loading splash
|
| 39 |
+
* package.json ← {"main":"main.mjs"} (from electron/package.json)
|
| 40 |
+
* scripts/ ← backend scripts
|
| 41 |
+
* node_modules/ ← runtime closure of RUNTIME_PACKAGES (restored by afterPack)
|
| 42 |
+
* config/ ← defaults.json
|
| 43 |
+
* build/ ← static frontend (npm run build:app output)
|
| 44 |
+
*
|
| 45 |
+
* The bundled uv binary (resources/bin/) lands in <Resources>/bin/ via
|
| 46 |
+
* extraResources so Electron can inject it into PATH on startup.
|
| 47 |
+
*
|
| 48 |
+
* The bundled Node.js distribution (resources/node/) lands in
|
| 49 |
+
* <Resources>/node/ via extraResources — except for its root-level
|
| 50 |
+
* node_modules (npm itself), which electron-builder refuses to copy and the
|
| 51 |
+
* afterPack hook restores; see restoreBundledNodeNpm below.
|
| 52 |
+
*
|
| 53 |
+
* Electron prepends its bin dir to PATH at startup so backend scripts (`node
|
| 54 |
+
* scripts/ingress.mjs` etc.) and stdio MCP servers spawned via `npx -y …`
|
| 55 |
+
* (Slack, GitHub, Figma, etc.) can find a working node/npm/npx — the OS gives
|
| 56 |
+
* a Finder-launched .app a minimal PATH (/usr/bin:/bin) that has none of those.
|
| 57 |
+
*/
|
| 58 |
+
|
| 59 |
+
import { cp, rm } from "node:fs/promises";
|
| 60 |
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
| 61 |
+
import { dirname, join, relative } from "node:path";
|
| 62 |
+
import { fileURLToPath } from "node:url";
|
| 63 |
+
|
| 64 |
+
// npm packages the packaged app's child-process scripts import at runtime:
|
| 65 |
+
// scripts/static-server.mjs → sirv
|
| 66 |
+
// scripts/proxy-utils.mjs → httpxy (imported by ingress.mjs)
|
| 67 |
+
// Their dependency closure is copied back into Resources/app/node_modules
|
| 68 |
+
// after the strip below. If a spawned script gains a new bare import, add
|
| 69 |
+
// the package here — a missing one crashes that service in the installed
|
| 70 |
+
// app with ERR_MODULE_NOT_FOUND (invisible under Finder, where stdout goes
|
| 71 |
+
// to /dev/null) and the splash times out waiting for port 8000.
|
| 72 |
+
const RUNTIME_PACKAGES = ["sirv", "httpxy"];
|
| 73 |
+
|
| 74 |
+
const repoRoot = dirname(fileURLToPath(import.meta.url));
|
| 75 |
+
|
| 76 |
+
// Root package.json is the single source of truth for the app version
|
| 77 |
+
// (release-please bumps it). electron/package.json is a minimal manifest
|
| 78 |
+
// stub pinned at 1.0.0 — `extraMetadata` below overrides its version at
|
| 79 |
+
// pack time so artifact names and app.getVersion() carry the released
|
| 80 |
+
// version instead.
|
| 81 |
+
const rootPackageJson = JSON.parse(
|
| 82 |
+
readFileSync(join(repoRoot, "package.json"), "utf8"),
|
| 83 |
+
);
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* Resolve the packaged app's Resources directory.
|
| 87 |
+
*
|
| 88 |
+
* On macOS it lives inside the `.app` bundle; on Linux/Windows it's a flat
|
| 89 |
+
* `resources/` subdirectory. We resolve both shapes from `context.appOutDir`
|
| 90 |
+
* + the productFilename.
|
| 91 |
+
*/
|
| 92 |
+
function packagedResourcesDir(context) {
|
| 93 |
+
const platform = context.electronPlatformName;
|
| 94 |
+
const productFilename = context.packager.appInfo.productFilename;
|
| 95 |
+
return platform === "darwin" || platform === "mas"
|
| 96 |
+
? join(context.appOutDir, `${productFilename}.app`, "Contents", "Resources")
|
| 97 |
+
: join(context.appOutDir, "resources");
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/**
|
| 101 |
+
* afterPack entry point. Invoked by electron-builder once per platform target
|
| 102 |
+
* after the unpacked directory has been populated but before installer-format
|
| 103 |
+
* packaging (DMG, NSIS, deb).
|
| 104 |
+
*/
|
| 105 |
+
async function afterPack(context) {
|
| 106 |
+
await stripBundledNodeModules(context);
|
| 107 |
+
await restoreBundledNodeNpm(context);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
/**
|
| 111 |
+
* Strip the auto-bundled node_modules from the packaged app, then restore
|
| 112 |
+
* the small runtime closure of RUNTIME_PACKAGES.
|
| 113 |
+
*
|
| 114 |
+
* See the NODE_MODULES NOTE in the file header for the why.
|
| 115 |
+
*/
|
| 116 |
+
async function stripBundledNodeModules(context) {
|
| 117 |
+
const appDir = join(packagedResourcesDir(context), "app");
|
| 118 |
+
|
| 119 |
+
const nm = join(appDir, "node_modules");
|
| 120 |
+
if (existsSync(nm)) {
|
| 121 |
+
// Best-effort size report so the log line shows what we saved. Skip if
|
| 122 |
+
// walking the tree fails for any reason — the rm below is what matters.
|
| 123 |
+
let sizeMb = null;
|
| 124 |
+
try {
|
| 125 |
+
sizeMb = Math.round(getDirSizeBytes(nm) / (1024 * 1024));
|
| 126 |
+
} catch {}
|
| 127 |
+
|
| 128 |
+
await rm(nm, { recursive: true, force: true });
|
| 129 |
+
|
| 130 |
+
const rel = relative(process.cwd(), nm);
|
| 131 |
+
const human = sizeMb != null ? ` (~${sizeMb} MB)` : "";
|
| 132 |
+
// eslint-disable-next-line no-console -- electron-builder build log
|
| 133 |
+
console.log(
|
| 134 |
+
`[electron-builder] stripped bundled node_modules${human}: ${rel}`,
|
| 135 |
+
);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
await restoreRuntimeNodeModules(appDir);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/**
|
| 142 |
+
* Copy the dependency closure of RUNTIME_PACKAGES from the repo's
|
| 143 |
+
* node_modules into the packaged app's Resources/app/node_modules so the
|
| 144 |
+
* spawned `node scripts/…` servers can resolve their bare imports outside
|
| 145 |
+
* a repo checkout (see RUNTIME_PACKAGES above).
|
| 146 |
+
*
|
| 147 |
+
* Resolution is deliberately simple: every package (and every transitive
|
| 148 |
+
* `dependencies` entry) is looked up at the repo root's flat npm tree, and
|
| 149 |
+
* a miss throws so the build fails loudly instead of shipping a DMG whose
|
| 150 |
+
* ingress/static-server crash on launch.
|
| 151 |
+
*/
|
| 152 |
+
async function restoreRuntimeNodeModules(appDir) {
|
| 153 |
+
const rootNodeModules = join(repoRoot, "node_modules");
|
| 154 |
+
|
| 155 |
+
// name → source dir, walking `dependencies` breadth-first.
|
| 156 |
+
const packages = new Map();
|
| 157 |
+
const queue = [...RUNTIME_PACKAGES];
|
| 158 |
+
while (queue.length) {
|
| 159 |
+
const name = queue.shift();
|
| 160 |
+
if (packages.has(name)) continue;
|
| 161 |
+
const srcDir = join(rootNodeModules, ...name.split("/"));
|
| 162 |
+
const manifestPath = join(srcDir, "package.json");
|
| 163 |
+
if (!existsSync(manifestPath)) {
|
| 164 |
+
throw new Error(
|
| 165 |
+
`[electron-builder] runtime package "${name}" not found in ` +
|
| 166 |
+
`${rootNodeModules} — run npm install and rebuild`,
|
| 167 |
+
);
|
| 168 |
+
}
|
| 169 |
+
packages.set(name, srcDir);
|
| 170 |
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
| 171 |
+
queue.push(...Object.keys(manifest.dependencies ?? {}));
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
let totalBytes = 0;
|
| 175 |
+
for (const [name, srcDir] of packages) {
|
| 176 |
+
const destDir = join(appDir, "node_modules", ...name.split("/"));
|
| 177 |
+
await cp(srcDir, destDir, { recursive: true });
|
| 178 |
+
totalBytes += getDirSizeBytes(destDir);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
// eslint-disable-next-line no-console -- electron-builder build log
|
| 182 |
+
console.log(
|
| 183 |
+
`[electron-builder] restored runtime node_modules ` +
|
| 184 |
+
`(${[...packages.keys()].join(", ")}; ~${Math.round(totalBytes / 1024)} KB)`,
|
| 185 |
+
);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/**
|
| 189 |
+
* Restore the bundled Node distribution's root-level `node_modules` (npm
|
| 190 |
+
* itself), which electron-builder silently refuses to copy, then verify the
|
| 191 |
+
* packed result.
|
| 192 |
+
*
|
| 193 |
+
* WHY THIS IS NEEDED — app-builder-lib's copy filter drops a `node_modules`
|
| 194 |
+
* directory sitting at the ROOT of a `from` dir, before any `filter` pattern
|
| 195 |
+
* is consulted (packages/app-builder-lib/src/util/filter.ts):
|
| 196 |
+
*
|
| 197 |
+
* // filter the root node_modules, but not a subnode_modules
|
| 198 |
+
* if (relative === "node_modules") {
|
| 199 |
+
* return false
|
| 200 |
+
* }
|
| 201 |
+
*
|
| 202 |
+
* That check is reached for extraResources too — `copyFiles()` builds the
|
| 203 |
+
* copy filter with the same `createFilter()`. Our extraResources entry copies
|
| 204 |
+
* `resources/node/` → `<Resources>/node/`, and the Windows Node zip puts npm
|
| 205 |
+
* at `<root>/node_modules/npm`, i.e. exactly `node_modules` relative to the
|
| 206 |
+
* copy root. So it is skipped, and no `filter` value can opt back in.
|
| 207 |
+
*
|
| 208 |
+
* POSIX tarballs put npm at `<root>/lib/node_modules/npm`, which is not the
|
| 209 |
+
* root entry and copies fine — which is why this only ever broke Windows and
|
| 210 |
+
* went unnoticed on macOS.
|
| 211 |
+
*
|
| 212 |
+
* Symptom when it goes wrong: `<Resources>/node/` ships a working `node.exe`
|
| 213 |
+
* next to `npm.cmd` / `npx.cmd` shims that exec
|
| 214 |
+
* `%~dp0\node_modules\npm\bin\npx-cli.js` — a file that isn't there. Every
|
| 215 |
+
* `npx -y <pkg>` spawn (stdio MCP servers, ACP servers) then dies with
|
| 216 |
+
* MODULE_NOT_FOUND, and because injectBundledNode() PREPENDS this directory
|
| 217 |
+
* to PATH it also shadows the user's own working npm installation.
|
| 218 |
+
*/
|
| 219 |
+
async function restoreBundledNodeNpm(context) {
|
| 220 |
+
const isWin = context.electronPlatformName === "win32";
|
| 221 |
+
const nodeDir = join(packagedResourcesDir(context), "node");
|
| 222 |
+
|
| 223 |
+
// No bundled Node at all — `npm run download-node` was skipped. That is a
|
| 224 |
+
// different (and already loudly reported) situation; main.mjs warns at
|
| 225 |
+
// startup. Don't turn it into a build failure here.
|
| 226 |
+
if (!existsSync(nodeDir)) return;
|
| 227 |
+
|
| 228 |
+
const srcNodeModules = join(repoRoot, "resources", "node", "node_modules");
|
| 229 |
+
const destNodeModules = join(nodeDir, "node_modules");
|
| 230 |
+
if (!existsSync(destNodeModules) && existsSync(srcNodeModules)) {
|
| 231 |
+
await cp(srcNodeModules, destNodeModules, { recursive: true });
|
| 232 |
+
const rel = relative(process.cwd(), destNodeModules);
|
| 233 |
+
// eslint-disable-next-line no-console -- electron-builder build log
|
| 234 |
+
console.log(
|
| 235 |
+
`[electron-builder] restored bundled Node node_modules: ${rel}`,
|
| 236 |
+
);
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
// Mirror the check scripts/download-node.mjs runs on the source tree, but
|
| 240 |
+
// against the packed output — the last point where a broken npm is still a
|
| 241 |
+
// build failure instead of a broken install.
|
| 242 |
+
const npmCli = isWin
|
| 243 |
+
? join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js")
|
| 244 |
+
: join(nodeDir, "lib", "node_modules", "npm", "bin", "npm-cli.js");
|
| 245 |
+
if (!existsSync(npmCli)) {
|
| 246 |
+
throw new Error(
|
| 247 |
+
`[electron-builder] packaged Node is missing npm-cli.js at ${npmCli} — ` +
|
| 248 |
+
"npm/npx would fail with MODULE_NOT_FOUND in the installed app. " +
|
| 249 |
+
"Run `npm run download-node` and rebuild.",
|
| 250 |
+
);
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
function getDirSizeBytes(dir) {
|
| 255 |
+
// Synchronous walk so we can run it before the rm without async juggling
|
| 256 |
+
// in the hook. The directory we're sizing is always small enough (<1 GB)
|
| 257 |
+
// that this is negligible compared to the rm itself.
|
| 258 |
+
let total = 0;
|
| 259 |
+
const stack = [dir];
|
| 260 |
+
while (stack.length) {
|
| 261 |
+
const next = stack.pop();
|
| 262 |
+
let entries;
|
| 263 |
+
try {
|
| 264 |
+
entries = readdirSync(next, { withFileTypes: true });
|
| 265 |
+
} catch {
|
| 266 |
+
// Best-effort: skip unreadable dirs (symlink races, permission
|
| 267 |
+
// errors on platform-specific node_modules subtrees, etc.). The
|
| 268 |
+
// size number is only used in a build-log line, so under-counting
|
| 269 |
+
// is preferable to aborting the strip.
|
| 270 |
+
continue;
|
| 271 |
+
}
|
| 272 |
+
for (const entry of entries) {
|
| 273 |
+
const p = join(next, entry.name);
|
| 274 |
+
if (entry.isDirectory()) {
|
| 275 |
+
stack.push(p);
|
| 276 |
+
} else {
|
| 277 |
+
try {
|
| 278 |
+
total += statSync(p).size;
|
| 279 |
+
} catch {}
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
return total;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
/** @type {import('electron-builder').Configuration} */
|
| 287 |
+
const config = {
|
| 288 |
+
appId: "dev.openhands.agent-canvas",
|
| 289 |
+
productName: "OpenHands Agent Canvas",
|
| 290 |
+
copyright: "Copyright © 2025 OpenHands contributors",
|
| 291 |
+
|
| 292 |
+
// Stamp the packaged app with the released version (see rootPackageJson
|
| 293 |
+
// note above).
|
| 294 |
+
extraMetadata: { version: rootPackageJson.version },
|
| 295 |
+
|
| 296 |
+
// Treat electron/ as the app root. electron/package.json provides the
|
| 297 |
+
// Electron entry point without touching the npm-published root package.json.
|
| 298 |
+
// `buildResources` points at electron/build-resources so electron-builder
|
| 299 |
+
// can auto-discover the committed icon.icns / icon.ico (generated from the
|
| 300 |
+
// 1024×1024 icon.png master via `npm run generate-icons`) and, for Linux,
|
| 301 |
+
// icon.png itself.
|
| 302 |
+
directories: {
|
| 303 |
+
app: "electron",
|
| 304 |
+
output: "dist-electron",
|
| 305 |
+
buildResources: "electron/build-resources",
|
| 306 |
+
},
|
| 307 |
+
|
| 308 |
+
// Do not pack into asar — scripts are spawned as child processes by
|
| 309 |
+
// dev-with-automation.mjs and must exist as real files on disk.
|
| 310 |
+
asar: false,
|
| 311 |
+
|
| 312 |
+
// Skip native-module rebuild — the app has no native deps.
|
| 313 |
+
npmRebuild: false,
|
| 314 |
+
|
| 315 |
+
// Strip auto-bundled node_modules (see NODE_MODULES NOTE at top of file),
|
| 316 |
+
// then restore the bundled Node distribution's own npm (see
|
| 317 |
+
// restoreBundledNodeNpm).
|
| 318 |
+
afterPack,
|
| 319 |
+
|
| 320 |
+
// Files included in the packaged app.
|
| 321 |
+
// Paths with `from` are relative to directories.app (electron/).
|
| 322 |
+
// Bare globs are also relative to directories.app.
|
| 323 |
+
files: [
|
| 324 |
+
// electron/ base files (main.mjs, loading.html, package.json)
|
| 325 |
+
"**/*",
|
| 326 |
+
// Bundle the raw 1024×1024 PNG into Resources/app/build-resources/ so
|
| 327 |
+
// main.mjs can set it as the BrowserWindow icon at runtime (used for the
|
| 328 |
+
// Linux taskbar; macOS reads from the .icns inside the .app bundle).
|
| 329 |
+
"build-resources/icon.png",
|
| 330 |
+
// Windows runtime BrowserWindow icon (main.mjs picks .ico on win32).
|
| 331 |
+
"build-resources/icon.ico",
|
| 332 |
+
// Scripts from project root. Mostly Node built-ins; the two spawned
|
| 333 |
+
// servers additionally need RUNTIME_PACKAGES, restored into
|
| 334 |
+
// Resources/app/node_modules by the afterPack hook.
|
| 335 |
+
{ from: "../scripts", to: "scripts", filter: ["**/*.mjs", "**/*.cjs"] },
|
| 336 |
+
// Centralised version / port / path config
|
| 337 |
+
{ from: "../config", to: "config" },
|
| 338 |
+
// Pre-built static frontend (npm run build:app output)
|
| 339 |
+
{ from: "../build", to: "build" },
|
| 340 |
+
// Custom Python tools (canvas_ui_tool.py). dev-safe.mjs sets
|
| 341 |
+
// OH_EXTRA_PYTHON_PATH to this directory so the agent-server can import
|
| 342 |
+
// canvas_ui_tool at runtime. The path is computed as ../tools relative to
|
| 343 |
+
// scripts/dev-safe.mjs, which resolves correctly in both dev and packaged mode.
|
| 344 |
+
{ from: "../tools", to: "tools" },
|
| 345 |
+
],
|
| 346 |
+
|
| 347 |
+
// Bundled prerequisites — placed in <Resources>/ so Electron can put
|
| 348 |
+
// them on PATH before starting the backend stack.
|
| 349 |
+
// bin/ — uv + uvx (downloaded by `npm run download-uv`)
|
| 350 |
+
// node/ — official Node.js distribution; provides `node` plus the
|
| 351 |
+
// bundled `npm` / `npx` that stdio MCP servers (Slack, GitHub,
|
| 352 |
+
// Figma, etc.) spawn via `npx -y <package>` (downloaded by
|
| 353 |
+
// `npm run download-node`)
|
| 354 |
+
// `from` is relative to the project root (not directories.app).
|
| 355 |
+
// build:desktop calls both download scripts before invoking electron-builder.
|
| 356 |
+
extraResources: [
|
| 357 |
+
{ from: "resources/bin/", to: "bin/", filter: ["**/*"] },
|
| 358 |
+
{ from: "resources/node/", to: "node/", filter: ["**/*"] },
|
| 359 |
+
],
|
| 360 |
+
|
| 361 |
+
// ── macOS ──────────────────────────────────────────────────────────────────
|
| 362 |
+
//
|
| 363 |
+
// Default to the native CPU arch so day-to-day `npm run build:desktop`
|
| 364 |
+
// is fast (one Electron binary, one packaging pass).
|
| 365 |
+
//
|
| 366 |
+
// For a distributable universal build set ELECTRON_ARCH=universal:
|
| 367 |
+
// ELECTRON_ARCH=universal npm run build:desktop
|
| 368 |
+
//
|
| 369 |
+
// Or use the dedicated script:
|
| 370 |
+
// npm run build:desktop:universal
|
| 371 |
+
//
|
| 372 |
+
// CAUTION: the bundled uv/node extraResources are downloaded for the
|
| 373 |
+
// BUILD HOST's architecture only (scripts/download-uv.mjs and
|
| 374 |
+
// download-node.mjs have no arch override), so a "universal" build still
|
| 375 |
+
// ships single-arch runtimes and breaks on the other architecture. Don't
|
| 376 |
+
// distribute universal DMGs until the download scripts support multi-arch.
|
| 377 |
+
//
|
| 378 |
+
mac: {
|
| 379 |
+
category: "public.app-category.developer-tools",
|
| 380 |
+
target: [
|
| 381 |
+
{
|
| 382 |
+
target: "dmg",
|
| 383 |
+
arch: [
|
| 384 |
+
process.env.ELECTRON_ARCH ??
|
| 385 |
+
(process.arch === "arm64" ? "arm64" : "x64"),
|
| 386 |
+
],
|
| 387 |
+
},
|
| 388 |
+
],
|
| 389 |
+
// Icon auto-discovered from directories.buildResources/icon.icns
|
| 390 |
+
// (committed; regenerate with `npm run generate-icons`).
|
| 391 |
+
},
|
| 392 |
+
|
| 393 |
+
dmg: {
|
| 394 |
+
title: "OpenHands Agent Canvas",
|
| 395 |
+
contents: [
|
| 396 |
+
{ x: 130, y: 220 },
|
| 397 |
+
{ x: 410, y: 220, type: "link", path: "/Applications" },
|
| 398 |
+
],
|
| 399 |
+
window: { width: 540, height: 380 },
|
| 400 |
+
// Default is "OpenHands Agent Canvas-<version>-<arch>.dmg"; GitHub release
|
| 401 |
+
// assets mangle spaces, so keep the asset name literal (matches the nsis
|
| 402 |
+
// convention). ${version}/${arch}/${ext} are electron-builder macros.
|
| 403 |
+
artifactName: "OpenHands-Agent-Canvas-${version}-${arch}.${ext}",
|
| 404 |
+
},
|
| 405 |
+
|
| 406 |
+
// ── Windows ────────────────────────────────────────────────────────────────
|
| 407 |
+
win: {
|
| 408 |
+
target: [{ target: "nsis", arch: ["x64"] }],
|
| 409 |
+
// Icon auto-discovered from directories.buildResources/icon.ico
|
| 410 |
+
// (committed; regenerate with `npm run generate-icons`). Also used for
|
| 411 |
+
// the NSIS installer/uninstaller and the rcedit exe icon resource.
|
| 412 |
+
},
|
| 413 |
+
|
| 414 |
+
nsis: {
|
| 415 |
+
oneClick: false,
|
| 416 |
+
perMachine: false,
|
| 417 |
+
allowToChangeInstallationDirectory: true,
|
| 418 |
+
createDesktopShortcut: true,
|
| 419 |
+
createStartMenuShortcut: true,
|
| 420 |
+
// The default artifact name is "OpenHands Agent Canvas Setup <version>.exe";
|
| 421 |
+
// GitHub release assets mangle spaces, so ship a space-free name.
|
| 422 |
+
// ${version}/${ext} are electron-builder macros, not JS interpolation.
|
| 423 |
+
artifactName: "OpenHands-Agent-Canvas-Setup-${version}.${ext}",
|
| 424 |
+
},
|
| 425 |
+
|
| 426 |
+
// ── Linux ──────────────────────────────────────────────────────────────────
|
| 427 |
+
linux: {
|
| 428 |
+
target: [
|
| 429 |
+
{ target: "AppImage", arch: ["x64"] },
|
| 430 |
+
{ target: "deb", arch: ["x64"] },
|
| 431 |
+
],
|
| 432 |
+
category: "Development",
|
| 433 |
+
// Icon auto-discovered from directories.buildResources/icon.png.
|
| 434 |
+
// fpm-backed targets (deb) require a maintainer with an email address;
|
| 435 |
+
// electron/package.json carries no author, so set it here. Without this
|
| 436 |
+
// the deb step fails with "Please specify author 'email'".
|
| 437 |
+
maintainer: "OpenHands <contact@all-hands.dev>",
|
| 438 |
+
},
|
| 439 |
+
};
|
| 440 |
+
|
| 441 |
+
export default config;
|
eslint.config.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Flat ESLint config (ESLint 9+).
|
| 2 |
+
//
|
| 3 |
+
// Migration notes (see commit history for full context):
|
| 4 |
+
// - Replaces the legacy `.eslintrc` file, which ESLint 9 deprecated and
|
| 5 |
+
// ESLint 10 will remove entirely.
|
| 6 |
+
// - Drops `eslint-config-airbnb` / `eslint-config-airbnb-typescript`: they
|
| 7 |
+
// pin ESLint to v7/v8 and `@typescript-eslint/*` to v7, so they block any
|
| 8 |
+
// future ESLint major bumps. The opinionated airbnb rules the codebase
|
| 9 |
+
// actually leaned on (no-param-reassign, import/extensions, etc.) are
|
| 10 |
+
// re-declared explicitly below, and the long list of airbnb rules the old
|
| 11 |
+
// config already turned off has just been deleted instead of re-disabled.
|
| 12 |
+
// - Replaces `eslint-plugin-import` (only declares an `^8 || ^9` peer) with
|
| 13 |
+
// the actively-maintained, flat-config-native fork `eslint-plugin-import-x`.
|
| 14 |
+
// The rule names are kept under the `import/` prefix below to minimise
|
| 15 |
+
// churn on existing `// eslint-disable-next-line import/...` comments.
|
| 16 |
+
// - `eslint-plugin-i18next` still ships only legacy config, so it is pulled
|
| 17 |
+
// in via `FlatCompat` from `@eslint/eslintrc`.
|
| 18 |
+
// - Targets ESLint 9, not 10. The original dependabot PR proposed eslint@10,
|
| 19 |
+
// but as of this commit `eslint-plugin-react` still calls
|
| 20 |
+
// `context.getFilename()` (removed in ESLint 10) at rule-load time, which
|
| 21 |
+
// makes a v10 bump explode on every file. Re-evaluate once that plugin
|
| 22 |
+
// ships an ESLint-10-compatible release.
|
| 23 |
+
|
| 24 |
+
import { fileURLToPath } from "node:url";
|
| 25 |
+
import path from "node:path";
|
| 26 |
+
|
| 27 |
+
import { FlatCompat } from "@eslint/eslintrc";
|
| 28 |
+
import js from "@eslint/js";
|
| 29 |
+
import tsParser from "@typescript-eslint/parser";
|
| 30 |
+
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
| 31 |
+
import reactPlugin from "eslint-plugin-react";
|
| 32 |
+
import reactHooksPlugin from "eslint-plugin-react-hooks";
|
| 33 |
+
import jsxA11yPlugin from "eslint-plugin-jsx-a11y";
|
| 34 |
+
import importXPlugin from "eslint-plugin-import-x";
|
| 35 |
+
import prettierPlugin from "eslint-plugin-prettier";
|
| 36 |
+
import prettierConfig from "eslint-config-prettier";
|
| 37 |
+
import unusedImportsPlugin from "eslint-plugin-unused-imports";
|
| 38 |
+
import tanstackQueryPlugin from "@tanstack/eslint-plugin-query";
|
| 39 |
+
import globals from "globals";
|
| 40 |
+
|
| 41 |
+
const __filename = fileURLToPath(import.meta.url);
|
| 42 |
+
const __dirname = path.dirname(__filename);
|
| 43 |
+
|
| 44 |
+
const compat = new FlatCompat({
|
| 45 |
+
baseDirectory: __dirname,
|
| 46 |
+
resolvePluginsRelativeTo: __dirname,
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* ESLint rule: forbid raw `fetch` calls that target an agent-server `/api/...`
|
| 51 |
+
* path, so API access goes through the typed @openhands/typescript-client
|
| 52 |
+
* clients. Only statically-resolvable agent-server paths are flagged; external
|
| 53 |
+
* URLs, dynamic identifiers, and non-`/api/` draws (cookie-auth static
|
| 54 |
+
* fileserver, OAuth device verification) are left alone.
|
| 55 |
+
*/
|
| 56 |
+
function createNoDirectAgentServerFetchRule() {
|
| 57 |
+
return {
|
| 58 |
+
meta: {
|
| 59 |
+
type: "problem",
|
| 60 |
+
docs: {
|
| 61 |
+
description:
|
| 62 |
+
"Use typed @openhands/typescript-client clients instead of global fetch for agent-server API calls.",
|
| 63 |
+
},
|
| 64 |
+
messages: {
|
| 65 |
+
noRawFetch:
|
| 66 |
+
"Use a typed @openhands/typescript-client client (or AgentServerClient.request) instead of global fetch for agent-server API calls.",
|
| 67 |
+
},
|
| 68 |
+
},
|
| 69 |
+
create(context) {
|
| 70 |
+
function resolveStaticUrl(node) {
|
| 71 |
+
if (node.type === "Literal" && typeof node.value === "string") {
|
| 72 |
+
return node.value;
|
| 73 |
+
}
|
| 74 |
+
if (node.type === "TemplateLiteral") {
|
| 75 |
+
return node.quasis
|
| 76 |
+
.map((q) => q.value.raw)
|
| 77 |
+
.join("");
|
| 78 |
+
}
|
| 79 |
+
return null;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
return {
|
| 83 |
+
CallExpression(node) {
|
| 84 |
+
const callee = node.callee;
|
| 85 |
+
if (callee.type !== "Identifier" || callee.name !== "fetch") return;
|
| 86 |
+
const urlNode = node.arguments[0];
|
| 87 |
+
if (!urlNode) return;
|
| 88 |
+
const url = resolveStaticUrl(urlNode);
|
| 89 |
+
if (url === null || !url.includes("/api/")) return;
|
| 90 |
+
context.report({ node, messageId: "noRawFetch" });
|
| 91 |
+
},
|
| 92 |
+
};
|
| 93 |
+
},
|
| 94 |
+
};
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
export default [
|
| 98 |
+
// Files / dirs we never want to lint.
|
| 99 |
+
{
|
| 100 |
+
ignores: [
|
| 101 |
+
"node_modules/**",
|
| 102 |
+
"dist/**",
|
| 103 |
+
"build/**",
|
| 104 |
+
"coverage/**",
|
| 105 |
+
".react-router/**",
|
| 106 |
+
"playwright-report/**",
|
| 107 |
+
"playwright-report-live/**",
|
| 108 |
+
"test-results/**",
|
| 109 |
+
"test-results-live/**",
|
| 110 |
+
"public/mockServiceWorker.js",
|
| 111 |
+
// Self-contained browser ES module served to extensions at runtime;
|
| 112 |
+
// not part of the TypeScript project.
|
| 113 |
+
"src/fixtures/canvas-extensions/**/*.js",
|
| 114 |
+
"src/i18n/declaration.d.ts",
|
| 115 |
+
],
|
| 116 |
+
},
|
| 117 |
+
|
| 118 |
+
// Base JS recommended rules.
|
| 119 |
+
js.configs.recommended,
|
| 120 |
+
|
| 121 |
+
// `eslint-plugin-import-x` ships flat configs directly. The rules are
|
| 122 |
+
// registered under both `import-x/*` and `import/*` aliases so existing
|
| 123 |
+
// `// eslint-disable-next-line import/*` comments still match.
|
| 124 |
+
importXPlugin.flatConfigs.recommended,
|
| 125 |
+
importXPlugin.flatConfigs.typescript,
|
| 126 |
+
|
| 127 |
+
// `eslint-plugin-i18next` is still eslintrc-only — pull it in via compat.
|
| 128 |
+
...compat.extends("plugin:i18next/recommended"),
|
| 129 |
+
|
| 130 |
+
// Project-wide settings + React/TS/etc. rules for source files.
|
| 131 |
+
{
|
| 132 |
+
files: ["**/*.{ts,tsx,js,jsx,mjs,cjs}"],
|
| 133 |
+
// Surface (but don't fail on) `// eslint-disable-next-line ...` directives
|
| 134 |
+
// that target rules this config no longer enables. The codebase has ~50
|
| 135 |
+
// such directives left over from the airbnb era; "warn" lets us clean
|
| 136 |
+
// them up incrementally instead of either failing CI on day one or
|
| 137 |
+
// letting new stale directives accumulate silently.
|
| 138 |
+
linterOptions: {
|
| 139 |
+
reportUnusedDisableDirectives: "warn",
|
| 140 |
+
},
|
| 141 |
+
languageOptions: {
|
| 142 |
+
ecmaVersion: "latest",
|
| 143 |
+
sourceType: "module",
|
| 144 |
+
parser: tsParser,
|
| 145 |
+
parserOptions: {
|
| 146 |
+
project: "./tsconfig.json",
|
| 147 |
+
ecmaFeatures: { jsx: true },
|
| 148 |
+
},
|
| 149 |
+
globals: {
|
| 150 |
+
...globals.browser,
|
| 151 |
+
...globals.node,
|
| 152 |
+
...globals.es2024,
|
| 153 |
+
},
|
| 154 |
+
},
|
| 155 |
+
plugins: {
|
| 156 |
+
"@typescript-eslint": tsPlugin,
|
| 157 |
+
react: reactPlugin,
|
| 158 |
+
"react-hooks": reactHooksPlugin,
|
| 159 |
+
"jsx-a11y": jsxA11yPlugin,
|
| 160 |
+
"@tanstack/query": tanstackQueryPlugin,
|
| 161 |
+
"unused-imports": unusedImportsPlugin,
|
| 162 |
+
// Alias `import-x` rules under `import/*` so existing
|
| 163 |
+
// `// eslint-disable-next-line import/foo` comments keep working.
|
| 164 |
+
import: importXPlugin,
|
| 165 |
+
prettier: prettierPlugin,
|
| 166 |
+
// Local rule enforcing that agent-server API calls go through the typed
|
| 167 |
+
// @openhands/typescript-client clients instead of a raw `fetch`.
|
| 168 |
+
local: {
|
| 169 |
+
rules: {
|
| 170 |
+
"no-direct-agent-server-fetch":
|
| 171 |
+
createNoDirectAgentServerFetchRule(),
|
| 172 |
+
},
|
| 173 |
+
},
|
| 174 |
+
},
|
| 175 |
+
settings: {
|
| 176 |
+
react: { version: "detect" },
|
| 177 |
+
// `eslint-import-resolver-typescript` resolves both TS path aliases
|
| 178 |
+
// (via tsconfig) and regular node-style imports, so we don't also
|
| 179 |
+
// configure the legacy `node` resolver shortcut here.
|
| 180 |
+
"import-x/resolver": {
|
| 181 |
+
typescript: true,
|
| 182 |
+
},
|
| 183 |
+
},
|
| 184 |
+
rules: {
|
| 185 |
+
// Recommended rule packs we want everywhere.
|
| 186 |
+
...tsPlugin.configs["eslint-recommended"].overrides[0].rules,
|
| 187 |
+
...tsPlugin.configs.recommended.rules,
|
| 188 |
+
...reactPlugin.configs.recommended.rules,
|
| 189 |
+
...reactPlugin.configs["jsx-runtime"].rules,
|
| 190 |
+
...reactHooksPlugin.configs.recommended.rules,
|
| 191 |
+
...jsxA11yPlugin.configs.recommended.rules,
|
| 192 |
+
...tanstackQueryPlugin.configs.recommended.rules,
|
| 193 |
+
|
| 194 |
+
// Prettier integration. `eslint-config-prettier` turns off conflicting
|
| 195 |
+
// stylistic rules; `eslint-plugin-prettier` re-reports prettier diffs
|
| 196 |
+
// as lint errors so `npm run lint` fails on unformatted code (matching
|
| 197 |
+
// the pre-flat-config behaviour).
|
| 198 |
+
...prettierConfig.rules,
|
| 199 |
+
"prettier/prettier": "error",
|
| 200 |
+
|
| 201 |
+
// Project conventions previously enforced via airbnb / custom rules.
|
| 202 |
+
// Lint JSX *attributes* (not just text between tags) for hard-coded
|
| 203 |
+
// user-facing strings. The plugin default (`mode: 'jsx-text-only'`)
|
| 204 |
+
// never checks attribute values, which let untranslated strings like
|
| 205 |
+
// `aria-label="Close"` / `placeholder="..."` slip past lint (cf. #1306).
|
| 206 |
+
//
|
| 207 |
+
// `jsx-only` checks every literal inside a JSX subtree, so we scope it:
|
| 208 |
+
// - jsx-attributes.include: only attributes that carry translatable
|
| 209 |
+
// text. Everything else (testId, name, color, to, href, className,
|
| 210 |
+
// data-*, …) is ignored automatically — no brittle deny-list.
|
| 211 |
+
// - callees/object-properties: re-list the plugin defaults (the option
|
| 212 |
+
// merge is shallow, so providing a key replaces it) and add
|
| 213 |
+
// `cn`/`className` so Tailwind class strings built via `cn(...)` or
|
| 214 |
+
// `{ className: "..." }` aren't flagged.
|
| 215 |
+
"i18next/no-literal-string": [
|
| 216 |
+
"error",
|
| 217 |
+
{
|
| 218 |
+
mode: "jsx-only",
|
| 219 |
+
"jsx-attributes": {
|
| 220 |
+
include: [
|
| 221 |
+
"placeholder",
|
| 222 |
+
"alt",
|
| 223 |
+
"aria-label",
|
| 224 |
+
"title",
|
| 225 |
+
"label",
|
| 226 |
+
"heading",
|
| 227 |
+
"text",
|
| 228 |
+
],
|
| 229 |
+
},
|
| 230 |
+
callees: {
|
| 231 |
+
exclude: [
|
| 232 |
+
"i18n(ext)?",
|
| 233 |
+
"t",
|
| 234 |
+
"require",
|
| 235 |
+
"addEventListener",
|
| 236 |
+
"removeEventListener",
|
| 237 |
+
"postMessage",
|
| 238 |
+
"getElementById",
|
| 239 |
+
"dispatch",
|
| 240 |
+
"commit",
|
| 241 |
+
"includes",
|
| 242 |
+
"indexOf",
|
| 243 |
+
"endsWith",
|
| 244 |
+
"startsWith",
|
| 245 |
+
"cn",
|
| 246 |
+
],
|
| 247 |
+
},
|
| 248 |
+
"object-properties": {
|
| 249 |
+
exclude: ["[A-Z_-]+", "className"],
|
| 250 |
+
},
|
| 251 |
+
},
|
| 252 |
+
],
|
| 253 |
+
"unused-imports/no-unused-imports": "error",
|
| 254 |
+
"@typescript-eslint/prefer-optional-chain": "error",
|
| 255 |
+
"no-restricted-imports": [
|
| 256 |
+
"error",
|
| 257 |
+
{
|
| 258 |
+
paths: [
|
| 259 |
+
{
|
| 260 |
+
name: "@openhands/typescript-client/client/http-client",
|
| 261 |
+
message:
|
| 262 |
+
"Use typed @openhands/typescript-client clients instead of constructing HttpClient directly.",
|
| 263 |
+
},
|
| 264 |
+
],
|
| 265 |
+
},
|
| 266 |
+
],
|
| 267 |
+
// All agent-server API access must go through the typed
|
| 268 |
+
// @openhands/typescript-client clients. A raw global `fetch` against an
|
| 269 |
+
// agent-server `/api/...` path bypasses the typed access layer, so it is
|
| 270 |
+
// banned here (see also src/api/no-direct-agent-server-calls.test.ts).
|
| 271 |
+
// Browser-cookie-auth and external (non-agent-server) fetches — e.g. the
|
| 272 |
+
// workspace static fileserver, OAuth device verification, and the npm
|
| 273 |
+
// registry version check — do not target `/api/...` and are unaffected.
|
| 274 |
+
"local/no-direct-agent-server-fetch": "error",
|
| 275 |
+
|
| 276 |
+
// Allow `interface Foo extends Bar<"foo"> {}` — the codebase uses this
|
| 277 |
+
// discriminated-union pattern in `src/types/agent-server/**` and the
|
| 278 |
+
// empty body is intentional. We still disallow `interface Foo {}` and
|
| 279 |
+
// `type Foo = {}` (the default behaviour for the other two options).
|
| 280 |
+
"@typescript-eslint/no-empty-object-type": [
|
| 281 |
+
"error",
|
| 282 |
+
{ allowInterfaces: "with-single-extends" },
|
| 283 |
+
],
|
| 284 |
+
|
| 285 |
+
// tsPlugin's `recommended` enables no-unused-vars; we already report
|
| 286 |
+
// unused imports above, so make sure the TS rule still catches unused
|
| 287 |
+
// locals/args (with the standard airbnb-style `_`-prefix escape hatch).
|
| 288 |
+
// `caughtErrors: 'none'` keeps the v7 behaviour of allowing
|
| 289 |
+
// `catch (error)` clauses where the error is intentionally unused.
|
| 290 |
+
"no-unused-vars": "off",
|
| 291 |
+
"@typescript-eslint/no-unused-vars": [
|
| 292 |
+
"error",
|
| 293 |
+
{
|
| 294 |
+
args: "after-used",
|
| 295 |
+
argsIgnorePattern: "^_",
|
| 296 |
+
varsIgnorePattern: "^_",
|
| 297 |
+
ignoreRestSiblings: true,
|
| 298 |
+
caughtErrors: "none",
|
| 299 |
+
},
|
| 300 |
+
],
|
| 301 |
+
|
| 302 |
+
// React Compiler rules (added in eslint-plugin-react-hooks v5+) are
|
| 303 |
+
// opt-in for projects that have actually adopted the compiler. Keep
|
| 304 |
+
// them off so existing code isn't retroactively flagged.
|
| 305 |
+
"react-hooks/immutability": "off",
|
| 306 |
+
"react-hooks/preserve-manual-memoization": "off",
|
| 307 |
+
"react-hooks/refs": "off",
|
| 308 |
+
"react-hooks/set-state-in-effect": "off",
|
| 309 |
+
|
| 310 |
+
// Match the legacy config: don't require .ts/.tsx import extensions,
|
| 311 |
+
// but enforce them for everything else. See
|
| 312 |
+
// https://stackoverflow.com/q/59265981 for background.
|
| 313 |
+
"import-x/extensions": [
|
| 314 |
+
"error",
|
| 315 |
+
"ignorePackages",
|
| 316 |
+
{ "": "never", ts: "never", tsx: "never", js: "never", jsx: "never" },
|
| 317 |
+
],
|
| 318 |
+
// The old config inherited a bunch of import rules from airbnb that
|
| 319 |
+
// either don't apply or are now noise; explicitly turn off the ones
|
| 320 |
+
// most likely to fire on this codebase.
|
| 321 |
+
"import-x/prefer-default-export": "off",
|
| 322 |
+
"import-x/no-extraneous-dependencies": "off",
|
| 323 |
+
// `import-x/no-unresolved` is redundant with the TypeScript compiler:
|
| 324 |
+
// `tsc` (run as `npm run typecheck` before `eslint`) already fails on
|
| 325 |
+
// unresolved imports with much better error messages, and the rule
|
| 326 |
+
// has known false positives with `paths`/exports-map resolution even
|
| 327 |
+
// when the typescript resolver is configured. Keeping it on duplicates
|
| 328 |
+
// tsc errors and produces noise on Vite's `?url` / `?worker` import
|
| 329 |
+
// suffixes that ESLint can't see through.
|
| 330 |
+
"import-x/no-unresolved": "off",
|
| 331 |
+
// These two fire a lot of false positives on TypeScript projects that
|
| 332 |
+
// import a namespace and then call methods off it (`import api from
|
| 333 |
+
// './foo'; api.bar()` etc.). The TS compiler already catches anything
|
| 334 |
+
// truly wrong here.
|
| 335 |
+
"import-x/no-named-as-default": "off",
|
| 336 |
+
"import-x/no-named-as-default-member": "off",
|
| 337 |
+
},
|
| 338 |
+
},
|
| 339 |
+
|
| 340 |
+
// TypeScript-only overrides (re-applied airbnb-ish relaxations from the
|
| 341 |
+
// previous `.eslintrc` overrides block).
|
| 342 |
+
{
|
| 343 |
+
files: ["**/*.{ts,tsx}"],
|
| 344 |
+
ignores: ["src/hooks/query/query-keys.ts"],
|
| 345 |
+
rules: {
|
| 346 |
+
// Allow state mutation in reduce and Redux-style reducers.
|
| 347 |
+
"no-param-reassign": [
|
| 348 |
+
"error",
|
| 349 |
+
{
|
| 350 |
+
props: true,
|
| 351 |
+
ignorePropertyModificationsFor: ["acc", "state"],
|
| 352 |
+
},
|
| 353 |
+
],
|
| 354 |
+
"no-restricted-syntax": [
|
| 355 |
+
"error",
|
| 356 |
+
{
|
| 357 |
+
selector:
|
| 358 |
+
"Property[key.name='queryKey'] > ArrayExpression[elements.0.value='settings']",
|
| 359 |
+
message:
|
| 360 |
+
"Use SETTINGS_QUERY_KEYS helpers instead of raw settings query key arrays.",
|
| 361 |
+
},
|
| 362 |
+
{
|
| 363 |
+
selector:
|
| 364 |
+
"CallExpression[callee.name='t'] > Literal:first-child[value=/^[A-Z0-9_]+\\$/]",
|
| 365 |
+
message: "Use I18nKey instead of raw translation key strings.",
|
| 366 |
+
},
|
| 367 |
+
{
|
| 368 |
+
selector:
|
| 369 |
+
"CallExpression[callee.property.name='t'] > Literal:first-child[value=/^[A-Z0-9_]+\\$/]",
|
| 370 |
+
message: "Use I18nKey instead of raw translation key strings.",
|
| 371 |
+
},
|
| 372 |
+
],
|
| 373 |
+
"react/require-default-props": "off",
|
| 374 |
+
"no-underscore-dangle": "off",
|
| 375 |
+
"jsx-a11y/no-static-element-interactions": "off",
|
| 376 |
+
"jsx-a11y/click-events-have-key-events": "off",
|
| 377 |
+
// For https://github.com/airbnb/javascript/issues/1885
|
| 378 |
+
"jsx-a11y/label-has-associated-control": [
|
| 379 |
+
2,
|
| 380 |
+
{
|
| 381 |
+
required: {
|
| 382 |
+
some: ["nesting", "id"],
|
| 383 |
+
},
|
| 384 |
+
},
|
| 385 |
+
],
|
| 386 |
+
"react/prop-types": "off",
|
| 387 |
+
"react/no-array-index-key": "off",
|
| 388 |
+
"react-hooks/exhaustive-deps": "off",
|
| 389 |
+
"react/react-in-jsx-scope": "off",
|
| 390 |
+
},
|
| 391 |
+
},
|
| 392 |
+
];
|
global.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface Window {
|
| 2 |
+
__GITHUB_CLIENT_ID__?: string | null;
|
| 3 |
+
}
|
| 4 |
+
|
| 5 |
+
declare module "postcss-prefix-selector" {
|
| 6 |
+
interface PrefixerOptions {
|
| 7 |
+
prefix: string;
|
| 8 |
+
transform?: (
|
| 9 |
+
prefix: string,
|
| 10 |
+
selector: string,
|
| 11 |
+
prefixedSelector: string,
|
| 12 |
+
) => string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default function prefixer(
|
| 16 |
+
options: PrefixerOptions,
|
| 17 |
+
): import("postcss").AcceptedPlugin;
|
| 18 |
+
}
|
hero.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { heroui } from "@heroui/react";
|
| 2 |
+
|
| 3 |
+
export default heroui({
|
| 4 |
+
defaultTheme: "dark",
|
| 5 |
+
layout: {
|
| 6 |
+
radius: {
|
| 7 |
+
small: "5px",
|
| 8 |
+
large: "20px",
|
| 9 |
+
},
|
| 10 |
+
},
|
| 11 |
+
themes: {
|
| 12 |
+
dark: {
|
| 13 |
+
colors: {
|
| 14 |
+
primary: "#4465DB",
|
| 15 |
+
|
| 16 |
+
// Map HeroUI's zinc-based semantic colours to our cool-grey palette.
|
| 17 |
+
// This ensures every HeroUI component that uses bg-default, bg-content*,
|
| 18 |
+
// etc. stays within the same colour family as the rest of the UI.
|
| 19 |
+
|
| 20 |
+
background: {
|
| 21 |
+
DEFAULT: "#0B0E14", // cool-grey-950 — app shell base
|
| 22 |
+
foreground: "#F7F9FC", // cool-grey-50
|
| 23 |
+
},
|
| 24 |
+
|
| 25 |
+
foreground: {
|
| 26 |
+
DEFAULT: "#C3CDDC", // cool-grey-300 — primary readable text
|
| 27 |
+
"50": "#05070A", // cool-grey-975
|
| 28 |
+
"100": "#0B0E14", // cool-grey-950
|
| 29 |
+
"200": "#21252F", // cool-grey-925
|
| 30 |
+
"300": "#2C313F", // cool-grey-900
|
| 31 |
+
"400": "#383F50", // cool-grey-800
|
| 32 |
+
"500": "#4B5468", // cool-grey-700
|
| 33 |
+
"600": "#626D82", // cool-grey-600
|
| 34 |
+
"700": "#7E8A9E", // cool-grey-500
|
| 35 |
+
"800": "#A3B0C4", // cool-grey-400
|
| 36 |
+
"900": "#C3CDDC", // cool-grey-300
|
| 37 |
+
},
|
| 38 |
+
|
| 39 |
+
// Surface layers: panel → card → inner card → inset
|
| 40 |
+
content1: { DEFAULT: "#21252F", foreground: "#EEF2F7" }, // cool-grey-925 / 100
|
| 41 |
+
content2: { DEFAULT: "#2C313F", foreground: "#DCE3EE" }, // cool-grey-900 / 200
|
| 42 |
+
content3: { DEFAULT: "#383F50", foreground: "#C3CDDC" }, // cool-grey-800 / 300
|
| 43 |
+
content4: { DEFAULT: "#4B5468", foreground: "#A3B0C4" }, // cool-grey-700 / 400
|
| 44 |
+
|
| 45 |
+
focus: {
|
| 46 |
+
DEFAULT: "#ffffff", // white focus ring — visible on all dark surfaces
|
| 47 |
+
},
|
| 48 |
+
default: {
|
| 49 |
+
"50": "#05070A", // cool-grey-975
|
| 50 |
+
"100": "#0B0E14", // cool-grey-950
|
| 51 |
+
"200": "#21252F", // cool-grey-925
|
| 52 |
+
"300": "#2C313F", // cool-grey-900
|
| 53 |
+
"400": "#383F50", // cool-grey-800
|
| 54 |
+
"500": "#4B5468", // cool-grey-700
|
| 55 |
+
"600": "#626D82", // cool-grey-600
|
| 56 |
+
"700": "#7E8A9E", // cool-grey-500
|
| 57 |
+
"800": "#A3B0C4", // cool-grey-400
|
| 58 |
+
"900": "#C3CDDC", // cool-grey-300
|
| 59 |
+
DEFAULT: "#383F50", // cool-grey-800 — hover/selected tint
|
| 60 |
+
foreground: "#F7F9FC", // cool-grey-50 — text on default bg
|
| 61 |
+
},
|
| 62 |
+
},
|
| 63 |
+
},
|
| 64 |
+
},
|
| 65 |
+
});
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "@openhands/agent-canvas",
|
| 3 |
+
"version": "1.18.0",
|
| 4 |
+
"description": "Agent Canvas UI for OpenHands - run AI coding agents with a visual interface",
|
| 5 |
+
"license": "MIT",
|
| 6 |
+
"private": false,
|
| 7 |
+
"type": "module",
|
| 8 |
+
"repository": {
|
| 9 |
+
"type": "git",
|
| 10 |
+
"url": "https://github.com/OpenHands/OpenHands"
|
| 11 |
+
},
|
| 12 |
+
"homepage": "https://github.com/OpenHands/OpenHands#readme",
|
| 13 |
+
"bugs": {
|
| 14 |
+
"url": "https://github.com/OpenHands/OpenHands/issues"
|
| 15 |
+
},
|
| 16 |
+
"bin": {
|
| 17 |
+
"agent-canvas": "bin/agent-canvas.mjs"
|
| 18 |
+
},
|
| 19 |
+
"engines": {
|
| 20 |
+
"node": ">=24"
|
| 21 |
+
},
|
| 22 |
+
"dependencies": {
|
| 23 |
+
"@heroui/react": "2.8.10",
|
| 24 |
+
"@microlink/react-json-view": "1.31.25",
|
| 25 |
+
"@monaco-editor/react": "4.7.0",
|
| 26 |
+
"@openhands/extensions": "0.20.0",
|
| 27 |
+
"@openhands/typescript-client": "1.46.0",
|
| 28 |
+
"@react-router/node": "7.18.2",
|
| 29 |
+
"@react-router/serve": "7.18.2",
|
| 30 |
+
"@tailwindcss/vite": "4.3.3",
|
| 31 |
+
"@tanstack/react-query": "5.101.4",
|
| 32 |
+
"@types/shell-quote": "1.7.5",
|
| 33 |
+
"@uidotdev/usehooks": "2.4.1",
|
| 34 |
+
"@xterm/addon-fit": "0.11.0",
|
| 35 |
+
"@xterm/xterm": "6.0.0",
|
| 36 |
+
"axios": "1.18.0",
|
| 37 |
+
"class-variance-authority": "0.7.1",
|
| 38 |
+
"clsx": "2.1.1",
|
| 39 |
+
"downshift": "9.3.2",
|
| 40 |
+
"framer-motion": "12.38.0",
|
| 41 |
+
"httpxy": "0.5.3",
|
| 42 |
+
"i18next": "26.3.6",
|
| 43 |
+
"i18next-browser-languagedetector": "8.2.1",
|
| 44 |
+
"i18next-http-backend": "4.0.1",
|
| 45 |
+
"isbot": "5.1.39",
|
| 46 |
+
"lucide-react": "1.25.0",
|
| 47 |
+
"monaco-editor": "0.56.0",
|
| 48 |
+
"posthog-js": "1.380.0",
|
| 49 |
+
"react": "19.2.8",
|
| 50 |
+
"react-dom": "19.2.8",
|
| 51 |
+
"react-hot-toast": "2.6.0",
|
| 52 |
+
"react-i18next": "17.0.11",
|
| 53 |
+
"react-icons": "5.6.0",
|
| 54 |
+
"react-markdown": "10.1.0",
|
| 55 |
+
"react-router": "7.18.2",
|
| 56 |
+
"react-syntax-highlighter": "16.1.1",
|
| 57 |
+
"rehype-raw": "7.0.0",
|
| 58 |
+
"rehype-sanitize": "6.0.0",
|
| 59 |
+
"remark-breaks": "4.0.0",
|
| 60 |
+
"remark-gfm": "4.0.1",
|
| 61 |
+
"shell-quote": "1.9.0",
|
| 62 |
+
"sirv": "3.0.2",
|
| 63 |
+
"sirv-cli": "3.0.1",
|
| 64 |
+
"socket.io-client": "4.8.3",
|
| 65 |
+
"tailwind-merge": "3.6.0",
|
| 66 |
+
"tailwind-scrollbar": "4.0.2",
|
| 67 |
+
"unist-util-visit": "5.1.0",
|
| 68 |
+
"uuid": "14.0.0",
|
| 69 |
+
"vite": "8.0.16",
|
| 70 |
+
"winston": "3.19.0",
|
| 71 |
+
"winston-daily-rotate-file": "5.0.0",
|
| 72 |
+
"zustand": "5.0.14"
|
| 73 |
+
},
|
| 74 |
+
"scripts": {
|
| 75 |
+
"dev": "node --env-file-if-exists=.env scripts/dev-with-automation.mjs",
|
| 76 |
+
"dev:static": "node --env-file-if-exists=.env scripts/dev-static.mjs",
|
| 77 |
+
"dev:extra-backend": "node --env-file-if-exists=.env scripts/dev-extra-backend.mjs",
|
| 78 |
+
"dev:minimal": "node --env-file-if-exists=.env scripts/dev-safe.mjs",
|
| 79 |
+
"dev:frontend": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
|
| 80 |
+
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev",
|
| 81 |
+
"build": "npm run build:app",
|
| 82 |
+
"build:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router build",
|
| 83 |
+
"start": "npx sirv-cli build/ --single",
|
| 84 |
+
"test": "npm run make-i18n && vitest run",
|
| 85 |
+
"test:e2e": "playwright test --pass-with-no-tests",
|
| 86 |
+
"test:e2e:live": "node --env-file-if-exists=.env tests/e2e/live/scripts/run-live-e2e.mjs",
|
| 87 |
+
"test:e2e:mock-llm": "playwright test --config=playwright.mock-llm.config.ts",
|
| 88 |
+
"test:e2e:mock-llm:docker": "playwright test --config=playwright.mock-llm-docker.config.ts",
|
| 89 |
+
"test:coverage": "npm run make-i18n && vitest run --coverage",
|
| 90 |
+
"test:mutation": "npm run make-i18n && stryker run",
|
| 91 |
+
"test:mutation:diff": "npm run make-i18n && node scripts/stryker-diff.mjs",
|
| 92 |
+
"test:mutation:incremental": "npm run make-i18n && stryker run --incremental",
|
| 93 |
+
"dev_wsl": "VITE_WATCH_USE_POLLING=true vite",
|
| 94 |
+
"preview": "vite preview",
|
| 95 |
+
"make-i18n": "node scripts/make-i18n-translations.cjs",
|
| 96 |
+
"prelint": "npm run make-i18n",
|
| 97 |
+
"lint": "npm run typecheck && eslint src && prettier --check src/**/*.{ts,tsx}",
|
| 98 |
+
"lint:fix": "eslint src --fix && prettier --write src/**/*.{ts,tsx}",
|
| 99 |
+
"postinstall": "node -e \"if (process.env.npm_config_global === 'true') { console.log('\\n\\x1b[32m\\x1b[1m\\u2713 @openhands/agent-canvas installed!\\x1b[0m\\n\\nTo start Agent Canvas, run:\\n\\n \\x1b[36m\\x1b[1magent-canvas\\x1b[0m\\n\\nThis launches the full stack at \\x1b[36mhttp://localhost:8000\\x1b[0m.\\n\\nDocs: https://docs.openhands.dev/openhands/usage/agent-canvas/setup\\n') }\"",
|
| 100 |
+
"prepare": "husky",
|
| 101 |
+
"typecheck": "react-router typegen && tsc",
|
| 102 |
+
"typecheck:staged": "react-router typegen && npx tsc --noEmit --skipLibCheck",
|
| 103 |
+
"check-translation-completeness": "node scripts/check-translation-completeness.cjs",
|
| 104 |
+
"build:app": "npm run make-i18n && react-router build",
|
| 105 |
+
"build:lib": "npm run make-i18n && react-router typegen && cross-env BUILD_LIB=true vite build && tsc -p tsconfig.lib.json",
|
| 106 |
+
"build:docker": "node scripts/docker-build.mjs",
|
| 107 |
+
"example:acp-docker:env": "node scripts/gen-acp-docker-env.mjs",
|
| 108 |
+
"predesktop": "node scripts/brand-dev-electron.mjs",
|
| 109 |
+
"desktop": "npm run build:app && electron electron",
|
| 110 |
+
"build:desktop": "npm run build:app && node scripts/download-uv.mjs && node scripts/download-node.mjs && electron-builder --config electron-builder.config.mjs --publish never",
|
| 111 |
+
"build:desktop:universal": "npm run build:app && node scripts/download-uv.mjs && node scripts/download-node.mjs && cross-env ELECTRON_ARCH=universal electron-builder --config electron-builder.config.mjs --publish never",
|
| 112 |
+
"download-uv": "node scripts/download-uv.mjs",
|
| 113 |
+
"download-node": "node scripts/download-node.mjs",
|
| 114 |
+
"generate-icons": "node scripts/generate-icons.mjs"
|
| 115 |
+
},
|
| 116 |
+
"lint-staged": {
|
| 117 |
+
"src/**/*.{ts,tsx,js}": [
|
| 118 |
+
"eslint --fix",
|
| 119 |
+
"prettier --write"
|
| 120 |
+
],
|
| 121 |
+
"src/**/*.{ts,tsx}": [
|
| 122 |
+
"node scripts/run-staged-typecheck.mjs"
|
| 123 |
+
],
|
| 124 |
+
"src/**/*": [
|
| 125 |
+
"npm run check-translation-completeness"
|
| 126 |
+
]
|
| 127 |
+
},
|
| 128 |
+
"devDependencies": {
|
| 129 |
+
"@eslint/eslintrc": "3.3.6",
|
| 130 |
+
"@eslint/js": "9.39.4",
|
| 131 |
+
"@mswjs/socket.io-binding": "0.2.0",
|
| 132 |
+
"@playwright/test": "1.62.1",
|
| 133 |
+
"@react-router/dev": "7.18.2",
|
| 134 |
+
"@stryker-mutator/core": "9.6.1",
|
| 135 |
+
"@stryker-mutator/vitest-runner": "9.6.1",
|
| 136 |
+
"@tailwindcss/typography": "0.5.20",
|
| 137 |
+
"@tanstack/eslint-plugin-query": "5.101.4",
|
| 138 |
+
"@testing-library/dom": "10.4.1",
|
| 139 |
+
"@testing-library/jest-dom": "7.0.0",
|
| 140 |
+
"@testing-library/react": "16.3.2",
|
| 141 |
+
"@testing-library/user-event": "14.6.1",
|
| 142 |
+
"@types/mdast": "4.0.4",
|
| 143 |
+
"@types/node": "26.1.2",
|
| 144 |
+
"@types/react": "19.2.18",
|
| 145 |
+
"@types/react-dom": "19.2.4",
|
| 146 |
+
"@types/react-syntax-highlighter": "15.5.13",
|
| 147 |
+
"@typescript-eslint/eslint-plugin": "8.65.0",
|
| 148 |
+
"@typescript-eslint/parser": "8.65.0",
|
| 149 |
+
"@vercel/react-router": "1.3.0",
|
| 150 |
+
"@vitest/coverage-v8": "4.1.10",
|
| 151 |
+
"cross-env": "10.1.0",
|
| 152 |
+
"electron": "42.3.3",
|
| 153 |
+
"electron-builder": "26.15.3",
|
| 154 |
+
"eslint": "9.39.5",
|
| 155 |
+
"eslint-config-prettier": "10.1.8",
|
| 156 |
+
"eslint-import-resolver-typescript": "4.4.5",
|
| 157 |
+
"eslint-plugin-i18next": "6.1.5",
|
| 158 |
+
"eslint-plugin-import-x": "4.17.1",
|
| 159 |
+
"eslint-plugin-jsx-a11y": "6.10.2",
|
| 160 |
+
"eslint-plugin-prettier": "5.5.6",
|
| 161 |
+
"eslint-plugin-react": "7.37.5",
|
| 162 |
+
"eslint-plugin-react-hooks": "7.1.1",
|
| 163 |
+
"eslint-plugin-unused-imports": "4.4.1",
|
| 164 |
+
"globals": "16.5.0",
|
| 165 |
+
"husky": "9.1.7",
|
| 166 |
+
"jsdom": "30.0.1",
|
| 167 |
+
"lint-staged": "17.3.0",
|
| 168 |
+
"msw": "2.15.0",
|
| 169 |
+
"png2icons": "2.0.1",
|
| 170 |
+
"postcss-prefix-selector": "2.1.1",
|
| 171 |
+
"prettier": "3.8.3",
|
| 172 |
+
"tailwindcss": "4.3.3",
|
| 173 |
+
"typescript": "6.0.3",
|
| 174 |
+
"vite-plugin-svgr": "5.2.0",
|
| 175 |
+
"vitest": "4.1.10"
|
| 176 |
+
},
|
| 177 |
+
"packageManager": "npm@10.5.0",
|
| 178 |
+
"volta": {
|
| 179 |
+
"node": "22.12.0"
|
| 180 |
+
},
|
| 181 |
+
"msw": {
|
| 182 |
+
"workerDirectory": [
|
| 183 |
+
"public"
|
| 184 |
+
]
|
| 185 |
+
},
|
| 186 |
+
"overrides": {
|
| 187 |
+
"@vercel/static-config": {
|
| 188 |
+
"ajv": "8.20.0"
|
| 189 |
+
},
|
| 190 |
+
"dompurify": "3.4.14",
|
| 191 |
+
"typed-rest-client": {
|
| 192 |
+
"qs": "6.15.3"
|
| 193 |
+
}
|
| 194 |
+
},
|
| 195 |
+
"main": "./dist/index.cjs",
|
| 196 |
+
"module": "./dist/index.js",
|
| 197 |
+
"types": "./dist/index.d.ts",
|
| 198 |
+
"files": [
|
| 199 |
+
"dist",
|
| 200 |
+
"bin",
|
| 201 |
+
"build",
|
| 202 |
+
"config",
|
| 203 |
+
"scripts",
|
| 204 |
+
"tools"
|
| 205 |
+
],
|
| 206 |
+
"exports": {
|
| 207 |
+
".": {
|
| 208 |
+
"types": "./dist/index.d.ts",
|
| 209 |
+
"import": "./dist/index.js",
|
| 210 |
+
"require": "./dist/index.cjs"
|
| 211 |
+
},
|
| 212 |
+
"./browser": {
|
| 213 |
+
"types": "./dist/components/browser/index.d.ts",
|
| 214 |
+
"import": "./dist/components/browser/index.js",
|
| 215 |
+
"require": "./dist/components/browser/index.cjs"
|
| 216 |
+
},
|
| 217 |
+
"./conversation": {
|
| 218 |
+
"types": "./dist/components/conversation/index.d.ts",
|
| 219 |
+
"import": "./dist/components/conversation/index.js",
|
| 220 |
+
"require": "./dist/components/conversation/index.cjs"
|
| 221 |
+
},
|
| 222 |
+
"./files": {
|
| 223 |
+
"types": "./dist/components/files/index.d.ts",
|
| 224 |
+
"import": "./dist/components/files/index.js",
|
| 225 |
+
"require": "./dist/components/files/index.cjs"
|
| 226 |
+
},
|
| 227 |
+
"./settings": {
|
| 228 |
+
"types": "./dist/components/settings/index.d.ts",
|
| 229 |
+
"import": "./dist/components/settings/index.js",
|
| 230 |
+
"require": "./dist/components/settings/index.cjs"
|
| 231 |
+
},
|
| 232 |
+
"./sidebar": {
|
| 233 |
+
"types": "./dist/components/sidebar/index.d.ts",
|
| 234 |
+
"import": "./dist/components/sidebar/index.js",
|
| 235 |
+
"require": "./dist/components/sidebar/index.cjs"
|
| 236 |
+
},
|
| 237 |
+
"./terminal": {
|
| 238 |
+
"types": "./dist/components/terminal/index.d.ts",
|
| 239 |
+
"import": "./dist/components/terminal/index.js",
|
| 240 |
+
"require": "./dist/components/terminal/index.cjs"
|
| 241 |
+
},
|
| 242 |
+
"./i18n": {
|
| 243 |
+
"types": "./dist/i18n/index.d.ts",
|
| 244 |
+
"import": "./dist/i18n/index.js",
|
| 245 |
+
"require": "./dist/i18n/index.cjs"
|
| 246 |
+
},
|
| 247 |
+
"./package.json": "./package.json"
|
| 248 |
+
},
|
| 249 |
+
"peerDependencies": {
|
| 250 |
+
"react": "19.2.8",
|
| 251 |
+
"react-dom": "19.2.8",
|
| 252 |
+
"react-router": "7.18.2"
|
| 253 |
+
}
|
| 254 |
+
}
|
playwright.config.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, devices } from "@playwright/test";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Read environment variables from file.
|
| 5 |
+
* https://github.com/motdotla/dotenv
|
| 6 |
+
*/
|
| 7 |
+
// import dotenv from 'dotenv';
|
| 8 |
+
// import path from 'path';
|
| 9 |
+
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* See https://playwright.dev/docs/test-configuration.
|
| 13 |
+
*/
|
| 14 |
+
export default defineConfig({
|
| 15 |
+
testDir: "./tests/e2e",
|
| 16 |
+
testIgnore: ["**/e2e/live/**", "**/e2e/mock-llm/**"],
|
| 17 |
+
/* Run tests in files in parallel */
|
| 18 |
+
fullyParallel: true,
|
| 19 |
+
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
| 20 |
+
forbidOnly: !!process.env.CI,
|
| 21 |
+
/* Retry on CI only */
|
| 22 |
+
retries: process.env.CI ? 2 : 0,
|
| 23 |
+
/* Use 2 workers on CI (matches ubuntu-24.04's 2 vCPUs). Tests are isolated
|
| 24 |
+
* per browser context so parallel execution is safe. */
|
| 25 |
+
workers: process.env.CI ? 2 : undefined,
|
| 26 |
+
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
| 27 |
+
reporter: "html",
|
| 28 |
+
|
| 29 |
+
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
| 30 |
+
use: {
|
| 31 |
+
/* Base URL to use in actions like `await page.goto('/')`. */
|
| 32 |
+
baseURL: "http://localhost:3001/",
|
| 33 |
+
|
| 34 |
+
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
| 35 |
+
trace: "on-first-retry",
|
| 36 |
+
/* Ignore SSL errors for browser agent test */
|
| 37 |
+
/* Solution inspired by StackOverflow post: https://stackoverflow.com/questions/67048422/ignore-ssl-errors-with-playwright-code-generation */
|
| 38 |
+
ignoreHTTPSErrors: true,
|
| 39 |
+
},
|
| 40 |
+
|
| 41 |
+
/* Configure projects for major browsers */
|
| 42 |
+
projects: [
|
| 43 |
+
{
|
| 44 |
+
name: "chromium",
|
| 45 |
+
use: { ...devices["Desktop Chrome"] },
|
| 46 |
+
},
|
| 47 |
+
|
| 48 |
+
{
|
| 49 |
+
name: "firefox",
|
| 50 |
+
use: { ...devices["Desktop Firefox"] },
|
| 51 |
+
},
|
| 52 |
+
|
| 53 |
+
{
|
| 54 |
+
name: "webkit",
|
| 55 |
+
use: { ...devices["Desktop Safari"] },
|
| 56 |
+
},
|
| 57 |
+
|
| 58 |
+
/* Test against mobile viewports. */
|
| 59 |
+
// {
|
| 60 |
+
// name: 'Mobile Chrome',
|
| 61 |
+
// use: { ...devices['Pixel 5'] },
|
| 62 |
+
// },
|
| 63 |
+
// {
|
| 64 |
+
// name: 'Mobile Safari',
|
| 65 |
+
// use: { ...devices['iPhone 12'] },
|
| 66 |
+
// },
|
| 67 |
+
|
| 68 |
+
/* Test against branded browsers. */
|
| 69 |
+
// {
|
| 70 |
+
// name: 'Microsoft Edge',
|
| 71 |
+
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
| 72 |
+
// },
|
| 73 |
+
// {
|
| 74 |
+
// name: 'Google Chrome',
|
| 75 |
+
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
| 76 |
+
// },
|
| 77 |
+
],
|
| 78 |
+
|
| 79 |
+
/* Run your local dev server before starting the tests */
|
| 80 |
+
webServer: {
|
| 81 |
+
command: "npm run dev:mock -- --port 3001",
|
| 82 |
+
url: "http://localhost:3001/",
|
| 83 |
+
reuseExistingServer: !process.env.CI,
|
| 84 |
+
},
|
| 85 |
+
});
|
playwright.live.config.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, devices } from "@playwright/test";
|
| 2 |
+
import { randomBytes } from "node:crypto";
|
| 3 |
+
|
| 4 |
+
const configuredLiveE2ESessionApiKey =
|
| 5 |
+
process.env.LIVE_E2E_SESSION_API_KEY?.trim();
|
| 6 |
+
const liveE2ESessionApiKey =
|
| 7 |
+
configuredLiveE2ESessionApiKey || randomBytes(32).toString("hex");
|
| 8 |
+
process.env.LIVE_E2E_SESSION_API_KEY = liveE2ESessionApiKey;
|
| 9 |
+
const liveE2EFrontendPort = process.env.LIVE_E2E_FRONTEND_PORT ?? "3101";
|
| 10 |
+
const liveE2EBackendURL =
|
| 11 |
+
process.env.LIVE_E2E_BACKEND_URL ?? "http://127.0.0.1:18100";
|
| 12 |
+
let liveE2EBackendPort: string;
|
| 13 |
+
try {
|
| 14 |
+
liveE2EBackendPort = new URL(liveE2EBackendURL).port || "18100";
|
| 15 |
+
} catch {
|
| 16 |
+
throw new Error("Invalid LIVE_E2E_BACKEND_URL. Expected an absolute URL.");
|
| 17 |
+
}
|
| 18 |
+
const liveE2EFrontendURL = `http://localhost:${liveE2EFrontendPort}/`;
|
| 19 |
+
const liveE2EVideoMode =
|
| 20 |
+
process.env.LIVE_E2E_RECORD_VIDEO === "on" ? "on" : "retain-on-failure";
|
| 21 |
+
|
| 22 |
+
function shellQuote(value: string) {
|
| 23 |
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function envAssignment(name: string, value: string) {
|
| 27 |
+
return `${name}=${shellQuote(value)}`;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export default defineConfig({
|
| 31 |
+
testDir: "./tests/e2e/live",
|
| 32 |
+
testMatch: /.*\.spec\.ts/,
|
| 33 |
+
fullyParallel: false,
|
| 34 |
+
forbidOnly: !!process.env.CI,
|
| 35 |
+
retries: process.env.CI ? 1 : 0,
|
| 36 |
+
workers: 1,
|
| 37 |
+
timeout: 180_000,
|
| 38 |
+
reporter: [
|
| 39 |
+
["line"],
|
| 40 |
+
["json", { outputFile: "test-results-live/results.json" }],
|
| 41 |
+
["html", { outputFolder: "playwright-report-live", open: "never" }],
|
| 42 |
+
],
|
| 43 |
+
outputDir: "test-results-live",
|
| 44 |
+
use: {
|
| 45 |
+
baseURL: liveE2EFrontendURL,
|
| 46 |
+
screenshot: "only-on-failure",
|
| 47 |
+
trace: "off",
|
| 48 |
+
video: liveE2EVideoMode,
|
| 49 |
+
},
|
| 50 |
+
projects: [
|
| 51 |
+
{
|
| 52 |
+
name: "chromium",
|
| 53 |
+
use: { ...devices["Desktop Chrome"] },
|
| 54 |
+
},
|
| 55 |
+
],
|
| 56 |
+
// This live stack is intended for macOS/Linux shells and Ubuntu CI runners;
|
| 57 |
+
// the command below intentionally uses POSIX environment assignment syntax.
|
| 58 |
+
webServer: {
|
| 59 |
+
command:
|
| 60 |
+
"node -e \"const fs=require('node:fs'); for (const p of ['.tmp/live-e2e-state','node_modules/.vite']) fs.rmSync(p,{recursive:true,force:true});\" && " +
|
| 61 |
+
[
|
| 62 |
+
"OH_CANVAS_SAFE_STATE_DIR=.tmp/live-e2e-state",
|
| 63 |
+
envAssignment("LOCAL_BACKEND_API_KEY", liveE2ESessionApiKey),
|
| 64 |
+
envAssignment("OH_CANVAS_SAFE_BACKEND_PORT", liveE2EBackendPort),
|
| 65 |
+
"VITE_DO_NOT_TRACK=1",
|
| 66 |
+
"VITE_ENABLE_BROWSER_TOOLS=false",
|
| 67 |
+
envAssignment("VITE_FRONTEND_PORT", liveE2EFrontendPort),
|
| 68 |
+
"npm run dev:minimal",
|
| 69 |
+
].join(" "),
|
| 70 |
+
url: liveE2EFrontendURL,
|
| 71 |
+
timeout: 120_000,
|
| 72 |
+
reuseExistingServer: false,
|
| 73 |
+
},
|
| 74 |
+
});
|
playwright.mock-llm-docker.config.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Playwright config for mock-LLM E2E tests against a Docker container.
|
| 3 |
+
*
|
| 4 |
+
* Reuses the same test specs as playwright.mock-llm.config.ts but launches
|
| 5 |
+
* the agent-canvas stack inside a Docker container instead of via
|
| 6 |
+
* bin/agent-canvas.mjs + uvx.
|
| 7 |
+
*
|
| 8 |
+
* Starts two processes:
|
| 9 |
+
* 1. Mock LLM server (Python on the host, using openhands-sdk TestLLM)
|
| 10 |
+
* 2. Docker container running the agent-canvas all-in-one image
|
| 11 |
+
* (agent-server + automation backend + static frontend + proxy)
|
| 12 |
+
* The container also starts a second static-server instance on
|
| 13 |
+
* PUBLIC_MODE_PORT with --auth-required for auth-mode E2E tests.
|
| 14 |
+
*
|
| 15 |
+
* Networking:
|
| 16 |
+
* Uses --network host on Linux so the container shares the host's network
|
| 17 |
+
* stack. This means the agent-server inside Docker can reach the mock LLM
|
| 18 |
+
* server at 127.0.0.1:<port> — identical to the npm path.
|
| 19 |
+
*
|
| 20 |
+
* For macOS/Windows (Docker Desktop with bridge networking), set
|
| 21 |
+
* MOCK_LLM_AGENT_URL=http://host.docker.internal:<port> so the
|
| 22 |
+
* agent-server can reach the host-side mock LLM server.
|
| 23 |
+
*
|
| 24 |
+
* Required:
|
| 25 |
+
* - A built Docker image. Set MOCK_LLM_DOCKER_IMAGE to the image tag
|
| 26 |
+
* (default: ghcr.io/openhands/agent-canvas:latest).
|
| 27 |
+
* - Docker daemon must be running.
|
| 28 |
+
*/
|
| 29 |
+
|
| 30 |
+
import { defineConfig, devices } from "@playwright/test";
|
| 31 |
+
import { randomBytes } from "node:crypto";
|
| 32 |
+
import { mkdirSync } from "node:fs";
|
| 33 |
+
import { resolve } from "node:path";
|
| 34 |
+
|
| 35 |
+
// ── Docker image ────────────────────────────────────────────────────────
|
| 36 |
+
const DOCKER_IMAGE =
|
| 37 |
+
process.env.MOCK_LLM_DOCKER_IMAGE ?? "ghcr.io/openhands/agent-canvas:latest";
|
| 38 |
+
|
| 39 |
+
// Container name for cleanup — unique per run to avoid collisions.
|
| 40 |
+
const CONTAINER_NAME =
|
| 41 |
+
process.env.MOCK_LLM_CONTAINER_NAME ??
|
| 42 |
+
`agent-canvas-mock-llm-${randomBytes(4).toString("hex")}`;
|
| 43 |
+
|
| 44 |
+
// ── Port allocation (separate from live E2E / dev to avoid collisions) ─
|
| 45 |
+
const MOCK_LLM_PORT = process.env.MOCK_LLM_PORT ?? "9999";
|
| 46 |
+
|
| 47 |
+
// The Docker container exposes a single port for the unified ingress.
|
| 48 |
+
// With --network host this is accessible at localhost directly.
|
| 49 |
+
const INGRESS_PORT = process.env.MOCK_LLM_INGRESS_PORT ?? "18300";
|
| 50 |
+
|
| 51 |
+
// Public-mode static server — runs inside the Docker container when
|
| 52 |
+
// PUBLIC_MODE_PORT is set (see docker/entrypoint.sh). With --network host
|
| 53 |
+
// the port is accessible from the host at localhost directly.
|
| 54 |
+
const PUBLIC_MODE_PORT = process.env.MOCK_LLM_PUBLIC_MODE_PORT ?? "18301";
|
| 55 |
+
|
| 56 |
+
// ── Session API key ────────────────────────────────────────────────────
|
| 57 |
+
const sessionApiKey =
|
| 58 |
+
process.env.MOCK_LLM_SESSION_API_KEY?.trim() ||
|
| 59 |
+
randomBytes(32).toString("hex");
|
| 60 |
+
process.env.MOCK_LLM_SESSION_API_KEY = sessionApiKey;
|
| 61 |
+
|
| 62 |
+
// ── URLs ───────────────────────────────────────────────────────────────
|
| 63 |
+
// The Docker entrypoint starts static-server.mjs with `--host ::` (dual-
|
| 64 |
+
// stack), so it accepts both IPv4 and IPv6 connections — same as the npm
|
| 65 |
+
// path's ingress.mjs. `localhost` is safe for both configs now.
|
| 66 |
+
// The mock LLM server (Python, host-side) also listens dual-stack.
|
| 67 |
+
const INGRESS_URL = `http://localhost:${INGRESS_PORT}/`;
|
| 68 |
+
const MOCK_LLM_URL = `http://127.0.0.1:${MOCK_LLM_PORT}`;
|
| 69 |
+
|
| 70 |
+
// Python binary for the mock server — defaults to "python3" but CI can
|
| 71 |
+
// point this at a venv (e.g. ".mock-llm-venv/bin/python3") to avoid
|
| 72 |
+
// PEP 668 "externally managed" errors on Ubuntu 24.04+.
|
| 73 |
+
const MOCK_LLM_PYTHON = process.env.MOCK_LLM_PYTHON ?? "python3";
|
| 74 |
+
|
| 75 |
+
// Export for the test helpers — BACKEND_URL points to the ingress (API
|
| 76 |
+
// calls are proxied to the agent-server, so no direct backend port needed).
|
| 77 |
+
process.env.MOCK_LLM_BACKEND_URL = `http://localhost:${INGRESS_PORT}`;
|
| 78 |
+
process.env.MOCK_LLM_PORT = MOCK_LLM_PORT;
|
| 79 |
+
process.env.MOCK_LLM_PUBLIC_MODE_URL = `http://localhost:${PUBLIC_MODE_PORT}`;
|
| 80 |
+
process.env.VITE_SESSION_API_KEY = sessionApiKey;
|
| 81 |
+
|
| 82 |
+
// MOCK_LLM_AGENT_URL — the URL the agent-server inside Docker uses to
|
| 83 |
+
// call the mock LLM for inference. With --network host on Linux the
|
| 84 |
+
// agent-server can reach 127.0.0.1 directly. For macOS/Windows Docker
|
| 85 |
+
// Desktop, override this to http://host.docker.internal:<port>.
|
| 86 |
+
if (!process.env.MOCK_LLM_AGENT_URL) {
|
| 87 |
+
process.env.MOCK_LLM_AGENT_URL = MOCK_LLM_URL;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// ── Skill test support ────────────────────────────────────────────────
|
| 91 |
+
// The skill tests create git repos and user skill files on the host.
|
| 92 |
+
// We volume-mount these into the container so the agent-server can see them.
|
| 93 |
+
|
| 94 |
+
// Project skills: host creates repos here, container sees them at a fixed path.
|
| 95 |
+
const SKILL_REPOS_HOST_DIR = resolve(".tmp/mock-llm-skill-repos");
|
| 96 |
+
const SKILL_REPOS_CONTAINER_DIR = "/tmp/mock-llm-skill-repos";
|
| 97 |
+
mkdirSync(SKILL_REPOS_HOST_DIR, { recursive: true });
|
| 98 |
+
process.env.MOCK_LLM_SKILL_REPOS_CONTAINER_DIR = SKILL_REPOS_CONTAINER_DIR;
|
| 99 |
+
|
| 100 |
+
// User skills: host creates skill files here, container mounts them at
|
| 101 |
+
// the agent-server's expected ~/.openhands/skills/ path.
|
| 102 |
+
const USER_SKILLS_HOST_DIR = resolve(".tmp/mock-llm-user-skills");
|
| 103 |
+
const USER_SKILLS_CONTAINER_DIR = "/home/openhands/.openhands/skills";
|
| 104 |
+
mkdirSync(USER_SKILLS_HOST_DIR, { recursive: true });
|
| 105 |
+
process.env.MOCK_LLM_USER_SKILLS_HOST_DIR = USER_SKILLS_HOST_DIR;
|
| 106 |
+
|
| 107 |
+
// ── Folder-workspace test support ──────────────────────────────────────
|
| 108 |
+
// The folder-workspace test creates a temp directory on the host that the
|
| 109 |
+
// agent-server's folder browser needs to list. Mount a shared directory into
|
| 110 |
+
// the container at the same path so the agent-server can see it.
|
| 111 |
+
const FOLDER_WORKSPACE_HOST_DIR = resolve(".tmp/e2e-folder-workspace-test");
|
| 112 |
+
const FOLDER_WORKSPACE_CONTAINER_DIR = "/tmp/e2e-folder-workspace-test";
|
| 113 |
+
mkdirSync(FOLDER_WORKSPACE_HOST_DIR, { recursive: true });
|
| 114 |
+
process.env.MOCK_LLM_FOLDER_WORKSPACE_HOST_DIR = FOLDER_WORKSPACE_HOST_DIR;
|
| 115 |
+
process.env.MOCK_LLM_FOLDER_WORKSPACE_CONTAINER_DIR =
|
| 116 |
+
FOLDER_WORKSPACE_CONTAINER_DIR;
|
| 117 |
+
|
| 118 |
+
// ── ACP test support ──────────────────────────────────────────────────
|
| 119 |
+
// The mock ACP server script lives on the host. We volume-mount it into
|
| 120 |
+
// the container and tell the test which container-side paths to use when
|
| 121 |
+
// typing the `acp_command` into the Settings UI.
|
| 122 |
+
const MOCK_ACP_HOST_PATH = resolve(
|
| 123 |
+
"tests/e2e/mock-llm/scripts/mock-acp-server.py",
|
| 124 |
+
);
|
| 125 |
+
const MOCK_ACP_CONTAINER_PATH = "/opt/mock-acp-server.py";
|
| 126 |
+
|
| 127 |
+
// The agent-server image ships Python 3 via the openhands-sdk base.
|
| 128 |
+
process.env.MOCK_ACP_CONTAINER_PYTHON = "python3";
|
| 129 |
+
process.env.MOCK_ACP_CONTAINER_SCRIPT = MOCK_ACP_CONTAINER_PATH;
|
| 130 |
+
|
| 131 |
+
const DEFAULT_CI_GLOBAL_TIMEOUT_MS = 1_200_000;
|
| 132 |
+
const configuredCiGlobalTimeoutMs = Number.parseInt(
|
| 133 |
+
process.env.MOCK_LLM_DOCKER_GLOBAL_TIMEOUT_MS ??
|
| 134 |
+
String(DEFAULT_CI_GLOBAL_TIMEOUT_MS),
|
| 135 |
+
10,
|
| 136 |
+
);
|
| 137 |
+
const ciGlobalTimeoutMs = Number.isFinite(configuredCiGlobalTimeoutMs)
|
| 138 |
+
? configuredCiGlobalTimeoutMs
|
| 139 |
+
: DEFAULT_CI_GLOBAL_TIMEOUT_MS;
|
| 140 |
+
|
| 141 |
+
export default defineConfig({
|
| 142 |
+
testDir: "./tests/e2e/mock-llm",
|
| 143 |
+
testMatch: /.*\.spec\.ts/,
|
| 144 |
+
fullyParallel: false,
|
| 145 |
+
forbidOnly: !!process.env.CI,
|
| 146 |
+
// One retry for transient Docker container startup failures.
|
| 147 |
+
retries: process.env.CI ? 1 : 0,
|
| 148 |
+
workers: 1,
|
| 149 |
+
timeout: 60_000,
|
| 150 |
+
globalTimeout: process.env.CI ? ciGlobalTimeoutMs : 0, // 20 min hard cap in CI
|
| 151 |
+
reporter: [
|
| 152 |
+
["line"],
|
| 153 |
+
[
|
| 154 |
+
"json",
|
| 155 |
+
{ outputFile: "test-results-mock-llm-docker/results.json" },
|
| 156 |
+
],
|
| 157 |
+
[
|
| 158 |
+
"html",
|
| 159 |
+
{
|
| 160 |
+
outputFolder: "playwright-report-mock-llm-docker",
|
| 161 |
+
open: "never",
|
| 162 |
+
},
|
| 163 |
+
],
|
| 164 |
+
["./tests/e2e/mock-llm/reporters/done-marker-reporter.ts"],
|
| 165 |
+
],
|
| 166 |
+
outputDir: "test-results-mock-llm-docker",
|
| 167 |
+
use: {
|
| 168 |
+
baseURL: INGRESS_URL,
|
| 169 |
+
screenshot: "only-on-failure",
|
| 170 |
+
trace: "on-first-retry",
|
| 171 |
+
video: "on",
|
| 172 |
+
},
|
| 173 |
+
projects: [
|
| 174 |
+
{
|
| 175 |
+
name: "chromium",
|
| 176 |
+
use: { ...devices["Desktop Chrome"] },
|
| 177 |
+
},
|
| 178 |
+
],
|
| 179 |
+
webServer: [
|
| 180 |
+
// 1. Mock LLM server (Python, on the host)
|
| 181 |
+
{
|
| 182 |
+
command: `${MOCK_LLM_PYTHON} tests/e2e/mock-llm/scripts/mock-llm-server.py --port ${MOCK_LLM_PORT}`,
|
| 183 |
+
url: MOCK_LLM_URL,
|
| 184 |
+
timeout: 30_000,
|
| 185 |
+
reuseExistingServer: !process.env.CI,
|
| 186 |
+
stdout: "pipe",
|
| 187 |
+
stderr: "pipe",
|
| 188 |
+
},
|
| 189 |
+
// 2. Docker container running the agent-canvas all-in-one image
|
| 190 |
+
//
|
| 191 |
+
// Uses --network host so the container shares the host's network:
|
| 192 |
+
// - The ingress port is available at localhost:<INGRESS_PORT>
|
| 193 |
+
// - The agent-server can reach the mock LLM at 127.0.0.1:<MOCK_LLM_PORT>
|
| 194 |
+
//
|
| 195 |
+
// The container is started with --rm for auto-cleanup. A named container
|
| 196 |
+
// is used so the teardown can `docker stop` it reliably.
|
| 197 |
+
//
|
| 198 |
+
// Note: --network host is Linux-only. On macOS/Windows Docker Desktop,
|
| 199 |
+
// use -p port mapping and set MOCK_LLM_AGENT_URL=http://host.docker.internal:<port>.
|
| 200 |
+
{
|
| 201 |
+
command: [
|
| 202 |
+
// Stop any leftover container from a previous failed run
|
| 203 |
+
`docker rm -f ${CONTAINER_NAME} 2>/dev/null;`,
|
| 204 |
+
"exec docker run",
|
| 205 |
+
// Keep the container until the workflow captures its logs.
|
| 206 |
+
`--name ${CONTAINER_NAME}`,
|
| 207 |
+
"--network host",
|
| 208 |
+
// Mount the mock ACP server script so the agent-server inside
|
| 209 |
+
// Docker can spawn it as an ACP subprocess.
|
| 210 |
+
`-v ${MOCK_ACP_HOST_PATH}:${MOCK_ACP_CONTAINER_PATH}:ro`,
|
| 211 |
+
// Mount skill test directories so the agent-server can access
|
| 212 |
+
// repos and user skills created by the host-side test code.
|
| 213 |
+
`-v ${SKILL_REPOS_HOST_DIR}:${SKILL_REPOS_CONTAINER_DIR}`,
|
| 214 |
+
`-v ${USER_SKILLS_HOST_DIR}:${USER_SKILLS_CONTAINER_DIR}`,
|
| 215 |
+
// Mount the folder-workspace test directory so the agent-server's
|
| 216 |
+
// folder browser can list/navigate it inside the container.
|
| 217 |
+
`-v ${FOLDER_WORKSPACE_HOST_DIR}:${FOLDER_WORKSPACE_CONTAINER_DIR}`,
|
| 218 |
+
`-e PORT=${INGRESS_PORT}`,
|
| 219 |
+
`-e SESSION_API_KEY=${sessionApiKey}`,
|
| 220 |
+
`-e OH_SESSION_API_KEYS_0=${sessionApiKey}`,
|
| 221 |
+
`-e PUBLIC_MODE_PORT=${PUBLIC_MODE_PORT}`,
|
| 222 |
+
"-e VITE_DO_NOT_TRACK=1",
|
| 223 |
+
"-e VITE_ENABLE_BROWSER_TOOLS=false",
|
| 224 |
+
DOCKER_IMAGE,
|
| 225 |
+
].join(" "),
|
| 226 |
+
// Probe the automation list endpoint through the ingress to ensure
|
| 227 |
+
// the FULL stack (agent-server + automation backend + ingress) is
|
| 228 |
+
// up before tests start. GET /api/automation/v1 returns 200 (empty
|
| 229 |
+
// list) without auth — the automation backend does not enforce
|
| 230 |
+
// session-key auth on the list endpoint.
|
| 231 |
+
url: `http://localhost:${INGRESS_PORT}/api/automation/v1`,
|
| 232 |
+
timeout: 180_000, // Docker pull + all services startup
|
| 233 |
+
reuseExistingServer: !process.env.CI,
|
| 234 |
+
},
|
| 235 |
+
],
|
| 236 |
+
// globalTeardown stops the Docker container when Playwright exits.
|
| 237 |
+
// Playwright sends SIGTERM to the webServer command, but `docker run`
|
| 238 |
+
// with --rm handles cleanup automatically on termination.
|
| 239 |
+
});
|
playwright.mock-llm.config.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Playwright config for mock-LLM E2E tests.
|
| 3 |
+
*
|
| 4 |
+
* Starts three processes:
|
| 5 |
+
* 1. Mock LLM server (Python, using openhands-sdk TestLLM)
|
| 6 |
+
* 2. Full agent-canvas stack via bin/agent-canvas.mjs (agent-server +
|
| 7 |
+
* automation backend + static frontend + ingress proxy), matching the
|
| 8 |
+
* production npm-published binary.
|
| 9 |
+
* 3. A second static-server instance with `--auth-required` (public mode)
|
| 10 |
+
* on a separate port, proxying to the same backend. Used by the
|
| 11 |
+
* auth-mode E2E tests.
|
| 12 |
+
*
|
| 13 |
+
* The test creates an LLM profile via the UI that points at the mock server,
|
| 14 |
+
* so no real LLM credentials are needed.
|
| 15 |
+
*
|
| 16 |
+
* A pre-built `build/` directory is required — the Playwright webServer
|
| 17 |
+
* command runs `npm run build:app` when `build/index.html` is absent.
|
| 18 |
+
* CI should run the build step explicitly before the tests for caching.
|
| 19 |
+
*/
|
| 20 |
+
|
| 21 |
+
import { defineConfig, devices } from "@playwright/test";
|
| 22 |
+
import { randomBytes } from "node:crypto";
|
| 23 |
+
import { dirname, join, resolve } from "node:path";
|
| 24 |
+
|
| 25 |
+
// ── Port allocation (separate from live E2E / dev to avoid collisions) ─
|
| 26 |
+
const MOCK_LLM_PORT = process.env.MOCK_LLM_PORT ?? "9999";
|
| 27 |
+
|
| 28 |
+
// The agent-canvas binary exposes a single ingress port that routes:
|
| 29 |
+
// /api/automation/* → automation backend
|
| 30 |
+
// /api/*, /sockets → agent-server
|
| 31 |
+
// /* → static frontend
|
| 32 |
+
// Tests use this single URL for both the browser (baseURL) and backend API
|
| 33 |
+
// calls (the ingress proxies /api/* transparently).
|
| 34 |
+
const INGRESS_PORT = process.env.MOCK_LLM_INGRESS_PORT ?? "18300";
|
| 35 |
+
|
| 36 |
+
// A second static-server instance for public-mode auth tests. It serves
|
| 37 |
+
// the same build/ directory with --auth-required (no baked session key)
|
| 38 |
+
// and proxies to the same backend.
|
| 39 |
+
const PUBLIC_MODE_PORT = process.env.MOCK_LLM_PUBLIC_MODE_PORT ?? "18301";
|
| 40 |
+
|
| 41 |
+
// ── Session API key ────────────────────────────────────────────────────
|
| 42 |
+
const sessionApiKey =
|
| 43 |
+
process.env.MOCK_LLM_SESSION_API_KEY?.trim() ||
|
| 44 |
+
randomBytes(32).toString("hex");
|
| 45 |
+
process.env.MOCK_LLM_SESSION_API_KEY = sessionApiKey;
|
| 46 |
+
|
| 47 |
+
// ── State directory (isolated per test run) ────────────────────────────
|
| 48 |
+
const STATE_DIR = resolve(".tmp/mock-llm-state");
|
| 49 |
+
|
| 50 |
+
// Automation DB lives at $parent_of_STATE_DIR/automation/automations.db,
|
| 51 |
+
// mirroring docker/entrypoint.sh which uses $HOME/.openhands/automation/automations.db.
|
| 52 |
+
// Both STATE_DIR and AUTOMATION_DB_DIR must be cleaned between runs to avoid stale data.
|
| 53 |
+
const AUTOMATION_DB_DIR = join(dirname(STATE_DIR), "automation");
|
| 54 |
+
|
| 55 |
+
// ── URLs ───────────────────────────────────────────────────────────────
|
| 56 |
+
const INGRESS_URL = `http://localhost:${INGRESS_PORT}/`;
|
| 57 |
+
const MOCK_LLM_URL = `http://127.0.0.1:${MOCK_LLM_PORT}`;
|
| 58 |
+
|
| 59 |
+
// Python binary for the mock server — defaults to "python3" but CI can
|
| 60 |
+
// point this at a venv (e.g. ".mock-llm-venv/bin/python3") to avoid
|
| 61 |
+
// PEP 668 "externally managed" errors on Ubuntu 24.04+.
|
| 62 |
+
const MOCK_LLM_PYTHON = process.env.MOCK_LLM_PYTHON ?? "python3";
|
| 63 |
+
|
| 64 |
+
// Export for the test helpers — BACKEND_URL points to the ingress (API
|
| 65 |
+
// calls are proxied to the agent-server, so no direct backend port needed).
|
| 66 |
+
process.env.MOCK_LLM_BACKEND_URL = `http://localhost:${INGRESS_PORT}`;
|
| 67 |
+
process.env.MOCK_LLM_PORT = MOCK_LLM_PORT;
|
| 68 |
+
process.env.MOCK_LLM_PUBLIC_MODE_URL = `http://localhost:${PUBLIC_MODE_PORT}`;
|
| 69 |
+
|
| 70 |
+
function shellQuote(value: string) {
|
| 71 |
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function envAssignment(name: string, value: string) {
|
| 75 |
+
return `${name}=${shellQuote(value)}`;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
const DEFAULT_CI_GLOBAL_TIMEOUT_MS = 1_200_000;
|
| 79 |
+
const configuredCiGlobalTimeoutMs = Number.parseInt(
|
| 80 |
+
process.env.MOCK_LLM_GLOBAL_TIMEOUT_MS ??
|
| 81 |
+
String(DEFAULT_CI_GLOBAL_TIMEOUT_MS),
|
| 82 |
+
10,
|
| 83 |
+
);
|
| 84 |
+
const ciGlobalTimeoutMs = Number.isFinite(configuredCiGlobalTimeoutMs)
|
| 85 |
+
? configuredCiGlobalTimeoutMs
|
| 86 |
+
: DEFAULT_CI_GLOBAL_TIMEOUT_MS;
|
| 87 |
+
|
| 88 |
+
export default defineConfig({
|
| 89 |
+
testDir: "./tests/e2e/mock-llm",
|
| 90 |
+
testMatch: /.*\.spec\.ts/,
|
| 91 |
+
fullyParallel: false,
|
| 92 |
+
forbidOnly: !!process.env.CI,
|
| 93 |
+
retries: 0,
|
| 94 |
+
workers: 1,
|
| 95 |
+
timeout: 60_000,
|
| 96 |
+
globalTimeout: process.env.CI ? ciGlobalTimeoutMs : 0, // 20 min hard cap in CI
|
| 97 |
+
reporter: [
|
| 98 |
+
["line"],
|
| 99 |
+
["json", { outputFile: "test-results-mock-llm/results.json" }],
|
| 100 |
+
["html", { outputFolder: "playwright-report-mock-llm", open: "never" }],
|
| 101 |
+
["./tests/e2e/mock-llm/reporters/done-marker-reporter.ts"],
|
| 102 |
+
],
|
| 103 |
+
outputDir: "test-results-mock-llm",
|
| 104 |
+
use: {
|
| 105 |
+
baseURL: INGRESS_URL,
|
| 106 |
+
screenshot: "only-on-failure",
|
| 107 |
+
trace: "on-first-retry",
|
| 108 |
+
video: "on",
|
| 109 |
+
},
|
| 110 |
+
projects: [
|
| 111 |
+
{
|
| 112 |
+
name: "chromium",
|
| 113 |
+
use: { ...devices["Desktop Chrome"] },
|
| 114 |
+
},
|
| 115 |
+
],
|
| 116 |
+
webServer: [
|
| 117 |
+
// 1. Mock LLM server (Python)
|
| 118 |
+
{
|
| 119 |
+
command: `${MOCK_LLM_PYTHON} tests/e2e/mock-llm/scripts/mock-llm-server.py --port ${MOCK_LLM_PORT}`,
|
| 120 |
+
url: MOCK_LLM_URL,
|
| 121 |
+
timeout: 30_000,
|
| 122 |
+
reuseExistingServer: !process.env.CI,
|
| 123 |
+
stdout: "pipe",
|
| 124 |
+
stderr: "pipe",
|
| 125 |
+
},
|
| 126 |
+
// 2. Full agent-canvas stack via bin/agent-canvas.mjs
|
| 127 |
+
//
|
| 128 |
+
// This mirrors the production `npx @openhands/agent-canvas` path:
|
| 129 |
+
// - Pre-built static frontend served via static-server.mjs
|
| 130 |
+
// - Agent-server via uvx
|
| 131 |
+
// - Automation backend via uvx
|
| 132 |
+
// - Ingress proxy unifying all routes on a single port
|
| 133 |
+
//
|
| 134 |
+
// `exec` replaces the shell so Playwright's tracked PID IS the node
|
| 135 |
+
// process. SIGTERM goes directly to the shutdown handler, which
|
| 136 |
+
// kills children via process groups and exits cleanly.
|
| 137 |
+
{
|
| 138 |
+
command:
|
| 139 |
+
// Clean state dir and automation DB dir to avoid stale data between runs.
|
| 140 |
+
// Automation DB is stored outside STATE_DIR (at AUTOMATION_DB_DIR) so both
|
| 141 |
+
// must be cleaned; see scripts/dev-with-automation.mjs startAutomationBackend.
|
| 142 |
+
`node -e "const fs=require('node:fs'); fs.rmSync('${STATE_DIR}',{recursive:true,force:true}); fs.rmSync('${AUTOMATION_DB_DIR}',{recursive:true,force:true});" && ` +
|
| 143 |
+
// Build frontend if not already built (CI should pre-build for caching)
|
| 144 |
+
"[ -f build/index.html ] || npm run build:app && " +
|
| 145 |
+
[
|
| 146 |
+
"exec env",
|
| 147 |
+
envAssignment("OH_CANVAS_SAFE_STATE_DIR", STATE_DIR),
|
| 148 |
+
envAssignment("PORT", INGRESS_PORT),
|
| 149 |
+
envAssignment("LOCAL_BACKEND_API_KEY", sessionApiKey),
|
| 150 |
+
"VITE_DO_NOT_TRACK=1",
|
| 151 |
+
"VITE_ENABLE_BROWSER_TOOLS=false",
|
| 152 |
+
// Bypass npm — exec directly into node so SIGTERM reaches
|
| 153 |
+
// the shutdown handler (npm swallows it).
|
| 154 |
+
"node --env-file-if-exists=.env bin/agent-canvas.mjs",
|
| 155 |
+
].join(" "),
|
| 156 |
+
// Probe the automation list endpoint through the ingress to ensure
|
| 157 |
+
// the FULL stack (agent-server + automation backend + ingress) is
|
| 158 |
+
// up before tests start. The automation backend starts last via
|
| 159 |
+
// uvx and can take 30-60s — checking only the ingress root or
|
| 160 |
+
// /server_info would let tests begin before it's ready.
|
| 161 |
+
// GET /api/automation/v1 returns 200 (empty list) without auth
|
| 162 |
+
// because the dev automation backend does not enforce session-key
|
| 163 |
+
// auth on the list endpoint (confirmed in CI).
|
| 164 |
+
url: `http://localhost:${INGRESS_PORT}/api/automation/v1`,
|
| 165 |
+
timeout: 180_000, // allow extra time for build + agent-server + automation startup
|
| 166 |
+
reuseExistingServer: !process.env.CI,
|
| 167 |
+
// Without this, Playwright tears the webServer down with
|
| 168 |
+
// process.kill(-pid, "SIGKILL"), which the stack cannot catch. Its
|
| 169 |
+
// services are spawned detached (see scripts/dev-process-utils.mjs), so
|
| 170 |
+
// they sit in their own process groups and survive that group kill,
|
| 171 |
+
// orphaning to PID 1 while still holding 18000/18001/18300/3001. Asking
|
| 172 |
+
// for SIGTERM lets the existing shutdown handler in
|
| 173 |
+
// scripts/dev-with-automation.mjs run, which stops each service, waits
|
| 174 |
+
// 3s, then force-kills stragglers. The timeout below leaves headroom
|
| 175 |
+
// over that 3s pass.
|
| 176 |
+
gracefulShutdown: { signal: "SIGTERM", timeout: 15_000 },
|
| 177 |
+
},
|
| 178 |
+
// 3. Public-mode static server — same build/, same backend, but with
|
| 179 |
+
// --auth-required (no session key injected). The agent-server's
|
| 180 |
+
// internal ports are the defaults from config/defaults.json (18000
|
| 181 |
+
// for agent-server, 18001 for automation).
|
| 182 |
+
{
|
| 183 |
+
command: [
|
| 184 |
+
"exec node scripts/static-server.mjs",
|
| 185 |
+
"--dir build",
|
| 186 |
+
`--port ${PUBLIC_MODE_PORT}`,
|
| 187 |
+
"--auth-required",
|
| 188 |
+
"--route /api/automation=http://localhost:18001",
|
| 189 |
+
"--route /api=http://localhost:18000",
|
| 190 |
+
"--route /server_info=http://localhost:18000",
|
| 191 |
+
"--route /sockets=http://localhost:18000",
|
| 192 |
+
].join(" "),
|
| 193 |
+
url: `http://localhost:${PUBLIC_MODE_PORT}/`,
|
| 194 |
+
timeout: 15_000,
|
| 195 |
+
reuseExistingServer: !process.env.CI,
|
| 196 |
+
},
|
| 197 |
+
],
|
| 198 |
+
});
|
react-router.config.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Config } from "@react-router/dev/config";
|
| 2 |
+
import { vercelPreset } from "@vercel/react-router/vite";
|
| 3 |
+
|
| 4 |
+
const normalizeBasePath = (value?: string) => {
|
| 5 |
+
const raw = value?.trim();
|
| 6 |
+
if (!raw || raw === "/") return undefined;
|
| 7 |
+
|
| 8 |
+
const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
| 9 |
+
return withLeadingSlash.replace(/\/+$/, "");
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
const basename = normalizeBasePath(process.env.VITE_BASE_PATH);
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* This script is used to unpack the client directory from the frontend build directory.
|
| 16 |
+
* Remix SPA mode builds the client directory into the build directory. This function
|
| 17 |
+
* moves the contents of the client directory to the build directory and then removes the
|
| 18 |
+
* client directory.
|
| 19 |
+
*
|
| 20 |
+
* This script is used in the buildEnd function of the Vite config.
|
| 21 |
+
*/
|
| 22 |
+
let unpackClientDirectoryPromise: Promise<void> | null = null;
|
| 23 |
+
|
| 24 |
+
const moveBuildEntry = async (
|
| 25 |
+
fs: typeof import("fs"),
|
| 26 |
+
source: string,
|
| 27 |
+
destination: string,
|
| 28 |
+
) => {
|
| 29 |
+
await fs.promises.rm(destination, { recursive: true, force: true });
|
| 30 |
+
|
| 31 |
+
try {
|
| 32 |
+
await fs.promises.rename(source, destination);
|
| 33 |
+
} catch (error) {
|
| 34 |
+
const code = (error as NodeJS.ErrnoException).code;
|
| 35 |
+
if (code !== "EPERM" && code !== "EXDEV") {
|
| 36 |
+
throw error;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
await fs.promises.cp(source, destination, {
|
| 40 |
+
recursive: true,
|
| 41 |
+
force: true,
|
| 42 |
+
});
|
| 43 |
+
await fs.promises.rm(source, { recursive: true, force: true });
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const unpackClientDirectoryOnce = async () => {
|
| 48 |
+
if (process.env.VERCEL) {
|
| 49 |
+
// Vercel's React Router builder reads static assets from build/client.
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const fs = await import("fs");
|
| 54 |
+
const path = await import("path");
|
| 55 |
+
|
| 56 |
+
const buildDir = path.resolve(__dirname, "build");
|
| 57 |
+
const clientDir = path.resolve(buildDir, "client");
|
| 58 |
+
|
| 59 |
+
let files: string[];
|
| 60 |
+
try {
|
| 61 |
+
files = await fs.promises.readdir(clientDir);
|
| 62 |
+
} catch (error) {
|
| 63 |
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
| 64 |
+
return;
|
| 65 |
+
}
|
| 66 |
+
throw error;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
for (const file of files) {
|
| 70 |
+
await moveBuildEntry(
|
| 71 |
+
fs,
|
| 72 |
+
path.resolve(clientDir, file),
|
| 73 |
+
path.resolve(buildDir, file),
|
| 74 |
+
);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
await fs.promises.rm(clientDir, { recursive: true, force: true });
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const unpackClientDirectory = async () => {
|
| 81 |
+
unpackClientDirectoryPromise ??= unpackClientDirectoryOnce().finally(() => {
|
| 82 |
+
unpackClientDirectoryPromise = null;
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
await unpackClientDirectoryPromise;
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
export default {
|
| 89 |
+
appDirectory: "src",
|
| 90 |
+
...(basename ? { basename } : {}),
|
| 91 |
+
buildEnd: unpackClientDirectory,
|
| 92 |
+
presets: [vercelPreset()],
|
| 93 |
+
ssr: false,
|
| 94 |
+
} satisfies Config;
|
release-please-config.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
| 3 |
+
"include-component-in-tag": false,
|
| 4 |
+
"include-v-in-tag": true,
|
| 5 |
+
"draft-pull-request": true,
|
| 6 |
+
"packages": {
|
| 7 |
+
".": {
|
| 8 |
+
"release-type": "node",
|
| 9 |
+
"changelog-type": "github",
|
| 10 |
+
"skip-changelog": true,
|
| 11 |
+
"extra-files": [
|
| 12 |
+
{
|
| 13 |
+
"type": "json",
|
| 14 |
+
"path": "config/defaults.json",
|
| 15 |
+
"jsonpath": "$.versions.agentCanvas"
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"_comment": "Must stay `generic` (annotation-driven). A bare \"helm/agent-canvas/Chart.yaml\" string would resolve to CompositeUpdater(GenericYaml('$.version'), Generic), which overwrites the chart-level `version` and strips every comment in the file when js-yaml re-serializes it.",
|
| 19 |
+
"type": "generic",
|
| 20 |
+
"path": "helm/agent-canvas/Chart.yaml"
|
| 21 |
+
},
|
| 22 |
+
"README.md",
|
| 23 |
+
"README.windows.md"
|
| 24 |
+
]
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
}
|
specs/backend-management.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend Management Specs
|
| 2 |
+
|
| 3 |
+
---
|
| 4 |
+
|
| 5 |
+
### BM-001: Auto-switch on connect
|
| 6 |
+
- [x] Adding a backend shall automatically switch the active selection to it.
|
| 7 |
+
|
| 8 |
+
### BM-002: Switching backends keeps the user on the same page
|
| 9 |
+
- [x] Switching backends shall redirect to the same section but on the new backend. The user shall never see stale data from the previous backend.
|
| 10 |
+
|
| 11 |
+
### BM-003: Fallback on active backend removal
|
| 12 |
+
- [x] Removing the currently active backend shall fall back to a remaining local backend. The user shall never be left without an active backend.
|
specs/canvas-extensions.md
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Canvas Extensions
|
| 2 |
+
|
| 3 |
+
## Status
|
| 4 |
+
|
| 5 |
+
Implementation plan and v1 contract. The frontend vertical slice may ship behind
|
| 6 |
+
Agent Server capability detection while the backend endpoints are implemented.
|
| 7 |
+
|
| 8 |
+
## Product definition
|
| 9 |
+
|
| 10 |
+
Canvas Extensions are installable packages that change Agent Canvas itself.
|
| 11 |
+
They contribute UI and local product behavior such as routed pages, conversation
|
| 12 |
+
panels, renderers, slots, and themes. Skills and plugins change the agent;
|
| 13 |
+
Canvas Extensions change the app.
|
| 14 |
+
|
| 15 |
+
The Customize area remains the single inventory for Skills, Plugins, MCP, and
|
| 16 |
+
Canvas Extensions. The inventory item is named **Extensions**; "addon" is an
|
| 17 |
+
informal alias only.
|
| 18 |
+
|
| 19 |
+
## Decisions
|
| 20 |
+
|
| 21 |
+
1. **The active Agent Server owns extensions.** An extension is installed on the
|
| 22 |
+
computer or container running the Agent Server. Its source, resolved revision,
|
| 23 |
+
files, manifest, and enabled state live there. Canvas only discovers and loads
|
| 24 |
+
extensions from the currently active backend. Switching backends replaces the
|
| 25 |
+
active extension set.
|
| 26 |
+
2. **Extension code is trusted, same-realm code.** There is no iframe, worker
|
| 27 |
+
sandbox, or granular permission system. Once enabled, an extension has the
|
| 28 |
+
same ambient browser authority as Canvas code. Shadow DOM may be offered later
|
| 29 |
+
as optional style isolation, but never as a security boundary.
|
| 30 |
+
3. **Install and enable are separate.** Installation always produces a disabled
|
| 31 |
+
extension. An agent may install or update an extension, but the user returns
|
| 32 |
+
to Customize -> Extensions and explicitly enables it. In v1 this is a product
|
| 33 |
+
consent invariant, not proof of human presence against an agent that can call
|
| 34 |
+
the same authenticated APIs. A future backend policy may allow agent-driven
|
| 35 |
+
enablement.
|
| 36 |
+
4. **Enablement is hot.** Enabling loads and activates the extension without an
|
| 37 |
+
app or Agent Server restart. Disabling unmounts registered surfaces and calls
|
| 38 |
+
lifecycle cleanup. Because code is trusted same-realm JavaScript, cleanup is
|
| 39 |
+
best-effort; an extension can create global effects the host cannot revoke.
|
| 40 |
+
5. **Distribution follows plugins, not their runtime.** Install coordinates are
|
| 41 |
+
`source`, optional `ref`, and optional `repo_path`, resolved and pinned by the
|
| 42 |
+
Agent Server. A repository may contain multiple extensions and other artifact
|
| 43 |
+
types under subpaths. Backend-local paths are interpreted on the backend
|
| 44 |
+
machine, never in the frontend process.
|
| 45 |
+
6. **Updates preserve enablement.** Refresh resolves and installs a new revision
|
| 46 |
+
atomically and keeps the prior enabled state. The UI shows the resulting
|
| 47 |
+
resolved revision. Since v1 is a trusted-code model, there is no misleading
|
| 48 |
+
permission-diff approval gate. The staged check/apply flow currently exists
|
| 49 |
+
only at the Agent Server service layer; until it is exposed over HTTP, the
|
| 50 |
+
Customize UI offers no Refresh action.
|
| 51 |
+
|
| 52 |
+
## Trust disclosure
|
| 53 |
+
|
| 54 |
+
Before enabling, Canvas says plainly that the extension can access and modify the
|
| 55 |
+
Canvas page and can make authenticated requests available to the current browser
|
| 56 |
+
session. The review screen shows source, requested ref, resolved revision,
|
| 57 |
+
manifest metadata, and contributed surfaces. It does not show fictitious
|
| 58 |
+
fine-grained permissions.
|
| 59 |
+
|
| 60 |
+
Install and update are still meaningful trust actions because they select the
|
| 61 |
+
code revision stored by the backend. Enable is the explicit point at which Canvas
|
| 62 |
+
executes that code.
|
| 63 |
+
|
| 64 |
+
## Package contract
|
| 65 |
+
|
| 66 |
+
The manifest filename is `canvas-extension.json`.
|
| 67 |
+
|
| 68 |
+
```json
|
| 69 |
+
{
|
| 70 |
+
"schema_version": 1,
|
| 71 |
+
"name": "example-dashboard",
|
| 72 |
+
"display_name": "Example dashboard",
|
| 73 |
+
"version": "0.1.0",
|
| 74 |
+
"description": "A backend-specific project dashboard.",
|
| 75 |
+
"entrypoint": "dist/extension.js",
|
| 76 |
+
"contributes": {
|
| 77 |
+
"pages": [
|
| 78 |
+
{
|
| 79 |
+
"id": "dashboard",
|
| 80 |
+
"title": "Dashboard",
|
| 81 |
+
"path": "/dashboard",
|
| 82 |
+
"nav_label": "Dashboard"
|
| 83 |
+
}
|
| 84 |
+
]
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
Rules for v1:
|
| 90 |
+
|
| 91 |
+
- `name` and contribution IDs use lowercase letters, digits, and hyphens.
|
| 92 |
+
- Page `path` values are absolute kebab-case routes (leading `/`); Canvas
|
| 93 |
+
mounts them relative to `/extensions/{name}`.
|
| 94 |
+
- `entrypoint` is a path inside the installed package.
|
| 95 |
+
- The entrypoint is one self-contained browser ESM bundle. It must not contain
|
| 96 |
+
unresolved bare imports or external chunks; dependencies, CSS, and small
|
| 97 |
+
assets are bundled or embedded by the authoring template.
|
| 98 |
+
- The backend validates that the manifest, entrypoint, and any future asset path
|
| 99 |
+
remain inside the installed extension root.
|
| 100 |
+
- Host compatibility fields will be added before marketplace distribution. The
|
| 101 |
+
initial schema is intentionally small while the page ABI is proven by a
|
| 102 |
+
separately built sample extension.
|
| 103 |
+
|
| 104 |
+
The v1 module exports `activate`:
|
| 105 |
+
|
| 106 |
+
```ts
|
| 107 |
+
export function activate(host: CanvasExtensionHost): void | (() => void) {
|
| 108 |
+
return host.registerPage("dashboard", ({ container, path, navigate }) => {
|
| 109 |
+
container.textContent = `Extension route: ${path}`;
|
| 110 |
+
return () => container.replaceChildren();
|
| 111 |
+
});
|
| 112 |
+
}
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
`CanvasExtensionHost` is versioned independently from the manifest. The first
|
| 116 |
+
host API contains:
|
| 117 |
+
|
| 118 |
+
- `apiVersion: "1"`
|
| 119 |
+
- immutable extension/backend metadata
|
| 120 |
+
- `registerPage(id, mount)` for page factories declared by the manifest
|
| 121 |
+
- `navigate(path)` using Canvas base-path-aware routing
|
| 122 |
+
- `agentServer.request(...)`, an authenticated request helper targeting the
|
| 123 |
+
extension's owning backend
|
| 124 |
+
|
| 125 |
+
The runtime fetches the bundle as authenticated text and imports it through a
|
| 126 |
+
temporary Blob URL. A direct `<script src>` or `import(backendUrl)` cannot carry
|
| 127 |
+
`X-Session-API-Key`, so it is not the v1 loading path. Importing the bundle does
|
| 128 |
+
not activate it; Canvas calls `activate` only for an enabled installation.
|
| 129 |
+
|
| 130 |
+
## Agent Server API
|
| 131 |
+
|
| 132 |
+
The API deliberately mirrors plugin distribution management while remaining a
|
| 133 |
+
separate runtime:
|
| 134 |
+
|
| 135 |
+
| Method | Path | Purpose |
|
| 136 |
+
| -------- | ------------------------------------------------- | --------------------------------------------------------- |
|
| 137 |
+
| `GET` | `/api/canvas-extensions/installed` | List installed extensions and parsed manifests |
|
| 138 |
+
| `POST` | `/api/canvas-extensions/install` | Install from git or a backend-local path; always disabled |
|
| 139 |
+
| `GET` | `/api/canvas-extensions/installed/{name}` | Read one installation |
|
| 140 |
+
| `PATCH` | `/api/canvas-extensions/installed/{name}` | Set enabled state |
|
| 141 |
+
| `DELETE` | `/api/canvas-extensions/installed/{name}` | Uninstall |
|
| 142 |
+
| `GET` | `/api/canvas-extensions/installed/{name}/bundle` | Return the entrypoint as JavaScript text |
|
| 143 |
+
|
| 144 |
+
A refresh endpoint (`POST /api/canvas-extensions/installed/{name}/refresh`) is
|
| 145 |
+
planned but not part of the current router; the service-layer staged check/apply
|
| 146 |
+
flow backs it and is tracked separately.
|
| 147 |
+
|
| 148 |
+
The list endpoint wraps its results in a `canvas_extensions` array.
|
| 149 |
+
|
| 150 |
+
Install request:
|
| 151 |
+
|
| 152 |
+
```json
|
| 153 |
+
{
|
| 154 |
+
"source": "github:owner/repository",
|
| 155 |
+
"ref": "main",
|
| 156 |
+
"repo_path": "extensions/example-dashboard",
|
| 157 |
+
"force": false
|
| 158 |
+
}
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
Installed response:
|
| 162 |
+
|
| 163 |
+
```json
|
| 164 |
+
{
|
| 165 |
+
"name": "example-dashboard",
|
| 166 |
+
"version": "0.1.0",
|
| 167 |
+
"description": "A backend-specific project dashboard.",
|
| 168 |
+
"enabled": false,
|
| 169 |
+
"source": "github:owner/repository",
|
| 170 |
+
"resolved_ref": "6f5f9a...",
|
| 171 |
+
"repo_path": "extensions/example-dashboard",
|
| 172 |
+
"installed_at": "2026-08-01T12:00:00Z",
|
| 173 |
+
"install_path": "/home/user/.openhands/canvas-extensions/installed/example-dashboard",
|
| 174 |
+
"manifest": {}
|
| 175 |
+
}
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
The `manifest` field (parsed `canvas-extension.json`, including
|
| 179 |
+
`contributes.pages`) is not returned by the current router yet; until it ships,
|
| 180 |
+
Canvas treats it as optional and renders installations without page
|
| 181 |
+
contributions or a display name.
|
| 182 |
+
|
| 183 |
+
The backend must prevent install requests from setting `enabled: true`. The
|
| 184 |
+
frontend's enable control calls the patch endpoint. Backend authorization and
|
| 185 |
+
multi-user installation scope follow the Agent Server deployment's existing
|
| 186 |
+
session trust model; v1 does not introduce a separate approval credential.
|
| 187 |
+
|
| 188 |
+
Until the API exists, Canvas treats HTTP 404 as "this backend does not support
|
| 189 |
+
Canvas Extensions". Customize shows an upgrade message, while the global runtime
|
| 190 |
+
stays silent. It must not collapse unsupported, unreachable, and an empty
|
| 191 |
+
inventory into the same state.
|
| 192 |
+
|
| 193 |
+
## Frontend runtime
|
| 194 |
+
|
| 195 |
+
The runtime is keyed by backend ID, organization scope, and connection revision.
|
| 196 |
+
This prevents an enabled bundle or cached inventory from one backend appearing
|
| 197 |
+
on another after a switch or reconnect.
|
| 198 |
+
|
| 199 |
+
For each enabled installation:
|
| 200 |
+
|
| 201 |
+
1. Fetch the authenticated entrypoint text from the owning backend.
|
| 202 |
+
2. Import the self-contained ESM bundle from a Blob URL.
|
| 203 |
+
3. Validate `activate` and its host API version.
|
| 204 |
+
4. Create an extension-scoped registry and call `activate`.
|
| 205 |
+
5. Admit registrations only when their IDs were declared in the manifest.
|
| 206 |
+
6. Render each surface inside an error boundary owned by Canvas.
|
| 207 |
+
7. On disable, uninstall, update, or backend switch, unmount every surface,
|
| 208 |
+
invoke the extension disposer, and remove its registry entries.
|
| 209 |
+
|
| 210 |
+
The initial route shape is
|
| 211 |
+
`/extensions/{extension-name}/{declared-page-path}`. `/extensions` itself is the
|
| 212 |
+
Customize inventory. All routing goes through React Router so `VITE_BASE_PATH`
|
| 213 |
+
continues to work.
|
| 214 |
+
|
| 215 |
+
## Delivery plan
|
| 216 |
+
|
| 217 |
+
### Slice 1: contract and management
|
| 218 |
+
|
| 219 |
+
- Land this spec and shared TypeScript manifest/installation types.
|
| 220 |
+
- Add a backend-keyed service and query hooks for list, install, enable/disable,
|
| 221 |
+
uninstall, and authenticated bundle fetch.
|
| 222 |
+
- Add Customize -> Extensions with an unsupported-backend state, source install
|
| 223 |
+
form, source/revision/contribution review, and explicit enable control.
|
| 224 |
+
- Keep the global runtime silent when the active backend returns 404.
|
| 225 |
+
|
| 226 |
+
### Slice 2: routed page proof
|
| 227 |
+
|
| 228 |
+
- Add the versioned host API, module loader, lifecycle registry, and error
|
| 229 |
+
boundary.
|
| 230 |
+
- Add dynamic page routes and left-rail navigation for enabled page
|
| 231 |
+
contributions.
|
| 232 |
+
- Build a standalone fixture extension with the same scaffold authors will use.
|
| 233 |
+
- Verify enable, hot activation, navigation, disable cleanup, update, backend
|
| 234 |
+
switching, session-key-authenticated bundle loading, and `VITE_BASE_PATH`.
|
| 235 |
+
|
| 236 |
+
### Slice 3: theme proof
|
| 237 |
+
|
| 238 |
+
- Define a code-free theme contribution with a strict Canvas token schema.
|
| 239 |
+
- Validate theme manifests on the backend and surface installed themes in
|
| 240 |
+
Settings.
|
| 241 |
+
- Keep theme selection browser/user scoped while availability follows the active
|
| 242 |
+
backend; fall back cleanly when switching away from the providing backend.
|
| 243 |
+
|
| 244 |
+
### Slice 4: conversation surfaces
|
| 245 |
+
|
| 246 |
+
- Replace the closed conversation tab union with namespaced runtime IDs and
|
| 247 |
+
centralized persistence admission.
|
| 248 |
+
- Add extension tabs/panels, then host-owned header/footer/badge slots.
|
| 249 |
+
- Define mobile behavior, ordering, conflicts, missing-extension fallback, and
|
| 250 |
+
per-conversation lifecycle context before opening each surface to authors.
|
| 251 |
+
|
| 252 |
+
### Slice 5: visualizers and authoring loop
|
| 253 |
+
|
| 254 |
+
- Add augment/replace visualizer registrations with explicit event matching,
|
| 255 |
+
precedence, and built-in fallback.
|
| 256 |
+
- Publish the stable extension SDK types, bundler template, validation command,
|
| 257 |
+
and sample repository.
|
| 258 |
+
- Only then ship `/build-canvas-extension`, including backend-local versus remote
|
| 259 |
+
workspace guidance, temporary install, manual enable instructions, and git
|
| 260 |
+
publication/update flow.
|
| 261 |
+
|
| 262 |
+
## Verification
|
| 263 |
+
|
| 264 |
+
Each slice adds service and runtime unit tests. The routed page slice also adds a
|
| 265 |
+
mock-LLM end-to-end spec (`tests/e2e/mock-llm/canvas-extensions/`) that drives
|
| 266 |
+
the production build through install → enable → page render → disable →
|
| 267 |
+
uninstall. Until the pinned agent-server ships the endpoints, the spec serves
|
| 268 |
+
the API contract from `src/fixtures/canvas-extensions/demo-page` through
|
| 269 |
+
Playwright route interception; once it does, the stub is removed and the same
|
| 270 |
+
steps run against the real backend.
|
| 271 |
+
Changing the mock-LLM feature layout or mapping requires updating `AGENTS.md` in
|
| 272 |
+
the same change. Before merge, run `npm run lint`, `npm test`, `npm run build`,
|
| 273 |
+
and `npm run build:lib`.
|
| 274 |
+
|
| 275 |
+
## Explicit non-goals for v1
|
| 276 |
+
|
| 277 |
+
- iframe/worker isolation or enforceable fine-grained permissions
|
| 278 |
+
- arbitrary lifecycle scripts during installation
|
| 279 |
+
- automatic enablement by agents
|
| 280 |
+
- marketplace ranking or publisher signing
|
| 281 |
+
- conversation tabs, slots, or visualizer replacement in the first vertical
|
| 282 |
+
slice
|
| 283 |
+
- treating `@openhands/extensions` as an installable runtime format
|
specs/llm-defaults.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LLM Defaults Specs
|
| 2 |
+
|
| 3 |
+
---
|
| 4 |
+
|
| 5 |
+
### LLD-001: Frontend always sends its chosen default model
|
| 6 |
+
|
| 7 |
+
- [x] When the agent-server returns an absent or empty `llm.model` (e.g. because the user has never saved settings), the frontend adapter shall substitute `DEFAULT_SETTINGS.llm_model` (`"openai/gpt-5.6-sol"`) before sending the conversation-start request.
|
| 8 |
+
- [x] The frontend shall never rely on the agent-server SDK's own default model (`gpt-5.5`); it shall always send an explicit model value.
|
| 9 |
+
- [x] Whitespace-only model strings shall be treated as absent and fall back to the default.
|
| 10 |
+
- [x] The same guard applies when LLM settings arrive via `encryptedAgentSettings` (the conversation-start encrypted payload path).
|
specs/mcp-settings.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MCP Settings Specs
|
| 2 |
+
|
| 3 |
+
---
|
| 4 |
+
|
| 5 |
+
### MCP-001: Sparse mutations preserve sibling servers
|
| 6 |
+
|
| 7 |
+
- [x] Adding, updating, or deleting an MCP server shall issue exactly one
|
| 8 |
+
dedicated MCP settings request containing only the affected server.
|
| 9 |
+
- [x] An MCP mutation shall never use redacted or encrypted settings snapshots
|
| 10 |
+
as its mutation base.
|
| 11 |
+
- [x] Untouched sibling servers and credentials shall survive add, update,
|
| 12 |
+
delete, concurrent different-key updates, and failed mutations.
|
| 13 |
+
|
| 14 |
+
### MCP-002: Secret patches preserve user intent
|
| 15 |
+
|
| 16 |
+
- [x] Omitting an unchanged secret shall preserve its stored value.
|
| 17 |
+
- [x] Supplying a secret shall replace its stored value.
|
| 18 |
+
- [x] Explicitly clearing a supported secret field shall send `null`.
|
| 19 |
+
- [x] The display-only redaction sentinel shall never be sent as mutation data.
|
| 20 |
+
|
| 21 |
+
### MCP-003: Settings map keys are stable MCP identities
|
| 22 |
+
|
| 23 |
+
- [x] Rendering order and transport grouping shall not change persistence
|
| 24 |
+
identity.
|
| 25 |
+
- [x] Deletion shall address the settings map key without matching URL, command,
|
| 26 |
+
or arguments.
|
| 27 |
+
- [x] Rename shall use one atomic map patch, reject collisions, and reject
|
| 28 |
+
hidden-secret renames that could lose credentials.
|
specs/workspace-upload-path.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Workspace Upload Path Specs
|
| 2 |
+
|
| 3 |
+
---
|
| 4 |
+
|
| 5 |
+
### WUP-001: Relative working dirs are resolved against `/api/file/home`, not the filesystem root
|
| 6 |
+
- [x] When the frontend creates a conversation and the resolved working dir is **relative** (e.g. the `DEFAULT_WORKING_DIR = "workspace/project"` fallback), `AgentServerConversationService.createConversation` shall resolve it to an **absolute** path before sending `workspace.working_dir` to the agent-server, by prefixing the agent-server's home directory as returned by `GET /api/file/home`.
|
| 7 |
+
- [x] When the frontend uploads a file, `buildWorkspaceUploadPath` shall resolve the conversation's working dir through the same home-directory anchor, so the upload destination always matches the conversation's worktree location.
|
| 8 |
+
- [x] When the working dir is already absolute (e.g. POSIX `/foo`, Windows `C:\foo`, or the explicit selection from `search_subdirs`), the resolver shall pass it through unchanged.
|
| 9 |
+
- [x] The home-directory lookup shall be cached per backend host so concurrent uploads share a single in-flight `/api/file/home` request, and a cached value is reused for subsequent uploads.
|
| 10 |
+
- [x] A failed lookup shall not be cached so the next call retries fresh.
|
| 11 |
+
- [x] Upload paths shall never be constructed by naively prepending `/`; the legacy `toAbsoluteWorkspacePath` helper that did so was removed once its last callers disappeared (recoverable from git history), and the home-anchored resolver is the only sanctioned mechanism.
|
| 12 |
+
|
| 13 |
+
### Why this exists
|
| 14 |
+
- The agent-server's `/api/file/upload` endpoint requires an absolute path and `mkdir -p`s the parent of the destination. Naively prepending `/` to the default `workspace/project/<hex>` produces `/workspace/project/<hex>`. On macOS and on fresh Docker images that mount only `/home/<user>` as writable, the filesystem root is read-only, so the upload fails with `OSError: [Errno 30] Read-only file system: '/workspace'`.
|
| 15 |
+
- The agent-server otherwise interprets a relative `workspace.working_dir` against its process CWD (which is whichever directory the launcher used), so absent this resolver, the conversation's worktree lands in one place and the upload tries to land in a totally different place.
|
| 16 |
+
- `/api/file/home` is the most reliable absolute, writable anchor the agent-server API currently exposes; `/server_info` does not include the CWD.
|
stryker.config.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// @ts-check
|
| 2 |
+
|
| 3 |
+
/** @type {import("@stryker-mutator/api/core").PartialStrykerOptions} */
|
| 4 |
+
const config = {
|
| 5 |
+
testRunner: "vitest",
|
| 6 |
+
mutate: [
|
| 7 |
+
"src/**/*.{ts,tsx}",
|
| 8 |
+
"!src/**/*.{test,spec}.{ts,tsx}",
|
| 9 |
+
"!src/**/__tests__/**",
|
| 10 |
+
"!src/**/*.d.ts",
|
| 11 |
+
"!src/**/*.types.ts",
|
| 12 |
+
"!src/**/*.{gen,generated}.{ts,tsx}",
|
| 13 |
+
"!src/i18n/declaration.ts",
|
| 14 |
+
"!src/{fixtures,mocks,dev}/**",
|
| 15 |
+
],
|
| 16 |
+
vitest: {
|
| 17 |
+
configFile: "vite.config.ts",
|
| 18 |
+
related: true,
|
| 19 |
+
},
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
export default config;
|
tailwind.config.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
import typography from "@tailwindcss/typography";
|
| 3 |
+
export default {
|
| 4 |
+
darkMode: "class",
|
| 5 |
+
theme: {
|
| 6 |
+
extend: {
|
| 7 |
+
colors: {
|
| 8 |
+
modal: {
|
| 9 |
+
background: "#171717",
|
| 10 |
+
input: "#27272A",
|
| 11 |
+
primary: "#F3CE49",
|
| 12 |
+
secondary: "#737373",
|
| 13 |
+
muted: "#A3A3A3",
|
| 14 |
+
},
|
| 15 |
+
surface: {
|
| 16 |
+
DEFAULT: "#050505",
|
| 17 |
+
card: "#0a0a0a",
|
| 18 |
+
elevated: "#1a1a1a",
|
| 19 |
+
outline: "#171717",
|
| 20 |
+
background: "#262626",
|
| 21 |
+
divider: "#525252",
|
| 22 |
+
button: "#737373",
|
| 23 |
+
text: "#A3A3A3",
|
| 24 |
+
},
|
| 25 |
+
border: {
|
| 26 |
+
DEFAULT: "#242424",
|
| 27 |
+
hover: "#3a3a3a",
|
| 28 |
+
},
|
| 29 |
+
content: {
|
| 30 |
+
DEFAULT: "#fafafa",
|
| 31 |
+
muted: "#8c8c8c",
|
| 32 |
+
icon: "#3a3a3a",
|
| 33 |
+
},
|
| 34 |
+
status: {
|
| 35 |
+
"success-bg": "rgba(16, 185, 129, 0.1)",
|
| 36 |
+
"success-border": "rgba(16, 185, 129, 0.4)",
|
| 37 |
+
"success-text": "#6ee7b7",
|
| 38 |
+
"success-badge-bg": "rgba(16, 185, 129, 0.15)",
|
| 39 |
+
"fail-bg": "rgba(244, 63, 94, 0.1)",
|
| 40 |
+
"fail-border": "rgba(244, 63, 94, 0.4)",
|
| 41 |
+
"fail-text": "#fda4af",
|
| 42 |
+
"fail-solid": "#dc2626",
|
| 43 |
+
"fail-solid-hover": "#b91c1c",
|
| 44 |
+
},
|
| 45 |
+
toggle: {
|
| 46 |
+
active: "#34d399",
|
| 47 |
+
"active-bg": "rgba(52, 211, 153, 0.2)",
|
| 48 |
+
"active-border": "rgba(52, 211, 153, 0.5)",
|
| 49 |
+
inactive: "#242424",
|
| 50 |
+
"inactive-knob": "#8c8c8c",
|
| 51 |
+
"inactive-border": "#3a3a3a",
|
| 52 |
+
},
|
| 53 |
+
"muted-overlay": "rgba(5, 5, 5, 0.4)",
|
| 54 |
+
"pill-bg": "rgba(31, 31, 31, 0.3)",
|
| 55 |
+
},
|
| 56 |
+
},
|
| 57 |
+
},
|
| 58 |
+
plugins: [typography],
|
| 59 |
+
};
|
test-utils.tsx
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { PropsWithChildren } from "react";
|
| 2 |
+
import { RenderOptions, render } from "@testing-library/react";
|
| 3 |
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
| 4 |
+
import { I18nextProvider, initReactI18next } from "react-i18next";
|
| 5 |
+
import i18n from "i18next";
|
| 6 |
+
import { vi } from "vitest";
|
| 7 |
+
import { AxiosError } from "axios";
|
| 8 |
+
import {
|
| 9 |
+
ActionEvent,
|
| 10 |
+
MessageEvent,
|
| 11 |
+
ObservationEvent,
|
| 12 |
+
PlanningFileEditorObservation,
|
| 13 |
+
} from "#/types/agent-server/core";
|
| 14 |
+
import {
|
| 15 |
+
NavigationProvider,
|
| 16 |
+
type NavigationContextValue,
|
| 17 |
+
} from "#/context/navigation-context";
|
| 18 |
+
import { SecurityRisk } from "#/types/agent-server/core";
|
| 19 |
+
|
| 20 |
+
export const useParamsMock = vi.fn(() => ({
|
| 21 |
+
conversationId: "test-conversation-id",
|
| 22 |
+
}));
|
| 23 |
+
|
| 24 |
+
// Mock useParams before importing components
|
| 25 |
+
vi.mock("react-router", async () => {
|
| 26 |
+
const actual =
|
| 27 |
+
await vi.importActual<typeof import("react-router")>("react-router");
|
| 28 |
+
return {
|
| 29 |
+
...actual,
|
| 30 |
+
useParams: useParamsMock,
|
| 31 |
+
useRevalidator: () => ({
|
| 32 |
+
revalidate: vi.fn(),
|
| 33 |
+
}),
|
| 34 |
+
};
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
// Initialize i18n for tests - return key when translation is missing
|
| 38 |
+
i18n.use(initReactI18next).init({
|
| 39 |
+
lng: "en",
|
| 40 |
+
fallbackLng: "en",
|
| 41 |
+
ns: ["translation", "openhands"],
|
| 42 |
+
defaultNS: "translation",
|
| 43 |
+
resources: {
|
| 44 |
+
en: {
|
| 45 |
+
translation: {},
|
| 46 |
+
openhands: {},
|
| 47 |
+
},
|
| 48 |
+
},
|
| 49 |
+
interpolation: {
|
| 50 |
+
escapeValue: false,
|
| 51 |
+
},
|
| 52 |
+
returnEmptyString: false,
|
| 53 |
+
missingKeyHandler: false,
|
| 54 |
+
saveMissing: false,
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
const createNavigationValue = (
|
| 58 |
+
overrides: Partial<NavigationContextValue> = {},
|
| 59 |
+
): NavigationContextValue => ({
|
| 60 |
+
currentPath: "/",
|
| 61 |
+
conversationId: "test-conversation-id",
|
| 62 |
+
isNavigating: false,
|
| 63 |
+
navigate: vi.fn(),
|
| 64 |
+
...overrides,
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
// This type interface extends the default options for render from RTL
|
| 68 |
+
interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> {
|
| 69 |
+
navigation?: Partial<NavigationContextValue>;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Export our own customized renderWithProviders function that renders with QueryClient and i18next providers
|
| 73 |
+
// Since we're using Zustand stores, we don't need a Redux Provider wrapper
|
| 74 |
+
export function renderWithProviders(
|
| 75 |
+
ui: React.ReactElement,
|
| 76 |
+
renderOptions: ExtendedRenderOptions = {},
|
| 77 |
+
) {
|
| 78 |
+
const { navigation, ...rtlRenderOptions } = renderOptions;
|
| 79 |
+
const navigationValue = createNavigationValue(navigation);
|
| 80 |
+
|
| 81 |
+
function Wrapper({ children }: PropsWithChildren) {
|
| 82 |
+
return (
|
| 83 |
+
<QueryClientProvider
|
| 84 |
+
client={
|
| 85 |
+
new QueryClient({
|
| 86 |
+
defaultOptions: { queries: { retry: false } },
|
| 87 |
+
})
|
| 88 |
+
}
|
| 89 |
+
>
|
| 90 |
+
<I18nextProvider i18n={i18n}>
|
| 91 |
+
<NavigationProvider value={navigationValue}>
|
| 92 |
+
{children}
|
| 93 |
+
</NavigationProvider>
|
| 94 |
+
</I18nextProvider>
|
| 95 |
+
</QueryClientProvider>
|
| 96 |
+
);
|
| 97 |
+
}
|
| 98 |
+
return render(ui, { wrapper: Wrapper, ...rtlRenderOptions });
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
export const createAxiosNotFoundErrorObject = () =>
|
| 102 |
+
new AxiosError(
|
| 103 |
+
"Request failed with status code 404",
|
| 104 |
+
"ERR_BAD_REQUEST",
|
| 105 |
+
undefined,
|
| 106 |
+
undefined,
|
| 107 |
+
{
|
| 108 |
+
status: 404,
|
| 109 |
+
statusText: "Not Found",
|
| 110 |
+
data: { message: "Settings not found" },
|
| 111 |
+
headers: {},
|
| 112 |
+
// @ts-expect-error - we only need the response object for this test
|
| 113 |
+
config: {},
|
| 114 |
+
},
|
| 115 |
+
);
|
| 116 |
+
|
| 117 |
+
export const createAxiosError = (
|
| 118 |
+
status: number,
|
| 119 |
+
statusText: string,
|
| 120 |
+
data: unknown,
|
| 121 |
+
) =>
|
| 122 |
+
new AxiosError(
|
| 123 |
+
`Request failed with status code ${status}`,
|
| 124 |
+
"ERR_BAD_REQUEST",
|
| 125 |
+
undefined,
|
| 126 |
+
undefined,
|
| 127 |
+
{
|
| 128 |
+
status,
|
| 129 |
+
statusText,
|
| 130 |
+
data,
|
| 131 |
+
headers: {},
|
| 132 |
+
// @ts-expect-error - we only need the response object for this test
|
| 133 |
+
config: {},
|
| 134 |
+
},
|
| 135 |
+
);
|
| 136 |
+
|
| 137 |
+
// Helper to create a PlanningFileEditorAction event
|
| 138 |
+
export const createPlanningFileEditorActionEvent = (
|
| 139 |
+
id: string,
|
| 140 |
+
): ActionEvent => ({
|
| 141 |
+
id,
|
| 142 |
+
timestamp: new Date().toISOString(),
|
| 143 |
+
source: "agent",
|
| 144 |
+
thought: [{ type: "text", text: "Planning action" }],
|
| 145 |
+
thinking_blocks: [],
|
| 146 |
+
action: {
|
| 147 |
+
kind: "PlanningFileEditorAction",
|
| 148 |
+
command: "create",
|
| 149 |
+
path: "/workspace/PLAN.md",
|
| 150 |
+
file_text: "Plan content",
|
| 151 |
+
old_str: null,
|
| 152 |
+
new_str: null,
|
| 153 |
+
insert_line: null,
|
| 154 |
+
view_range: null,
|
| 155 |
+
},
|
| 156 |
+
tool_name: "planning_file_editor",
|
| 157 |
+
tool_call_id: "call-1",
|
| 158 |
+
tool_call: {
|
| 159 |
+
id: "call-1",
|
| 160 |
+
type: "function",
|
| 161 |
+
function: {
|
| 162 |
+
name: "planning_file_editor",
|
| 163 |
+
arguments: '{"command": "create"}',
|
| 164 |
+
},
|
| 165 |
+
},
|
| 166 |
+
llm_response_id: "response-1",
|
| 167 |
+
security_risk: SecurityRisk.UNKNOWN,
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
// Helper to create a non-planning action event
|
| 171 |
+
export const createOtherActionEvent = (id: string): ActionEvent => ({
|
| 172 |
+
id,
|
| 173 |
+
timestamp: new Date().toISOString(),
|
| 174 |
+
source: "agent",
|
| 175 |
+
thought: [{ type: "text", text: "Other action" }],
|
| 176 |
+
thinking_blocks: [],
|
| 177 |
+
action: {
|
| 178 |
+
kind: "ExecuteBashAction",
|
| 179 |
+
command: "echo test",
|
| 180 |
+
is_input: false,
|
| 181 |
+
timeout: null,
|
| 182 |
+
reset: false,
|
| 183 |
+
},
|
| 184 |
+
tool_name: "execute_bash",
|
| 185 |
+
tool_call_id: "call-1",
|
| 186 |
+
tool_call: {
|
| 187 |
+
id: "call-1",
|
| 188 |
+
type: "function",
|
| 189 |
+
function: {
|
| 190 |
+
name: "execute_bash",
|
| 191 |
+
arguments: '{"command": "echo test"}',
|
| 192 |
+
},
|
| 193 |
+
},
|
| 194 |
+
llm_response_id: "response-1",
|
| 195 |
+
security_risk: SecurityRisk.UNKNOWN,
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
// Helper to create a PlanningFileEditorObservation event
|
| 199 |
+
export const createPlanningObservationEvent = (
|
| 200 |
+
id: string,
|
| 201 |
+
actionId: string = "action-1",
|
| 202 |
+
): ObservationEvent<PlanningFileEditorObservation> => ({
|
| 203 |
+
id,
|
| 204 |
+
timestamp: new Date().toISOString(),
|
| 205 |
+
source: "environment",
|
| 206 |
+
tool_name: "planning_file_editor",
|
| 207 |
+
tool_call_id: "call-1",
|
| 208 |
+
action_id: actionId,
|
| 209 |
+
observation: {
|
| 210 |
+
kind: "PlanningFileEditorObservation",
|
| 211 |
+
content: [{ type: "text", text: "Plan content" }],
|
| 212 |
+
is_error: false,
|
| 213 |
+
command: "create",
|
| 214 |
+
path: "/workspace/PLAN.md",
|
| 215 |
+
prev_exist: false,
|
| 216 |
+
old_content: null,
|
| 217 |
+
new_content: "Plan content",
|
| 218 |
+
},
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
// Helper to create a user message event
|
| 222 |
+
export const createUserMessageEvent = (id: string): MessageEvent => ({
|
| 223 |
+
id,
|
| 224 |
+
timestamp: new Date().toISOString(),
|
| 225 |
+
source: "user",
|
| 226 |
+
llm_message: {
|
| 227 |
+
role: "user",
|
| 228 |
+
content: [{ type: "text", text: "User message" }],
|
| 229 |
+
},
|
| 230 |
+
activated_skills: [],
|
| 231 |
+
extended_content: [],
|
| 232 |
+
});
|
tools/canvas_ui_tool.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Legacy conversation compatibility for the former Python Canvas UI tool.
|
| 2 |
+
|
| 3 |
+
New conversations define ``canvas_ui_control`` through the agent-server's
|
| 4 |
+
``client_tools`` JSON API. The distinct name avoids colliding with this
|
| 5 |
+
process-global legacy registration. This module remains importable because
|
| 6 |
+
persisted conversations store
|
| 7 |
+
``tool_module_qualnames = {"canvas_ui": "canvas_ui_tool"}`` and may contain
|
| 8 |
+
``CanvasUIAction`` / ``CanvasUIObservation`` events. Agent-server imports the
|
| 9 |
+
module before restoring those conversations so the legacy event kinds and tool
|
| 10 |
+
registration remain resolvable.
|
| 11 |
+
|
| 12 |
+
The server-side executor is a no-op that returns an acknowledgment. The actual
|
| 13 |
+
UI effect happens client-side: the frontend watches the WebSocket stream for
|
| 14 |
+
legacy ``canvas_ui`` and current ``canvas_ui_control`` ActionEvents and dispatches
|
| 15 |
+
the command.
|
| 16 |
+
|
| 17 |
+
The launchers also import this module at agent-server startup
|
| 18 |
+
(``--import-modules canvas_ui_tool``) so the builtin ``FinishTool`` registration
|
| 19 |
+
at the bottom runs before any conversation is created.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from collections.abc import Sequence
|
| 23 |
+
from typing import Literal
|
| 24 |
+
|
| 25 |
+
from pydantic import Field
|
| 26 |
+
|
| 27 |
+
from openhands.sdk import Action, Observation, ToolDefinition
|
| 28 |
+
from openhands.sdk.tool import (
|
| 29 |
+
FinishTool,
|
| 30 |
+
ToolAnnotations,
|
| 31 |
+
ToolExecutor,
|
| 32 |
+
list_registered_tools,
|
| 33 |
+
register_tool,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
CanvasCommand = Literal["navigate_to_file", "open_tab", "show_preview"]
|
| 38 |
+
CanvasTab = Literal[
|
| 39 |
+
"files", "browser", "vscode", "terminal", "planner", "tasklist"
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class CanvasUIAction(Action):
|
| 44 |
+
"""Direct the Agent Canvas frontend to perform a UI action."""
|
| 45 |
+
|
| 46 |
+
command: CanvasCommand = Field(description="UI command to dispatch.")
|
| 47 |
+
path: str | None = Field(
|
| 48 |
+
default=None,
|
| 49 |
+
description=(
|
| 50 |
+
"Workspace-relative file path. Required for navigate_to_file and "
|
| 51 |
+
"show_preview; ignored otherwise."
|
| 52 |
+
),
|
| 53 |
+
)
|
| 54 |
+
tab: CanvasTab | None = Field(
|
| 55 |
+
default=None,
|
| 56 |
+
description=(
|
| 57 |
+
"Tab to open. Required for open_tab; ignored otherwise. One of "
|
| 58 |
+
"files, browser, vscode, terminal, planner, tasklist."
|
| 59 |
+
),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class CanvasUIObservation(Observation):
|
| 64 |
+
"""Acknowledgment that the UI command was dispatched to the frontend."""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class CanvasUIExecutor(ToolExecutor[CanvasUIAction, CanvasUIObservation]):
|
| 68 |
+
def __call__(
|
| 69 |
+
self,
|
| 70 |
+
action: CanvasUIAction,
|
| 71 |
+
conversation=None, # noqa: ARG002
|
| 72 |
+
) -> CanvasUIObservation:
|
| 73 |
+
return CanvasUIObservation.from_text(
|
| 74 |
+
f"UI command '{action.command}' dispatched to the Agent Canvas frontend."
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
_CANVAS_UI_DESCRIPTION = """The user is interacting with you inside Agent Canvas — a web UI with a chat panel on the left and a tabbed right-side panel (files, terminal, browser, vscode, planner, tasklist). This tool lets you drive that right-side panel so the user sees what you just produced.
|
| 79 |
+
|
| 80 |
+
They will NOT see the files you wrote, the terminal output, or the browser
|
| 81 |
+
unless you call this tool to switch the right-side panel to the relevant
|
| 82 |
+
tab. Call this every time you finish work that produces something the user
|
| 83 |
+
should look at — don't rely on them noticing on their own.
|
| 84 |
+
|
| 85 |
+
When to call (pick the most specific option that matches your last action):
|
| 86 |
+
|
| 87 |
+
* You wrote or modified a single file (ANY language, ANY size — including
|
| 88 |
+
small scripts like a hello-world bash file) →
|
| 89 |
+
command="navigate_to_file", path=<workspace-relative path of that file>
|
| 90 |
+
|
| 91 |
+
* You generated an HTML page, image, SVG, PDF, markdown report, or other
|
| 92 |
+
previewable artifact →
|
| 93 |
+
command="show_preview", path=<that file>
|
| 94 |
+
|
| 95 |
+
* You finished editing multiple files in one logical step →
|
| 96 |
+
command="open_tab", tab="files"
|
| 97 |
+
(The Files tab automatically renders a diff view when the workspace has
|
| 98 |
+
uncommitted git changes, which covers the "highlight changes" case.)
|
| 99 |
+
|
| 100 |
+
* You ran a long-running terminal command, or one whose output the user
|
| 101 |
+
should inspect →
|
| 102 |
+
command="open_tab", tab="terminal"
|
| 103 |
+
|
| 104 |
+
* You browsed to a URL the user should see →
|
| 105 |
+
First call browser_get_state(include_screenshot=true) after your final
|
| 106 |
+
browser interaction so Agent Canvas has a screenshot to display, then call
|
| 107 |
+
command="open_tab", tab="browser"
|
| 108 |
+
(browser_navigate alone only updates the URL; without browser_get_state,
|
| 109 |
+
the Browser tab will open without a screenshot.)
|
| 110 |
+
|
| 111 |
+
Call this BEFORE writing your chat-message summary of the change, so the
|
| 112 |
+
artifact is visible while the user reads what you did. One canvas_ui call
|
| 113 |
+
per logical step is enough — don't repeat it for the same file or tab in
|
| 114 |
+
the same turn."""
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class CanvasUITool(ToolDefinition[CanvasUIAction, CanvasUIObservation]):
|
| 118 |
+
"""Tool for controlling the Agent Canvas UI from the agent."""
|
| 119 |
+
|
| 120 |
+
@classmethod
|
| 121 |
+
def create(
|
| 122 |
+
cls,
|
| 123 |
+
conv_state=None, # noqa: ARG003
|
| 124 |
+
**params, # noqa: ARG003
|
| 125 |
+
) -> Sequence["CanvasUITool"]:
|
| 126 |
+
return [
|
| 127 |
+
cls(
|
| 128 |
+
description=_CANVAS_UI_DESCRIPTION,
|
| 129 |
+
action_type=CanvasUIAction,
|
| 130 |
+
observation_type=CanvasUIObservation,
|
| 131 |
+
executor=CanvasUIExecutor(),
|
| 132 |
+
annotations=ToolAnnotations(
|
| 133 |
+
readOnlyHint=True,
|
| 134 |
+
destructiveHint=False,
|
| 135 |
+
idempotentHint=True,
|
| 136 |
+
openWorldHint=False,
|
| 137 |
+
),
|
| 138 |
+
)
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# Persisted pre-client_tools conversations import this module by qualname before
|
| 143 |
+
# restoring their agent and events. Keep the registration until those records
|
| 144 |
+
# have a server-side migration path.
|
| 145 |
+
register_tool("canvas_ui", CanvasUITool)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# openhands-automation >= 1.9.0 preset entrypoints build their agent with
|
| 149 |
+
# get_default_agent(finish_tool_response_schema=TaskOutcome). That registers the
|
| 150 |
+
# SDK's builtin FinishTool only inside the entrypoint's own process and
|
| 151 |
+
# advertises it to the agent-server as `openhands.sdk.tool.builtins.finish` — a
|
| 152 |
+
# module that does not self-register — so the remote conversation every Agent
|
| 153 |
+
# Canvas automation run dispatches fails with "ToolDefinition 'FinishTool' is
|
| 154 |
+
# not registered". Registering the plain builtin here is enough: resolve_tool()
|
| 155 |
+
# strips `response_schema` before FinishTool.create() and re-applies it. Drop
|
| 156 |
+
# this once the SDK registers its builtins for remote conversations.
|
| 157 |
+
if FinishTool.__name__ not in list_registered_tools():
|
| 158 |
+
register_tool(FinishTool.__name__, FinishTool)
|
tsconfig.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"include": [
|
| 3 |
+
"**/*.ts",
|
| 4 |
+
"**/*.tsx",
|
| 5 |
+
"**/.server/**/*.ts",
|
| 6 |
+
"**/.server/**/*.tsx",
|
| 7 |
+
"**/.client/**/*.ts",
|
| 8 |
+
"**/.client/**/*.tsx",
|
| 9 |
+
".react-router/types/**/*"
|
| 10 |
+
],
|
| 11 |
+
"compilerOptions": {
|
| 12 |
+
"lib": ["dom", "dom.iterable", "es2022"],
|
| 13 |
+
"target": "es2022",
|
| 14 |
+
"types": ["@react-router/node", "vite/client"],
|
| 15 |
+
"rootDirs": [".", "./.react-router/types"],
|
| 16 |
+
"allowJs": true,
|
| 17 |
+
"skipLibCheck": true,
|
| 18 |
+
"esModuleInterop": true,
|
| 19 |
+
"allowSyntheticDefaultImports": true,
|
| 20 |
+
"strict": true,
|
| 21 |
+
"forceConsistentCasingInFileNames": true,
|
| 22 |
+
"noFallthroughCasesInSwitch": true,
|
| 23 |
+
"ignoreDeprecations": "6.0",
|
| 24 |
+
"module": "esnext",
|
| 25 |
+
"moduleResolution": "bundler",
|
| 26 |
+
"resolveJsonModule": true,
|
| 27 |
+
"isolatedModules": true,
|
| 28 |
+
"jsx": "react-jsx",
|
| 29 |
+
"baseUrl": ".",
|
| 30 |
+
"paths": {
|
| 31 |
+
"#/*": ["./src/*"]
|
| 32 |
+
},
|
| 33 |
+
// Vite takes care of building everything, not tsc.
|
| 34 |
+
"noEmit": true
|
| 35 |
+
}
|
| 36 |
+
}
|
tsconfig.lib.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"extends": "./tsconfig.json",
|
| 3 |
+
"include": [
|
| 4 |
+
"src/index.ts",
|
| 5 |
+
"src/**/*.d.ts",
|
| 6 |
+
"src/lib/**/*.ts",
|
| 7 |
+
"src/lib/**/*.tsx",
|
| 8 |
+
"src/components/**/*.ts",
|
| 9 |
+
"src/components/**/*.tsx",
|
| 10 |
+
"src/query-client-config.ts",
|
| 11 |
+
"src/i18n/**/*.ts",
|
| 12 |
+
"src/i18n/**/*.tsx",
|
| 13 |
+
"src/routes/conversation.tsx",
|
| 14 |
+
"src/routes/app-settings.tsx",
|
| 15 |
+
"src/routes/llm-settings.tsx",
|
| 16 |
+
"src/routes/mcp-settings.tsx",
|
| 17 |
+
"src/routes/secrets-settings.tsx"
|
| 18 |
+
],
|
| 19 |
+
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
| 20 |
+
"compilerOptions": {
|
| 21 |
+
"noEmit": false,
|
| 22 |
+
"declaration": true,
|
| 23 |
+
"emitDeclarationOnly": true,
|
| 24 |
+
"declarationMap": false,
|
| 25 |
+
"outDir": "dist",
|
| 26 |
+
"rootDir": "src",
|
| 27 |
+
"types": [
|
| 28 |
+
"@react-router/node",
|
| 29 |
+
"vite/client",
|
| 30 |
+
"vite-plugin-svgr/client",
|
| 31 |
+
"node"
|
| 32 |
+
]
|
| 33 |
+
}
|
| 34 |
+
}
|
vercel.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://openapi.vercel.sh/vercel.json",
|
| 3 |
+
"installCommand": "bash scripts/vercel-install.sh"
|
| 4 |
+
}
|
vite-env.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="vite/client" />
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Absolute filesystem path to the bundled extensions skills directory,
|
| 5 |
+
* injected by Vite at build time via `define` in vite.config.ts.
|
| 6 |
+
* Empty string in library builds; always a real path in app/dev builds.
|
| 7 |
+
*/
|
| 8 |
+
declare const __EXTENSIONS_SKILLS_DIR__: string;
|
vite.config.ts
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/// <reference types="vitest" />
|
| 2 |
+
/// <reference types="vite-plugin-svgr/client" />
|
| 3 |
+
import { fileURLToPath } from "node:url";
|
| 4 |
+
import { createRequire } from "node:module";
|
| 5 |
+
import { readFile } from "node:fs/promises";
|
| 6 |
+
import { resolve, dirname, relative, isAbsolute } from "node:path";
|
| 7 |
+
import { defineConfig, loadEnv } from "vite";
|
| 8 |
+
import svgr from "vite-plugin-svgr";
|
| 9 |
+
import { reactRouter } from "@react-router/dev/vite";
|
| 10 |
+
import { configDefaults } from "vitest/config";
|
| 11 |
+
import tailwindcss from "@tailwindcss/vite";
|
| 12 |
+
import prefixer from "postcss-prefix-selector";
|
| 13 |
+
import {
|
| 14 |
+
AGENT_SERVER_UI_SCOPE_SELECTOR,
|
| 15 |
+
transformAgentServerUISelector,
|
| 16 |
+
} from "./src/styles/agent-server-ui-style-scope";
|
| 17 |
+
|
| 18 |
+
const LIB_ENTRY = fileURLToPath(new URL("./src/index.ts", import.meta.url));
|
| 19 |
+
const LIB_EXTERNALS = [
|
| 20 |
+
"react",
|
| 21 |
+
"react-dom",
|
| 22 |
+
"react/jsx-runtime",
|
| 23 |
+
"react/jsx-dev-runtime",
|
| 24 |
+
"react-router",
|
| 25 |
+
];
|
| 26 |
+
const APP_CHUNK_MAX_BYTES = 450 * 1024;
|
| 27 |
+
|
| 28 |
+
const normalizeBasePath = (value?: string) => {
|
| 29 |
+
const raw = value?.trim();
|
| 30 |
+
if (!raw || raw === "/") return "/";
|
| 31 |
+
|
| 32 |
+
const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`;
|
| 33 |
+
return `${withLeadingSlash.replace(/\/+$/, "")}/`;
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
// Absolute path to the bundled extensions skills directory in node_modules.
|
| 37 |
+
// Injected as __EXTENSIONS_SKILLS_DIR__ so agent-server-adapter.ts can pass
|
| 38 |
+
// real filesystem paths to the Python agent-server (which uses them to
|
| 39 |
+
// resolve bundled skill resources like scripts/ and references/).
|
| 40 |
+
const _require = createRequire(import.meta.url);
|
| 41 |
+
const EXTENSIONS_SKILLS_DIR = resolve(
|
| 42 |
+
dirname(_require.resolve("@openhands/extensions/package.json")),
|
| 43 |
+
"skills",
|
| 44 |
+
);
|
| 45 |
+
const PUBLIC_LOCALES_DIR = resolve(process.cwd(), "public", "locales");
|
| 46 |
+
|
| 47 |
+
const appBuildConfig = {
|
| 48 |
+
rolldownOptions: {
|
| 49 |
+
output: {
|
| 50 |
+
codeSplitting: {
|
| 51 |
+
groups: [
|
| 52 |
+
{
|
| 53 |
+
// Keep the styling core (HeroUI + tailwind-variants + its
|
| 54 |
+
// tailwind-merge/clsx deps) in one un-size-split chunk. When the
|
| 55 |
+
// generic size-split vendor group slices these apart, a HeroUI
|
| 56 |
+
// component's top-level `tv()` recipe can evaluate before
|
| 57 |
+
// tailwind-variants' core is initialized, throwing
|
| 58 |
+
// "TypeError: s is not a function" at module load and blanking the
|
| 59 |
+
// whole app (root-layout module fails to load → reload loop).
|
| 60 |
+
name: "vendor-styling",
|
| 61 |
+
test: /node_modules[\\/](@heroui[\\/]|tailwind-variants[\\/]|tailwind-merge[\\/]|clsx[\\/])/,
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
// Keep the markdown / syntax-highlight ecosystem
|
| 65 |
+
// (react-syntax-highlighter + refractor/lowlight/highlight.js and
|
| 66 |
+
// the unified/hast/mdast/micromark/vfile module tree it pulls in)
|
| 67 |
+
// in one un-size-split chunk. Same failure mode as vendor-styling
|
| 68 |
+
// above: once the file-viewer (HighlightedSourceView) is imported
|
| 69 |
+
// from a second route, the generic size-split vendor group slices
|
| 70 |
+
// this tree across chunk boundaries, so a vfile/unified module's
|
| 71 |
+
// interop `require` shim can evaluate before the chunk that defines
|
| 72 |
+
// it, throwing "TypeError: i is not a function" at module load and
|
| 73 |
+
// blanking the whole app (the shared home/conversation/files-tab
|
| 74 |
+
// chunk fails to load → route-module reload loop).
|
| 75 |
+
name: "vendor-markdown",
|
| 76 |
+
test: /node_modules[\\/](react-syntax-highlighter[\\/]|refractor[\\/]|lowlight[\\/]|highlight\.js[\\/]|hastscript[\\/]|(hast|mdast|unist|micromark|remark|rehype)[^\\/]*[\\/]|unified[\\/]|vfile[^\\/]*[\\/]|property-information[\\/]|(comma|space)-separated-tokens[\\/]|character-entities[^\\/]*[\\/]|(parse|stringify)-entities[\\/]|decode-named-character-reference[\\/]|bail[\\/]|trough[\\/]|is-plain-obj[\\/]|zwitch[\\/]|longest-streak[\\/]|ccount[\\/]|escape-string-regexp[\\/]|markdown-table[\\/]|devlop[\\/])/,
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
name: "vendor",
|
| 80 |
+
test: /node_modules[\\/]/,
|
| 81 |
+
maxSize: APP_CHUNK_MAX_BYTES,
|
| 82 |
+
minSize: 20 * 1024,
|
| 83 |
+
entriesAware: true,
|
| 84 |
+
},
|
| 85 |
+
],
|
| 86 |
+
},
|
| 87 |
+
},
|
| 88 |
+
},
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
export default defineConfig(({ mode }) => {
|
| 92 |
+
const {
|
| 93 |
+
VITE_BACKEND_HOST = "127.0.0.1:8000",
|
| 94 |
+
VITE_USE_TLS = "false",
|
| 95 |
+
VITE_FRONTEND_PORT = "3001",
|
| 96 |
+
VITE_INSECURE_SKIP_VERIFY = "false",
|
| 97 |
+
VITE_BASE_PATH,
|
| 98 |
+
VITE_VSCODE_BASE_PATH,
|
| 99 |
+
VITE_VSCODE_TARGET,
|
| 100 |
+
// Runtime-services metadata for the dev server, passed by launchers that
|
| 101 |
+
// run the Vite dev server directly (e.g. dev:minimal). Unlike
|
| 102 |
+
// ingress/static-server, the Vite proxy cannot post-process the upstream
|
| 103 |
+
// /server_info response, so the launcher serializes the info here and the
|
| 104 |
+
// middleware below merges it into the proxied response.
|
| 105 |
+
VITE_RUNTIME_SERVICES_INFO,
|
| 106 |
+
} = loadEnv(mode, process.cwd());
|
| 107 |
+
|
| 108 |
+
const isLibraryBuild = process.env.BUILD_LIB === "true";
|
| 109 |
+
const USE_TLS = VITE_USE_TLS === "true";
|
| 110 |
+
const INSECURE_SKIP_VERIFY = VITE_INSECURE_SKIP_VERIFY === "true";
|
| 111 |
+
const PROTOCOL = USE_TLS ? "https" : "http";
|
| 112 |
+
const WS_PROTOCOL = USE_TLS ? "wss" : "ws";
|
| 113 |
+
|
| 114 |
+
const API_URL = `${PROTOCOL}://${VITE_BACKEND_HOST}/`;
|
| 115 |
+
const WS_URL = `${WS_PROTOCOL}://${VITE_BACKEND_HOST}/`;
|
| 116 |
+
const FE_PORT = Number.parseInt(VITE_FRONTEND_PORT, 10);
|
| 117 |
+
const base = normalizeBasePath(VITE_BASE_PATH);
|
| 118 |
+
|
| 119 |
+
return {
|
| 120 |
+
base,
|
| 121 |
+
define: {
|
| 122 |
+
// Empty string for library builds so consumers aren't bound to this
|
| 123 |
+
// machine's node_modules path; agent-server-adapter falls back to
|
| 124 |
+
// "public" when the value is falsy.
|
| 125 |
+
__EXTENSIONS_SKILLS_DIR__: JSON.stringify(
|
| 126 |
+
isLibraryBuild ? "" : EXTENSIONS_SKILLS_DIR,
|
| 127 |
+
),
|
| 128 |
+
},
|
| 129 |
+
plugins: [
|
| 130 |
+
{
|
| 131 |
+
name: "suppress-chrome-devtools-well-known",
|
| 132 |
+
apply: "serve",
|
| 133 |
+
configureServer(server) {
|
| 134 |
+
server.middlewares.use(
|
| 135 |
+
"/.well-known/appspecific/com.chrome.devtools.json",
|
| 136 |
+
(_req, res) => {
|
| 137 |
+
res.writeHead(204);
|
| 138 |
+
res.end();
|
| 139 |
+
},
|
| 140 |
+
);
|
| 141 |
+
},
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
name: "serve-generated-i18n-locales",
|
| 145 |
+
apply: "serve",
|
| 146 |
+
configureServer(server) {
|
| 147 |
+
server.middlewares.use(async (req, res, next) => {
|
| 148 |
+
const method = req.method ?? "GET";
|
| 149 |
+
if (method !== "GET" && method !== "HEAD") {
|
| 150 |
+
next();
|
| 151 |
+
return;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
const pathname = new URL(req.url ?? "", "http://localhost")
|
| 155 |
+
.pathname;
|
| 156 |
+
if (
|
| 157 |
+
!pathname.startsWith("/locales/") ||
|
| 158 |
+
!pathname.endsWith(".json")
|
| 159 |
+
) {
|
| 160 |
+
next();
|
| 161 |
+
return;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
const requestedPath = decodeURIComponent(
|
| 165 |
+
pathname.slice("/locales/".length),
|
| 166 |
+
);
|
| 167 |
+
const filePath = resolve(PUBLIC_LOCALES_DIR, requestedPath);
|
| 168 |
+
const relativePath = relative(PUBLIC_LOCALES_DIR, filePath);
|
| 169 |
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
| 170 |
+
res.writeHead(403);
|
| 171 |
+
res.end();
|
| 172 |
+
return;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
try {
|
| 176 |
+
const content = await readFile(filePath);
|
| 177 |
+
res.writeHead(200, {
|
| 178 |
+
"Content-Type": "application/json; charset=utf-8",
|
| 179 |
+
"Cache-Control": "no-cache",
|
| 180 |
+
});
|
| 181 |
+
res.end(method === "HEAD" ? "" : content);
|
| 182 |
+
} catch {
|
| 183 |
+
next();
|
| 184 |
+
}
|
| 185 |
+
});
|
| 186 |
+
},
|
| 187 |
+
},
|
| 188 |
+
!process.env.VITEST && !isLibraryBuild && reactRouter(),
|
| 189 |
+
svgr(),
|
| 190 |
+
tailwindcss(),
|
| 191 |
+
],
|
| 192 |
+
resolve: {
|
| 193 |
+
tsconfigPaths: true,
|
| 194 |
+
},
|
| 195 |
+
css: {
|
| 196 |
+
postcss: {
|
| 197 |
+
plugins: [
|
| 198 |
+
prefixer({
|
| 199 |
+
prefix: AGENT_SERVER_UI_SCOPE_SELECTOR,
|
| 200 |
+
transform(
|
| 201 |
+
prefix: string,
|
| 202 |
+
selector: string,
|
| 203 |
+
prefixedSelector: string,
|
| 204 |
+
) {
|
| 205 |
+
return transformAgentServerUISelector(
|
| 206 |
+
prefix,
|
| 207 |
+
selector,
|
| 208 |
+
prefixedSelector,
|
| 209 |
+
);
|
| 210 |
+
},
|
| 211 |
+
}),
|
| 212 |
+
],
|
| 213 |
+
},
|
| 214 |
+
},
|
| 215 |
+
build: isLibraryBuild
|
| 216 |
+
? {
|
| 217 |
+
outDir: "dist",
|
| 218 |
+
emptyOutDir: true,
|
| 219 |
+
sourcemap: true,
|
| 220 |
+
lib: {
|
| 221 |
+
entry: LIB_ENTRY,
|
| 222 |
+
formats: ["es"],
|
| 223 |
+
},
|
| 224 |
+
rollupOptions: {
|
| 225 |
+
external: LIB_EXTERNALS,
|
| 226 |
+
output: [
|
| 227 |
+
{
|
| 228 |
+
format: "es",
|
| 229 |
+
preserveModules: true,
|
| 230 |
+
preserveModulesRoot: "src",
|
| 231 |
+
entryFileNames: "[name].js",
|
| 232 |
+
chunkFileNames: "chunks/[name]-[hash].js",
|
| 233 |
+
assetFileNames: "assets/[name]-[hash][extname]",
|
| 234 |
+
},
|
| 235 |
+
{
|
| 236 |
+
format: "cjs",
|
| 237 |
+
preserveModules: true,
|
| 238 |
+
preserveModulesRoot: "src",
|
| 239 |
+
entryFileNames: "[name].cjs",
|
| 240 |
+
chunkFileNames: "chunks/[name]-[hash].cjs",
|
| 241 |
+
assetFileNames: "assets/[name]-[hash][extname]",
|
| 242 |
+
exports: "named",
|
| 243 |
+
},
|
| 244 |
+
],
|
| 245 |
+
},
|
| 246 |
+
}
|
| 247 |
+
: appBuildConfig,
|
| 248 |
+
copyPublicDir: !isLibraryBuild,
|
| 249 |
+
optimizeDeps: {
|
| 250 |
+
// Disable discovery of new deps at runtime. All deps must be listed in
|
| 251 |
+
// `include`. This prevents Vite from returning 504s while optimizing
|
| 252 |
+
// newly-discovered deps - Safari doesn't retry like Chrome does.
|
| 253 |
+
noDiscovery: true,
|
| 254 |
+
include: [
|
| 255 |
+
// Pre-bundle client entry dependencies so the first page load does not 504
|
| 256 |
+
// with Vite's "Outdated Optimize Dep" before hydration finishes.
|
| 257 |
+
// Safari is particularly sensitive to runtime dep optimization - it gets
|
| 258 |
+
// stuck in 504 errors when Vite triggers "optimized dependencies changed. reloading"
|
| 259 |
+
"react",
|
| 260 |
+
"react/jsx-runtime",
|
| 261 |
+
"react-dom/client",
|
| 262 |
+
"react-router/dom",
|
| 263 |
+
// Pre-bundle ALL dependencies to prevent runtime optimization and page reloads
|
| 264 |
+
// These are discovered during initial app load:
|
| 265 |
+
"posthog-js",
|
| 266 |
+
"@tanstack/react-query",
|
| 267 |
+
"react-hot-toast",
|
| 268 |
+
"i18next",
|
| 269 |
+
"i18next-http-backend",
|
| 270 |
+
"i18next-browser-languagedetector",
|
| 271 |
+
"react-i18next",
|
| 272 |
+
"axios",
|
| 273 |
+
"@uidotdev/usehooks",
|
| 274 |
+
"react-icons/fa6",
|
| 275 |
+
"react-icons/fa",
|
| 276 |
+
"clsx",
|
| 277 |
+
"tailwind-merge",
|
| 278 |
+
// CJS dependencies used by react-transition-group. Without pre-bundling,
|
| 279 |
+
// Vite can serve them directly to the browser before route hydration.
|
| 280 |
+
"prop-types",
|
| 281 |
+
"react-is",
|
| 282 |
+
"@heroui/react",
|
| 283 |
+
"lucide-react",
|
| 284 |
+
"@microlink/react-json-view",
|
| 285 |
+
"socket.io-client",
|
| 286 |
+
// These are discovered when launching conversations:
|
| 287 |
+
"react-icons/vsc",
|
| 288 |
+
"react-icons/lu",
|
| 289 |
+
"react-icons/di",
|
| 290 |
+
"react-icons/io5",
|
| 291 |
+
"react-icons/io",
|
| 292 |
+
"@monaco-editor/react",
|
| 293 |
+
"react-textarea-autosize",
|
| 294 |
+
"react-markdown",
|
| 295 |
+
"remark-gfm",
|
| 296 |
+
"remark-breaks",
|
| 297 |
+
"react-syntax-highlighter",
|
| 298 |
+
"react-syntax-highlighter/dist/esm/styles/prism",
|
| 299 |
+
"react-syntax-highlighter/dist/esm/styles/hljs",
|
| 300 |
+
// Terminal dependencies - added to prevent runtime optimization
|
| 301 |
+
"@xterm/addon-fit",
|
| 302 |
+
"@xterm/xterm",
|
| 303 |
+
"@xterm/xterm/css/xterm.css",
|
| 304 |
+
// OpenHands typescript client
|
| 305 |
+
"@openhands/typescript-client",
|
| 306 |
+
"@openhands/typescript-client/client/http-client",
|
| 307 |
+
"@openhands/typescript-client/client/device-flow-client",
|
| 308 |
+
"@openhands/typescript-client/clients",
|
| 309 |
+
"@openhands/typescript-client/events/remote-events-list",
|
| 310 |
+
"@openhands/typescript-client/workspace/remote-workspace",
|
| 311 |
+
// Additional dependencies discovered at runtime
|
| 312 |
+
"class-variance-authority",
|
| 313 |
+
"downshift",
|
| 314 |
+
"framer-motion",
|
| 315 |
+
"rehype-raw",
|
| 316 |
+
"rehype-sanitize",
|
| 317 |
+
// ``shell-quote`` is a CJS module used by ``src/utils/acp-command.ts``
|
| 318 |
+
// for the Settings → Agent textarea. With ``noDiscovery: true`` above,
|
| 319 |
+
// omitting it from this list means Vite serves the raw CJS file to
|
| 320 |
+
// the browser and dev crashes with ``ReferenceError: exports is not
|
| 321 |
+
// defined`` on the first import of agent-settings.tsx.
|
| 322 |
+
"shell-quote",
|
| 323 |
+
"unist-util-visit",
|
| 324 |
+
"uuid",
|
| 325 |
+
"zustand",
|
| 326 |
+
"zustand/middleware",
|
| 327 |
+
// Syntax highlighter language definitions - Safari fails if these are
|
| 328 |
+
// optimized at runtime. Include all commonly used languages.
|
| 329 |
+
"react-syntax-highlighter/dist/esm/languages/prism/bash",
|
| 330 |
+
"react-syntax-highlighter/dist/esm/languages/prism/batch",
|
| 331 |
+
"react-syntax-highlighter/dist/esm/languages/prism/c",
|
| 332 |
+
"react-syntax-highlighter/dist/esm/languages/prism/clike",
|
| 333 |
+
"react-syntax-highlighter/dist/esm/languages/prism/clojure",
|
| 334 |
+
"react-syntax-highlighter/dist/esm/languages/prism/cpp",
|
| 335 |
+
"react-syntax-highlighter/dist/esm/languages/prism/csharp",
|
| 336 |
+
"react-syntax-highlighter/dist/esm/languages/prism/css",
|
| 337 |
+
"react-syntax-highlighter/dist/esm/languages/prism/dart",
|
| 338 |
+
"react-syntax-highlighter/dist/esm/languages/prism/diff",
|
| 339 |
+
"react-syntax-highlighter/dist/esm/languages/prism/docker",
|
| 340 |
+
"react-syntax-highlighter/dist/esm/languages/prism/elixir",
|
| 341 |
+
"react-syntax-highlighter/dist/esm/languages/prism/erlang",
|
| 342 |
+
"react-syntax-highlighter/dist/esm/languages/prism/fsharp",
|
| 343 |
+
"react-syntax-highlighter/dist/esm/languages/prism/go",
|
| 344 |
+
"react-syntax-highlighter/dist/esm/languages/prism/graphql",
|
| 345 |
+
"react-syntax-highlighter/dist/esm/languages/prism/groovy",
|
| 346 |
+
"react-syntax-highlighter/dist/esm/languages/prism/haskell",
|
| 347 |
+
"react-syntax-highlighter/dist/esm/languages/prism/hcl",
|
| 348 |
+
"react-syntax-highlighter/dist/esm/languages/prism/http",
|
| 349 |
+
"react-syntax-highlighter/dist/esm/languages/prism/ini",
|
| 350 |
+
"react-syntax-highlighter/dist/esm/languages/prism/java",
|
| 351 |
+
"react-syntax-highlighter/dist/esm/languages/prism/javascript",
|
| 352 |
+
"react-syntax-highlighter/dist/esm/languages/prism/json",
|
| 353 |
+
"react-syntax-highlighter/dist/esm/languages/prism/json5",
|
| 354 |
+
"react-syntax-highlighter/dist/esm/languages/prism/jsx",
|
| 355 |
+
"react-syntax-highlighter/dist/esm/languages/prism/julia",
|
| 356 |
+
"react-syntax-highlighter/dist/esm/languages/prism/kotlin",
|
| 357 |
+
"react-syntax-highlighter/dist/esm/languages/prism/less",
|
| 358 |
+
"react-syntax-highlighter/dist/esm/languages/prism/lua",
|
| 359 |
+
"react-syntax-highlighter/dist/esm/languages/prism/makefile",
|
| 360 |
+
"react-syntax-highlighter/dist/esm/languages/prism/markdown",
|
| 361 |
+
"react-syntax-highlighter/dist/esm/languages/prism/markup",
|
| 362 |
+
"react-syntax-highlighter/dist/esm/languages/prism/matlab",
|
| 363 |
+
"react-syntax-highlighter/dist/esm/languages/prism/nginx",
|
| 364 |
+
"react-syntax-highlighter/dist/esm/languages/prism/nix",
|
| 365 |
+
"react-syntax-highlighter/dist/esm/languages/prism/objectivec",
|
| 366 |
+
"react-syntax-highlighter/dist/esm/languages/prism/ocaml",
|
| 367 |
+
"react-syntax-highlighter/dist/esm/languages/prism/perl",
|
| 368 |
+
"react-syntax-highlighter/dist/esm/languages/prism/php",
|
| 369 |
+
"react-syntax-highlighter/dist/esm/languages/prism/powershell",
|
| 370 |
+
"react-syntax-highlighter/dist/esm/languages/prism/properties",
|
| 371 |
+
"react-syntax-highlighter/dist/esm/languages/prism/protobuf",
|
| 372 |
+
"react-syntax-highlighter/dist/esm/languages/prism/python",
|
| 373 |
+
"react-syntax-highlighter/dist/esm/languages/prism/r",
|
| 374 |
+
"react-syntax-highlighter/dist/esm/languages/prism/regex",
|
| 375 |
+
"react-syntax-highlighter/dist/esm/languages/prism/ruby",
|
| 376 |
+
"react-syntax-highlighter/dist/esm/languages/prism/rust",
|
| 377 |
+
"react-syntax-highlighter/dist/esm/languages/prism/sass",
|
| 378 |
+
"react-syntax-highlighter/dist/esm/languages/prism/scala",
|
| 379 |
+
"react-syntax-highlighter/dist/esm/languages/prism/scss",
|
| 380 |
+
"react-syntax-highlighter/dist/esm/languages/prism/shell-session",
|
| 381 |
+
"react-syntax-highlighter/dist/esm/languages/prism/solidity",
|
| 382 |
+
"react-syntax-highlighter/dist/esm/languages/prism/sql",
|
| 383 |
+
"react-syntax-highlighter/dist/esm/languages/prism/swift",
|
| 384 |
+
"react-syntax-highlighter/dist/esm/languages/prism/toml",
|
| 385 |
+
"react-syntax-highlighter/dist/esm/languages/prism/tsx",
|
| 386 |
+
"react-syntax-highlighter/dist/esm/languages/prism/typescript",
|
| 387 |
+
"react-syntax-highlighter/dist/esm/languages/prism/yaml",
|
| 388 |
+
],
|
| 389 |
+
},
|
| 390 |
+
server: {
|
| 391 |
+
port: FE_PORT,
|
| 392 |
+
strictPort: true, // Fail if port is busy (dynamic allocation handles fallback)
|
| 393 |
+
host: true,
|
| 394 |
+
allowedHosts: true,
|
| 395 |
+
proxy: {
|
| 396 |
+
"/api": {
|
| 397 |
+
target: API_URL,
|
| 398 |
+
changeOrigin: true,
|
| 399 |
+
secure: !INSECURE_SKIP_VERIFY,
|
| 400 |
+
},
|
| 401 |
+
"/server_info": {
|
| 402 |
+
target: API_URL,
|
| 403 |
+
changeOrigin: true,
|
| 404 |
+
secure: !INSECURE_SKIP_VERIFY,
|
| 405 |
+
},
|
| 406 |
+
"/alive": {
|
| 407 |
+
target: API_URL,
|
| 408 |
+
changeOrigin: true,
|
| 409 |
+
secure: !INSECURE_SKIP_VERIFY,
|
| 410 |
+
},
|
| 411 |
+
"/health": {
|
| 412 |
+
target: API_URL,
|
| 413 |
+
changeOrigin: true,
|
| 414 |
+
secure: !INSECURE_SKIP_VERIFY,
|
| 415 |
+
},
|
| 416 |
+
"/ready": {
|
| 417 |
+
target: API_URL,
|
| 418 |
+
changeOrigin: true,
|
| 419 |
+
secure: !INSECURE_SKIP_VERIFY,
|
| 420 |
+
},
|
| 421 |
+
"/docs": {
|
| 422 |
+
target: API_URL,
|
| 423 |
+
changeOrigin: true,
|
| 424 |
+
secure: !INSECURE_SKIP_VERIFY,
|
| 425 |
+
},
|
| 426 |
+
"/redoc": {
|
| 427 |
+
target: API_URL,
|
| 428 |
+
changeOrigin: true,
|
| 429 |
+
secure: !INSECURE_SKIP_VERIFY,
|
| 430 |
+
},
|
| 431 |
+
"/openapi.json": {
|
| 432 |
+
target: API_URL,
|
| 433 |
+
changeOrigin: true,
|
| 434 |
+
secure: !INSECURE_SKIP_VERIFY,
|
| 435 |
+
},
|
| 436 |
+
"/sockets": {
|
| 437 |
+
target: WS_URL,
|
| 438 |
+
ws: true,
|
| 439 |
+
changeOrigin: true,
|
| 440 |
+
secure: !INSECURE_SKIP_VERIFY,
|
| 441 |
+
},
|
| 442 |
+
// The bundled editor, when the launcher put agent-server into
|
| 443 |
+
// prefix-mode (`dev:minimal` — see VITE_VSCODE_TARGET in
|
| 444 |
+
// scripts/dev-safe.mjs). agent-server then advertises
|
| 445 |
+
// `<origin><prefix>/?tkn=…`, and this origin is Vite's, so without
|
| 446 |
+
// this entry the prefix falls through to the SPA and the editor
|
| 447 |
+
// button opens a second copy of the canvas.
|
| 448 |
+
//
|
| 449 |
+
// The prefix is preserved, not rewritten: openvscode-server is
|
| 450 |
+
// launched with `--server-base-path`, generates its HTTP and
|
| 451 |
+
// WebSocket URLs beneath the prefix, and only answers there.
|
| 452 |
+
// `ws: true` because the workbench upgrades to a WebSocket
|
| 453 |
+
// immediately on load.
|
| 454 |
+
...(VITE_VSCODE_BASE_PATH && VITE_VSCODE_TARGET
|
| 455 |
+
? {
|
| 456 |
+
[VITE_VSCODE_BASE_PATH]: {
|
| 457 |
+
target: VITE_VSCODE_TARGET,
|
| 458 |
+
ws: true,
|
| 459 |
+
changeOrigin: true,
|
| 460 |
+
secure: !INSECURE_SKIP_VERIFY,
|
| 461 |
+
},
|
| 462 |
+
}
|
| 463 |
+
: {}),
|
| 464 |
+
},
|
| 465 |
+
watch: {
|
| 466 |
+
ignored: ["**/node_modules/**", "**/.git/**"],
|
| 467 |
+
},
|
| 468 |
+
},
|
| 469 |
+
ssr: {
|
| 470 |
+
noExternal: ["react-syntax-highlighter"],
|
| 471 |
+
},
|
| 472 |
+
clearScreen: false,
|
| 473 |
+
test: {
|
| 474 |
+
environment: "jsdom",
|
| 475 |
+
setupFiles: ["vitest.setup.ts"],
|
| 476 |
+
exclude: [...configDefaults.exclude, "tests"],
|
| 477 |
+
// The full suite runs many DOM-heavy tests in parallel, which can
|
| 478 |
+
// push individual `userEvent`-driven tests past Vitest's 5000ms
|
| 479 |
+
// default on busy machines (the skills-settings and i18n
|
| 480 |
+
// namespace tests are the typical victims). Bumping the global
|
| 481 |
+
// timeout keeps these tests deterministic without changing any
|
| 482 |
+
// production behavior.
|
| 483 |
+
testTimeout: 30000,
|
| 484 |
+
hookTimeout: 30000,
|
| 485 |
+
server: {
|
| 486 |
+
deps: {
|
| 487 |
+
inline: ["@openhands/typescript-client"],
|
| 488 |
+
},
|
| 489 |
+
},
|
| 490 |
+
coverage: {
|
| 491 |
+
reporter: ["text", "json", "html", "lcov", "text-summary"],
|
| 492 |
+
reportsDirectory: "coverage",
|
| 493 |
+
include: ["src/**/*.{ts,tsx}"],
|
| 494 |
+
},
|
| 495 |
+
},
|
| 496 |
+
};
|
| 497 |
+
});
|
vitest.setup.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
| 2 |
+
import { cleanup } from "@testing-library/react";
|
| 3 |
+
import { server } from "#/mocks/node";
|
| 4 |
+
import "@testing-library/jest-dom/vitest";
|
| 5 |
+
|
| 6 |
+
// Some modules read env at import time before Vitest's per-test hooks run.
|
| 7 |
+
// The beforeEach below restores the same default after tests call
|
| 8 |
+
// `vi.unstubAllEnvs()`.
|
| 9 |
+
vi.stubEnv("VITE_SESSION_API_KEY", "test-session-key");
|
| 10 |
+
|
| 11 |
+
if (typeof HTMLCanvasElement !== "undefined") {
|
| 12 |
+
HTMLCanvasElement.prototype.getContext = vi.fn();
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
if (typeof HTMLElement !== "undefined") {
|
| 16 |
+
HTMLElement.prototype.scrollTo = vi.fn();
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const windowStub =
|
| 20 |
+
typeof window === "undefined"
|
| 21 |
+
? ({ event: undefined } as unknown as Window & typeof globalThis)
|
| 22 |
+
: window;
|
| 23 |
+
|
| 24 |
+
vi.stubGlobal("window", windowStub);
|
| 25 |
+
windowStub.scrollTo = vi.fn();
|
| 26 |
+
|
| 27 |
+
// Node.js 25+ ships a built-in localStorage that requires --localstorage-file
|
| 28 |
+
// and is not functional without it. Stub it with a plain in-memory
|
| 29 |
+
// implementation so zustand's persist middleware works in tests.
|
| 30 |
+
if (
|
| 31 |
+
typeof localStorage === "undefined" ||
|
| 32 |
+
typeof localStorage.setItem !== "function"
|
| 33 |
+
) {
|
| 34 |
+
const store: Record<string, string> = {};
|
| 35 |
+
vi.stubGlobal("localStorage", {
|
| 36 |
+
getItem: (key: string) => store[key] ?? null,
|
| 37 |
+
setItem: (key: string, value: string) => {
|
| 38 |
+
store[key] = String(value);
|
| 39 |
+
},
|
| 40 |
+
removeItem: (key: string) => {
|
| 41 |
+
delete store[key];
|
| 42 |
+
},
|
| 43 |
+
clear: () => {
|
| 44 |
+
Object.keys(store).forEach((k) => delete store[k]);
|
| 45 |
+
},
|
| 46 |
+
get length() {
|
| 47 |
+
return Object.keys(store).length;
|
| 48 |
+
},
|
| 49 |
+
key: (index: number) => Object.keys(store)[index] ?? null,
|
| 50 |
+
});
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
if (typeof requestAnimationFrame === "undefined") {
|
| 54 |
+
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) =>
|
| 55 |
+
setTimeout(() => callback(0), 0),
|
| 56 |
+
);
|
| 57 |
+
vi.stubGlobal(
|
| 58 |
+
"cancelAnimationFrame",
|
| 59 |
+
(timeoutId: ReturnType<typeof setTimeout>) => clearTimeout(timeoutId),
|
| 60 |
+
);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// MSW's XMLHttpRequest interceptor references the bare `ProgressEvent`
|
| 64 |
+
// global from inside async `respondWith` callbacks (via `createEvent`), and
|
| 65 |
+
// the bare `XMLHttpRequestUpload` global in `trigger` (`target instanceof
|
| 66 |
+
// XMLHttpRequestUpload`). Vitest's jsdom environment installs these as own
|
| 67 |
+
// properties on `globalThis` and removes them during per-file teardown with
|
| 68 |
+
// `keys.forEach((key) => delete global[key])`. If an in-flight intercepted
|
| 69 |
+
// XHR (e.g. PostHog analytics, or any request that escaped to the real
|
| 70 |
+
// network under `onUnhandledRequest: "bypass"` and is still waiting on a
|
| 71 |
+
// socket) settles after teardown, its callback evaluates `ProgressEvent`
|
| 72 |
+
// against a torn-down global and throws
|
| 73 |
+
// `ReferenceError: ProgressEvent is not defined` (or the
|
| 74 |
+
// `XMLHttpRequestUpload` equivalent). Vitest reports that as an
|
| 75 |
+
// unhandled rejection and fails the whole run even though every test passed.
|
| 76 |
+
//
|
| 77 |
+
// Two earlier attempts at this (an own-property getter, then the `afterAll`
|
| 78 |
+
// drain below) both put the fallback where teardown can reach it, or bounded
|
| 79 |
+
// how long a late callback may take. Neither holds: `delete` removes any own
|
| 80 |
+
// property regardless of who defined it, and a request stuck on a real socket
|
| 81 |
+
// can settle long after 30 macrotask ticks.
|
| 82 |
+
//
|
| 83 |
+
// `delete` only removes *own* properties, while identifier resolution walks
|
| 84 |
+
// the prototype chain. So the fallbacks go on an object inserted into
|
| 85 |
+
// `globalThis`'s prototype chain, where teardown cannot delete them: while
|
| 86 |
+
// the environment is alive jsdom's own properties shadow them, and once
|
| 87 |
+
// teardown removes those own properties, the bare identifiers resolve
|
| 88 |
+
// through the prototype to the classes below. Node's `globalThis` does not
|
| 89 |
+
// have `Object.prototype` as its direct prototype, so this adds nothing to
|
| 90 |
+
// plain objects.
|
| 91 |
+
class MockProgressEvent extends Event {
|
| 92 |
+
readonly lengthComputable: boolean;
|
| 93 |
+
|
| 94 |
+
readonly loaded: number;
|
| 95 |
+
|
| 96 |
+
readonly total: number;
|
| 97 |
+
|
| 98 |
+
constructor(type: string, eventInitDict: ProgressEventInit = {}) {
|
| 99 |
+
super(type, eventInitDict);
|
| 100 |
+
this.lengthComputable = eventInitDict.lengthComputable ?? false;
|
| 101 |
+
this.loaded = eventInitDict.loaded ?? 0;
|
| 102 |
+
this.total = eventInitDict.total ?? 0;
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// The interceptor only needs `XMLHttpRequestUpload` as an `instanceof`
|
| 107 |
+
// operand. While jsdom is alive its own property shadows this fallback, so
|
| 108 |
+
// behavior is unchanged; after teardown the check just takes the non-upload
|
| 109 |
+
// branch, quietly skipping listener dispatch for callbacks that have already
|
| 110 |
+
// outlived their environment instead of crashing the run.
|
| 111 |
+
class MockXMLHttpRequestUpload extends EventTarget {}
|
| 112 |
+
|
| 113 |
+
// Setup files run once per test file, and a worker process is reused across
|
| 114 |
+
// files. Without this marker each file would splice another holder into the
|
| 115 |
+
// prototype chain, so the chain would grow with every file in the run.
|
| 116 |
+
const XHR_GLOBALS_FALLBACK = Symbol.for("agent-canvas.xhr-globals-fallback");
|
| 117 |
+
|
| 118 |
+
function installXhrGlobalsFallback(fallbacks: Record<string, unknown>) {
|
| 119 |
+
const currentProto = Object.getPrototypeOf(globalThis) as object | null;
|
| 120 |
+
if (currentProto && XHR_GLOBALS_FALLBACK in currentProto) return;
|
| 121 |
+
|
| 122 |
+
const holder = Object.create(currentProto) as Record<PropertyKey, unknown>;
|
| 123 |
+
Object.defineProperty(holder, XHR_GLOBALS_FALLBACK, { value: true });
|
| 124 |
+
for (const [name, fallback] of Object.entries(fallbacks)) {
|
| 125 |
+
Object.defineProperty(holder, name, {
|
| 126 |
+
value: fallback,
|
| 127 |
+
configurable: true,
|
| 128 |
+
writable: true,
|
| 129 |
+
});
|
| 130 |
+
}
|
| 131 |
+
Object.setPrototypeOf(globalThis, holder);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
installXhrGlobalsFallback({
|
| 135 |
+
ProgressEvent: MockProgressEvent,
|
| 136 |
+
XMLHttpRequestUpload: MockXMLHttpRequestUpload,
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
// Mock ResizeObserver for test environment
|
| 140 |
+
class MockResizeObserver {
|
| 141 |
+
observe = vi.fn();
|
| 142 |
+
|
| 143 |
+
unobserve = vi.fn();
|
| 144 |
+
|
| 145 |
+
disconnect = vi.fn();
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// Mock the i18n provider
|
| 149 |
+
vi.mock("react-i18next", async (importOriginal) => ({
|
| 150 |
+
...(await importOriginal<typeof import("react-i18next")>()),
|
| 151 |
+
useTranslation: () => ({
|
| 152 |
+
t: (key: string) => key,
|
| 153 |
+
i18n: {
|
| 154 |
+
language: "en",
|
| 155 |
+
exists: () => false,
|
| 156 |
+
},
|
| 157 |
+
}),
|
| 158 |
+
}));
|
| 159 |
+
|
| 160 |
+
vi.mock("#/hooks/use-is-on-tos-page", () => ({
|
| 161 |
+
useIsOnTosPage: () => false,
|
| 162 |
+
}));
|
| 163 |
+
|
| 164 |
+
vi.mock("#/hooks/use-is-on-intermediate-page", () => ({
|
| 165 |
+
useIsOnIntermediatePage: () => false,
|
| 166 |
+
}));
|
| 167 |
+
|
| 168 |
+
// Mock useRevalidator from react-router to allow direct store manipulation in tests
|
| 169 |
+
vi.mock("react-router", async (importOriginal) => ({
|
| 170 |
+
...(await importOriginal<typeof import("react-router")>()),
|
| 171 |
+
useRevalidator: () => ({
|
| 172 |
+
revalidate: vi.fn(),
|
| 173 |
+
}),
|
| 174 |
+
}));
|
| 175 |
+
|
| 176 |
+
// Import the Zustand mock to enable automatic store resets
|
| 177 |
+
vi.mock("zustand");
|
| 178 |
+
|
| 179 |
+
// Mock requests during tests
|
| 180 |
+
beforeAll(() => {
|
| 181 |
+
server.listen({ onUnhandledRequest: "bypass" });
|
| 182 |
+
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
| 183 |
+
});
|
| 184 |
+
|
| 185 |
+
beforeEach(() => {
|
| 186 |
+
vi.stubEnv("VITE_SESSION_API_KEY", "test-session-key");
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
afterEach(async () => {
|
| 190 |
+
server.resetHandlers();
|
| 191 |
+
window.sessionStorage?.removeItem("openhands-active-backend");
|
| 192 |
+
// Cleanup the document body after each test
|
| 193 |
+
cleanup();
|
| 194 |
+
// Drain any queued microtasks before jsdom is torn down between test files.
|
| 195 |
+
// Without this, async state updates queued during render (for example by
|
| 196 |
+
// HeroUI v2 components wrapped in framer-motion's LazyMotion) can resolve
|
| 197 |
+
// after `window` is gone and trigger spurious unhandled rejections in
|
| 198 |
+
// react-dom's `resolveUpdatePriority`. We use `Promise.resolve()` (a
|
| 199 |
+
// microtask) rather than `setTimeout(0)` so this stays compatible with
|
| 200 |
+
// tests that install fake timers.
|
| 201 |
+
await Promise.resolve();
|
| 202 |
+
await Promise.resolve();
|
| 203 |
+
});
|
| 204 |
+
afterAll(async () => {
|
| 205 |
+
// Drain pending MSW `respondWith` callbacks (and any other queued
|
| 206 |
+
// macrotasks) before jsdom is torn down, so most late callbacks settle
|
| 207 |
+
// against a live jsdom rather than a torn-down one. This is a best-effort
|
| 208 |
+
// tidy-up, not the guarantee: a callback can always outlast the drain
|
| 209 |
+
// window (a bypassed request stuck on a real socket, for instance), which
|
| 210 |
+
// is what the prototype-chain XHR-globals fallback above is for. We
|
| 211 |
+
// restore real timers first so a test that left fake timers active can't
|
| 212 |
+
// stall the drain.
|
| 213 |
+
vi.useRealTimers();
|
| 214 |
+
// Reset handlers first so no new intercepted requests start processing
|
| 215 |
+
// during the drain window.
|
| 216 |
+
server.resetHandlers();
|
| 217 |
+
for (let i = 0; i < 30; i += 1) {
|
| 218 |
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
| 219 |
+
}
|
| 220 |
+
server.close();
|
| 221 |
+
vi.unstubAllGlobals();
|
| 222 |
+
});
|