跳转到内容

Node + Docker (no Cloudflare)

此内容尚不支持你的语言。

If you want openma without a Cloudflare account — on a VPS, your laptop, fly.io, your k8s cluster, or anywhere else that runs Linux/macOS — this is the path. Same code. The harness, the event log, the recovery logic, the API, the Console UI: identical to the Cloudflare deploy. Only the I/O bindings change.

SQLite + LocalFs (default)Postgres + LocalFsMySQL + LocalFs
Bundled composedocker-compose.ymldocker-compose.postgres.ymlExternal MySQL 8.x DSN
Best forSingle instance, ≤10 GB sessions, dev, demoMulti-instance, large tables, share an existing PG clusterExisting MySQL infrastructure
SQL concurrencyOne writer per processMany writers — HA-ableMany writers — CAS-backed claims
Backupscp ./data/*.db or litestream → S3pg_dump / managed PG snapshotsmysqldump / managed snapshots

Same openma/main-node:dev image either way — DATABASE_URL env at runtime decides. SQLite needs only DATABASE_PATH; server databases use DATABASE_URL=postgres://... or DATABASE_URL=mysql://.... better-auth uses the selected server database; only SQLite mode uses a separate auth.db.

better-sqlite3 is an optional dependency of main-node: it is installed by default, but a Postgres or MySQL image does not need its native addon. If it is missing or failed to build, a server-database deployment still starts, and SQLite mode refuses to start with a message naming the dependency. Installs use the prebuilt addon that better-sqlite3 ships for Linux (glibc and musl), macOS and Windows on x64 and arm64; the workspace does not compile it (allowBuilds in pnpm-workspace.yaml), so an image build needs neither a C++ toolchain nor access to nodejs.org.

Terminal window
git clone https://github.com/openma-ai/open-managed-agents.git
cd open-managed-agents
cp .env.example .env
# Two secrets are required before first boot — both generated locally:
# BETTER_AUTH_SECRET — signs Console sessions
# PLATFORM_ROOT_SECRET — encrypts credentials, model-card API keys, integration tokens
# (lose it and every encrypted row is unreadable — back it up)
$EDITOR .env
# BETTER_AUTH_SECRET=$(openssl rand -hex 32)
# PLATFORM_ROOT_SECRET=$(openssl rand -base64 32)
# ANTHROPIC_API_KEY is optional — in production, prefer a per-tenant Model Card.
# SANDBOX_PROVIDER is required; choose an isolated runtime and add its credentials:
# SANDBOX_PROVIDER=e2b
# E2B_API_KEY=...
# First time builds the image; subsequent runs skip --build
docker compose up -d --build
# Sanity
curl localhost:8787/health
# → {"status":"ok","backends":{"db":"sqlite ..."},...}
# Console UI on the same port — sign up with email + password,
# you'll be in the app immediately (no email-OTP gate by default)
open http://localhost:8787

Drive the harness end-to-end:

Terminal window
AID=$(curl -s -X POST localhost:8787/v1/agents -H 'content-type: application/json' \
-d '{"name":"hello","model":"claude-sonnet-4-6","tools":[{"type":"agent_toolset_20260401"}]}' \
| jq -r .id)
SID=$(curl -s -X POST localhost:8787/v1/sessions -H 'content-type: application/json' \
-d "{\"agent\":\"$AID\"}" | jq -r .id)
curl -N localhost:8787/v1/sessions/$SID/events/stream &
curl -s -X POST localhost:8787/v1/sessions/$SID/events -H 'content-type: application/json' \
-d '{"events":[{"type":"user.message","content":[{"type":"text","text":"Run uname -a"}]}]}'

The SSE stream prints agent.tool_use → agent.tool_result → agent.message → session.status_idle.

Terminal window
# Same .env (compose sets DATABASE_URL internally to the bundled postgres)
docker compose -f docker-compose.postgres.yml up -d --build
curl localhost:8787/health
# → {"backends":{"agents":"postgres","events":"postgres","db":"postgres ..."}, ...}

Brings up postgres:16-alpine on a named volume oma-pgdata. No host port published — only oma-server reaches it on the bridge network. For a managed PG (RDS / Neon / Supabase / your cluster), drop the postgres service block and set DATABASE_URL=postgres://user:pw@host:5432/oma in .env.

Use a fresh MySQL 8.x database and the same image:

Terminal window
DATABASE_URL=mysql://oma:password@mysql.internal:3306/oma \
pnpm --filter @open-managed-agents/main-node start
curl localhost:8787/health
# → {"auth":"better-auth-mysql","backends":{"agents":"mysql","events":"mysql",...}}

The MySQL adapter owns native DDL and optimistic CAS/transaction details; v1 stores use the same Ports as SQLite and Postgres. Bootstrap fails closed when an existing database needs a newer explicit migration. Until a shared realtime adapter is configured, run one main-node replica so SSE fanout is not split between in-process hubs.

Default self-host has no email server, so the auth path is plain email + password — sign-up creates a session immediately, no OTP screen.

ModeWhenHow to enable
email + password (default)Single user / team / dev / demoJust docker compose up, sign up via Console
AUTH_DISABLED=1”I’m the only user, skip auth entirely”echo "AUTH_DISABLED=1" >> .env, restart. Every request becomes tenant_id=default.
email + OTP gateReal prod, you wired up SMTPAUTH_REQUIRE_EMAIL_VERIFY=1, override sendVerificationOTP in apps/main-node/src/auth/config.ts to call Resend / SES / SMTP / Postmark / etc.
Google OAuthTeam SSOGOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET in .env. Console renders Google button automatically.
OpenID Connect (company SSO)Keycloak, Okta, or an internal IdPPass signIn.oidc to createBetterAuthComponent in your composition root (below). Console renders one button per provider.

A deployment that builds its own composition root can offer any OpenID Connect provider and, optionally, turn email and password off. Users sign in with authorization code and PKCE. The first sign-in creates the user and a workspace, like any sign-up. Register this redirect URI with the provider: <PUBLIC_BASE_URL>/auth/oauth2/callback/<id>.

import { loadNodeConfig } from "@open-managed-agents/main-node/config";
import {
createBetterAuthComponent,
nodeDefaults,
openNodeDatabase,
} from "@open-managed-agents/main-node/components";
const config = loadNodeConfig(process.env);
const database = await openNodeDatabase(config.database);
const auth = await createBetterAuthComponent({
config,
database,
email: null,
signIn: {
oidc: [{
id: "sso", // lowercase; appears in the callback path
name: "Company SSO", // button label
discoveryUrl: "https://sso.example.com/.well-known/openid-configuration",
clientId: process.env.SSO_CLIENT_ID!,
clientSecret: process.env.SSO_CLIENT_SECRET!,
// scopes: ["openid", "profile", "email"], // default
// clientAuthentication: "basic", // default "post"
}],
emailPassword: false, // only SSO can sign users in
},
});
const components = await nodeDefaults(config, { database, auth });

/auth-info reports the providers as oidc: [{ id, name }] and whether email_password is on. Discovery URLs must use HTTPS (loopback HTTP is allowed for local testing).

The agent’s bash / read / write / etc. tools execute in a sandbox. SANDBOX_PROVIDER env picks the backend.

ModeWhen to pickRequired env
liteboxLocal hardware isolation, no daemonSANDBOX_PROVIDER=litebox. Host needs /dev/kvm (Linux) or Apple Silicon (macOS). Per-VM Firecracker-equivalent via libkrun.
boxrunMulti-host with KVM, OMA pods on managed k8sSANDBOX_PROVIDER=boxrun BOXRUN_URL=http://host:8100/v1/default. Run boxlite serve on a separate KVM host; OMA pods reach it over HTTP.
daytonaProduction / untrusted codeSANDBOX_PROVIDER=daytona DAYTONA_API_KEY=... (+ optional DAYTONA_API_URL). Daytona-managed Linux VMs, one per session.
e2bE2B SaaS / E2B Infra self-host / Aliyun Agent Sandbox (e2b-compatible)SANDBOX_PROVIDER=e2b E2B_API_KEY=... (+ optional E2B_API_URL, E2B_SANDBOX_URL, and E2B_DOMAIN for a compatible endpoint; SANDBOX_IMAGE selects the template). Firecracker microVMs.

SANDBOX_PROVIDER has no default. Missing, unknown, and subprocess values fail closed; the non-isolated local adapter is retained only as an internal test fixture and cannot be selected by the deployable Node server.

Remote sandboxes (Daytona, E2B, BoxRun) sharing memory mounts use MEMORY_S3_* env (any S3-compatible store — AWS / R2 / OSS / MinIO).

Set an Agent’s OMA extension to harness: "acp-sandbox" and provide the process command under acp.agent. The selected E2B template must contain that binary:

{
"_oma": {
"harness": "acp-sandbox",
"acp": {
"agent": {
"command": "codex-acp",
"args": [],
"cwd": "/workspace"
},
"restart": {
"mode": "on-crash",
"max_restarts": 3,
"window_ms": 60000
}
}
}
}

OpenMA uses E2B’s live-stdin command channel for ACP JSON-RPC rather than log polling. While a turn is running it renews a 90-second lease every 30 seconds. When idle, E2B is configured to memory-pause and auto-resume instead of killing the ACP process. On host shutdown OpenMA preserves the ACP session checkpoint, requests session/close when the agent advertises that capability, then tears down the child before the sandbox.

For deployment-level fencing, portable /workspace checkpoints, immutable Session output manifests, and the difference between an official Managed Agents worker and an OpenMA-supervised harness, see Managed Runtime Host.

Sandbox egress proxy (CLI credential injection)

Section titled “Sandbox egress proxy (CLI credential injection)”

The sandbox owns credential interception: a local relay on loopback with its own CA, or the provider’s native outbound adapter. Users only attach Vaults. The control plane exposes the same ordinary HTTPS /v1/oma/egress routes on Node and Cloudflare; it verifies the Session pass and current Vault bindings on every request, injects the real token upstream, and never returns it.

There is no extra control-plane listener, shared CA, or proxy configuration. Sandboxes need access only to the ordinary HTTPS API. Local relay installation, readiness and warm repair run through the sandbox adapter. The standalone relay bundle is built with pnpm --filter @open-managed-agents/sandbox-egress build.

Environment proxies are best effort, not network isolation: clients can bypass them, certificate pinning breaks interception, and SSH git does not use them.

Point CONSOLE_BRANDING_DIR at a directory with a branding.json and the files it names. Every field is optional:

{
"product_name": "Acme Agents",
"logo": "logo.svg",
"logo_dark": "logo-dark.svg",
"favicon": "favicon.png",
"stylesheet": "theme.css",
"login": { "title": "Sign in to Acme Agents", "subtitle": "Use your Acme account" }
}
  • product_name replaces “openma” in the page title, the sign-in page, and the dashboard heading.

  • logo / logo_dark replace the mark on the sign-in pages; the image keeps the mark’s height and its own width.

  • stylesheet loads after the console’s CSS, so it can override the theme tokens, for example:

    :root { --brand: #2563eb; --brand-hover: #1d4ed8; --brand-subtle: #eff6ff; --brand-fg: #fff; }
    .dark { --brand: #60a5fa; --brand-hover: #93c5fd; --brand-subtle: #172554; --brand-fg: #0b1220; }

The directory is served under /branding/. With CONSOLE_DIR set, the control plane also writes the title, favicon, stylesheet, and settings into index.html, so the first paint is already branded. File names must stay inside the directory, and the server refuses to start when branding.json is invalid or names a missing file.

  • AUTH_DISABLED=1 belongs in .env, not the shell. Compose substitutes ${AUTH_DISABLED} from the .env file at the compose dir — exporting it only in your shell is silently ignored.
  • docker compose restart does NOT re-read env. After editing .env, use docker compose up -d --force-recreate oma-server.
  • First boot from an old oma.db. main-node runs idempotent ALTER TABLE for the unified-runtime turn marker columns on every start, so existing dbs upgrade in place. If you ever see no such column: turn_id, you’re on a pre-migration build — pull a newer image.

Skills → Install from URL makes the control plane fetch the given URL itself. To keep that from reaching internal services, it only fetches https URLs whose host resolves to a public address, checks the address again at connect time, and does not follow redirects. A skill source on your intranet needs an explicit allowlist entry:

Terminal window
# Host names, IP literals, or *.example.com (subdomains only)
SKILL_SOURCES_ALLOW_HOSTS=skills.corp.example,*.git.corp.example

Allowed hosts skip the private-address check and may use plain http. Archive URLs in a v2 index must stay on the index’s origin.

SQLite → Postgres (one-way):

Terminal window
# Export
sqlite3 ./data/oma.db ".dump managed_agents managed_agent_versions sessions session_events session_streams \
memories memory_stores memory_versions vaults credentials session_memory_stores \
tenant membership" > /tmp/oma-dump.sql
# Strip SQLite-specific pragmas
sed -i '/^PRAGMA\|sqlite_sequence/d' /tmp/oma-dump.sql
# Import into Postgres
docker exec -i oma-postgres psql -U oma -d oma < /tmp/oma-dump.sql

agent.id, session.id are TEXT, BIGINT timestamps work in both, JSON columns are TEXT not jsonb — no schema translation needed beyond the pragma strip.

SQLitePostgresMySQL
Hot backuplitestream replicates ./data/*.db to S3 continuouslypg_dump / managed PG snapshots / WAL streamingmysqldump / managed MySQL snapshots
RestoreStop server, copy db back, restartpg_restore into fresh PG, point DATABASE_URL at itRestore into fresh MySQL, point DATABASE_URL at it
Sandbox workdirsBack up ./data/sandboxes/ separatelySameSame
Memory blobsBack up ./data/memory-blobs/ or set MEMORY_S3_*SameSame
AuthSeparate ./data/auth.dbIn the Postgres databaseIn the MySQL database

The backends share the same crash-recovery test surface (55 tests across adapter, recovery logic, real-process SIGKILL bootstrap, CF DO eviction).

main-node persists session state on every write — kill -9 doesn’t lose the conversation.

Terminal window
# 1. Drop a few synthetic events
SID=$(curl -s -X POST localhost:8787/v1/sessions -d '{}' | jq -r .id)
for i in 1 2 3; do
curl -s -X POST localhost:8787/v1/sessions/$SID/_test_emit \
-H 'content-type: application/json' -d "{\"text\":\"event-$i\"}"
done
# 2. SSE shows ids 1, 2, 3
curl -N localhost:8787/v1/sessions/$SID/events/stream
# Ctrl-C
# 3. Hard-kill — no graceful shutdown
docker kill -s SIGKILL oma-server
# 4. Restart
docker compose up -d
# 5. Resume from where you left off
curl -N -H 'Last-Event-ID: 3' localhost:8787/v1/sessions/$SID/events/stream