From 979e64fe190d46dfdee1e7ed3125fe9b1a9ae98b Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Wed, 20 May 2026 00:24:45 -0400 Subject: [PATCH] feat: add Docker CI to build all-in-one image with agent-server + automation + frontend (#634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Docker CI to build all-in-one image with agent-server + automation + frontend Adds a GitHub Actions workflow (.github/workflows/docker.yml) that builds and publishes ghcr.io/openhands/agent-canvas — a single Docker image combining: 1. Agent Server (ghcr.io/openhands/agent-server base image from SDK repo) 2. Automation server (pip-installed from openhands-automation) 3. agent-canvas frontend (static build from this repo) The automation server is pip-installed rather than copied from its Docker image because both services share openhands-sdk, fastapi, uvicorn, pydantic, httpx etc. — installing into the agent-server's Python 3.13 deduplicates all shared packages. Only automation-specific deps (asyncpg, sqlalchemy, boto3, …) are added on top. An entrypoint script starts all three services and a static-server proxy that unifies them behind a single port (default 8000): /api/automation/* → automation backend (:18001) /api/* → agent-server (:18000) /* → static frontend + SPA fallback Workflow triggers: - Push to main: builds and pushes with branch + SHA tags - v* tags (releases): also pushes semver tags (1.2.3, 1.2, 1, latest) - PRs: builds, pushes SHA-tagged image, updates PR description with pull/run instructions (same pattern as the SDK repo) - workflow_dispatch: supports overriding base image and automation version Files added: - docker/Dockerfile (multi-stage: frontend build + agent-server base) - docker/entrypoint.sh (process manager for all three services) - .dockerignore - .github/workflows/docker.yml Co-authored-by: openhands * fix: build multi-arch Docker images (amd64 + arm64) Adds QEMU setup for cross-compilation and defaults the platform matrix to linux/amd64,linux/arm64 so the image works on both Intel and Apple Silicon machines. Co-authored-by: openhands * refactor: rewrite Docker workflow to match SDK repo structure Replace the single-job QEMU approach with the same architecture-matrix pattern used by the SDK repo's server.yml: 1. build-and-push-image — matrix over {amd64, arm64} with native runners (ubuntu-24.04 for amd64, ubuntu-24.04-arm for arm64). Each job pushes arch-suffixed tags (e.g. sha-abc1234-amd64) and uploads build-info artifacts. 2. merge-manifests — downloads both arch build-infos, strips the -amd64 suffix from amd64 tags to derive manifest tags, and creates multi-arch manifests via `docker buildx imagetools create`. 3. consolidate-build-info — aggregates all build-info and manifest-info artifacts into a single JSON summary (PR-only). 4. update-pr-description — renders the summary into the PR body between AGENT_CANVAS_DOCKER_START/END markers. Native runners avoid the 3-5× slowdown of QEMU emulation for arm64 builds. Co-authored-by: openhands * fix: sanitize branch names in Docker tags (/ is not allowed) Branch names like 'feat/docker-ci' produce invalid Docker tags because '/' is forbidden in tag names. Replace '/' with '-' so the tag becomes 'feat-docker-ci-amd64'. Co-authored-by: openhands * fix: default automation to SQLite and fix wait blocking proxy startup Two bugs: 1. The automation server defaults to PostgreSQL on localhost, which doesn't exist in the all-in-one container. Default AUTOMATION_DB_URL to sqlite+aiosqlite:// so it works out of the box. Users can override with a real Postgres URL for production. 2. The bare 'wait' command waited for ALL background children — including the long-running agent-server and automation processes — so the static-server/proxy on port 8000 never started. Fix by waiting only for the wait_for_port subshell PIDs. Verified locally: all three services start, endpoints respond correctly, no more scheduler ConnectionRefusedError. Co-authored-by: openhands * feat: add VOLUME directives for persistence and project mounts Declare /home/openhands/.openhands (settings, secrets, conversations, automation SQLite DB) and /projects (user code) as Docker volumes so data survives container restarts by default. Users should bind-mount these for durable persistence: docker run -v ~/.openhands:/home/openhands/.openhands \ -v ~/projects:/projects \ -p 8000:8000 ghcr.io/openhands/agent-canvas Co-authored-by: openhands * fix: set OH_SECRET_KEY default and pre-create persistence dirs Three issues fixed: 1. OH_SECRET_KEY was not set → agent-server refused to return encrypted secrets → conversation creation failed with 503. Set the same static default used by dev-safe.mjs / dev-docker.mjs. 2. Persistence dirs (conversations, bash_events, automation DB) were not pre-created → the openhands user got PermissionError when the VOLUME directive created them as root. Pre-create with correct ownership before the USER switch in the Dockerfile. 3. Set OH_PERSISTENCE_DIR, OH_CONVERSATIONS_PATH, OH_BASH_EVENTS_DIR defaults in the entrypoint (matching dev-docker.mjs) so data lands under the well-known ~/.openhands tree. Verified locally: all three services start clean, no warnings about OH_SECRET_KEY, SQLite migrations apply successfully. Co-authored-by: openhands * chore: merge main and remove stale dev-docker.mjs references Main removed scripts/dev-docker.mjs (Docker is no longer a dependency of the npm package flow). Update comments in docker.yml, entrypoint.sh, and AGENTS.md that referenced the deleted file. Co-authored-by: openhands * feat: centralize config into config/defaults.json (single source of truth) All version pins, port defaults, persistence paths, package names, and the dev secret key now live in config/defaults.json. Consumers read from it instead of hardcoding values: - scripts/dev-safe.mjs: reads via JSON.parse(readFileSync(...)) - scripts/dev-with-automation.mjs: same - scripts/check-sdk-version-sync.mjs: same (no longer regex-parses JS) - docker/Dockerfile: config-gen build stage converts JSON to /opt/agent-canvas/defaults.env (shell-sourceable) - docker/entrypoint.sh: sources defaults.env at startup; also adds session API key auto-generation so the image doesn't run wide-open - .github/workflows/docker.yml: reads versions from JSON in a setup step (no more hardcoded env vars) To bump a version, edit config/defaults.json only. Co-authored-by: openhands * fix: address PR review feedback (#634) - Fix PID tracking bug: move PIDS+=($!) inside if/elif branches so the else (automation-not-found) path doesn't add a stale PID - chmod 600 session API key file to prevent credential leak - Warn when using insecure default OH_SECRET_KEY in Docker entrypoint - Add try/catch + field validation for config/defaults.json loading in check-sdk-version-sync.mjs - Fix semver tag parsing: strip pre-release/build metadata, only create abbreviated tags (major.minor, major, latest) for stable releases - Sanitize branch names for Docker tags (tr invalid chars, strip leading dot/dash) to handle branches with #, @, spaces, etc. - Add arch validation before manifest merge (assert both amd64.json and arm64.json exist) - Remove $schema reference to non-existent defaults.schema.json Co-authored-by: openhands * fix: remove hardcoded version defaults from Dockerfile Replace hardcoded ARG defaults (AGENT_SERVER_IMAGE, AUTOMATION_VERSION) with empty ARGs. Values are always derived from config/defaults.json: - CI: reads JSON in the workflow config step, passes --build-arg - Local: new scripts/docker-build.mjs helper reads JSON and invokes docker build with the correct --build-arg values Added npm run build:docker convenience script. Co-authored-by: openhands * fix: stabilize snapshot tests and auto-generate Docker secret key Two fixes: 1. **Flaky snapshot tests**: The 'Local pagination fixture' mock conversation used a fixed absolute timestamp (PAGINATION_BASE_TIME = May 13, 2026) for its created_at/updated_at, while 'Errored Project' used a relative timestamp (now - 7d). As real time progressed past the crossover point, their sort order in the sidebar flipped, causing 30/73 snapshot diffs on every PR. Fix: use relative timestamps (now - 6d) for the pagination fixture's conversation listing fields. The internal event timestamps (used by pagination tests) still use PAGINATION_BASE_TIME — only the sidebar ordering is affected. 2. **Docker OH_SECRET_KEY**: The entrypoint used a static insecure default for OH_SECRET_KEY and warned about it. Now mirrors the session API key pattern: auto-generate a cryptographic random key on first run, persist it to ~/.openhands/agent-canvas/secret-key.txt, and reuse on restart. Users can still override via the OH_SECRET_KEY env var. Removed the now-unused CONFIG_SECRET_KEY from the Docker defaults.env generation. Also deduped STATE_DIR computation (was repeated for session key path). Co-authored-by: openhands * docs: update AGENTS.md with mock timestamp and Docker secret key notes Co-authored-by: openhands * fix: include canvas_ui tool in Docker image The Docker image was missing the tools/ directory and OH_EXTRA_PYTHON_PATH, so the agent-server couldn't import canvas_ui_tool.py when the frontend sent canvas_ui in the conversation tools list. This caused: HTTP 500: ToolDefinition 'canvas_ui' is not registered Fix: COPY tools/ into the image and set OH_EXTRA_PYTHON_PATH in the entrypoint, matching what scripts/dev-safe.mjs already does for local dev. Co-authored-by: openhands --------- Co-authored-by: openhands --- .dockerignore | 39 +++ .github/workflows/docker.yml | 519 +++++++++++++++++++++++++++++ AGENTS.md | 11 +- config/defaults.json | 38 +++ docker/Dockerfile | 131 ++++++++ docker/entrypoint.sh | 191 +++++++++++ package.json | 3 +- scripts/check-sdk-version-sync.mjs | 100 +++--- scripts/dev-safe.mjs | 18 +- scripts/dev-with-automation.mjs | 21 +- scripts/docker-build.mjs | 71 ++++ src/mocks/conversation-handlers.ts | 12 +- 12 files changed, 1070 insertions(+), 84 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker.yml create mode 100644 config/defaults.json create mode 100644 docker/Dockerfile create mode 100644 docker/entrypoint.sh create mode 100644 scripts/docker-build.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..c32286706b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Dependencies +node_modules/ + +# Build outputs (rebuilt in Docker) +build/ +dist/ + +# Development files +.git/ +.github/ +.openhands/ +.agents/ +.pr/ + +# Test files +__tests__/ +tests/ +__mocks__/ +test-results/ +playwright-report*/ +coverage/ + +# IDE / OS +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# Environment +.env +.env.* +!.env.sample + +# Misc +artifacts/ +*.tgz +*.log diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000000..f1564fd493 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,519 @@ +--- +name: Docker + +on: + push: + branches: [main] + tags: + - "v*" + pull_request: + branches: [main] + workflow_dispatch: + inputs: + agent_server_image: + description: Agent Server base image (ghcr.io/openhands/agent-server:TAG) + type: string + default: "" + automation_version: + description: Automation server version (pip) + type: string + default: "" + image: + description: GHCR image name + type: string + default: ghcr.io/openhands/agent-canvas + +# Cancel redundant runs for the same branch/PR. +concurrency: + group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +env: + IMAGE: ${{ inputs.image != '' && inputs.image || 'ghcr.io/openhands/agent-canvas' }} + # Use the PR head SHA for PR events so tags point at the actual code. + RELEVANT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + RELEVANT_REF: ${{ github.head_ref != '' && format('refs/heads/{0}', github.head_ref) || github.ref }} + +jobs: + # ═══════════════════════════════════════════════════════════════════════════ + # Build & Push (per-architecture, native runners) + # ═══════════════════════════════════════════════════════════════════════════ + build-and-push-image: + name: Build & Push (${{ matrix.arch }}) + # Skip fork PRs — they cannot authenticate to GHCR. + if: > + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + !github.event.pull_request.head.repo.fork) + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + platform: linux/amd64 + - arch: arm64 + runner: ubuntu-24.04-arm + platform: linux/arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + + env: + ARCH: ${{ matrix.arch }} + PLATFORM: ${{ matrix.platform }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || '' }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Read defaults from config/defaults.json + id: config + run: | + # Single source of truth for version pins — no hardcoded values in this workflow. + AGENT_SERVER_VERSION=$(node -p "require('./config/defaults.json').versions.agentServer") + AGENT_SERVER_IMAGE_BASE=$(node -p "require('./config/defaults.json').images.agentServer") + AUTOMATION_VERSION=$(node -p "require('./config/defaults.json').versions.automation") + echo "agent_server_version=$AGENT_SERVER_VERSION" >> "$GITHUB_OUTPUT" + echo "default_agent_server_image=${AGENT_SERVER_IMAGE_BASE}:${AGENT_SERVER_VERSION}-python" >> "$GITHUB_OUTPUT" + echo "default_automation_version=$AUTOMATION_VERSION" >> "$GITHUB_OUTPUT" + + - name: Compute metadata and tags + id: prep + run: | + SHORT_SHA=$(echo "$RELEVANT_SHA" | cut -c1-7) + echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" + + # Resolve agent-server base image (input override > config/defaults.json) + if [ -n "${{ inputs.agent_server_image }}" ]; then + echo "agent_server_image=${{ inputs.agent_server_image }}" >> "$GITHUB_OUTPUT" + else + echo "agent_server_image=${{ steps.config.outputs.default_agent_server_image }}" >> "$GITHUB_OUTPUT" + fi + + # Resolve automation version (input override > config/defaults.json) + if [ -n "${{ inputs.automation_version }}" ]; then + echo "automation_version=${{ inputs.automation_version }}" >> "$GITHUB_OUTPUT" + else + echo "automation_version=${{ steps.config.outputs.default_automation_version }}" >> "$GITHUB_OUTPUT" + fi + + # Build arch-suffixed tags (e.g., sha-abc1234-amd64) + TAGS="" + add_tag() { TAGS="${TAGS:+${TAGS},}${IMAGE}:${1}-${ARCH}"; } + + # SHA tags (always) + add_tag "sha-${SHORT_SHA}" + + # Branch / PR / tag-based tags + if [[ "$RELEVANT_REF" == refs/heads/* ]]; then + # Sanitize branch name for Docker tag safety: + # replace any char outside [a-zA-Z0-9._-] with -, strip leading/trailing .- + BRANCH="${RELEVANT_REF#refs/heads/}" + BRANCH=$(echo "$BRANCH" | tr -c 'a-zA-Z0-9._-' '-' | sed 's/^[-.]//; s/[-.]*$//') + add_tag "$BRANCH" + fi + + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + add_tag "pr-${{ github.event.pull_request.number }}" + fi + + if [[ "$RELEVANT_REF" == refs/tags/v* ]]; then + VERSION="${RELEVANT_REF#refs/tags/v}" + add_tag "$VERSION" + + # Strip pre-release/build metadata for major.minor.patch derivation + VERSION_BASE="${VERSION%%-*}" + VERSION_BASE="${VERSION_BASE%%+*}" + + # Only create abbreviated + latest tags for stable (non-pre-release) versions + if [[ "$VERSION" != *"-"* ]] && [[ "$VERSION" != *"+"* ]]; then + # major.minor + MINOR="${VERSION_BASE%.*}" + if [ "$MINOR" != "$VERSION_BASE" ]; then + add_tag "$MINOR" + fi + # major + MAJOR="${VERSION_BASE%%.*}" + if [ "$MAJOR" != "$VERSION_BASE" ] && [ "$MAJOR" != "$MINOR" ]; then + add_tag "$MAJOR" + fi + add_tag "latest" + fi + fi + + echo "tags=$TAGS" >> "$GITHUB_OUTPUT" + + echo "=== Build outputs ===" + echo "Short SHA: $SHORT_SHA" + echo "Tags: $TAGS" + echo "====================" + + - name: Build & Push (${{ matrix.arch }}) + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ matrix.platform }} + push: true + tags: ${{ steps.prep.outputs.tags }} + build-args: | + AGENT_SERVER_IMAGE=${{ steps.prep.outputs.agent_server_image }} + AUTOMATION_VERSION=${{ steps.prep.outputs.automation_version }} + OPENHANDS_BUILD_GIT_SHA=${{ env.RELEVANT_SHA }} + OPENHANDS_BUILD_GIT_REF=${{ env.RELEVANT_REF }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: true + sbom: true + + - name: Summary (${{ matrix.arch }}) + run: | + echo "Image: ${{ env.IMAGE }}" + echo "Architecture: ${{ matrix.arch }}" + echo "Platform: ${{ matrix.platform }}" + echo "Short SHA: ${{ steps.prep.outputs.short_sha }}" + echo "Tags: ${{ steps.prep.outputs.tags }}" + echo "Build digest: ${{ steps.build.outputs.digest }}" + + - name: Save build info for consolidation + run: | + mkdir -p build-info + jq -n \ + --arg arch "${{ matrix.arch }}" \ + --arg image "${{ env.IMAGE }}" \ + --arg short_sha "${{ steps.prep.outputs.short_sha }}" \ + --arg tags "${{ steps.prep.outputs.tags }}" \ + --arg agent_server_image "${{ steps.prep.outputs.agent_server_image }}" \ + --arg automation_version "${{ steps.prep.outputs.automation_version }}" \ + --arg platform "${{ matrix.platform }}" \ + --arg git_sha "${{ env.RELEVANT_SHA }}" \ + --arg git_ref "${{ env.RELEVANT_REF }}" \ + '{arch: $arch, image: $image, short_sha: $short_sha, tags: $tags, agent_server_image: $agent_server_image, automation_version: $automation_version, platform: $platform, git_sha: $git_sha, git_ref: $git_ref}' \ + > "build-info/${{ matrix.arch }}.json" + cat "build-info/${{ matrix.arch }}.json" + + - name: Upload build info artifact + uses: actions/upload-artifact@v7 + with: + name: build-info-${{ matrix.arch }} + path: build-info/${{ matrix.arch }}.json + retention-days: 1 + + # ═══════════════════════════════════════════════════════════════════════════ + # Merge Multi-Arch Manifests + # ═══════════════════════════════════════════════════════════════════════════ + merge-manifests: + name: Merge Multi-Arch Manifests + needs: build-and-push-image + if: > + github.event_name == 'push' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && + !github.event.pull_request.head.repo.fork) + runs-on: ubuntu-24.04 + + steps: + - name: Download build info artifacts + uses: actions/download-artifact@v8 + with: + pattern: build-info-* + merge-multiple: true + path: build-info + + - name: Extract SHORT_SHA from build info + id: get_sha + run: | + SHORT_SHA=$(jq -r '.short_sha' build-info/amd64.json) + echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" + echo "Using SHORT_SHA: $SHORT_SHA" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push multi-arch manifests + id: create_manifests + run: | + # Validate both architectures built successfully + if [[ ! -f build-info/amd64.json ]] || [[ ! -f build-info/arm64.json ]]; then + echo "::error::Missing architecture builds (need both amd64.json and arm64.json)" + echo "Available: $(ls -1 build-info/*.json 2>/dev/null || echo 'none')" + exit 1 + fi + + SHORT_SHA=${{ steps.get_sha.outputs.short_sha }} + AMD64_TAGS_CSV=$(jq -r '.tags' build-info/amd64.json) + declare -A SEEN_MANIFEST_TAGS=() + MANIFEST_TAGS=() + + create_manifest() { + local manifest_tag=$1 + local source_tag=${2:-$1} + + echo "Creating multi-arch manifest: ${IMAGE}:${manifest_tag}" + docker buildx imagetools create -t "${IMAGE}:${manifest_tag}" \ + "${IMAGE}:${source_tag}-amd64" \ + "${IMAGE}:${source_tag}-arm64" + + echo "Inspecting multi-arch manifest:" + docker buildx imagetools inspect "${IMAGE}:${manifest_tag}" + echo "✓ Multi-arch manifest created: ${IMAGE}:${manifest_tag}" + } + + IFS=',' read -ra AMD64_TAGS <<< "$AMD64_TAGS_CSV" + for AMD64_IMAGE_TAG in "${AMD64_TAGS[@]}"; do + if [ -z "$AMD64_IMAGE_TAG" ]; then + continue + fi + + TAG_NAME=${AMD64_IMAGE_TAG#${IMAGE}:} + if [ "$TAG_NAME" = "$AMD64_IMAGE_TAG" ] || [[ ! "$TAG_NAME" == *-amd64 ]]; then + echo "Skipping unexpected architecture tag: $AMD64_IMAGE_TAG" + continue + fi + + MANIFEST_TAG=${TAG_NAME%-amd64} + if [ -n "${SEEN_MANIFEST_TAGS[$MANIFEST_TAG]+x}" ]; then + continue + fi + + SEEN_MANIFEST_TAGS[$MANIFEST_TAG]=1 + MANIFEST_TAGS+=("$MANIFEST_TAG") + create_manifest "$MANIFEST_TAG" + done + + # Preserve a latest alias on main pushes. + if [ "${{ github.ref }}" == "refs/heads/main" ]; then + LATEST_TAG="latest" + create_manifest "$LATEST_TAG" "main" + MANIFEST_TAGS+=("$LATEST_TAG") + fi + + MANIFEST_TAG_CSV=$(IFS=,; echo "${MANIFEST_TAGS[*]}") + echo "manifest_tags=$MANIFEST_TAG_CSV" >> "$GITHUB_OUTPUT" + + # Save manifest info for consolidation + mkdir -p manifest-info + jq -n \ + --arg image "${{ env.IMAGE }}" \ + --arg short_sha "$SHORT_SHA" \ + --arg manifest_tags "$MANIFEST_TAG_CSV" \ + '{image: $image, short_sha: $short_sha, manifest_tags: $manifest_tags}' \ + > manifest-info/manifests.json + cat manifest-info/manifests.json + + - name: Upload manifest info artifact + uses: actions/upload-artifact@v7 + with: + name: manifest-info + path: manifest-info/manifests.json + retention-days: 1 + + # ═══════════════════════════════════════════════════════════════════════════ + # Consolidate Build Information + # ═══════════════════════════════════════════════════════════════════════════ + consolidate-build-info: + name: Consolidate Build Information + needs: [build-and-push-image, merge-manifests] + if: github.event_name == 'pull_request' && always() && (needs.build-and-push-image.result == 'success' || needs.build-and-push-image.result == 'failure') + runs-on: ubuntu-24.04 + outputs: + build_summary: ${{ steps.consolidate.outputs.build_summary }} + + steps: + - name: Download build info artifacts + uses: actions/download-artifact@v8 + with: + pattern: build-info-* + merge-multiple: true + path: build-info + + - name: Download manifest info artifacts + uses: actions/download-artifact@v8 + with: + name: manifest-info + path: manifest-info + continue-on-error: true + + - name: Consolidate build information from artifacts + id: consolidate + run: | + echo "Processing build info artifacts..." + ls -la build-info/ + + IMAGE="" + SHORT_SHA="" + ALL_TAGS="" + AGENT_SERVER_IMAGE="" + AUTOMATION_VERSION="" + GIT_SHA="" + GIT_REF="" + ARCHS="" + + for info_file in build-info/*.json; do + if [[ ! -f "$info_file" ]]; then + continue + fi + echo "=== Processing $info_file ===" + cat "$info_file" + + ARCH=$(jq -r '.arch' "$info_file") + FILE_IMAGE=$(jq -r '.image' "$info_file") + FILE_SHA=$(jq -r '.short_sha' "$info_file") + FILE_TAGS=$(jq -r '.tags' "$info_file") + + if [[ -z "$IMAGE" ]]; then + IMAGE="$FILE_IMAGE" + SHORT_SHA="$FILE_SHA" + AGENT_SERVER_IMAGE=$(jq -r '.agent_server_image' "$info_file") + AUTOMATION_VERSION=$(jq -r '.automation_version' "$info_file") + GIT_SHA=$(jq -r '.git_sha' "$info_file") + GIT_REF=$(jq -r '.git_ref' "$info_file") + fi + + ARCHS="${ARCHS:+${ARCHS}, }${ARCH}" + + if [[ -n "$FILE_TAGS" ]]; then + TAG_LIST=$(echo "$FILE_TAGS" | tr ',' '\n') + ALL_TAGS="${ALL_TAGS:+${ALL_TAGS} + }${TAG_LIST}" + fi + done + + # Add manifest tags + if [[ -f "manifest-info/manifests.json" ]]; then + MANIFEST_TAG_CSV=$(jq -r '.manifest_tags' manifest-info/manifests.json) + MANIFEST_TAG_LIST=$(echo "$MANIFEST_TAG_CSV" | tr ',' '\n' | sed "s|^|${IMAGE}:|") + ALL_TAGS="${ALL_TAGS:+${ALL_TAGS} + }${MANIFEST_TAG_LIST}" + fi + + BUILD_SUMMARY=$(jq -n \ + --arg image "$IMAGE" \ + --arg short_sha "$SHORT_SHA" \ + --arg all_tags "$ALL_TAGS" \ + --arg archs "$ARCHS" \ + --arg agent_server_image "$AGENT_SERVER_IMAGE" \ + --arg automation_version "$AUTOMATION_VERSION" \ + --arg git_sha "$GIT_SHA" \ + --arg git_ref "$GIT_REF" \ + --arg ghcr_url "https://github.com/OpenHands/agent-canvas/pkgs/container/agent-canvas" \ + '{image: $image, short_sha: $short_sha, all_tags: $all_tags, architectures: $archs, agent_server_image: $agent_server_image, automation_version: $automation_version, git_sha: $git_sha, git_ref: $git_ref, ghcr_package_url: $ghcr_url}') + + echo "Consolidated build summary:" + echo "$BUILD_SUMMARY" | jq . + + { + echo 'build_summary<> "$GITHUB_OUTPUT" + + # ═══════════════════════════════════════════════════════════════════════════ + # Update PR description with image info (PRs only) + # ═══════════════════════════════════════════════════════════════════════════ + update-pr-description: + name: Update PR description with Docker image + needs: consolidate-build-info + if: github.event_name == 'pull_request' && needs.consolidate-build-info.result == 'success' + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: write + + steps: + - name: Generate PR description from build summary + id: generate_description + run: | + BUILD_SUMMARY='${{ needs.consolidate-build-info.outputs.build_summary }}' + echo "Build summary received:" + echo "$BUILD_SUMMARY" | jq . + + IMAGE=$(echo "$BUILD_SUMMARY" | jq -r '.image') + SHORT_SHA=$(echo "$BUILD_SUMMARY" | jq -r '.short_sha') + GHCR_URL=$(echo "$BUILD_SUMMARY" | jq -r '.ghcr_package_url') + ALL_TAGS=$(echo "$BUILD_SUMMARY" | jq -r '.all_tags') + ARCHS=$(echo "$BUILD_SUMMARY" | jq -r '.architectures') + AGENT_SERVER_IMAGE=$(echo "$BUILD_SUMMARY" | jq -r '.agent_server_image') + AUTOMATION_VERSION=$(echo "$BUILD_SUMMARY" | jq -r '.automation_version') + GIT_SHA=$(echo "$BUILD_SUMMARY" | jq -r '.git_sha') + + PR_CONTENT=$(cat << EOF + + + --- + **🐳 Docker images for this PR** + + • **GHCR package:** ${GHCR_URL} + + | Component | Value | + |---|---| + | **Image** | \`${IMAGE}\` | + | **Architectures** | ${ARCHS} | + | **Agent Server** | \`${AGENT_SERVER_IMAGE}\` | + | **Automation** | \`openhands-automation==${AUTOMATION_VERSION}\` | + | **Commit** | \`${GIT_SHA}\` | + + **Pull (multi-arch manifest)** + \`\`\`bash + # Multi-arch manifest — Docker automatically pulls the correct architecture + docker pull ${IMAGE}:sha-${SHORT_SHA} + \`\`\` + + **Run** + \`\`\`bash + docker run -it --rm \\ + -p 8000:8000 \\ + ${IMAGE}:sha-${SHORT_SHA} + \`\`\` + + **All tags pushed for this build** + \`\`\` + ${ALL_TAGS} + \`\`\` + + **About Multi-Architecture Support** + - Each tag (e.g., \`sha-${SHORT_SHA}\`) is a **multi-arch manifest** supporting both **amd64** and **arm64** + - Docker automatically pulls the correct architecture for your platform + - Individual architecture tags (e.g., \`sha-${SHORT_SHA}-amd64\`) are also available if needed + + EOF + ) + + { + echo 'pr_content<> "$GITHUB_OUTPUT" + + - name: Update PR description with docker image details + uses: nefrob/pr-description@v1.2.0 + with: + content: ${{ steps.generate_description.outputs.pr_content }} + regex: ".*?" + regexFlags: s + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 3f3f90718b..6242dd4352 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,7 @@ you are running inside of — NOT the automation backend. - Snapshots are organized by `{snapshotDir}/{testFilePath}/{projectName}/{arg}.png` (configured in `playwright.config.ts`). - **Conversation page snapshot tests**: The dev server uses MSW service workers for API mocking. For conversation-page tests, rely on MSW's pre-defined mock conversations (IDs "1", "2", "3" in `src/mocks/conversation-handlers.ts`) rather than fighting Playwright route interception. MSW's service worker intercepts requests before Playwright `page.route()` can; Playwright route interceptors only see requests that escape the service worker. Stub WebSocket via `page.addInitScript()` and inject events into the Zustand store via the exposed `window.__OH_EVENT_STORE__` API. Use `test.describe.configure({ mode: "serial" })` for conversation tests since the WebSocket stub + heavier page setup can cause intermittent failures in parallel mode. - **Baseline generation for CI**: Baselines generated locally will NOT match CI (different OS, fonts, rendering). Baselines are regenerated automatically on every push to `main`. After adding new snapshot tests, open a PR — the new snapshots will be shown as "🆕 New" in the PR comment and become the baseline when the PR merges. To force-refresh baselines from main without waiting for a code push, trigger the "Snapshot Tests" workflow manually with `force_update=true`. +- **Mock conversation timestamps must ALL be `now`-relative**: `src/mocks/conversation-handlers.ts` defines mock conversations sorted by `updated_at` descending in the sidebar. Every conversation's `created_at`/`updated_at` must use `now - X * days` (relative to module-load time), never a fixed absolute date like `PAGINATION_BASE_TIME`. Mixing the two strategies causes a sort-order crossover as real time passes — the fixed-date conversation ages past a relative one and they swap positions, breaking every snapshot that includes the sidebar. `PAGINATION_BASE_TIME` is kept only for internal event timestamps used by pagination tests; conversation listing timestamps are decoupled from it. - **MSW handler state is PAGE-level JS, not service-worker state**: In MSW 2.x browser mode the request handlers (including mutable Maps like `automations`) are compiled into the client bundle and run in the main thread. `page.reload()` re-initialises all module-level state (e.g. `const automations = new Map(...)` runs fresh on every page load). Tests that need to show an "empty list" state must NOT call `page.reload()` after deleting items. Instead: make the DELETE fetches from `page.evaluate()` (which DO go through MSW), then call `window.__TEST_INVALIDATE_QUERIES__()` (exposed in mock mode by `entry.client.tsx`) to trigger React Query's cache invalidation in-place without a reload. Example: the automations empty-state snapshot test in `tests/e2e/snapshots/automations.snapshot.spec.ts`. ## Live End-to-End Test Framework @@ -337,7 +338,7 @@ return new ConversationClient(getAgentServerClientOptions()).someMethod(...); - `/api/*`, `/sockets`, etc. → agent server (:18000) - `/*` (default) → frontend server (:3001), either Vite or static depending on launcher mode - Environment variables: `PORT` (ingress port, default: 8000), `OH_AUTOMATION_GIT_REF` (git ref, overrides default version), `OH_AUTOMATION_VERSION` (default: `1.0.0a3`), `AUTOMATION_LOCAL_API_KEY` (optional, use a fixed key; default: persisted generated key), `OH_AUTOMATION_API_KEY_PATH` (override the persisted default key path) - - `scripts/check-sdk-version-sync.mjs` checks the released `openhands-automation` package against `DEFAULT_AUTOMATION_SDK_VERSION` in `scripts/dev-with-automation.mjs`; that value may intentionally lag `DEFAULT_AGENT_SERVER_VERSION` while automation has not yet published a matching release. + - `scripts/check-sdk-version-sync.mjs` checks the released `openhands-automation` package against `versions.automationSdk` in `config/defaults.json`; that value may intentionally lag `versions.agentServer` while automation has not yet published a matching release. - Access points: `http://localhost:8000/` (main UI), `http://localhost:8000/api/automation/docs` (API docs) - Security: `AUTOMATION_LOCAL_API_KEY` defaults to a generated key persisted across restarts because static frontend builds bake it into `VITE_AUTOMATION_API_KEY`. Set the env var explicitly to rotate or pin it. The cipher key (`OH_SECRET_KEY`) keeps a static default for local dev since it's used for encrypting/decrypting persisted settings values. - `scripts/ingress.mjs` is a standalone HTTP reverse proxy that can be used independently to route traffic to multiple backends based on URL path prefix. @@ -438,4 +439,12 @@ return new ConversationClient(getAgentServerClientOptions()).someMethod(...); - ESLint config (flat, ESLint 9): the project uses `eslint.config.js` (not `.eslintrc`) and runs on `eslint@9.x`, not 10. The constraint pinning us below 10 is `eslint-plugin-react@7.37.x`, which still calls `context.getFilename()` at rule-load time — that API was removed in ESLint 10 and `@eslint/compat`'s `fixupPluginRules` does NOT shim it. Don't try to bump eslint past 9 until eslint-plugin-react ships a v10-compatible release. Import rules come from `eslint-plugin-import-x` (the maintained fork of `eslint-plugin-import`) but are registered under both `import-x/` and `import/` prefixes via `plugins: { import: importXPlugin, ... }` so existing `// eslint-disable-next-line import/...` directives keep working. `linterOptions.reportUnusedDisableDirectives` is set to `"warn"` (not "off") so stale airbnb-era disable comments still surface in lint output without failing CI. The TS-overrides block has an `ignores: ["src/hooks/query/query-keys.ts"]` so the `no-restricted-syntax` rule banning raw `["settings", ...]` query keys doesn't fire on the file that defines the helpers themselves. No `.npmrc` / `legacy-peer-deps` flag is needed — all our plugins declare ESLint 9 peer compatibility. +- **Centralized config**: `config/defaults.json` is the single source of truth for version pins (agent-server, automation, automation SDK), port defaults, persistence paths, package names, and the dev secret key. All consumers read from this file: + - JS scripts (`dev-safe.mjs`, `dev-with-automation.mjs`, `check-sdk-version-sync.mjs`) read it via `JSON.parse(readFileSync(...))`. + - Docker: a `config-gen` build stage converts the JSON to `/opt/agent-canvas/defaults.env` (shell-sourceable); `entrypoint.sh` sources it at startup. + - CI workflow: a `Read defaults from config/defaults.json` step uses `node -p` to extract values into `$GITHUB_OUTPUT`. + - Dockerfile ARG defaults are kept as fallbacks for local `docker build` without the CI workflow; CI always passes `--build-arg` overrides from the JSON. + - To bump a version, edit `config/defaults.json` only — the JS scripts, Docker build, and CI workflow all derive their values from it. +- Docker all-in-one image: `.github/workflows/docker.yml` builds and publishes `ghcr.io/openhands/agent-canvas` — a combined image that bundles the agent-server (from `ghcr.io/openhands/agent-server`), the automation server (`openhands-automation` via pip), and the agent-canvas frontend (static build). The Dockerfile lives at `docker/Dockerfile`, the entrypoint at `docker/entrypoint.sh`. The workflow structure mirrors the SDK repo's `server.yml`: a `build-and-push-image` matrix job (2 × arch: amd64 on `ubuntu-24.04`, arm64 on `ubuntu-24.04-arm`) pushes arch-suffixed tags, then `merge-manifests` creates multi-arch manifests via `docker buildx imagetools create`, then `consolidate-build-info` aggregates artifacts, and `update-pr-description` updates the PR body (using `` / `` markers). The workflow triggers on push to main, `v*` tags (releases), PRs, and `workflow_dispatch`. On release tags it also pushes semver tags (e.g. `1.2.3`, `1.2`, `1`, `latest`). Fork PRs are skipped (no GHCR auth). The image exposes port 8000 as a unified entry point: `/api/automation/*` → automation (:18001), `/api/*` → agent-server (:18000), `/*` → static frontend. The entrypoint auto-generates **both** the session API key and `OH_SECRET_KEY` (persisted to `~/.openhands/agent-canvas/session-api-key.txt` and `secret-key.txt` respectively) when none is provided, so the image runs secure by default. Users can override either via env var (`OH_SECRET_KEY`, `SESSION_API_KEY` / `OH_SESSION_API_KEYS_0`). Unlike `scripts/dev-safe.mjs` (which uses a static default secret key for local dev convenience), the Docker entrypoint never falls back to a known default. + - Cloud conversation resume gating: when a cloud conversation is closed from the UI (`pauseCloudSandbox` is called), the conversation's `conversation_url` is NOT cleared -- it still points to the old sandbox host. `WebSocketProviderWrapper` must suppress the URL (pass `null` to `ConversationWebSocketProvider`) while `sandbox_status === "PAUSED"`, otherwise the WebSocket immediately tries the stale URL before the sandbox wakes. Symmetrically, `useActiveConversation`'s refetch interval must fast-poll (3 s) on both `!conversation_url` AND `sandbox_status === "PAUSED"` -- checking only the missing URL would leave the hook on the 30 s interval while the sandbox is resuming. The resume sequence: navigate -> sandbox PAUSED detected -> `resumeCloudSandbox` called (in `conversation.tsx`) -> fast-poll detects RUNNING -> `conversationUrl` unblocked -> WebSocket connects. diff --git a/config/defaults.json b/config/defaults.json new file mode 100644 index 0000000000..6a95a0570e --- /dev/null +++ b/config/defaults.json @@ -0,0 +1,38 @@ +{ + "_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.", + + "versions": { + "agentServer": "1.22.1", + "automation": "1.0.0a3", + "automationSdk": "1.22.1" + }, + + "images": { + "agentServer": "ghcr.io/openhands/agent-server", + "agentCanvas": "ghcr.io/openhands/agent-canvas" + }, + + "ports": { + "agentServer": 18000, + "automation": 18001, + "proxy": 8000 + }, + + "paths": { + "stateSubdir": "agent-canvas", + "conversations": "agent-canvas/conversations", + "bashEvents": "agent-canvas/bash_events", + "automationDb": "automation/automations.db" + }, + + "packages": { + "agentServer": "openhands-agent-server", + "automation": "openhands-automation", + "tools": "openhands-tools", + "workspace": "openhands-workspace" + }, + + "defaults": { + "secretKey": "openhands-dev-secret-key-change-in-prod" + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000000..f4bce09a55 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,131 @@ +# syntax=docker/dockerfile:1.7 + +# ═══════════════════════════════════════════════════════════════════════════════ +# agent-canvas all-in-one Docker image +# +# Combines three services into a single image: +# 1. Agent Server — from ghcr.io/openhands/agent-server (upstream SDK image) +# 2. Automation — installed via pip from openhands-automation +# 3. Frontend — agent-canvas static build served by Node.js +# +# The entrypoint starts all three services and an ingress proxy that unifies +# them behind a single port (default 8000): +# /api/automation/* → automation backend (:18001) +# /api/*, /sockets → agent server (:18000) +# /* (default) → static frontend + SPA fallback +# ═══════════════════════════════════════════════════════════════════════════════ + +# ── Build args ──────────────────────────────────────────────────────────────── +# No hardcoded defaults — values are derived from config/defaults.json. +# CI passes these via --build-arg; for local builds use: +# node scripts/docker-build.mjs (recommended, reads JSON for you) +# docker build --build-arg AGENT_SERVER_IMAGE=... --build-arg AUTOMATION_VERSION=... -f docker/Dockerfile . +ARG AGENT_SERVER_IMAGE +ARG AUTOMATION_VERSION + +# ── Stage 1: Build frontend ────────────────────────────────────────────────── +FROM node:24-slim AS frontend-build + +WORKDIR /build + +# Cache-friendly: package files first +COPY package.json package-lock.json ./ +RUN npm ci + +# Copy everything needed for the build +COPY . . + +# Build the static frontend +RUN npm run build + +# ── Stage 1b: Generate shell-sourceable defaults from config/defaults.json ── +# This avoids needing jq/python at container runtime to parse the JSON. +FROM node:24-slim AS config-gen +COPY config/defaults.json /tmp/defaults.json +RUN node -e " \ + const c = JSON.parse(require('fs').readFileSync('/tmp/defaults.json','utf-8')); \ + const lines = [ \ + 'CONFIG_AGENT_SERVER_PORT=' + c.ports.agentServer, \ + 'CONFIG_AUTOMATION_PORT=' + c.ports.automation, \ + 'CONFIG_PROXY_PORT=' + c.ports.proxy, \ + 'CONFIG_STATE_SUBDIR=' + c.paths.stateSubdir, \ + 'CONFIG_CONVERSATIONS=' + c.paths.conversations, \ + 'CONFIG_BASH_EVENTS=' + c.paths.bashEvents, \ + 'CONFIG_AUTOMATION_DB=' + c.paths.automationDb, \ + ]; \ + require('fs').writeFileSync('/tmp/defaults.env', lines.join('\n') + '\n'); \ +" + +# ── Stage 2: Combined image ────────────────────────────────────────────────── +FROM ${AGENT_SERVER_IMAGE} AS final + +ARG AUTOMATION_VERSION +ARG OPENHANDS_BUILD_GIT_SHA=unknown +ARG OPENHANDS_BUILD_GIT_REF=unknown + +LABEL org.opencontainers.image.title="agent-canvas" +LABEL org.opencontainers.image.description="All-in-one agent-canvas: Agent Server + Automation + Frontend" +LABEL org.opencontainers.image.source="https://github.com/OpenHands/agent-canvas" +LABEL org.opencontainers.image.revision="${OPENHANDS_BUILD_GIT_SHA}" + +ENV AGENT_CANVAS_BUILD_GIT_SHA=${OPENHANDS_BUILD_GIT_SHA} +ENV AGENT_CANVAS_BUILD_GIT_REF=${OPENHANDS_BUILD_GIT_REF} + +USER root + +# Install system deps required by automation's transitive dependencies +# (asyncpg needs libpq, which the agent-server base image may not include). +RUN if command -v apt-get >/dev/null 2>&1; then \ + apt-get update && \ + apt-get install -y --no-install-recommends libpq-dev && \ + rm -rf /var/lib/apt/lists/*; \ + fi + +# Install automation server via pip. +# Shared deps (openhands-sdk, fastapi, uvicorn, pydantic, httpx, …) are +# already satisfied by the agent-server base image, so only automation- +# specific packages (asyncpg, sqlalchemy, boto3, gcloud, …) are added. +RUN uv pip install --system "openhands-automation==${AUTOMATION_VERSION}" 2>/dev/null \ + || pip install --no-cache-dir "openhands-automation==${AUTOMATION_VERSION}" + +# Copy the frontend build output. +# react-router.config.ts unpacks build/client/ into build/ for non-Vercel builds. +COPY --from=frontend-build /build/build /opt/agent-canvas/frontend + +# Copy the static-server script (serves frontend + proxies to backends) +COPY scripts/static-server.mjs /opt/agent-canvas/static-server.mjs + +# Copy custom tools (e.g. canvas_ui_tool.py) so the agent-server can import +# them via tool_module_qualnames. OH_EXTRA_PYTHON_PATH is set in entrypoint.sh. +COPY tools/ /opt/agent-canvas/tools/ + +# Copy generated defaults.env (from config/defaults.json via config-gen stage) +COPY --from=config-gen /tmp/defaults.env /opt/agent-canvas/defaults.env + +# Copy the entrypoint +COPY docker/entrypoint.sh /opt/agent-canvas/entrypoint.sh +RUN chmod +x /opt/agent-canvas/entrypoint.sh + +# Pre-create persistence directories with correct ownership so the +# openhands user can write to them even when Docker creates anonymous +# volumes (which default to root). +RUN mkdir -p /home/openhands/.openhands/agent-canvas/conversations \ + /home/openhands/.openhands/agent-canvas/bash_events \ + /home/openhands/.openhands/automation \ + /projects && \ + chown -R openhands:openhands /home/openhands/.openhands /projects + +USER openhands + +# Persistence volumes: +# /home/openhands/.openhands — settings, secrets, conversations, automation DB +# /projects — user code the agent can read/edit +# Bind-mount these for data to survive container restarts: +# docker run -v ~/.openhands:/home/openhands/.openhands -v ~/projects:/projects ... +VOLUME ["/home/openhands/.openhands", "/projects"] + +# The entrypoint starts all services and the ingress proxy. +# Port 8000 is the unified entry point. +EXPOSE 8000 + +ENTRYPOINT ["tini", "--", "/opt/agent-canvas/entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000000..32e0e544ff --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# ═══════════════════════════════════════════════════════════════════════════════ +# agent-canvas all-in-one entrypoint +# +# Starts three services: +# 1. Agent Server on port $AGENT_SERVER_PORT (default 18000) +# 2. Automation on port $AUTOMATION_PORT (default 18001) +# 3. Static server on port $PORT (default 8000) +# Routes /api/automation/* → automation, /api/* → agent-server, +# and serves the frontend static build for everything else. +# +# Environment variables: +# PORT – Unified entry point port (default: 8000) +# AGENT_SERVER_PORT – Internal agent-server port (default: 18000) +# AUTOMATION_PORT – Internal automation port (default: 18001) +# OH_SECRET_KEY – Secret key for settings encryption (auto-generated +# and persisted if not provided) +# OPENHANDS_AUTOMATION_API_KEY – API key for automation backend auth +# Any agent-server or automation env vars are passed through. +# ═══════════════════════════════════════════════════════════════════════════════ +set -uo pipefail + +log() { printf '[agent-canvas] %s\n' "$*"; } +log_error() { printf '[agent-canvas] ERROR: %s\n' "$*" >&2; } + +# ── Load centralized defaults (generated from config/defaults.json at build) ─ +# shellcheck source=/dev/null +if [ -f /opt/agent-canvas/defaults.env ]; then + # shellcheck disable=SC1091 + . /opt/agent-canvas/defaults.env +fi + +PORT="${PORT:-${CONFIG_PROXY_PORT:-8000}}" +AGENT_SERVER_PORT="${AGENT_SERVER_PORT:-${CONFIG_AGENT_SERVER_PORT:-18000}}" +AUTOMATION_PORT="${AUTOMATION_PORT:-${CONFIG_AUTOMATION_PORT:-18001}}" + +# Persistence paths — keep settings, conversations, bash history under a +# single well-known directory that the VOLUME directive exposes. +OPENHANDS_DIR="${HOME}/.openhands" +STATE_DIR="${OPENHANDS_DIR}/${CONFIG_STATE_SUBDIR:-agent-canvas}" +export OH_PERSISTENCE_DIR="${OH_PERSISTENCE_DIR:-${OPENHANDS_DIR}}" +export OH_CONVERSATIONS_PATH="${OH_CONVERSATIONS_PATH:-${OPENHANDS_DIR}/${CONFIG_CONVERSATIONS:-agent-canvas/conversations}}" +export OH_BASH_EVENTS_DIR="${OH_BASH_EVENTS_DIR:-${OPENHANDS_DIR}/${CONFIG_BASH_EVENTS:-agent-canvas/bash_events}}" + +# OH_SECRET_KEY is required for settings/secrets encryption. Without it the +# agent-server refuses to return encrypted secrets → conversation creation +# fails with a 503. Auto-generate and persist (just like the session API key) +# so the image never runs with a known default. +SECRET_KEY_FILE="${STATE_DIR}/secret-key.txt" +if [ -z "${OH_SECRET_KEY:-}" ]; then + if [ -f "$SECRET_KEY_FILE" ]; then + OH_SECRET_KEY="$(cat "$SECRET_KEY_FILE")" + else + OH_SECRET_KEY="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" + mkdir -p "$(dirname "$SECRET_KEY_FILE")" + printf '%s' "$OH_SECRET_KEY" > "$SECRET_KEY_FILE" + chmod 600 "$SECRET_KEY_FILE" + log "Generated OH_SECRET_KEY (persisted to $SECRET_KEY_FILE)" + fi +fi +export OH_SECRET_KEY + +# Session API key — generate one if not provided so the image doesn't run +# wide-open by default. Persisted so restarts reuse the same key. +SESSION_KEY_FILE="${STATE_DIR}/session-api-key.txt" +if [ -z "${OH_SESSION_API_KEYS_0:-}" ] && [ -z "${SESSION_API_KEY:-}" ]; then + if [ -f "$SESSION_KEY_FILE" ]; then + SESSION_API_KEY="$(cat "$SESSION_KEY_FILE")" + else + SESSION_API_KEY="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" + mkdir -p "$(dirname "$SESSION_KEY_FILE")" + printf '%s' "$SESSION_API_KEY" > "$SESSION_KEY_FILE" + chmod 600 "$SESSION_KEY_FILE" + log "Generated session API key (persisted to $SESSION_KEY_FILE)" + fi + export OH_SESSION_API_KEYS_0="$SESSION_API_KEY" +fi + +# AGENT_SERVER_URL — needed by automation sandbox callbacks. +export AGENT_SERVER_URL="${AGENT_SERVER_URL:-http://127.0.0.1:${AGENT_SERVER_PORT}}" + +# Make custom tools (e.g. canvas_ui_tool.py) importable by the agent-server +# via tool_module_qualnames. Matches what scripts/dev-safe.mjs does with +# OH_EXTRA_PYTHON_PATH: config.canvasToolsDir. +export OH_EXTRA_PYTHON_PATH="${OH_EXTRA_PYTHON_PATH:-/opt/agent-canvas/tools}" + +# Track child PIDs so we can clean up on exit. +PIDS=() + +cleanup() { + log "Shutting down..." + for pid in "${PIDS[@]}"; do + kill "$pid" 2>/dev/null || true + done + wait 2>/dev/null || true +} +trap cleanup EXIT SIGINT SIGTERM + +# ── 1. Start Agent Server ──────────────────────────────────────────────────── +log "Starting agent-server on port $AGENT_SERVER_PORT..." + +if command -v openhands-agent-server >/dev/null 2>&1; then + # Binary build (production image) + openhands-agent-server --port "$AGENT_SERVER_PORT" & +elif [ -x /agent-server/.venv/bin/python ]; then + # Source build (development image) + /agent-server/.venv/bin/python -m openhands.agent_server --port "$AGENT_SERVER_PORT" & +else + log_error "Cannot find agent-server binary or source venv." + exit 1 +fi +PIDS+=($!) + +# ── 2. Start Automation Server ─────────────────────────────────────────────── +log "Starting automation server on port $AUTOMATION_PORT..." + +# Disable the automation's own frontend — agent-canvas provides the UI. +export AUTOMATION_FRONTEND_DIR="" + +# Default to SQLite so the automation server works out of the box without +# an external PostgreSQL instance. Users can override AUTOMATION_DB_URL to +# point at a real Postgres for production deployments. +if [ -z "${AUTOMATION_DB_URL:-}" ]; then + AUTOMATION_DB_FILE="${OPENHANDS_DIR}/${CONFIG_AUTOMATION_DB:-automation/automations.db}" + mkdir -p "$(dirname "$AUTOMATION_DB_FILE")" + export AUTOMATION_DB_URL="sqlite+aiosqlite:///${AUTOMATION_DB_FILE}" + log "Using SQLite database: $AUTOMATION_DB_URL" +fi + +# The automation server uses uvicorn. Set AUTOMATION_PORT via its CLI. +if command -v uvicorn >/dev/null 2>&1; then + uvicorn openhands.automation.app:app \ + --host 0.0.0.0 \ + --port "$AUTOMATION_PORT" & + PIDS+=($!) +elif python -c "import openhands.automation" 2>/dev/null; then + python -m uvicorn openhands.automation.app:app \ + --host 0.0.0.0 \ + --port "$AUTOMATION_PORT" & + PIDS+=($!) +else + log "WARNING: Automation server not found, skipping." +fi + +# ── 3. Wait for backends to be ready ───────────────────────────────────────── +wait_for_port() { + local port=$1 name=$2 max_wait=${3:-30} + local elapsed=0 + while ! (echo >/dev/tcp/127.0.0.1/"$port") 2>/dev/null; do + sleep 1 + elapsed=$((elapsed + 1)) + if [ "$elapsed" -ge "$max_wait" ]; then + log "WARNING: $name on port $port did not become ready within ${max_wait}s" + return 1 + fi + done + log "$name is ready on port $port" +} + +wait_for_port "$AGENT_SERVER_PORT" "Agent Server" 60 & +WAIT_PID1=$! +wait_for_port "$AUTOMATION_PORT" "Automation Server" 60 & +WAIT_PID2=$! +wait "$WAIT_PID1" "$WAIT_PID2" + +# ── 4. Start static server (frontend + proxy) ──────────────────────────────── +log "Starting frontend + proxy on port $PORT..." + +node /opt/agent-canvas/static-server.mjs \ + --port "$PORT" \ + --host 0.0.0.0 \ + --dir /opt/agent-canvas/frontend \ + --route "/api/automation=http://127.0.0.1:${AUTOMATION_PORT}" \ + --route "/api=http://127.0.0.1:${AGENT_SERVER_PORT}" \ + --route "/server_info=http://127.0.0.1:${AGENT_SERVER_PORT}" \ + --route "/sockets=http://127.0.0.1:${AGENT_SERVER_PORT}" \ + --route "/alive=http://127.0.0.1:${AGENT_SERVER_PORT}" \ + --route "/health=http://127.0.0.1:${AGENT_SERVER_PORT}" \ + --route "/ready=http://127.0.0.1:${AGENT_SERVER_PORT}" \ + --route "/docs=http://127.0.0.1:${AGENT_SERVER_PORT}" \ + --route "/redoc=http://127.0.0.1:${AGENT_SERVER_PORT}" \ + --route "/openapi.json=http://127.0.0.1:${AGENT_SERVER_PORT}" & +PIDS+=($!) + +log "All services started. Unified entry point: http://0.0.0.0:${PORT}/" + +# Wait for any child to exit. If one dies, the trap will clean up the rest. +wait -n "${PIDS[@]}" 2>/dev/null +EXIT_CODE=$? +log_error "A service exited with code $EXIT_CODE" +exit "$EXIT_CODE" diff --git a/package.json b/package.json index 43dba5b4dd..657226e914 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,8 @@ "typecheck:staged": "react-router typegen && npx tsc --noEmit --skipLibCheck", "check-translation-completeness": "node scripts/check-translation-completeness.cjs", "build:app": "npm run make-i18n && react-router build", - "build:lib": "npm run make-i18n && react-router typegen && cross-env BUILD_LIB=true VITE_APP_ENV=production vite build && tsc -p tsconfig.lib.json" + "build:lib": "npm run make-i18n && react-router typegen && cross-env BUILD_LIB=true VITE_APP_ENV=production vite build && tsc -p tsconfig.lib.json", + "build:docker": "node scripts/docker-build.mjs" }, "lint-staged": { "src/**/*.{ts,tsx,js}": [ diff --git a/scripts/check-sdk-version-sync.mjs b/scripts/check-sdk-version-sync.mjs index 1e45048ef1..cd1cee9f2b 100644 --- a/scripts/check-sdk-version-sync.mjs +++ b/scripts/check-sdk-version-sync.mjs @@ -11,9 +11,9 @@ * - openhands-agent-server * * This script checks the RELEASED PyPI version of openhands-automation (as specified - * by DEFAULT_AUTOMATION_VERSION in dev-with-automation.mjs), not the main branch. - * DEFAULT_AUTOMATION_SDK_VERSION records the SDK dependency version for that - * released automation package and may intentionally lag DEFAULT_AGENT_SERVER_VERSION. + * by versions.automation in config/defaults.json), not the main branch. + * versions.automationSdk records the SDK dependency version for that + * released automation package and may intentionally lag versions.agentServer. * * This script is run in CI to catch version drift between projects. * @@ -23,9 +23,9 @@ * node scripts/check-sdk-version-sync.mjs --check-pypi * * Environment variables: - * EXPECTED_SDK_VERSION - Override the expected version (instead of reading from dev-with-automation.mjs) + * EXPECTED_SDK_VERSION - Override the expected version (instead of reading from config/defaults.json) * AUTOMATION_PACKAGE_NAME - Override the automation package name (default: openhands-automation) - * AUTOMATION_PACKAGE_VERSION - Override the automation package version (instead of reading from dev-with-automation.mjs) + * AUTOMATION_PACKAGE_VERSION - Override the automation package version (instead of reading from config/defaults.json) * * Options: * --check-pypi Also check the latest SDK version on PyPI @@ -56,10 +56,9 @@ SDK Version Sync Check Verifies that the released openhands-automation package on PyPI uses the SDK version expected for that automation release. -The automation version is read from DEFAULT_AUTOMATION_VERSION in -dev-with-automation.mjs (currently used for local development). The expected -SDK dependency version is read from DEFAULT_AUTOMATION_SDK_VERSION when present, -falling back to DEFAULT_AGENT_SERVER_VERSION for older configs. +The automation version is read from config/defaults.json (versions.automation). +The expected SDK dependency version is read from versions.automationSdk, +falling back to versions.agentServer for older configs. Usage: node scripts/check-sdk-version-sync.mjs [options] @@ -69,9 +68,9 @@ Options: --help, -h Show this help Environment variables: - EXPECTED_SDK_VERSION Override the expected SDK version (instead of reading from dev-with-automation.mjs) + EXPECTED_SDK_VERSION Override the expected SDK version (instead of reading from config/defaults.json) AUTOMATION_PACKAGE_NAME Override the automation package name (default: openhands-automation) - AUTOMATION_PACKAGE_VERSION Override the automation package version (instead of reading from dev-with-automation.mjs) + AUTOMATION_PACKAGE_VERSION Override the automation package version (instead of reading from config/defaults.json) Triggering from other repos: The automation repo or SDK repo can trigger this check via GitHub repository_dispatch: @@ -143,27 +142,31 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -/** - * Read the default agent-server SDK version from dev-safe.mjs. - */ -function getDefaultAgentServerVersion() { - const devSafePath = join(projectRoot, "scripts", "dev-safe.mjs"); - const content = readFileSync(devSafePath, "utf8"); - - const match = content.match( - /const DEFAULT_AGENT_SERVER_VERSION = "([^"]+)"/, +// ── Centralized config ────────────────────────────────────────────────────── +let SHARED_DEFAULTS; +try { + SHARED_DEFAULTS = JSON.parse( + readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"), ); - if (!match) { - throw new Error( - "Could not find DEFAULT_AGENT_SERVER_VERSION in dev-safe.mjs", - ); + if (!SHARED_DEFAULTS.versions?.agentServer || !SHARED_DEFAULTS.versions?.automationSdk) { + throw new Error("missing required fields: versions.agentServer, versions.automationSdk"); } - return { version: match[1], source: "dev-safe.mjs" }; +} catch (err) { + console.error(`${colors.red}Failed to load config/defaults.json: ${err.message}${colors.reset}`); + console.error("Ensure the file exists and contains valid JSON with required fields."); + process.exit(1); } /** - * Read the expected automation SDK dependency version from environment, - * dev-with-automation.mjs, or dev-safe.mjs. + * Read the default agent-server SDK version from config/defaults.json. + */ +function getDefaultAgentServerVersion() { + return { version: SHARED_DEFAULTS.versions.agentServer, source: "config/defaults.json" }; +} + +/** + * Read the expected automation SDK dependency version from environment + * or config/defaults.json. */ function getExpectedVersion() { // Allow override via environment variable (useful for CI triggers). @@ -172,19 +175,10 @@ function getExpectedVersion() { return { version: envVersion.trim(), source: "EXPECTED_SDK_VERSION env var" }; } - const devAutomationPath = join(projectRoot, "scripts", "dev-with-automation.mjs"); - const content = readFileSync(devAutomationPath, "utf8"); - const match = content.match( - /const DEFAULT_AUTOMATION_SDK_VERSION = "([^"]+)"/, - ); - if (match) { - return { - version: match[1], - source: "DEFAULT_AUTOMATION_SDK_VERSION in dev-with-automation.mjs", - }; - } - - return getDefaultAgentServerVersion(); + return { + version: SHARED_DEFAULTS.versions.automationSdk, + source: "config/defaults.json (versions.automationSdk)", + }; } /** @@ -205,7 +199,7 @@ async function fetchPyPIVersion(packageName) { } /** - * Read the automation version from env var or dev-with-automation.mjs + * Read the automation version from env var or config/defaults.json */ function getAutomationVersion() { // Allow override via environment variable @@ -214,18 +208,10 @@ function getAutomationVersion() { return { version: envVersion.trim(), source: "AUTOMATION_PACKAGE_VERSION env var" }; } - const devAutomationPath = join(projectRoot, "scripts", "dev-with-automation.mjs"); - const content = readFileSync(devAutomationPath, "utf8"); - - const match = content.match( - /const DEFAULT_AUTOMATION_VERSION = "([^"]+)"/, - ); - if (!match) { - throw new Error( - "Could not find DEFAULT_AUTOMATION_VERSION in dev-with-automation.mjs", - ); - } - return { version: match[1], source: "dev-with-automation.mjs" }; + return { + version: SHARED_DEFAULTS.versions.automation, + source: "config/defaults.json (versions.automation)", + }; } /** @@ -324,7 +310,7 @@ async function main() { console.log(""); try { - // Get expected version from env var or dev-safe.mjs + // Get expected version from env var or config/defaults.json const { version: expectedVersion, source: versionSource } = getExpectedVersion(); console.log( `Expected automation SDK version: ${colors.green}${expectedVersion}${colors.reset} (from ${versionSource})`, @@ -337,7 +323,7 @@ async function main() { ); } - // Get automation version from env var or dev-with-automation.mjs + // Get automation version from env var or config/defaults.json const { version: automationVersion, source: automationSource } = getAutomationVersion(); console.log( `Automation package: ${colors.cyan}${AUTOMATION_PACKAGE_NAME}==${automationVersion}${colors.reset} (from ${automationSource})`, @@ -427,13 +413,13 @@ async function main() { console.log(""); console.log("To fix, update one of the following:"); console.log( - ` 1. Update DEFAULT_AUTOMATION_SDK_VERSION in scripts/dev-with-automation.mjs to match the automation release`, + ` 1. Update versions.automationSdk in config/defaults.json to match the automation release`, ); console.log( ` 2. Release a new version of ${AUTOMATION_PACKAGE_NAME} with SDK dependencies pinned to ${expectedVersion}`, ); console.log( - ` 3. Update DEFAULT_AUTOMATION_VERSION in scripts/dev-with-automation.mjs to a newer release`, + ` 3. Update versions.automation in config/defaults.json to a newer release`, ); console.log(""); process.exit(1); diff --git a/scripts/dev-safe.mjs b/scripts/dev-safe.mjs index b79c763adf..32cfecab8c 100644 --- a/scripts/dev-safe.mjs +++ b/scripts/dev-safe.mjs @@ -22,10 +22,16 @@ import { signalProcessTree, } from "./dev-process-utils.mjs"; -const DEFAULT_BACKEND_PORT = 18000; +// ── Centralized config (single source of truth for versions, ports, etc.) ─── +const __dev_safe_dirname = path.dirname(fileURLToPath(import.meta.url)); +const SHARED_DEFAULTS = JSON.parse( + readFileSync(path.join(__dev_safe_dirname, "..", "config", "defaults.json"), "utf-8"), +); + +const DEFAULT_BACKEND_PORT = SHARED_DEFAULTS.ports.agentServer; const DEFAULT_VITE_PORT = 3001; const DEFAULT_WAIT_TIMEOUT_MS = 30_000; -const DEFAULT_AGENT_SERVER_PACKAGE = "openhands-agent-server"; +const DEFAULT_AGENT_SERVER_PACKAGE = SHARED_DEFAULTS.packages.agentServer; const AGENT_SERVER_GIT_REPO = "https://github.com/OpenHands/software-agent-sdk"; const LOCAL_AGENT_SERVER_SUBDIRS = [ "openhands-agent-server", @@ -33,12 +39,8 @@ const LOCAL_AGENT_SERVER_SUBDIRS = [ "openhands-tools", "openhands-workspace", ]; -// Default secret key for local development (DO NOT use in production) -// This is kept static because it's used for encrypting/decrypting persisted settings -const DEFAULT_SECRET_KEY = "openhands-dev-secret-key-change-in-prod"; -// Default agent-server version (released PyPI version) -// Set OH_AGENT_SERVER_GIT_REF to use a git branch/SHA instead -const DEFAULT_AGENT_SERVER_VERSION = "1.22.1"; +const DEFAULT_SECRET_KEY = SHARED_DEFAULTS.defaults.secretKey; +const DEFAULT_AGENT_SERVER_VERSION = SHARED_DEFAULTS.versions.agentServer; const FRONTEND_REQUIRED_BINS = ["cross-env", "react-router"]; /** diff --git a/scripts/dev-with-automation.mjs b/scripts/dev-with-automation.mjs index 11f660a6dd..1508e262fb 100644 --- a/scripts/dev-with-automation.mjs +++ b/scripts/dev-with-automation.mjs @@ -39,7 +39,7 @@ */ import { spawn, spawnSync } from "node:child_process"; -import { mkdirSync, existsSync } from "node:fs"; +import { mkdirSync, existsSync, readFileSync } from "node:fs"; import { join, resolve, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { homedir } from "node:os"; @@ -67,16 +67,19 @@ import { const __dirname = dirname(fileURLToPath(import.meta.url)); const projectRoot = resolve(__dirname, ".."); +// ── Centralized config (single source of truth for versions, ports, etc.) ─── +const SHARED_DEFAULTS = JSON.parse( + readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"), +); + const DEFAULT_AUTOMATION_REPO = "https://github.com/OpenHands/automation"; -const DEFAULT_AUTOMATION_PACKAGE = "openhands-automation"; -// Default automation version (released PyPI version) -// Set OH_AUTOMATION_GIT_REF to use a git branch/SHA instead -const DEFAULT_AUTOMATION_VERSION = "1.0.0a3"; +const DEFAULT_AUTOMATION_PACKAGE = SHARED_DEFAULTS.packages.automation; +const DEFAULT_AUTOMATION_VERSION = SHARED_DEFAULTS.versions.automation; // SDK version used by DEFAULT_AUTOMATION_VERSION. This can intentionally lag -// DEFAULT_AGENT_SERVER_VERSION while automation releases catch up. -const DEFAULT_AUTOMATION_SDK_VERSION = "1.22.1"; -const DEFAULT_BACKEND_PORT = 18000; -const DEFAULT_AUTOMATION_PORT = 18001; +// the agent-server version while automation releases catch up. +const DEFAULT_AUTOMATION_SDK_VERSION = SHARED_DEFAULTS.versions.automationSdk; +const DEFAULT_BACKEND_PORT = SHARED_DEFAULTS.ports.agentServer; +const DEFAULT_AUTOMATION_PORT = SHARED_DEFAULTS.ports.automation; // Where the auto-generated default automation API key is persisted. Static // frontend builds bake VITE_AUTOMATION_API_KEY at build time, so the default // must remain stable across restarts and --skip-build reuse. diff --git a/scripts/docker-build.mjs b/scripts/docker-build.mjs new file mode 100644 index 0000000000..8474b1c992 --- /dev/null +++ b/scripts/docker-build.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * Local Docker build helper. + * + * Reads version pins from config/defaults.json and invokes `docker build` + * with the correct --build-arg values so developers never need to remember + * (or hardcode) version strings. + * + * Usage: + * node scripts/docker-build.mjs # defaults + * node scripts/docker-build.mjs --tag my-tag # custom tag + * node scripts/docker-build.mjs -- --no-cache # extra docker args + */ +import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, ".."); + +const config = JSON.parse( + readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"), +); + +const agentServerImage = `${config.images.agentServer}:${config.versions.agentServer}-python`; +const automationVersion = config.versions.automation; + +// Parse CLI: --tag and everything after -- is passed to docker build +let tag = "agent-canvas:local"; +const extraArgs = []; +const args = process.argv.slice(2); +for (let i = 0; i < args.length; i++) { + if (args[i] === "--tag" && i + 1 < args.length) { + tag = args[++i]; + } else if (args[i] === "--") { + extraArgs.push(...args.slice(i + 1)); + break; + } else { + extraArgs.push(args[i]); + } +} + +const cmd = [ + "docker", + "build", + "-f", + "docker/Dockerfile", + "--build-arg", + `AGENT_SERVER_IMAGE=${agentServerImage}`, + "--build-arg", + `AUTOMATION_VERSION=${automationVersion}`, + "-t", + tag, + ...extraArgs, + ".", +]; + +console.log(`Agent Server image : ${agentServerImage}`); +console.log(`Automation version : ${automationVersion}`); +console.log(`Tag : ${tag}`); +console.log(`\n$ ${cmd.join(" ")}\n`); + +try { + execFileSync(cmd[0], cmd.slice(1), { + cwd: projectRoot, + stdio: "inherit", + }); +} catch (err) { + process.exit(err.status || 1); +} diff --git a/src/mocks/conversation-handlers.ts b/src/mocks/conversation-handlers.ts index 685d2775d8..e0e72dad77 100644 --- a/src/mocks/conversation-handlers.ts +++ b/src/mocks/conversation-handlers.ts @@ -78,10 +78,8 @@ const conversations: MockConversation[] = [ { id: PAGINATION_LOCAL_CONVERSATION_ID, title: "Local pagination fixture", - created_at: new Date(PAGINATION_BASE_TIME).toISOString(), - updated_at: new Date( - PAGINATION_BASE_TIME + PAGINATION_EVENT_COUNT * 60_000, - ).toISOString(), + created_at: new Date(now - 6 * 24 * 60 * 60 * 1000).toISOString(), + updated_at: new Date(now - 6 * 24 * 60 * 60 * 1000).toISOString(), execution_status: "idle", workspace: { working_dir: "/workspace/project" }, }, @@ -160,10 +158,8 @@ async function maybeReturnPaginationEvents( } function createCloudPaginationConversation(): AppConversation { - const createdAt = new Date(PAGINATION_BASE_TIME).toISOString(); - const updatedAt = new Date( - PAGINATION_BASE_TIME + PAGINATION_EVENT_COUNT * 60_000, - ).toISOString(); + const createdAt = new Date(now - 6 * 24 * 60 * 60 * 1000).toISOString(); + const updatedAt = createdAt; return { id: PAGINATION_CLOUD_CONVERSATION_ID,