Skip to content

INTEGRATIONS

Mike Crowe edited this page Jul 5, 2026 · 5 revisions

Integrations

External systems, APIs, services, and third-party tools that harnessed interacts with at runtime or build time.


Container Runtime: podman (primary) / docker (fallback)

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).

Pod model

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.

Egress firewall

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 MCP Hub

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/mcp by default (port overridable via HATAGO_PORT)
  • Config consumed from hatago.config.json emitted 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/mcp

AI Harnesses (agent CLIs, installed in per-harness images)

Each 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.

Claude Code (harnessed-claude)

catalog/base/Dockerfile.harnessed-claude — installs via the official curl installer:

RUN curl -fsSL https://claude.ai/install.sh | bash

Auth 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.

omp — Oh My Pi (harnessed-omp)

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-bridge

omp 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.

Gemini CLI (harnessed-gemini)

catalog/base/Dockerfile.harnessed-gemini — installed via mise:

RUN mise use -g npm:@google/gemini-cli && mise install

MCP wired via a baked ~/.gemini/settings.json (gemini does not read .claude/.mcp.json):

{ "mcpServers": { "hatago": { "url": "http://localhost:3535/mcp", "type": "http" } } }

OpenAI Codex CLI (harnessed-codex)

catalog/base/Dockerfile.harnessed-codex — installed via mise:

RUN mise use -g npm:@openai/codex && mise install

MCP wired via a baked ~/.codex/config.toml:

[mcp_servers.hatago]
url = "http://localhost:3535/mcp"

opencode (harnessed-opencode)

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-path

MCP 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.


MCP Servers (recipe-provided)

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).

serena — LSP-backed semantic code intelligence

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]

codebase-memory-mcp (cbm) — 158-language code knowledge graph

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-mcp

mcp-server-time — time/timezone tool

Baked 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.

ping — FastMCP tracer service (network-native)

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.


1Password

Desktop SSH agent (Linux/macOS)

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.

Service account (CI / headless)

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 (secret resolution)

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.


Supply-chain Scanning

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.

osv-scanner (Google OSV)

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.

pip-audit

Source-only advisory scan of Python requirements.txt files in recipe directories:

pip-audit -r <requirements.txt> --format json --vulnerability-service osv

pip-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).

snyk

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.


SSH / GPG credential forwarding

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_keys field
  • 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.


Claude auth wiring

Two auth artifacts are forwarded into the claude harness container:

  1. ~/.claude/.credentials.json — OAuth token; mounted read-only:
    args += ["-v", f"{creds}:{ctr_home}/.claude/.credentials.json:ro"]
  2. ~/.claude.json stub — 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.


git (subprocess, host-side)

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.


beads (bd) — issue tracker

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-agent installs the SessionStart hook in .claude/settings.local.json

The beads-stealth sibling recipe places .beads/ outside the repo entirely (zero git footprint).


pnpm supply-chain policy

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 (no postinstall scripts 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 args

XDG path conventions

All 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)

Clone this wiki locally