SaylorTwift HF Staff commited on
Commit
ee9fcd0
·
verified ·
1 Parent(s): 980a8ef

Add files using upload-large-folder tool

Browse files
.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
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
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
+ });