zoryn devenv¶
zoryn devenv prepares an isolated environment for building and testing a package: it resolves the build dependencies (BuildRequires) and installs them for the target ALT branch, with the project directory bind-mounted read-write. In it you can:
- build and test the package under a specific scenario — branch, dependency set, tooling;
- edit code — on the host or right inside the environment — and re-run the build immediately;
- tailor the environment — pull in packages and external tools declaratively through config (
packages,build/build_usersteps, features), or install them on the fly withzoryn devenv install; - add packages from a test gyle build task (
--with-task-repo) or a builder's output repository (--with-builder-repo) and debug the build against them; - run LLM assistants (claude, codex, opencode, kimi) isolated from the host.
The environment is cached and reused across runs.
Experimental
zoryn devenv is experimental. The interface and caching behaviour may change in future releases.
Usage¶
zoryn devenv # prepare (or reuse cached) env, drop into a shell
zoryn devenv --backend bwrap # use bubblewrap backend
zoryn devenv --backend podman # use podman backend
zoryn devenv --image registry.altlinux.org/p11/alt:latest # override the podman base image
zoryn devenv --profile p11 # use the [devenv.profiles.p11] config profile
zoryn devenv --branch p11 # one-shot branch override (no config edit)
zoryn devenv --backend podman -u root # podman only: enter as a specific user
zoryn devenv --rebuild # drop this env's container+image and recreate (reuses build cache)
zoryn devenv --rebuild --no-cache # the above, fully from scratch (podman build --no-cache)
zoryn devenv -s # silent: hide podman build/install output
zoryn devenv -- make check # run a command instead of an interactive shell
zoryn devenv -- rpmbuild -bi *.spec # run rpmbuild inside the env
zoryn resolves the package's BuildRequires: by macro-expanding the spec inside the prepared environment (two passes, see Dependency set), merges in any configured extra packages (see Configuration), and drops you into a shell in the current package directory. The project directory is mounted read-write, so edits made on the host are immediately visible inside.
zoryn devenv -- CMD runs CMD without allocating a TTY (so it works in pipes and CI). An interactive shell is opened only when stdin is a terminal.
During preparation the podman build and dependency install stream their output live (dimmed); -v/--verbose passes it through verbatim, -s/--silent hides it behind stage markers. After the spec is expanded, the resolved BuildRequires are printed.
Backends¶
bwrap¶
Significantly more limited than podman
The bwrap/hasher backend only installs the spec's BuildRequires: into a chroot and bind-mounts the project. The podman-only capabilities — features, build/build_user image steps, a custom prompt, outbound_interface, SSH-agent forwarding, --with-task-repo, and live zoryn devenv install — are not available here. (Extra mounts and --mount-bind are honoured on bwrap, bound at enter time.) Prefer podman for the full feature set; use bwrap only where podman is unavailable.
Uses bubblewrap over a freshly prepared hasher chroot. The package's BuildRequires: are installed via hasher into the chroot; the project directory is bind-mounted read-write on top. Re-entering a cached environment is lightweight — the chroot is reused without reinstalling packages.
git is always included in the bwrap chroot.
By default hasher initialises the chroot from the host apt configuration, so the bwrap backend does not automatically follow branch. Set [devenv].apt_config to a host apt config path — an apt.conf-format file, a sources.list file, or a directory containing either — and the chroot is populated from the repositories you choose (e.g. a p11 apt config to build against p11). hsh --apt-config itself only accepts an apt.conf-format file, so an apt.conf file is passed through as-is, while a sources.list is automatically wrapped into a generated apt.conf under ~/.cache/zoryn/devenv-aptconf/. The value is tilde-expanded and must exist; changing it rebuilds the chroot (it is part of the cache key). Machine config only (~/.zoryn, projects.d/). The podman backend also honours apt_config: it is used as the container's sources.list when no apt_builder or apt_sources override is set in the same config layer.
Requires bubblewrap and hasher.
podman¶
Set up rootless podman first
The podman backend runs containers rootless. Configure podman on the host beforehand (subuid/subgid ranges, storage, registries) per the ALT Linux guide: https://www.altlinux.org/Podman. Domain (LDAP/AD/SSSD) accounts in particular need explicit /etc/subuid and /etc/subgid entries (e.g. via usermod --add-subuids/--add-subgids) — without them rootless podman cannot build or run at all. zoryn additionally remaps an out-of-range host id to a fixed in-range id at build time and uses --userns=keep-id:uid=…,gid=… (podman 4.3+) so the container still maps back to your real id; see below.
The podman backend prepares an environment in two stages: it builds a small derived image once, then runs a persistent container from it that is reused on every later entry.
Image build. On first prepare, zoryn builds a derived image (zdevenv-<pkg>-<branch>:<key> — the package directory name, the branch, and a short cache-key hash) FROM an ALT base image (default registry.altlinux.org/<branch>/alt:latest, configurable via image in [devenv] or --image). Provisioning runs at build time as root, before any user-namespace remap — so apt's lock and cache directories are writable. The image:
- runs
apt-get update && apt-get dist-upgrade -y(mandatory: ALT base images are often stale); - installs the dependency set plus
sudo,bashandgit; - bakes in a user matching your host UID/GID, with passwordless
sudo. If your host id is out of range — above 60000, e.g. a domain (LDAP/AD/SSSD) account — the dev user is baked at a fixed in-range id (1000) instead, becauseuseradd(ALT tcb shadow) and a rootless build'schowncannot use an id outside yoursubuidrange.
Persistent container. zoryn then runs one container per cache key (named zdevenv-<pkg>-<branch>-<key>) with podman run -d --userns=keep-id … sleep infinity, the project bind-mounted read-write via -v:
- the keep-id uid remap happens once, at container creation; when the dev user was baked at a remapped id, zoryn uses
--userns=keep-id:uid=1000,gid=1000so your real host id still maps to it and bind-mounted files stay owned by you; - the image's
USERis the baked-in user, sopodman execenters as you (not root), and files you write to the bind-mounted project stay owned by you; - because the container persists, later entries and
install(whichexecinto the same container) are instant; - pass
--user/-u(podman only) to enter as someone else — e.g.-u rootor-u 1000:1000, mapped topodman exec --user.
Zombie reaping and the pid limit. The container runs with --init so a minimal PID 1 reaps zombies: the sleep infinity entrypoint would not, and build/test subprocesses (notably git's background auto-gc after commits) would otherwise accumulate as defunct processes and exhaust the pid limit. That cap is --pids-limit=<n> from [devenv].pids_limit (default 4096 — podman's own 2048 is low for parallel builds; 0 or a negative value means unlimited). It is not part of the cache key, so a change takes effect only when the container is next recreated (e.g. with --rebuild).
Other details. The container gets a distinct --hostname (the package directory name) so its shell prompt is visibly different from the host. zoryn devenv install adds packages live into the running container as in-container root (podman exec --user root … apt-get install) — no image rebuild. --rebuild drops the container and image and recreates them from scratch (add --no-cache to also bust podman's build-layer cache; --no-cache on its own is only a build modifier and neither rebuilds nor deletes anything). The base image must be pullable (image / --image); pull or update it yourself when needed (e.g. podman pull registry.altlinux.org/sisyphus/alt:latest).
Custom image-build steps (build, build_user)¶
Two arrays of shell commands that become RUN lines in the generated Containerfile, run at image-build time:
buildruns as root, after the dependency install and before the host user is created (theUSERswitch) — for system-level provisioning (compile/install a tool, drop a config file).build_userruns as the host user, after theUSERswitch, in that user'sHOME— for per-user installers. A tool like claude'scurl … | bashinstalls into~/.local/bin, which would land in root's home (invisible to your shell) if run viabuild; put it inbuild_userso it lands in your home and is onPATHin the dev shell. Root steps here can use the passwordlesssudothat is set up.
Both run in the container's /bin/sh, are written verbatim, and are part of the cache key (changing either rebuilds the image).
This is machine config only — accepted in ~/.zoryn and ~/.config/zoryn/projects.d/<project>.toml, but not in the committed .gear/devenv (warns as unknown). Build steps are a machine/developer choice and must not be executed from a committed repo file.
[devenv]
build = ["ln -s /usr/bin/python3 /usr/local/bin/python"] # as root, before USER
build_user = ["curl -fsSL https://claude.ai/install.sh | bash"] # as you, after USER
Requires podman.
Extra bind-mounts (mounts)¶
The mounts key in [devenv] is an array of host paths bind-mounted into the persistent container, in addition to the project directory (which is always mounted read-write). Each entry is one of:
- a bare host path — bound at the same path inside the container; or
- a
host:container[:opts]spec passed through topodman run -vunchanged (e.g./data:/data:ro).
Behaviour:
- A leading
~/~/is expanded to$HOMEon both sides of an entry (podman does not expand tildes itself; under--userns=keep-idthe container's home equals the host$HOME). The options field (e.g.ro) is left untouched. - Mounts become
-vflags at container-create time, so changing them recreates the container (the mount list is part of the cache key). - A mount source under
$HOMEthat does not yet exist is pre-created before the container starts — as an empty file if the name looks like a file (e.g.~/.claude.json), otherwise as a directory — so podman does not create a directory where a file is expected. - A read-only (
:ro) source is not auto-created if missing: it is treated as user-supplied input (a missing~/.vimrcis left absent rather than created empty).
This is machine config only — mounts is accepted in ~/.zoryn and ~/.config/zoryn/projects.d/<project>.toml, but not in the committed .gear/devenv (a mounts key there warns as unknown).
Both backends honour mounts. With podman the mounts are -v flags applied at container-create time. With bwrap they are bound into the chroot at enter time as --bind (or --ro-bind for a :ro spec), bound only if the host source exists; the $HOME-autocreate behaviour above is podman-only.
One-shot --mount-bind¶
To add a mount for a single run without editing the config, pass --mount-bind SPEC (repeatable). The SPEC uses the exact same syntax as a mounts entry, and the specs are appended after the configured ones. A relative host path is resolved against the current directory (after -C/--chdir takes effect):
zoryn devenv --mount-bind /srv/data # rw, same path both sides
zoryn devenv --mount-bind ~/ref:~/ref:ro # read-only
zoryn devenv --mount-bind ../OpenUSD # relative → resolved to an absolute path
zoryn devenv --mount-bind /a:/a --mount-bind /b:/b # several at once
podman: changing mounts recreates the container
podman fixes bind-mounts at container creation — a mount cannot be attached to an already-created container. --mount-bind is therefore folded into the container cache key: a run whose mount set differs from the existing container's creates a new container from the cached image. This is cheap (image layers are reused), but anything changed inside the previous container's filesystem — outside the mounted paths — does not carry over. Repeating the run with the same --mount-bind set reuses the same container, and a run without the flag goes back to the previous one. Containers left behind by changed mounts are removed by zoryn devenv clean.
With bwrap the mount is bound at enter time, so it attaches to the existing prepared chroot without any rebuild.
Custom shell prompt (prompt)¶
The prompt key in [devenv] is a literal bash PS1 string baked into the derived image: it is appended to the host user's ~/.bashrc at image-build time, so the interactive podman exec … bash shell uses it. The value is stored verbatim (single-quoted internally), so the usual prompt escapes — \u (user), \h (host), \w (working dir), and colour sequences like \[\e[32m\]…\[\e[0m\] — are interpreted by bash at prompt time. Changing it rebuilds the image (the prompt is part of the cache key).
This is machine config only — prompt is accepted in ~/.zoryn and ~/.config/zoryn/projects.d/<project>.toml, but not in the committed .gear/devenv (a prompt key there warns as unknown). It applies to the podman backend only.
Requires podman.
Outbound interface (outbound_interface)¶
The outbound_interface key in [devenv] is the name of a host network interface (e.g. eth1, wg0, eth0.10). When set, the container is switched off the default --network host onto a rootless pasta network namespace whose outbound traffic is forced out that interface via --outbound-if4/--outbound-if6 (pasta binds its host-side sockets to the interface with SO_BINDTODEVICE), so all the container's outgoing connections egress through it regardless of the host routing table. Only the address families actually present on the interface are bound — an IPv4-only interface such as a WireGuard wg0 does not drag in a failing IPv6 binding. When unset, the default --network host is kept. The same --network is used for the image build too, so apt at build time also goes out the chosen interface.
The interface is bound for egress only, not used as pasta's template: point-to-point / tun / WireGuard interfaces have no usable L2 and cannot be a template (External interface not usable). Instead the host's default-route interface is passed as the template (-i), so the container mirrors a normal interface's addresses/routes while its traffic leaves via the chosen interface. The chosen interface must have at least one usable IP address, and a host default route must exist, or container creation fails with a clear error. Resulting argument shape: --network pasta:-i,<default-iface>,--outbound-if4,<iface>.
If the chosen interface carries only one address family, pasta is restricted to it (-4 or -6). This keeps the unused family out of the container and, crucially, prevents a leak: the template interface may have the other family, whose traffic would otherwise egress via the template's route instead of the bound interface. So an IPv4-only WireGuard wg0 yields an IPv4-only container with no IPv6 path at all.
Because this gives the container its own network namespace, host-localhost services are no longer shared as they are with --network host (a service the host exposes on 127.0.0.1 is not reachable from inside the container, and vice versa). Changing the value recreates the container (the interface is part of the cache key). The name is validated to a safe charset (letters, digits, . _ @ -).
This is machine config only — outbound_interface is accepted in ~/.zoryn and ~/.config/zoryn/projects.d/<project>.toml, but not in the committed .gear/devenv (an outbound_interface key there warns as unknown). It applies to the podman backend only.
Requires podman (and a pasta-capable podman; pasta is the rootless default in current podman).
SSH agent forwarding (forward_ssh_agent / --ssh-agent)¶
Enable forwarding of the host's SSH agent into the container so tools inside (git over SSH, ssh to remote builders) use your agent keys — including keys forwarded into your login by ssh -A. Turn it on with the --ssh-agent (-A) flag, or persistently via [devenv].forward_ssh_agent = true; the flag wins over the config.
When enabled, zoryn bind-mounts the host $SSH_AUTH_SOCK to a fixed in-container path (/run/ssh-agent.sock) and sets SSH_AUTH_SOCK to it in the container, so podman exec (the enter command) inherits it. $SSH_AUTH_SOCK must be set on the host and point at an existing socket, otherwise forwarding is skipped with a warning.
The agent socket is not part of the cache key — its path changes every login (e.g. ssh -A creates a fresh /tmp/ssh-XXXX/agent.<pid> per session). Instead the socket path is recorded on the container as the zoryn.ssh_auth_sock label; on the next zoryn devenv, if it differs from the current $SSH_AUTH_SOCK (a new login, or toggling forwarding), the container is recreated so the live socket is bound. The image build is a layer-cache hit, but the recreated container re-resolves and reinstalls the spec's BuildRequires. Within a single login the socket is stable, so repeated entries are instant.
Machine config only (~/.zoryn, projects.d/), podman backend only.
Apt configuration (podman)¶
The podman backend can generate its own sources.list, replacing the base image's apt sources, and bind-mount any local file: repositories it references (read-only) so apt can read them at both image-build and install time.
Specify the apt config one of four ways (precedence: --apt-conf > project config > global config; within one layer set only one key):
--apt-conf <path>— asources.listfile or a builder-style apt-config dir--apt-conf builder:NAME— reuse builderNAME's apt configapt_builder = "NAME"(config) — reuse a builder's apt configapt_config = "<path>"(config) — a sources.list file, apt.conf file, or apt-config dir (also used by the bwrap backend, normalised to an apt.conf forhsh --apt-config)apt_sources = ["rpm file:///… x86_64 classic", …](config) — inline lines
Local file: repositories are mounted read-only at the same path inside the container; a referenced path that does not exist on the host is an error.
If the configured source resolves to a file with no repository entries (blank or comments only — e.g. an empty /etc/apt/sources.list whose real entries live in sources.list.d/), zoryn prints a warning naming the file and keeps the container's default apt sources instead of failing.
Machine config only (~/.zoryn, projects.d/); podman backend only for apt_builder and apt_sources.
Builder output repo (--with-builder-repo BUILDER)¶
--with-builder-repo BUILDER adds an additive apt-rpm rpm-dir source pointing at the builder's hasher output repository, so packages that were just built locally (in the hasher chroot) can be installed inside the dev environment alongside the packages from the normal repositories.
- Local builders: the builder's hasher output directory (
<hasher_dir>/repo) is mounted read-only into the container at the same host path; no copy is made. - Remote builders: the output repo is rsynced from the builder's host into
~/.cache/zoryn/devenv-repos/<builder>/repo/before the environment is prepared; the local cache is then used. - The builder's architecture must match the devenv architecture (
--arch); if they differ, zoryn exits with an error. - podman backend: a
sources.list.ddrop-in (zoryn-extra.list) is written into the container, leaving the configured base repositories intact. - bwrap backend: the repo line is merged with the existing apt sources (from
apt_config, or/etc/apt/sources.listwhen unset) and a generated apt.conf referencing the merged sources.list is passed tohsh --apt-config; the base repositories stay intact. - The repo line and repo directory are folded into the cache key, so changing or removing the flag rebuilds the environment.
- Mutually exclusive with
--with-task-repo.
Gyle task repo (--with-task-repo TASK_ID)¶
--with-task-repo TASK_ID downloads the RPMs produced by a gyle build task into a local cache and exposes them as an additive apt-rpm rpm-dir source inside the dev environment, so packages from an ongoing or completed gyle task can be installed alongside packages from the normal repositories.
- The task's RPMs are downloaded via HTTP into
~/.cache/zoryn/devenv-task-repos/<TASK_ID>/before the environment is prepared; the cache is reused on subsequent runs unless--no-cacheis given. - The downloaded RPM cache under
~/.cache/zoryn/devenv-task-repos/<TASK_ID>/is not removed byzoryn devenv clean; delete it manually when no longer needed. - The downloaded RPMs must include packages for the devenv architecture (
--arch); if no packages match that arch, zoryn exits with an error. - podman backend only — errors immediately on
--backend bwrapor any other non-podman backend. - Mutually exclusive with
--with-builder-repo. - The repo line and cache directory are folded into the cache key, so changing or removing the flag rebuilds the environment.
hasher (reserved)¶
The hasher backend name is reserved for future use. Passing --backend hasher currently exits with an error (project-mount scheme TBD).
Configuration¶
The [devenv] section controls which extra packages are installed and which backend is used.
Config locations¶
There are three places to put [devenv] configuration, each with a different scope:
| File | Scope | Accepted keys |
|---|---|---|
~/.zoryn | Global — all packages on this machine | backend, packages, image, branch, build, build_user, mounts, pids_limit, prompt, outbound_interface, forward_ssh_agent, apt_config, apt_builder, apt_sources, default_profile; [devenv.features.<id>] tables |
~/.config/zoryn/projects.d/<project>.toml | Per-project, machine-local | same as ~/.zoryn |
.gear/devenv | Per-package, committed, shared with the team | packages only |
.gear/devenv intentionally does not accept backend, image, branch, build, build_user, mounts, pids_limit, prompt, outbound_interface, forward_ssh_agent, profiles, or [devenv.features.<id>] tables — those are machine and developer choices, not something to encode in the repository.
Profiles¶
A profile is a named, fully independent [devenv] configuration, declared as [devenv.profiles.<name>] (in ~/.zoryn or projects.d/<project>.toml). Select one with --profile <name>/-p <name>; if omitted, [devenv].default_profile is used, and if that is unset only the base section applies. Use profiles to keep, say, a sisyphus and a p11 environment side by side and switch between them.
When a profile is active, every [devenv] key (backend, packages, image, branch, build, build_user, mounts, pids_limit, prompt, outbound_interface, forward_ssh_agent, apt_config, apt_builder, apt_sources, and [devenv.features.*]) is read only from [devenv.profiles.<name>]. The base [devenv] section is not consulted — its values are neither inherited nor merged into the profile. The base section applies only when no profile is active. A key absent from the active profile is simply unset, not borrowed from the base.
The only carve-out: a package's own BuildRequires (resolved from its spec) and packages committed in .gear/devenv are always installed regardless of the profile — they describe the package itself, not the developer's environment.
default_profile itself is always read from the base [devenv] (selecting a profile from within a profile would be circular).
The branch key sets the target ALT branch (default sisyphus); when image is unset it derives the base image registry.altlinux.org/<branch>/alt:latest. zoryn devenv --branch <name>/-B is a one-shot override that wins over the configured branch without editing config (handy to try another branch once). Each resolved profile (or --branch) produces its own cached environment (branch, packages and image are part of the cache key), so two coexist and both show up in zoryn devenv list.
How branch affects the env depends on backend: podman pulls the matching base image, so its apt repositories really are that branch's; bwrap uses hasher, which reads the host apt config, so branch only names/separates the cached chroot — point hasher at the branch's repositories with apt_config (below).
[devenv]
packages = ["gdb"] # used ONLY when no --profile is active
default_profile = "sisyphus" # used when --profile is omitted
[devenv.profiles.sisyphus]
branch = "sisyphus"
packages = ["gdb"] # independent: list it here too if you want it
[devenv.profiles.p11]
branch = "p11"
packages = ["vim"] # → vim only (base "gdb" is NOT inherited)
Features¶
A feature is a named, reusable bundle that contributes build packages, install steps, and bind-mounts to the development environment. It is defined as a feature.toml file.
Selection is a map, not a list. A feature is selected by adding its [devenv.features.<id>] table to your config. An empty table selects the feature with no options; keys inside the table are the feature's typed options.
[devenv.features.claude] # selected, no options
[devenv.features.opam] # selected, with an option (local opam feature)
version = "5.3.0"
Profile-aware (layer-select). Like every other [devenv] key, feature selection is per-layer — a profile is fully independent of the base:
- No profile active — only
[devenv.features.<id>]tables (the base layer) apply. - Profile active — only
[devenv.profiles.<p>.features.<id>]tables (the profile layer) apply; base[devenv.features.*]are not inherited.
[devenv.features.claude] # used only when no --profile
[devenv.profiles.p11.features.gdb] # used only with --profile p11 (claude NOT included)
Options for a feature come from its table in the active layer. Across files (global ~/.zoryn + project-local), ids are unioned and options are project-over-global — within the active layer.
A feature contributes:
packages— merged into the apt install alongside the spec'sBuildRequires:and your ownpackages.run— array of shell commands run as root (equivalent tobuild) during image build.run_user— array of shell commands run as the host user (equivalent tobuild_user) after theUSERswitch.mounts— extra bind-mounts added to the container.
Feature steps run before your own build/build_user commands (features-first). A feature's typed options are passed to its commands as environment variables: each option name is upper-cased and exported into the command's environment (e.g. channel → CHANNEL). The env prefix is applied to each run/run_user entry individually.
No ${VAR} substitution in the command text
zoryn does not perform ${VAR} substitution on the command string. Writing ${CHANNEL} literally in a run/run_user entry will not be expanded to the option value — the env prefix CHANNEL='latest' cmd does not make ${CHANNEL} visible to cmd's own argument list in POSIX sh (the parameter expansion happens before the assignment takes effect, so it is empty). The value reaches a program only through the environment: the command (or an installer it pipes into) must read $CHANNEL itself via getenv.
Inside a mounts entry, by contrast, an option can be referenced as ${NAME} (the upper-cased option name) and is substituted textually with the option value before the mount becomes a -v flag (e.g. ~/.x:~/.x:${CONFIG_ACCESS} with config_access = "ro" yields ~/.x:~/.x:ro). This is in addition to options being exported as env vars to run/run_user; it lets an option parameterise a mount (a -v spec is not a shell command, so the env-prefix mechanism does not apply there).
Resolution by id: a local definition at ~/.config/zoryn/devenv/features/<id>/feature.toml overrides the built-in with the same id.
Bundled features:
claude— installs the Claude Code CLI into your~/.local/bin; bind-mounts~/.claudeand~/.claude.jsonso your login and settings persist across container runs.GLM-claude-code— identical toclaude, but bind-mounts a separate profile: host~/.config/claude-glm→ container~/.config/claudeand host~/.claude-glm.json→ container~/.claude.json, keeping a GLM login/config apart from the defaultclaudeprofile.codex— installs the OpenAI Codex CLI via its official installer; bind-mounts~/.codexso its login persists.opencode— installs the opencode CLI; bind-mounts~/.local/share/opencode,~/.config/opencodeand~/.agentsso its login, config and agent definitions persist.kimi— installs the Kimi Code CLI (MoonshotAI) into~/.local/kimi(the binary lives outside the mounted state dir) and bind-mounts~/.kimi-codeso its login,config.toml, MCP servers and sessions persist.vim— installsvim-consoleandvim-plugin-spec_alt-ftplugin, and bind-mounts the host's vim config:~/.vimrcread-only, and~/.vimread-only by default. Set[devenv.features.vim] config_access = "rw"to mount~/.vimread-write so vim's runtime writes (swap, undo, netrwhist, spell, plugin data) don't fail.~/.viminfolives in$HOME, not~/.vim, so history persists regardless.ssh— installsopenssh-clientsand bind-mounts the host's~/.sshread-only, so keys,configandknown_hostsare available inside (e.g. for git-over-ssh and remote builders). Being read-only,known_hostsupdates from inside the container do not persist.zoryn— installs thezorynpackage from the container branch's repo and bind-mounts the host's zoryn config (~/.zorynand~/.config/zoryn, which holdsbuilders.d/) read-only, sozorynruns inside the environment against your host builders/settings without being able to modify them. The package ships its Agent Skill under/usr/share/zoryn/skills/, so no skill mount is needed. The package must exist in the target branch's repository.
This is machine config only — [devenv.features.<id>] tables are accepted in ~/.zoryn and ~/.config/zoryn/projects.d/<project>.toml, but not in the committed .gear/devenv. Podman backend only; a feature not declaring the current backend in its backends list is skipped with a warning.
Example local feature definition:
# ~/.config/zoryn/devenv/features/claude/feature.toml
id = "claude"
version = "1"
backends = ["podman"]
packages = ["curl"]
run_user = ["curl -fsSL https://claude.ai/install.sh | bash"]
mounts = ["~/.claude", "~/.claude.json"]
[options.channel]
type = "enum"
values = ["stable", "latest"]
default = "stable"
The channel option is exported as CHANNEL into the installer's environment, so the install.sh script picks it up if it reads $CHANNEL (a getenv); the value is not interpolated into the command string itself.
One-shot --feature¶
-F ID, --feature ID — One-shot: additionally enable devenv feature ID for this run, on top of the features from [devenv.features] or the active profile (deduplicated; nothing is removed). Repeatable. Unknown ID is an error. Options for a feature also present in config are reused; a feature enabled only via --feature uses its built-in defaults.
# Pull in the 'claude' feature for one shell without touching config
zoryn devenv --feature claude
# Several at once
zoryn devenv -F claude -F vim
Dependency set¶
The environment is populated from two sources:
1. The spec's BuildRequires:, resolved inside the environment in two passes (modelled on hasher's hsh-rebuild), so rpm macros and %if conditionals are expanded by a real rpmbuild for the target arch — not parsed textually on the host:
- Pass 1 installs
BuildRequires(pre):(the packages that define the macros, e.g.rpm-build-python3), read from the raw spec with macro tokens dropped. - Pass 2 runs
rpmbuild -bEon the spec (now that the macro packages are present), then installs the full expandedBuildRequiresset. This also picks up dynamic build dependencies injected by build-body macros (%pyproject_build,%set_gcc_version, …): they accumulate in%_buildrequires_buildrather than the preamble, so zoryn expands a copy of the spec with%{?_buildrequires_build}appended after the body and installs what that dump reveals (e.g.python3-module-pyproject-installerpulled in by%pyproject_*).
rpm-build is always present in the environment so the spec can be expanded. Capability requires (pkgconfig(...), perl(...)) are resolved by apt (podman) / hasher (bwrap). Version constraints are dropped — a dev environment installs by name. Packages whose scriptlets insist on a hasher environment (e.g. rpm-build-vm-run, which checks for /.host, /.in, /.out) get those marker directories created in the podman container first so they install (the bwrap backend is already a hasher chroot). When the spec needs KVM for a VM-based build (a /dev/kvm BuildRequires, or a KVM-pulling package like rpm-build-vm-run) and the host has /dev/kvm, it is passed into the podman container via --device /dev/kvm.
2. Configured extra packages — the union (deduplicated) of packages from [devenv] in ~/.zoryn (your personal toolkit, e.g. gdb, vim, strace), .gear/devenv (team-shared extras), and ~/.config/zoryn/projects.d/<project>.toml (machine-local per-project extras).
Editing the spec recreates the environment (its digest is part of the cache key).
For template specs that use gear specsubst placeholders (e.g. kernel-image's Name: kernel-image-@kflavour@), the @var@ values are expanded before resolution — exactly as zoryn build does for a short-circuit section build — taking each value from git config gear.specsubst.<var> or the [specsubst] section of .gear/version-up. If a value is unset, zoryn devenv stops and tells you to set it.
Backend resolution order¶
--backend flag → ~/.config/zoryn/projects.d/<project>.toml → ~/.zoryn → an already-cached environment for this package → default (podman if installed, else bwrap).
When you express no preference, zoryn devenv re-enters an existing cached environment automatically: if exactly one of podman/bwrap has a prepared env for the current dependency set, that backend is used. So after building a podman environment once, a bare zoryn devenv enters it without rebuilding. When nothing is cached either, the default backend is podman when podman is installed, falling back to bwrap otherwise.
Image resolution order (podman only)¶
--image flag → ~/.config/zoryn/projects.d/<project>.toml → ~/.zoryn → registry.altlinux.org/<branch>/alt:latest.
Set image (or pass --image) when the default is not pullable on your host (e.g. you maintain a local mirror or use a registry with a different tag scheme).
~/.zoryn example¶
[devenv]
backend = "bwrap"
packages = ["gdb", "vim", "strace", "ltrace"]
# image = "registry.example.com/alt/sisyphus" # override when the default isn't reachable
# build = ["go install golang.org/x/tools/cmd/goimports@latest"] # podman: extra image-build RUN steps
.gear/devenv example¶
~/.config/zoryn/projects.d/<project>.toml example¶
zoryn devenv install¶
zoryn devenv install gdb strace # add gdb and strace to the prepared env
zoryn devenv install --save heaptrack # add heaptrack and remember it in .gear/devenv
zoryn devenv install --backend podman ltrace
zoryn devenv install --container blender-abc123 gdb # into a specific container
zoryn devenv install --container . gdb # into the current directory's container
Installs extra packages into the current package's prepared environment (the cached base), so the next zoryn devenv shell has them without a full rebuild. This host-side command exists because the bwrap dev shell is unprivileged over a read-only chroot — you cannot apt-get from inside it.
If no environment has been prepared yet, install prepares it first (the full resolved dependency set, exactly as zoryn devenv would), then installs the extra packages on top. For the podman backend it installs them live into the running container as in-container root (podman exec --user root … apt-get install), with no image rebuild; for bwrap it installs into the cached chroot.
--container¶
By default the target environment is computed from the current directory's resolved config — the same cache key plain zoryn devenv would use. --container CONTAINER (podman only) instead installs straight into that existing container (name/ID as shown by zoryn devenv list, or . for the current directory's container), resolving no project config — it works from any directory, like zoryn devenv enter. Use it to target a container whose cache key differs from the plain config, e.g. one created with a one-shot --mount-bind. Environment-selection flags (--backend, --image, --apt-conf, …) are ignored with --container.
--save still writes the current directory's .gear/devenv, so combining it with --container is allowed only when the container belongs to the current directory (its zoryn.project label matches); otherwise the command refuses before installing anything.
--save¶
Also append the named packages to [devenv].packages in .gear/devenv as a sorted, deduplicated union with the existing entries. This makes them survive a cache rebuild and shares them with the team (the file is committed). Other keys in the file are preserved. If there is no .gear/ directory, the save is skipped with a warning.
The hasher backend is not supported here (same project-mount scheme TBD error as zoryn devenv).
zoryn devenv feature add¶
Adds one or more devenv features to the current project's running podman container in place — without recreating it. This is the counterpart to zoryn devenv install for features: the feature's packages are installed and its run/run_user steps executed live, via podman exec, in the container you are already using.
Unlike --feature/-F (which folds the feature into the environment's identity and so recreates the container to build it in), feature add acts on the running container directly. The effect is therefore ephemeral — like packages added with zoryn devenv install, it lives only in the container and is dropped on the next full rebuild (--rebuild, or zoryn devenv clean).
Constraints:
- Podman only. If there is no prepared environment yet, run
zoryn devenvfirst. - A feature that declares a bind-mount cannot be attached to an already-running container and is refused — use
zoryn devenv -F IDinstead, which recreates the environment with the mount in place. - An unknown feature id is an error.
zoryn devenv clean¶
zoryn devenv clean # remove the cached env for the current package
zoryn devenv clean --backend podman # target the podman-backend cache specifically
zoryn devenv clean --all # podman: remove ALL of this project's envs (any cache key)
zoryn devenv clean --project /path/to/proj # podman: remove a named project's envs, from anywhere
Removes the cached base for the current package's dev environment so the next zoryn devenv rebuilds it from scratch.
- bwrap: runs
hsh-rmchrooton the chroot (required because the chroot subdirectory is owned by hasher-priv subuids and cannot be removed with plainrm), then removes the entire cache directory. - podman: removes the persistent container with
podman rm -fand the built image (zdevenv-<pkg>-<branch>:<key>) withpodman rmi -f.
The environment targeted is the one matching the current resolved backend, dependency set, architecture, branch, and image. Switching --backend, branch, or image (via config) targets a different cached environment.
--all (podman)¶
A single clean only removes the one environment matching the current config. Changing config that feeds the cache key — prompt, extra packages, mounts, outbound_interface, image — produces a new image + container and leaves the previous one running, so stale environments accumulate over time.
zoryn devenv clean --all removes every dev environment of the current project at once, regardless of cache key. Containers are tagged at creation with a zoryn.project=<project-dir> label; --all removes all containers carrying the current project's label and then rmis their backing images (non-force, so an image still referenced by another project's container survives). Other projects' environments are never touched. Containers created before this labelling was introduced lack the label and must be removed manually (podman rm -f <name>).
--project PATH (podman)¶
zoryn devenv clean --project PATH removes every dev environment of the project at PATH — its directory path (the zoryn.project value shown by zoryn devenv list) or its basename — from anywhere, without being in that directory. Useful to reclaim disk from another project's environments straight off the list output. It needs no prepared environment or resolvable config in the current directory (so it works even when the current package's devenv config can't be resolved). Backing images are rmi'd non-force. Overrides --all and the current-directory target.
--all applies to the podman backend only; for bwrap it removes just the current chroot (bwrap chroots are shared by dependency set, not per-project).
If no cached environment exists, clean is a no-op and prints a notice.
The hasher backend is not supported (same project-mount scheme TBD error as zoryn devenv).
zoryn devenv list¶
Lists every dev environment zoryn created, across all projects and cache keys — useful for finding stale ones that accumulated as configs or dependencies changed (each distinct config gets its own environment).
- podman: every container carrying the
zoryn.devenvlabel, shown as<project-dir> <container-name> <status>. - bwrap: prepared chroot cache directories under
~/.cache/zoryn/devenv/(content-addressed by dependency set, so no project name — just the path).
To remove environments: zoryn devenv clean --all (from a project, removes that project's podman environments), zoryn devenv clean (the current config's), or podman rm -f <name> for a specific container.
zoryn devenv enter¶
zoryn devenv enter # enter the current directory's container
zoryn devenv enter <CONTAINER> # enter an existing container by name/ID
zoryn devenv enter <CONTAINER> --root # enter as root
zoryn devenv enter <CONTAINER> -u 1000 # enter as a specific user
zoryn devenv enter <CONTAINER> -- make # run a command instead of a shell
zoryn devenv enter . -- make # run a command in the default container
Enters an already-created podman dev-environment container directly by its name or ID (as shown by zoryn devenv list), starting it first if stopped. Unlike plain zoryn devenv, this resolves no project config and need not be run from any package directory — handy to jump straight into a specific container (e.g. one belonging to another project). The shell lands in the container's configured working directory (the project it was created for).
CONTAINER may be omitted: the (newest) container created for the current directory is entered, falling back to the only existing container when the directory matches none. . is an explicit placeholder for the same default — use it to combine the default container with a command, as in zoryn devenv enter . -- make.
Only containers created by zoryn devenv (carrying the zoryn.devenv label) can be entered; any other name is rejected. --root is a shortcut for --user root (and wins over an explicit --user). Pass a command after -- to run it instead of an interactive shell.
There is no --mount-bind here: podman cannot attach a bind-mount to an existing container. To add or change mounts, run plain zoryn devenv --mount-bind SPEC from the project directory — the container is recreated with the new mounts (image layers are reused, so it is cheap).
zoryn devenv remove¶
Removes a single podman dev-environment container directly by its name or ID (as shown by zoryn devenv list), together with its backing image (removed non-force, so an image still referenced by another container survives). Resolves no project config and need not be run from any package directory. Only containers created by zoryn devenv (carrying the zoryn.devenv label) can be removed; any other name is rejected.
Use this to drop one specific environment from the list output; zoryn devenv clean --project PATH removes all of a project's environments at once, and zoryn devenv clean removes the one matching the current config.
zoryn devenv inspect¶
Prints the zoryn config recorded on a podman dev-environment container at creation — its cache key, image, branch, architecture, configured packages, selected features, mounts, build/build_user steps, prompt/outbound_interface/apt_config, and the spec digest. These are stored as zoryn.* container labels at creation, so each environment self-describes what produced it.
It also prints the resolved BuildRequires actually installed, recorded inside the container during preparation and split by hasher pass — build_requires(pre) (pass 1, the BuildRequires(pre) macro-defining packages) and build_requires (pass 2, the full expanded set) — so the two stages can be told apart and compared independently.
Use it to understand an opaque entry in zoryn devenv list, or to compare two environments of the same package — inspect both and diff the output to see exactly which input differs (e.g. a changed spec_hash, an added feature, or different packages/build_requires). Containers created before this labelling show only the base labels. (The label data is also in podman inspect <name> --format '{{json .Config.Labels}}'; the build-deps are in /var/lib/zoryn-devenv/ inside the container.)
The spec is not part of the podman cache key. A podman environment's identity is its config (image, branch, packages, features, mounts, build steps, …) — not the spec — so editing the spec never creates a new container. Instead, on the next zoryn devenv the existing container is reused and its build dependencies are reconciled:
- zoryn compares a digest of the spec up to
%changelog(spec_hash, recorded inside the container asbr_digest). Unchanged → enter immediately; changed → re-resolve, install the delta, and update the record. - On a change it re-resolves the spec's
BuildRequiresinside the container andapt-installs only the missing ones (idempotent — already-present packages are a no-op). - The build body is part of the digest because it drives dynamic
BuildRequires(%pyproject_buildand friends), so editing it re-resolves; only%changelog(high-churn, dependency-irrelevant) is excluded. - Adding a
BuildRequiresjust installs it — no rebuild. RemovedBuildRequiresare left installed (harmless for a dev env;zoryn devenv cleanfor a clean slate).
The bwrap backend still keys on the spec (its chroot is rebuilt on a spec change rather than reconciled).
zoryn devenv profile list¶
zoryn devenv profile list # names only
zoryn devenv profile list --verbose # each profile's resolved config
Prints the name of every [devenv.profiles.<name>] profile defined in the machine config (~/.zoryn) and the per-project local config (projects.d/<project>.toml), one per line, sorted and deduplicated. Profiles that appear only through a nested [devenv.profiles.<name>.features.<id>] header are listed too.
With --verbose, prints each profile's effective [devenv] configuration instead — the keys and features it resolves to (backend, image, branch, packages, build, features, …), merging the machine and per-project local configs the same way the runtime does. Only set keys are shown.
The plain form is what shell completion calls to offer the right values on zoryn devenv --profile <TAB>, so the completion always reflects your actual configured profiles instead of a hardcoded list.
Caching¶
Prepared environments are cached under ~/.cache/zoryn/devenv/<key>/ (bwrap), where the key is derived from the dependency set, backend, architecture, branch, and — for the podman backend — the base image. Changing image therefore targets a different environment rather than reusing a stale one. A cached environment is reused on subsequent launches without reinstalling packages.
The architecture is part of the key, but the environment is always built for the host architecture — there is no cross-architecture emulation.
For the podman backend, the cache is a built image (zdevenv-<pkg>-<branch>:<key>) plus a persistent container (zdevenv-<pkg>-<branch>-<key>) created from it — not a directory. The container is reused — restarted if stopped — on every launch; zoryn devenv clean removes both the container and the image.
The container's project bind-mount is fixed when it is first created. The cache key does not include the working directory, so if the package directory later moves (or you run zoryn devenv for it from a different absolute path), the reused container still binds the original path and your edits won't be visible inside. Run zoryn devenv clean (or --rebuild) to recreate it against the new location.
Pass --rebuild to drop the cached environment and recreate it (for podman, this rebuilds the image and recreates the container); add --no-cache to also bust podman's build-layer cache so every layer (apt dist-upgrade, deps) is re-run. On its own, --no-cache is just a build modifier — it neither forces a rebuild nor deletes anything. Rebuilding is useful after a BuildRequires: change or when the base image has gone stale since the env was prepared.
See also¶
- Configuration —
~/.zorynreference - Sandbox — bubblewrap and hasher-based isolation used by zoryn hooks