Skip to content

ARCHITECTURE

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

harnessed — Codebase Architecture

Generated codebase map. The authoritative vocabulary (agent/recipe/service/stack/catalog) is in ARCHITECTURE.md at the repo root. This document covers how the code is structured, how data flows, and where the key abstractions live.


Pattern: emit-only assembly + host-native build

The core architectural invariant: assembly is pure in-process file emission; nothing in the assembler touches a container runtime. The sequence is:

catalog YAML files
  → schema.py (parse into typed Python dataclasses)
  → assemble.py (orchestrate: merge servers, detect collisions)
  → emit.py (write profile artifacts to disk)
  → [launcher.py calls podman build on the emitted artifacts]
  → [launcher.py calls podman run to start the pod]

This means assemble.py + emit.py can run in any Python environment, are unit-testable without containers, and regenerate a deterministic profile from the same inputs.


Entry points

Two separate CLI binaries, one Python package:

harnessed — the launcher (src/harnessed/launcher.py)

Installed as the primary CLI. Built with Typer. Drives the full lifecycle:

  • harnessed build <stack> — assemble (in-process) + podman build the derived image.
  • harnessed <stack> (default launch) — launch the pod; re-attaches running instances.
  • harnessed init <stack> — run one-time per-recipe init containers.
  • harnessed new, harnessed list, harnessed test, harnessed rescan — scaffolding and tooling.

The launcher is the only component that invokes podman/docker.

# src/harnessed/launcher.py — Typer app
app = typer.Typer(name="harnessed", ...)

@app.command()
def build(stack: str, ...): ...

@app.command()
def launch(stack: str, ...): ...

harnessed-tools — the emit-only assembler (src/harnessed/cli.py)

A minimal argparse-based CLI. Used internally by harnessed build, but also runnable standalone (e.g. in CI to assemble without launching). Subcommands:

  • assemble <stack> --build-dir <dir> — emit profile artifacts only, no podman.
  • test <stack> — capability test: launch headless, diff declared vs live capabilities.
  • scan <stack> — source/Python supply-chain scan of recipe dirs + emitted profile.
  • scan-image <archive> — image-archive scan via osv-scanner.
  • scan-image-online <archive> — same, using the live osv.dev DB (for nightly re-scans).
  • scan-snyk-container <image> — Snyk container scan (token-gated, warns without SNYK_TOKEN).
  • persist-list, persist-prune — manage per-project persist dirs.

Data flow: recipe.yaml → running container

1. Parse (schema.py)

schema.py parses catalog YAML files into typed Python dataclasses. The three key loaders are:

load_stack(stack_dir: Path) -> Stack
load_stack_with_recipes(root, stack_name, strict=False) -> tuple[Stack, list[Recipe]]
load_agent(harness: str) -> Agent
load_service(root, name: str) -> Service

Key dataclasses:

Class Source Purpose
Stack stacks/<name>/stack.yaml Links a harness + list of recipe names
Recipe recipes/<name>/recipe.yaml Capability bundle: servers, skills, commands, persist, init
McpServer recipe.mcp.servers[] One MCP server: stdio child or network URL
Agent agents/<name>/agent.yaml Container image + Dockerfile for a harness
Service services/<name>/service.yaml Shared sidecar: image + port
Expect recipe.expect What a Dockerfile-delivered recipe declares for the capability test
PersistSpec recipe.persist[] Per-recipe bind-mount declarations
InitSpec recipe.init One-time init command + idempotency marker

Catalog resolution is done by paths.find_in_catalog(kind, name), which searches the user overlay (~/.config/harnessed/catalog) before the repo catalog/ — user wins on name clash.

2. Assemble (assemble.py)

assemble(root, stack_name, build_dir, strict=False) -> AssembleResult

The orchestration function. In order:

  1. Calls load_stack_with_recipes to get the Stack + all Recipe objects.
  2. Validates each recipe: rejects raw npm/npx (validate_no_raw_npm) and floating Dockerfile pins like :latest or --branch main (validate_pin).
  3. Calls _merge_servers(recipes) — collects all McpServer objects, fails fast on duplicate server names across recipes.
  4. Calls _resolve_service_servers(servers, root) — for servers declared with service:, reads the service YAML to get the published port and sets the URL to http://host.containers.internal:<port>/mcp.
  5. Calls emit.* functions to write the profile to disk.
  6. Uses LinkSyncer to fan skills/commands/rules from recipe dirs into the profile .claude/ tree, failing fast on name collisions.

Return value AssembleResult carries:

  • stack — the parsed Stack
  • recipes — all Recipe objects
  • profile_dir — the emitted profile path
  • servers — all McpServer objects (network + stdio)
  • baked — stdio children only (hatago must bake + spawn these)

3. Emit (emit.py)

Pure file-writing. Each function writes exactly one artifact:

Function Artifact Purpose
reset_profile(profile_dir) wipes + recreates the dir guarantees reproducible build
write_mcp_json(profile_dir) profiles/<stack>/.mcp.json single hatago endpoint for the harness
write_hatago_config(profile_dir, servers) profiles/<stack>/hatago.config.json hatago child/proxy server list
write_settings_json(profile_dir, servers, recipes) profiles/<stack>/settings.json MCP grant + recipe hooks floor
write_derived_dockerfile(profile_dir, stack, recipes) profiles/<stack>/Dockerfile.harnessed-<stack> concatenated recipe layers

The emitted .mcp.json always has exactly ONE entry — the hatago hub:

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

The emitted hatago.config.json lists every actual MCP server:

{
  "version": 1,
  "mcpServers": {
    "time":  { "command": "uvx", "args": ["mcp-server-time"] },
    "ping":  { "url": "http://host.containers.internal:4040/mcp", "type": "http" }
  }
}

4. Build (launcher.py — _build_stack)

After emit, the launcher runs these podman build commands in sequence:

  1. _build_base_image — rebuilds harnessed-base:latest from catalog/base/Dockerfile.harnessed-base. Layer-cached; no-op when unchanged. This image bakes hatago + runtime tooling (node, mise, uvx, etc.).
  2. _build_agent_image — rebuilds harnessed-<harness>:latest from the agent's Dockerfile (resolved via agent.yaml). Passes agent.build_args as --build-arg.
  3. _build_derived_image — builds harnessed-<stack>:latest from the emitted Dockerfile.harnessed-<stack>. Its final layer runs harnessed-scan (advisory supply-chain scan; never fails the build).

Post-build steps (also in _build_stack):

  • _merge_baked_extensions — extracts ~/.claude/{skills,commands,plugins,agents,hooks,rules} from the derived image into the profile tree (so image-baked files survive the profile mount).
  • _merge_baked_settings — reads ~/.claude/settings.json from the derived image and re-applies harnessed's required grants via emit.merge_settings().
  • _surface_scan_report — copies ~/.harnessed/scan-report.json from the image and prints a supply-chain summary.

5. Launch (launcher.py — launch command)

The launch (default) command:

  1. Checks whether a container named harnessed-<stack>-<project_hash> already exists and is running → re-attaches if so.
  2. If not built or stale (image rebuilt since last launch), calls _build_stack.
  3. Runs _run_init_for_stack for any recipe with an init: block whose marker is absent.
  4. Assembles -v mount arguments via _build_mount_args:
    • Profile tree: .mcp.json, settings.json, .claude/{skills,commands,agents,hooks,rules} (ro).
    • Session state dirs: .claude/projects, .claude/file-history, etc. (rw from host ~).
    • Credentials: ~/.claude/.credentials.json (ro), ~/.claude.json stub (rw per-instance copy).
    • Project path mirrored at its host absolute path inside the container (MNT2-02).
  5. Adds credential forwarding: 1Password agent socket, GPG public surface, git config, SSH config.
  6. Adds persist bind-mounts via _persist_mounts (from persist.py + paths.py).
  7. Creates the podman pod + container and executes the harness attach command.

The harness attach command is looked up from _HARNESS_ATTACH_CMD:

_HARNESS_ATTACH_CMD = {
    "claude":      "claude --mcp-config '{mcp_cfg}' --strict-mcp-config",
    "omp":         "omp",
    "opencode":    "opencode",
    "gemini":      "gemini",
    "antigravity": "agy",
    "codex":       "codex",
}

Key abstractions

paths.py — single source of truth for paths

All path computations live here. No other module computes profile dirs, instance names, or container paths independently.

Key functions:

repo_root() -> Path          # honors $HARNESSED_DIR, else derived from __file__
user_catalog() -> Path       # $XDG_CONFIG_HOME/harnessed/catalog
catalog_roots() -> list[Path] # [user_catalog, repo/catalog] — user wins
find_in_catalog(kind, name) -> Path  # searches catalog_roots in order
profiles_root() -> Path      # $XDG_DATA_HOME/harnessed/profiles/
profile_dir(stack) -> Path   # profiles_root() / stack
instance_name(stack, project_path) -> str  # harnessed-<stack>-<sha1[:8]>
project_hash(project_path) -> str          # sha1[:8] of normalized path
persist_root() -> Path       # $XDG_DATA_HOME/harnessed/persist/
persist_workspace_dir(recipe, project_path, name) -> Path  # per-worktree
persist_project_dir(recipe, project_path, name) -> Path    # keyed by git-common-dir
hatago_endpoint() -> str     # http://localhost:3535/mcp (honors $HATAGO_PORT)
CONTAINER_HOME = Path("/home/harnessed")

synclinks.py — skill/command fan-out

LinkSyncer collects all skills/, commands/, and rules/ dirs declared by recipes and copies them into <profile>/.claude/{skills,commands,rules}/. Collision detection is fail-fast (two recipes shipping the same name raises CollisionError before any file is written).

persist.py — global persist guard

Implements the two-layer security gate for scope: global persist entries:

  1. Hard-deny~/.ssh, ~/.aws, ~/.gnupg, ~/.config/harnessed, and bare $HOME are always denied, regardless of the allowlist.
  2. Allowlist~/.config/harnessed/persist-allowlist (one path per line, user-owned). Absent from this file → PersistNotAllowlistedError.

capability.py / report.py — the oracle

run_capability_test launches the stack headless with --fresh, then probes the running instance:

  • ~/.claude/skills/<name> — for assembler-visible skills and expect.skills
  • ~/.claude/commands/<name> — for commands
  • ~/.claude/plugins/<name> — for expect.plugins
  • hatago MCP tool list — for expect.mcp

schema.expected_capabilities(stack, recipes) computes the union of what the assembler can see (fanned skills/commands, MCP server names) plus what each recipe declares in its expect: block. The oracle diffs declared vs live and returns a structured result. report.emit() renders it as a Rich table or JSON.

scan.py — supply-chain scanning

Implements BLD-02 and SEC-04:

  • run_source_scan(root, stack, build_dir) — osv-scanner + pip-audit over recipe dirs and the emitted profile (runs on the host at build time).
  • run_image_scan(archive) — osv-scanner over a saved image archive.
  • run_image_scan_online(archive) — same, using the live osv.dev DB (nightly re-scan).
  • run_snyk_container_scan(image_name) — Snyk container test (token-gated).

All raise ScanError on CVSS HIGH+ findings; LOW/MEDIUM emit as warnings, never block.

persist_gc.py — persist lifecycle

list_entries() and prune_project() manage the $XDG_DATA_HOME/harnessed/persist/ tree. Used by the harnessed-tools persist-list and persist-prune commands.


Settings.json lifecycle

This is the most complex multi-step state transformation in the codebase:

  1. Assemble timeemit.write_settings_json() writes a minimal "floor": {permissions: {allow: ["mcp__hatago"]}, hooks: {...recipe-declared-hooks...}}. This is incomplete because the image does not exist yet.

  2. Post-buildlauncher._merge_baked_settings() reads ~/.claude/settings.json out of the newly-built derived image via podman cp, then calls emit.merge_settings(baked, required). The merge rules:

    • baked is authoritative (installer-written, post-image-build).
    • required contributions (the hatago MCP grant + recipe hooks) are unioned in, never overwritten — harnessed's grant is re-added even if baked denied it.
    • All other baked keys pass through verbatim (no generic deep-merge to avoid corrupting array-valued keys like permissions.deny).
  3. At launch — the final merged settings.json is mounted read-only into the container at $CONTAINER_HOME/.claude/settings.json.


Harness abstraction

The profile model is designed to be harness-agnostic: any harness shares the same assembled profile and the same hatago MCP hub. On main today, only claude and omp are launchable (they have a catalog/agents/<name>/agent.yaml); opencode, gemini, antigravity, codex are planned/unmerged (base Dockerfiles only, no agent.yaml — bd main-9sv). Harnesses differ only in:

  • How they read the profile: claude is native; omp uses the claude-hooks-bridge; gemini/codex use image-baked config files that point at hatago.
  • Auth model: claude uses a read-only credential mount + per-instance .claude.json stub; omp bind-mounts the host ~/.omp/agent directory read-write (shared state, intentional).
  • Attach command: looked up from _HARNESS_ATTACH_CMD in launcher.py.

HARNESS_CONFIG_DIR in schema.py maps every harness to .claude — they all consume the same Claude-canonical profile dir. No harnesses: field exists in recipe YAML; per-harness behavior is encoded inside a recipe's Dockerfile via ${HARNESS}.


Persist system

Recipe persist entries have three axes:

Axis Values Meaning
scope workspace / project / global Keying: per-worktree path / git-common-dir / real host path
location host / in_repo Storage: harnessed-managed dir / already inside project mount
vcs tracked / ignored For in_repo only: whether harnessed adds .gitignore entry

Scope determines the persist dir key in $XDG_DATA_HOME/harnessed/persist/:

  • workspace → keyed by sha1(project_path)[:8] (per exact launch path)
  • project → keyed by sha1(git_common_dir)[:8] (same across all worktrees of one checkout)
  • global → real host dir passed through the allowlist + hard-deny gate

The in_repo location declares that the item lives inside the already-mounted project tree — no extra bind-mount, just a VCS intent annotation.


Init system

A recipe can declare a one-time init command:

init:
  marker:
    scope: workspace
    location: in_repo
    name: .beads
    file: embeddeddolt   # optional: check for a file inside the named dir
  run: bd-resolve-beads-dir >/dev/null && bd init --quiet --non-interactive --role maintainer && bd-setup-agent

_run_init_for_stack in launcher.py:

  1. Resolves the marker path on the host using the same path helpers as _persist_mounts.
  2. If the marker exists, skips the recipe (idempotent).
  3. Runs a transient podman run --rm container with the same project + persist mounts as a normal launch. No secrets, no hatago. Non-zero exit is a hard failure.

Init containers use _init_mount_args which, for a worktree-based git layout, also bind-mounts the git common dir even when it lives outside the project mount — so bd init can wire git hooks and the origin remote correctly.

Clone this wiki locally