Skip to content

ARCHITECTURE

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

Architecture

Analysis Date: 2026-07-31

Pattern

Layered CLI pipeline: parse → assemble (emit-only) → image build → volume population → launch. All orchestration is in-process Python on the host; podman is driven via subprocess. No daemon socket, no tool container.

Entry Points

Two CLI entry points, both installed by pyproject.toml:

  • harnessedsrc/harnessed/launcher.py:main() — the user-facing CLI (Typer). Handles build, launch, host-run, test, new, svc, update, scan, rescan, install, uninstall, list, stop, rm, prune, clean, aws-sso, host-gc, volume-gc.
  • harnessed-toolssrc/harnessed/cli.py:main() — the build-time-only assembler (argparse). Invoked in-process by harnessed build and also usable standalone. Subcommands: assemble, test, scan-image-online, persist-list, persist-prune.

Layers

1. Schema / Parsing (schema.py)

Pure reading: loads YAML manifests into typed dataclasses. No I/O except reading files; never invokes podman.

Key dataclasses (all in src/harnessed/schema.py):

Class Purpose
Recipe Parsed recipe.yaml: name, servers (list of McpServer), skills, commands, rules, persist (PersistSpec), install (InstallSpec), setup (SetupSpec), hooks, env, expect (Expect), tools, egress
Stack Parsed stack.yaml: name, recipes (names), services (names), instructions, ssh_keys, permissions, optional extends (resolved transitively)
ServiceDef Parsed service.yaml: name, port, scope (global/project), socket, data dir, client_env
Agent Parsed agent.yaml: name, image
McpServer One MCP entry in a recipe's mcp: block: name, optional command/args (stdio child) or url (network-native), transport, service (reference to a ServiceDef), url_env, env, headers
PersistEntry / PersistSpec One persist entry (scope × location × name/path × vcs) and the collection from a recipe
InstallSpec install: block: script path, system reason (prose), cache key
SetupSpec / SetupConfigItem setup: block: script path, condition (bash expression), config items (prompted key/value pairs), note
Expect What the capability oracle probes after a build: skills/commands/plugins/mcp names
Capabilities Union of assembler-visible + expect: declared capabilities; the test oracle
InitSpec init: block: run (sourced bash snippet, runs inside the attach shell before the harness command)
HookCommand One hook command: matcher, command, skip_harnesses
FileExt A skills/ or commands/ or rules/ directory entry

Catalog resolution:

  • load_stack_with_recipes(root, name)load_stack()_resolve_stack_extends() (recursive extends:) → load_recipe() for each recipe
  • root=None → multi-root resolution via paths.catalog_roots() (user overlay first); root=<path> → single-root (used by tests with fixtures)
  • Variety refs (beads/stealth) map to catalog/recipes/beads/stealth/ via paths.catalog_relpath()
  • Unknown recipe fields generate a Levenshtein typo suggestion; raise SchemaError in strict mode

Validation (all in schema.py, called from assemble.assemble()):

  • validate_no_raw_npm — rejects npm/npx in manifests and vendored package.json scripts
  • validate_pin — rejects @latest, --branch main/master, :latest in recipe Dockerfiles
  • validate_container_only_declared — requires system: when a Dockerfile has a RUN but install.sh was migrated out
  • validate_no_claude_writes — rejects ~/.claude writes in Dockerfiles (invisible to host-run; shadowed by volume mount in container)
  • validate_install_script / validate_setup_script — lint the script files (same npm/pin rules applied)
  • validate_init_no_exit — rejects exit in init.run (sourced into the shell; an exit kills it)

2. Assembly (assemble.py + emit.py + synclinks.py)

Pure file emission — no podman, no daemon. Orchestrated by assemble.assemble() (src/harnessed/assemble.py:106):

assemble(root, stack_name, build_dir, harness)
  → load_stack_with_recipes()       # schema layer: parse + validate manifests
  → validate_*()                    # fail-fast lint (npm, pins, container-only, claude-writes)
  → _resolve_service_servers()      # service: refs → http://host.containers.internal:<port>/mcp
  → _merge_servers()                # collect all MCP servers; fail-fast on name collisions
  → emit.reset_profile()            # wipe and recreate profile_dir under build_dir/profiles/<stack>/<harness>/
  → emit.write_mcp_json()           # .mcp.json: ONE entry → hatago endpoint (port 3535)
  → emit.write_settings_json()      # settings.json floor: permissions, hook commands, env
  → emit.write_hatago_config()      # hatago.config.json: all MCP servers the hub must proxy/spawn
  → emit.write_derived_dockerfile() # Dockerfile.harnessed-<stack>: ARG HARNESS + recipe bodies
  → LinkSyncer.fan()                # copy skills/commands/rules into .claude/{skills,commands,rules}/
  → emit.write_claude_md() / ...    # harness-specific identity file from stack.instructions
  → staleness.write_stamp()         # .build-stamp: SHA-256 of harnessed version + stack + recipe inputs
  → return AssembleResult

emit.py writers:

  • write_mcp_json{"mcpServers": {"hatago": {"type": "http", "url": "http://localhost:3535/mcp"}}} — the harness sees ONLY the hatago hub, never individual MCP servers directly
  • write_hatago_config — every recipe's MCP servers, so hatago can proxy or spawn them
  • write_derived_dockerfile — concatenates ARG HARNESS=<h> then recipe Dockerfile bodies in order; a bare ARG HARNESS line is the scope anchor (_ARG_HARNESS_RE) that later blocks can branch on via ${HARNESS}
  • write_settings_json — emits a "floor" settings.json merged post-build with the image-baked file
  • Identity writers per harness: write_claude_md (claude → .claude/CLAUDE.md), write_antigravity_identity (agy → .gemini/GEMINI.md + settings.json), write_codex_agents_md (codex → .codex/AGENTS.md, inlines rules because codex has no directory-rules primitive), write_omp_identity (omp → delimiter-marked blocks in shared ~/.omp/agent)
  • write_opencode_persona — stack instructions as opencode custom-agent prompt file (opencode/prompts/<agent_name>.md)

synclinks.py:LinkSyncer:

  • add_recipe(recipe) registers skill/command/rule paths and detects collisions (CollisionError)
  • fan(profile_dir/.claude) copies the registered tree — fail-fast if two recipes declare the same harness-native name

staleness.py:

  • compute_stamp() — SHA-256 of harnessed version + scheme number + stack.yaml bytes + every file under every recipe dir (sorted by relative path — deterministic, independent of resolution order)
  • check_profile_fresh() — called at launch/host-run: re-resolves recipes (existence check) then stamp comparison; raises StaleProfileError if mismatch

3. Image Build (launcher.py)

Orchestrated by launcher._build_stack() (called from @app.command("build")):

harnessed build <stack> <harness>
  → assemble()                       # profile dir emitted under a staged build context
  → _build_base_image()              # podman build Dockerfile.harnessed-base (hatago baked in-container)
  → _ensure_harness_image()          # podman build Dockerfile.harnessed-<harness> if not already current
  → _build_derived_image()           # podman build Dockerfile.harnessed-<stack> (if any recipe has a Dockerfile)
  → _ensure_stack_volumes()          # fingerprint-gated: run installs into per-stack named volumes
  → _scan_image()                    # osv-scanner + pip-audit (skipped with HARNESSED_NO_SCANS)

Key details:

  • _staged_build_context() — copies catalog/ into a temp dir before calling podman build; prevents .git/.venv/node_modules from shipping to the daemon and avoids "symlink escapes context" rejection
  • _build_derived_image() — stamps the image with label harnessed.recipe-hash (output of compute_recipe_hash()); a later reconciliation pass compares this against podman inspect to skip unnecessary rebuilds
  • _ensure_stack_volumes() — creates two podman named volumes (harnessed-cfg-<harness>-<stack> mapping to ~/.claude and harnessed-tools-<harness>-<stack> mapping to ~/.local); fingerprint-gated; calls _run_container_installs() which runs each recipe's install.sh inside a throwaway container writing to those volumes

4. Launch — Container Backend (launcher.py)

@app.command("launch")launcher.launch()launcher._attach():

harnessed launch <stack> <harness> [project-path]
  → check_profile_fresh()            # staleness guard — raises if recipe sources changed
  → _ensure_services()               # start global/project service containers (idempotent)
  → _ensure_stack_volumes()          # fingerprint-gated install phase
  → podman pod create                # create pod named after instance_name()
  → podman run --pod …               # start agent container with volumes + bind mounts
  → _wait_hatago()                   # poll port 3535 until hatago is ready (30 s timeout)
  → _run_container_setups()          # podman exec: setup.sh scripts (have project context)
  → _apply_firewall()                # egress-firewall.sh with allowed domains
  → _prompt_setup_notices()          # display applicable setup notices to user
  → os.execvp(podman exec …)         # attach: claude/omp/opencode/agy/codex command

Volumes mounted into the agent container:

  • harnessed-cfg-<harness>-<stack>/home/harnessed/.claude (profile + installed skill content)
  • harnessed-tools-<harness>-<stack>/home/harnessed/.local (tool binaries)
  • project dir bind-mounted at its host path (auto-widened to bare-repo container for sibling worktree visibility — _resolve_mount_path)
  • recipe persist dirs bound per PersistSpec entries (_persist_mounts)

Instance naming: harnessed-<harness>-<stack>-<sha1[:8](project_path)> (see paths.instance_name). Stable, project-specific, no collision between stacks or projects.

Service management (_ensure_services): scope: global services are host-published on stable ports registered in $XDG_DATA_HOME/harnessed/svc-ports.json; scope: project services use unix sockets inside the persist data dir — reached by all containers via the same bind mount, no port allocation, no cross-namespace TCP.

Harness attach commands (defined in _HARNESS_ATTACH_CMD, src/harnessed/launcher.py):

  • claudeclaude --mcp-config '/home/harnessed/.mcp.json' --strict-mcp-config
  • ompomp [--session-dir …] (session dir pinned to host key to align with host sessions)
  • opencodeopencode [--agent <name>] (agent name only when stack has instructions:)
  • antigravityagy
  • codexcodex

5. Launch — Host Backend (launcher.py)

@app.command("host-run")launcher.host_run()launcher._launch_host():

harnessed host-run <stack> [harness] [project-path]
  → check_profile_fresh()
  → _materialize_host_home()         # fingerprint-gated: rsync profile → host_home; rmtree if stale
  → _host_run_installs()             # run each recipe's install.sh on the HOST filesystem
  → _host_run_inits()                # source each recipe's init.run (propagates exported env vars)
  → _host_run_setups()               # run setup.sh scripts (have project context; run after install)
  → _share_host_claude_state()       # symlink history/sessions/memories back to shared store
  → os.environ.update(harnessed_env())  # inject folder-env contract into the process env
  → os.execvp(harness_binary)        # exec the harness binary (no container)

Host-run isolates configuration only: CLAUDE_CONFIG_DIR / PI_CODING_AGENT_DIR point at the stack's materialized home ($XDG_DATA_HOME/harnessed/home/<stack>/<harness>/). The agent runs against the real filesystem and real credentials.

_materialize_host_home() is fingerprint-gated on _host_stack_fingerprint(): if unchanged, the existing home is reused; if changed, it is wiped and re-populated from the profile dir.

Host-only package manager redirection (set inside _host_run_installs): UV_TOOL_DIR, UV_TOOL_BIN_DIR, npm_config_prefix all point into the stack's own tools directory so installs never pollute the user's global prefix.

Key Abstractions

Catalog Resolution (paths.py)

paths.catalog_roots()[user_catalog(), harnessed_home()/catalog] — user overlay wins. paths.find_in_catalog(kind, name) walks roots; first existing entry wins. paths.list_catalog(kind) enumerates all entries across roots, deduped, supporting recipe family detection (<family>/<variety> where the parent dir has no recipe.yaml).

harnessed_home() (src/harnessed/paths.py:76) resolves via $HARNESSED_DIR<pkg>/catalog/../ (through src/harnessed/catalog symlink, with .resolve() so the result is always a real directory). The staged build context copies this real dir to avoid symlinks escaping into the podman context.

Folder-Env Contract

harnessed_env() (src/harnessed/launcher.py) defines the single set of env vars exposed to all recipe-authored content. Key vars: HARNESS, PROJECT_DIR, MAIN_REPO_DIR, HARNESSED_GIT_COMMON_DIR, HOST_HOME, HARNESSED_BIN_DIR, HARNESSED_RECIPE_DIR, HOST_WORKSPACE_DIR, CONTAINER_WORKSPACE_DIR, HARNESSED_<SERVICE>_SOCKET. Install scripts get a deliberate subset — no PROJECT_DIR, since a build cannot know the project path.

MCP Hub Architecture

Every harness's .mcp.json has exactly one entry: {"hatago": {"type": "http", "url": "http://localhost:3535/mcp"}}. hatago.config.json (mounted into the container at /home/harnessed/hatago.config.json) lists all the actual MCP servers. Hatago runs in-container as an inlined process (hatago-consolidation), spawning stdio children and proxying network-native ones. Recipes add MCP servers by adding to hatago.config.json — not to any harness config — so the same profile works on every harness.

Persist (schema.py, persist.py, launcher.py)

Three scopes:

  • workspace — keyed by paths.project_hash(project_path) (per worktree)
  • project — keyed by paths.project_hash(git_common_dir) (shared across worktrees of one checkout)
  • global — a real host dir; default-deny via persist.resolve_global_persist() (hard-deny set + user allowlist file)

Two locations (not applicable to global):

  • host$XDG_DATA_HOME/harnessed/persist/<recipe>/<hash>/<name>/ (bind-mounted rw)
  • in_repo — inside the already-mounted project workspace (no extra bind mount needed)

scope: project services (ServiceDef) use the persist data dir as their data dir AND their socket location, allowing the unix socket to cross container boundaries via the bind mount with no port allocation.

Supply-Chain Scan (scan.py)

Two modes: build-time (in-container, offline DB via _scan_image) and nightly re-scan (harnessed rescanrun_image_scan_online, contacts osv.dev). The HIGH gate (CVSS >= 7.0) is computed in pure Python from JSON output — never from the scanner exit code (osv-scanner exits 1 on any finding, so the exit code is useless for severity gating). _cvss3_base() parses CVSS v3.1 vector strings via the FIRST.org formula; qualitative labels fall back to a band table.

Staleness Detection (staleness.py)

compute_stamp() hashes harnessed version + scheme number + stack.yaml bytes + every file under every recipe dir (sorted). Written to .build-stamp in the profile dir at assembly time. Checked at launch/host-run: re-resolves recipes (existence check first, then stamp comparison). _STAMP_SCHEME is bumped when the hashing logic changes to force rebuilds of all existing profiles.

Data Flow: harnessed build gsd-core_repowise claude

launcher.build("gsd-core_repowise", "claude")
  launcher._build_stack(rt, "gsd-core_repowise", "claude")
    _staged_build_context() → /tmp/harnessed-ctx-xxx/
    assemble(root=None, "gsd-core_repowise", /tmp/.../ctx/, "claude")
      paths.find_in_catalog("stacks", "gsd-core_repowise")
        → ~/.config/harnessed/catalog/stacks/ OR catalog/stacks/
      load_stack_with_recipes(None, "gsd-core_repowise")
        load_stack(stack_dir)          # recipes=[repowise, gsd-core]
        load_recipe(repowise_dir)
        load_recipe(gsd-core_dir)
      validate_no_raw_npm, validate_pin, … per recipe
      _merge_servers([repowise, gsd-core])   # collect McpServer list
      _resolve_service_servers(servers, None) # service: refs → URLs
      emit.reset_profile(profile_dir)
      emit.write_mcp_json(profile_dir)       # → .mcp.json (hatago only)
      emit.write_settings_json(…)
      emit.write_hatago_config(…)
      emit.write_derived_dockerfile(…)       # ARG HARNESS=claude + recipe bodies
      LinkSyncer.fan(.claude/)               # copy skills/commands/rules
      emit.write_claude_md(…)                # .claude/CLAUDE.md
      staleness.write_stamp(profile_dir, …)  # .build-stamp
    podman build Dockerfile.harnessed-base   → harnessed-base:latest
    podman build Dockerfile.harnessed-claude → harnessed-claude:latest
    podman build Dockerfile.harnessed-gsd-core_repowise → harnessed-claude-gsd-core_repowise:latest
    _ensure_stack_volumes(rt, "gsd-core_repowise", "claude", …)
      # fingerprint check → if changed:
      podman run (install container) bash install.sh → writes to harnessed-cfg-claude-gsd-core_repowise
    _scan_image(rt, …, "harnessed-claude-gsd-core_repowise:latest")
      podman run osv-scanner + pip-audit → gate(json) → HIGH abort or warnings

Data Flow: harnessed launch gsd-core_repowise claude /my/project

launcher.launch("gsd-core_repowise", "claude", "/my/project")
  check_profile_fresh(None, "gsd-core_repowise", "claude")   # staleness guard
  _ensure_services(rt, stack, "/my/project")                  # start global/project services
  _ensure_stack_volumes(rt, "gsd-core_repowise", "claude", "/my/project")  # install phase
  podman pod create harnessed-claude-gsd-core_repowise-<hash8>
  podman run --pod … \
    -v harnessed-cfg-claude-gsd-core_repowise:/home/harnessed/.claude \
    -v harnessed-tools-claude-gsd-core_repowise:/home/harnessed/.local \
    -v /my/project:/my/project \
    -v <persist_dirs>… \
    harnessed-claude-gsd-core_repowise:latest harnessed-start
  _wait_hatago(rt, instance, port=3535)
  _run_container_setups(rt, instance, stack, "/my/project")   # setup.sh (runtime)
  _apply_firewall(rt, instance, egress_domains)
  _prompt_setup_notices(…)
  os.execvp("podman", ["exec", "-it", instance,
    "claude", "--mcp-config", "/home/harnessed/.mcp.json", "--strict-mcp-config"])

Extension Seams

Adding a New Recipe

  1. Create catalog/recipes/<name>/recipe.yaml (or catalog/recipes/<family>/<variety>/recipe.yaml for a variety).
  2. Optionally add Dockerfile (system-layer steps only; no ~/.claude writes; any RUN alongside a migrated install.sh requires install.system: "<reason>").
  3. Optionally add install.sh (both modes; use $HARNESSED_BIN_DIR, $HARNESSED_RECIPE_DIR, $HARNESSED_CONFIG_DIR, $HARNESSED_INSTALL_CACHE).
  4. Add to a stack's recipes: list.
  5. Run harnessed build <stack> <harness> — all lint checks run automatically.

Adding a New Harness

  1. Create catalog/agents/<name>/agent.yaml (name:, image:).
  2. Create catalog/base/Dockerfile.harnessed-<name>.
  3. Add to _HARNESS_ATTACH_CMD in src/harnessed/launcher.py.
  4. Add to HARNESS_CONFIG_DIR in src/harnessed/schema.py.
  5. If the harness uses a config format other than .claude/CLAUDE.md for stack identity, add an identity-emission branch in assemble.assemble() and a corresponding emit.* writer.

Adding a New Service

  1. Create catalog/services/<name>/service.yaml (name:, port:, scope:, data:).
  2. Create catalog/services/<name>/Dockerfile.
  3. Reference via recipe mcp.servers[].service: <name> (MCP endpoint exposed through hatago) or stack services: [<name>] (attached sidecar with no MCP surface).

Adding a New Stack

Create catalog/stacks/<name>/stack.yaml:

name: <name>
recipes: [recipe-a, recipe-b]
services: []
instructions: |     # optional — becomes .claude/CLAUDE.md / harness-equivalent

Use extends: <parent> to inherit another stack's recipes/services/fields. User overlay stacks (in ~/.config/harnessed/catalog/stacks/) can extend repo stacks.

State Management

All persistent state lives on the host filesystem or in podman named volumes:

Store Location Owned by
Assembled profiles $XDG_DATA_HOME/harnessed/profiles/<stack>/<harness>/ assembler (emit-only)
Config volume podman named volume harnessed-cfg-<harness>-<stack> installer + agent
Tools volume podman named volume harnessed-tools-<harness>-<stack> installer
Host homes $XDG_DATA_HOME/harnessed/home/<stack>/<harness>/ host-run backend
Persist dirs $XDG_DATA_HOME/harnessed/persist/<recipe>/<hash>/<name>/ recipe tools
Install cache $XDG_CACHE_HOME/harnessed/install/<recipe>/<cache_key>/ install scripts
Service port registry $XDG_DATA_HOME/harnessed/svc-ports.json launcher
Setup-dismissed flags $XDG_STATE_HOME/harnessed/setup-dismissed/<instance> launcher
Global persist allowlist $XDG_CONFIG_HOME/harnessed/persist-allowlist user
Extra tools list $XDG_CONFIG_HOME/harnessed/extra-tools.txt user

Agent session state (history, sessions, memories, usage) is symlinked from the stack home back to the harness's real shared store — history is universal across stacks and worktrees.

Credentials: claude authenticates primarily via CLAUDE_CODE_OAUTH_TOKEN (mechanism 2 — a token, no file), in which case nothing credential-shaped is mounted. Only when no token is configured does the legacy fallback apply, and that path does copy: _claude_creds_seed_mount seeds a per-instance copy under $XDG_STATE_HOME/harnessed/<inst>/ and mounts it rw, re-seeding on expiry. omp bind-mounts ~/.omp/agent read-write (mechanism 1, at dir granularity).

Clone this wiki locally