-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
Two separate CLI binaries, one Python package:
Installed as the primary CLI. Built with Typer. Drives the full lifecycle:
-
harnessed build <stack>— assemble (in-process) +podman buildthe 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, ...): ...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 withoutSNYK_TOKEN). -
persist-list,persist-prune— manage per-project persist dirs.
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) -> ServiceKey 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.
assemble(root, stack_name, build_dir, strict=False) -> AssembleResult
The orchestration function. In order:
- Calls
load_stack_with_recipesto get theStack+ allRecipeobjects. - Validates each recipe: rejects raw
npm/npx(validate_no_raw_npm) and floating Dockerfile pins like:latestor--branch main(validate_pin). - Calls
_merge_servers(recipes)— collects allMcpServerobjects, fails fast on duplicate server names across recipes. - Calls
_resolve_service_servers(servers, root)— for servers declared withservice:, reads the service YAML to get the published port and sets the URL tohttp://host.containers.internal:<port>/mcp. - Calls
emit.*functions to write the profile to disk. - Uses
LinkSyncerto 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)
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" }
}
}After emit, the launcher runs these podman build commands in sequence:
-
_build_base_image— rebuildsharnessed-base:latestfromcatalog/base/Dockerfile.harnessed-base. Layer-cached; no-op when unchanged. This image bakes hatago + runtime tooling (node, mise, uvx, etc.). -
_build_agent_image— rebuildsharnessed-<harness>:latestfrom the agent's Dockerfile (resolved viaagent.yaml). Passesagent.build_argsas--build-arg. -
_build_derived_image— buildsharnessed-<stack>:latestfrom the emittedDockerfile.harnessed-<stack>. Its final layer runsharnessed-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.jsonfrom the derived image and re-applies harnessed's required grants viaemit.merge_settings(). -
_surface_scan_report— copies~/.harnessed/scan-report.jsonfrom the image and prints a supply-chain summary.
The launch (default) command:
- Checks whether a container named
harnessed-<stack>-<project_hash>already exists and is running → re-attaches if so. - If not built or stale (image rebuilt since last launch), calls
_build_stack. - Runs
_run_init_for_stackfor any recipe with aninit:block whose marker is absent. - Assembles
-vmount 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.jsonstub (rw per-instance copy). - Project path mirrored at its host absolute path inside the container (MNT2-02).
- Profile tree:
- Adds credential forwarding: 1Password agent socket, GPG public surface, git config, SSH config.
- Adds persist bind-mounts via
_persist_mounts(frompersist.py+paths.py). - 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",
}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")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).
Implements the two-layer security gate for scope: global persist entries:
-
Hard-deny —
~/.ssh,~/.aws,~/.gnupg,~/.config/harnessed, and bare$HOMEare always denied, regardless of the allowlist. -
Allowlist —
~/.config/harnessed/persist-allowlist(one path per line, user-owned). Absent from this file →PersistNotAllowlistedError.
run_capability_test launches the stack headless with --fresh, then probes the running instance:
-
~/.claude/skills/<name>— for assembler-visible skills andexpect.skills -
~/.claude/commands/<name>— for commands -
~/.claude/plugins/<name>— forexpect.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.
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.
list_entries() and prune_project() manage the $XDG_DATA_HOME/harnessed/persist/ tree. Used by
the harnessed-tools persist-list and persist-prune commands.
This is the most complex multi-step state transformation in the codebase:
-
Assemble time —
emit.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. -
Post-build —
launcher._merge_baked_settings()reads~/.claude/settings.jsonout of the newly-built derived image viapodman cp, then callsemit.merge_settings(baked, required). The merge rules:-
bakedis authoritative (installer-written, post-image-build). -
requiredcontributions (the hatago MCP grant + recipe hooks) are unioned in, never overwritten — harnessed's grant is re-added even ifbakeddenied it. - All other baked keys pass through verbatim (no generic deep-merge to avoid corrupting
array-valued keys like
permissions.deny).
-
-
At launch — the final merged
settings.jsonis mounted read-only into the container at$CONTAINER_HOME/.claude/settings.json.
All harnesses (claude, omp, opencode, gemini, antigravity, codex) share the same assembled profile and the same hatago MCP hub. They 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.jsonstub; omp bind-mounts the host~/.omp/agentdirectory read-write (shared state, intentional). -
Attach command: looked up from
_HARNESS_ATTACH_CMDinlauncher.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}.
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 bysha1(project_path)[:8](per exact launch path) -
project→ keyed bysha1(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.
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:
- Resolves the marker path on the host using the same path helpers as
_persist_mounts. - If the marker exists, skips the recipe (idempotent).
- Runs a transient
podman run --rmcontainer 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.
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)