on-activate hook calls out to a secret store and exports the result as an
environment variable. That pattern works, but every environment that uses it
hand-writes the same retrieval script.
Plugins let you package that script once, as a regular installable
package, and configure it per environment through a dedicated [plugins]
section of the manifest. Anyone who installs the package gets the retrieval
logic; they only need to supply the configuration.
Secrets retrieval is the use case that motivated plugins, and this page
anchors on it. But [plugins] itself is general-purpose: Flox stores
whatever data you put there without interpreting it, so a plugin can use
it for anything. And a plugin isn’t limited to running a script at
activation — through lifecycle hooks, a plugin can
participate in the whole life of an environment: wrapping the session,
injecting variables into every attaching shell, running a daemon for the
activation’s lifetime, or cleaning up at teardown.
Sandboxing is the flagship consumer of those
hooks. See Beyond secrets for other examples.
How plugins work
A plugin has two halves:- Configuration lives in the manifest, under
[plugins.<plugin-name>]. Flox treats it as opaque data — any keys, any values — and stores it without validating its shape. - Behavior lives in a package, at well-known paths inside its output:
- a script in
etc/profile.d/, sourced during activation — the standard way packages hook into shell setup, and the only payload most plugins need. See Activating environments for where this fits in the activation timeline. - optionally, executables and scripts under
etc/flox/hooks/, the hook tree, which let a plugin participate in other phases of the environment’s lifecycle.
- a script in
profile.d script reads its own configuration with the
flox_plugin_data shell function, which Flox provides during activation.
Hook executables receive the same table through a context file instead,
since they run outside the activation shell. Nothing else ties a package
to a plugin — it’s a naming convention, not a manifest field that marks a
package as one.
The environment lifecycle
A Flox environment moves through phases — it’s created and edited, locked and built, activated, attached to by additional shells, and eventually deactivated. Each extension point below is a place where a plugin can participate. Theprofile.d convention covers the most common need
(setup at activation start); the rest are lifecycle
hooks.
Extension points for the remaining phases (init, lock, push/pull,
containerize, services) are named in the design but deliberately not
built until something needs them.
Installing and configuring a plugin
Installing a plugin is the same as installing any package, plus one step: adding its configuration table. Suppose avault-secrets package provides a plugin that wraps HashiCorp
Vault. Install it, then add a [plugins.vault-secrets] table following
the convention its author documented — here, a flat map of environment
variable name to secret path:
flox activate, and GH_TOKEN and DB_PASSWORD are exported, fetched
fresh from Vault — the same result as a hand-written on-activate hook,
except the retrieval logic now ships with the package instead of living in
your manifest.
Add a [plugins.<name>] table without installing a matching plugin, and
nothing happens — Flox doesn’t cross-reference the two. What happens if
you install a plugin but skip its configuration is up to the plugin: a
script that lets flox_plugin_data’s failure propagate aborts activation;
one that checks for it explicitly can warn and continue instead. See
Writing a plugin for both patterns.
Plugins that use lifecycle hooks need one more piece:
a declaration in the [plugin-hooks]
section. Unlike [plugins.<name>] data, hook participation is
cross-referenced — a declaration without a matching installed package
fails the activation, and a shipped hook without a declaration is
ignored with a warning.
Writing a plugin
Any package can be a plugin. What makes it one is aprofile.d script that
reads its own manifest data:
etc/profile.d/0900_vault-secrets.sh
flox_plugin_data <plugin-name> prints the [plugins.<plugin-name>] table
from the locked manifest as compact JSON, or fails if the table is
missing. Parse the JSON however you like — ${_jq:-jq} reaches for the
jq that Flox’s own activation helpers already resolved into $_jq
before falling back to a jq on PATH, so your script doesn’t need to
depend on one itself.
The script above fails hard: _data="$(flox_plugin_data vault-secrets)"
is a plain assignment, and profile.d scripts run under set -e, so a
missing table aborts activation. That’s a choice, not something Flox
enforces — wrap the call and check its exit status yourself to degrade
gracefully instead, for example printing a warning and leaving a variable
unset when a secret is optional. Fail hard for a plugin the environment
can’t run without; fail soft for one it can.
A few conventions to follow when naming and scoping a plugin:
- Name it after your package. The plugin name doesn’t have to match the
package’s install ID or
pkg-path, but matchingpkg-pathmakes the connection obvious to anyone reading the manifest. For plugins that declare lifecycle hooks the alignment is mandatory: the[plugin-hooks]declaration, the install ID, and the shipped hook filename must all carry the same name. - Read only your own table. Nothing stops a script from reading the
whole manifest, but Flox won’t enforce that boundary for you — stick to
[plugins.<your-plugin-name>]. - Order your script deliberately.
profile.dscripts run in filename order. Flox’s own setup scripts currently top out around0800; a0900prefix runs after them, and after any other plugin your logic depends on.
flox build too, so [build] commands can
read your plugin’s exported variables — not just interactive and
flox activate -- <cmd> sessions.
Lifecycle hooks
profile.d scripts cover one moment in the lifecycle: environment setup
at activation start. Lifecycle hooks let a plugin participate everywhere
else. Each hook kind is a file at a well-known path inside the plugin
package, discovered in the rendered environment and dispatched by Flox at
the right moment.
The hook tree
profile.d does. One caveat is load-bearing: two packages
shipping an identical leaf filename is a hard build failure, so naming
hook files after the plugin (<plugin-name>, or
1000_<plugin-name>.sh for sourced scripts) is a requirement, not
tidiness.
Declaring hooks: [plugin-hooks]
Executable hooks don’t run just because a package ships them. The
environment’s manifest must opt in, through a typed, top-level section:
- A declared plugin that isn’t installed, that doesn’t ship the declared hook, whose hook file isn’t executable, or whose hook file is actually shipped by a different package (shadowing a plugin’s name) is an activation error.
- A shipped hook that isn’t declared is ignored with a warning naming the
fix:
Ignored session-wrap hook '<name>' shipped by an installed package. Declare it under [plugin-hooks] in the manifest to enable it.
profile.d
script runs with your privileges. The declaration is not a code-execution
boundary. What it gates is three specific powers a profile.d script
doesn’t have:
- session capture — a
session-wraphook execs your terminal session under code the plugin controls; - per-attach injection — an
envhook writes into every shell’s environment, for the activation’s lifetime; - supervised lifetime — a
sidecarhook gets a daemon that Flox keeps alive alongside the activation.
profile.d and on-deactivate.d scripts have none of those powers, so
they stay undeclared.
Unknown keys in [plugin-hooks] fail at parse time, and session-wrap
is typed as a single string, so two wrappers are unrepresentable in one
manifest.
Consent and composition
Declaring a session wrapper means “activating this environment hands the session to that plugin”. Flox makes sure that’s always something you wrote, and something you agree to:-
Only the top-level manifest’s
[plugin-hooks]section is effective. When one environment includes another, an included manifest’s[plugin-hooks]section is dropped during composition, with a message naming the include. Plugin data tables flow through includes; hook participation does not — a declaration can never arrive from a manifest you didn’t author. To enable an included environment’s plugin hooks, restate the declaration in your own manifest. -
Auto-activation asks first. Entering a
directory whose environment declares a session wrapper prompts before
handing over the session, and the default is No:
Bare Enter declines; declining is remembered for the rest of the shell session (cleared when you leave the directory). The prompt appears on every entry, even for directories you’ve allowed with
flox activate allow— a prior allow may predate the wrap declaration — and accepting starts a foreground session rather than the usual in-place activation. On fish and tcsh, or without a terminal, no prompt is shown — a notice points at runningflox activateyourself instead.
The hook protocol
Executable hooks share one invocation contract. Flox writes a JSON context file readable only by you (mode0600 for session-wrap and
env hooks; the sidecar’s context lives inside its private 0700
runtime directory) and invokes the hook with:
FLOX_HOOK_CTX— path to the context fileFLOX_HOOK— the hook kind:session-wrap,env, orsidecarFLOX_PLUGIN_NAME— the plugin whose hook is being invokedFLOX_HOOK_JQ— a guaranteedjq, so shell-scripted hooks can parse the context without depending on oneFLOX_BIN— the invokingfloxbinary (session-wraponly)
ctx_version) and its fields vary by hook
kind, but every hook receives plugin_table — its own
[plugins.<name>] table as verbatim JSON. This is how hook executables
read their configuration: they run outside the activation shell, so the
flox_plugin_data function isn’t available to them.
Hooks are language-agnostic — a hook with real logic can be a compiled
binary shipped in the package; simple ones stay shell. Shell hooks run
with the invoking user’s environment before any activation setup, which
on macOS can mean bash 3.2 — keep them compatible.
session-wrap
The marquee hook: it runs the entire activation session under the plugin’s control. Flox dispatches it duringflox activate, after the
environment is locked, built, and rendered, immediately before the
session would start. The hook composes whatever boundary it implements —
an OS sandbox, a container, a remote hand-off — and execs the
activation inside it; on success it never returns. A hook that returns
instead fails the activation: an environment that declares a wrapper
either activates wrapped or not at all.
The context gives a wrapper two ways to re-enter the activation:
inner_argv, a host-side argv sufficient for same-filesystem boundaries
that re-exec flox under a wrapper process, and invocation_type, the
structured form of how the user invoked activation (interactive, -c
shell string, or -- cmd argv), from which container and remote
boundaries compose their own in-boundary command.
Rules Flox enforces around the wrap:
- One wrapper per manifest, structurally (see the schema above).
- Re-entry is detected, nesting is refused. The hook marks the
wrapped process with a scope value from its context
(
_FLOX_SESSION_WRAPPED); re-activating the same environment inside its own boundary skips the wrap, while activating a different wrapping environment inside it is an error. - In-place activation is refused.
eval "$(flox activate)"cannot hand your current shell to a wrapper. - Stdio is inherited but not guaranteed to be a terminal — a hook that wants to prompt must check the tty state the context provides and talk to the terminal directly, never stdout.
env
An executable that contributes environment variables — at activation start and again at every shell attach, which is the seamprofile.d scripts don’t reach (attaching shells
replay the recorded activation environment rather than re-running
setup). The hook prints a JSON object of variables on stdout:
- Runs on every attach, so it must be fast and idempotent — check before appending to path-like variables.
- Multiple declared
envhooks run in lexical plugin-name order, last-writer-wins; their contributions are reapplied to each shell and win over values set byprofile.dscripts or the user’s hooks. - Fail-closed: a non-zero exit or malformed output fails the activation or attach. This is a declared control surface, not best-effort decoration.
_FLOX_-prefixed variables are rejected — Flox’s own control state can’t be forged through this channel.
sidecar
A long-running process with the activation’s lifetime, supervised by the same Flox process that supervises services. The generic form of “my plugin needs a daemon”: a policy broker, a proxy, a watcher. Supervision contract:- Spawned at activation start with the hook context plus a private
runtime directory (mode
0700, for sockets) beside the services socket. Spawn failure fails the activation. - A crash mid-activation is logged and non-fatal; there is no automatic restart. Design plugins to fail closed on a dead sidecar.
- At teardown the sidecar is terminated (SIGTERM, a grace period, then
SIGKILL) and its runtime directory removed — after services shut down,
before
on-deactivate.dscripts run. - Its stdio is detached; a sidecar that needs to log writes its own files, conventionally under the plugin’s cache directory.
on-deactivate.d
The package counterpart of the manifest’shook.on-deactivate: shell
scripts at etc/flox/hooks/on-deactivate.d/*.sh, sourced in filename
order when the last activation of the environment ends, before the
user’s own hook.on-deactivate. They run with the activation-end
environment replayed, flox_plugin_data available, output going to the
activation’s log, and failures swallowed — teardown always continues.
Like hook.on-deactivate, these scripts don’t run when the environment
is torn down uncleanly (a killed supervisor, a removed state directory,
containers), so they suit janitorial cleanup — caches, scratch state —
not anything correctness depends on.
Writing and testing a hook
Hooks are testable without publishing anything. Build the plugin package (a[build] target whose output ships the hook tree), install it into a
test environment by store path, declare it, and activate:
<project>/.flox/cache/plugins/<plugin-name>/ — it survives across
activations and is not committed.
Debugging a plugin
Activation runs plugin scripts silently. When one doesn’t do what you expect, pass-v to flox activate — verbose mode traces the
activation script command by command, including every profile.d script
as it’s sourced:
-- true activates, runs true, and exits — a quick way to capture a
trace without entering a subshell. The trace goes to stderr, hence the
redirect.
The trace answers the questions that come up while writing a plugin:
-
Did my script run, and when? Each
+ sourceline appears in filename order — Flox’s own setup scripts first, then plugin scripts. If noprofile.dlines appear at all, the environment was already active somewhere and this activation attached instead of re-running setup — exit the other activation first. If only Flox’s own0100script appears, the environment is inrunmode, which skips packageprofile.dscripts entirely. -
What data did it receive? Drop the
grepand the trace shows every command inside your script as it executes, including whatflox_plugin_dataprinted:This is the only window into that call —flox_plugin_dataexists only whileprofile.dscripts are being sourced, so you can’t run it by hand in the activated shell afterward. -
Which command failed?
profile.dscripts run underset -e, so when a plugin aborts activation, the last traced command before the failure is the one that caused it.
- Verbose mode logs each dispatch (
exec'ing session-wrap hook,running env hook) with the resolved hook path. - A
session-wraphook inherits your terminal, so anything it writes to stderr reaches you directly. - An
envhook’s stderr is not currently captured anywhere — have the hook write diagnostics to a file while developing it. - Sidecar lifecycle events (spawned, exited, terminated) and
on-deactivate.dscript output land in the activation’s log directory.
Plugin data in composed environments
When one environment includes another, and both configure the same plugin, the including environment’s table wins outright — Flox doesn’t merge the two tables key by key:DB_PASSWORD, not GH_TOKEN
plus an overridden DB_PASSWORD. Flox warns when this happens — a
partial, key-by-key merge could hand a plugin a table its author never
intended. If you compose environments that share a plugin, restate every
key you want to keep in the including environment’s table.
[plugin-hooks] sections don’t merge at all: an included environment’s
declarations are dropped, as described in
Consent and composition.
Beyond secrets
Secrets retrieval fits[plugins] well because “environment variable name
→ secret path” is exactly the kind of per-environment configuration
shared logic needs. That shape isn’t unique to secrets — a plugin could
equally:
- Standardize the config for a linter or formatter across every
environment that installs it, instead of copying the same
[vars]or[hook]entries into each manifest. - Toggle a package’s optional behavior — verbose logging, a feature flag, a telemetry opt-out — per environment.
- Inject build-time metadata, like a license key or an internal registry URL, that a package needs to configure itself correctly.
[plugins]
is free-form storage plus a convention for reading it; what a given
plugin does with its table — and with its hooks — is entirely up to its
author.
Further reading
manifest.tomlreference —[plugins]section- Secrets management — the hand-written pattern a secrets plugin packages up
- Sandboxing — sandbox plugins, the flagship consumers of lifecycle hooks
- Activating environments — where
profile.dscripts run relative tohookandprofile - Composing environments — how
includemerges manifests