Skip to content

container filesystem

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

Container filesystem layout

This page is a consolidated reference for every host-path → container-path mapping that harnessed creates when it launches a stack. Use it to understand where files land inside the container, diagnose mount problems, and author persist: / init: entries correctly.

Two invariants govern all mounts:

  1. Profile is assembled ahead of time. Nothing in $XDG_DATA_HOME/harnessed/profiles/<stack>/ is resolved, templated, or patched at container start — what harnessed build wrote is what the container sees, byte-for-byte.
  2. No secrets baked into images, and credentials never resolved by harnessed build. Every secret surface (.credentials.json, SSH agent socket, opt-in git credentials) reaches the container only at launch time, as a bind-mount or env-file, and only when the host-side file exists. harnessed build never invokes varlock and never touches any credential at all — see Secrets guide for the deliberate separation between build and launch.

Container identity

Value Detail
Container user harnessed (renamed from Ubuntu's default ubuntu)
$HOME /home/harnessed
CONTAINER_HOME constant paths.CONTAINER_HOME = Path("/home/harnessed")

The base image (catalog/base/Dockerfile.harnessed-base) sets ENV HOME=/home/harnessed. Every in-container path that starts with $HOME or ~ resolves there.


Profile mounts (read-only)

Assembled by harnessed build into $XDG_DATA_HOME/harnessed/profiles/<stack>/. All mounts below are read-only (ro). Only harnesses that actually consume each file receive the mount (claude/omp/opencode for the .claude/ tree; claude-only for .mcp.json).

Host path Container path Notes
profiles/<stack>/.mcp.json $HOME/.mcp.json claude only; passed to --mcp-config
profiles/<stack>/settings.json $HOME/.claude/settings.json claude, omp, opencode
profiles/<stack>/.claude/skills/ $HOME/.claude/skills/ claude, omp, opencode
profiles/<stack>/.claude/commands/ $HOME/.claude/commands/ claude, omp, opencode
profiles/<stack>/.claude/agents/ $HOME/.claude/agents/ claude, omp, opencode
profiles/<stack>/.claude/hooks/ $HOME/.claude/hooks/ claude, omp, opencode
profiles/<stack>/.claude/rules/ $HOME/.claude/rules/ claude, omp, opencode
profiles/<stack>/hatago.config.json $HOME/hatago.config.json all harnesses

where profiles/ expands to $XDG_DATA_HOME/harnessed/profiles/ (defaults to ~/.local/share/harnessed/profiles/).


Project workspace mount

The project directory (or a parent via --mount-folder) is mounted path-mirrored: the same absolute path on the host appears at the same absolute path inside the container. This means the agent sees host paths directly — no path translation needed.

Host path Container path Mode
<mount_path> <mount_path> (same) rw

The agent's working directory (cwd) is set to <project_path> (or --agent-start-folder if specified). With --mount-folder, <mount_path> is a parent of <project_path> — the whole subtree is visible but the agent starts inside the project.


Claude session history mounts (read-write)

These dirs are sourced from the host $HOME/.claude/ and mounted read-write so Claude's session history, file-tracking, and todos persist across container restarts. Dirs are created on the host automatically if they don't exist.

Host path Container path Mode
~/.claude/projects/ $HOME/.claude/projects/ rw
~/.claude/file-history/ $HOME/.claude/file-history/ rw
~/.claude/tasks/ $HOME/.claude/tasks/ rw
~/.claude/session-env/ $HOME/.claude/session-env/ rw
~/.claude/todos/ $HOME/.claude/todos/ rw

Credential mounts

Claude OAuth token

Host path Container path Mode Notes
~/.claude/.credentials.json $HOME/.claude/.credentials.json ro Only mounted when file exists
$XDG_STATE_HOME/harnessed/<instance>/claude.json $HOME/.claude.json rw Per-instance onboarding stub; seeded from host ~/.claude.json (account identity only, never the OAuth token)

<instance> is harnessed-<stack>-<sha1[:8] of project path>.
$XDG_STATE_HOME defaults to ~/.local/state.

The stub at .claude.json marks hasCompletedOnboarding: true so Claude Code skips its first-run login screen. The real authentication is ~/.claude/.credentials.json (OAuth token) — the stub never contains token material.

omp (Oh My Pi)

Host path Container path Mode Notes
~/.omp/agent/ $HOME/.omp/agent/ rw omp harness only; full host-state sharing

SSH signing/auth agent + git config (always forwarded when present)

Forwarded automatically whenever the host agent socket is live — no forward_git_credentials needed. Safe as a default because the agent exposes no key material and gates every sign/auth behind a host-side 1Password approval or YubiKey touch, and the git config is a public signing-key reference, not a secret. All read-only unless noted; only mounted when the host file/socket exists.

Host path Container path Mode Notes
~/.1password/agent.sock (Linux) or macOS equivalent $HOME/.1password/agent.sock socket 1Password SSH agent; sets $SSH_AUTH_SOCK
gpg-agent SSH socket (gpgconf --list-dirs agent-ssh-socket) $HOME/.gnupg-sockets/S.gpg-agent.ssh socket YubiKey fallback; sets $SSH_AUTH_SOCK when 1Password absent
~/.config/git/ (or ~/.gitconfig) $HOME/.config/git/ (or $HOME/.gitconfig) ro Carries user.signingkey, gpg.format=ssh, etc.

git credentials (opt-in, forward_git_credentials: true in stack.yaml)

The secret-bearing surface — only mounted when a stack sets forward_git_credentials: true, and only when the corresponding host file/dir/device exists. All read-only unless noted.

Host path Container path Mode Notes
~/.config/gh/hosts.yml $HOME/.config/gh/hosts.yml ro gh oauth token — so gh authenticates as you
~/.gnupg/pubring.kbx, trustdb.gpg, gpg.conf, gpg-agent.conf, sshcontrol $HOME/.gnupg/<name> ro Non-secret GPG surface only; private-keys-v1.d/ is explicitly excluded
~/.ssh/config, known_hosts, *.pub $HOME/.ssh/<name> ro Non-secret SSH surface
~/.ssh/<name> where <name> is in stack.yaml ssh_keys: $HOME/.ssh/<name> ro Opt-in private keys; honored only from user overlay catalog
YubiKey USB device --device passthrough Linux only; when YubiKey is attached

Persist mounts (read-write)

Recipe persist: entries create bind-mounts that survive --fresh restarts. The scope × location axes determine how the host path is constructed. For the full authoring schema (field names, VCS control, valid combinations), see Recipe authoring — persist:.

scope: workspace, location: host — per-worktree, harnessed-managed

Host path template Container path Mode
persist/<recipe>/<sha1[:8] of realpath(project_path)>/<name>/ $HOME/<name>/ rw

persist/ expands to $XDG_DATA_HOME/harnessed/persist/ (defaults to ~/.local/share/harnessed/persist/). The key is the SHA-1[:8] of the resolved launch path — two worktrees of the same git checkout get separate dirs.

scope: project, location: host — cross-worktree, harnessed-managed

Host path template Container path Mode
persist/<recipe>/<sha1[:8] of git-common-dir>/<name>/ $HOME/<name>/ rw

The key is derived from git rev-parse --path-format=absolute --git-common-dir — the same path for every worktree of a given checkout, so all worktrees share one host dir. Falls back to the workspace hash (with a warning) when project_path is not inside a git repository. Does not survive an independent git clone of the same repo (git-common-dir changes).

scope: global, location: (not specified) — real host path, allowlisted

Host path Container path Mode
<path> (the real host path, allowlisted) <path> (same, path-mirrored) rw

The path is mounted path-preserving (host path == container path) so the tool finds its data where it expects. Gated by $XDG_CONFIG_HOME/harnessed/persist-allowlist (default ~/.config/harnessed/persist-allowlist). Several sensitive dirs (~/.ssh, ~/.aws, ~/.gnupg, ~/.config/harnessed, $HOME itself) are hard-denied regardless of the allowlist.

Path-preserving ≠ $HOME-relative. The pod's $HOME is /home/harnessed, so a host dotdir mounted this way lands at /home/you/.foo, not ~/.foo — a tool that looks under $HOME will not find it. The launcher exports HOST_HOME into the attach shell for exactly this case; point the tool at the mirrored path from the recipe's init:. The pulumi recipe is the reference implementation (see the Pulumi guide):

persist:
  - scope: global
    path: ~/.pulumi        # the real host dir: credentials.json + the plugin cache

init:
  run: export PULUMI_HOME="$HOST_HOME/.pulumi"

scope: workspace|project, location: in_repo — inside the workspace, no extra mount

No additional bind-mount: the entry lives inside the path-mirrored workspace mount, which is already rw. vcs: ignored causes harnessed to append the entry name to the project's .gitignore idempotently. vcs: tracked leaves .gitignore untouched.


Init (init:)

init: has no host-side marker (the declarative marker was removed — "Model A"). The run command is sourced inline in the attach shell before the harness starts, on every attach, so it must self-gate cheaply (e.g. bd list >/dev/null 2>&1 || bd init). There are no per-recipe marker files and no transient podman run --rm. See recipe authoring — One-time init.


Misc fixed mounts

Host path Container path Mode Notes
catalog/base/egress-firewall.sh /usr/local/sbin/egress-firewall ro Applied by _apply_firewall after the container starts; not configurable per recipe

See also

  • Recipe authoringpersist: and init: schema reference (field names, valid scope/location combos, vcs:, worked examples)
  • harnessed-design.md — the design rationale behind the mount model (profile-ahead-of-time, path mirroring, no-host-defaults invariant, secrets threat model)
  • research/home-folder-claude-requirements.md — why the specific .claude/ subpaths are forwarded from the host (and which are intentionally excluded)
  • Stacksstate.session_state in stack.yaml is a different, stack-level mechanism (where Claude session history volumes land: host vs volume) and is currently unimplemented in the launcher. Do not conflate it with recipe persist: entries, which are fully implemented and described on this page.
  • Secrets (varlock + 1Password) — how secrets reach the container at launch time without being baked into the image or the profile

Clone this wiki locally