recipes: map plus an optional default:. Registries
resolve bundled → ~/.dabs/recipes.yaml (global) → ./dabs.yaml
(project), later winning by name.
Field notes
- image — a bare name reuses
~/.dabs/images/<name>(built from a bundled image recipe on first use). An inline{dockerfile, context}builds withdabs build; the image is named after the recipe, so two recipes sharing one Dockerfile still build two tags. - command — must not bake in agent instructions; recipes provision, callers prompt.
- sources — exactly one origin kind per entry (
mount/mkmount/copy/worktree). Space variables$NODE_VOLUME|HELD|TMPand$PARENT_VOLUME|HELD|TMPname HOST origins, and substitute in a source’s origin path only. ($NODE_EPHEMERAL/$PARENT_EPHEMERALare permanent aliases for the held variants.) Mounts land parent-before-child regardless of declaration order. $NODE_IDin box paths — a source’s destinationpath:and the recipe’sworkdir:may name$NODE_ID(or${NODE_ID}), which expands to the id of the box being provisioned, so a recipe can auto-namespace what it mounts per box (path: /$NODE_ID). It is the only variable a box path may name — a space variable mixed into one is still rejected — and the id is validated as a slug before it is substituted, so$NODE_IDcan never become a traversal out of the directory the path names. See Sources.- publish — grants the box the ability to publish services (
forward publish <name> --type webui|general --port <n>inside it, reached from the host withdabs services). It grants the ABILITY, not a list of names: what a box publishes is chosen at run time by the program in it. dabs realizes the grant as the box’s door — one dabs-owned unix socket, answered on the host by a relay that lives as long as the box’s node — so it needs a box (a recipe with noimage:is refused) and a local target (a box on another machine has no path to a relay on this host). Without the grant there is no door, andforward publishin the box refuses by name instead of failing on a missing file. - user — who the box’s processes run as, written
uidoruid:gid, and numeric: a name is resolved in the image’s own/etc/passwd, so it would say nothing about which host user ends up owning what the box writes. Unset leaves the image’s user, which for an image naming noUSERis root. Set it when the HOST reads what the box writes: amount:/mkmount:carries uids, not names, so under the docker driver — whose daemon is root — a root box leaves root-owned files that the ordinary user who booted it cannot open. The bwrap and apple drivers need nothing here and ignore it: a bwrap box is already a process of your own inside a namespace that maps only your uid, and the apple driver’s micro-VM translates ownership across its own boundary (and its boxes must be root to open a relayed socket at all). - target —
""(local) or a server fromdabs servers. The reservedINTERNAL-docker-privileged-for-nested-sandboxingtarget exists for boxes that must boot nested sandboxes. - boxless recipes — a recipe with no
image:provisions its places (a worktree, a copied dir) and stops;wtandscratchare the bundled examples.
Egress
egress controls the box’s outbound network. As a scalar it is open (full
outbound, the default when unset) or none (no network). As a mapping it
routes ALL egress through the dabs proxy engine, which enforces two independent
layers:
- Policy —
allow/deny(protocol-agnostic). The engine checks the CONNECT host against these patterns on the plaintexthost:port, before any tunnel, for every protocol (HTTPS, WebSocket, raw TCP — HTTP cannot skip it).allowdefault-denies the rest;denydefault-allows it; they are mutually exclusive. A pattern isexample.com(exact),*.example.com(any subdomain, not the apex), or*(all). Write a comma-separated string or a YAML list. - Content —
http_proxy(HTTP/1.1 only). An ordered chain, box→internet, oftlsboundary directives and hookmodules. Atls: terminateopens a plaintext window (optionally scoped todomains:) that atls: originatecloses; module hooks inside it inspect decrypted HTTP through four streaming verbs (onRequest,onRequestChunk,onResponse,onResponseChunk). Bodies stream through chunk by chunk — the engine never buffers. Non-HTTP traffic inside a terminate window (h2, WebSocket over TLS, any raw protocol) is re-originated untouched, so terminating a domain never costs the box its other protocols to it. Module paths resolve like sources: relative to the recipe’s owndabs.yaml. - Connection — a
moduleOUTSIDE anytlswindow (protocol-agnostic). Such a hop sees no content; it acts on the connection through a fifth verb,onConnect({host, port}, ctx), returning nothing (or{action: "allow"}) to let the dial through and{action: "deny", reason}to refuse it with a 403. It runs on both proxy paths after theallow/denygate has already allowed the host, so it can only narrow the recipe’s policy, never widen it. Hops run chain order and the first deny wins; a hook that throws, hangs, or answers with anything else denies the connection. A refusal is recorded in the connection ledger — the hop’s name and its length-capped reason — and nowhere else. A hop out here that also exports content verbs is warned about at boot: those verbs still need atls: terminateto run. - Address (protocol-agnostic, not configurable in a recipe). Whenever the
engine opens a connection for the box, it resolves the name once, checks every
address it gets, and connects to an address it checked. It never connects to
a loopback, unspecified, private (RFC 1918), shared (
100.64.0.0/10), benchmarking, link-local, multicast, reserved or unique local address, to an IPv6 address that carries one of those IPv4 addresses (IPv4-mapped, NAT64, 6to4), to IPv4-compatible, IPv4-translated, local-use NAT64 or Teredo IPv6 addresses, or to any address on a network of one of the host’s own interfaces. If any address of a name is refused, the name is refused. It tries the addresses in order and gives each 5 seconds to answer. The box gets a 403 with the reason in the body and in theX-Dabs-Egress-Refusedheader (inside the TLS stream for a terminated host, or a closed stream for a non-HTTP one), and the connection ledger gets adeny-addressline with the address. A ledger line for a connection that opened (tunnel,forward,originate) is written after it opened and names the address, or the upstream in upstream mode, so a refused connection shows only as refused. A tunnel opens before the box gets its 200. If the box closes before sending anything, or sends nothing within 10 seconds of the 200, the engine closes the CONNECT and its tunnel and writes ano-helloline with the reason. A ClientHello that carries Encrypted Client Hello is dropped with anech-dropline. For a tunnel, both lines name the address too. A name that does not resolve, or whose addresses do not answer, gets a 502 with the reason inX-Dabs-Egress-Failedinstead, so a client can tell a failure from a refusal. A request a hook answers itself opens no connection, so it is never refused for its address. The host operator can open given addresses withDABS_EGRESS_ALLOW_LOCALin the environment dabs runs in: a comma-separated list of addresses or CIDR prefixes, announced as a warning at boot.
DABS_UPSTREAM_PROXY=http://host:port in the environment dabs runs in makes the
engine reach every destination with a CONNECT through that HTTP proxy, and never
dial the network itself. It is for a host whose only way out is another proxy,
such as a VM with a closed network. In that mode the engine checks IP literals
(against the same table) and refuses localhost names, but does not resolve
other names: the upstream proxy must refuse a name that resolves to a local
address. dabs starts the engine without HTTP_PROXY, HTTPS_PROXY, ALL_PROXY
or their lowercase forms in this mode, so a hook’s own requests cannot leave by
another proxy. A malformed value stops the boot. When the upstream answers a
CONNECT with a 4xx or 5xx, the box gets that status, its body and its
X-Dabs-Egress-Refused or X-Dabs-Egress-Failed header, so a refusal by an
upstream dabs engine reaches the box with its reason. A 407 becomes a 502.
Proxy egress needs bun and openssl on the host PATH, and a forwarder binary
to mount in the box: the CLI carries one embedded (-tags withforwarder), and a
program embedding dabs as a Go module — which has no embed — supplies its own
with actions.Real.WithForwarder(path), any binary speaking the forwarder
protocol. An explicit supply wins over an embed. Without either, open/none
still work and proxy egress fails clearly at boot.
egress: open has a host dependency of its own on Linux (the bwrap driver): the
box’s network namespace is built by pasta, so the passt package must be
installed and dabs must run as an unprivileged user. Without either, an
open-egress boot fails clearly, naming the package. pasta must be snapshot 2025_05_03 or newer, for the address flags dabs passes: Debian trixie+ and Fedora 41+ package a new enough one, Ubuntu’s and Alpine’s current packages refuse those flags, and there you build from passt.top at the version contrib/recipes/dabseption.Dockerfile pins. egress: none needs
nothing.
The linux drivers mount that binary into the box. The apple driver mounts no
host binary — a host one cannot run in the linux micro-VM — so there the image
carries the forwarder at /run/dabs/forward (the boot probes for it, and the
error names the Dockerfile lines that add it), and the image’s copy is what
runs.