-
Notifications
You must be signed in to change notification settings - Fork 0
ARCHITECTURE
Analysis Date: 2026-07-31
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.
Two CLI entry points, both installed by pyproject.toml:
-
harnessed—src/harnessed/launcher.py:main()— the user-facing CLI (Typer). Handlesbuild,launch,host-run,test,new,svc,update,scan,rescan,install,uninstall,list,stop,rm,prune,clean,aws-sso,host-gc,volume-gc. -
harnessed-tools—src/harnessed/cli.py:main()— the build-time-only assembler (argparse). Invoked in-process byharnessed buildand also usable standalone. Subcommands:assemble,test,scan-image-online,persist-list,persist-prune.
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()(recursiveextends:) →load_recipe()for each recipe -
root=None→ multi-root resolution viapaths.catalog_roots()(user overlay first);root=<path>→ single-root (used by tests with fixtures) - Variety refs (
beads/stealth) map tocatalog/recipes/beads/stealth/viapaths.catalog_relpath() - Unknown recipe fields generate a Levenshtein typo suggestion; raise
SchemaErrorin strict mode
Validation (all in schema.py, called from assemble.assemble()):
-
validate_no_raw_npm— rejectsnpm/npxin manifests and vendoredpackage.jsonscripts -
validate_pin— rejects@latest,--branch main/master,:latestin recipe Dockerfiles -
validate_container_only_declared— requiressystem:when a Dockerfile has aRUNbut install.sh was migrated out -
validate_no_claude_writes— rejects~/.claudewrites 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— rejectsexitininit.run(sourced into the shell; anexitkills it)
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— concatenatesARG HARNESS=<h>then recipe Dockerfile bodies in order; a bareARG HARNESSline 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.yamlbytes + 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; raisesStaleProfileErrorif mismatch
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()— copiescatalog/into a temp dir before callingpodman build; prevents.git/.venv/node_modulesfrom shipping to the daemon and avoids "symlink escapes context" rejection -
_build_derived_image()— stamps the image with labelharnessed.recipe-hash(output ofcompute_recipe_hash()); a later reconciliation pass compares this againstpodman inspectto skip unnecessary rebuilds -
_ensure_stack_volumes()— creates two podman named volumes (harnessed-cfg-<harness>-<stack>mapping to~/.claudeandharnessed-tools-<harness>-<stack>mapping to~/.local); fingerprint-gated; calls_run_container_installs()which runs each recipe'sinstall.shinside a throwaway container writing to those volumes
@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
PersistSpecentries (_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):
-
claude—claude --mcp-config '/home/harnessed/.mcp.json' --strict-mcp-config -
omp—omp [--session-dir …](session dir pinned to host key to align with host sessions) -
opencode—opencode [--agent <name>](agent name only when stack hasinstructions:) -
antigravity—agy -
codex—codex
@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.
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.
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.
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.
Three scopes:
-
workspace— keyed bypaths.project_hash(project_path)(per worktree) -
project— keyed bypaths.project_hash(git_common_dir)(shared across worktrees of one checkout) -
global— a real host dir; default-deny viapersist.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.
Two modes: build-time (in-container, offline DB via _scan_image) and nightly re-scan (harnessed rescan → run_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.
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.
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
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"])
- Create
catalog/recipes/<name>/recipe.yaml(orcatalog/recipes/<family>/<variety>/recipe.yamlfor a variety). - Optionally add
Dockerfile(system-layer steps only; no~/.claudewrites; anyRUNalongside a migratedinstall.shrequiresinstall.system: "<reason>"). - Optionally add
install.sh(both modes; use$HARNESSED_BIN_DIR,$HARNESSED_RECIPE_DIR,$HARNESSED_CONFIG_DIR,$HARNESSED_INSTALL_CACHE). - Add to a stack's
recipes:list. - Run
harnessed build <stack> <harness>— all lint checks run automatically.
- Create
catalog/agents/<name>/agent.yaml(name:,image:). - Create
catalog/base/Dockerfile.harnessed-<name>. - Add to
_HARNESS_ATTACH_CMDinsrc/harnessed/launcher.py. - Add to
HARNESS_CONFIG_DIRinsrc/harnessed/schema.py. - If the harness uses a config format other than
.claude/CLAUDE.mdfor stack identity, add an identity-emission branch inassemble.assemble()and a correspondingemit.*writer.
- Create
catalog/services/<name>/service.yaml(name:,port:,scope:,data:). - Create
catalog/services/<name>/Dockerfile. - Reference via recipe
mcp.servers[].service: <name>(MCP endpoint exposed through hatago) or stackservices: [<name>](attached sidecar with no MCP surface).
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.
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).
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)