-
Notifications
You must be signed in to change notification settings - Fork 0
harnessed design
Status: Design spec (resolved via discuss/grill session). Architecture decisions (§2–§9) are confirmed. Schemas, repo layout, and CLI (§10–§13) are proposed and open for review. Items in §14 are to verify during execution.
Names: the executable is
harnessed; what it launches is a stack — a podman pod with the harness container + hatago + shared services.
A prior attempt to merge a curated set of skills/plugins/MCP into the host config
(~/.agents + sync-plugin-links + universal-hooks) failed: a single shared host namespace
(~/.claude, ~/.agents) cannot hold every experiment at once — openbrain and hindsight
collide, per-runtime settingSources drift, and vendored deps pollute ~.
Insight: do the merge per container, where each stack is isolated, so the collision that killed the host merge disappears by construction.
There is one executable, harnessed. Every stack shares the same base image, the same
host-integration mounts (§4), the same project mount, and host auth. Config source is always:
auth seeded + the assembled stack profile mounted; nothing from host config — a "clean room
with exactly what I picked."
A running stack is composed at runtime, in a podman pod on a shared network — not
at build time. (FROM is linear inheritance + multi-stage COPY --from; it cannot union two
sibling systems. See §6.)
Provider abstraction. The shared-netns group is runtime-abstracted by the host CLI (
src/harnessed/launcher.py): podman uses a pod (pod create+run --pod, rootless uid via--userns=keep-id); docker uses a shared-netns pair — hatago runs first, the harness joins with--network container:<instance>-hatago(rootless docker remaps uids daemon-side, no--userns). Applecontainerhas no shared-netns equivalent (one VM+IP per container) and is a tracked follow-up (needs a named network + non-localhost MCP endpoint), not yet supported.
podman pod: harnessed-<stack>-<proj>
┌──────────────────────────────────────────────┐
│ [ harnessed-<harness> ] ──→ [ hatago ] │
│ mounts cwd + profile MCP hub · HTTP │
└───────────────────────────────────────┬───────┘
│ MCP over host.containers.internal (HARNESSED_NET: opt-in)
┌──────────────────────┴──────────────────────┐
▼ ▼
[ hindsight ] [ openbrain ]
shared · service-scoped shared · service-scoped
own image · volume · lifecycle own image · volume · lifecycle
- **harness container** — runs the harness (`claude`/`omp`/`opencode`/`gemini`/`antigravity`/`codex`), auth seeded, current folder mounted,
stack profile mounted into the harness config dir.
- **hatago** — MCP hub. Aggregates all of the stack's MCP servers behind **one** HTTP endpoint;
the harness container's `.mcp.json` points at `localhost:<port>`. Light `npx`/`uvx` stdio servers run as
hatago's children (baked into the hatago image); heavy services are proxied over the network.
- **shared services** — heavy/stateful systems (hindsight = postgres+MCP, openbrain). Each is
its **own** image/container/volume, **service-scoped and harness-independent**, with a
lifecycle independent of any instance. Multiple harnessed instances attach to the **same** running service concurrently.
## 4. Mounts: host-integration layer + isolated config source
Two distinct mount layers that compose for every stack.
### 4a. Host-integration layer (shared by ALL stacks)
Ported verbatim from `container.sh`'s `start_new_container` — these are credentials, signing,
and agents, *not* the config-experiment surface, so they belong in every instance:
- 1Password SSH agent socket (`SSH_AUTH_SOCK`)
- GPG agent SSH socket + `~/.gnupg` (ro) — YubiKey SSH / commit signing
- YubiKey USB device passthrough (`--device`)
- `~/.ssh` (ro), git config (ro), `/etc/machine-id` (ro)
- `~/.zai.json` (ro) and per-tool `~/.config/<tool>` dirs (editor configs, etc.)
- egress firewall (`--cap-add NET_ADMIN`, `egress-firewall.sh`)
- the current project folder, mounted at the work dir
### 4b. Config source — surgical per-file mounts
Auth seeded, config from the profile via **surgical per-file mounts** — the core isolation trick:
- The real credential is **`~/.claude/.credentials.json`** (OAuth token). Mount it read-only.
Auth credential mounts are handled by the §4a host-integration layer — never by the per-harness
manifest (manifests list only config/skill files, never auth credential paths).
- **`~/.claude.json`** is *not* auth — it's `oauthAccount` metadata + ~450 KB of config/state
(`projects`, `mcpServers`, caches). **Do not mount it.** **Generate** a minimal stub with only
the fields needed to skip onboarding (see §14 — exact set to verify).
- **Surgical per-file mounts via `lib/manifests/<harness>.yaml`.** Profile assets are NOT mounted
as a whole `profiles/<stack>/` directory. Each harness has a YAML manifest
(`lib/manifests/claude.yaml`, `lib/manifests/omp.yaml`, etc.) with two top-level keys:
- `profile_files`: individual filenames (e.g. `.mcp.json`, `settings.json`) mounted read-only
from `profiles/<stack>/` into the container's harness config dir. The host CLI
(`src/harnessed/launcher.py`) reads the manifest at launch time and applies harness-aware
container target paths: claude/omp/opencode mount profile files to `~/.claude/<f>`;
gemini/antigravity/codex skip profile file mounting (their MCP config is image-baked).
- `history_dirs`: `$HOME`-relative paths bind-mounted read-write for history surfacing (e.g.
`.claude/projects`, `.claude/todos`, `.claude/tasks` for claude).
Why surgical? Mounting a whole directory lets host defaults (other skills, stale config, personal
CLAUDE.md) bleed into the container — exactly the "no host defaults" invariant §4 protects.
Individual per-file mounts make the container's config surface exactly what the profile declares,
no more.
- Session state — `~/.claude/projects/` + `history.jsonl` — persists to the **host** by default,
so sessions survive instance recreation and stay inspectable. The project is mounted at a stable
in-container path (e.g. `/home/harnessed/<relpath>`) so Claude's slug is legible
(`-home-harnessed-<relpath>`), under a harnessed-owned dir so it never pollutes the host's own
`~/.claude`. `session_state: volume` (§12) opts into a throwaway per-instance volume instead.
Every stack is authenticated but carries **no host defaults** — exactly what was picked.
### 4c. omp auth is a deliberate exception: shared host state, not isolated
The surgical-mount model above keeps **claude** auth isolated — a read-only `~/.claude/.credentials.json`
mount (the OAuth token, always current) plus a generated token-free `~/.claude.json` stub to skip
onboarding. No host state is writable from the container.
**omp does not fit that model, and we intentionally do not force it to.** omp (Oh My Pi) keeps
*everything* — credentials, the setup gate (`config.yml` `setupVersion`), provider/model config,
usage tracking, and sessions — in one place: `~/.omp/agent/` (credentials live in `agent.db` →
`auth_credentials`, as plaintext-JSON OAuth/API entries). There is no separate, mountable credential
file to isolate. So for omp the launcher **bind-mounts the host `~/.omp/agent` read-write** into the
pod (`_omp_agent_mount`), and launches plain `omp` (NOT `omp --profile <instance>` — `--profile`
points omp at an isolated, *empty* store, which is exactly why a credential-only seed left it at the
login screen).
Why this is the right call here — and why it's safe to break the isolation invariant for omp only:
- **The container is the user's primary omp, not a sandbox of it.** When you live in these
containers, the host is just another client of one shared omp state — there is no "host source of
truth" to protect *from* the container. Isolating omp per-instance would fragment auth, usage, and
sessions across every stack and force a re-login dance on each launch.
- **Unified, always-current state.** Auth never goes stale (read live, not copied); usage tracking
writes back to the single host ledger; sessions are visible across the host and every container.
- **We tried the isolated path and it was worse.** A per-instance snapshot (copy `agent.db`, strip
history, refresh creds, seed `config.yml`) was built and rejected: it duplicated state, reset usage
on recreation, and still had to copy most of the agent dir to work — all cost, no isolation benefit
the user wanted.
- **Scope of the exception.** Only omp shares writable host state. claude's credential stays
read-only; nothing else bind-mounts a host state dir. The tradeoff (full host-state sharing; the
container's omp can write the host DB) is accepted deliberately for omp and documented here so it
is not "fixed" back to isolation — doing so silently re-breaks omp auth.
- **Version note.** Because the container's omp writes the host's shared `agent.db`, keep the image's
pinned omp (`OMP_VERSION` in `catalog/base/Dockerfile.harnessed-omp`) reasonably aligned with the
host's. An older container omp *reading* a newer DB has been fine in practice (it read the token
without trouble); the theoretical risk is an older omp *writing* an incompatible schema. We chose
not to add a launch-time version gate — it was noise for a risk that hasn't materialized; align the
pin if it ever does.
## 5. Composition unit: recipes (hand-authored, not dynamic)
A **recipe** is a hand-authored integration definition for **one** project (hindsight, gsd,
caveman, headroom, …). A **stack** is a harness + a chosen set of recipes. Nothing is resolved
at runtime; recipes are assembled ahead of time into committed artifacts.
A recipe can contribute to **two layers**:
- **MCP layer** → server entries merged into the stack's hatago config (and/or a shared service ref).
- **File-extension layer** → `skills`/`commands`/`agents`/`hooks`/`rules` in Claude-canonical form.
## 6. Image tier — `FROM` is for base lineage only
`FROM` gives **linear** inheritance; multiple `FROM`s = multi-stage build whose **last** stage is
the image, with `COPY --from=…` pulling artifacts from earlier stages. There is **no** "union two
images" operator. So systems are **not** combined via `FROM`; they are combined at runtime (§3).
Legitimate build-time images:
- `harnessed-base` — mise/node/python + common tooling → `FROM harnessed-base` → **`harnessed-claude`**, **`harnessed-omp`**, **`harnessed-opencode`**, **`harnessed-gemini`**, **`harnessed-antigravity`**, **`harnessed-codex`**.
- `hatago` — the hub + the *light* `npx`/`uvx` stdio MCP servers baked in.
- **Per heavy service** — `services/hindsight/Dockerfile`, `services/openbrain/Dockerfile`, each
standalone, independently versioned, reusable across stacks.
## 7. Assembly — Dockerfile recipe model + supply-chain gate
A **recipe** contributes a `Dockerfile` (no `FROM` line) that the assembler concatenates into the
derived stack Dockerfile. The recipe's `recipe.yaml` carries metadata only — MCP server declarations,
harness compatibility, and a smoke-check list. Build steps live in the Dockerfile, not in YAML fields.
### Recipe = Dockerfile body
Each recipe's `Dockerfile` declares `ARG HARNESS=<default-harness>` at the top. This lets the body
reference `${HARNESS}` for harness-specific installs (e.g. `pnpm dlx @gstack/install --host ${HARNESS}`).
The assembler:
1. Strips each recipe's `ARG HARNESS` declaration (and any `FROM` line — recipes must not have one).
2. Emits `FROM harnessed-${HARNESS}:latest` as the derived Dockerfile header.
3. Concatenates recipe bodies in declaration order under that header, annotated with recipe names as comments.
4. Writes the result to `profiles/<stack>/Dockerfile.harnessed-<stack>`.
Example emitted derived Dockerfile:
```dockerfile
# Generated by harnessed assemble — do not edit.
FROM harnessed-claude:latest
ARG HARNESS=claude
# ── recipe: time ──
# (time MCP server is baked into the hatago config — no RUN steps needed)
# ── recipe: gstack (pinned) ──
ARG GSTACK_REF=v1.4.0
RUN git clone --branch ${GSTACK_REF} --depth 1 https://github.com/garrytan/gstack.git \
~/.claude/skills/gstack && cd ~/.claude/skills/gstack && ./setup --host ${HARNESS}
recipe.yaml carries:
-
name,description— identity. -
harnesses:— the harnesses this recipe's installer supports. The assembler refuses to compose the recipe onto a stack whose harness is not listed (clean error, not a cryptic build failure). -
mcp.servers:— MCP server entries merged intohatago.config.jsonacross all recipes in the stack. -
expect:— a smoke-check subset of skills/tools the capability test (§18) asks the agent about. Not a completeness oracle;expect:confirms the install landed, not that nothing extra was added.
The prior assembly model — which resolved plugins, fanned skills/commands trees into the profile, and
committed them — is superseded. Skills are image-baked by recipe Dockerfiles; the profile carries
only assembler-generated config files (.mcp.json, settings.json).
Two non-negotiables on every recipe Dockerfile:
-
Pin every source. A floating ref (
--branch main, unversionedpnpm dlx @pkg) is a validation error — the assembler refuses before any build starts. Acceptable pins:--branch v1.4.0,pnpm dlx @pkg@1.2.3,ARG PKG_REF=abc123def(commit SHA). -
Scan what actually landed, in-image. The scan runs as the derived image's final RUN layer (
catalog/base/harnessed-scan, baked into the base; emitted byemit.write_derived_dockerfile), not as a post-build pass over an image archive. A host-side source scan is useless here — recipes vendor nothing; every dependency installs into the image — so the scan runs inside it over the trees that have no top-level lockfile a host could see:-
snyk (token-gated by a build secret, warn-skips without one): scans the mise-managed node
globals and each recipe's
node_modulesby synthesizing apackage.jsonfrom the on-disk packages and pointingsnyk testat it. TheSNYK_TOKENarrives via--mount=type=secret,id=snyk_token(never a build-arg → never baked into image history). -
osv-scanner (credential-free):
osv-scanner scan source --recursiveover the recipe trees — sees real lockfiles (bun.lock,package-lock.json) the synth manifest misses. - pip-audit (credential-free): the active Python env.
Advisory, never gating. Each scanner runs in JSON mode; a single compact severity summary is printed and a consolidated
~/.harnessed/scan-report.jsonis written (the launcher copies it toprofiles/<stack>/scan-report.jsonand echoes a one-line host summary). The scan always exits 0. The recipe model runs arbitrary upstream installers whose dependency trees essentially always carry open HIGH advisories at any moment (and node's own bundled npm carries CVEs the user can't fix) — so a hard build-gate would make the recipe system unusable. Visibility, not enforcement, is the contract; raw scanner output is suppressed unlessHARNESSED_SCAN_VERBOSE=1. -
snyk (token-gated by a build secret, warn-skips without one): scans the mise-managed node
globals and each recipe's
A nightly job (the systemd-timer pattern) re-scans installed harnessed-<stack> images online so
a CVE disclosed after build still surfaces.
For each stack build the assembler produces:
-
profiles/<stack>/Dockerfile.harnessed-<stack>— the concatenated derived Dockerfile. -
profiles/<stack>/hatago.config.json— MCP server config assembled fromrecipe.yaml mcp.serversentries across all recipes. -
profiles/<stack>/.mcp.jsonandprofiles/<stack>/settings.json— per-stack config files mounted surgically into the container at launch (§4b).
The host then runs podman build --build-arg HARNESS=<agent> -t harnessed-<stack>:latest -f profiles/<stack>/Dockerfile.harnessed-<stack> ..
Only snyk needs a credential; osv-scanner and pip-audit use public DBs. Same rule as Claude
auth: reference host creds, never bake or commit them. There is no harnessed auth command — the
token is resolved host-native and handed to the build as a secret, never typed interactively.
-
Present → use, silently. If
~/.config/harnessed/.env.schemadeclaresSNYK_TOKEN(e.g. anop(op://…)ref) andvarlockis installed,_build_derived_imagewraps the derivedpodman buildinvarlock run … -- podman build … --secret id=snyk_token,env=SNYK_TOKEN. The scan layer reads it from/run/secrets/snyk_token(--mount=type=secret), so it never enters image history. -
Missing → snyk warn-skips, not an interactive prompt. No schema / no varlock → a plain build
with no secret; the scan still runs
osv-scanner+pip-audit.harnessed buildmust stay non-interactive / reproducible (CI, the nightly timer), and a token must never land in a repo or image layer.
All JavaScript installs — global, per-recipe, and hatago's bundled servers — use pnpm, not
npm/npx. Rationale: https://pnpm.io/supply-chain-security. A managed pnpm config (shipped in
harnessed-base / lib/) enables:
-
minimumReleaseAge— quarantine newly published versions (cooldown) so a compromised release isn't installed the moment it lands. -
lifecycle scripts default-denied —
strictDepBuilds(live in the global config) makes pnpm exit non-zero on any unreviewed postinstall/build script. The curatedallowBuildsexcept-list is project-scoped (pnpm-workspace.yaml / config-dependencies): pnpm v11 rejects it from the global config, so it is deferred until a build-script package (e.g. esbuild) actually needs to run. - store integrity verification + content-addressed store.
minimumReleaseAge, strictDepBuilds, and store-integrity ship in the managed global
~/.config/pnpm/config.yaml (shipped from lib/pnpm/config.yaml) — not .npmrc, which
is auth/registry-only in v11. (allowBuilds is the one exception: it belongs in each
project's pnpm-workspace.yaml, not the global config — verified in the phase-3 checkpoint.)
npx <pkg> → pnpm dlx <pkg>; npm install → pnpm install. Recipe validation (part of
harnessed build) flags any raw npm/npx in a recipe Dockerfile and points at the pnpm
equivalent.
Claude Code format is the single source of truth for skills/commands/hooks/plugins. Other harnesses adapt out of it:
- claude — native; mount directly.
-
omp — consumes Claude-format hooks/skills at runtime via
claude-hooks-bridge(~/Programming/AI/omp-extensions/claude-hooks-bridge) +lib-pi-adapter.sh. No re-authoring. Theompbase recipe pulls these in. -
opencode — consumes the same Claude-canonical profile: it reads
.claude/skills/**/SKILL.mdand~/.claude/CLAUDE.mdnatively (no bridge, no re-authoring). MCP is wired via the image-baked~/.config/opencode/opencode.json, which declares one remote (Streamable-HTTP) MCP server pointing at the hatago hub — opencode ignores.mcp.json. Caveat:.claude/commandsand.claude/agentsare NOT consumed (skills + CLAUDE.md/AGENTS.md port directly). -
gemini — mounts the same
.claude/profile as claude/omp/opencode (HARNESS_CONFIG_DIR["gemini"] = ".claude") but does NOT natively consume Claude skills/commands (its native asset format differs). Capability wiring is MCP via the image-baked~/.gemini/settings.json, whosemcpServerspoints one remote (Streamable-HTTP) server at the hatago hub. -
antigravity (agy) — mounts the same
.claude/profile (HARNESS_CONFIG_DIR["antigravity"] = ".claude") but likewise does NOT natively consume Claude skills/commands. Capability wiring is MCP via the image-baked~/.gemini/config/mcp_config.json, whosemcpServerspoints one remote server (serverUrl) at the hatago hub. -
codex (OpenAI Codex CLI) — mounts the same
.claude/profile (HARNESS_CONFIG_DIR["codex"] = ".claude") but likewise does NOT natively consume Claude skills/commands (it readsAGENTS.md+ its own~/.codex/promptsformat). Capability wiring is MCP via the image-baked~/.codex/config.toml, whose[mcp_servers.hatago]entry points one remote (Streamable-HTTP) server at the hatago hub (url = "http://localhost:3535/mcp"— codex 0.139+ natively supports remote Streamable-HTTP MCP, no stdio bridge).
One harness per stack. A stack targets exactly one of claude, omp, opencode, gemini, antigravity, or codex, never two at once.
-
Default persistent,
--freshto wipe. Accumulation is the value of a memory system;--freshgives a clean-room comparison run (throwaway volume). -
Service volumes are service-scoped & harness-independent —
hindsight-data, notharnessed-data-<stack>. This is what letsclaude+hindsightandomp+hindsightshare one memory. -
Shared instance, concurrent. One long-lived
hindsightcontainer, owned by the service not any instance; postgres serves both instances at once. An instance starts it if absent; it outlives instances (harnessed svc up/down). The service publishes its port to0.0.0.0and peers reach it via the podman host gatewayhost.containers.internal:<port>(the primary reachability model); theharnessed-netbridge + DNS-by-name is theHARNESSED_NETopt-in for bridge-capable hosts (a rootless bridge is unsupported on most hosts — netavark "Operation not supported"). -
Harness-state —
projects/+history.jsonlpersist to the host by default (harnessed-scoped, path-mirrored for a stable slug;session_state: volumefor throwaway). Other ephemeral state (sessions/, caches) stays in a per-instance volume.
The publish + host-gateway model above depends on two operator-side controls that already ship in the repo. They are documented here as prerequisites, not implementation details:
-
Egress-firewall allow rule for
host.containers.internal. Rootless podman exposes the host gateway athost.containers.internal(169.254.1.2).lib/egress-firewall.sh:55-63computesPODMAN_GW=$(getent ahosts host.containers.internal …)and adds an iptables allow rule for it — distinct from the default-route gateway. Without this rule the proxy path is blocked (iptables is netns-wide, so it gates hatago too). -
FastMCP
allowed_hosts. A Streamable-HTTP service proxied overhost.containers.internalMUST add it toTransportSecuritySettings.allowed_hosts, or FastMCP's DNS-rebinding protection returns421 Misdirected Request. Canonical implementation:services/ping/server.py:19-25(commit6f6c1b3); see also the "Networking note" indocs/guides/service-authoring.md.
harnessed/
├── pyproject.toml # the Python project (name: harnessed)
├── src/harnessed/ # the application — ALL assembly + launch logic
│ ├── launcher.py # `harnessed` CLI (Typer): build / launch / test / new / svc / …
│ ├── cli.py # `harnessed-tools` (assemble/scan/test entrypoints)
│ ├── assemble.py emit.py # emit-only assembler: stack + recipes → a profile
│ ├── schema.py # typed models + catalog resolution (Agent/Recipe/Service/Stack)
│ ├── capability.py report.py # the capability test (the integration oracle)
│ ├── paths.py # host/container paths + catalog roots
│ ├── scan.py synclinks.py
├── tests/ # pytest (unit + podman-gated integration); tests/fixtures/
├── catalog/ # everything contributors author
│ ├── agents/<name>/agent.yaml # an AI harness (claude, omp) + its image/Dockerfile
│ ├── base/ # shared base + hatago Dockerfiles, pnpm policy, egress script
│ ├── recipes/<name>/ # recipe.yaml [+ skills/ commands/ Dockerfile]
│ ├── services/<name>/ # service.yaml + Dockerfile + server
│ └── stacks/<agent>_<recipe>…/stack.yaml
└── docs/
Relationship: recipes/ (inputs) + stacks/<name>/stack.yaml (composition) → assemble →
profiles/<name>/ (committed output, mounted) + hatago config + ensured images.
name: hindsight
description: Hindsight long-term memory (postgres + MCP)
# --- MCP layer ---
mcp:
servers:
- name: hindsight
service: hindsight # references services/hindsight → shared sidecar
url_env: HINDSIGHT_URL # optional env injected into the instance
# light server alternative (hatago runs it as a child, wraps stdio→HTTP):
# - name: fetch
# command: uvx
# args: ["mcp-server-fetch"]
# transport: stdio
# --- File-extension layer (Claude-canonical) ---
plugins: # vendored via vendor-plugin
- marketplace: hindsight
plugin: hindsight-memory
# or: { url: ..., sha: ..., subdir: ... }
skills: # standalone skill dirs shipped by this recipe
- path: skills/hindsight-docs
hooks:
event_dir: hooks # NN-name.sh handlers grouped under <Event>.d/
# Dependencies — uv for Python, pnpm for Node (never npm/npx — see §7). Usually AUTO-DETECTED
# from a vendored plugin's own files; declare here for standalone recipes / overrides.
deps:
python: pyproject.toml # requirements.txt → `uv pip install -r`
# pyproject.toml → `uv venv` + `uv pip install -e .`
node: package.json # → `pnpm install` (managed supply-chain config)omp base recipe:
name: omp
description: omp/pi base — consume Claude-format hooks/skills
extensions: # omp-native extensions installed into the instance
- package: npm:@ryan_nookpi/pi-extension-claude-hooks-bridgename: claude-openbrain-headroom-caveman
harness: claude # claude | omp | opencode | gemini | antigravity | codex (exactly one)
permissions: yolo # prompt | auto (default) | yolo — writes per-harness skip-permission
# config (Permissions.md) into the profile; safe in an isolated instance
recipes: [openbrain, headroom, caveman]
services: [openbrain] # shared services attached by reference
state:
persist: true # default; `--fresh` overrides at runtime
session_state: host # host (default — projects/history persist, inspectable) | volumeharnessed <stack> [path] # start/attach a stack against cwd (or path), then exec the harness
harnessed build <stack> # assemble recipes → profile + images (build-time)
harnessed install <stack> # write ~/.local/bin/<stack> launcher shim (see below)
harnessed uninstall <stack> # remove the launcher shim
harnessed --fresh <stack> # start with empty state volumes
harnessed new <stack> --harness claude --recipes a,b,c # scaffold a stack manifest
harnessed list # stacks + running instances
harnessed stop <stack>
harnessed rm <stack>
harnessed clean # remove built profiles from XDG data dir
harnessed svc up <service> # start a shared service (publishes its port; peers reach it via host.containers.internal, or by DNS name under HARNESSED_NET)
harnessed svc down <service>
harnessed svc list
Naming/identity (proposed):
- pod:
harnessed-<stack>-<projhash>— same stack runnable across projects without recreate (bind mounts are fixed at creation, so the project is part of identity). - shared services: global by name (
hindsight), reached via the host gatewayhost.containers.internal:<port>(or by DNS name over theHARNESSED_NETbridge on bridge-capable hosts).
harnessed install <stack> writes an executable ~/.local/bin/<stack> so you can launch an instance
by name from anywhere (mirrors how the harnessed CLI itself is put on PATH via uv tool install / pipx):
#!/usr/bin/env bash
# generated by `harnessed install claude-openbrain-headroom-caveman`
HARNESSED_PATH=/home/you/Programming/.../harnessed # abs path to the harnessed executable
HARNESSED_NAME=claude-openbrain-headroom-caveman # the stack to launch
exec "$HARNESSED_PATH" "$HARNESSED_NAME" "$@" # "$@" forwards an optional project pathThen claude-openbrain-headroom-caveman [path] from any directory starts that instance.
harnessed uninstall <stack> removes the shim.
Startup gaps (all uninitialized)
┌───────────────────────┬──────────────────────────────┬───────────────────────────────┐ │ Substrate │ Status │ To enable │ ├───────────────────────┼──────────────────────────────┼───────────────────────────────┤ │ beads DB │ ✗ no .beads, BEADS_DIR unset │ bd init │ ├───────────────────────┼──────────────────────────────┼───────────────────────────────┤ │ agent-carnet │ ✗ no .carnet/ anywhere │ created on first note write │ ├───────────────────────┼──────────────────────────────┼───────────────────────────────┤ │ caveman │ ✗ not set up │ — │ ├───────────────────────┼──────────────────────────────┼───────────────────────────────┤ │ rtk │ ✗ not set up │ — │ ├───────────────────────┼──────────────────────────────┼───────────────────────────────┤ │ codebase memory (cbm) │ ✗ list_projects → [] │ index_repository(repo_path=…) │ └───────────────────────┴──────────────────────────────┴───────────────────────────────┘
-
Minimal
.claude.jsonstub fields. Boot an instance and confirm no re-login/onboarding prompt. Candidate set:oauthAccount,userID,hasCompletedOnboarding(+ possiblyfirstStartTime,numStartups). [INFERENCE — verify empirically.] - Per-server MCP transport. Which servers already speak Streamable HTTP vs need hatago's stdio→HTTP wrapping (hindsight already runs as postgres+MCP, likely network-native).
-
Intra-stack collision policy. Confirm fail-fast (reuse
sync-plugin-links' conflict exit) is the desired behavior vs last-wins/namespacing when two recipes ship the same skill/command name. -
Harness config mount points. Exact target paths per harness (claude
~/.claude/...; omp config dir) for the profile mount. (Resolved, HRN-02) opencode mounts the same.claude/profile as claude/omp (HARNESS_CONFIG_DIR["opencode"] = ".claude"), plus a baked~/.config/opencode/opencode.jsonMCP config and a read-only~/.local/share/opencode/auth.json. -
(Resolved, HRN-03 — gemini) gemini mounts the same
.claude/profile (HARNESS_CONFIG_DIR["gemini"] = ".claude"); the gemini-cli is already installed and working inharnessed-base(v0.46.0, pure-JS, no broken postinstall), so theharnessed-geminiimage just bakes a global~/.gemini/settings.jsonwhosemcpServerspoints one remote (Streamable-HTTP) server athttp://localhost:3535/mcp. Auth: host~/.geminiOAuth creds (mounted) orGEMINI_API_KEY/GOOGLE_API_KEYenv. -
(Resolved, HRN-04 — antigravity) antigravity mounts the same
.claude/profile (HARNESS_CONFIG_DIR["antigravity"] = ".claude"); theagyCLI is installed via the official vendor curl installer (curl -fsSL https://antigravity.google/cli/install.sh | bash— a standalone Go binary in~/.local/bin). Theharnessed-antigravityimage bakes a~/.gemini/config/mcp_config.jsonwhosemcpServerspoints one remote server (serverUrl) athttp://localhost:3535/mcp. Auth:ANTIGRAVITY_API_KEYenv or one-time OAuth creds. -
(Resolved, HRN-05 — codex) codex mounts the same
.claude/profile (HARNESS_CONFIG_DIR["codex"] = ".claude"); codex-cli is already installed and working inharnessed-base(v0.139.0,npm:@openai/codexships platform binaries as optionalDependencies — no blocked postinstall), so theharnessed-codeximage just bakes a global~/.codex/config.tomlwhose[mcp_servers.hatago]entry points one remote server (url = "http://localhost:3535/mcp") at the hatago hub (codex 0.139+ natively supports remote Streamable-HTTP MCP — no stdio bridge). Auth: host~/.codex/auth.json(mounted ro) orOPENAI_API_KEYenv. - hatago placement. Confirmed: in the pod over HTTP (not stdio inside the harness container) to keep npx/uvx out of the harness container — re-verify once a real stack is built.
-
Editor/tool configs in isolated mode. §4a mounts
~/.config/<tool>(nvim, etc.) for all stacks as operational. Confirm that's wanted inisolatedinstances, or gate behind a flag if a truly empty environment is ever needed. -
Host-projects scope. Does
session_state: hostwrite the host's own~/.claude/projects/(full continuity with host claude) or a harnessed-owned dir (~/.harnessed/projects/) to keep instance sessions separate? (Recommendation: harnessed-owned.) -
Container home path.
/home/harnessed/<relpath>(vscontainer.sh's/container/$USER) for a legible, stable project slug — confirm it doesn't break the harness installs. -
pnpm rollout (resolved, phase 3). mise routes its
npm:backend through pnpm (npm.package_manager=pnpm, confirmed in the harnessed-base build).minimumReleaseAge=1440-
strictDepBuilds(default-deny) ship in the global config.allowBuildsis project-scoped (pnpm-workspace.yaml) — v11 rejects it globally — so the allowlist is deferred until a build-script package (esbuild) actually needs to run.
-
-
CLAUDE_CONFIG_DIRrelocation. Verify whether it relocates~/.claude.json(not just the.claude/dir). If yes, both modes can point Claude at a per-instance config dir instead of copy-on-start, fully decoupling container state from the host file. [INFERENCE — verify.]
Host dependencies: podman/docker + Python (via pipx or uvx). harnessed is distributed as a
Python package; the only additional host tool is podman.
-
Install:
pipx install harnessed(persistent, on PATH) oruvx harnessed(zero-install, runs the latest published version).pipxinstalls into an isolated venv; no host Python pollution. -
harnessedPython CLI (Typer). All launch/build/list/stop logic lives insrc/harnessed/launcher.pyas a Typer CLI.os.execvpis used for the interactive attach so the TTY is native with no tunneling. -
Assembly (build time).
harnessed build <stack>runs the assembler in-process: parse/validate YAML, emit.mcp.json+settings.json+hatago.config.jsoninto$XDG_DATA_HOME/harnessed/profiles/<stack>/, then drivespodman buildfor the derived image. All assembly logic lives in the Python package — no separate container image needed. -
Profile location. Profiles live in
$XDG_DATA_HOME/harnessed/profiles/<stack>/(defaults to~/.local/share/harnessed/profiles/<stack>/).harnessed cleanremoves them. - Podman is invoked directly on the host. No API socket, no DooD, no host-absolute-path footgun. The launcher builds mount args with host-absolute paths by construction.
Why no DooD. Separating "generate the build/run inputs" (the assembler, in-process Python) from
"execute podman build/podman run" (host podman subprocess) removes every cost of driving the
daemon from inside a container:
-
No API socket to mount, no
CONTAINER_HOST/DOCKER_HOST. podman is invoked directly on the host. -
No host-absolute-path footgun. The launcher runs on the host, so
$HOME/$PWD/project paths are host-native by construction — the classic DooD bind-path gotcha cannot occur. -
Clean TTY for free.
os.execvpon the host process gives native TTY with no tunneling.
Net install: pipx install harnessed (or uvx harnessed for zero-install); first harnessed build
assembles the profile and builds the container images. Podman/docker + pipx/uvx are the only host deps.
Runtime abstraction (provider-agnostic isolated mode). The shared-netns group is abstracted
by the host CLI (src/harnessed/launcher.py): podman → pod; docker → shared-netns pair
(--network container:<hatago>); Apple container not yet (tracked). Integration coverage is
provided by pytest: uv run pytest -q (fast, no containers) and
HARNESSED_PODMAN=1 uv run pytest tests/test_recipes_integration.py (live — builds + capability-tests
every stack in catalog/stacks/, auto-discovered). The live suite is the regression gate for
provider ports and cross-harness correctness.
varlock is optional — harnessed works fully without it. It's an opt-in secrets source: if you
use it, secrets resolve from 1Password instead of loose files. varlock reads a .env.schema
(@env-spec DSL) whose secret values are op(op://Vault/Item/field) references, validates them, and
injects the resolved values into a process (varlock run -- <cmd>, or varlock load --format env
to emit the dotenv, which is what harnessed uses — see below). Copying the shipped
.env.schema.example is what turns it on; users who skip it lose nothing else.
Schema locations (XDG):
-
~/.config/harnessed/.env.schema— harnessed-level secrets (e.g.SNYK_TOKEN,SOCKET_SECURITY_API_KEY). The repo ships.env.schema.exampleto copy here. -
~/.config/<service>/.env.schema— per-service secrets (e.g.~/.config/hindsight/.env.schema), loaded when that service starts (harnessed svc up <service>). - (optional, later) per-stack overrides referenced from
stack.yaml.
How harnessed uses it:
- On launch,
harnessedchecks for a relevant.env.schema. Present (opt-in) → resolution runsvarlock load --format envon the host; the resolved dotenv is written to a mode-0600 temp env-file that the launcher spreads into the container (--env-file, unlinked after launch). This reaches all launch paths — the isolated pod, per-service sidecars (~/.config/<service>/.env.schema), and the build scan. Absent (the default) → plain host env passthrough (the §7 scanner present/skip logic still applies); with no schema, varlock is never invoked. -
App-auth runs on the host, not in the container. 1Password's desktop app authorizes the
opCLI by calling application — the user's terminal. Anoprunning inside a throwaway container has no host app to bind the grant to, so app-auth (@initOp(allowAppAuth=true)) fails there ("cannot connect to 1Password app") regardless of which socket is mounted. The~/.1password/agent.sockmounted in §4a is the SSH agent (for git commit signing), not theopapp-auth transport. So varlock +oprun on the host; the resolved env is what crosses into the container. - The in-container path is the headless fallback, used only when the host has no
varlock(e.g. CI, the nightly timer): there the schema resolves inside a--rmtools container withOP_SERVICE_ACCOUNT_TOKEN(HTTPS bearer auth — no desktop app, no app-auth, no socket). It stays inert unless a schema exists. - Resolved secrets are injected as env only — never written to the repo, a committed profile, or an image layer (same rule as Claude auth).
Docs are a gated deliverable, not an afterthought — harnessed is a tool other people (and
future-you) must operate. Required surface, by audience:
-
README.md — what
harnessedis, install (pipx install/uvx harnessed), first-run build, a 60-second quickstart. - docs/harnessed-design.md (this file) — architecture + decisions; the source of truth for why.
-
Recipe authoring guide — writing
recipes/<name>/recipe.yaml: MCP servers, file extensions, deps, the Claude-canonical rule, the pnpm rule — with one worked example end to end. -
Stack guide — composing recipes into
stacks/<name>/stack.yaml;permissions/session_state;harnessed install. -
Secrets setup — varlock + 1Password (§16): copying
.env.schema.example,op(op://…)refs. -
Service authoring — adding a heavy sidecar under
services/(image +.env.schema). -
Troubleshooting / ops — podman socket, first-run build, auth/onboarding,
--fresh, inspecting host-persisted sessions.
Cadence: each section lands with the feature it documents (a feature isn't "done" until its docs exist), per this repo's existing AGENTS.md / README conventions. This design spec stays current as decisions change (as it has through this session).
Per the project's TDD philosophy (tdd skill): test behavior through the public interface, and
write tests that survive refactors. For harnessed the public interface is the running instance, and
the behavior is "the instance exposes exactly the MCP servers / skills / commands its stack declares."
- No assembler unit tests. Testing assembler internals couples to implementation and breaks on refactor — the anti-pattern the TDD skill warns against. The assembler is covered transitively: wire the wrong thing and the capability test fails.
-
The stack manifest is the test oracle. Expected capabilities (
recipes→ MCP servers + skills/tools;services) are derived fromstack.yaml+ its recipes; the test asserts the live instance matches. It reads like a spec: "gstack-timeexposes MCPtimeand skillsreview,qa."
Two-oracle capability test (per stack):
The capability test uses two complementary oracles that prove different things:
Oracle 1 — Structured MCP probe (deterministic). Hit hatago's hatago://servers resource — a
JSON snapshot of the connected child servers behind the hub — and/or claude mcp list. Assert that
every mcp.servers entry declared in the stack's recipes appears as connected. No model call; fast
and deterministic.
Oracle 2 — Un-primed agent probe (behavioral). Ask the harness, headless, what capabilities it
has — deliberately NOT priming it with the expected list. The prompt names the expect: skills/tools
from the recipe PLUS a decoy capability (a name that exists in neither the recipe nor the image).
The agent must report which it has and which it lacks, without the test telling it which are real.
podman exec <container> claude -p 'You have a set of skills and tools available. For EACH name below, answer "have" or "missing" — do not assume; check what is actually loaded.
office-hours, plan-ceo-review, review, qa, ship, browse, <decoy-not-installed>
Respond as JSON: {"have": [...], "missing": [...]}'Negative control (anti-sycophancy gate). The decoy MUST appear in missing. If the agent
claims the decoy is present, the test exits with status INVALID — distinct from a normal
capability-failure non-zero exit. INVALID means priming/sycophancy was detected: the model is
hallucinating agreement, not reporting what it actually loaded. An INVALID result causes the run to
fail regardless of how all other capabilities scored.
Why un-primed with a negative control? Naming expected capabilities in a "respond YES" prompt primes the model to confirm capabilities it never loaded. The decoy closes that hole: a model that actually checked its loaded skills gets the decoy right; a model hallucinating agreement claims it.
Assertion logic:
- Oracle 1: assert each
mcp.serversentry appears inhatago://servers— fail if not connected. - Oracle 2: assert decoy is in
missing→ INVALID if it is inhave; assert eachexpect:entry is inhave— fail any that appear inmissing.
Capability report — the test output is a user artifact. harnessed test <stack> writes
profiles/<stack>/capability-report.md after every run, rendering a per-capability table showing
✓/✗ for each MCP server and expect: entry, plus an INVALID banner when priming is detected:
## gstack-time — capability report
| capability | kind | status |
|-----------------|-------|-------------|
| time | mcp | ✓ connected |
| office-hours | skill | ✓ present |
| plan-ceo-review | skill | ✓ present |
| review | skill | ✓ present |
| qa | skill | ✓ present |
| ship | skill | ✓ present |
| browse | tool | ✓ present |
INVALID: agent claimed decoy capability '<decoy>' — sycophancy/priming detected.
harnessed build renders it with rich (already in the tools image; markdown → terminal); CI
consumes the same structured result as the assertion. One mechanism, two audiences — the user sees
how complete/healthy the build is, CI sees green/red.
Build harnessed itself in vertical slices (tracer bullets, not horizontal): the first slice is a minimal stack — one harness + one MCP server + one skill — with its capability test green end to end (assemble → run → assert). Then add recipes one at a time, each with its own red→green capability test. Never "write all recipes, then all tests."
Honest tradeoff: integration-only means an assembler bug surfaces as a capability failure, not a pinpointed unit failure — coarser to debug. Mitigate with clear assembler errors (e.g. pin-validation rejections with the offending line) so a failed build says what it couldn't wire.
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)