# Env & secrets Hack now uses repo-level env overlay files as the canonical project env model. The short version: - commit `.hack/hack.env.default.yaml` - optionally commit `.hack/hack.env..yaml` - use `.hack/hack.env.local.yaml` and `.hack/hack.env..local.yaml` for worktree-local overrides - keep `.hack.secret.key` out of git, or provide `HACK_ENV_SECRET_KEY` - let `hack up`, `hack run`, `hack restart`, `hack host exec`, `hack host shell`, and env-aware session flows inject values directly at runtime - use `hack env materialize` only when you explicitly need a compatibility `.hack/.env` ## Current model Canonical env state lives at the repo root: - `.hack/hack.env.default.yaml` - `.hack/hack.env..yaml` - `.hack/hack.env.local.yaml` - `.hack/hack.env..local.yaml` Legacy compatibility note: - if an older repo already tracks `.hack/hack.env.local.yaml` as the shared `--env local` overlay, Hack keeps reading that file as the shared overlay - in that compatibility case, worktree-local default overrides are written to `.hack/hack.env.default.local.yaml` so `--local` mutations do not overwrite tracked shared config Compatibility and local-only state still lives under `.hack/`: - `.hack/.env` - `.hack/.env.state.json` - `.hack/.internal/...` Encryption key material is local-only by default: - `.hack.secret.key` - or `HACK_ENV_SECRET_KEY` `hack init` now scaffolds `.hack/hack.env.default.yaml` by default. ## Ignore rules (committed `.hack/.gitignore`) Hack owns a committed `.hack/.gitignore` that keeps machine-local generated files out of git. Because the file is committed, fresh clones and linked git worktrees inherit the rules with zero setup. It covers (patterns relative to `.hack/`): - `.internal/` - `.branch/` - `.env` - `.env.state.json` - `hack.env.local.yaml` - `hack.env.*.local.yaml` - `.hack.env*.tmp` (staging files an interrupted env write can leave behind) - `tickets/` (retained only to contain retired machine-local ticket caches during upgrades) How it is maintained: - created by `hack init` for a new project, and refreshed when you write machine-local env overrides (`hack env set --local`, `hack env hydrate`) - in an existing project without it (for example one an earlier Hack version initialized), `hack doctor` reports the "hack gitignore" check as a warning, and `hack doctor --fix` creates the file, or restores its managed block, as its own step. Commit the result so fresh clones and worktrees get the rules. `hack init` does not repair an existing project unless you confirm overwriting its scaffold files. Hack never writes through a symlinked `.hack/.gitignore` or `.hack/` (including a `.hack/` that links outside the repository); doctor reports that case and leaves it alone. Doctor asks git about the project root (the directory that holds `.hack/`), not about `.hack/` itself. So when the project root is not a git checkout, the check reports "Skipped (not a git checkout)" even if `.hack/` links into another repository; Hack still refuses to write through that link. - a read-only `.hack/.gitignore` is never replaced, the same policy as for `info/exclude`. A file with no write permission bit (for example mode 0444) is read-only for every user, root included; otherwise Hack asks the operating system whether you may write it. Hack checks the mode bits first, so an access control list (ACL) that grants you write access to a 0444 file does not make it writable for Hack; add a write bit instead. When it lacks the managed block, `hack doctor` warns and names the fix (make it writable, then run `hack doctor --fix`), and `--fix` leaves it unchanged. `hack env set --local` and `hack env hydrate` still write the local layer, print the same warning on stderr (so `--json` output on stdout stays valid JSON), and leave the file unchanged. Interactive `hack init` that overwrites an existing scaffold prints the same warning and leaves the file unchanged. A read-only file that already has the block is fine. - runtime commands (`hack up`, `down`, `restart`, `run`, `exec`) never write into your working tree to set up ignores, so they leave `git status` unchanged. Before they write `.hack/.internal/` or `.hack/.branch/` files, they add the same patterns, anchored at the repository root, to a marked block in the repository's `info/exclude` (resolved with `git rev-parse --git-common-dir`, so one block covers every linked worktree). Env commands that write `.hack/.env` (`hack env set` without an env config, `hack env materialize`) do the same. That file is local to your clone and never committed. The update is best-effort: if git is unavailable, or `info/` or `info/exclude` is a symlink or not writable, Hack leaves it alone and the command still runs. "Not writable" follows the `.hack/.gitignore` rule above: an `info/exclude` with no write permission bit is left alone even for root or when an ACL grants write. - never commit `.hack/.internal/`: it holds machine-local generated state that runtime commands rewrite. If it is tracked, `hack doctor --fix` untracks it. - the entries live inside a marked, hack-managed block; anything you add outside the markers is preserved on every rewrite - the root `.gitignore` still gets a `.hack.secret.key` entry when the secret key is first generated (the key lives at the repo root, not under `.hack/`). Hack writes that entry before the key, so if the entry cannot be written, no key is created. Before a `--secret` set or hydrate uses an existing checkout-local key that git does not ignore, Hack adds the entry; if it cannot, or a later rule re-includes the key, the command is refused and nothing is encrypted with that key. The refusal names the cause: either the entry could not be written, or the file already lists the key and a later rule (such as `!.hack.secret.key`) re-includes it, which you need to remove - outside git there is nothing to commit, so an existing key does not need the entry and Hack does not recreate a deleted root `.gitignore` for it. Hack does not run when you later turn the directory into a repository with `git init`, so it cannot add the entry at that moment. If the entry is missing then, `hack doctor` reports `env-key-unignored` (see [Key strength and exposure](#secret-key-handling)), and the first `--secret` set or hydrate in the new repository adds the entry before it uses the key, or is refused - older repos that added `.hack/.internal/` to the root `.gitignore` keep working; the entries are equivalent and nothing is removed Leak detection and repair: - `hack doctor` runs a "generated files" check: it lists any of the paths above (including retired `.hack/tickets/` caches, plus `.hack.secret.key`) that are tracked in git, except a tracked `.hack/hack.env.local.yaml`, which is never flagged because older repos may intentionally track it as the shared `--env local` overlay (see the legacy compatibility note above) - a tracked `.hack.secret.key` is reported as an error — it is a committed secret; rotate it after untracking - `hack doctor --fix` untracks the offenders with `git rm --cached` (files stay on disk) and re-ensures `.hack/.gitignore`; commit the removal - `--fix` and `--migrate-env-config` cannot be combined with `--json`; run the repair and JSON audit separately so a mutating request is never silently ignored. run them separately from a JSON-mode invocation ## File format Each env file is YAML with: - `version: 1` - `environment` - `secretsprovider: project_key` - `values` `values` is split by scope: - `global`: values applied everywhere - ``: values applied only for that compose service - `host`: optional overrides applied to host execution (`hack host exec`, `hack host shell`, and lifecycle hooks/processes) Example: ```yaml version: 1 environment: default secretsprovider: project_key values: global: GLOBAL_FLAG: "true" API_BASE_URL: "https://api.example.com" api: PORT: "4000" SERVICE_TOKEN: secure: v1:... ``` Plaintext values are written as scalars. Secret values are written as `{ secure: ... }`. ## Merge rules Hack resolves env in this order: 1. load `.hack/hack.env.default.yaml` 2. if `--env=` is selected, load `.hack/hack.env..yaml` 3. in a linked git worktree, load the primary checkout's `.hack/hack.env.local.yaml` 4. if `--env=` is selected, load the primary checkout's `.hack/hack.env..local.yaml` 5. load this checkout's `.hack/hack.env.local.yaml` 6. if `--env=` is selected, load this checkout's `.hack/hack.env..local.yaml` 7. merge `values.global` 8. if a service scope is requested, merge `values.` on top Worktree-local overrides win over shared repo overlays. Service values override global values. Primary local overlays are read live, without copying or creating files. Checkout-local overrides always win over primary local overlays; `hack env set --local` writes only to the current checkout. Inheritance is disabled by `CI=1` or `CI=true`, `HACK_EXECUTION_MODE=slim` or `codex`, `HACK_WORKTREE_INHERIT_LOCAL=0` (also `false`, `off`, or `no`), or `"worktree": { "inherit_local": false }` in `.hack/hack.config.json`. Symlinked primary project directories are ignored; symlinked, non-regular, or unreadable primary local files are rejected, and an unreadable `.hack/hack.config.json` in a linked worktree is an error rather than a silent opt-in. Checkout paths containing newlines never inherit. A worktree whose only env input is an inherited overlay still uses the YAML commands. If every input of a materialized `.hack/.env` later disappears (including by opting out), `hack doctor` reports the output as obsolete. Host execution applies `host` values last. This includes `hack host exec`, `hack host shell`, and lifecycle hooks/processes, which all run on the host rather than in Compose. Projects can set a default overlay in `.hack/hack.config.json`: ```json { "env": { "defaultOverlay": "qa" } } ``` Use `--env=base` to bypass that default and read only `.hack/hack.env.default.yaml` (plus local overrides). `default` and `none` are aliases for `base`, in any case, on every command that accepts `--env`; an overlay file named after one of those spellings is never selected by name. Any other value must already be a canonical overlay name: lowercase letters and digits joined by single hyphens (`^[a-z0-9]+(-[a-z0-9]+)*$`, for example `qa` or `qa-2`), at most 128 characters long; surrounding whitespace is ignored. Hack never rewrites a requested name, so `QA`, `q_a`, `X=1` or a name longer than 128 characters is refused with a fixed `Invalid --env value.` message (`E_USAGE` under `--json`) that does not repeat the argument, before any project file is read or any child process starts. The limit keeps every file name Hack derives from the overlay name, such as the layer's lock, within the usual 255-byte file name limit. See the [command and delivery matrix](#command-and-delivery-matrix) for what each command does with the selection. ## Metadata-only planning API Repository integrations can call `resolveProjectEnvMetadata` from `src/lib/project-env-config.ts` to inspect modern env bindings without obtaining a key or decrypting values. Pass `projectRoot`, `projectDir`, and `serviceNames`; optional `envName` selects an overlay, omission uses the project default, and `null` bypasses that default. The result contains selection and file paths, declared and unknown scope names, and per-target key metadata (`scope` and `secret`) for Compose and host execution. It contains no plaintext values, ciphertext, merged config, or raw layers. It reads the same ordered files and uses the same scope projection as `resolveProjectEnvConfig`: later layers override earlier ones, null removes an earlier binding, and a later value can reintroduce it. Linked worktrees inherit primary local layers before their own local overrides, subject to the existing inheritance settings. If a service is named `host`, that scope remains a service scope rather than an override applied to every host target. A `null` result means no modern env configuration exists; it does not inspect or resolve legacy `.env` or secret-store values. Callers retain responsibility for their existing legacy fallback. Selected managed-env layer reads distinguish a missing path from a failed read or a dangling symlink. Only regular files (including readable symlinks to regular files inside the real project root) are read; invalid or unreadable layers throw a value-free error without YAML excerpts or a nested cause. Project configuration selection keeps its existing semantics. Metadata inspection never writes key files or materializes `.env`. Paths, variable names and scope names can still disclose private project details; keep this metadata private. It is not portable plan serialization or an atomic snapshot/admission check. It does not establish that encrypted values can be decrypted or that runtime injection will succeed. ## Runtime behavior Direct runtime injection is the default path. Hack reads the canonical YAML files and injects the resolved env directly into: - `hack up` - `hack run` - `hack restart` - lifecycle host processes - `hack host exec` - `hack host shell` - `hack session start --env ... --service ...` - `hack session exec --env ... --service ...` That means `.hack/.env` is no longer the primary runtime source of truth. On the Compose backend, Hack still generates `.hack/.internal/compose.env.override.yml` so each service gets its own environment. The file holds no values: each entry is a `${HACK_ENV__}` reference, and Hack passes the resolved values to `docker compose` through its process environment. Nothing Hack writes there contains a decrypted value, whether `up` succeeds, fails or is interrupted. The file is written with mode 0600, and Hack refuses to write through a symlink at that path. A value reaches the container literally, `$` included. Only the `docker compose` invocations that read the override (`up`, `run`, `exec`, `ps` and the dependency cache initialization) receive the `HACK_ENV_*` values. The other `docker` commands Hack runs around Compose, such as `docker inspect`, `docker logs` and `docker events` for dependency cache progress and reuse, get the same environment without them. Because the file holds only references, a `docker compose -f .hack/.internal/compose.env.override.yml ...` command run outside Hack (a script, an IDE integration or a test harness) gets a blank value for every managed variable, with only a per-variable warning from Compose. Use `hack run` or `hack exec`, or supply the same `HACK_ENV_*` variables to that command. A value that a service gets from the global scope uses one `HACK_ENV_*` reference shared by every such service. A key that a service scope sets keeps a reference of its own for that service. The Compose client environment therefore grows with the distinct values, not with services times keys: twenty services that share one 64 KiB global value add about 64 KiB, not 1.25 MiB. The choice follows which scope sets each key, so the override file still holds no value. One safety check does compare values: if a key that the resolved scopes call global has a value for a service that differs from the global value, that service keeps a reference of its own for the key, so the container still gets its own value. In that case the file shows that the service's value differs from the global value, but never the value itself or its length. With a normal env config the scopes and values come from the same resolution and always agree, so this check never changes the file. Compose also receives each global value under its own name for `${KEY}` interpolation in your Compose file, so a global value reaches the `docker compose` process twice. The operating system limits the arguments and environment of a new process (`ARG_MAX`): 1 MiB in total on macOS, and on Linux a quarter of the stack size limit (2 MiB with the default 8 MiB stack, at most 6 MiB), plus 128 KiB (`MAX_ARG_STRLEN`) for a single `NAME=value` string. Hack measures the environment the `docker compose` process would get (your shell environment plus the managed values) before it writes the override or starts Compose. It keeps 64 KiB for the Compose arguments. When the environment does not fit, `up`, `restart`, `run` and `exec` fail with `E_ENV_TOO_LARGE`. The message and the `--json` detail name the limit (`ARG_MAX` or `MAX_ARG_STRLEN`) and give byte counts only, never a name or a value. To fix it, make large values smaller or remove them (for example, mount a large credential as a file and pass its path), set a value once in the global scope instead of in each service scope, or unset large variables in the shell that runs `hack`. When you upgrade from an earlier version, read the two subsections at the end of this section: managed values that contain `$` now reach containers differently, and an override written by the earlier version can still hold values. `hack env materialize` is manual by design. Use it only when you need a compatibility file for an external tool that expects `.env` on disk. Hack writes `.hack/.env` and `.hack/.env.` (from `materialize`, legacy `hack env set`, and the daemon env route) with mode `0600` by atomic replace: a private temporary file in `.hack/` is written, fsynced and renamed over the target, so readers never see a partial file. An existing file with group or other access is tightened to `0600` even when its contents are unchanged. These write and update paths refuse, with a fixed message that names no file or value, an env file that is a symlink (including a dangling one) or not a regular file, or one whose `.hack` directory is a symlink. Replace the link with a regular file or directory to continue. Code paths that only read an env file do not apply this check and still follow a symlink. For host execution, `hack host exec`, `hack host shell`, and lifecycle hooks/processes use a host-local view. That means Hack prefers host-usable values when it can: - explicit `host` scope values override the normal `global` + `` merge - common container-only hostnames such as `host.docker.internal` and compose service hosts are rewritten to `127.0.0.1` Use `--target compose` if you explicitly want the container-oriented compose view instead. ### Upgrading: managed values that contain `$` Earlier versions wrote each value into the override literally, so Compose interpolated a `$` inside a managed value: `$VAR` and `${VAR}` became the host or Compose value of `VAR` (blank when unset), and `$$` became `$`. Now Compose substitutes each reference once and does not interpolate the value, so the container receives the value exactly as stored, `$` included. If a managed value contains `$`: - the container sees a different value after the upgrade, and the next `hack up` recreates that service's containers because their configuration changed; - a value that relied on interpolation (for example `${HOME}/data`) now reaches the container unexpanded; store the expanded value instead; - a value that escaped `$` as `$$` now reaches the container as `$$`; store a single `$`. Values without `$` reach containers unchanged, and their containers are not recreated. ### Overrides from earlier versions An override written by an earlier Hack version can still hold literal values. The next `hack up`, `hack restart`, `hack run` or `hack exec` replaces it or, when it renders no override, removes it. `hack down` removes it before its lifecycle hooks, the remote target or `docker compose down` run, so a failed hook, a remote target or a failed Compose down does not keep it. `hack doctor` reports such a file in its "compose env at rest" check (counts only, never values), and `hack doctor --fix` removes it. A legacy adoption (`hack config adopt`) pins the override's exact bytes and file identity for as long as the adoption exists, and adopted commands never rewrite it. So: - `hack config adopt`, `hack config adopt --stop` and `hack config adopt --dry-run` refuse a literal override before they prepare anything. Run `hack up` once so Hack rewrites it with references, then adopt. - A preparation that an earlier version made over a literal override is never published. If it has no interrupted stop or pending switch, doctor names `hack up` followed by `hack config adopt --stop`: there is no active adoption to roll back, and `hack up` starts the originals. For an interrupted stop, `hack config adopt --recover --stop` (which retries the stop, then publishes) and `hack config adopt --recover` (which completes a pending switch) refuse before any original file moves. The switch stays journaled as pending, and the error names `hack config adopt --recover --rollback` as the step that releases it. After that rollback, run `hack up` and adopt again. - An active adoption made by an earlier version over a literal override keeps that file. Hack never removes it while a prepared, active or pending adoption exists, because removal would make the adoption refuse to load. `hack doctor` reports it with this next step. To drop the values, stop the adopted containers (`hack down`), run `hack config adopt --rollback`, run `hack up` so Hack rewrites the file with references, then adopt again with `hack config adopt --stop`. A removal first moves the file aside, then checks what it moved. If a concurrent Hack command had just replaced it with a value-free file, that file is put back rather than deleted. Between the move and the put-back the path is briefly absent, so a Compose command started by that concurrent Hack command at that moment can fail to find it; run the command again. If Hack is killed between the move and the delete, the moved file (`.hack/.internal/.compose.env.override.yml..remove`) can hold values. `hack doctor` reports such files by count, and the next removal, `hack doctor --fix`, or `hack up`, `hack restart`, `hack run` or `hack exec` deletes each one once it is more than a minute old. A newer one may belong to a removal that is still running, so it is left alone. Every step that touches the override first checks, without following a symlink, that `.hack` and `.hack/.internal` are real directories and that the override is a regular file; a step that moves or deletes something checks the directories again first. If `.hack`, `.hack/.internal` or the override is a symlink or not a regular file or directory, a Compose command that renders the override refuses with a fixed message, and `hack doctor` reports a fixed refusal that names no value. Neither `hack doctor --fix`, a removal nor the sweep of leftover `.remove` files lists, reads, moves or deletes anything behind the link. Replace the link with a regular directory or file. ## Choosing the right execution surface - `hack host exec` / `hack host shell`: run on your host machine with Hack-resolved env injected. - `hack run ...`: run a one-off command in the compose network with an ephemeral service container. - `hack exec ...`: run inside an already-running container when you specifically need that container's live filesystem or process context. For host execution, `--scope` means "which env scope should Hack inject", not "where should this run." For example, `hack host exec --scope api -- bun db:migrate` still runs on the host. ## Command and delivery matrix Every command that accepts `--env` resolves it with the same rule: omitted selects the configured default overlay (`env.defaultOverlay`); `base`, `default` or `none` selects base layers only; any other value must be a canonical overlay name and selects that overlay. The table lists every such command; `tests/env-command-matrix.test.ts` fails when a command gains or loses `--env` without a matching row, or when a row's selectors drift from the CLI. Commands not listed here refuse `--env` as an option that is not valid for them (arguments after a standalone `--` belong to the child command). The **Selectors** column lists the env-related flags each command accepts. `--service` selects a Compose service scope, `--scope` selects the env scope injected into a host command, and `--target` on env and host commands chooses the `host` or `compose` env view. On `up`, `down` and `restart`, `--target` is execution routing (`auto|local|remote`), not an env view. | Command | Selectors | Resolution timing | Refresh path | Native and compose limits | | --- | --- | --- | --- | --- | | `hack up` | `--env`, `--target` | Once, when services are created; values go into Compose containers and lifecycle hooks/processes, and the selection is recorded for the running stack. | `hack restart` or `hack env apply --service `; editing env files does not change running containers. | Authored native projects receive the same selection before dispatch. | | `hack down` | `--env`, `--target` | At teardown, for lifecycle down hooks only; omitted reuses a named overlay recorded for the running stack, else the configured default. | None; nothing is delivered to containers. | The native runtime backend refuses `--env`. | | `hack restart` | `--env`, `--target` | When services are recreated; omitted reuses a named overlay recorded for the running stack, else the configured default, and an explicit `--env` (including `base`) wins. | This is the refresh path for the whole stack or the named services. | Authored native projects receive the same selection before dispatch. | | `hack run` | `--env` | Per invocation, for the new one-off container. | Each run resolves again. | The native runtime backend refuses `--env`; restart with the selection first. | | `hack exec` | `--env` | Checked at invocation against the env recorded for the running stack; the command runs with the env the container was created with. Compose exec and native-runtime exec (Compose-file and authored native projects) warn on stderr when the service's resolved env changed since it started. | `hack restart ` (or `hack restart --env `); native projects restart as a whole with `hack restart` | Compose refuses when the requested env differs from the env recorded for the running stack; the native runtime backend refuses `--env`. | | `hack env list` | `--env`, `--service` | Per invocation, read-only; secret values are masked unless `--show-secrets`. | Not needed. | Reads files; no runtime required. | | `hack env get` | `--env`, `--service` | Per invocation; prints exactly one plaintext value to stdout. | Not needed. | Reads files; refuses native project inputs. | | `hack env explain` | `--env`, `--service`, `--target` | Per invocation; reports source, precedence and delivery without the value. | Not needed. | Reads files; no runtime required. | | `hack env apply` | `--env`, `--service` | Runs `hack restart ` with the same `--env`, recreating one service. | This is the refresh path for one service. | `--service` is required. | | `hack env add` | `--env`, `--service` | Selects the file written (`hack.env..yaml`, or the base file for `base`/`default`/`none`; `--local` writes the local variant). | Running workloads keep old values until `hack env apply` or `hack restart`. | Mutates only env files. | | `hack env set` | `--env`, `--service` | Alias for `hack env add`, except that it refuses a dangling layer link. | Same as `hack env add`. | Mutates only env files. | | `hack env hydrate` | `--env`, `--service` | Selects the local file written, as for `hack env add --local`; imports every entry in one commit. | Same as `hack env add`. | Mutates only the machine-local env file; refuses a git-tracked one. | | `hack env unset` | `--env`, `--service` | Selects the file the key is removed from, as for `hack env add`. | Same as `hack env add`. | Mutates only env files. | | `hack env materialize` | `--env`, `--service` | Writes `.hack/.env` once, at invocation. | Rerun `hack env materialize`. | Compatibility output for external tools; runtime injection does not depend on it. | | `hack env exec` | `--env`, `--service`, `--target` | Per invocation, into one host command (host view by default). | Each invocation resolves again. | Runs on the host, not in a container. | | `hack env shell` | `--env`, `--service`, `--target` | When the shell starts. | Start a new shell. | Runs on the host, not in a container. | | `hack host exec` | `--env`, `--scope`, `--target` | Per invocation, into one host command (host view by default). | Each invocation resolves again. | Runs on the host, not in a container. | | `hack host shell` | `--env`, `--scope`, `--target` | When the shell starts. | Start a new shell. | Runs on the host, not in a container. | | `hack config validate` | `--env` | Per invocation; resolves the native selection without reading managed env documents. | Not needed. | Authored native projects only; cannot be combined with `--file`. | | `hack config plan` | `--env` | Per invocation; metadata only (names, scopes, secret flags), never decrypted. | Not needed. | Authored native projects only. | | `hack project target` | `--env`, `--service` | Per invocation, read-only; reports the overlay selection (explicit, project default or base) without reading env files or values. | Not needed. | Selection only: overlay existence is not checked; no runtime required. | | `hack project task list` | `--env` | Per invocation, read-only; selects the overlay that each recipe's reviewed env scope is compared with, without reading env files or values. | Not needed. | Selection only; lists recipes and runs nothing, so no runtime is required. | | `hack project task inspect` | `--env` | Per invocation, read-only; as for `hack project task list`, for one recipe. | Not needed. | Selection only; runs nothing, so no runtime is required. | | `hack session start` | `--env`, `--service` | When the workspace is created; the name gains `.env-` (`.env-base` for base spellings), and `--up` runs `hack up -d` with the same selection. | Start a new workspace or pass `--env` to `hack session exec`. | Injects into the workspace shell, not into containers. | | `hack session exec` | `--env`, `--service` | Per command; omitted reuses the selection encoded in the workspace name. | Each command resolves again. | Injects into the workspace command, not into containers. | ### Stale env warning on `hack exec` `hack up` and `hack restart` record, for each Compose service they create, a keyed digest of the env set delivered to it. The digest lives in Hack-managed runtime state (`.hack/.internal/runtime-state.json`) and holds no key name or value. `hack exec` resolves the same env set again and, when the digest differs, prints one fixed warning to stderr that names the service and suggests `hack restart `. The command still runs with the env the container was created with, and stdout stays the target command's output. The warning never prints a key or value. The check is advisory. Identical env, state written before this check existed, and a service whose last targeted restart failed produce no warning. `hack up `, `hack restart ` and `hack env apply --service ` refresh only the named services' digests. Projects on the native runtime backend run the same check. The foreground `hack up` or `hack restart` records value-free digests of the env selection it delivered once the run is ready, in `.hack/.internal/native-env-fingerprints/` (one file per instance, mode 0600). The record is bound to that run and removed when the foreground ends. `hack exec` resolves the same selection again: for a Compose-file (image) project, the env for the run's recorded env name; for an authored project, the managed values for the recorded `--env` and `--profile` selection. When they differ, it prints one fixed warning to stderr that names the service and suggests `hack restart`, since native restart is whole-project. An image-project exec receives freshly resolved values itself, so its warning says the running service still uses the env it started with. A restart drops the record once the previous run stops, so a failed restart produces no warning. A record from another run, a missing record, or any resolution failure produces no warning. An authored-project check is bounded: after five seconds without a result it is skipped and the command's output and exit code follow as usual. Ctrl-C during the check ends the command with exit code 130 without waiting for it. Two exec paths do not run this check yet: an authored `.hack/hack.project.json` project on the Compose backend (see [native configuration with Compose](https://github.com/hack-dance/hack/blob/4e51bc3f8119e154f49e040e606b6ed7f727d6d3/docs/reference/native-compose-commands.md)), and an existing Compose project switched with [`hack config adopt`](https://github.com/hack-dance/hack/blob/4e51bc3f8119e154f49e040e606b6ed7f727d6d3/docs/reference/native-compose-adoption.md), whose exec runs in the retained containers. MCP tools accept no env selector. They run the CLI without `--env`, so each row's omitted-`--env` behavior applies. An overlay name is a label for a set of env files. It implies no production authority and no network or instance isolation: a `prod` overlay still runs on the local stack, and two overlays share the same instance unless you choose a separate branch instance. ## Common commands Inspect resolved env: ```bash hack env list hack env list --env qa hack env list --env qa --service api hack env list --json hack env list --show-secrets hack env explain GITHUB_TOKEN --env runner --service installer --target compose ``` `hack env explain` never prints the value. It reports availability, secret classification, selected/effective scope, source file and precedence, and whether the value is delivered to host lifecycle code or Compose. Recreate just one service after an env change with: ```bash hack env apply --service api --env qa ``` `--show-secrets` prints secret values in plaintext instead of the masked default; use it deliberately. Read one value explicitly: ```bash hack env get SERVICE_TOKEN --service api hack env get SERVICE_TOKEN --service api --env qa hack env get API_BASE_URL --env default ``` `hack env get KEY_NAME` writes exactly the resolved UTF-8 value to stdout, with **no added newline**, quotes, label, or JSON envelope. Empty values succeed with zero output bytes. Multiline content and trailing newlines are preserved; shell command substitution may itself remove trailing newlines. Missing or deleted values exit nonzero with an error on stderr and empty stdout. Invalid arguments, malformed env files, and unavailable or denied keys/backends also fail without printing values, ciphertext, or raw backend errors. `--json` and `--show-secrets` are not accepted by `get`; help is available separately with `--help`. Omitting `--env` uses the configured default overlay. `--env default` (also `--env base` or `--env none`) selects base layers, including local overrides. An absent optional overlay keeps the existing base/local fallback behavior. `--service api` selects global plus service values using the same layer ordering as runtime injection; the default scope is `global`. Linked worktrees use the same primary-local inheritance and checkout-local precedence as other env flows. `get` returns that stored scope's value without host-address rewriting. This explicit command needs no running containers and adds no confirmation. Existing native keychain authorization still applies. Modern YAML secrets decrypt only the requested winning entry, so an unrelated missing key or corrupt encrypted value does not force disclosure or block a plaintext read. Legacy projects retain their configured backend and declared-key lookup; only the requested overlay/base key is fetched. The existing encrypted-file backend internally decrypts its sealed bundle. Empty legacy values are returned as present by `get`; legacy runtime injection keeps its existing empty-value fallback behavior. `get` does not migrate or materialize env files, write plaintext artifacts, or add secret values to diagnostics, MCP tools, or audit output. Treat its stdout as a secret when the requested value is sensitive; avoid redirecting it into logs or committed files. Use `hack env explain KEY_NAME` for inspection without disclosure. Add or update values: ```bash hack env add API_BASE_URL https://api.example.com hack env add --env qa API_BASE_URL https://qa.example.com hack env add API_BASE_URL https://api.example.com --local ``` `hack env set` still works as a compatibility alias for `hack env add`, but `add` is the primary UX now. The two differ on an in-project dangling layer link: `set` refuses it and `add` replaces it. ### Entering secret values Never pass a secret value as a command argument: arguments land in shell history and are visible to other local users and tools through process listings. Use one of these instead: ```bash # Pipe the value from a secret manager or file; it never appears in argv. | hack env add SERVICE_TOKEN --service api --secret --value-stdin hack env add SERVICE_TOKEN --service api --secret --value-stdin < token.txt # In an interactive terminal, omit the value to use the hidden prompt. hack env add SERVICE_TOKEN --service api --secret ``` `--value-stdin` works on `hack env add` and `hack env set`, with or without `--secret`. It reads all of stdin, up to 64 KiB, and strips exactly one trailing newline (`\n` or `\r\n`), so `printf '%s\n' "$value"` and `printf '%s' "$value"` store the same value; any further trailing newlines are kept. It refuses, with a fixed message that never repeats the input and without writing anything: - an interactive terminal on stdin (pipe the value, or drop `--value-stdin` for the hidden prompt) - a value argument alongside the flag, including `KEY=value` - a missing key argument - empty input, input over 64 KiB, invalid UTF-8, or a NUL byte - input that has not reached end of file within 30 seconds, for example a pipe whose writer never closes it, or an inherited stdin that nothing writes to. Close the pipe after writing the value, or redirect from a file. `HACK_ENV_VALUE_STDIN_TIMEOUT_MS` sets the deadline (1,000–600,000 ms). Calls to the OS keychain (macOS Keychain, or the Secret Service over the D-Bus session bus on Linux) are bounded too. With no desktop session, a locked keychain, or a session bus that never answers, the call fails after 60 seconds with a fixed message instead of waiting. The outcome of a timed-out write or delete is unknown, so check it once the keychain responds. For unattended hosts, use the `encrypted_file` secrets backend with its key file in place or `HACK_SECRETS_FILE_KEY` set; without either, that backend also reads its key from the keychain. `HACK_KEYCHAIN_TIMEOUT_MS` sets this deadline (1,000–600,000 ms). `--local` writes to the worktree-local override file (`.hack/hack.env.local.yaml` or `.hack/hack.env..local.yaml`) instead of the shared repo env file — use it for per-checkout values you don't want to commit. With no env layers yet, `hack env set --local` writes legacy `.hack/.env` (or `.hack/.env.`), including when a `hack.env.json` contract exists. Use `hack env add --local` to migrate first and then write the machine-local layer. Contract migration writes `plain_env` values from legacy dotenv files to the gitignored machine-local layer, never a tracked layer, and refuses a `plain_env` value that comes only from your shell. It reports each moved key and destination path, without values. It refuses a local destination already tracked by Git. Values declared as `keychain` migrate to tracked layers as ciphertext. Migration requires effective ignore protection before writing plaintext. It refuses an outdated read-only `.hack/.gitignore` or a local destination that Git does not ignore. Without `hack.env.json`, `set --local` still writes `.hack/.env`; if a contract is added later, migration keeps those values machine-local too. Repeating `hack env add` or `hack env set` with the value already stored is a no-op: when the existing secret decrypts to the same value under the current key, its ciphertext is kept, so once Hack has written the file it stays byte-identical and Git shows no change. A hand-edited file (comments, other formatting, or no `global: {}`) is normalized on the first repeat and stays byte-identical after that. A different value, a plain entry becoming secret, or an entry that does not decrypt under the current key gets new ciphertext with a fresh IV. Each command writes one key; a script that sets several keys is not atomic across them. To write many machine-local values in one commit, use `hack env hydrate` (below). Each env file write goes to a hidden `...tmp` file in the same directory and replaces the file by rename, so an interrupted write leaves the previous file in place. A write killed before the rename can leave that staging file behind. It holds the whole new layer, so `.hack/.gitignore` ignores `.hack.env*.tmp`, and you can delete it. Hack does not remove it on the next write, because another write may still be using it. The rename has these effects: - a symlinked YAML env layer is allowed only when its fully resolved path stays inside the project's real root. Relative links and link chains are resolved at each hop; a root behind a symlink works too. An allowed link keeps its link, the write lands at its target, and the staging file is created next to that target - outside targets (including dangling outside links), sibling-prefix escapes and link loops are refused with a closed, value-free error before reading values from stdin or writing decrypted or hydrated output. This applies to the base, tracked overlays and local layers, including init scaffold writes; mutation discovery checks all named YAML layers, including case-variant names on case-insensitive filesystems. `hack doctor --fix` reports an error for a `.hack` linked outside the project and leaves the link and outside files in place - `hack env set` and `hack env unset` refuse an in-project symlink whose target does not exist with `E_PATH_CONFLICT`, and strict planning and hydrate reads refuse it too. Replace or remove the link, then retry. `hack env add` replaces that link with a regular file - the writer rechecks both the layer and its resolved destination before staging and commit. These path checks narrow concurrent-swap races; they are not an atomic filesystem fence against a directory changed after the last check - a hard link is broken: the env file gets a new inode, and any other name for the old file keeps the previous contents - the directory must be writable, even when the env file itself is writable; if it is not (for example a `.hack` directory at mode `0555`), Hack refuses with a one-line error that names the env file and says its directory is not writable A rename does not need write access to the file it replaces, so Hack checks that access itself before it stages anything. It refuses to replace an existing env file that it cannot read, that is not a regular file, that is not writable, or whose owner and group it cannot keep (for example a root-owned file left by an earlier `sudo hack`). `hack env unset` refuses an env file it cannot read in the same way, rather than reporting that there is nothing to remove. The error is one line under every `HACK_LOGGER` backend (`HACK_DEBUG=1` adds the stack), names the file and the reason, never a value, and the file is left as it was. Fix the file's permissions or ownership and run the command again. A rewrite keeps the file's permission bits, owner and group. This includes a file you own whose group you are not a member of. Without root, the new file cannot be given that group, so Hack refuses rather than let the file fall back to your default group: keeping group-readable permission bits under a different group could let other accounts read it (on macOS the default group is usually `staff`, which every local account belongs to). Such a file usually comes from moving it (`mv`) out of a directory with another group, for example `/tmp` on macOS, where new files take the directory's group `wheel`, or from being removed from the file's group. Run `chgrp ` and run the command again. The other files these commands write fail the same way. These are `.hack/.gitignore` (`--local` and `hydrate`), `.hack.secret.key` (the first `--secret` value, including the key lock Hack takes in the git directory first), the repository root `.gitignore` entry for that key, and `.hack/.internal/` (the `hydrate` lock). If a write is denied, the filesystem is read-only, the disk or quota is full, a parent path is not a directory, or a directory is in the way of a file, Hack prints one line. That line names the file or directory Hack was writing, gives the reason and says what to fix. It never shows the raw system error, the hidden staging file name or the lock's staging file. An env file on a read-only filesystem is reported as a read-only filesystem, not as a file that is not writable. Each of these lines starts with a stable code, and `--json` puts the same code and message in the envelope: - `E_PERMISSION`: permission denied, a read-only filesystem, or an owner or group Hack cannot keep. A `.hack` directory you cannot search (for example mode `0644`) and one you cannot write (for example mode `0555`) both give this code. - `E_NO_SPACE`: the disk or the disk quota is full. - `E_PATH_CONFLICT`: a path is the wrong kind of entry, such as a file where Hack needs a directory, a directory where Hack needs a file (including a `.hack/.gitignore` that is a directory or a symlink), or a `.hack/.internal` that `hack env hydrate` finds to be a symlink (dangling or not). A file name or path that is too long for the filesystem is reported with this code too; use a shorter `--env` overlay name or move the project to a shorter path. - `E_CONFIG_INVALID`: `hack env hydrate` could not check its project inputs. A `.hack/.internal` that is a file gives this code with `Cannot inspect Hack project inputs. Check filesystem permissions and paths before retrying. Values omitted.`: Hack cannot tell whether the private state it keeps inside `.hack/.internal` exists, so it stops before hydrating instead of treating it as absent. Replace the file with a directory (or remove it), then retry. - `E_LOCKED`: the project env key lock (`/.hack.secret.key.lock`, taken by the first `--secret` value) stayed held for the whole 10-second wait. Nothing was written. Another hack command may be creating the key; retry when it finishes. If no other hack command is running, the lock is stale (for example its process ID now belongs to an unrelated process, which Hack never treats as abandoned): remove the lock file the message names, then retry. The first `--secret` value also works on a filesystem without hard links, such as ExFAT or FAT on a USB drive. Hack normally publishes the key file and its locks with a hard link, so they appear complete or not at all. Where that is not possible, it creates an empty placeholder exclusively and renames the complete file over it, which keeps the same rules: an existing file or symlink is never replaced or followed, and a reader never sees a partial file. If Hack is killed between those two steps, an empty `.hack.secret.key` or lock file is left behind. Hack refuses it rather than use or replace it; remove it and run the command again. ExFAT and FAT do not store Unix permissions, so on such a drive the key file is not limited to your user the way mode `0600` limits it elsewhere. If you omit `key`, `value`, or `--service`, `hack env add` prompts for them interactively. Pass `--no-interactive` (or set `HACK_NO_INTERACTIVE=1`) for scripted/agent usage: the command applies documented defaults or fails fast with `E_INTERACTIVE_REQUIRED` instead of blocking on a prompt. In scripts, supply secret values with `--value-stdin` rather than as arguments. `hack env unset` and `hack env set` accept `--local` and `--no-interactive` too. Remove a value: ```bash hack env unset API_BASE_URL hack env unset SERVICE_TOKEN --service api --env qa hack env unset API_BASE_URL --local ``` ### Concurrent writes to one env file Hack env commands that change an env file never silently lose another command's change. `hack env set`, `hack env add`, `hack env unset` and `hack env hydrate` each read the file, change it and replace it while holding that file's write lock, `.hack/.internal/.lock` (for example `.hack/.internal/hack.env.yaml.lock`). The same applies to the legacy `.hack/.env` files. So when several commands change different keys of the same file at once, every change is kept. A command that finds the lock held waits up to 10 seconds, then stops with a one-line message that names the file and its lock and says nothing was written; run it again. The lock is released when the command ends. If a command is killed while it holds the lock, the next command takes the lock over once it confirms that process has exited. Hack never removes a lock it cannot prove is abandoned. That includes a lock file that is not a complete Hack receipt, one owned by another user or writable by others, and one that names a process ID now used by an unrelated live process. In those cases every retry would wait and fail the same way, so the message also says what to do: if no `hack env` command is running, remove the lock file it names and retry. If a command was killed while it was taking over an abandoned lock, it leaves `.recovery` behind, and the message says the recovery guard is busy and names both paths; when no `hack env` command is running, remove both and retry. `hack env hydrate` reports the same message when it cannot get the env file's lock. `.hack/.internal/` is ignored by the managed `.hack/.gitignore`, so lock files never show in `git status`. If `.hack/.internal` is a symlink, dangling or not, these commands refuse with one `E_PATH_CONFLICT` line instead of creating the lock through it. If it is a regular file, they refuse with one `E_CONFIG_INVALID` line (`Cannot inspect Hack project inputs...`) and write nothing. If you cannot write the directory that holds the env file (for example a read-only checkout), no lock is taken: no command can replace the file there, so a command that changes nothing still succeeds and one that would change the file is refused as before. Programs that do not take this lock, such as an editor or Hack v4, are not kept out. For `hack.env*.yaml` files, `hack env set`, `add`, `unset` and `hydrate` also check that the file still has the contents they read just before replacing it. If it changed, the command writes nothing and stops with a retryable conflict ("The file changed while it was being replaced; nothing was written."), or, for `hydrate`, a `concurrent_write` refusal. That check narrows the window in which such a program's write can be overwritten, but cannot close it. ### Hydrate machine-local values in one step `hack env hydrate` imports many values into the machine-local layer at once, for example when a new checkout or machine pulls its secrets from a secret manager. Tracked defaults stay in `hack.env.default.yaml`; hydration writes only the gitignored local layer (`.hack/hack.env.local.yaml`, or `.hack/hack.env..local.yaml` with `--env`). ```bash # Pipe KEY=value lines from a secret manager; values never appear in argv. | hack env hydrate --secret # Or read a file, or a pipe such as <(secret-source). hack env hydrate --secret --file .secrets.env hack env hydrate --secret --file secrets.json --format json --env qa --service api ``` Input comes only from piped stdin or `--file`, never from arguments, and is at most 1 MiB. The default `env` format is strict, one entry per line: - blank lines and lines starting with `#` are skipped, and an `export ` prefix is allowed - `KEY=value`, where the key matches `^[A-Z_][A-Z0-9_]*$`; an unquoted value is the rest of the line with surrounding spaces removed, and `#` inside it is part of the value - `'...'` is literal; `"..."` accepts `\n`, `\r`, `\t`, `\"` and `\\`; a quoted value must close on the same line, followed only by spaces or a `# comment` - empty values, repeated keys and `${VAR}` expansion are not supported `--format json` takes one object whose values are non-empty strings. A key that appears twice is refused, as in the `env` format; it is not resolved by keeping the last value. One malformed entry refuses the whole input. The message gives only the line number, never the line, and nothing is written. `--secret` encrypts every imported value; without it the values are stored as plain entries in the local layer. A plain import refuses to replace a key that the local layer already stores as a secret, so repeat an import with the same flags it was first run with. `--service` picks one scope for all entries (default `global`). The output and `--json` result list the added, updated and unchanged keys, never values. Hydration is idempotent and all-or-nothing: - An entry that already holds its value is left alone: a secret keeps its ciphertext and a plain number keeps its YAML type. When nothing changes, the file is not rewritten, so comments and formatting survive and Git shows no change. - A changed value gets new ciphertext with a fresh IV. - Local entries that are not in the input, other scopes, and removal markers for other keys are kept. A key in the input replaces that key's local entry, including a value you set by hand with `--local`. - The whole new layer is written to a staging file and published by one rename, so an interrupted hydration leaves the previous layer, with every key at its old value. Hydration refuses, without changing the layer, when: - git tracks the target local layer - the project is in a git checkout but git cannot say whether it tracks the local layer, for example after a `safe.directory` refusal, with a damaged index, or with git not installed (outside any git checkout, hydration proceeds) - the local layer exists but is not a valid env file, for example invalid YAML, the wrong `version`, or a scope that is not a mapping; fix or remove it first - a secret already in the local layer does not decrypt under the project env key, or no key is available (`.hack.secret.key` is used before `HACK_ENV_SECRET_KEY`, as when values are read) - `--secret` is used, no `.hack.secret.key` exists, and `HACK_ENV_SECRET_KEY` is set (a new key file would replace that key for every layer) - `--secret` is not used and an imported key is stored as a secret in the target scope; re-run with `--secret`, or remove the key with `hack env unset --local` first - another `hack env hydrate` for the same project is running, or an interrupted one left a stale lock (see below) - the local layer changed between hydration's read and its rename, by a program that does not take the env file's write lock (for example an editor or Hack v4) - hydration waited 10 seconds for the env file's write lock without getting it Concurrent hydrations are serialized by `.hack/.internal/env-hydrate.lock`, which holds the owner's process ID. A hydration that is killed (for example with SIGKILL) cannot remove it; the next hydration then reports the stale lock and its path instead of taking it over. Remove the file once no hydration is running. `hack env set --local` does not take this lock, but hydration also holds the local file's write lock (see [Concurrent writes to one env file](#concurrent-writes-to-one-env-file)), so a concurrent `hack env set --local` waits for the hydration and both changes are kept. Materialize a compatibility `.hack/.env`: ```bash hack env materialize hack env materialize --env qa hack env materialize --env qa --service api ``` Run a host command with injected env: ```bash hack host exec -- bun db:migrate hack host exec --env qa --scope api -- bun db:migrate hack host exec --env qa --scope api --target compose -- bun test ``` Cancelling `hack host exec` or `hack env exec` with SIGINT or SIGTERM forwards the signal to the command. Commands run in an owned process group, so cancellation also stops their descendants, escalating to SIGKILL after two seconds if necessary. Hack returns 130 for SIGINT and 143 for SIGTERM. Interactive commands use a supervisor in their own foreground group on the same terminal, preserving stdin, separate output streams, and Ctrl-Z/foreground resume. Piped stdin also retains the controlling terminal, so commands can open `/dev/tty` for native authentication prompts. The supervisor holds group ownership until cancellation cleanup finishes. SIGKILL cannot be forwarded; supervisors must terminate the whole owned process tree when force-killing a wrapper. Commands have no implicit time limit, and normal completion preserves the command's exit status. When you want to inspect an injected value, avoid `hack host exec -- echo $VAR`. Your current shell expands `$VAR` before Hack starts the child process, so the command often sees an empty string. Use one of these instead: ```bash hack host exec -- printenv APPLE_TEAM_ID hack host exec -- sh -lc 'printf "%s\n" "$APPLE_TEAM_ID"' hack host exec --shell 'echo $APPLE_TEAM_ID' ``` Open a host shell with injected env: ```bash hack host shell hack host shell --env qa --scope api hack host shell --env qa --scope api --target compose ``` Example with an explicit host override: ```yaml version: 1 environment: default secretsprovider: project_key values: global: DATABASE_URL: secure: v1:... REDISHOST: redis host: REDISHOST: "127.0.0.1" ``` ## Sessions and env Session flows can now carry env selection too. Examples: ```bash hack session start my-project --env qa --service api --detach hack session exec my-project.env-qa.svc-api --env qa --service api "bun db:migrate" ``` When you pass `--env` or `--service`, Hack creates a stable scoped workspace name instead of reusing the plain default workspace. That prevents one shell from silently inheriting the wrong env selection. ## Secret key handling Secret values in the YAML files are encrypted with the project key. The [env and secrets threat model](https://github.com/hack-dance/hack/blob/4e51bc3f8119e154f49e040e606b6ed7f727d6d3/docs/security/env-secrets-threat-model.md) lists what this protects against and the gaps that are still open. Default behavior: - generate `.hack.secret.key` the first time you add a secret - add `.hack.secret.key` to `.gitignore` - decrypt secrets from `.hack.secret.key` on the local machine - linked git worktrees can reuse the primary checkout's `.hack.secret.key` when the worktree copy is missing Linked worktree behavior: - when a repo uses linked git worktrees, Hack prefers a shared key under the git common dir - sibling worktrees can decrypt the same committed secrets without manually copying gitignored files - if a checkout-local `.hack.secret.key` exists, it still wins for that checkout - `hack up` in a linked worktree auto-selects a branch instance (`worktree.auto_branch`); that selection determines which instance's resolved env is the one actually injected, so keep the key readable from every checkout that runs `hack up` Linked worktrees also read the primary checkout's eligible local YAML overrides (`hack.env.local.yaml`, or its legacy-compatible local-default path, and the selected `hack.env..local.yaml`). Resolution order is checkout tracked default and overlay, primary local default and overlay, then checkout local default and overlay. The resolved file list includes inherited paths. Primary tracked configuration, legacy `.env`, runtime state and unrelated ignored files are not inherited. Local values override inherited values. A YAML `null` entry explicitly removes a value supplied by earlier layers; deleting the entry instead reveals the earlier value again. `hack env unset KEY --local` in an inheriting linked worktree records that removal without modifying the primary file. A later `set --local` replaces it. Inheritance reads current files each time and never copies them into the worktree. Invalid inherited files and redirected file paths fail explicitly. Set `worktree.inherit_local: false` in the checkout's Hack config to disable local file inheritance. CI (`CI=1` or `CI=true`) and slim/Codex execution mode disable it automatically. This setting does not change the existing secret-key lookup order below. Missing primary local files are optional; retained encrypted values still require the selected checkout's valid decryption key. Full read order for decrypting secrets (first match wins): 1. checkout-local `.hack.secret.key` 2. shared key under the git common dir 3. the primary checkout's key, inherited automatically for a linked worktree that has neither of the above 4. `HACK_ENV_SECRET_KEY` Write-path guarantees in a linked worktree (first secret added from any checkout): - an existing checkout-local key is reused as-is - otherwise an existing shared key (git common dir) is reused - otherwise the primary checkout's key is adopted by copying it to the shared location, so sibling checkouts converge on one key - only then is a new key generated, and it is written to the shared location — never silently to the checkout - concurrent first use from several checkouts converges on one key: when no key exists, creation runs under a cooperative lock at `/.hack.secret.key.lock`, and a key file is published only when complete, never empty or partial (Hack v4 writers do not take the lock; on a filesystem without hard links, such as ExFAT, an empty placeholder is visible for a moment before the complete key replaces it) - if the shared location cannot be resolved (git unavailable) or written, Hack falls back to a checkout-local key and prints a loud divergence warning - `hack doctor` flags divergent `.hack.secret.key` contents across checkouts with the exact paths Key strength and exposure (`hack doctor` "env key" check): `hack doctor` and `hack doctor --json` audit the checkout-local key, the shared key under the git common dir and, in a linked worktree, the primary checkout's key. When no key file exists, the check classifies `HACK_ENV_SECRET_KEY` instead. The check is read-only. It never prints key bytes or absolute paths, and `--fix` does not act on it. Each finding names a project-relative subject and the exact command to run from the project root. `--json` adds an `envKey` object to the check with `status` and `findings` (`code`, `status`, `subject`, `remedy`). The native runtime backend's `hack doctor` runs the same check inside a project. Nested public artifact directories are found with a bounded walk from the project root: at most six levels deep, 4096 directories and 100,000 entries. It skips `node_modules` and `.git`, does not follow symlinked directories, and reports a path it cannot spell portably as `/.hack.secret.key`. | Code | Status | Meaning | | --- | --- | --- | | `env-key-ok` | ok | key material is a regular, private, 256-bit-class file | | `env-key-absent` | ok | no key file and no `HACK_ENV_SECRET_KEY` | | `env-key-weak-permissions` | warn | group or others have access; suggests `chmod 600` | | `env-key-wrong-owner` | warn | the file is not owned by the current user | | `env-key-wrong-length` | warn | shorter than the 43-character base64url form of a 256-bit key, or empty | | `env-key-low-entropy` | warn | long enough but repetitive (estimated under 128 bits) | | `env-key-unignored` | warn | the checkout-local key is not ignored by git, so `git status` offers it for commit (for example after `git init` in a directory whose root `.gitignore` entry was removed); suggests adding it to the root `.gitignore` | | `env-key-symlink` | error | the key path is a symlink; Hack refuses symlinked keys | | `env-key-not-regular` | error | the key path is a directory or another non-file entry | | `env-key-unreadable` | error | unreadable, oversized or not UTF-8 | | `env-key-tracked` | error | a file named `.hack.secret.key` is tracked by git at any depth | | `env-key-public-path` | error | a key copy sits directly in `public/`, `static/`, `dist/`, `build/`, `out/`, `www/`, `_site/`, `.next/` or `.output/`, at the project root or in a nested package such as `apps/web/public/` | Hack derives the AES-256-GCM key by hashing the key text with SHA-256, so key strength is the entropy of that text. Generated keys are 32 random bytes in base64url. For a weak, tracked or published key, rotation is required. Preserve a private, recoverable copy of the old key and encrypted inputs before changing either. Recover each selected secret with the old key, then re-add it with the new key using the hidden prompt or `--value-stdin`; never put secret values in arguments. Individual atomic writes do not make this sequence a crash-safe rotation transaction. See the [env security decision and rotation gates](https://github.com/hack-dance/hack/blob/4e51bc3f8119e154f49e040e606b6ed7f727d6d3/docs/security/env-secrets.md). CI and managed container fallback: - if `.hack.secret.key` is missing, Hack falls back to `HACK_ENV_SECRET_KEY` That makes this model usable in CI and portable container images without committing the key file. Published container paths: - slim managed-container base: `hackdance/hack:slim` — the supported CI / managed-container story - full remote runtime: `hackdance/hack:latest` — serves the remote/node surface, which is experimental and unsupported (hidden behind `hack help --all`); only relevant if you have explicitly opted into that surface In both cases, inject `HACK_ENV_SECRET_KEY` from the runtime or secret manager instead of copying `.hack.secret.key` into the image. ## Legacy repos and migration Older repos may still have: - `.hack/hack.env.json` - `.hack/.env` - `.hack/.env.` - configured secret backends via `hack env backend` Hack still detects and reads that legacy format, but the preferred migration path is: ```bash hack doctor --migrate-env-config ``` That migrates the repo into: - `.hack/hack.env.default.yaml` - `.hack/hack.env..yaml` - `.hack/hack.env.local.yaml` and `.hack/hack.env..local.yaml` for plain values - `.hack.secret.key` when needed Tracked layers receive secret-backed ciphertext, not legacy plaintext. The migration runs the same way on the Compose and native backends: it changes project files only, so native `hack doctor --migrate-env-config` needs no Docker and no native runtime. `hack.env.json` and the Compose file are shared, git-tracked configuration, so the migration never deletes or edits a git-tracked file without your consent. Interactively, doctor lists the git-tracked files that may change before it migrates, and asks before it removes `env_file` entries from a tracked Compose file and, separately, before it removes git-tracked legacy files such as `hack.env.json`. A file whose git status can't be read inside a git checkout counts as tracked. A run that can't prompt (no TTY, `HACK_NO_INTERACTIVE`, or `--no-interactive`) refuses with `E_INTERACTIVE_REQUIRED` before any doctor step writes, naming each tracked file it would delete or edit, unless you pass `--accept-tracked-env-changes`: ```bash hack doctor --migrate-env-config --no-interactive --accept-tracked-env-changes ``` The list is conservative; with a contract it can include `hack.config.json`, which the migration rewrites when it still holds legacy keys such as `defaultEnvConfig`, and a legacy dotenv file the migration ends up keeping. Commit the changes so teammates get them. Commands that need env values, such as `hack env exec`, also migrate a project that has `hack.env.json` and no env layers, without asking. That migration reports each moved key, never its value, and removes a legacy `.hack/.env` or `.hack/.env.` file only when every entry now lives in an env layer, Compose no longer names it as `env_file`, Compose doesn't interpolate a key from it that the env layers now set only for a service, and git doesn't track it, so no second plaintext copy stays behind. It keeps `hack.env.json`, never edits the Compose file and never deletes a git-tracked file; it names a kept file and says why. Run `hack doctor --migrate-env-config` for those changes. Like doctor, it rewrites legacy keys in `hack.config.json`. A repo that has no `.hack/hack.env.json` is migrated as well. Its values live only in `.hack/.env` and `.hack/.env.`, which are gitignored and may hold passwords or API keys, so the migration keeps them out of tracked files: each `.hack/.env` entry becomes a plaintext global value in the gitignored machine-local layer `hack.env.local.yaml`, and each overlay entry that differs from `.hack/.env` becomes a global value in `hack.env..local.yaml`. These machine-local layers are created as private (mode 0600) files and never replace an existing entry. The tracked `hack.env.default.yaml`, and `hack.env..yaml` for each migrated overlay, are written with no values, so the repo switches to env layers. Teammates do not receive these values through git; share the ones they need with `hack env set` (plain settings) or `hack env set --secret` (encrypted) after the migration. A repo with no legacy env file gets only an empty `hack.env.default.yaml`. Once this is committed, a teammate's own `.hack/.env` and overlays may stop reaching services, because their checkout now uses env layers. Hack compares those files with what the layers deliver. For any value the layers don't deliver, `hack up` warns and doctor reports it. For a value in `.hack/.env`, `hack env materialize` also refuses, so the file isn't replaced; it doesn't write overlay files, so an overlay value doesn't stop it. They run `hack doctor --migrate-env-config` to move those values to their own local layers, adding to a local layer that already exists and making it private (mode 0600). A value equal to what the layers deliver isn't copied, so later team changes still reach them. A key the local layer sets differently refuses (`E_PATH_CONFLICT`), and so does a local layer that can't be read as an env layer (`E_CONFIG_INVALID`), which is left as it is. Adding to a layer drops its YAML comments. Messages name valid keys, count malformed entries and name unreadable files, and never show a value. This comparison skips a `.hack/.env` with a valid materialization state. A migration that runs for another reason, such as an overlay value, also checks that file's text, as described below. With nothing else to migrate, doctor reports that the project already uses env layers without checking the text, and a later `hack env materialize` replaces the file. Tell teammates when you commit the migration. Migrated values are global, so a service added later receives them too, and so do host lifecycle hooks. A migrated value also takes precedence over the same variable exported in your shell, including for Compose interpolation. This migration refuses, with a one-line `E_*` error, writes nothing and keeps the legacy files, when: - a key does not match `^[A-Z_][A-Z0-9_]*$`, for example a lowercase name or a line that starts with `export ` (`E_CONFIG_INVALID`). The error counts the keys per file but does not show them, because a malformed line may hold a value. Rename or remove them; - a legacy file is named `.hack/.env.default`, `.hack/.env.local` or `.hack/.env..local`, whose layer would be the default or a machine-local layer rather than an overlay (`E_CONFIG_INVALID`). Rename or remove it; - a legacy `.hack/.env.` file has a name that is not an overlay name (lowercase letters, digits and single dashes), such as `.env.staging.old` or `.env.Prod` (`E_CONFIG_INVALID`). The error names such files. Rename or remove them; a renamed overlay is then selected by its new name (`--env `). Template and backup copies (`.env.example`, `.env.sample`, `.env.template`, `.env.dist`, `.env.bak`, `.env.backup`, `.env.old`, `.env.orig`) are not overlays: they are neither migrated nor offered for cleanup; - Compose `env_file` uses one of those template or backup copies (`E_CONFIG_INVALID`), so its values would be lost. Rename the file to an overlay name or move it out of `.hack`, and update `env_file` to match; - Compose `env_file` uses an overlay such as `.env.staging` that holds values of its own and is not `env.defaultOverlay` (`E_CONFIG_INVALID`). Compose loads that file on every launch, but after the migration a launch without `--env` would not select the overlay, so the service would lose those values. Set `env.defaultOverlay` to it, or remove the `env_file` entry and select the overlay with `--env`; - an entry named like an env layer (`.hack/hack.env.*.yaml`) is a dangling symlink, a directory or another non-file (`E_PATH_CONFLICT`). With a symlinked layer inside the project, or with nothing to move, `hack doctor` reports that the repo already uses env layers and migrates nothing, and it never writes through the link. It can still remove `.hack/.env*` entries from `env_file` in the Compose file, and a service then stops receiving a value only that file set, so move such values first with `hack env set --local`. A `.hack` or a layer linked outside the project is refused with a closed error, and the link and outside files stay as they are; - outside a git checkout, `hack.env.local.yaml` sets `environment: local`, so it serves as the `local` overlay and base values written there wouldn't reach the default env (`E_CONFIG_INVALID`). If it's meant to be your machine-local layer, set its `environment` to `default`, then retry; - git tracks a machine-local layer it would write, or, in a git checkout, git cannot report whether it does (`E_PATH_CONFLICT`); - in a git checkout, git does not ignore a machine-local layer it would write, for example because `.hack/.gitignore` is read-only and lacks Hack's managed block (`E_PATH_CONFLICT`). Make `.hack/.gitignore` writable, or ignore the path, and retry; - a migrated value would replace a service or host value in an env layer, in the base or in an overlay, including an overlay a linked worktree inherits from its primary checkout (`E_CONFIG_INVALID`). A machine-local global value outranks a service or host value in an earlier layer, so that target would silently switch. The error names the keys, targets and envs, never values. Remove the key from the legacy file, or change that value in its layer; - a key the legacy files set is also set in a Compose service's own `environment` (`E_CONFIG_INVALID`). Compose ranks `environment` above `env_file`, but migrated values become global layer values that Hack applies after `environment`, so the service would switch to the `.env` value. This includes an entry that only refers to its own key (`PORT: ${PORT}`), whose result can differ when an overlay or the shell sets the key. The error names the keys and services, never values. Remove the key from the legacy `.env` file or overlay that sets it, or from that service's `environment`, then retry; - a service does not list a legacy file with values to migrate in `env_file` (`E_CONFIG_INVALID`). Migrated values are global and reach every service, including third-party images, so that service would receive values it never had. This includes a file no service lists, whose values today reach only Compose interpolation. Listing the file for each service is how you accept that. Otherwise, move those values out of the file and set them with `hack env set --service` after migrating, or keep them in your shell. Values Compose interpolates need the shell or a listed file, because service values do not reach interpolation. The error names files, services and keys, never values; - Compose uses a top-level `include`, a service `extends`, a `!reset` or `!override` tag, a variable or a `~` in an `env_file` path, a file listed twice in one `env_file`, or a `format` on an `env_file` entry (`E_CONFIG_INVALID`), so the migration cannot tell which values services receive. Write those `env_file` entries directly in the Compose file, each file once, with a plain path and no `format`; - a key the legacy files set is also set in another file a Compose service uses as `env_file` (`E_CONFIG_INVALID`), including as `export KEY=value`, `KEY: value`, a bare `KEY` or after a closing quote (`A='x' KEY=value`), for the same reason: that file stays in Compose, but the migrated value would override it. The check does not look at the order of the entries, or expand variables in an `env_file` path. Remove the key from one of the files, then retry; - a legacy entry is one Compose reads differently from Hack (`E_CONFIG_INVALID`): an unquoted inline comment (`PORT=3000 # dev`), a `$` outside single quotes, a `\` in a quoted value (such as `\n` in a PEM key), text after a closing quote (`A='x' B=2`), or a line without `=` (`A: 1`, or a bare `A`). The value would change or be lost. The error counts entries per file and never shows them. Write each value on its own line, in single quotes, with real line breaks and no backslashes, or remove the line; - a service lists legacy files in an order that gives a key a different value from the migrated layers (`E_CONFIG_INVALID`), such as `env_file: [.env.staging, .env]` with `.env.staging` as the default overlay. Compose lets the last file win, but the default overlay always wins over `.hack/.env` in env layers. List `.env` first, or make the files agree; - the default overlay sets a key differently from `.hack/.env`, and Compose reads that key: its name appears anywhere in the Compose file or another `env_file` (`${KEY}`, a bare `build.args` entry, a secret's `environment:`), or it is a `COMPOSE_*` setting (`E_CONFIG_INVALID`). Compose takes it from `.hack/.env` today, but the overlay value would apply after the migration. Make the two files agree or stop using the key in Compose; - a migrated `.hack/.env` value would replace what an overlay's layers deliver for a key its legacy overlay file doesn't set (`E_CONFIG_INVALID`), because a local base outranks tracked and inherited overlay layers. Remove the key from `.hack/.env`, or set the value you want in that legacy overlay file, creating it if needed; the Compose `env_file` checks above then apply to that file; - env layers include a symlink while legacy values remain undelivered (`E_PATH_CONFLICT`). Replace linked layers with regular files containing the intended layer content, then retry `hack doctor --migrate-env-config`. The migration does not write through a linked layer; - `.hack/.env` has a state file, env layers exist, and its text isn't what `hack env materialize` would write (`E_PATH_CONFLICT`). The file is out of date or was edited by hand, and Hack can't tell which. If it's generated, move any value of your own with `hack env set --local`, then run `hack env materialize`, which replaces it. If it's your own file, delete `.hack/.env.state.json` so it's read as a legacy file, but not for generated output, whose values would then migrate as your own. When the text does match, the migration doesn't read the file or offer it for cleanup; - `.hack/.env` has a state entry that can't be read or parsed, such as a dangling link, and env layers exist (`E_PATH_CONFLICT`). If `.env` is your own file, delete `.hack/.env.state.json`. If it's generated output you don't need, delete both, then run `hack env materialize`; - a legacy file sets a key that Docker, its build tools or the process read from their environment (`E_CONFIG_INVALID`): `DOCKER_*`, `BUILDKIT_*`, `BUILDX_*`, a proxy setting, `SOURCE_DATE_EPOCH`, `HOME` or `PATH`. After the migration it would reach the Docker client too. Remove it from the legacy file, and set it in your shell or for one service with `hack env set --service --local` after migrating; - the Compose file is not a single YAML document this check can read, such as several `---` documents (`E_CONFIG_INVALID`). Merge it into one document; - `.hack/.env` or a legacy overlay is a symlink to a file outside `.hack` (`E_PATH_CONFLICT`), so its values would be copied from elsewhere into this project's layers. Replace the link with a regular file in `.hack`, or remove it; - the Compose file exists but cannot be read (`E_PERMISSION`), so the migration can't tell which values services receive. Make it a readable regular file, then retry; - an env layer can't be read or resolved (`E_CONFIG_INVALID`), for example it doesn't parse or its project env key is missing, including a layer for an overlay you don't use, so the migration can't tell which values the layers already deliver. The error names the file or gives the `hack env list` command that shows the problem. Fix it, then retry; - a legacy `.hack/.env` or overlay file exists but cannot be read, for example mode 000 or a dangling symlink (`E_PERMISSION`), so its values would be lost. A symlinked overlay that resolves to a readable file inside `.hack` is migrated like a regular one; cleanup removes only the link, never its target. A refused migration keeps the legacy source files. Earlier layer merges or ignore-rule updates may remain, and removing newly created layers after a failure can itself fail. Check the layers and ignore rules before retrying; migration compares the values again. With `hack.env.json`, the migration refuses (`E_CONFIG_INVALID`) when a `plain_env` value comes only from your shell, because it would otherwise be saved to a plaintext file you didn't ask for. This applies to commands that migrate implicitly, such as `hack env exec`. Simply skipping the value isn't enough, because env layers don't read the contract, so services would stop receiving it. The error names the key and never the value, and gives the commands for the scope the contract declares: 1. Run one command without the variable, so the migration runs: `env -u KEY hack env exec -- true`. Add a `-u` for each key it names. 2. Set it for the declared scope: `hack env set KEY --service --local --value-stdin`, or without `--service` for a key the contract doesn't scope. `--local` alone delivers it to every service, so keep `--service` for a scoped key. 3. If an overlay file also sets the key, the migration moves that value to the overlay's machine-local layer, which outranks the local base, so the overlay keeps it. To change it: `hack env set KEY --env --local --value-stdin`, with `--service` as above. The error prints a command only for a name the CLI accepts. It names an overlay file `--env` can't select, such as `.env.My_Staging` or `.env.default`, or a service `--service` can't. Fix those before step 1, because env layers don't read the contract once the project migrates. 4. Keep it exported if Compose interpolation, or a host command run without that service's scope, reads a key you set with `--service`, because a service value doesn't reach them. A value set without `--service` does reach them, and overrides your shell. The refusal comes before Hack provisions any secret-store key. If plaintext values are mirrored to a secret backend whose key isn't available, Hack can't tell whether a key your shell sets is also in the backend, so it refuses that key the same way. Make the backend's key available and retry. If the backend has nothing stored yet and `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK` isn't set, you can run these steps instead, and the migration creates its key; set the key only if the backend doesn't hold it. Any other backend failure, such as a corrupt store, stops the migration with that error. With `hack.env.json`, the migration also refuses (`E_CONFIG_INVALID`), writing no env layer, an overlay named `default`, `local` or `.local`, in any case, that sets a value of its own, because it would replace the base layer or write a machine-local file, and a contract service that isn't a valid env scope when a value would be written for it, because no read could parse that layer. A legacy `.env.default` or `.env.local` refuses even when it sets no value of its own, and so does a different-case spelling of either, through the destination check. A secret backend's key may already have been created by then. The migration also refuses, writing nothing itself, a `hack.env.json` it can't open or read as a contract, and it doesn't quote the file. Earlier `hack doctor` steps, such as creating `.hack/.gitignore`, can still have run. With `hack.env.json`, the migration discovers only regular `.hack/.env.` files, so a symlinked overlay is not migrated. It refuses (`E_PATH_CONFLICT`) when `.hack/.env` or a configured default overlay is a symlink, wherever it points, because another file's values, such as a machine-local `.env.local`, would otherwise be copied into this project's env layers. Without `hack.env.json`, nothing lists values kept in a secret backend, so none are migrated: set them again with `hack env set --secret` after the migration. In that case `.hack/hack.config.json`, and any secret backend it configures, is left unchanged. The migration changes project files only, so it runs even when Docker is not reachable; the other `--migrate-env-config` repairs still need Docker. As with any migration, `hack doctor` then offers to remove the legacy `.hack/.env*` files that Compose no longer references (a non-interactive run accepts for untracked files, and for git-tracked ones only with `--accept-tracked-env-changes`). Files Compose still names as `env_file` are offered only when the Compose repair removed those references. Without `hack.env.json`, a legacy file that git tracks, or one whose status git cannot report in a git checkout, is never offered, because the migrated values now live only in this machine's local layers. Doctor offers legacy dotenv files for cleanup only when every parsed entry was delivered to an env layer. Files containing undeclared or undelivered entries stay on disk; the cleanup report lists their key names and omits their values. This also applies to untracked overlays such as `.env.bak` and `.env.example`. Doctor also keeps `.hack/.env` when the contract scopes a key in it to services (`services: [...]`) and the Compose file or another `env_file` it lists names that key, for example in `${KEY}`. Compose reads `.hack/.env` for interpolation on its own, and service values don't reach interpolation, so removing the file would drop that value. To remove the file, set the key without `--service` or export it in your shell. Migration refuses a legacy `.env.default` overlay before writing env layers, because it would overwrite the base `hack.env.default.yaml` layer. Rename that legacy overlay and update its project references before retrying. It also refuses `.env.local`, whose tracked overlay path would collide with the machine-local base layer. Rename that legacy overlay before migration. All tracked and effective local destinations are checked for collisions before any writes, including compatibility paths. For example, `.env.staging` together with `.env.staging.local` is refused because both would write `hack.env.staging.local.yaml`. Collision checks include case-only aliases for checkouts with case-insensitive filenames. If a legacy dotenv file cannot be read, migration refuses cleanup and leaves legacy artifacts in place. Env layers may already have been written; check read access and preserve the remaining entries before cleaning up manually. `hack doctor` also warns when a repo still depends on the old format. Its `env mode` check reports an error, not the legacy format, when a `.hack/hack.env.*.yaml` entry is a dangling symlink or another non-file. For modern env repos, `hack doctor` also warns when materialized compatibility output in `.hack/.env` or `.hack/.env.state.json` is stale and points to `hack env materialize`. The legacy `.hack/.env` holds one value per key, with no per-service values. In a legacy project, `hack env set` and `hack env unset` change only that global value, so they refuse `--service ` with a usage error instead of ignoring it (an invalid scope is refused the same way in both formats). `--service global`, or no `--service`, still works. Migrate to env layers first with `hack doctor --migrate-env-config`, which also converts a project that has only `.hack/.env` (see above), then set or unset the service-scoped value. Values migrated from `.hack/.env` live in the machine-local layer, and a later layer wins over a more specific scope in an earlier one. So to override a migrated key for one service, add `--local`: `hack env set KEY --service api --local`. Without it the new value goes to the tracked default layer, where the migrated global value in `hack.env.local.yaml` still wins and the command has no effect on the runtime value. `hack env set` and `hack env unset` choose between the env layers and the legacy file by looking for `.hack/hack.env.*.yaml` entries, `hack.env.default.yaml` included. A regular file, or a symlink inside the project to one, counts as an env layer. An entry linked outside the project is refused with a closed error. Any other entry with a layer name, such as a dangling symlink or a directory, is refused with `E_PATH_CONFLICT`. Nothing is written either way, and neither silently selects the legacy `.hack/.env`. Replace or remove the entry, then retry. Reads refuse the same way: `hack env list`, `hack env explain` and runtime env injection stop with `E_PATH_CONFLICT` on a selected layer entry that is not a regular file (such as a dangling `hack.env.default.yaml`), and with a closed one-line error on one that cannot be read, instead of falling back to the legacy file. ## Compatibility notes The modern YAML env model is the canonical repo format today. Some older surfaces are still compatibility-oriented: - `hack env backend ...` remains relevant for legacy repos and older secret-store configurations. The `encrypted_file` store (also used by the `cloud` shim) is replaced atomically with mode `0600`, and set/delete hold an in-process queue plus a cooperative `.lock` so concurrent writers, including separate processes, do not lose updates. A store path that is a symlink is refused for writes. Explicit key provisioning also uses an atomic mode-`0600` replacement and refuses symlinked key paths or immediate parent directories. A key generated on first use is published only when no key file exists, so concurrent first users adopt the key published first instead of replacing it under a store already encrypted with it. This protects individual key writes; it does not make key replacement and store re-encryption a crash-safe rotation transaction. Keep the previous key until recovery has been verified. The v1 store envelope and legacy `v1:` values authenticate no associated data: they are not bound to a path, project or variable name beyond the scoped key inside the encrypted store plaintext - the daemon's env HTTP route (used by the macOS app) reads and writes the legacy dotenv-shaped contract (`source: 'plain_env'|'keychain'`, `resolvedFrom: 'dotenv'/'process'/'keychain'/'portable_backend'`), not the modern YAML `hack env list --json` shape; there is no web dashboard surface anymore If you are writing new docs or new project setup flows, document the YAML overlay model first and treat `.hack/hack.env.json` as legacy migration context. ## Recommended project policy - Commit `.hack/hack.env.default.yaml` - Commit `.hack/hack.env..yaml` when the overlay is team-shared - Never commit `.hack.secret.key` - Prefer `hack env add` over hand-editing encrypted values - Enter secrets with `--value-stdin` or the hidden prompt, never as command arguments - Prefer direct runtime injection over materializing `.hack/.env` - Use `hack host exec` or env-aware sessions for host-side scripts like migrations, generators, and admin tasks ## Related docs - [Initialize a project](/docs/v5/guides/init-project) - [Sessions](/docs/v5/sessions) - [CLI reference](/docs/v5/cli) - [Env and secrets threat model](https://github.com/hack-dance/hack/blob/4e51bc3f8119e154f49e040e606b6ed7f727d6d3/docs/security/env-secrets-threat-model.md): assets, trust boundaries, current mitigations and open gaps - [Env secret security decision and rotation gates](https://github.com/hack-dance/hack/blob/4e51bc3f8119e154f49e040e606b6ed7f727d6d3/docs/security/env-secrets.md) ### Host command lifetime and diagnostics `hack host exec` and `hack env exec` record payload-free execution metadata under `$HACK_HOME/host-commands` (normally `~/.hack/host-commands`). Records contain the executable basename, project, process identities, owned process group, intended lifetime, elapsed time, exit/cancellation/timeout outcome, and final child CPU/RSS accounting. Arguments, shell strings, environment values and command output are not recorded. Completed records expire after seven days during later executions; interrupted records remain available for review. A diagnostic storage failure warns on stderr and preserves command execution and exit status. ```sh hack host exec --timeout 60 -- bun scripts/check.ts # Lifecycle (startup hooks + host processes) Many projects need host-side setup around `hack up` (auth, local proxies, tunnels). Lifecycle config lets you run short-lived commands and manage long-running host processes alongside the project runtime. Lifecycle is configured in `.hack/hack.config.json` under `lifecycle`. You can also use a shorthand `startup` array for common `hack up` startup flows. For project-wide `up` and `restart`, Hack resolves hook environment and checks registry credentials before running `lifecycle.up.before`. After successful preparation, it renders internal host, branch, dependency-cache and runtime-metadata overrides for Compose. Extra-host registrations made by a before hook therefore apply on that same start; replacements and removals are reflected too. Explicit extra hosts also work when internal DNS and TLS are disabled. This does not reload arbitrary project config or replace the hook environment after a hook runs. A failed before hook prevents the Compose up phase. Service-scoped operations retain their existing behavior and do not run project-wide hooks. Hook code should use the supported integration that owns its host registrations rather than hand-editing Hack's generated internal files. ## Why `singleton` exists Some lifecycle-managed helpers bind fixed local ports and are easy to start outside Hack: - AWS SSM port-forwarding tunnels - local API or database proxies - search/dashboard tunnels Without an ownership hint, `hack up` can only assume it should launch the configured command again. If a matching helper is already running manually, or a previous reconnect loop already restored it, that can create duplicate supervisors, partial port conflicts, or aggressive cleanup/restart churn. `singleton` exists to make those fixed-port helpers explicit. It gives Hack a conservative rule: - start the helper when none of its expected listeners exist - adopt the existing helper when every expected port is already occupied and the config opts into reuse - fail fast on partial occupancy instead of trying to replace part of a live tunnel stack That keeps Hack local-first and predictable. It avoids turning "something equivalent is already running" into "kill and recreate it", which is especially important for brittle port-forwarding workflows. Adoption only checks that every configured port is occupied. It is not a health, identity, or readiness check: an unrelated service that happens to hold the same ports is adopted too, and an adopted tunnel may still be reconnecting. When the wrong listener would matter, pair `onConflict: "adopt"` with a `lifecycle.up.before` hook that verifies the listener's identity (for example, a request to a known endpoint) and fails when it does not match. The [shared host helper lifecycle contract](https://github.com/hack-dance/hack/blob/4e51bc3f8119e154f49e040e606b6ed7f727d6d3/docs/reference/shared-host-lifecycle.md) defines the identity-verified alternative: adoption only after the listener names the expected helper and upstream, exact PID-plus-start-time claims, serialized starters, and release that never signals a foreign process. Projects use it through [`lifecycle.sharedHelpers`](#shared-helpers); `singleton` is unchanged. ## Config ```json { "$schema": "https://schemas.hack/hack.config.schema.json", "lifecycle": { "up": { "before": [ { "name": "aws sso login", "command": "aws sso login", "cwd": "." }, { "name": "aws-ssm-proxy", "command": "bun run proxy", "cwd": "packages/infra", "persistent": true, "singleton": { "ports": [3306, 9200, 9201, 8443, 8444, 8445], "onConflict": "adopt" } } ], "after": [] }, "down": { "before": [], "after": [] }, "processes": [ { "name": "aws-proxy", "command": "bun run dev:aws-proxy", "cwd": "." } ] } } ``` ## Startup shorthand `startup` is a concise alias for startup hooks/processes: - `persistent: false` (default) maps to `lifecycle.up.before` - `persistent: true` maps to `lifecycle.processes` ```json { "$schema": "https://schemas.hack/hack.config.schema.json", "startup": [ { "name": "aws sso", "run": "aws sso login", "persistent": false }, { "name": "aws-ssm-proxy", "run": "cd packages/infra && bun run proxy", "persistent": true, "singleton": { "ports": [3306, 9200, 9201, 8443, 8444, 8445], "onConflict": "adopt" } } ] } ``` Each startup item can be: - a string command (one-shot startup hook), or - an object with: - `run` (or `command`) required - `name` optional - `cwd` optional - `persistent` optional boolean (default `false`) - `singleton` optional object with `ports` and optional `onConflict` `cwd` is always resolved from the repo root (not from `.hack/`). A `persistent: true` startup entry with no `name` gets a generated name (`startup-1`, `startup-2`, ... in config order). That generated name is what shows up as the window name and what you pass to `hack logs `, so naming persistent entries explicitly makes logs easier to find. ### Hooks Hook lists live under: - `lifecycle.up.before` - `lifecycle.up.after` - `lifecycle.down.before` - `lifecycle.down.after` Each entry can be either: - a string (shell command), or - an object: - `name` (optional): label for logs - `command` (required): shell command - `cwd` (optional): working directory; relative paths are resolved from repo root - `persistent` (optional): `true` means "start this as a managed lifecycle process" - `singleton` (optional): local listener policy with: - `ports` required array of local TCP listener ports - `onConflict` optional `"adopt"` or `"fail"` (default `"fail"`) Hooks run on the host as `sh -c `. Failures stop the operation. Commands inherit the CLI process environment (including PATH), plus resolved project env vars. `persistent` behavior: - In `lifecycle.up.before`, `persistent: true` starts the command in the lifecycle mux session and immediately continues to the next hook. - Ordering is preserved: each persistent hook is started in sequence before moving to later hooks. - In other hook lists (`up.after`, `down.before`, `down.after`), `persistent` is ignored and the hook runs as a normal blocking command. `singleton` behavior: - Hack checks the configured local TCP listener ports before starting the lifecycle process. - If none of the configured ports are in use, Hack starts the process normally. - If all configured ports already have listeners and `onConflict` is `"adopt"`, Hack skips startup, records an adoption note, and leaves the external process alone on `hack down`. - If only some configured ports are already occupied, Hack fails fast instead of launching a competing partial replacement. - If all configured ports already have listeners and `onConflict` is omitted or `"fail"`, Hack stops with an explicit error so the operator can decide whether to stop or reuse the existing process. Important boundaries: - `singleton` is a listener-level guard, not a health, identity, or readiness check. Adopt only checks that every configured port is occupied; pair it with a `lifecycle.up.before` identity check when a foreign listener on the same port would cause harm. - Adoption means "Hack observed the full expected local listener set and will not start another copy". It does not mean Hack now owns or can safely tear down that external process. - `hack down` only stops processes Hack actually launched inside the lifecycle session. Adopted external listeners are intentionally left alone. ### Processes Long-running processes live under `lifecycle.processes` and are objects with: - `name` (required): stable identifier (used for window naming) - `command` (required): shell command (run in a mux session shell) - `cwd` (optional): working directory (defaults to repo root) - `singleton` (optional): listener-ownership policy with `ports` and `onConflict` Processes receive the resolved env contract (see `env.md`) as their environment. ### Shared helpers `lifecycle.sharedHelpers` declares host helpers that several branches (or projects) share through one fixed loopback port, under the [shared host helper contract](https://github.com/hack-dance/hack/blob/4e51bc3f8119e154f49e040e606b6ed7f727d6d3/docs/reference/shared-host-lifecycle.md). Unlike `singleton`, a listener is adopted only after it proves its identity, and Hack stops a helper only when it started it and the last branch using it is released. ```json { "lifecycle": { "sharedHelpers": [ { "name": "issuer-bridge", "command": ["bun", "run", "bridge"], "cwd": "tools/issuer-bridge", "port": 4100, "upstreamEnv": "ISSUER_UPSTREAM", "env": ["ISSUER_TOKEN"] } ] } } ``` - `name` (required): helper kind. The helper must answer `GET http://127.0.0.1:` with JSON `{"helper": "", "upstream": ""}` (an optional `pid` must be its own). - `command` (required): argv array, executed directly, never through a shell string. - `port` (required): the loopback port. `name` and `port` must be unique in the list. - `upstream` or `upstreamEnv` (exactly one): the upstream this instance needs, literally or from a variable in the resolved lifecycle env (then the CLI environment). Use `upstreamEnv` with an env overlay when branches need different upstreams. - `identityPath` (default `/__hack/identity`), `cwd` (repo-root relative), `enabled` (default `true`), `readyTimeoutMs` (default 30000, at most 600000). - `env`: variable names passed to the helper. It also gets `PATH`; nothing else from the CLI environment is inherited. A listed name that is not set fails `hack up`. An invalid `sharedHelpers` entry is refused, not skipped: `hack up`, `hack down` and `hack restart` fail with `E_LIFECYCLE_FAILED` before any hook, helper, lifecycle process or Compose command runs. This is stricter than other config parse errors, which these commands only warn about. Fix the entry and run the command again. Behavior: - `hack up` and `hack restart` ensure each helper after `lifecycle.up.before` hooks and before `lifecycle.processes`. Each instance (`` or `--`) is one holder. A healthy helper is reused; an identity-matching helper someone else started is adopted; otherwise Hack starts it and waits until its identity endpoint answers. - A port held by anything else (another helper, the wrong upstream, a silent listener) fails `hack up` with `E_LIFECYCLE_FAILED` and a closed reason; that process is left running. Messages never include upstreams, argv or environment values. - If `hack up` fails later, or receives SIGINT/SIGTERM at any point while helpers or lifecycle processes start, Hack releases only the holders that run added and stops a helper it had just started. Overlapping `hack up` runs of the same instance are ordered by an instance lock; an earlier run's rollback never releases a holder after a later run of that instance has started (the holder stays recorded until `hack down`). Holders of containers that may still be running are kept instead (see below). - A disabled helper (`enabled: false`) needs none of its `env` or `upstreamEnv` values. - A project with `sharedHelpers` and a lifecycle session (lifecycle processes or a persistent `up.before` hook) needs tmux for that session. With zellij selected, `hack up` and `hack restart` fail with `E_LIFECYCLE_FAILED` before any hook, helper or process runs: zellij records the session's ownership after creating it, so an interrupted start could leave a session `hack down` cannot reclaim. Install tmux or set `sessions.mux` to `tmux`. If the selection changes to zellij while hooks run, the start fails when it chooses the session's backend, before creating the session, and rolls back the helper holders it added. zellij support is tracked in HACK-1849. - `hack down` releases the instance's holder. A helper still used by another branch keeps running; the last release stops a helper Hack started and never signals an adopted one. Down releases every helper the instance acquired, recorded at start, so removing or renaming an entry (or switching to a revision without it) does not strand its holder. - A release that fails or cannot confirm the stop keeps the claim and fails `hack down` and `hack restart` with `E_LIFECYCLE_FAILED` (non-zero exit, the helper names in `detail`). Fix the claim and run `hack down` again. - Holders are released only after the instance's containers and lifecycle session stopped. `hack restart` keeps the containers running, so it keeps their holders and reuses the helpers. Once a detached `hack up` or `hack restart` started the containers, a later failure (such as a failing `up.after` hook) stops the lifecycle processes but keeps the holders for `hack down`. If Compose fails or times out while starting containers (including a failed restart repair), the holders are kept unless `docker compose ps` confirms that no container of the instance may still run. SIGINT/SIGTERM once a container start has begun keeps the holders too (the start may still be starting services). If `docker compose down` fails, times out or is interrupted, the holders are kept (with a warning) and the command fails with Compose's error; run `hack down` again. A successful `docker compose down` is not enough on its own: it uses the current Compose file without `--remove-orphans`, so a container of a service the file no longer declares (an orphan) can keep running while Compose exits 0. Before releasing the holders, Hack therefore lists every container labelled with the instance's Compose project (`com.docker.compose.project=`, orphans included). It releases them only when none is running, restarting, paused or in another state that may run (created, exited and dead containers do not count). If one may still run, or the listing fails or times out, the holders are kept, the warning names the containers (or the failed check), and the down fails with `E_LIFECYCLE_FAILED`. Stop the remaining containers (for example with `docker compose -p down --remove-orphans`), then run `hack down` again to release the holders. If the session stop fails or cannot be confirmed (including when tmux cannot say whether the session exists), the session's processes may still use the helpers: `hack down`, `hack restart` and native retirement keep every holder, warn, and fail the same way. Run `hack down` again once the session can be stopped. Likewise, an up whose failed start kept its lifecycle session (its stop could not be confirmed) keeps the holders it added. - A `hack down` that overlaps a `hack up` of the same instance (same branch): - `hack down` marks the instance as stopping once the down is committed: after its `lifecycle.down.before` hooks succeed (and, on the native path, after the ownership and finalization rechecks that follow them), right before Compose or graph shutdown. A down refused before that point supersedes nothing. Its release removes the instance's operation record. Both advance the instance's shutdown generation. - A down supersedes every `hack up` of the instance whose lifecycle preparation began before it, even after a later `hack up` succeeds. Preparation begins just before the `lifecycle.up.before` hooks run, so a down that completes while those hooks run also supersedes that up: it fails with `E_LIFECYCLE_FAILED` before starting any helper. The later up's helpers need not be the earlier up's (an entry may have been disabled or changed), so the earlier up never counts as current again. - **Prevented during preparation.** A `hack up` checks the instance right before its containers start (after detached image preparation, before an attached `up`, and at the end of native preparation). If a down marked or released it by then, that up fails with `E_LIFECYCLE_FAILED` and starts no container. - **Detected after launch, not prevented.** The check takes the instance lock only briefly, so a down can still complete between the check and container start, or while containers start. The up checks again after Compose returns: a detached up then fails with `E_LIFECYCLE_FAILED` (its containers may already run); an attached up only checks after Compose exits. In that case run `hack down` for the branch (`--branch `) to stop the containers, then `hack up` again. - Two overlapping `hack up` runs of the same instance, with no down in between: - The newer up does not supersede the earlier one: an earlier, slower up still starts, because the newer up holds the same helpers. - The up that created the lifecycle session stops it on failure (or SIGINT/SIGTERM) only if no other up adopted it since. Hack records each session adoption and creation under the shared helper instance lock, so this holds whichever up ensured its helpers first. An up that only adopted the session never stops it. - An up whose lifecycle session another up replaced while it was still starting (its entry was still pending) fails with an error saying so. It does not publish over the replacement, start processes or set environment in it, or stop it; the replacement stays owned and `hack down` can stop it. - These guarantees hold for tmux commands that finish within their time limit (15 s). Hack stops waiting for a command that does not, and treats its outcome as unknown, but a stalled tmux server can still apply it later. A late kill or window command from an earlier up can then stop or change a newer up's session of the same branch; `hack down` and `hack up` recover it. A late session creation leaves an empty session that the next `hack up` refuses; stop it with tmux. Closing this gap is tracked in HACK-1849. - A failed up releases only the helper holders it added, and only while it is the latest up of the instance to have its helpers ready and no `hack down` has started since (including one that was interrupted); otherwise the next `hack down` releases them. - The failed up's other failure handling is unchanged (for example, it still clears the instance's runtime-state entry), so check `hack ps --branch ` after such an overlap. - This applies to projects with `sharedHelpers`. If the shared helper instance lock stays busy for 10 s (for example, a third up is still waiting for a slow helper), ownership cannot be decided: the failed up keeps the lifecycle session and its helper holders, warns, and leaves them for `hack down` to stop. - Claims live under `~/.hack/shared-helpers/-/` (or `$HACK_HOME`), shared by every project and branch that uses that helper name and port. - Helpers are not part of the lifecycle mux session and do not appear in `hack logs`. ## Visibility Lifecycle output is now surfaced across CLI/runtime views: - `hack logs` includes lifecycle hook/process output alongside compose logs. - `hack logs ` supports lifecycle service names, including persistent process names. - `hack projects --details` includes a `Startup & lifecycle` section with hooks + persistent processes. - Runtime inventories include persistent lifecycle processes as synthetic services. - The macOS project detail view includes a `Startup & Lifecycle` section in the main project overview. ## Runtime behavior ### `hack up` 1. Resolve env contract (and optionally prompt for missing required env in interactive shells). 2. Run `lifecycle.up.before` hooks in order. 3. For `up.before` hooks with `persistent: true`, start each as a managed lifecycle process and continue immediately. 4. Start lifecycle processes (if any) inside the same dedicated lifecycle session. 5. Run `docker compose up` (or `up -d` when `--detach`). 6. Run `lifecycle.up.after` hooks. Before starting persistent work, Hack reconciles the expected lifecycle session with `.hack/.internal/lifecycle/state.json`: - A session whose mux ownership token, saved definition hash, and live process windows all match is adopted without starting duplicate processes. - A token-owned stale session is replaced. Pre-ownership tmux sessions are replaced only when saved state, checkout path, creation time, and window count provide deterministic legacy ownership proof. - A same-name session without matching ownership proof is left untouched and `hack up` fails with an actionable collision error. If the operation later fails in Compose or `up.after`, Hack removes only the lifecycle session created by that operation. The same exact-token cleanup runs for supported `SIGINT` and `SIGTERM` paths, including signals received while the mux session is still being initialized. ### `hack down` 1. Run `lifecycle.down.before` hooks. 2. Run `docker compose down`. 3. Stop lifecycle processes by killing the lifecycle session. 4. Run `lifecycle.down.after` hooks. If `lifecycle.down.before` fails, shutdown is aborted before Compose or lifecycle processes are stopped. `hack restart` preserves the same guard semantics during its down phase. If a concurrent foreground finalizer removes the same owned lifecycle session during shutdown, Hack accepts a fresh, explicit absence check. Missing ownership metadata by itself, a changed token, or an unavailable session check still refuses cleanup. ### `hack restart` `hack restart` runs the down lifecycle hooks and stops owned host processes, but preserves the current Compose runtime until preflight succeeds. It then force-recreates services and attempts a repair start if recreation fails. This avoids destroying a healthy stack before env and registry checks have passed. `hack restart ` is service-scoped: it skips project-wide lifecycle hooks, initializes declared shared dependency caches before replacing their selected consumers, uses `--no-deps`, and verifies only the selected services. Failed cache initialization leaves the existing consumers running. From the primary checkout, it targets only the base Compose/lifecycle instance. A linked worktree uses its isolated derived branch instance, and `--branch ` targets only that explicit branch. ### `--json` `hack up`, `hack down`, and `hack restart` accept `--json`. A lifecycle hook failure surfaces as `{ok: false, error: {code: 'E_LIFECYCLE_FAILED', message}}`; a `docker compose` failure surfaces as `E_COMPOSE_FAILED`. If Compose exits successfully but one or more containers remain `created`, exit non-zero, or enter another failed runtime state, Hack returns `E_STARTUP_INCOMPLETE`. Successful one-shot services (`exited` with code 0) are reported under `services.completed` and do not fail startup. Hack recognizes dependency installers, services labeled `hack.service.one-shot=true`, and services referenced by another service with Compose `condition: service_completed_successfully`. An unrelated service that merely exits zero is still treated as incomplete startup. Lifecycle failures are one of the primary consumers of the `--json` error envelope, since hook and process startup are common failure points around `hack up`. ## Sessions backend Lifecycle processes run inside the configured mux backend (tmux or zellij), using the same selection rules as `hack session`. Config: - Project: `.hack/hack.config.json` → `sessions.mux = auto|tmux|zellij|none` - Env override: `HACK_SESSIONS_MUX=auto|tmux|zellij|none` Lifecycle session name: - No branch: `--lifecycle` - With `--branch `: `--lifecycle-` Lifecycle hooks and processes run on the host. They receive the selected overlay's `global` values plus `host` overrides, with `host` taking precedence. Service-scoped values remain container-specific unless a host command explicitly selects that scope with `hack host exec --scope `. Notes: - If no mux backend is available, lifecycle process startup fails with an actionable error. - Teardown is implemented by killing the lifecycle session; anything running inside that session will be stopped. - Current lifecycle sessions carry the same random ownership token in mux metadata and persisted state. Cleanup requires an exact token match; deterministic names alone never authorize session teardown. - Healthy-session adoption also matches a stable SHA-256 fingerprint of the effective overlay and sorted lifecycle environment. Environment names, keys, and values are never persisted; changing an overlay or value replaces the owned session so host helpers cannot retain stale configuration. - For tmux-backed lifecycle sessions, Hack also persists the pane PID and the wrapped command's actual process-group metadata to `.hack/.internal/lifecycle/state.json`, with per-hook/process output logged to `.hack/.internal/lifecycle/*.log`. If tmux pane state disappears before teardown, `hack down` still uses that persisted metadata to clean up any live lifecycle process groups instead of leaving orphaned host processes behind. - `hack doctor` reports stale lifecycle state, leaderless process groups, ownership collisions, and ownership-proven sessions with no running Compose instance. Recently updated sessions are treated as possible in-flight startups for five minutes and stay untouched. `hack doctor --fix` reaps only established orphans after rechecking runtime liveness and mux ownership; unverified same-name sessions stay untouched. - Runtime hygiene also warns when regular Compose services are stuck in `Created`, including stacks left behind by an interrupted startup. This check is diagnostic only; inspect the explicit target with `hack ps --json` before deciding whether to retry or tear it down. ## Tips - For long-running setup commands, use either `lifecycle.processes` or `lifecycle.up.before` with `persistent: true`. - For local proxy/tunnel helpers that bind fixed ports, prefer `singleton.ports` with `onConflict: "adopt"` so `hack up` can reuse an already-running external tunnel instead of racing it. Adopt only checks that every configured port is occupied, not health, identity, or readiness; pair it with a `lifecycle.up.before` identity check. - Keep non-persistent hooks short and deterministic. - Store lifecycle secrets in `hack.env.*.yaml` as encrypted `secure:` values and prefer runtime injection over relying on `.hack/.env`. - If a hook requires interactive auth (e.g. browser-based SSO), it will still work; it runs with `stdin: inherit`. - Global `--no-interactive` (or `HACK_NO_INTERACTIVE=1`) affects only the env-prompt step in `hack up` (step 1 above): with prompting disallowed, missing required env is never prompted for — the run fails fast with an actionable error instead of blocking on a prompt, following the same `--no-interactive` convention used across the CLI (documented defaults or `E_INTERACTIVE_REQUIRED`). It does not change hook `stdin` behavior — a hook that itself blocks on interactive auth still blocks under `--no-interactive`. --- # Sessions Sessions are **persistent project workspaces**. They keep a repo-root shell alive across terminal restarts, SSH reconnects, and long-running agent work so you can return to the same context instead of rebuilding it each time. The guided default is **tmux**: - `hack session` opens a tmux-first interactive picker - `hack session start ` reuses the default project workspace unless you ask for an isolated one - `hack session start --env qa --service api` creates a stable env-scoped workspace when you need a specific overlay - `hack setup tmux` installs the recommended popup binding so the picker is easy to reach from tmux Other mux backends still exist. `sessions.mux=auto` prefers tmux and falls back to zellij when tmux is unavailable, and you can explicitly set `sessions.mux=zellij` if that is your preferred backend. The interactive onboarding and pane tooling are tmux-first today. ## Quick start ```bash # Interactive picker for existing workspaces and new project workspaces hack session # Reuse the default workspace for a project, or create it if needed hack session start # Create an env-scoped workspace for one overlay/service combination hack session start --env qa --service api --detach # List active workspaces hack session list # Attach to an existing workspace by name hack session attach # Queue a command in a running workspace hack session exec "bun test" # Queue a command with injected env for that run hack session exec --env qa --service api "bun db:migrate" # Stop a workspace hack session stop # Capture recent output (defaults to active pane) hack session capture # Tail workspace output for a short window (defaults to active pane) hack session tail # List panes in a workspace hack session panes ``` ## Core flows ### Interactive picker Running `hack session` without arguments opens an interactive picker showing: - **Active workspaces**: running workspaces from the configured mux backend(s), with attached status when the backend can report it - **Available projects**: registered projects that do not currently have a default workspace When selecting an attached session, you can choose to: - **Attach**: detach other clients and take over the workspace - **Create isolated**: start a separate long-running workspace such as `project--2` This is the recommended entry point when you want to resume a project context but do not remember the exact workspace name. `hack session` with no subcommand is inherently interactive (it prompts with a picker) and is not a scripted/agent entry point. For scripted or agent-driven runs, use `hack session start ` (optionally with `--detach`) or `hack session list --json` instead of the bare picker. ### Start and create ```bash # Reuse the default project workspace (creates it if needed) hack session start my-project # Force creation of an isolated numbered workspace hack session start my-project --new # Create an isolated workspace with a custom suffix hack session start my-project --name agent-1 # Creates: my-project--agent-1 # Prepare services before creating or attaching hack session start my-project --up # Create or reuse without attaching the current terminal hack session start my-project --detach # Create a stable env-scoped workspace hack session start my-project --env qa --service api --detach ``` Default behavior is deliberate: - The first workspace for a project is just the project name, for example `my-project` - Passing `--env` or `--service` creates a stable scoped workspace name such as `my-project.env-qa.svc-api` - `--new` or `--name` creates an isolated workspace for a second agent, experiment, or review lane - `--detach` keeps the workspace alive for another tool or terminal to attach later Workspaces are created at the project's repo root, not the `.hack/` directory. ### Attach ```bash # Attach to an existing workspace hack session attach my-project # From inside tmux, this switches clients instead of nesting tmux ``` Use `attach` when you already know which workspace you want. If you are already inside tmux, hack switches clients instead of nesting tmux inside tmux. When attaching, tmux detaches other clients to avoid terminal size conflicts across multiple devices. If the workspace is running in zellij, hack attaches to that named session directly instead. ### Stop ```bash hack session stop ``` `stop` kills the underlying mux session (`backend.killSession`) outright — anything still running inside it, including a long-lived dev server or an in-progress agent task, is terminated. There is no graceful shutdown step; if you need cleanup hooks to run first, do that before stopping. ### Exec ```bash # Send a command to a running workspace hack session exec my-project "bun test" # tmux: sends the command + Enter to the active pane # zellij: opens a new pane and runs the command because there is no active-pane equivalent # Inject a selected overlay/service into the queued command hack session exec my-project.env-qa.svc-api --env qa --service api "bun db:migrate" ``` Use `exec` when you want to drive a persistent workspace from another terminal, script, or agent without attaching interactively first. If you pass `--env` or `--service`, Hack resolves the selected project env and injects it into the queued command without materializing `.hack/.env`. ### Listing panes (tmux only) Use `hack session panes` to list panes with useful metadata (target, active flag, window/pane indices, command, path). ```bash # List panes (NDJSON start/log/end) hack session panes my-project # Explicit NDJSON output (same as the default) hack session panes my-project --json # Single JSON object output hack session panes my-project --pretty ``` `--json` and `--pretty` are mutually exclusive; passing both is an error. ### Capturing output (tmux only) `hack session capture` emits NDJSON events by default for machine parsing (start/log/end). By default it targets the active pane; use `--target` to select a specific pane. Use `--pretty` for raw pane output; `--json` and `--pretty` are mutually exclusive. ```bash # Capture last 200 lines (default) as NDJSON hack session capture my-project # Capture a specific pane target and line count (tmux pane targets use `:` and `.`) hack session capture my-project --target my-project:0.1 --lines 500 # Human-friendly raw output hack session capture my-project --pretty ``` ### Tailing output (tmux only) `hack session tail` also emits NDJSON events by default and stops after `--max-ms` (default 5000), polling every `--interval-ms` (default 500). By default it tails the active pane; use `--target` to select a specific pane. `--json` and `--pretty` are mutually exclusive. ```bash # Poll capture-pane and emit only new lines for 5s (default) hack session tail my-project # Customize polling interval and max duration hack session tail my-project --interval-ms 250 --max-ms 10000 # Human-friendly raw output hack session tail my-project --pretty ``` ## Remote access with SSH Use `hack ssh` to get connection info for remote access: ```bash # Interactive: choose Tailscale or direct SSH hack ssh # Use Tailscale SSH hack ssh --tailscale # Use direct SSH with hostname hack ssh --direct --host 192.168.1.100 # Specify user and port hack ssh --host example.com --user dev --port 2222 # Connect directly to a workspace hack ssh my-session ``` ### What `hack ssh` shows 1. **SSH command**: Copy-paste command to connect 2. **QR code**: Scan with mobile SSH apps (Blink, Termius) 3. **Active workspaces**: List of active workspaces on this machine, including the active mux backend when known 4. **Action picker**: Done or connect to a workspace When you connect to an existing workspace, hack uses that workspace's backend. When you create a new one from the SSH flow, hack uses the configured default backend (`sessions.mux`). ### Tailscale setup If Tailscale is stopped or not connected, `hack ssh` will prompt to start it: ``` Tailscale is stopped ? Turn on Tailscale? › yes Starting Tailscale... Tailscale connected! ``` ### Terminal size conflicts When multiple clients attach to the same tmux workspace, terminal size can get messed up. The session commands use `tmux attach -d` to detach other clients, avoiding this issue. If you're already in tmux and want to switch workspaces without detaching: ```bash # Inside tmux: prefix + s to open session switcher # Or use the tmux command: tmux switch-client -t ``` ## Configuration Project config: `.hack/hack.config.json` ```json { "sessions": { "mux": "auto" } } ``` Values: - `auto` (default): prefer tmux, fall back to zellij - `tmux`: require tmux - `zellij`: require zellij - `none`: disable sessions Env override: `HACK_SESSIONS_MUX=auto|tmux|zellij|none` Global default: ```bash hack config set --global sessions.mux zellij ``` Recommended onboarding: ```bash # Install the tmux popup binding that launches the picker hack setup tmux # Keep tmux as the guided default, but switch globally if your team prefers zellij hack config set --global sessions.mux zellij ``` --- # Development runtime safety Hack's development defaults must preserve hot reload without manufacturing host-wide CPU, memory, or storage pressure. This guide records the runtime contract added after the 2026-07-30 OrbStack/Next investigation. ## Root cause and scope The generated Compose path used to inject both `CHOKIDAR_USEPOLLING=true` and `WATCHPACK_POLLING=true` into every discovered service. The injection was unconditional: Next and non-Next services, small repos and bind-mounted monorepos, and runtimes with working native file notifications all received the same polling policy. In the observed Apple Silicon workload, polling combined with an unnecessary `linux/amd64` override and multi-gigabyte persistent `.next` volumes. The result was sustained emulation/watcher CPU, very high cumulative writes, and a large set of stopped branch containers and cache volumes. The bounded comparison removed those project-level overrides without changing application code: the Next 16 development server ran as native arm64, hot reload remained available through native notifications, and idle container CPU settled near zero. A slow-filesystem warning remained for the `.next` volume, which identifies storage as a latency factor but does not justify forcing polling. Hack owns only two parts of that failure chain: 1. generated Compose defaults must not impose polling; 2. lifecycle targeting and optional cache cleanup must not silently abandon branch-owned resources. Image platform selection and application health-check cadence remain explicit project configuration. Hack does not rewrite them. ## File watching contract Generated services rely on the container runtime's native file notifications. Hack does not add Chokidar or Watchpack polling variables. A project can still set either variable on an individual service when a measured runtime requires it: ```yaml services: web: environment: CHOKIDAR_USEPOLLING: "true" WATCHPACK_POLLING: "750" ``` Keep polling service-scoped and measure CPU plus write activity after enabling it. Next's local development guidance notes that Docker filesystem access on macOS and Windows can be slower than host-local development; OrbStack likewise documents that bind mounts cross the macOS boundary while named volumes remain on the Linux side. Those storage tradeoffs are separate from watcher correctness. ## Branch rename contract An implicit `hack down` in a linked worktree uses Docker's Compose labels and exact `com.docker.compose.project.working_dir` evidence: - no owned runtime: use the current sanitized Git branch, preserving normal behavior; - exactly one owned runtime: target it, including when it is Created or stopped and the Git branch now has a different name; - multiple owned runtimes: fail before Compose mutation and require `--branch `; - explicit `--branch`: always target that branch without inference; - detached HEAD: continue to require an explicit branch; - primary checkout: continue to target the base instance by default. Canonical checkout paths prevent one worktree from stopping a sibling's runtime. ## Disposable cache cleanup contract Ordinary `hack down` preserves every named volume. `hack down --prune-caches` is a narrow, confirmation-gated addition for build caches. It removes only exact volume names observed on the target containers, then independently verifies the volume's Compose project and logical-volume labels. A named volume is eligible when every observed destination ends in `.next`, or when the top-level Compose volume explicitly declares `hack.cache.disposable: "true"`. The explicit label makes cleanup framework-agnostic without guessing whether destinations such as `.turbo`, `target`, `.gradle`, or a generic `cache` directory contain disposable output: ```yaml services: worker: volumes: - build-cache:/app/target volumes: build-cache: labels: hack.cache.disposable: "true" ``` Runtime inventory mount records now expose the Docker volume `name` separately from `source` (the engine storage path), so callers can inspect the same removal identity without guessing. The cleanup intentionally excludes: - bind mounts; - database, Redis, application-upload, and dependency volumes unless explicitly marked disposable; - external or unlabeled non-Next volumes; - volumes owned by another Compose project or checkout; - loose volume-name matches. Use `--yes` only after reviewing the same project-scoped evidence. Hack does not infer disposability from arbitrary volume names or mount paths because those signals cannot distinguish generated build output from durable application data. ## Doctor boundary This change does not add threshold-based doctor warnings for CPU, stopped-instance counts, volume size, or `linux/amd64` on arm64. Those signals can be legitimate and require image/platform or workload context that static configuration cannot prove. The generated-polling regression tests, branch-ownership resolution, and confirmation-gated cleanup are deterministic; policy guesses are left out of doctor until Hack can make them equally actionable. ## References - [Next.js local development](https://nextjs.org/docs/app/guides/local-development) - [Next.js Turbopack reference](https://nextjs.org/docs/app/api-reference/turbopack) - [OrbStack volumes and mounts](https://docs.orbstack.dev/docker/file-sharing) - [OrbStack efficiency](https://docs.orbstack.dev/efficiency) --- # macOS browser cannot reach a local service A successful terminal request does not prove that a browser can reach the same service. On macOS 15 and later, Local Network access is controlled per application; Apple exempts command-line tools launched from Terminal or SSH. See Apple's [Local Network privacy explanation](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy) and [permission settings guide](https://support.apple.com/guide/mac-help/mchla4f49138/mac). ## `.local` names need a one-time grant outside Safari New projects use `.hack.local` by default. macOS applies Local Network privacy by name as well as by address: Apple's TN3179 treats a `.local` lookup as local network activity because `.local` is the multicast DNS (Bonjour) namespace. A browser that opens `https://my-app.hack.local` therefore needs Local Network access, even though Hack's resolver answers with a loopback address. - **Safari** opens `.local` names without this permission. - **Chrome, Firefox and other browsers** need it once per app. macOS asks the first time; if you declined, enable it in **System Settings → Privacy & Security → Local Network**, then fully quit and reopen the browser. On macOS, Hack prints one hint line on stderr when a command opens or prints a `.local` browser URL (`hack open`, `hack branch open`, `hack status` and `hack projects`, and Doctor's browser check). The hint is shown at most once per command and never appears inside `--json` output. Hack does not read, request or change this permission for you. If you prefer not to grant the permission, use a name outside `.local`: - **OAuth alias:** enable `oauth.enabled` (or `hack init --oauth`) and open the project's `.hack.gy` alias. `hack open` prefers it automatically; `hack open --prefer alias` requires it. - **Custom suffix:** choose a dedicated suffix under `hack.gy`, for example `dev.hack.gy`. With the native candidate, run `hack global dns preview --domain dev.hack.gy` and then `hack global dns activate --domain dev.hack.gy` (see [native candidate DNS](/docs/v5/native/full-reference)). With the Docker-backed stack, set `hack config set --global default_domain ` and run `hack global install` (see [Initialize a project](/docs/v5/guides/init-project)). Existing project hosts are not migrated automatically. Avoiding `.local` avoids only the name-based rule. Address-based checks still apply, so verify the exact origin in the browser as described below. ## Diagnose a browser failure Run `hack doctor` first. DNS, resolver, Caddy, project services and certificate trust have separate checks. The browser check remains unverified until you report an observation; Doctor does not read the browser's permissions or its private data. ## Compare the same URL Open your local service URL in the affected browser, then report what you observed: ```sh hack doctor --browser-url https://admin.my-project.hack.local --browser-result fails ``` Use your actual hostname, including a legacy or custom domain if configured. The URL must be an HTTPS origin (no path beyond `/`, credentials, query or fragment). Choose a safe, read-only root page that needs no authentication. Doctor makes a bounded request with normal certificate verification, without browser cookies or following redirects. `--browser-result` accepts: | Value | Observation | | --- | --- | | `unknown` | You have not checked the browser yet (the default). | | `works` | The same URL loaded successfully in the affected browser. | | `fails` | The browser failed; the cause is unknown. | | `permission-denied` | You observed a Local Network denial or confirmed that the affected app's permission is off. | If CLI HTTPS succeeds while the browser fails, Doctor identifies a browser-specific boundary and suggests checking Local Network access. This is a possible cause, not proof: browser certificate state, proxy settings or extensions can also differ. A CLI DNS, TLS, connection, redirect or unsuccessful HTTP response keeps the comparison ambiguous; address those findings before attributing the failure to privacy. A redirect response proves only that the original endpoint responded, not that its destination works. Browser results are your report, not an automated test. ## Recover and verify in the browser 1. Open **System Settings → Privacy & Security → Local Network** and inspect the affected browser or app. If access is disabled and you intend to allow it, enable that app's access yourself. 2. Fully quit and reopen that browser, then open the **same URL** again. If it works, rerun the command with `--browser-result works` to record the recheck. 3. If it still fails, retain the exact browser error and compare Doctor's other findings. Certificate errors need the [macOS certificate trust checks](/docs/v5/guides/macos-certificate-trust). If the app is absent from Local Network settings, absence alone does not establish denial. Doctor, including `--fix`, does not change these privacy settings or restart your browser. Do not disable TLS verification, reset system privacy databases or turn off network security to make the diagnostic pass. The check is macOS-specific; other platforms report it as not applicable. --- # macOS certificate trust After Caddy rotates its local root CA, an older trusted certificate with a similar name does not establish trust in the new root. `hack doctor` checks the exported CA's validity dates, exact certificate identity in the System keychain, macOS TLS trust evaluation, and inclusion in Hack's host CA bundle. It also reads the public root from the running Caddy container and compares its certificate bytes with the export. An old export, old keychain entry and old host bundle agreeing with one another cannot establish that Caddy's current root is trusted. An unavailable runtime is reported as unverified, not healthy. Run `hack global trust` to export the current root from the running Caddy instance, install it in the System keychain, and refresh the host trust bundle and shell environment. The keychain step uses the local administrator prompt. Never send an administrator password to an agent or put it in a command. Non-interactive runs skip this step when sudo would need a password; host environment preparation can still finish independently. `hack doctor --fix` can offer the same repair. After installation, Hack verifies trust again before reporting success. Fully quit and reopen Chrome if it retains the old trust state. Existing roots are preserved because another local service may still depend on them. Refreshing a stale public CA export is a separate confirmation from system trust. Doctor rechecks the running root after confirmation and replaces only the export; it leaves an existing export intact if the runtime cannot be verified. It refuses symlink or non-regular export destinations. An expired, not-yet-valid, malformed or non-CA certificate is refused before native import or host trust-bundle changes. The export must contain exactly one PEM certificate, with no appended certificates or other content, and fit within 64 KiB. Trusting an expired root cannot renew it: investigate Caddy's PKI state first. These checks concern the exported root and host configuration. They do not prove that every running service presents a valid leaf certificate or that an existing browser process has refreshed its trust state. ## Superseded Caddy roots Each Caddy rotation or reinstall can leave an older root in the System keychain. Doctor's `superseded caddy roots` check lists them. It reads the same System keychain listing as the trust check and counts self-issued CA certificates with the current root's subject but different certificate bytes. It ignores the year in Caddy's root name, so a stale `2025 ECC Root` belongs with a live `2026 ECC Root`. It also counts how many are expired or not yet valid. The current root and roots with unrelated names are never counted. The check is informational. A superseded root is never accepted as current trust. Doctor and `hack doctor --fix` never remove keychain certificates, because another local service may still depend on an older root. The JSON output adds a `supersededRoots` object with `count`, `expired`, `notYetValid` and up to 32 public SHA-256 fingerprints (`sha256`), and nothing else. Both the Docker and native Doctor paths run this check. To remove a superseded root yourself: 1. Run `hack doctor --json > doctor-before.json` and note the `superseded caddy roots` count and the SHA-256 fingerprint you intend to remove. 2. List the matching keychain entries with their hashes: `security find-certificate -a -Z -c "Caddy Local Authority" /Library/Keychains/System.keychain`. Find the entry whose `SHA-256 hash` line equals a fingerprint from Doctor and copy the `SHA-1 hash` from that same entry. Use only a fingerprint that Doctor listed. A name match alone is not enough. 3. In your own terminal, run `sudo security delete-certificate -Z /Library/Keychains/System.keychain`. Enter your password only at the native macOS administrator prompt. Never give an administrator password to an agent or put it in a command. 4. Run `hack doctor --json > doctor-after.json` and confirm that the count dropped and the removed fingerprint is gone. The current root's trust check must still pass. The two files are a before and after record. That check holds only counts and public fingerprints, but the full Doctor report holds other local details, so review both files before you share them. --- # Hack in Codex Cloud and remote development environments Use the existing repository, its encrypted env configuration, and its pinned dependencies to prepare a development environment on another machine. Start by choosing the target and Hack version explicitly. A portable CLI installation gives you env resolution and host commands; starting a full application also requires that project's runtime, services, network access, and credentials. The optional `hack-remote` skill guides this workflow. A plugin on your laptop does not install Hack or enroll a cloud machine. Confirm that the skill is available in the target task, for example through the repository's supported skill location. ## Choose the execution path | Target | Useful existing path | Check before starting the app | | --- | --- | --- | | Codex Cloud or a managed CI container | Portable/slim CLI and the repository's host commands | Toolchain, key injection during the task, network access, and required services | | Existing remote development machine with Docker | Hack's Compose workflow | Working Docker Engine and Compose, project dependencies, lifecycle hooks, routing needs | | Apple Silicon Mac testing v5 | Explicit native prerelease channel | Verified bundle, provider preparation, isolated native home, application acceptance | The native prerelease installer currently targets Apple Silicon macOS. It is not a Linux installer or a qualified nested-VM provisioning path. See [candidate installation](/docs/v5/native/install) and [native candidate preparation](/docs/v5/native/full-reference). Do not use the unsupported Hack node/gateway/dispatch surface as a fallback. For an existing remote machine, establish its identity and the intended checkout before installation. Read `AGENTS.md`, inspect `git status --short`, and check `uname -s` and `uname -m`. Creating a cloud machine, publishing or sharing an environment, and making privileged host changes require authority for that target and effect. Ordinary setup should reuse the project's existing development path. ## Select a CLI without replacing another channel Use a reviewed absolute executable path throughout setup and verification. Check its `--version` and `--help`; do not assume the first `hack` on PATH is the desired version. An installed native candidate must use its channel launcher, for example `$HOME/.hack-next/bin/hack-next`. That launcher selects the verified executor and the version's private homes. Running its raw `hack-cli` binary is not equivalent. For a portable installation, select a published stable release that contains `hack-codex-install.sh` and the matching platform archive. Download and review the script from that same tag. This example uses a caller-supplied approved tag and a dedicated installation path: ```sh set -euo pipefail : "${HACK_INSTALL_TAG:?Set the approved release tag before running this script}" curl --fail --location --proto '=https' --proto-redir '=https' --tlsv1.2 \ "https://github.com/hack-dance/hack/releases/download/${HACK_INSTALL_TAG}/hack-codex-install.sh" \ --output hack-codex-install.sh ``` Review the downloaded script and the release's source and artifact identity before running it. Select unused dedicated destinations, or the same installation you intend to update: ```sh HACK_INSTALL_TAG="$HACK_INSTALL_TAG" \ HACK_INSTALL_BIN="$HOME/.local/share/hack-portable/bin" \ HACK_INSTALL_ASSETS="$HOME/.local/share/hack-portable/assets" \ bash ./hack-codex-install.sh hack_cli="$HOME/.local/share/hack-portable/bin/hack" "$hack_cli" --version ``` The portable installer downloads its platform archive over HTTPS; it does not implement the native prerelease installer's receipt, inventory, and signature checks. Use your environment's artifact verification policy. Do not point the portable installer at a native prerelease archive or claim that installing it also provisioned a VM. The wrapper defaults to `HACK_EXECUTION_MODE=codex`, disables Docker event watching, and selects its installed assets. It does not need the Bun runtime to execute its compiled Hack CLI. Install the project's own toolchain separately. For a Bun project, use the repository's pinned Bun version and: ```sh bun install --frozen-lockfile ``` Resolve lockfile failures instead of silently running an unlocked install. Keep the same rule in maintenance scripts and when a new branch changes dependencies. Container systems that accept a custom base image can use a reviewed, pinned digest of `hackdance/hack:slim` or `ghcr.io/hack-dance/hack:slim`, then add project-specific tools. Do not assume that an arbitrary managed cloud environment accepts a custom image. The slim image includes Hack and Bun; it does not provide every language or application dependency. ## Inject the project key for the correct lifetime Hack resolves committed `.hack/hack.env.default.yaml`, overlays, and local overrides. A fresh independent checkout can receive its decryption key through `HACK_ENV_SECRET_KEY`. Use the target's approved secret manager or direct runtime injection; enter the value through the provider's secret UI, never through chat, command arguments, logs, or a checked-in setup script. The key must be available to the Hack process that decrypts the env. Hack requires the actual key bytes. An HTTPS proxy secret placeholder cannot satisfy local decryption. Use non-production access for development and inspect variable names or masked metadata when checking configuration. Do not copy `.hack.secret.key` into images, shell profiles, prepared filesystem snapshots, or dependency caches. Do not save decrypted env to extend the lifetime of an injected key. See [Env & secrets](/docs/v5/env) for key precedence and linked-worktree behavior. ### Current Codex Cloud Current Cloud records an Install script and Start skill; publication captures the prepared filesystem, and new tasks use it. Repository refresh preserves caches but does not rerun installation or startup. Review prepared files before publishing, then test a new task. See [Cloud environments](https://learn.chatgpt.com/docs/environments/cloud-environments). Request `HACK_ENV_SECRET_KEY` as a direct environment variable, preferably a personal value scoped to this environment. It is visible to programs in the task. Network secrets instead supply proxy placeholders for HTTPS requests, so they are unsuitable for this key. Configure and test required service access separately. See [environment variables and network secrets](https://learn.chatgpt.com/docs/environments/cloud-environments#configure-environment-variables-and-network-secrets). Put deterministic tool and dependency installation in the Install script. Put the selected CLI, env overlay, start command, and readiness check in the Start skill. Only publish or share when requested. Keep credentials out of prepared files. ### Legacy Codex Cloud The [legacy environment guide](https://learn.chatgpt.com/docs/environments/cloud-environment) describes a different lifecycle: secrets are available only during setup and are removed before the agent phase. Setup exports do not persist. Cached containers can resume with an optional maintenance script. For this path, a setup-only key does not authorize or enable later decryption. Use a separately approved runtime injection mechanism or a secret-free task. Never write the key or decrypted values into cached files to work around the boundary. Report runtime credentials as unavailable until a fresh agent-phase check proves otherwise. ## Verify env resolution without printing secrets The examples below assume `hack_cli` is the selected absolute executable and `project_root` is the intended absolute checkout. These are shell variables, not Hack configuration keys. Set `HACK_NO_INTERACTIVE=1` and `NO_COLOR=1` in the target command environment so unattended commands fail instead of hanging at prompts. Replace `DATABASE_URL` with a required key name from the project, and `default` with the intended non-production overlay. `env explain` reports provenance without the value. The child checks presence without printing it: ```sh "$hack_cli" env explain DATABASE_URL --path "$project_root" --env default --json "$hack_cli" host exec --path "$project_root" --env default \ -- sh -c 'test -n "$DATABASE_URL"' ``` For service-scoped values, add `--scope SERVICE` to `host exec` and `--service SERVICE` to `env explain`. Commands run on the current machine with Hack's resolved env. `host exec` defaults to host-oriented addresses; `--target compose` selects container-oriented values but does not enter or start a container. Run the repository's documented test or development command through `host exec` when it has a supported host workflow. Inspect that command's logging before providing real secrets. Do not use `env get`, `env list --show-secrets`, `printenv`, or a shell env dump as a credential check. A presence check proves injection only; an application health check must prove that required services are reachable and credentials work. ## Start only a supported runtime For an existing Compose project, verify the target engine and Compose client: ```sh docker info >/dev/null docker compose version ``` These are capability checks; use Hack to start and manage the application's services. A missing engine is a provisioning requirement, not a reason to retry `hack up` or assume Docker-in-Docker or nested virtualization will work. Engine installation requires an authorized host setup. Likewise, installing the native candidate does not prepare its provider or activate routing/trust. Once the chosen runtime is ready, inspect the project's lifecycle hooks for the remote target, then run: ```sh "$hack_cli" up --detach --path "$project_root" --env default --json "$hack_cli" ps --path "$project_root" --json ``` Use a bounded application health request and one meaningful development operation to verify readiness. A running container alone is insufficient. Machine-wide `hack global` operations and Loki-backed logs are unavailable in slim mode, which skips Caddy, CoreDNS, Loki, Grafana, local CA/TLS bootstrap, and daemon Docker event watching. A project that requires those surfaces needs the appropriate authorized host setup or its documented portable development path. Record the target, selected CLI path/version, overlay name, start/readiness result, and remaining gap without credentials. Preserve existing services and application data; cleanup applies only to resources this task owns and is authorized to remove. ## Contributor checks and current limits `scripts/portable-container-smoke.sh` exercises an isolated synthetic project in a CI-built slim image: it injects a synthetic key after removing the fixture key file, then checks env resolution and host execution. Run it with: ```sh bun run smoke:portable-container ``` This proves the portable env/host-command contract for that image. It does not prove Codex Cloud skill loading, secret lifetime in a real task, Docker availability there, or v5 native application startup on Linux. For Hack contributors using a reviewed source checkout, the repo-local `scripts/install-codex-slim.sh` and `scripts/maintain-codex-slim.sh` are separate helpers. The source wrapper requires the pinned Bun runtime and references that checkout; it is not the compiled release installer. Use a dedicated destination when testing it beside another Hack version. Automatic lightweight runtime provisioning on arbitrary fresh cloud machines, native Linux qualification, and a complete fresh Codex Cloud application run remain separate acceptance work. Report them explicitly rather than treating a successful CLI installation or env test as full application readiness. --- # hack CLI reference This reference omits the retired agent instruction-management commands (`hack setup sync`, standalone `hack setup` exports and `hack agent prime`), which refuse in v5. See [Agent integrations](/docs/v5/integrations) for the opt-in plugin and MCP setup. Run `hack help ` for the same content in the terminal. Global options: `--no-interactive` (or `HACK_NO_INTERACTIVE=1`); JSON shapes vary by command and backend. See [Command and output contracts](/docs/v5/reference/command-contracts) for envelopes, bare reports and streamed records. ## Project ## `hack project` Inspect or manage project metadata ### Usage ```bash hack project [options] ``` ### Subcommands | Command | Summary | | --- | --- | | `hack project owner` | Inspect project ownership | | `hack project target` | Inspect the exact project, checkout, instance and service a command would target (read-only, provisional) | | `hack project task` | Discover reviewed task recipes without running them (read-only, provisional) | ### Options | Option | Description | | --- | --- | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | ## `hack project owner` Inspect project ownership ### Usage ```bash hack project owner [options] ``` ### Subcommands | Command | Summary | | --- | --- | | `hack project owner show` | Show the local ownership metadata for the current project | ### Options | Option | Description | | --- | --- | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | ## `hack project target` Inspect the exact project, checkout, instance and service a command would target (read-only, provisional) ### Usage ```bash hack project target [options] ``` ### Options | Option | Description | | --- | --- | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--project ` | Target a registered project by name (from ~/.hack/projects.json) | | `--branch ` | Run against a branch-specific instance (compose name + hostnames) | | `--service ` | Also resolve one Compose service of the target | | `--env ` | Select an env overlay by name; omit for the configured default overlay, or use 'base' or 'default' for base layers only | | `--expect-generation ` | Refuse unless the project's config generation still equals this id | | `--json` | Output JSON (machine-readable) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | ## `hack project task` Discover reviewed task recipes without running them (read-only, provisional) ### Usage ```bash hack project task [options] ``` ### Subcommands | Command | Summary | | --- | --- | | `hack project task list` | List reviewed task recipes and refusals for the target (read-only, provisional; runs nothing) | | `hack project task inspect ` | Show one reviewed task recipe: argv, service, env scope, effects and pins (read-only, provisional; runs nothing) | ### Options | Option | Description | | --- | --- | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | ## `hack init` Initialize a repo (generate .hack/ with compose + config) ### Usage ```bash hack init [options] ``` ### Options | Option | Description | | --- | --- | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--manual` | Skip discovery and define services manually (or generate a minimal compose in --auto) | | `--auto` | Run non-interactive init with sensible defaults | | `--name ` | Project slug (default: repo name) | | `--dev-host ` | DEV_HOST override (new projects use global default_domain, or hack.local) | | `--oauth` | Enable OAuth-safe alias host | | `--oauth-tld ` | OAuth alias TLD override (default: gy) | | `--no-discovery` | Skip discovery and generate a minimal compose | | `--with ` | After init, hand the onboarding prompt to an agent CLI (prints the prompt when the CLI is missing or the run is non-interactive) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | ## `hack up [services...]` Start project services (docker compose up) ### Usage ```bash hack up [services...] [options] ``` ### Arguments | Arg | Description | | --- | --- | | `services` | | ### Options | Option | Description | | --- | --- | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--project ` | Target a registered project by name (from ~/.hack/projects.json) | | `--env ` | Select an env overlay by name; omit for the configured default overlay, or use 'base' or 'default' for base layers only | | `--branch ` | Run against a branch-specific instance (compose name + hostnames) | | `--detach, -d` | Run in background (docker compose up -d) | | `--profile ` | Enable one or more compose profiles (comma-separated) | | `--target ` | Execution target routing (auto routes to remote when project execution mode requires it) | | `--json` | Output JSON (machine-readable) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | ## `hack down` Stop project services (docker compose down) ### Usage ```bash hack down [options] ``` ### Options | Option | Description | | --- | --- | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--project ` | Target a registered project by name (from ~/.hack/projects.json) | | `--env ` | Select an env overlay by name; omit for the configured default overlay, or use 'base' or 'default' for base layers only | | `--branch ` | Run against a branch-specific instance (compose name + hostnames) | | `--profile ` | Enable one or more compose profiles (comma-separated) | | `--target ` | Execution target routing (auto routes to remote when project execution mode requires it) | | `--prune-caches` | After down, remove confirmed Compose-owned disposable cache volumes | | `--remove-data` | Native backend: delete a stopped project's owned data volumes so the next up starts fresh (asks first) | | `--yes` | Confirm --prune-caches or --remove-data without prompting | | `--json` | Output JSON (machine-readable) | | `--recover` | Explicitly reconcile an interrupted native stop, or stop the services of a native run whose foreground owner exited, while retaining data | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | ## `hack restart [services...]` Restart the project or selected services ### Usage ```bash hack restart [services...] [options] ``` ### Arguments | Arg | Description | | --- | --- | | `services` | | ### Options | Option | Description | | --- | --- | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--project ` | Target a registered project by name (from ~/.hack/projects.json) | | `--env ` | Select an env overlay by name; omit for the configured default overlay, or use 'base' or 'default' for base layers only | | `--branch ` | Run against a branch-specific instance (compose name + hostnames) | | `--profile ` | Enable one or more compose profiles (comma-separated) | | `--target ` | Execution target routing (auto routes to remote when project execution mode requires it) | | `--json` | Output JSON (machine-readable) | | `--recover-frontend` | Recover an interrupted native frontend after proving its owner and effects are gone | | `--expect-finalization-attempt <32-hex>` | Require this exact interrupted native frontend attempt | | `--expect-frontend-pid ` | Previously observed frontend PID (required only for legacy v1 state) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | ## `hack ps` Show project status (docker compose ps) ### Usage ```bash hack ps [options] ``` ### Options | Option | Description | | --- | --- | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--project ` | Target a registered project by name (from ~/.hack/projects.json) | | `--branch ` | Run against a branch-specific instance (compose name + hostnames) | | `--profile ` | Enable one or more compose profiles (comma-separated) | | `--json` | Output JSON (machine-readable) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | ## `hack tui` Open the project TUI (services + logs) ### Usage ```bash hack tui [options] ``` ### Options | Option | Description | | --- | --- | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--project ` | Target a registered project by name (from ~/.hack/projects.json) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | ## `hack run [cmd...]` Run a one-off command in a service container (docker compose run --rm) ### Usage ```bash hack run [options] [cmd...] ``` Everything from `` on is passed to the command unchanged, including flags such as `--version` or `--help`. Put Hack options before it; a leading `--` is optional. ### Arguments | Arg | Description | | --- | --- | | `service` | | | `cmd` | | ### Options | Option | Description | | --- | --- | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--project ` | Target a registered project by name (from ~/.hack/projects.json) | | `--env ` | Select an env overlay by name; omit for the configured default overlay, or use 'base' or 'default' for base layers only | | `--branch ` | Run against a branch-specific instance (compose name + hostnames) | | `--workdir ` | Working directory inside the container (docker compose run -w) | | `--profile ` | Enable one or more compose profiles (comma-separated) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | ## `hack exec [cmd...]` Run a command in an already-running service container (docker compose exec) ### Usage ```bash hack exec [options] [cmd...] ``` Everything from `` on is passed to the command unchanged, including flags such as `--version` or `--help`. Put Hack options before it; a leading `--` is optional. ### Arguments | Arg | Description | | --- | --- | | `service` | | | `cmd` | | ### Options | Option | Description | | --- | --- | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--project ` | Target a registered project by name (from ~/.hack/projects.json) | | `--env ` | Select an env overlay by name; omit for the configured default overlay, or use 'base' or 'default' for base layers only | | `--branch ` | Run against a branch-specific instance (compose name + hostnames) | | `--workdir ` | Working directory inside the container (docker compose run -w) | | `--profile ` | Enable one or more compose profiles (comma-separated) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | ## `hack logs [service]` Tail logs (compose by default; Loki for queries/history via --loki/--query) ### Usage ```bash hack logs [service] [options] ``` ### Arguments | Arg | Description | | --- | --- | | `service` | | ### Options | Option | Description | | --- | --- | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--project ` | Target a registered project by name (from ~/.hack/projects.json) | | `--branch ` | Run against a branch-specific instance (compose name + hostnames) | | `--follow, -f` | Follow logs (default for `hack logs`) | | `--no-follow` | Print logs and exit (do not follow) | | `--tail ` | Tail last N log lines (default: 200) | | `--pretty` | Pretty-print logs (best-effort JSON parsing + formatting) | | `--json` | Output JSON (machine-readable) | | `--profile ` | Enable one or more compose profiles (comma-separated) | | `--compose` | Read logs directly from docker compose (bypass Loki) | | `--loki` | Force Loki backend (do not fall back to docker compose logs) | | `--services ` | Filter Loki logs by service(s), comma-separated (e.g. api,www) | | `--query ` | Raw LogQL selector/query (overrides auto selector built from project + services) | | `--since