OpenHands
openhands.alwaldend.com
OpenHands deploys as separate components because each owns a distinct
responsibility and trust boundary:
| Component | Responsibility |
|---|
| Agent Canvas | Browser client for conversations, files, settings, backends, automations |
| Agent Server | Runs conversations, agents, tools, and workspace operations |
| Automation Server | Stores schedules and triggers, tracks runs, dispatches conversations |
The agent server runs directly on the host with the service account’s
permissions. Its workspace is a working directory; operating-system account
permissions define its access to the host. Treat the agent server host as
trusted infrastructure.
Layout
tf_setup creates the Agent Canvas, secured agent server, and automation
server VMs in the src_infra_openhands Xen Orchestra resource set.ansible deploys the three VM components. The
host-bot project owns and
deploys its unsecured agent server.dnsconfig.json owns the component hostnames and static addresses.al.lua authenticates with the src_infra_openhands Vault AppRole, signs
host keys for Ansible, and injects the component secrets.
There is no tf package: these components keep their state in files and
SQLite on their own hosts, so no API-side resources exist to declare.
Canvas serves the packaged browser assets through its
static-server service
on loopback. Configure the browser’s backend with the canvas origin as its
host and the agent server session key as its credential.
Traefik and TLS
Each component host runs Traefik from the shared role with certificates issued
by src_infra_openhands_pki_server. The canvas origin proxies /api/*,
/api/automation, /sockets, and /server_info to the agent server and
automation server, so the browser talks to one origin and issues no
cross-origin request.
The public canvas.openhands.alwaldend.com record is a CNAME to
ingress.alwaldend.com, and infra/ingress forwards it to
dc1.canvas.openhands.alwaldend.com. That ingress keeps its
RequireAndVerifyClientCert client-auth policy, so reaching canvas from
outside still requires a client certificate.
Secrets
agent_server holds session_api_key and secret_key; automation holds
kv_secret. The session API key is shared by the agent server, the automation
server, and the browser because all three authenticate the same
X-Session-API-Key value.
The unsecured host-bot server has a separate secret_key reference owned by
its host’s AL configuration.
Prepare one private JSON input for each path through the
secret-handling workflow.
Set OPENHANDS_AGENT_SERVER_JSON, OPENHANDS_AUTOMATION_JSON, and
OPENHANDS_HOST_BOT_JSON to those input file paths. Each JSON object contains
the fields listed above for its path. Initialize the paths through standard
input; -cas=0 prevents overwriting existing data:
bazel_agent bazel run //infra/openhands:vault.kv_put -- -cas=0 \
alwaldend.com/vault1/approles/src_infra_openhands/agent_server \
- < "$OPENHANDS_AGENT_SERVER_JSON"
bazel_agent bazel run //infra/openhands:vault.kv_put -- -cas=0 \
alwaldend.com/vault1/approles/src_infra_openhands/automation \
- < "$OPENHANDS_AUTOMATION_JSON"
bazel_agent bazel run //users/simeonwarren:vault.kv_put -- -cas=0 \
alwaldend.com/vault1/approles/user_simeonwarren/openhands \
- < "$OPENHANDS_HOST_BOT_JSON"
Deployment
bazel run //infra/openhands/tf_setup:tf_setup.plan
bazel run //infra/openhands/tf_setup:tf_setup.apply
bazel run //infra/openhands/ansible
The component AppRole must first exist in Vault. Follow the
XO authentication and resource-set workflow to
synchronize its OIDC identity and apply its resource-set assignment before
tf_setup runs. The assignment in
resource_set_inventory supplies the template,
storage, and network by name. Component secrets must be written to Vault
before Ansible runs. State and locking use the repository’s tf_backend
plugin, which stores them as Vault KV entries and creates the entries on first
use, so no separate state bucket is required.
1 - Ansible
OpenHands deployment
Deploys the OpenHands components. The inventory has three hosts in three
groups, and the playbook applies one component role per group:
| Group | Host | Component |
|---|
openhands_canvas | host1.canvas.openhands.alwaldend.com | Agent Canvas |
openhands_server_secure | host1.server.openhands.alwaldend.com | Agent server (secured) |
openhands_automation | host1.automation.openhands.alwaldend.com | Automation server |
The three XCP-ng VMs exist only for the secured agent server and for the
components that must not share a trust boundary with host-bot. The secured
group sets openhands_server_session_api_key and hands the same value to the
automation server so the automation dispatcher can authenticate its
dispatched conversations.
Every component applies host and traefik. Canvas runs the published
package’s static server directly on loopback. The operator configures the
browser backend with the canvas origin and the shared session key.
The canvas host routes /api/automation to the automation host and the agent
server API prefixes to the secured agent server. These routes take precedence
over the frontend route, so the configured browser reaches one origin.
Downstream routers accept the original canvas Host header.
The unsecured agent server runs on host-bot and is deployed by
the host-bot project, which
owns that host entirely. It omits OH_SESSION_API_KEYS_0 and binds to loopback
without a session API key. Access is local to host-bot; its Traefik and firewall
configuration add no route for this server.
The group variables read the session key, secret key, and automation KV secret
from the environment the AL Vault injector populates.
2.1.1.1 - OpenHands infrastructure
Purpose
Describe the checked-in OpenHands component deployment, host boundaries,
ingress routing, and Vault secret references. These requirements describe desired
configuration and do not establish live deployment or service health.
Requirements
Requirement: Separate components by responsibility and trust boundary
The deployment SHALL run Agent Canvas, the agent server, and the automation
server as distinct components, because each owns a different responsibility.
The agent server SHALL run natively with its service account’s host
permissions. The deployment documentation SHALL identify those permissions as
the execution boundary and the workspace as its working directory.
Scenario: Inspect the component set
- WHEN a reader inspects the deployment
- THEN the canvas serves the browser client, the agent server runs
conversations and workspace operations, and the automation server stores
schedules and dispatches conversations
- AND the documentation states that the agent server acts with its
service account’s permissions on its host
Requirement: Scope Vault access to the component AppRole
infra/vault/tf SHALL declare the src_infra_openhands AppRole through a
module under infra/vault/tf/approle_src_infra_openhands/, including its
entity, group, SSH host-key signing role for openhands.alwaldend.com, and
server PKI role for the component hostnames. The AppRole SHALL receive only the
secrets, SSH, and certificate policies it needs, and the component SHALL
authenticate with it rather than a shared or administrative identity.
Scenario: Inspect the AppRole scope
- WHEN the Vault configuration is evaluated
- THEN the AppRole, SSH role, and server PKI role are declared in the
AppRole module and referenced by the shared identity groups
- AND the component’s
al.lua selects that AppRole by name
Requirement: Provision hosts separately from deployment
tf_setup SHALL create the canvas, secured agent server, and automation server
VMs in the src_infra_openhands Xen Orchestra resource set. Each VM SHALL
derive its hostname, address, and MAC from one record in dnsconfig.json.
Ansible SHALL deploy the components natively, without containers.
Scenario: Add a component host
- WHEN a new component is added
- THEN one record in
dnsconfig.json owns its name and static address - AND the provisioning
vms map references that record prefix
Requirement: Own ingress outside the component roles
Component roles SHALL install and run their services on loopback and SHALL NOT
declare their own ingress. The deployment SHALL expose them through the shared
Traefik role with per-component dynamic configuration.
Scenario: Expose a component through Traefik
- WHEN a component host is deployed
- THEN the shared
traefik role writes its dynamic configuration - AND the component role itself declares no router, entry point, or
certificate resolver
Scenario: Start the canvas frontend
- WHEN the canvas service starts
- THEN the package’s static server serves the packaged browser assets on
the declared loopback address
- AND it starts no agent server, automation server, or additional proxy
Requirement: Keep the browser on one origin
The deployment SHALL document configuring the browser backend with the canvas
origin and shared session key. The canvas origin SHALL proxy the backend API
paths to the agent server and automation server ahead of the frontend route.
Backend targets SHALL use site-local hostnames, and downstream routers SHALL
accept the forwarded canvas Host header.
Scenario: Serve the browser client
- WHEN the browser uses the canvas origin as its configured backend and
requests an automation or agent server API path
- THEN Traefik forwards it to the matching backend service
- AND the browser issues no cross-origin request
- AND the inter-service hop selects the downstream router without
traversing the public ingress
Requirement: Preserve the public ingress client-authentication policy
The component SHALL declare the public canvas.openhands.alwaldend.com CNAME
and the site-local canvas backend address consumed by its ingress service
entry. Canvas integration SHALL satisfy the existing
ingress routing and client-authentication requirements.
Scenario: Inspect public canvas routing
- WHEN the OpenHands DNS and ingress service declarations are inspected
- THEN the public canvas CNAME selects the shared ingress and the service
entry selects the component’s site-local backend address
- AND the integration uses the ingress owner’s existing TLS policy
Requirement: Inject secrets through the component’s Vault flow
Secret values SHALL be stored in Vault and referenced only by path, environment
variable name, or plugin label. The component al.lua SHALL inject them through
the repository’s injector plugin, and tasks that could reveal them SHALL use
no_log. The session API key SHALL be shared by the agent server, the
automation server, and the browser because all three authenticate the same
X-Session-API-Key value.
Scenario: Deploy with injected secrets
- WHEN the deployment reads the session key, secret key, or automation KV
secret
- THEN it receives them from the injected environment rather than committed
files
- AND the environment file that receives them is written with
no_log
Requirement: Deploy the unsecured agent server within its host’s owner
The unsecured agent server SHALL be owned and deployed by the host project that
owns its host, not by the OpenHands VM package. It SHALL bind to loopback,
receive no session API key, and add no ingress or firewall exposure of its own.
Scenario: Review the unsecured server deployment
- WHEN a reader inspects the unsecured agent server deployment
- THEN the host project’s inventory, playbook, group variables, and secret
injection declare it
- AND the OpenHands Ansible inventory contains only the three VM components
2.1.2.1 - owned-dns Specification
Purpose
Define OpenHands DNS management through the owner’s tf_setup root,
including canonical declarations, scoped credentials, and offline source checks
before adopting live records.
Requirements
Requirement: Owner-local DNS configuration
The tf_setup root SHALL consume this owner’s canonical dnsconfig.json through
the shared DNS Terraform module, preserving declared record identities and views.
DNS resources SHALL default to disabled before adoption and SHALL default to
enabled in the reviewed adoption revision. Adoption SHALL bind existing records
to their exact provider identities and SHALL preserve their declared attributes.
Existing-record adoption SHALL contain no DNS record additions, changes,
replacements, or deletions. Verified missing declarations MAY be provisioned
through an exact additions-only DNS plan that preserves every existing record
and excludes non-DNS managed-resource changes. After deployment, default DNS
reconciliation SHALL retain the bindings and preserve unrelated provider records.
Scenario: Inspect the preparatory configuration
- WHEN the checked-in root is evaluated with default inputs before adoption
- THEN it reads this owner’s declaration and disables managed DNS records
- AND the package contains the module and declaration inputs
Scenario: Inspect the adopted default
- WHEN the reviewed adoption revision is evaluated with default inputs after adoption
- THEN it reads this owner’s declaration and enables managed DNS records
- AND the package contains the module and declaration inputs
Scenario: Adopt existing records
- WHEN the owner’s existing records are adopted through its scoped DNS wrappers
- THEN Terraform binds the records to their exact provider identities
- AND their declared values, attributes, and views remain unchanged
- AND the reviewed adoption plan contains no DNS additions, changes,
replacements, or deletions
Scenario: Reconcile without changes after adoption
- WHEN the adopted root plans with default inputs and unchanged declarations
and provider records
- THEN the plan retains the adopted bindings and contains no DNS additions,
changes, replacements, or deletions
- AND unrelated provider records remain unchanged
Scenario: Provision missing declarations
- WHEN fresh complete provider inventories prove the owner’s declared records
are absent and conflict-free
- THEN the reviewed DNS plan creates only those declared missing records
- AND existing provider identities and attributes remain unchanged
- AND a subsequent DNS plan contains no changes
Requirement: Scoped DNS execution and offline checks
The dns.plan, dns.show, and dns.apply wrappers SHALL select dns=1 through
the repository AL flow using src_infra_openhands, and operate on module.dns
and its dependencies in the existing tf_setup root and Vault HTTP backend.
Apply SHALL consume the reviewed saved plan. Ordinary VM setup SHALL retain its
tf=setup and xoa_login=1 labels, XO authentication, and existing backend paths.
Secret values SHALL remain in injected variables. Real DNS credentials and
Vault policy grants SHALL be prerequisites for operational DNS calls. RouterOS
DNS credentials SHALL remain isolated from unrelated RouterOS resources.
Successful scoped DNS execution SHALL not establish VM or service health.
The package SHALL expose a format test that does not authenticate to Vault or
contact DNS providers.
- WHEN the package format test executes
- THEN it checks the packaged Terraform configuration without live credentials
- AND existing non-DNS authentication and backend paths remain unchanged
- WHEN the scoped DNS wrapper selects
dns=1 - THEN it selects DNS credential injection through the owner AppRole and
existing backend without starting XO login
- AND the targeted plan and saved-plan apply cover DNS and its dependencies,
without establishing VM or service health
- AND disabled DNS resources do not imply offline provider configuration
3 - Tf setup
Terraform setup
This package creates Xen Orchestra VMs in the src_infra_openhands resource
set for the three OpenHands components that run off host-bot: Agent Canvas,
the agent server, and the automation server. infra/openhands/al.lua
authenticates with OpenHands’ own Vault AppRole and the packaged XO OIDC login
plugin. The plugin supplies a temporary XO token and revokes it on shutdown;
no infrastructure administrator token is loaded. The setup HTTP backend
remains owned by the same OpenHands config.
Before running VM setup, bootstrap the AppRole’s XO OIDC user and apply
its resource-set membership through infra/xcp_ng/tf. Subjects are matched by
immutable Vault entity UUID under the configured OIDC issuer, not by login
name or AppRole group membership. See
XO authentication.
vms holds the component definitions, and each entry names the dnsconfig
record prefix that owns its hostname and address. Adding a component means
adding a record to ../dnsconfig.json and an entry here; the guest hostname,
static address, and derived MAC all come from that one record. The agent
server is sized larger because it stores every backend conversation,
workspace, and bash event.
Defaults select the imported Fedora 44 template, local storage and wired
network by name from the OpenHands resource set. Override
TF_VAR_xoa_template_name, TF_VAR_xoa_storage_name, and
TF_VAR_xoa_network_name for another assignment. Native provider lookups run
as OpenHands’ own identity and reject ambiguous matches. The selected network
must carry 192.168.10.0/24; gateway and DNS default to 192.168.10.1 (the
wired router) and can be overridden.
The PVE-based infra/cloud_init:xen_linux target supplies the shared Ansible
user and CA configuration through the repository template rule. Terraform adds
the hostname and static network configuration from ../dnsconfig.json. After
cloud-init completes, verify that /dev/xvda is the boot disk before running
Ansible.
bazel_agent bazel run //infra/openhands/tf_setup:tf_setup.plan
bazel_agent bazel run //infra/openhands/tf_setup:tf_setup.apply
Use this package’s dns.plan, dns.show, and dns.apply targets for the
scoped DNS adoption workflow.
They select dns=1 and target module.dns in the same root, AppRole, and
backend without starting XO login. Apply requires the reviewed saved plan;
the shared procedure
owns preparation and reconciliation.
dns_enabled defaults to true after
verified initial provisioning.
Keep it enabled to retain the managed records. Unchanged declarations and
provider records produce a no-change DNS plan. Successful DNS checks do not
establish VM, OpenHands service, or TLS readiness; the separate
service deployment change
retains that acceptance work.