-
Notifications
You must be signed in to change notification settings - Fork 0
INTEGRATIONS
External systems, APIs, services, and third-party tools that harnessed interacts with at runtime or build time.
harnessed drives podman via subprocess — there is no daemon socket and no Docker-in-Docker.
The launcher detects the runtime at startup:
# src/harnessed/launcher.py
def _container_runtime() -> str:
"""Return 'podman' or 'docker', whichever is on PATH (prefer podman)."""
for rt in ("podman", "docker"):
if shutil.which(rt):
return rt
_err.print("[bold red]error:[/bold red] neither podman nor docker found on PATH")All subsequent invocations (podman build, podman pod create, podman run, podman exec,
podman stop, podman rm, podman top, podman save) are plain subprocess.run calls in
launcher.py, or os.execvp for the final interactive attach (preserving TTY state).
Each stack instance is a podman pod: a named pod (harnessed-<stack>-<project_hash>) that
groups the agent container (with the harness CLI and hatago hub inside it) plus any referenced
service sidecars. --userns=keep-id maps the invoking user's UID 1:1 into the pod, so
bind-mounted host directories are writable without root.
An egress firewall script (catalog/base/egress-firewall.sh) is mounted read-only into the
container at /usr/local/sbin/egress-firewall and executed via podman exec immediately after
the instance starts. This applies iptables rules that restrict outbound network access. The
--no-firewall flag (or NO_FIREWALL=true) skips this step.
hatago (@himorishige/hatago-mcp-hub) is the in-container MCP multiplexer. It runs inside the
harness container (not a separate image) and acts as a single Streamable-HTTP endpoint that proxies
to all stdio children and network-native MCP servers the recipe stack declares.
- NPM package pinned at
HATAGO_VERSION=0.0.16, installed via pnpm into the base image - Listens on
http://localhost:3535/mcpby default (port overridable viaHATAGO_PORT) - Config consumed from
hatago.config.jsonemitted by the assembler into the profile
The emitted .mcp.json that every harness reads always contains exactly ONE entry — the hatago
endpoint — never a stdio server directly:
{
"mcpServers": {
"hatago": { "type": "http", "url": "http://localhost:3535/mcp" }
}
}hatago's hatago://servers resource URI is polled by the capability test to confirm which stdio
child servers connected:
# src/harnessed/capability.py
HATAGO_SERVERS_URI = "hatago://servers"
HATAGO_ENDPOINT = paths.hatago_endpoint() # http://localhost:3535/mcpEach harness is a separate podman image that inherits from harnessed-base:latest. Every harness
consumes the same Claude-canonical profile (.claude/) — only the wiring to that profile differs.
catalog/base/Dockerfile.harnessed-claude — installs via the official curl installer:
RUN curl -fsSL https://claude.ai/install.sh | bashAuth is forwarded at launch time via a read-only bind-mount of ~/.claude/.credentials.json
(OAuth token) and a synthesized token-free ~/.claude.json stub to suppress onboarding.
Reads .claude/.mcp.json natively, so the single hatago entry drives all MCP for this harness.
catalog/base/Dockerfile.harnessed-omp — installed via mise:
ARG OMP_VERSION
RUN mise use -g "github:can1357/oh-my-pi@${OMP_VERSION}" bun && mise install
RUN omp plugin install @drmikecrowe/omp-claude-hooks-bridgeomp stores auth + sessions in ~/.omp/agent; the launcher bind-mounts that host dir read-write
(not isolated) — this is intentional, not a bug (see docs/harnessed-design.md §4c). The
@drmikecrowe/omp-claude-hooks-bridge plugin adapts Claude skills/commands/hooks for omp at runtime.
Not launchable on main today. Only
claudeandomphave acatalog/agents/<name>/agent.yamland can be selected as a stack harness. The harnesses below (gemini,codex,opencode,antigravity) ship only a base Dockerfile — noagent.yaml— so they are unmerged/unverified. The wiring documented here is the intended design, not shipped behavior.
catalog/base/Dockerfile.harnessed-gemini — installed via mise:
RUN mise use -g npm:@google/gemini-cli && mise installMCP wired via a baked ~/.gemini/settings.json (gemini does not read .claude/.mcp.json):
{ "mcpServers": { "hatago": { "url": "http://localhost:3535/mcp", "type": "http" } } }catalog/base/Dockerfile.harnessed-codex — installed via mise:
RUN mise use -g npm:@openai/codex && mise installMCP wired via a baked ~/.codex/config.toml:
[mcp_servers.hatago]
url = "http://localhost:3535/mcp"catalog/base/Dockerfile.harnessed-opencode — installed via official curl installer (the npm
package opencode-ai is a thin wrapper whose postinstall is blocked by the pnpm lifecycle
default-deny policy):
ARG OPENCODE_VERSION=1.17.9
RUN curl -fsSL https://opencode.ai/install | bash -s -- --version "${OPENCODE_VERSION}" --no-modify-pathMCP wired via a baked ~/.config/opencode/opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"mcp": { "hatago": { "type": "remote", "url": "http://localhost:3535/mcp", "enabled": true } }
}opencode reads .claude/skills/**/SKILL.md natively, so skills fan through correctly without
special wiring.
Recipes declare MCP servers in their recipe.yaml; the assembler merges them into
hatago.config.json. Servers are either stdio children (hatago spawns them in-container) or
network-native (referenced via service: and resolved to host.containers.internal:<port>/mcp).
Present on main, but NOT capability-verified. The five capability-verified recipes are
beads,caveman,rtk,codebase-memory-mcp, andmikes-universal-setup. serena is documented here but has not passed the capability oracle.
catalog/recipes/serena/Dockerfile — installed via uv:
ARG SERENA_VERSION=1.5.3
RUN uv tool install -p 3.13 "serena-agent==${SERENA_VERSION}"Declared as a stdio child in catalog/recipes/serena/recipe.yaml:
mcp:
servers:
- name: serena
command: serena
args: [start-mcp-server, --context, ide, --project-from-cwd]catalog/recipes/codebase-memory-mcp/Dockerfile — static C binary downloaded from GitHub
releases and verified by SHA-256:
ARG CBM_VERSION=0.8.1
RUN ... curl -fsSL "${base}/${asset}" ... sha256sum -c expected.sha256 ... install -m 0755 codebase-memory-mcp /usr/local/bin/codebase-memory-mcpBaked into harnessed-base via uv (not a separate recipe Dockerfile):
ARG MCP_SERVER_TIME_VERSION=2026.6.4
RUN uv tool install "mcp-server-time==${MCP_SERVER_TIME_VERSION}"Declared by the time recipe as a stdio child consumed by hatago.
catalog/services/ping/ — a Python FastMCP server (mcp[cli] package) run as a separate
service container on port 8080. The recipe resolves its URL to
http://host.containers.internal:8080/mcp via the assembler's _resolve_service_servers in
assemble.py.
The 1Password SSH agent socket is forwarded into the container for commit signing and git push
over SSH. Resolution is OS-specific in launcher.py:
# src/harnessed/launcher.py
def _op_ssh_agent_sock() -> Path | None:
home = Path.home()
if sys.platform == "darwin":
return home / "Library" / "Group Containers" / "2BUA8C4S2C.com.1password" / "t" / "agent.sock"
return home / ".1password" / "agent.sock"The socket is mounted read-only into the container and SSH_AUTH_SOCK is set accordingly. Linux
YubiKey users also get the gpg-agent SSH socket as a fallback.
OP_SERVICE_ACCOUNT_TOKEN is read from the environment and injected into the container's env-file
(written as a mode-0600 temp file, never passed on the command line):
# src/harnessed/launcher.py
op_token = os.environ.get("OP_SERVICE_ACCOUNT_TOKEN")
if op_token:
lines += f"\nOP_SERVICE_ACCOUNT_TOKEN={op_token}\n"The 1Password CLI (op) is baked into harnessed-base via the official apt repository.
varlock is an optional external tool for resolving named secrets from a schema file into
environment variables at launch time. The launcher calls it if both the schema file
(~/.config/harnessed/.env.schema) and the varlock binary are present:
# src/harnessed/launcher.py
def _resolve_secrets() -> str | None:
"""Resolve launch-time secrets from ~/.config/harnessed/.env.schema via varlock."""
schema = paths.xdg_config_home() / "harnessed" / ".env.schema"
if not (schema.is_file() and shutil.which("varlock")):
return None
result = subprocess.run(
["varlock", "load", "--format", "env"],
...
)The resolved env-file is passed to podman run --env-file (mode 0600, temp file, deleted after
launch). No secrets are passed on the command line or baked into images.
Three scanners are invoked as a build gate in scan.py and orchestrated from cli.py/launcher.py.
All are gated at CVSS >= HIGH (7.0) — findings below that threshold are warnings, not blockers.
Offline source scan of recipe directories + the emitted profile. Run with:
osv-scanner scan source --offline --offline-vulnerabilities -r --format json <dir>The exit code is not used as the gate — osv-scanner exits 1 on any finding. Instead, scan.py
parses the JSON output and extracts CVSS v3 base scores from the vector string
(CVSS:3.1/AV:N/...) in pure Python, aborting only when a finding scores >= 7.0.
Image scanning uses podman save | osv-scanner scan image --format json.
Source-only advisory scan of Python requirements.txt files in recipe directories:
pip-audit -r <requirements.txt> --format json --vulnerability-service osvpip-audit findings are warnings only (the JSON does not carry CVSS scores; all findings are surfaced to the user but never red-line the build).
Token-gated (requires SNYK_TOKEN). Runs snyk test --severity-threshold=high --json on recipe
source directories and snyk container test on built images. Unlike osv-scanner, snyk's exit code
IS the gate (0 = clean, 1 = HIGH+ vulns found, 2 = error, 3 = no supported projects).
Without SNYK_TOKEN the scanner warns and skips — the build remains non-interactive.
The launcher selectively forwards host credentials into the container at launch — never the whole
~/.ssh or the GPG private keyring:
-
~/.ssh/config,~/.ssh/known_hosts,~/.ssh/*.pub— mounted read-only individually -
Opt-in private keys — only named keys explicitly declared in the recipe's
ssh_keysfield -
GPG public keyring + trustdb (
pubring.kbx,trustdb.gpg,gpg.conf) — mounted read-only; private keyring is never forwarded - Commit signing uses the forwarded SSH agent (1Password or gpg-agent) rather than the GPG private key, so full GPG-in-container is intentionally unsupported
Hard-deny rules in persist.py prevent any recipe from declaring ~/.ssh, ~/.aws,
~/.gnupg, or ~/.config/harnessed as a global persist mount.
Two auth artifacts are forwarded into the claude harness container:
-
~/.claude/.credentials.json— OAuth token; mounted read-only:args += ["-v", f"{creds}:{ctr_home}/.claude/.credentials.json:ro"]
-
~/.claude.jsonstub — a synthesized, token-free file containing only onboarding + identity fields (copied from the host state file, never the token). This prevents Claude Code from showing the first-run onboarding screen inside the container.
omp keeps auth + session state in ~/.omp/agent and the launcher mounts that dir read-write
(shared with the host) so the in-container session sees and updates the same credentials.
paths.py runs several git subprocess calls on the host to derive persist-key inputs:
# src/harnessed/paths.py
subprocess.run(
["git", "-C", str(project_path), "rev-parse", "--path-format=absolute", "--git-common-dir"],
...
)These are used to compute the project_hash (SHA-1[:8] of the resolved project path) that keys
per-project persist directories. The git common dir is used for cross-worktree scope (scope: project), so two worktrees of the same repo share one persist dir.
The beads recipe bakes the bd CLI binary into the container. bd bundles an embedded Dolt
database and uses refs/dolt/data on the git remote for sync (not a git branch). The recipe
configures:
-
.beads/created in-repo (location: in_repo,vcs: tracked) - Init run once per project:
bd init --quiet --non-interactive --role maintainer - Agent wiring:
bd-setup-agentinstalls theSessionStarthook in.claude/settings.local.json
The beads-stealth sibling recipe places .beads/ outside the repo entirely (zero git footprint).
All JS package installs in the base image and recipe Dockerfiles go through pnpm 11, governed by a
managed config baked at ~/.config/pnpm/config.yaml (catalog/base/pnpm/config.yaml). The key
security properties enforced:
-
strictDepBuilds: true— lifecycle default-deny (nopostinstallscripts without explicit allowlist) -
verifyStoreIntegrity: true— content-addressed store integrity check -
blockExoticSubdeps: true— blocks git/tarball/non-registry subdependencies -
minimumReleaseAge: 1440(1 day) — enforces a release age gate before a package can be installed
Recipe lint (validate_no_raw_npm in schema.py) rejects any recipe that declares npm or npx
as an MCP server command — only pnpm dlx is allowed as the npx replacement:
# src/harnessed/schema.py
def validate_no_raw_npm(recipe: Recipe) -> None:
# raises RecipeLintError if npm/npx found in server command or argsAll host-side paths follow the XDG Base Directory Specification, implemented as a single source of
truth in src/harnessed/paths.py:
| Path | Default | Contents |
|---|---|---|
$XDG_DATA_HOME/harnessed/profiles/ |
~/.local/share/harnessed/profiles/ |
Emitted stack profiles (.claude/, hatago.config.json, derived Dockerfile) |
$XDG_DATA_HOME/harnessed/persist/ |
~/.local/share/harnessed/persist/ |
Per-recipe per-project named persist directories |
$XDG_CONFIG_HOME/harnessed/ |
~/.config/harnessed/ |
User config: .env.schema (varlock), persist-allowlist.txt, user catalog overlay |
$XDG_STATE_HOME/ |
~/.local/state/ |
Claude state root (seeded into ~/.claude.json stub) |
Start Here
Guides
- Recipe authoring
- Service authoring
- Stacks
- Extending stacks (proposed)
- Recipe catalog
- System prompt & rules (proposed)
- Secrets
- AWS SSO
- Pulumi (host login forwarding)
- Egress & exposing services
- Container filesystem
- Git hooks
- Troubleshooting
- Pin management (harnessed update)
Codebase Map
Planning & Roadmap
- open work: GitHub Issues
Research & Prompts
- research/ (home-folder requirements per harness, browse in-repo)
- prompts/ (reusable prompt templates, browse in-repo)