Env Surface
The environment variables your package declares are the contract between you and every consumer. Get the surface right and dependent packages compose cleanly; get it wrong and consumers chase phantom paths or leak internal state into their shells. This page covers the publisher decisions: which variables to declare, which to mark visible, and how to migrate packages that pre-date the entry-visibility field.
Path Variables vs Constants
Most env entries fall into one of two patterns: path variables that prepend a directory onto an existing PATH-like list, and constant variables that replace the value outright. The distinction matters at composition time. Path entries from multiple packages stack cleanly — bin/ from one package, bin/ from another — both end up on the consumer's PATH. Constants don't stack: when two packages both declare JAVA_HOME, the last one in dependency order wins. The full composition model lives in the environments in depth page.
Pick path for anything that is a directory list (PATH, MANPATH, LD_LIBRARY_PATH, PKG_CONFIG_PATH). Pick constant for tool-prefix vars (JAVA_HOME, CARGO_HOME, CMAKE_ROOT) and version markers. Neither shape fits an option-list variable — see Appending Option Lists below. The metadata reference documents all three shapes in full.
Appending Option Lists
Some variables are neither a directory list nor a single value — they are a flat, ordered list of flags a runtime reads all at once: JDK_JAVA_OPTIONS, JAVA_TOOL_OPTIONS, NODE_OPTIONS, Go's GODEBUG. path joins with the platform path separator and prepends — wrong on both counts for a space- or comma-joined option string. constant replaces the whole value outright — the flags your dependency's own package declared for itself vanish the moment yours sets the same key.
list fits: it appends the contribution, joined by an author-chosen separator, removing any earlier occurrence of the same contribution first. Two packages contributing -Xmx1g and -ea to JDK_JAVA_OPTIONS both survive; if the same package re-declares one of them (a re-install, a launcher re-entry), the value does not grow.
{
"key": "JDK_JAVA_OPTIONS",
"type": "list",
"separator": " ",
"value": "-Xmx2g",
"visibility": "interface"
}separator is not optional here the way it is in ocx.toml or --env: package metadata is the wire, where no human is present to be told what was assumed, so a publisher must spell it out explicitly. It must be non-empty and must not contain =, a newline, or a carriage return — the last two because every downstream export (a CI env file, a shell snippet, a JSON-lines record) is line-oriented, and a separator ending a line stops being a delimiter. See Separator Is Required in the metadata reference for the full rule, including the per-key agreement across a composition.
Does the consumer actually resolve duplicates last-wins?
list appends. Whether the last contribution wins depends entirely on how the consuming tool resolves duplicates in that variable — OCX never parses list elements, so it cannot enforce or verify this on your behalf. A short survey of common option-list variables:
| Variable | Consumer behavior |
|---|---|
JDK_JAVA_OPTIONS, NODE_OPTIONS (scalar flags), GODEBUG | Last occurrence wins — append order gives the override you expect. |
RUST_LOG | Most-specific target wins, not append order — a broader directive appended after a narrower one (warn after my_crate::noisy=trace) does not silence it. Treat list on RUST_LOG as layering directives, not overriding them. |
-I/-L search-path flags on a compiler command line | First occurrence wins — appending a later -I adds a fallback search directory, never an override. The polarity is the reverse of the other rows in this table. |
NODE_OPTIONS has no quoting mechanism
Node.js accepts no escaping in NODE_OPTIONS — a value that would need quoting on a real command line (a path containing a space, for example) breaks for the consumer with no fix available on the producer side. Keep list values for NODE_OPTIONS free of whitespace.
JDK_JAVA_OPTIONS does not have this problem: the JVM launcher itself defines a quote grammar — single or double quotes wrap an argument containing whitespace, and the launcher strips the pair before use. A publisher who needs a space in a contribution quotes it the same way they would on a command line; OCX's dedup never parses list elements, so a quoted value passes through the fold intact. The quoting is the JVM's own — not something list or its separator need to account for.
Interpolation Tokens
An env value cannot hardcode an install path. Content-addressed storage puts the same package at a different path on every machine, and even a version bump on one machine moves it. ${...} tokens are how a value names "wherever OCX put me," or "wherever my dependency landed," without knowing either path at authoring time.
Four bodies are recognized. The install-path ones take an optional :native/:posix render modifier that controls path-separator rendering only; ${self.env.KEY} takes none, because OCX cannot know whether the referenced variable holds a path:
${installPath}(and its exact alias${self.installPath}) — this package's owncontent/directory. Takes a modifier.${self.env.KEY}— the resolved value of this package's ownKEYvar, declared earlier in the sameenvarray. No modifier; render at the declaring var instead.${deps.NAME.installPath}— a declared dependency'scontent/directory, whereNAMEis the last path segment of the dependency's OCI repository or its explicitnamefield. Takes a modifier.
${deps.NAME.installPath} is the publisher's escape hatch for declaring "I need to find my dependency's files." A wrapper package that bundles a configuration generator and points at its dependency's binary uses ${deps.cmake.installPath}/bin/cmake-gen to keep the path stable across registry layouts:
{
"env": [
{ "key": "MYTOOL_HELPER", "type": "constant", "value": "${deps.cmake.installPath}/bin/cmake-gen", "visibility": "public" }
]
}${...} is a closed namespace: every occurrence must parse as one of the four bodies above, or OCX refuses it rather than passing it through. That matters the moment a value needs to ship another tool's own ${...} syntax verbatim — devcontainer.json variables use the identical ${...} shape for ${workspaceFolder} and ${localEnv:VAR}, and OCX would otherwise try to resolve them as its own tokens and refuse the whole value. Write $${ to emit a literal ${ — $${workspaceFolder} publishes as the literal text ${workspaceFolder}. This is the only escape.
OCX validates every reference both locally during ocx package create --metadata <file> (no network needed) and again during ocx package push — a typo gets caught before the manifest reaches the registry. See Interpolation Tokens in the metadata reference for the full grammar and the exact refusal a bad token produces.
A ${self.env.KEY} chain has a byte ceiling
Each ${self.env.KEY} substitutes the referenced var's already-resolved value, so a chain that repeats a reference doubles with every hop it crosses. A resolved value is capped at 64 KiB — a chain that would push one var's resolved value past that cap is refused, exit 65, at composition time, rather than allowed to keep growing on every consumer that resolves it.
Choosing Visibility
Each env entry carries a visibility field that controls which surface it contributes to. The model is two surfaces, not a single visibility flag:
- Interface surface — what consumers see when they run
ocx package exec mypkg -- <cmd>or composemypkgas a dependency. PATH entries markedpublic,JAVA_HOME, every variable a downstream caller depends on lives here. - Private surface — what the package's own generated launchers see at exec time. Internal flags, lock-file paths, and any variable a consumer should never observe live here.
Three values map onto the two surfaces:
| Value | Interface surface | Private surface | Use case |
|---|---|---|---|
private (default) | No | Yes | Internal paths the package's own launchers need; not part of the public contract. |
public | Yes | Yes | Variables consumers should see — PATH, JAVA_HOME, tool-specific prefix paths. Both surfaces. |
interface | Yes | No | Values forwarded to consumers but not used by the package's own runtime — PKG_CONFIG_PATH, library include hints. |
The sealed value is rejected at parse time on env entries — a declared entry that contributes to neither surface is dead configuration. The deeper mechanic — how publisher-declared visibility composes with dependency-edge visibility during the resolution walk — lives in environments in depth.
Most variables are public
For a typical bare-binary package (cmake, node, uv), the entries you declare are exactly the ones consumers need: PATH, the tool's *_HOME, occasionally a MANPATH. Mark them all "visibility": "public". The encapsulation dividend kicks in for additional internal vars added later — those default to private and stay hidden from consumers without any extra annotation.
Last-Wins for Constants
When two packages on the same composition both declare the same constant variable (for example, two Java distributions each declaring JAVA_HOME), exactly one wins in ocx package exec / ocx env: the last one in topological dependency order. The first declaration is replaced silently in the main composition path. The full rule, including how transitive resolution preserves order, lives in environments in depth. (The ocx ci export command runs an extra ConstantTracker pass that does emit a warning when truly unrelated TC entries collide.)
Treat conflicting constants as a publisher signal. If your package declares JAVA_HOME and a sibling package already does too, the deployment is asking two tools to share one slot — the consumer needs to pick one to depend on and seal the other's env, not both publishers fighting over the same key.
Migrating from Implicitly Public
Entry visibility (private / public / interface on each env entry) arrived with the entry-points feature release. Before that, all env vars were implicitly public — every declared variable reached consumers without annotation.
The migration cost is a one-time annotation pass on your metadata.json. Most tools want PATH, JAVA_HOME, and similar vars visible to consumers — mark those "visibility": "public". Any var you add after the migration that you intentionally want private gets no annotation (the default is private). That is the encapsulation dividend: new internal vars stay hidden without any extra work.
This breaking change ships in the same release as entry points — one migration window, not two.
What the diff looks like
Before (no visibility field — each var was implicitly public):
{
"type": "bundle",
"version": 1,
"env": [
{ "key": "PATH", "type": "path", "value": "${installPath}/bin" },
{ "key": "JAVA_HOME", "type": "constant", "value": "${installPath}" },
{ "key": "MANPATH", "type": "path", "value": "${installPath}/share/man" }
]
}After (explicit "visibility": "public" on every var that consumers should see):
{
"type": "bundle",
"version": 1,
"env": [
{ "key": "PATH", "type": "path", "value": "${installPath}/bin", "visibility": "public" },
{ "key": "JAVA_HOME", "type": "constant", "value": "${installPath}", "visibility": "public" },
{ "key": "MANPATH", "type": "path", "value": "${installPath}/share/man", "visibility": "public" }
]
}Vars without a visibility field now default to "private" — they reach the package's own launchers but not consumers. If your package has no declared entrypoints and relies entirely on consumers invoking ocx package exec PKG -- cmd, every var a consumer needs must be explicitly "public".
Decision guide
| Variable pattern | Recommended visibility |
|---|---|
PATH pointing at ${installPath}/bin | "public" (consumers need it on their PATH) |
JAVA_HOME, CMAKE_ROOT, tool prefix vars | "public" (consumers reference these by name) |
MANPATH, PKG_CONFIG_PATH, ACLOCAL_PATH | "public" if consumers need the content; "interface" if the package itself does not use these paths |
Internal flags (_MY_TOOL_INIT, lock files, IPC paths) | "private" (default — no annotation needed) |
Packages with entrypoints
If your package declares entrypoints, consumers reach the launchers via each installed package's entrypoints/ directory — OCX prepends it to PATH automatically at exec time (consumers see the path as <symlink-root>/current/entrypoints). You do not need to keep PATH += ${installPath}/bin as "public" for consumer PATH resolution once entrypoints are declared. The ${installPath}/bin path entry can be "private" (launcher-only) if the entry-point launcher is the sole intended consumer interface.
See Also
envreference — every field on aenventry- Environments in depth — composition order, edge filter, conflicting constants
- Entry points — when entrypoints replace exposed
PATHentries - Dependency-edge visibility — how dep declarations propagate env