Files
OpenHands/scripts/dev-with-automation.mjs
T
dcf469855a UI polish: drawer tabs, empty states, and browser chrome (#1288)
* chore: bump version to 1.0.0-beta.1

* chore: publish beta and rc versions as 'latest' dist-tag

* chore: bump version to 1.0.0-beta.2

* fix: use X-Session-API-Key for local automation auth in prompts and RUNTIME_SERVICES (#999)

Fixes #980

The agent prompt in recommended-automations-launcher and the
RUNTIME_SERVICES block in agent-server-adapter both advertised
X-API-Key as the auth header for the local automation backend.
The automation service (openhands-automation) does not accept
X-API-Key — it accepts Authorization: Bearer and X-Session-API-Key.

X-Session-API-Key is the established local convention: the agent
server uses it, the frontend automation API client uses it (with an
explicit comment that both backends share the same header), and
auth.py describes it as matching that convention. Update both call
sites and the corresponding test assertion to use X-Session-API-Key.

Co-authored-by: openhands <openhands@all-hands.dev>

* feat: reuse mock-LLM E2E tests for Docker image validation (#992)

* feat: reuse mock-LLM E2E tests for Docker image validation

Add a Docker-specific Playwright config (playwright.mock-llm-docker.config.ts)
that runs the exact same test specs and helpers against the agent-canvas Docker
image instead of the npm build path (bin/agent-canvas.mjs + uvx).

Key changes:

- Split MOCK_LLM_BASE_URL into two constants in mock-llm-helpers.ts:
  - MOCK_LLM_BASE_URL: always host-local, used by tests for admin API
  - MOCK_LLM_AGENT_URL: env-overridable, used when configuring the LLM
    profile (the URL the agent-server uses for inference). Defaults to
    MOCK_LLM_BASE_URL for backward compatibility with the npm path.

- New playwright.mock-llm-docker.config.ts:
  - Starts the mock LLM server on the host (same as npm path)
  - Runs the Docker container with --network host (Linux CI)
  - Points to the same testDir (tests/e2e/mock-llm/) and specs
  - Separate output dirs to avoid collision with npm path results

- New CI workflow (.github/workflows/mock-llm-docker-e2e.yml):
  - Builds the Docker image from current code (or uses a pre-built image)
  - Runs the same specs against the container
  - Posts PR comment with differentiated report title

- render-mock-llm-report.mjs: accept --title flag for Docker vs npm reports
- npm run test:e2e:mock-llm:docker script added
- .gitignore updated for docker test output dirs

The npm path (test:e2e:mock-llm) is fully backward-compatible — no env var
override needed since MOCK_LLM_AGENT_URL defaults to MOCK_LLM_BASE_URL.

Co-authored-by: openhands <openhands@all-hands.dev>

* refactor: chain Docker E2E off existing Docker CI via workflow_run

Instead of rebuilding the Docker image in the E2E workflow (duplicating
~10-15 min of Docker build time), use workflow_run to trigger automatically
after the existing 'Docker' workflow completes successfully.

The workflow now:
- Triggers on: workflow_run (Docker completed) + workflow_dispatch (manual)
- Derives the image tag from the Docker build's commit SHA
  (ghcr.io/openhands/agent-canvas:sha-<short>-amd64)
- Pulls the already-built image from GHCR — no rebuild needed
- Checks out code at the same SHA as the Docker build
- Extracts PR number from workflow_run.pull_requests[] for comments

Removed: Docker build steps, Buildx setup, build-arg resolution.
All image building stays in docker.yml where it belongs.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: replace flaky 1s timeout with polling for Active badge assertion

The 'Active badge' check in step 2 used a hardcoded 1-second
waitForTimeout before reloading. On a loaded CI runner the profile
activation mutation may not persist in time, causing the reload to
show stale state. This is a pre-existing flake (identical test code
passed on the first push and failed on the second).

Replace with expect.poll() that retries the reload+check cycle with
increasing intervals (1s, 2s, 3s) up to 15 seconds total.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: add pull_request trigger for Docker E2E (workflow_run bootstrap)

workflow_run only fires when the workflow file exists on the default
branch (main). Since mock-llm-docker-e2e.yml is new and only on the
PR branch, GitHub doesn't recognize it as a workflow_run listener yet.

Add pull_request trigger (gated by 'e2e-tests' label, skip forks) that
polls the Docker workflow via gh API until it completes for the PR's
head SHA, then pulls the already-built image from GHCR and runs tests.

After merge, workflow_run takes over as the primary automatic trigger.
The pull_request path remains as a fallback for label-gated runs.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: add FILE_STORE, AUTOMATION_BASE_URL, AUTOMATION_WORKSPACE_BASE to Docker entrypoint

The Docker entrypoint was missing several environment variables that the npm
path (dev-with-automation.mjs) sets for the automation backend:

- FILE_STORE=local — without this, the automation backend may fall back to
  cloud storage (S3/GCS) which fails without credentials, causing tarball-
  based presets (preset/prompt, preset/plugin) to silently error
- LOCAL_STORAGE_PATH — where to store files on the local filesystem
- AUTOMATION_BASE_URL — publicly-reachable base URL for callback URLs
- AUTOMATION_WORKSPACE_BASE — where automation runs unpack tarballs

This explains the Docker E2E failure: the agent's curl to create an automation
via /api/automation/v1/preset/prompt returned an error (likely 500 from missing
storage config), but the mock LLM doesn't care about terminal output and
proceeded to return the scripted final reply. The test then found 0 automations.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: exclude auth-modes spec from Docker E2E tests

The mock-llm-auth-modes.spec.ts tests npm-binary-specific --auth-required
behaviour (a second static-server instance on port 18301). The Docker image
doesn't provide this second server — it has its own auth handling. Exclude
the spec from the Docker test run via testIgnore.

Co-authored-by: openhands <openhands@all-hands.dev>

* feat: run auth-modes tests inside Docker via PUBLIC_MODE_PORT

Instead of excluding the auth-modes spec from the Docker E2E run or
spinning up a host-side static server with a duplicate build/ directory,
the Docker entrypoint now supports an optional PUBLIC_MODE_PORT env var.

When set, entrypoint.sh starts a second static-server instance from the
same baked-in frontend assets with --auth-required (no session key
injected). This tests the actual Docker image's auth gate behaviour —
not a host-side approximation.

The Playwright Docker config passes -e PUBLIC_MODE_PORT=18301 to the
container and exports MOCK_LLM_PUBLIC_MODE_URL so the auth-modes spec
can reach it. With --network host the port is accessible from the host.

Co-authored-by: openhands <openhands@all-hands.dev>

* address review feedback: drop unlabeled trigger, improve error messages, document env vars

- Drop 'unlabeled' from pull_request trigger types to avoid wasted
  workflow runs when any label is removed (the job-level if: condition
  would skip immediately anyway)
- Distinguish 'no Docker run found' vs 'didn't complete in time' in
  the polling loop's final error message
- Add comment explaining /api/automation/v1 probe returns 200 without
  auth so the readiness check won't spin for 180s
- Document FILE_STORE, LOCAL_STORAGE_PATH, AUTOMATION_BASE_URL, and
  AUTOMATION_WORKSPACE_BASE in the entrypoint header — these affect
  production deployments, not just E2E tests

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: bump version to 1.0.0-beta.3

* ci: trigger CI on rel-* branch pushes for tag protection rule (#1004)

The Release Tag ruleset requires test-and-build (ubuntu) to pass
before v* tags can be pushed, but CI previously only ran on main and
pull_request events. This caused rel-* version bump commits to fail
the tag protection check unless a workaround PR was opened.

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: bump version to 1.0.0-beta.4

* chore: auto-graduate npm dist-tag from latest to per-tier once first stable release ships (#1028)

* chore: always publish to npm with --tag latest until first stable release

All alpha/beta/rc versions now get the 'latest' dist-tag so plain
'npm install @openhands/agent-canvas' always resolves to the newest
published release. The per-tier dist-tags (alpha/beta/rc) can be
re-introduced once the first full stable version is ready to ship.

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: auto-graduate npm dist-tag when first stable release ships

At publish time, query npm for any published version without a pre-release
suffix. If none exists, all releases (alpha/beta/rc/stable) use --tag latest
so plain 'npm install' always resolves to the newest build. Once a stable
version has been published, pre-release versions revert to their own
dist-tags (alpha/beta/rc) automatically — no workflow change required.

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: bump version to 1.0.0-beta.5

* feat(mcp): render markdown links in helperText; update Slack catalog pin (#1012)

* feat(mcp): render markdown links in helperText; bump extensions to slack field-order PR commit

- Add renderHelperText() to install-server-modal.tsx that converts
  [text](url) patterns into <a> elements with target=_blank, so the
  Slack workspace-ID helper text (and any future catalog entries) can
  embed clickable docs links inline.
- Bump @openhands/extensions to commit 2d43e9c (branch
  slack-catalog-field-order-and-helper-links, PR #285) which:
    • moves SLACK_TEAM_ID before SLACK_BOT_TOKEN in the install modal
    • replaces the plain SLACK_TEAM_ID helper text with linked copy:
      'First visit [here](...#find-your-url) to get your Slack URL
       and then visit [here](...#find-your-workspace-or-org-id) to
       get your workspace ID.'
- Removes stale integrity hash from package-lock.json for the
  @openhands/extensions entry; npm install will recompute it.

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: bump @openhands/extensions to d186872 (SLACK_BOT_TOKEN helperText)

Add inline linked helperText for SLACK_BOT_TOKEN in slack.json (PR #285,
commit d186872): 'You'll need to create or update a Slack App as shown
[here](https://github.com/zencoderai/slack-mcp-server#slack-bot-setup).'
Drops the now-redundant helperLink field.

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: bump @openhands/extensions to b45d3a1 (SLACK_TEAM_ID helperText rewrite)

Update SLACK_TEAM_ID helperText to named links:
'First get your [Slack URL](...). Then use that to get your [Workspace ID](...).'

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: bump @openhands/extensions to 84a0a6e (SLACK_BOT_TOKEN named link)

Update SLACK_BOT_TOKEN helperText to:
"You'll need to create or update a [Slack App](...#slack-bot-setup)."

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: bump @openhands/extensions to e07f427 (SLACK_BOT_TOKEN helperText)

Update SLACK_BOT_TOKEN helperText to:
"You'll need to create or update a [Slack App](...) to get a Bot token"

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: bump @openhands/extensions to 5efd1b8

Sync to latest commit on slack-catalog-field-order-and-helper-links (PR #285).

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: bump @openhands/extensions to 952c759

Sync to latest commit on slack-catalog-field-order-and-helper-links (PR #285).

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: bump @openhands/extensions to f30dbfb

Sync to latest commit on slack-catalog-field-order-and-helper-links (PR #285).

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: bump @openhands/extensions to 02715f4

Sync to latest commit on slack-catalog-field-order-and-helper-links (PR #285).

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: bump @openhands/extensions to cb092c8

Sync to latest commit on slack-catalog-field-order-and-helper-links (PR #285).

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(mcp): validate URL scheme in renderHelperText; use matchAll

- Guard href against javascript:/data: XSS via /^https?:\/\//i test
- Replace exec-in-while with matchAll to drop the eslint-disable comment

Addresses review bot feedback on PR #1012.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix(mcp): use double quotes for fallback href to satisfy Prettier

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: update @openhands/extensions to latest main (62594156)

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: bump version to 1.0.0-beta.6

* chore: bump version to 1.0.0-beta.7

* fix(mcp): drop duplicate renderHelperText after main merge

* chore: bump version to 1.0.0-beta.8

* docs: update README version to 1.0.0-beta.8

* fix: default LLM setup to Anthropic Claude Opus 4.8 (#1089)

* chore: bump version to 1.0.0-beta.9

* docs: update README version to 1.0.0-beta.9

* docs: update README.windows.md version to 1.0.0-beta.9

* fix(dev): align Vite dev origin with ingress and add chat footer padding

Route modules loaded from :3001 while the app opened on :8000, causing blank
screens on npm run dev. Point Vite server.origin/HMR at the ingress URL and add
bottom spacing under the archived conversation banner footer.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): polish spinners, settings empty states, and archived conversation UX

Remove grey track rings from all loading spinners so only the animated arc
remains visible. Wrap bare settings empty/error messages (SDK schema
unavailable, profile load failures, empty profiles/skills/secrets/MCP) in
the shared bordered empty-state container for visual consistency.

Canonicalize 127.0.0.1 backend URLs to localhost so health probes reach the
ingress proxy instead of Vite HMR on macOS dual-stack dev stacks, and sync
stored default-local backend host alongside the session key.

Disable conversation controls for archived sandboxes (MISSING/ERROR) with
tooltips explaining unavailability, using shared archive-status helpers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): restore light foreground on conversation tab loading state

Use the semantic text-foreground token for the spinner and label so loading copy stays readable on the dark surface background.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): polish conversation tab loading and automations empty state

Conversation tab loading:
- Use TextShimmer on the loading label (same treatment as message sending)
  with block w-full text-center so the sweep flows across the word, not per
  character
- Keep the spinner on text-tertiary-light for readable secondary grey
- Add ConversationTabContentCrossfade to cross-fade between loading and loaded
  content (agent init and lazy tab chunks); content preloads underneath at
  opacity 0 while the overlay fades out over 350ms; reduced-motion falls back
  to an instant swap

Automations empty state:
- Add a top border above the create-instructions section to separate it from
  the hint copy

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): unify drawer empty/loading states and polish browser/files tabs

Align Changes, VS Code, and runtime waiting states with shared drawer patterns, add browser chrome bar with inactive nav when empty, and improve Files tab empty state and tree toggle icon.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): remove browser screenshot rounding and improve panel fill

Drop rounded corners on the screenshot viewer and use min-h-0 flex layout so the browser tab fills the drawer edge-to-edge and collapses correctly.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): polish protip banner, browser chrome, and tab crossfade

Hide non-functional browser nav controls, restyle the changes-tab protip with icon and muted subtext, drop Customize label colons, and fix Suspense fallback setState during render.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): move VS Code to files toolbar and refresh drawer icons

Relocate editor access from the drawer Code tab into a bordered Files toolbar button, swap tab icons to Lucide, add a terminal empty state, and update the VS Code logo asset.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): animate drawer tab label reveal and icon shifts

Use Framer Motion layout transitions so the active tab label expands in and sibling icons slide smoothly when switching drawer tabs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): pin VS Code in drawer tab row and fix tab drag animation

Move VS Code to the drawer header, portal the overflow menu so it is not clipped, and disable tab layout animations while resizing the panel so icons only animate on click.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ui): shrink drawer tab icons to match standard chrome size

Use h-4 w-4 for drawer tab icons so they align with the ellipsis and other inline controls in the top row.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(home): allow changing repo, branch, or workspace before launch

Replace static git-control-bar link chips on the home screen with the same
dropdowns used in the open-workspace and open-repository dialogs so users
can revise their selection until they send the first message.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Revert "feat(home): allow changing repo, branch, or workspace before launch"

This reverts commit 569bf18bd18dbbe2bd2eaec5747737a162079e0e.

* refactor: remove unrelated files

* refactor: remove unrelated files

* refactor: remove unrelated files

* refactor: remove unrelated files

* refactor: remove unrelated files

* refactor: vscode tab

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
Co-authored-by: chuckbutkus <chuck@openhands.dev>
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-06-10 12:35:34 +07:00

1438 lines
49 KiB
JavaScript

/**
* Development Stack with Automation Service
*
* Extends agent-canvas's dev-safe.mjs to additionally run the OpenHands Automation
* backend via uvx. No cloning required - runs directly from git reference.
*
* Uses a standalone ingress proxy to route traffic to multiple backends.
*
* Architecture:
* ┌──────────────────────────────────────────────────────────────────────────┐
* │ http://localhost:8000 (Ingress Proxy) │
* │ /api/automation/* → Automation Backend │
* │ /api/*, /sockets → Agent Server │
* │ /* → Vite Dev Server │
* └──────────────────────────────────────────────────────────────────────────┘
* │ │ │
* ▼ ▼ ▼
* ┌─────────────┐ ┌───────────────┐ ┌──────────────────┐
* │ Vite │ │ Agent Server │ │ Automation │
* │ :3001 │ │ (uvx) :18000 │ │ Backend (uvx) │
* │ │ │ │ │ :18001 │
* └─────────────┘ └───────────────┘ └──────────────────┘
*
* Usage:
* node scripts/dev-with-automation.mjs
* node scripts/dev-with-automation.mjs --automation-ref feat/my-branch
* node scripts/dev-with-automation.mjs --port 12000
*
* Environment variables:
* - PORT: Ingress port (default: 8000)
* - OH_AUTOMATION_GIT_REF: Git ref for automation (default: main)
* - OH_AGENT_SERVER_LOCAL_PATH: Absolute path to a local software-agent-sdk
* checkout. Highest precedence for agent-server source selection: rebuilds
* the agent-server from local source and installs openhands-sdk,
* openhands-tools and openhands-workspace as editable so source edits are
* picked up without manual reinstall.
* - OH_AGENT_SERVER_GIT_REF: Git ref for agent-server
* Secrets:
* The session API key is automatically seeded into agent-server secrets
* as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations.
* Both the agent-server and automation backend use the same key value
* and the same `X-Session-API-Key` header for authentication.
*/
import { spawn, spawnSync } from "node:child_process";
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";
import { setTimeout as delay } from "node:timers/promises";
import process from "node:process";
import {
assertPortsFree,
buildAgentServerCommand,
buildSafeDevConfig,
buildAgentServerEnv,
buildNpmScriptCommand,
buildRuntimeServicesInfo,
formatMissingUvxGuidance,
getOrCreatePersistedApiKey,
validateFrontendDependencies,
validateLocalAgentServerPath,
} from "./dev-safe.mjs";
import {
createShutdownHookRegistry,
getProcessTreeSpawnOptions,
isProcessRunning,
signalProcessTree,
} from "./dev-process-utils.mjs";
import { fileLog, stripAnsi } from "./logger.mjs";
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 = SHARED_DEFAULTS.packages.automation;
const DEFAULT_AUTOMATION_VERSION = SHARED_DEFAULTS.versions.automation;
// SDK version used by DEFAULT_AUTOMATION_VERSION. This can intentionally lag
// 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;
// ═══════════════════════════════════════════════════════════════════════════
// Terminal Styling
// ═══════════════════════════════════════════════════════════════════════════
const c = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
};
function logService(name, message, color = c.reset) {
const ts = new Date().toISOString().split("T")[1].split(".")[0];
console.log(`${c.dim}${ts}${c.reset} ${color}[${name}]${c.reset} ${message}`);
fileLog("info", `[${name}] ${stripAnsi(message)}`);
}
function logStep(step, message) {
console.log(`${c.cyan}[${step}]${c.reset} ${message}`);
fileLog("info", `[${step}] ${message}`);
}
function logSuccess(message) {
console.log(`${c.green}✓${c.reset} ${message}`);
fileLog("info", `✓ ${message}`);
}
function logError(message) {
console.error(`${c.red}✗${c.reset} ${message}`);
fileLog("error", `✗ ${stripAnsi(message)}`);
}
/**
* Parse one JSON log line produced by the SDK's JsonFormatter and return a
* single-line human-readable string + an appropriate ANSI color.
*
* Returns null for non-JSON lines so callers can fall back to the raw text.
*
* @param {string} rawLine
* @returns {{ text: string; color: string } | null}
*/
function parseAgentServerLogLine(rawLine) {
try {
const obj = JSON.parse(rawLine);
if (!obj.levelname || obj.message === undefined) return null;
const level = obj.levelname.padEnd(8);
const location =
obj.filename && obj.lineno ? ` ${obj.filename}:${obj.lineno}` : "";
const text = `${level} ${obj.message}${location}`;
const lvl = obj.levelname;
const color =
lvl === "DEBUG"
? c.dim
: lvl === "WARNING"
? c.yellow
: lvl === "ERROR" || lvl === "CRITICAL"
? c.red
: c.blue;
return { text, color };
} catch {
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Configuration
// ═══════════════════════════════════════════════════════════════════════════
function parseArgs() {
const args = process.argv.slice(2);
const config = {
port: null,
automationGitRef: null,
automationRepo: null,
verbose: false,
static: false,
dynamic: false,
staticDir: null,
skipBuild: false,
public: false,
frontendOnly: false,
backendOnly: false,
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case "-p":
case "--port":
config.port = parseInt(args[++i], 10);
break;
case "--automation-ref":
config.automationGitRef = args[++i];
break;
case "--automation-repo":
config.automationRepo = args[++i];
break;
case "-v":
case "--verbose":
config.verbose = true;
break;
case "--static":
config.static = true;
break;
case "--dynamic":
config.dynamic = true;
break;
case "--static-dir":
config.staticDir = args[++i];
break;
case "--skip-build":
config.skipBuild = true;
break;
case "--public":
config.public = true;
break;
case "--frontend-only":
config.frontendOnly = true;
break;
case "--backend-only":
config.backendOnly = true;
break;
case "-h":
case "--help":
showHelp();
process.exit(0);
}
}
return config;
}
function showHelp() {
console.log(`
Agent Canvas + Automation Development Stack
Runs agent-canvas with the automation backend (via uvx, no clone needed).
Uses a standalone ingress proxy to route traffic.
USAGE:
node scripts/dev-with-automation.mjs [options]
OPTIONS:
-p, --port <port> Ingress port (default: 8000)
--automation-ref <ref> Git ref for automation (branch/tag/SHA)
--automation-repo <url> Git repo URL (default: ${DEFAULT_AUTOMATION_REPO})
--static Serve an existing production build instead of Vite
--static-dir <dir> Static build directory (default: build/)
--skip-build Reuse build/ when the launcher builds static assets
--dynamic Force Vite dev server when a wrapper defaults static
--frontend-only Start only the frontend behind ingress
--backend-only Start only agent-server + automation behind ingress
-v, --verbose Show detailed output
-h, --help Show this help
ENVIRONMENT VARIABLES:
PORT Alternative to --port
OH_AUTOMATION_GIT_REF Git ref for automation (overrides default version)
OH_AUTOMATION_VERSION Specific PyPI version for automation (default: ${DEFAULT_AUTOMATION_VERSION})
OH_AGENT_SERVER_LOCAL_PATH Absolute path to a local software-agent-sdk checkout (highest precedence)
OH_AGENT_SERVER_GIT_REF Git ref for agent-server SDK (overrides default version)
OH_AGENT_SERVER_VERSION Specific PyPI version for agent-server
OH_SECRET_KEY Secret key for sessions
SECRETS:
The session API key is automatically seeded into agent-server secrets
as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations.
Both backends (agent-server and automation) share the same key value.
ACCESS POINTS:
Main UI: http://localhost:PORT/
API Docs: http://localhost:PORT/api/automation/docs
`);
}
/**
* Build the uvx command for running automation backend.
*
* Environment variables (highest precedence first):
* - OH_AUTOMATION_GIT_REF: Git commit SHA or branch name
* - OH_AUTOMATION_VERSION: Specific PyPI version (e.g., "1.0.0a1")
*
* If none are set, defaults to the released version specified by
* DEFAULT_AUTOMATION_VERSION. Set OH_AUTOMATION_GIT_REF to use a
* git branch or commit instead.
*/
function buildAutomationCommand(env = process.env) {
const gitRef = env.OH_AUTOMATION_GIT_REF;
const version = env.OH_AUTOMATION_VERSION;
const repoUrl = env.OH_AUTOMATION_REPO || DEFAULT_AUTOMATION_REPO;
const uvxArgs = [];
let source = "";
if (gitRef) {
// Use git ref - refresh to ensure latest commit is fetched
const gitUrl = `git+${repoUrl}@${gitRef}`;
uvxArgs.push(
"--refresh",
"--from",
gitUrl,
"uvicorn",
"openhands.automation.app:app",
);
source = `git (${gitRef})`;
} else if (version) {
// Use specific PyPI version
uvxArgs.push(
"--from",
`${DEFAULT_AUTOMATION_PACKAGE}==${version}`,
"uvicorn",
"openhands.automation.app:app",
);
source = `PyPI (${version})`;
} else {
// Default to released PyPI version
uvxArgs.push(
"--from",
`${DEFAULT_AUTOMATION_PACKAGE}==${DEFAULT_AUTOMATION_VERSION}`,
"uvicorn",
"openhands.automation.app:app",
);
source = `PyPI (${DEFAULT_AUTOMATION_VERSION}, default)`;
}
return {
command: "uvx",
args: uvxArgs,
source,
};
}
async function buildConfig(args, env = process.env) {
// Apply args to env for buildAutomationCommand
if (args.automationGitRef) {
env.OH_AUTOMATION_GIT_REF = args.automationGitRef;
}
if (args.automationRepo) {
env.OH_AUTOMATION_REPO = args.automationRepo;
}
const frontendOnly = Boolean(args.frontendOnly);
const backendOnly = Boolean(args.backendOnly);
if (frontendOnly && backendOnly) {
throw new Error(
"--frontend-only and --backend-only cannot be used together",
);
}
const launchFrontend = !backendOnly;
const launchAgentServer = !frontendOnly;
const launchAutomation = !frontendOnly;
const isPublic = args.public;
if (isPublic && frontendOnly) {
throw new Error("--public cannot be used with --frontend-only");
}
// In public mode, LOCAL_BACKEND_API_KEY is required — without it the
// auth screen has nothing to validate against.
if (isPublic && !env.LOCAL_BACKEND_API_KEY) {
logError(
"PUBLIC MODE requires LOCAL_BACKEND_API_KEY environment variable.\n" +
" Example: LOCAL_BACKEND_API_KEY=my-secret npm run dev -- --public",
);
process.exit(1);
}
// Preferred ports (from env or defaults).
// OH_CANVAS_SAFE_BACKEND_PORT / OH_CANVAS_SAFE_AUTOMATION_PORT /
// OH_CANVAS_SAFE_VITE_PORT allow tests (and advanced users) to redirect
// internal service ports without affecting the production default.
const preferredIngressPort = args.port || parseInt(env.PORT, 10) || 8000;
const preferredBackendPort =
parseInt(env.OH_CANVAS_SAFE_BACKEND_PORT, 10) || DEFAULT_BACKEND_PORT;
const preferredAutomationPort =
parseInt(env.OH_CANVAS_SAFE_AUTOMATION_PORT, 10) || DEFAULT_AUTOMATION_PORT;
const preferredVitePort = parseInt(env.OH_CANVAS_SAFE_VITE_PORT, 10) || 3001;
// Fail fast if any preferred port for a service in this mode is already in use.
const requiredPorts = [{ name: "ingress", port: preferredIngressPort }];
if (launchAgentServer) {
requiredPorts.push({ name: "agent-server", port: preferredBackendPort });
}
if (launchAutomation) {
requiredPorts.push({ name: "automation", port: preferredAutomationPort });
}
if (launchFrontend) {
requiredPorts.push({ name: "frontend", port: preferredVitePort });
}
logStep("ports", "Checking ports...");
await assertPortsFree(requiredPorts);
const vscodePort = preferredBackendPort + 1000;
// API key — shared by both agent-server and automation backend.
// Both validate it via the `X-Session-API-Key` header.
// LOCAL_BACKEND_API_KEY is the single user-facing env var: if set it's
// used directly; otherwise one is auto-generated and persisted.
const stateDir =
env.OH_CANVAS_SAFE_STATE_DIR ||
join(homedir(), ".openhands", "agent-canvas");
const safeConfig = buildSafeDevConfig(projectRoot, {
...env,
OH_CANVAS_SAFE_STATE_DIR: stateDir,
OH_CANVAS_SAFE_BACKEND_PORT: preferredBackendPort.toString(),
OH_CANVAS_SAFE_VSCODE_PORT: vscodePort.toString(),
});
const sessionApiKey = safeConfig.sessionApiKey;
if (isPublic) {
logService(
"auth",
"PUBLIC MODE — key will NOT be injected into the frontend",
c.yellow,
);
logService(
"auth",
"Users must paste the LOCAL_BACKEND_API_KEY in the browser",
c.dim,
);
}
return {
// Ingress port (main entry point)
ingressPort: preferredIngressPort,
// Service ports (internal)
agentServerPort: preferredBackendPort,
autoBackendPort: preferredAutomationPort,
vitePort: preferredVitePort,
vscodePort,
// Paths
canvasPath: projectRoot,
// Data directories (same as dev-safe.mjs)
stateDir,
// Only bake the host-side workspace path when this launcher also starts
// the agent-server that can read it. In frontend-only mode the backend may
// be a tunnel/remote service, so leave VITE_WORKING_DIR unset unless the
// user explicitly supplied a backend-relative value.
viteWorkingDir: launchAgentServer
? safeConfig.workingDir
: env.VITE_WORKING_DIR,
// Auth — single key for both backends
sessionApiKey,
// Public mode — the session key should NOT be baked into the frontend
isPublic,
frontendOnly,
backendOnly,
launchFrontend,
launchAgentServer,
launchAutomation,
verbose: args.verbose,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// Prerequisites & Setup
// ═══════════════════════════════════════════════════════════════════════════
function commandExists(cmd) {
const result =
process.platform === "win32"
? spawnSync("where.exe", [cmd], { stdio: "pipe" })
: spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "pipe" });
return result.status === 0;
}
function checkPrerequisites({
checkUvx = true,
checkNpm = true,
checkFrontendDependencies = true,
} = {}) {
logStep("1/2", "Checking prerequisites...");
if (checkUvx) {
if (!commandExists("uvx")) {
const uvxGuidance = formatMissingUvxGuidance(projectRoot);
console.error(uvxGuidance);
fileLog("error", stripAnsi(uvxGuidance));
process.exit(1);
}
logSuccess("uvx found");
}
if (checkNpm) {
if (!commandExists("npm")) {
logError("npm is required but not found");
process.exit(1);
}
logSuccess("npm found");
}
if (checkFrontendDependencies) {
try {
validateFrontendDependencies(projectRoot);
} catch (error) {
logError(error instanceof Error ? error.message : String(error));
process.exit(1);
}
logSuccess("frontend dependencies found");
}
}
function ensureDirectories(config) {
const dirs = [
config.stateDir,
// Both agent-server and automation use storage; create it unconditionally
// whenever either backend service runs (i.e. not frontend-only).
...(!config.frontendOnly ? [join(config.stateDir, "storage")] : []),
];
if (config.launchAgentServer) {
dirs.push(
join(config.stateDir, "dev_conversations"),
join(config.stateDir, "workspaces"),
join(config.stateDir, "bash_events"),
);
}
if (config.launchAutomation) {
dirs.push(
// Automation DB directory — matches docker/entrypoint.sh mkdir -p behaviour.
dirname(
join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb),
),
);
}
for (const dir of dirs) {
mkdirSync(dir, { recursive: true });
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Process Management
// ═══════════════════════════════════════════════════════════════════════════
const processes = new Map();
const shutdownHooks = createShutdownHookRegistry((err) => {
logService("cleanup", `Cleanup hook failed: ${err.message}`, c.yellow);
});
function registerShutdownHook(hook) {
return shutdownHooks.add(hook);
}
function spawnService(name, command, args, options = {}) {
const proc = spawn(
command,
args,
getProcessTreeSpawnOptions({
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, ...options.env },
cwd: options.cwd,
shell: process.platform === "win32",
}),
);
const color = options.color || c.reset;
const parseLogLine = options.parseLogLine;
proc.stdout.on("data", (data) => {
data
.toString()
.split("\n")
.filter(Boolean)
.forEach((line) => {
const parsed = parseLogLine ? parseLogLine(line.trim()) : null;
logService(
name,
parsed ? parsed.text : line.trim(),
parsed ? parsed.color : color,
);
});
});
proc.stderr.on("data", (data) => {
data
.toString()
.split("\n")
.filter(Boolean)
.forEach((line) => {
const parsed = parseLogLine ? parseLogLine(line.trim()) : null;
logService(
name,
parsed ? parsed.text : line.trim(),
parsed ? parsed.color : c.yellow,
);
});
});
proc.on("error", (error) => {
logError(`${name} failed to start: ${error.message}`);
});
proc.on("exit", (code, signal) => {
if (code !== 0 && code !== null && !shuttingDown) {
logService(name, `Exited with code ${code}`, c.red);
}
processes.delete(name);
});
processes.set(name, proc);
return proc;
}
async function waitForService(name, url, timeoutMs = 30000) {
const start = Date.now();
let lastError = null;
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (res.ok) {
logService(name, `Ready at ${url}`, c.green);
return true;
}
} catch (err) {
lastError = err;
// Keep trying
}
await delay(500);
}
const elapsed = Math.round((Date.now() - start) / 1000);
logService(name, `Timeout waiting for ${url} after ${elapsed}s`, c.red);
if (lastError) {
logService(name, `Last error: ${lastError.message}`, c.dim);
}
return false;
}
// ═══════════════════════════════════════════════════════════════════════════
// Service Starters
// ═══════════════════════════════════════════════════════════════════════════
const AUTOMATION_ROUTE_PREFIX = "/api/automation";
const AGENT_SERVER_ROUTE_PREFIXES = [
"/api",
"/sockets",
"/server_info",
"/health",
"/ready",
"/alive",
"/docs",
"/redoc",
"/openapi.json",
];
function getLocalServiceRoutes(config) {
const routes = [];
if (config.launchAutomation) {
routes.push([
AUTOMATION_ROUTE_PREFIX,
`http://localhost:${config.autoBackendPort}`,
]);
}
if (config.launchAgentServer) {
for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) {
routes.push([prefix, `http://localhost:${config.agentServerPort}`]);
}
}
return routes;
}
function buildRouteArgs(routes) {
return routes.flatMap(([prefix, url]) => ["--route", `${prefix}=${url}`]);
}
/**
* Build --reject-prefix args for the static server.
* In frontend-only mode, API paths that have no backend should return 503
* instead of being SPA-fallbacked to index.html.
*/
function getRejectPrefixes(config) {
const prefixes = [];
if (!config.launchAutomation) {
prefixes.push(AUTOMATION_ROUTE_PREFIX);
}
if (!config.launchAgentServer) {
for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) {
prefixes.push(prefix);
}
}
return prefixes;
}
function buildRejectPrefixArgs(prefixes) {
return prefixes.flatMap((prefix) => ["--reject-prefix", prefix]);
}
function getFrontendBackend(config) {
return config.launchFrontend ? `http://localhost:${config.vitePort}` : null;
}
function buildViteBackendEnv(config, env = process.env) {
const backendBaseUrl = config.launchAgentServer
? `http://127.0.0.1:${config.ingressPort}`
: (env.VITE_BACKEND_BASE_URL ?? "http://127.0.0.1:8000");
const backendHost = config.launchAgentServer
? `127.0.0.1:${config.ingressPort}`
: (env.VITE_BACKEND_HOST ?? new URL(backendBaseUrl).host);
return {
VITE_BACKEND_HOST: backendHost,
VITE_BACKEND_BASE_URL: backendBaseUrl,
};
}
function buildAgentServerAutomationEnv(config) {
return {
// Make the session API key available to terminal commands spawned by the
// agent-server as OPENHANDS_AUTOMATION_API_KEY. The launcher also seeds
// this into Settings > Secrets, but agents commonly create automations
// with a curl command that references `$OPENHANDS_AUTOMATION_API_KEY`;
// exposing it here keeps that path working even before/without
// secret-registry env expansion.
OPENHANDS_AUTOMATION_API_KEY: config.sessionApiKey,
};
}
function startAgentServer(config) {
logService(
"agent-server",
`Starting on port ${config.agentServerPort}...`,
c.blue,
);
const agentServerCmd = buildAgentServerCommand(process.env);
logService("agent-server", `Using ${agentServerCmd.source}`, c.dim);
// Build safe config for agent-server env vars
const safeConfig = buildSafeDevConfig(config.canvasPath, {
...process.env,
OH_CANVAS_SAFE_STATE_DIR: config.stateDir,
OH_CANVAS_SAFE_BACKEND_PORT: config.agentServerPort.toString(),
OH_CANVAS_SAFE_VSCODE_PORT: config.vscodePort.toString(),
});
const agentServerEnv = {
...buildAgentServerEnv(safeConfig),
...buildAgentServerAutomationEnv(config),
// Ensure the agent-server uses the resolved key from config. This is
// LOCAL_BACKEND_API_KEY when set, or the auto-generated persisted key.
OH_SESSION_API_KEYS_0: config.sessionApiKey,
// Emit structured JSON log lines instead of Rich-formatted output.
// Rich wraps long messages across multiple lines and prepends its own
// timestamp; LOG_JSON=true produces one JSON object per record which
// parseAgentServerLogLine re-formats into a clean single-line entry.
LOG_JSON: "true",
};
spawnService(
"agent-server",
agentServerCmd.command,
[
...agentServerCmd.args,
"--host",
"127.0.0.1",
"--port",
String(config.agentServerPort),
],
{
cwd: safeConfig.workspacesPath,
env: agentServerEnv,
color: c.blue,
parseLogLine: parseAgentServerLogLine,
},
);
}
function startAutomationBackend(config) {
logService(
"automation",
`Starting on port ${config.autoBackendPort}...`,
c.green,
);
const automationCmd = buildAutomationCommand(process.env);
logService("automation", `Using ${automationCmd.source}`, c.dim);
spawnService(
"automation",
automationCmd.command,
[
...automationCmd.args,
"--host",
"127.0.0.1",
"--port",
config.autoBackendPort.toString(),
],
{
cwd: config.stateDir,
env: {
// Force UTF-8 for all Python file I/O (same reason as agent-server;
// see buildAgentServerEnv in dev-safe.mjs).
PYTHONUTF8: "1",
// The URL the automation backend itself uses to call the
// agent-server's REST API (tarball upload + bash dispatch).
//
// Priority:
// 1. AUTOMATION_AGENT_SERVER_URL explicitly set in the user's env
// 2. `localhost:<agentServerPort>`
AUTOMATION_AGENT_SERVER_URL:
process.env.AUTOMATION_AGENT_SERVER_URL ||
`http://localhost:${config.agentServerPort}`,
// The URL exported into the in-sandbox bash chain as
// `AGENT_SERVER_URL` (read by main.py / setup.sh to call back into
// the agent-server).
//
// Priority:
// 1. AUTOMATION_SANDBOX_AGENT_SERVER_URL explicitly set in env
// 2. launcher-provided value
// 3. unset — backend falls back to AUTOMATION_AGENT_SERVER_URL
...(process.env.AUTOMATION_SANDBOX_AGENT_SERVER_URL ||
config.sandboxAgentServerUrl
? {
AUTOMATION_SANDBOX_AGENT_SERVER_URL:
process.env.AUTOMATION_SANDBOX_AGENT_SERVER_URL ||
config.sandboxAgentServerUrl,
}
: {}),
AUTOMATION_AGENT_SERVER_API_KEY: config.sessionApiKey,
// ~/.openhands/automation/automations.db — matches docker/entrypoint.sh.
AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb)}`,
// The automation backend uses this as its publicly-reachable base
// URL: it's appended to callback URLs and injected into each
// sandbox as `AUTOMATION_API_URL` (consumed by setup.sh for
// /sdk-version and by the SDK for run completion).
// Priority:
// 1. AUTOMATION_BASE_URL explicitly set in the user's env
// 2. launcher-provided host
// 3. `localhost`
AUTOMATION_BASE_URL:
process.env.AUTOMATION_BASE_URL ||
`http://${config.automationApiHost ?? "localhost"}:${config.ingressPort}`,
// The dispatcher resolves this path and embeds it into a
// `mkdir -p ...` shell command executed by the agent-server.
// Priority:
// 1. AUTOMATION_WORKSPACE_BASE explicitly set in the user's env
// 2. `automationWorkspaceBase` option passed by the launcher
// 3. host-side default under config.stateDir
AUTOMATION_WORKSPACE_BASE:
process.env.AUTOMATION_WORKSPACE_BASE ||
config.automationWorkspaceBase ||
join(config.stateDir, "workspaces"),
// Session API key for self-hosted auth — shared with agent-server via X-Session-API-Key header
AUTOMATION_LOCAL_API_KEY: config.sessionApiKey,
// CORS: allow localhost origins for dev, unless explicitly overridden.
AUTOMATION_CORS_ORIGINS:
process.env.AUTOMATION_CORS_ORIGINS ||
`http://localhost:${config.ingressPort},http://127.0.0.1:${config.ingressPort},http://localhost:3001,http://127.0.0.1:3001`,
FILE_STORE: "local",
LOCAL_STORAGE_PATH: join(config.stateDir, "storage"),
OPENHANDS_SUPPRESS_BANNER: "1",
},
color: c.green,
},
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Main
// ═══════════════════════════════════════════════════════════════════════════
let shuttingDown = false;
function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
console.log("");
console.log(`${c.yellow}Shutting down...${c.reset}`);
fileLog("info", "Shutting down...");
for (const [name, proc] of processes) {
logService(name, "Stopping...", c.dim);
signalProcessTree(proc, "SIGTERM");
}
setTimeout(() => {
for (const [name, proc] of processes) {
if (isProcessRunning(proc)) {
logService(name, "Force stopping...", c.dim);
signalProcessTree(proc, "SIGKILL");
}
}
shutdownHooks.run();
process.exit(0);
}, 3000);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
function startIngress(config) {
logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow);
const ingressScript = join(projectRoot, "scripts", "ingress.mjs");
const frontendBackend = getFrontendBackend(config);
spawnService(
"ingress",
"node",
[
ingressScript,
"--port",
config.ingressPort.toString(),
...buildRouteArgs(getLocalServiceRoutes(config)),
...(frontendBackend ? ["--default", frontendBackend] : []),
],
{
cwd: projectRoot,
color: c.yellow,
},
);
}
/**
* Build the JSON-serializable runtime services info for an automation
* stack. Used by both the Vite dev server (dev mode) and static-build.mjs
* (static mode) so the frontend can populate the agent's
* `<RUNTIME_SERVICES>` system-prompt block.
*/
export function buildAutomationRuntimeServicesInfo(config) {
return buildRuntimeServicesInfo({
mode: config.mode ?? "dev:automation",
agentHostAlias: config.agentHostAlias ?? "localhost",
agentServerPort: config.agentServerPort,
ingressPort: config.ingressPort,
frontendPort: config.launchFrontend ? config.vitePort : undefined,
// The same port hosts Vite in dynamic mode and a static-file server
// in static mode. The launcher records this on the config so the
// description shown to the agent matches reality.
frontendKind: config.frontendKind ?? "vite",
automation: config.launchAutomation
? { port: config.autoBackendPort }
: undefined,
});
}
function startVite(config) {
logService("vite", `Starting on port ${config.vitePort}...`, c.magenta);
const frontendCommand = buildNpmScriptCommand("dev:frontend");
const runtimeServicesInfo = config.launchAgentServer
? buildAutomationRuntimeServicesInfo(config)
: null;
const viteEnv = {
// Full-stack mode points Vite at this launcher's ingress. Frontend-only
// mode uses the separately running backend ingress instead.
...buildViteBackendEnv(config),
VITE_FRONTEND_PORT: config.vitePort.toString(),
};
if (config.viteWorkingDir) {
viteEnv.VITE_WORKING_DIR = config.viteWorkingDir;
}
if (runtimeServicesInfo) {
// Inform the frontend (and downstream, the agent's system prompt) about
// which services are available in this dev stack.
viteEnv.VITE_RUNTIME_SERVICES_INFO = JSON.stringify(runtimeServicesInfo);
}
// In local mode, bake the session key into the frontend so the user
// never has to paste it. In public mode, omit the key and set
// VITE_AUTH_REQUIRED so the frontend shows the API key entry screen
// immediately (no network round-trip needed).
if (config.launchAgentServer && config.isPublic) {
viteEnv.VITE_AUTH_REQUIRED = "true";
} else if (config.launchAgentServer) {
viteEnv.VITE_SESSION_API_KEY = config.sessionApiKey;
}
spawnService("vite", frontendCommand.command, frontendCommand.args, {
cwd: config.canvasPath,
env: viteEnv,
color: c.magenta,
});
}
/**
* Seed the session API key into agent-server's secrets store as
* OPENHANDS_AUTOMATION_API_KEY so agents can authenticate with the
* automation backend in curl commands during conversations.
*
* Includes retry logic to handle slow server startup or transient failures.
*
* @param {object} config - Configuration object with agentServerPort, sessionApiKey
* @param {object} options - Options for retry behavior
* @param {number} options.maxRetries - Maximum number of retry attempts (default: 5)
* @param {number} options.retryDelayMs - Delay between retries in ms (default: 2000)
* @param {number} options.timeoutMs - Request timeout in ms (default: 10000)
* @returns {Promise<boolean>} True if seeding succeeded, false otherwise
*/
async function seedAutomationSecret(config, options = {}) {
const { maxRetries = 5, retryDelayMs = 2000, timeoutMs = 10000 } = options;
const secretName = "OPENHANDS_AUTOMATION_API_KEY";
const secretDescription =
"API key for authenticating with the automation backend";
logService("secrets", `Seeding ${secretName} into agent-server...`, c.dim);
const url = `http://localhost:${config.agentServerPort}/api/settings/secrets`;
const body = JSON.stringify({
name: secretName,
value: config.sessionApiKey,
description: secretDescription,
});
const headers = {
"Content-Type": "application/json",
// Include session API key if configured
...(config.sessionApiKey && { "X-Session-API-Key": config.sessionApiKey }),
};
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, {
method: "PUT",
headers,
body,
signal: AbortSignal.timeout(timeoutMs),
});
if (response.ok) {
logService("secrets", `${secretName} seeded successfully`, c.green);
return true;
}
const text = await response.text();
lastError = `HTTP ${response.status}: ${text}`;
// Don't retry on authentication errors - they won't resolve with retries
if (response.status === 401 || response.status === 403) {
logService(
"secrets",
`Warning: Failed to seed secret (${response.status}): ${text}`,
c.yellow,
);
return false;
}
// Retry on server errors or service unavailable
if (attempt < maxRetries) {
logService(
"secrets",
`Retry ${attempt}/${maxRetries} after ${response.status}...`,
c.dim,
);
await delay(retryDelayMs);
}
} catch (err) {
lastError = err.message;
// Connection errors likely mean server isn't ready - wait and retry
if (attempt < maxRetries) {
logService(
"secrets",
`Retry ${attempt}/${maxRetries}: ${err.message}`,
c.dim,
);
await delay(retryDelayMs);
}
}
}
logService(
"secrets",
`Warning: Failed to seed secret after ${maxRetries} attempts: ${lastError}`,
c.yellow,
);
return false;
}
function printBanner(config) {
const stackName = config.frontendOnly
? "Agent Canvas Frontend Stack"
: config.backendOnly
? "Agent Canvas Backend Stack"
: "Agent Canvas + Automation Stack";
// padEnd counts invisible ANSI escape bytes as visible characters, so we
// compute the visible length separately and pad with spaces accordingly.
const ansiRe = /\x1b\[[0-9;]*m/g;
const ansiPadEnd = (str, targetVisible) => {
const visible = str.replace(ansiRe, "").length;
return str + " ".repeat(Math.max(0, targetVisible - visible));
};
// The box has 62-char inner width; each content line needs 63 visible chars
// before the trailing border (1 leading ║ + 62 inner).
const BOX_INNER = 63;
console.log("");
console.log(
`${c.green}${c.bold}╔══════════════════════════════════════════════════════════════╗${c.reset}`,
);
console.log(
ansiPadEnd(
`${c.green}${c.bold}║${c.reset} ${c.bold}${stackName}${c.reset}`,
BOX_INNER,
) + `${c.green}${c.bold}║${c.reset}`,
);
console.log(
`${c.green}${c.bold}╠══════════════════════════════════════════════════════════════╣${c.reset}`,
);
console.log(
`${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`,
);
console.log(
ansiPadEnd(
`${c.green}${c.bold}║${c.reset} Ingress: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`,
BOX_INNER,
) + `${c.green}${c.bold}║${c.reset}`,
);
if (config.launchFrontend) {
console.log(
ansiPadEnd(
`${c.green}${c.bold}║${c.reset} Main UI: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`,
BOX_INNER,
) + `${c.green}${c.bold}║${c.reset}`,
);
}
if (config.launchAutomation) {
console.log(
ansiPadEnd(
`${c.green}${c.bold}║${c.reset} API Docs: ${c.cyan}http://localhost:${config.ingressPort}/api/automation/docs${c.reset}`,
BOX_INNER,
) + `${c.green}${c.bold}║${c.reset}`,
);
}
console.log(
`${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`,
);
console.log(
`${c.green}${c.bold}╚══════════════════════════════════════════════════════════════╝${c.reset}`,
);
console.log("");
console.log(`${c.dim}State directory: ${config.stateDir}${c.reset}`);
console.log(`${c.dim}Press Ctrl+C to stop${c.reset}`);
console.log("");
// Write a compact plain-text summary to the log file.
const summary = [
`${stackName} — started`,
` Ingress: http://localhost:${config.ingressPort}/`,
...(config.launchFrontend
? [` Main UI: http://localhost:${config.ingressPort}/`]
: []),
...(config.launchAutomation
? [
` API Docs: http://localhost:${config.ingressPort}/api/automation/docs`,
]
: []),
` State directory: ${config.stateDir}`,
];
fileLog("info", summary.join("\n"));
}
async function main(options = {}) {
const {
bannerTitle = "Agent Canvas + Automation Development Stack",
startAgentServer: startAgentServerOverride,
extraPrereqs,
viteWorkingDir,
// Path used as `AUTOMATION_WORKSPACE_BASE` by the automation backend.
// Defaults to a host-side path under config.stateDir.
automationWorkspaceBase,
// Host used in `AUTOMATION_BASE_URL` (the URL the automation sandbox
// uses to call back into the automation backend). Defaults to `localhost`.
automationApiHost,
// Value exported as `AUTOMATION_SANDBOX_AGENT_SERVER_URL` to the
// automation backend. This is the URL the in-sandbox bash chain uses
// to reach the agent-server. When unset the backend falls back to
// AUTOMATION_AGENT_SERVER_URL.
sandboxAgentServerUrl,
staticMode: staticModeOverride,
defaultStaticMode = false,
buildStaticFrontend,
staticDir: staticDirOverride,
// Hostname the agent uses to reach services running on the host.
agentHostAlias = "localhost",
// Human-readable label for the dev mode, surfaced in the agent's
// <RUNTIME_SERVICES> system-prompt block.
mode = "dev:automation",
// When true, enable public mode (require LOCAL_BACKEND_API_KEY,
// don't bake session key into frontend).
isPublic: isPublicOverride,
} = options;
const args = parseArgs();
// Allow options to override CLI args for public mode
if (isPublicOverride != null) {
args.public = isPublicOverride;
}
// Allow options to override CLI args (for bin/agent-canvas.mjs)
const useStaticMode =
staticModeOverride ??
(args.dynamic ? false : args.static || defaultStaticMode);
const staticDir =
staticDirOverride ?? args.staticDir ?? join(projectRoot, "build");
const modeLabel = useStaticMode && !args.backendOnly ? "(Static)" : "";
const titleWithMode = modeLabel ? `${bannerTitle} ${modeLabel}` : bannerTitle;
console.log("");
console.log(`${c.cyan}${c.bold}${titleWithMode}${c.reset}`);
console.log("");
fileLog("info", titleWithMode);
// Setup phase
checkPrerequisites({
checkUvx: !args.frontendOnly,
// Static-mode + backend-only has no frontend to build, so npm is not
// required — unless the caller provides a custom buildStaticFrontend hook.
checkNpm:
(!useStaticMode && !args.backendOnly) ||
typeof buildStaticFrontend === "function",
checkFrontendDependencies:
(!useStaticMode && !args.backendOnly) ||
typeof buildStaticFrontend === "function",
});
// Fail fast on an obviously bad OH_AGENT_SERVER_LOCAL_PATH so we don't waste
// time allocating ports / generating keys / launching uvx with a path that
// would only produce a cryptic build error. Mirrors dev-safe.mjs and
// dev-extra-backend.mjs.
if (!args.frontendOnly && process.env.OH_AGENT_SERVER_LOCAL_PATH) {
try {
validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH);
} catch (error) {
logError(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
// Build config with dynamic port allocation
const config = await buildConfig(args);
if (viteWorkingDir) config.viteWorkingDir = viteWorkingDir;
if (automationWorkspaceBase) {
config.automationWorkspaceBase = automationWorkspaceBase;
}
if (automationApiHost) {
config.automationApiHost = automationApiHost;
}
if (sandboxAgentServerUrl) {
config.sandboxAgentServerUrl = sandboxAgentServerUrl;
}
// Stamp the dev-mode label, host alias, and frontend kind on the config
// so downstream helpers (Vite spawn, static build) can produce a
// runtime-services info object describing what the agent can reach.
config.mode = mode;
config.agentHostAlias = agentHostAlias;
config.frontendKind = useStaticMode ? "static" : "vite";
ensureDirectories(config);
if (typeof extraPrereqs === "function") {
extraPrereqs(config);
}
if (
config.launchFrontend &&
useStaticMode &&
typeof buildStaticFrontend === "function"
) {
buildStaticFrontend(config, args);
}
// In static mode, verify build exists after any launcher-managed build.
if (config.launchFrontend && useStaticMode && !existsSync(staticDir)) {
logError(`Static directory not found: ${staticDir}`);
logError(`Run 'npm run build' first to create the static files.`);
process.exit(1);
}
// Start services phase
logStep("2/2", "Starting services...");
let agentServerReady = false;
// 1. Start agent-server first (automation depends on it)
if (config.launchAgentServer) {
const agentServerStarter = startAgentServerOverride ?? startAgentServer;
agentServerStarter(config);
// Wait for agent-server to be ready (60s timeout for slow systems)
agentServerReady = await waitForService(
"agent-server",
`http://localhost:${config.agentServerPort}/server_info`,
60000, // 60 second timeout for initial startup
);
}
// 2. Seed automation API key into agent-server secrets
// This makes the key available to agents during conversations
// Note: seedAutomationSecret has its own retry logic if server is still warming up
if (config.launchAutomation && agentServerReady) {
await seedAutomationSecret(config);
} else if (config.launchAutomation) {
logService(
"secrets",
"Skipping secret seeding - agent-server not ready",
c.yellow,
);
}
// 3. Start automation backend
if (config.launchAutomation) {
startAutomationBackend(config);
}
// 4. Start frontend server (Vite dev server OR static server)
if (config.launchFrontend) {
if (useStaticMode) {
startStaticFrontend(config, staticDir);
} else {
startVite(config);
}
}
// 5. Wait for services to be ready
await delay(2000);
// 6. Start ingress proxy (routes traffic only to running services)
startIngress(config);
// Wait for ingress to start
await delay(1000);
printBanner(config);
}
function startStaticFrontend(config, staticDir) {
logService("static", `Starting on port ${config.vitePort}...`, c.magenta);
logService("static", `Serving from: ${staticDir}`, c.dim);
// Build the runtime-services info JSON so the pre-built frontend can
// populate the agent's <RUNTIME_SERVICES> system-prompt block without
// VITE_RUNTIME_SERVICES_INFO baked in at build time.
const runtimeServicesInfo = config.launchAgentServer
? JSON.stringify(buildAutomationRuntimeServicesInfo(config))
: null;
const staticServerScript = join(projectRoot, "scripts", "static-server.mjs");
spawnService(
"static",
"node",
[
staticServerScript,
"--dir",
staticDir,
"--port",
String(config.vitePort),
// In local mode, inject the API key so the pre-built frontend can
// authenticate transparently. In public mode, pass --auth-required
// so the frontend shows the API key entry screen instead.
...(config.launchAgentServer && !config.isPublic && config.sessionApiKey
? ["--session-api-key", config.sessionApiKey]
: []),
...(config.launchAgentServer && config.isPublic
? ["--auth-required"]
: []),
// Inject runtime-services info so the agent knows what's reachable.
...(runtimeServicesInfo
? ["--runtime-services-info", runtimeServicesInfo]
: []),
// Proxy routes only to services that this launch mode started.
...buildRouteArgs(getLocalServiceRoutes(config)),
// Reject known API prefixes that have no backend — returns 503
// instead of SPA-fallbacking to index.html.
...buildRejectPrefixArgs(getRejectPrefixes(config)),
],
{
cwd: config.canvasPath,
color: c.magenta,
},
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Exports for testing
// ═══════════════════════════════════════════════════════════════════════════
export {
buildAgentServerAutomationEnv,
buildAutomationCommand,
buildConfig,
buildRouteArgs,
buildViteBackendEnv,
getFrontendBackend,
getLocalServiceRoutes,
main,
registerShutdownHook,
spawnService,
commandExists,
logService,
logStep,
logSuccess,
logError,
c,
DEFAULT_AUTOMATION_REPO,
DEFAULT_AUTOMATION_PACKAGE,
DEFAULT_AUTOMATION_VERSION,
DEFAULT_AUTOMATION_SDK_VERSION,
DEFAULT_BACKEND_PORT,
DEFAULT_AUTOMATION_PORT,
};
// ═══════════════════════════════════════════════════════════════════════════
// Main entry point (only when run directly, not when imported)
// ═══════════════════════════════════════════════════════════════════════════
// Check if this module is the main entry point
const isMainModule =
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMainModule) {
main().catch((err) => {
logError(`Fatal error: ${err.message}`);
if (err.stack) {
console.error(c.dim + err.stack + c.reset);
fileLog("error", err.stack);
}
process.exit(1);
});
}