Haive:确定性多 CLI 编排与 AI 智能体工作流工具
martinambrus/haive
Haive 是一个确定性多 CLI 编排与 AI 智能体工作流工具,用 TypeScript 步骤模块加 Web 表单替代 markdown 驱动的 Claude Code 流程,仅在需要推理的环节调用 AI CLI。
Deterministic multi-CLI orchestration and AI agentic workflow utility. Replaces a markdown-driven Claude Code onboarding flow, an autonomous /workflow implementation loop, and a sandboxed local environment replication step set with a deterministic web project. Agentic CLI invocations only happen for parts that need reasoning; everything else is a TypeScript step module with a web form.
Install
There are two ways in and they are different things. Run a release boots published images — no source, no build, one command. Build from source is the contributor path and is what the rest of this file documents.
Run a release
curl -fsSL https://raw.githubusercontent.com/martinambrus/haive/main/install/install.sh | shirm https://raw.githubusercontent.com/martinambrus/haive/main/install/install.ps1 | iex
Linux, macOS and Windows. On Windows this is PowerShell with Docker Desktop — no WSL shell is involved; Docker Desktop uses WSL2 as its engine, which is its plumbing rather than a requirement on you. If you would rather read a script than pipe one into a shell, download it, read it, run it.
Docker is the only prerequisite, and the installer never installs it. It names what is missing and the exact fix for your platform, then stops having changed nothing. Everything else Haive needs runs inside a container.
| Option | |
|---|---|
--version <v> |
Install a specific release. An alias (latest, next) is resolved to a concrete version and PINNED, so an install cannot drift onto a moving tag. Default: latest. |
--install-id <id> |
Names every container, volume, network and database this install owns ([a-z0-9_], default haive). Required for a SECOND install on one machine — see below. |
--dir <path> |
Where to install. Default ~/<install-id>. |
--check |
Run the prerequisite checks and exit, changing nothing. |
--no-start |
Write the install but do not boot it. |
--force |
Install into a non-empty directory. |
It writes one directory and nothing system-wide: the compose bundle, an .env holding secrets it
generates for this install (mode 600), and two helper scripts — ./haive and ./uninstall.sh
(.ps1 on Windows). Host ports are probed rather than assumed, so a second install, or a machine
already running ddev, gets free ones instead of a bind error.
./haive is how you run the install afterwards, and it is worth knowing before you need it:
cd ~/haive ./haive up # start it (or start it again) ./haive logs api # follow one service; ./haive logs for all of them ./haive ps # what is running ./haive down # stop it, keeping every volume
If the first boot does not come up, the install directory is already complete — the compose
bundle, the .env and both helpers are written before anything is pulled. So it is a retry, not a
reinstall: ./haive up, and ./haive logs to see why. Re-running the installer will not help and
is refused anyway. The commonest cause is a pull that did not finish on a slow connection; the
images are large and the first boot then also initialises the database, which on a laptop can take
several minutes after the containers appear.
Before you use it, know what it can do: the Haive worker mounts the Docker socket, which is equivalent to root on the host. That is how it runs AI CLIs in sandboxes. See Docker socket exposure for the rootless alternative.
First account: the first person to register becomes the administrator. Open the app and the setup page asks for an email and password; every account created after that is a normal user.
Exactly one first account can be anointed even if several arrive at once — the check and the insert share a transaction under an advisory lock. On an instance you expose to a network before registering yourself, set
SETUP_TOKENin the install's.envand that first registration must present it, which closes the window where a stranger could claim the instance.
Upgrading
An upgrade drains running work, snapshots the database, migrates, and keeps the new version only once the new containers report it — anything that fails before that point rolls back. Either:
- Admin console → Maintenance & upgrade, which also shows what work is still running and the history of past upgrades; or
./haive upgrade --version 0.2.0from the install directory, which is the one that still works when the stack is down.
The installer never upgrades: re-running it against an existing install is refused.
Uninstalling
cd ~/haive && ./uninstall.sh # stack + this install's runtime data cd ~/haive && ./uninstall.sh --purge # …and repositories, CLI logins, built images
The default keeps your cloned repositories, uploaded bundles, CLI logins and the DDEV CA, and
lists them by name; --purge removes those too. It only ever touches resources belonging to this
install id, and it does not delete the directory it is running from — the last line prints the
command that does.
More than one install on a machine
Give each one its own id:
sh install.sh --install-id staging --dir ~/haive-stagingEvery container, volume, network, image and database is namespaced by that id, and each install's cleanup only ever matches its own. Without it the second install refuses rather than adopting the first one's database.
Build from source
For contributing. Requires Docker and Docker Compose; WSL2 plus Docker Desktop is the supported developer environment (this constraint is about building, not about running a release).
# Clone git clone <repo-url> haive cd haive # Configure cp .env.example .env # Edit .env: at minimum set CONFIG_ENCRYPTION_KEY and JWT_SECRET. # Generate a 32-byte hex key: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # Boot the stack (postgres, redis, mailpit, api, worker, web) pnpm docker:dev # Web UI open http://localhost:3000 # Tear down pnpm docker:down
Ports (dev override)
These are the fixed ports docker-compose.dev.yml publishes for the source build. A release
install probes for free ports instead and writes what it chose into its own .env.
| Service | Host port | Container port | URL / DSN |
|---|---|---|---|
| Web (Next) | 3000 | 3000 | http://localhost:3000 |
| API (Hono) | 3001 | 3001 | http://localhost:3001/health |
| PostgreSQL | 5432 | 5432 | postgres://haive:...@localhost:5432/haive |
| Redis | 6379 | 6379 | redis://localhost:6379 |
| Mailpit SMTP | 1085 | 1025 | smtp://localhost:1085 |
| Mailpit UI | 8085 | 8025 | http://localhost:8085 |
Mailpit ports are shifted from the upstream defaults (1025/8025) because ddev binds those on many developer machines. If you do not run ddev, you may safely move them back in docker-compose.dev.yml.
Architecture
@haive/sharedtypes, schemas, crypto, logger@haive/databaseDrizzle ORM schemas and migrations@haive/apiHono REST API on port 3001@haive/workerBullMQ workers, step engine, CLI adapters, clawker sandbox@haive/webNext.js Conductor-style UI on port 3000
Three queues on Redis: task-queue, cli-exec-queue, env-replicate-queue. State of truth is PostgreSQL. Per-task CLI sandboxes are spawned with docker run (sandbox/docker-runner.ts). The clawker wrapper exists alongside it, but its binary is installed only when the worker image is built with CLAWKER_RELEASE_URL plus a matching CLAWKER_SHA256, which no build here sets.
Repositories come from a git remote, an uploaded archive, or a local directory. Remote and uploaded repos are copied into the haive_repos volume and mounted writable into each task sandbox. A local directory is, by default, bind-mounted read-only so a workflow can never mutate your real working tree. Tick "Work on a writable copy" when adding a local directory to instead snapshot it into the haive_repos volume at import; the workflow then edits and commits against the copy while your original directory stays untouched. The snapshot is taken once at import (it includes the full working tree, ignored folders such as node_modules included, so it uses extra disk); refreshing the repository re-copies from the source and discards any in-volume changes.
See CLAUDE.md for monorepo layout, package boundaries, conventions, build commands, and constraints.
Tech stack
Runtime and tooling
API and realtime
- Hono 4 REST framework
- @hono/node-server
- ws WebSocket server
- jsonwebtoken
- bcrypt
- prom-client metrics
Queues, cache, database
- BullMQ 5 on Redis 8
- ioredis
- PostgreSQL 18
- Drizzle ORM with drizzle-kit
- postgres.js driver
Schemas and logging
Web UI
- Next.js 16
- React 19
- Tailwind CSS 4
- xterm.js with fit and web-links addons
- Radix UI primitives
- lucide-react icons
- class-variance-authority, clsx, tailwind-merge
Sandbox and Docker
- clawker per-task sandbox (Apache 2.0 Go binary)
- Docker Compose
- dockerode
- tar-fs
Dev services
- Mailpit local SMTP
Testing
Development
Everything builds and runs inside containers — never pnpm install or
pnpm --filter … build on the host (it writes root-owned node_modules and
poisons the bind-mounted dist). Manage the whole stack with scripts/dev.sh,
aliased as pnpm docker:dev and pnpm docker <cmd>:
| Command | What it does |
|---|---|
pnpm docker:dev |
Boot the stack (GPU-aware). Alias for scripts/dev.sh up. |
pnpm docker:down |
Stop the stack; keeps all data (never -v). |
pnpm docker restart [svc] |
Recreate the stack (or one service) with the shared libs rebuilt once. |
pnpm docker logs [svc] |
Follow logs. |
pnpm docker status |
Show service status. |
pnpm docker libs |
Rebuild @haive/shared + @haive/database after editing their source. |
pnpm docker rebuild [svc] |
Pick up a dependency/lockfile change (rebuild image + recreate node_modules volumes). No args = full rebuild; a root/shared/database dep needs the full rebuild. |
pnpm docker reset |
Full rebuild + wipe compiled dist to recover a stale/corrupt build. Preserves all data. |
pnpm docker sandbox-build |
Build the CLI sandbox base image haive-cli-sandbox:latest, which every agent container derives from. The worker builds it on its own when it is missing, so this is the manual path — reach for it if that build fails. |
pnpm docker migrate |
Push the DB schema (drizzle-kit push --force). |
rebuild/reset only touch the node_modules and .next caches — data volumes
(postgres/redis/repos/…), your own ddev-* projects, and per-task runtimes are
never touched. Never run down -v or docker volume prune against this stack.
# Database pnpm db:push # drizzle-kit push (apply schema directly, dev only) pnpm db:studio # drizzle-kit studio # Tests pnpm test # vitest across all packages pnpm test:e2e # playwright against dev compose stack (provisions chromium first) pnpm test:e2e:setup # chromium plus the system libraries it links against (needs sudo) pnpm test:visual # visual baselines, inside a pinned Playwright image pnpm typecheck:e2e # type-check the specs (they are outside the workspace build) # Quality pnpm typecheck pnpm format pnpm format:check
pnpm test:visual is separate because screenshot comparison is only meaningful when one fixed
environment renders both sides. It runs the visual project inside a pinned
mcr.microsoft.com/playwright image (scripts/visual.sh), and the specs refuse to run outside it
— a baseline taken on your host would differ from CI's on font antialiasing alone. Pass
--update-snapshots to rewrite the baselines, and review the resulting images before committing.
Playwright no longer downloads browsers on install, so pnpm install provisions
none and playwright test fails with Executable doesn't exist at .../chromium_headless_shell-<rev>/... until one is fetched. pretest:e2e does that
for you, which is why pnpm test:e2e is the command to run rather than
playwright test directly. Reach for pnpm test:e2e:setup only on a host that is
also missing chromium's system libraries — it shells out to apt through sudo, so it
is deliberately not what the ordinary run does.
Browser IDE (Editor tab)
Each task's Editor tab runs a full browser VS Code (code-server) on the task's workspace, reverse-proxied through the authenticated API exactly like the Terminal and VNC tabs. It replaces the old read-only Source viewer (which remains as the fallback when the editor is unavailable).
- Workspace rooting: the editor is mounted on the task's git worktree ONLY (volume subpath), so the repo root and sibling worktrees are physically absent — a terminal inside the editor cannot escape to them. For worktree tasks there is no in-IDE git panel by design (the deterministic workflow owns all commits/merges); non-worktree tasks mount the full repo and get working git.
- Extensions: installed from the Open VSX registry (the Microsoft Marketplace is licensed to MS products only, so Pylance, C#, C/C++, Remote-*, and Live Share are unavailable). Extensions live on a per-user Docker volume — install once, present in every task you open.
- Settings: per-user global settings.json at Settings → Editor (seeded into every
editor session); per-project overrides in the repo's
.vscode/settings.json, which VS Code layers on top. - Lifecycle: lazy-started when you open the tab; never reaped while the tab is open; gracefully stopped 30 minutes after it closes. Unsaved (hot-exit) buffers live on a per-task volume that survives the reap, so reopening restores them.
- Image: pinned
codercom/code-server(seeCODE_SERVER_IMAGE). The first open pulls it (the tab shows a downloading state); optionally pre-pull withdocker pull codercom/code-server:<tag>to avoid that wait. - Kill-switch: Admin → "In-task editor (IDE)" (
config:ide:enabled). OFF hides the Editor tab and refuses launches; the read-only Source viewer remains. - Security: the editor sees the real worktree files and gets a worktree-scoped shell — the same human-trust model as the existing Terminal tab. The AI secret-file masking does NOT apply to the human editor. code-server runs with auth disabled, bound only to the internal sandbox network (never host-published); the API proxy (cookie-JWT + task ownership) is the sole auth boundary.
Resource limits and WSL2 tuning
Haive runs each task's DDEV/app environment as its own set of Docker containers (a privileged Docker-in-Docker runner plus DDEV's own web/db/router, and a headed Chromium for VNC testing). Several at once can exhaust a small VM, so a machine-aware resource governor bounds them:
- Every runtime runner and CLI-exec sandbox is spawned with Docker memory and CPU caps, with memory-swap disabled so a runaway container is OOM-killed rather than driving the host into swap thrash, plus a pid cap.
- An admission gate limits how many runtimes are live at once, so concurrent DDEV/VNC tasks queue instead of overcommitting RAM.
- A reaper reclaims leaked runners (a failed task keeps its runner for retry; if never retried it is reclaimed after a grace).
Defaults auto-derive from the host's RAM and CPU count, sized conservatively for thin machines (8-16 GB). Override any value in the admin console ("Runtime resource limits" card) or via the config keys RESOURCE_LIMITS_ENABLED, RUNTIME_MEMORY_MB, RUNTIME_CPUS, MAX_CONCURRENT_RUNTIMES, RUNTIME_IDLE_REAP_MINUTES (0 on a number means auto-derive). Set RESOURCE_LIMITS_ENABLED=false to disable the caps and gate entirely.
WSL2 sizes its VM at roughly 50% of host RAM, with 25% of that as swap, unless you tune it. On a memory-constrained host, set limits in %UserProfile%\.wslconfig and restart WSL (wsl --shutdown):
[wsl2] memory=24GB swap=8GB processors=12
The container caps above still apply inside whatever size the VM is; .wslconfig sets the ceiling the governor budgets against.
Hardening
What the security review stages examine — and what they deliberately do not — is documented in docs/SECURITY-COVERAGE.md.
Multi-user isolation
Every task, repository, credential, and CLI provider row is scoped by userId. API routes filter by the authenticated user in every query; there is no implicit admin bypass. A regression smoke test (packages/api/test/multi-user-isolation-smoke.ts) creates two users and asserts that user B cannot read, list, submit, action, or otherwise observe user A's resources.
Secret-file masking
AI CLI agents run with the model-provider endpoint allowlisted, so any repo file an agent reads can be shipped to a third-party model as context — the one egress channel the firewall must keep open. To limit that, the worker hides likely-secret files (.env and .env.*, private keys and certs, SSH keys, cloud and service-account credentials, registry auth, Terraform state, database dumps, backups, Drupal settings.local.php, and more) from the agent: before each CLI invocation it bind-mounts empty read-only files over every match inside the agent's sandbox. The running app (DDEV / app-runner) mounts the same repo volume without the masks, so it still boots with the real files.
Masking is on by default and covers untracked files only (committed secrets are left as-is). Per repository you can adjust it on the tooling settings page: turn it off, add an allow list to un-mask specific files, or add a deny-extend list to mask extra globs (for example **/*.sql if your repo keeps SQL dumps rather than schema migrations). The built-in deny list lives in DEFAULT_SECRET_DENY_GLOBS (@haive/shared); CONFIG_KEYS.SECRET_MASK_ENABLED is a global kill-switch.
Terminal control characters
Ctrl+C (0x03) and Ctrl+D (0x04) bytes are stripped from WebSocket input before being forwarded to the PTY so an accidental keystroke cannot tear down a long-running CLI session. To forward them explicitly the client sends a set_control_passthrough frame with allow: true; sending allow: false restores the default block. Pure helpers scanOauthPrompts and stripControlBytes are exported from packages/api/src/routes/terminal.ts and unit-tested in packages/api/test/terminal-parser.test.ts.
OAuth prompt detection
The terminal server parses CLI OAuth verification URLs out of PTY output (ANSI sequences stripped, trailing punctuation trimmed, rolling buffer capped) and emits an oauth_prompt frame with the URL and an inferred service (claude, codex, gemini, grok, amp). Clients can render this as a clickable link instead of forcing the user to copy the URL out of the raw terminal view.
Container cleanup
On task completion, failure, or cancellation, the worker destroys every container row associated with the task via cleanupTaskContainers in packages/worker/src/queues/task-queue.ts. A containers.destroyed task event records the reason and count. The runner is injectable via setContainerCleanupRunner so tests can assert cleanup without touching a real Docker daemon; see packages/worker/test/container-cleanup-smoke.ts.
Per-task resource limits
POST /tasks accepts an optional resourceLimits: { memoryLimitMb?: number, cpuLimitMilli?: number } object. Values land on the task row and flow through loadTaskResourceLimits into the container manager, which emits --memory {mb}m and --cpus {cores} flags when spawning clawker. Bounds: 128 MiB ≤ memory ≤ 65536 MiB, 100 ≤ CPU (millicores) ≤ 16000. Unit tests in packages/worker/test/resource-limits.test.ts.
Step error recovery
Failed steps surface Retry and Skip buttons in the task detail page; waiting_form steps also expose a Skip. Both post to POST /tasks/:id/steps/:stepId/action with { action: 'retry' | 'skip', note? }. Retry resets the step to pending, clears the error, moves the task to running, and enqueues ADVANCE_STEP. Skip marks the step skipped and advances to the next step (or completes the task if there is no next step). Verified by packages/api/test/step-retry-skip-smoke.ts.
Docker socket exposure
The worker container mounts a Docker socket at /var/run/docker.sock inside the container so clawker can drive Docker-in-Docker. With the default DOCKER_SOCKET=/var/run/docker.sock setting this is the root-privileged host daemon, which means a worker-container compromise is effectively root on the host. Do not run the default setup on a machine where untrusted code executes outside the sandbox.
Rootless Docker (recommended for hardened deployments)
Install Docker's rootless mode on the host (see the upstream walkthrough at docs.docker.com/engine/security/rootless/), start the rootless daemon as your user, then point the worker at the rootless socket via .env:
# One-time host setup (as a non-root user): dockerd-rootless-setuptool.sh install systemctl --user start docker # In haive/.env: DOCKER_SOCKET=/run/user/1000/docker.sock # substitute your uid from `id -u`
Bring the stack up with pnpm docker:dev. The worker container still sees /var/run/docker.sock internally (via DOCKER_HOST=unix:///var/run/docker.sock), but the host side is now the unprivileged rootless daemon. A container escape at that point lands the attacker in the unprivileged user's rootless namespace, not as root on the host.
Caveats: rootless Docker uses user-namespaced uid mapping, so files written inside per-task containers will not be owned by uid 0 on the host; clawker's bind-mount copy mode handles this correctly. Networking still uses the haive-network bridge. Rootless does not support --privileged containers. hAIve's per-task CLI sandboxes never request privilege, so those are not a regression; the per-task DDEV runner DOES (it runs a nested dockerd), so a project that needs DDEV needs a daemon that allows it.
Acknowledgements
hAIve has taken design ideas from several other projects, and ingests a few public
data feeds. Everything listed here is either something we learned from or something we
consume at runtime — ordinary npm dependencies are declared in package.json and are not
repeated here.
Where a row says ideas only, it means exactly that: the design was studied and reimplemented from scratch. No source was copied, which matters most for AGPL-licensed work — hAIve is MIT, and incorporating AGPL code would change that for everyone downstream.
| Source | What it gave us | Licence |
|---|---|---|
| SWE-AF | The workflow DAG design | — |
| Claude Security (Anthropic) | Coverage disclosure as a first-class result, the CWE-798 hard-coded-credential family, the three refuter lenses (reachability / impact / defenses), treating repository text as data rather than instructions, and the secrets sweep that deliberately inverts its own skip list for test fixtures | Apache-2.0 |
| Shannon (Keygraph) | Joining structured finding data by id instead of asking an agent to re-transcribe it across stages; replaying recorded provider streams as test fixtures; the login-flow and success-condition config shape behind authenticated browser testing; and this coverage-document idea. Ideas only — no code taken | AGPL-3.0 |
| DoorDash DashBench | The severity weight ladder in packages/shared/src/review/severity.ts, and the published false-positive-cost finding that the refutation pass argues against when it fails closed |
Published research |
| claude-code-history-viewer (jhlee0409) | The month-block activity heatmap, the ranked-bar usage list, and reading the token mix as one part-to-whole bar rather than as a single total. Ideas only — no code taken | MIT |
| LiteLLM (BerriAI) | model_prices_and_context_window.json, the direct-vendor rate feed behind per-invocation cost |
MIT |
| OpenRouter | The /api/v1/models catalog — both the model picker's list and the gateway's own resale rates |
Public API |
| European Central Bank | Daily euro reference rates, used to display costs in a currency other than USD | Public data |
| CWE™ (MITRE) | The CWE identifiers carried on security findings | See below |
CWE™ is a trademark of The MITRE Corporation. Copyright © 2006–2026, The MITRE Corporation. CWE is used here under the CWE Terms of Use; hAIve carries CWE identifiers only, not the catalog's titles or category data.
What each security stage does and does not cover is documented separately in docs/SECURITY-COVERAGE.md.
License
MIT. Copyright (c) 2026 Martin Ambrus. See LICENSE.
来源:GitHub · github.com