A config warning on stdout made a healthy alarm lane look dead
TL;DR — A CLI reported a missing environment variable by printing a
Config warningsbox to stdout. My pipeline was parsing that stdout, soawkgot box-drawing characters where data rows should be and matched nothing. “Nothing” read as the alarm lane is dead, not your shell is missing an env var. Forty minutes and one false outage report later: prefix the environment for any command whose output you parse, not just the ones that need the secret. Jump to the fix.
The problem
I run an agent gateway on a small boat server. It has an audit log, and the cheapest health check for “are the agents actually running” is a one-liner over the CLI:
gwcli audit --limit 12 | awk '$2=="agent_run"'
Which, one morning, returned nothing at all. No error. Exit code 0. Just an empty result where a dozen rows had been the week before.
The obvious reading — the one I made, and the one that cost the morning — is that there were no agent runs. Alarms flow through that gateway, so “no agent runs” means the soft alarm lane is down: a low-battery or depth alarm on the boat would raise, publish, and reach nobody. That’s a real outage on the boat’s safety path, so it got reported as one.
The lane was healthy the entire time. The hook was firing about twelve seconds after each test alarm, which is exactly the coalesce window. Nothing was down.
Here is what the command actually printed, once I stopped piping it:
$ gwcli audit --limit 12
┌ Config warnings ───────────────────────────────────┐
│ ! hooks.token: Missing env var GW_HOOKS_TOKEN │
└────────────────────────────────────────────────────┘
ts kind agent status
2026-08-10T06:12:04Z agent_run watch ok
2026-08-10T06:24:11Z agent_run watch ok
The rows were there. So was a warning box, printed to stdout, sitting on top
of them. awk '$2=="agent_run"' doesn’t crash on box-drawing characters; it just
doesn’t match them, and it never got the chance to be confused, because in the
real invocation the box was all there was.
Diagnosis
The day before, I’d moved two credentials out of the gateway’s config file and into secret references, so the config could go under version control:
// ~/.gwcli/config.json
{
"gateway": { "auth": { "token": { "$secret": "GW_AUTH_TOKEN" } } },
"hooks": { "token": "${GW_HOOKS_TOKEN}" }
}
Note the two forms. That asymmetry looks like sloppiness and it is not — more on that below. The secrets themselves lived in an env file that the gateway’s launcher script sourced before exec’ing the daemon:
# run-gateway.sh
set -a; . "$HOME/.gwcli/secrets.env"; set +a
exec gwcli gateway
The daemon was fine, because the daemon goes through that script. A bare
gwcli ... typed in a shell, or run over SSH, does not. And with the env
missing, the two fields fail in two completely different, differently-visible
ways:
| Field | Form | Channel | What you get |
|---|---|---|---|
gateway.auth.token |
typed secret ref | stderr | SecretRefUnavailableError: ... unavailable in this command path |
hooks.token |
${VAR} string |
stdout | a Config warnings box, mixed into the data you were parsing |
The stderr one is a good failure. It’s loud, it names the field, and it stops
you. The stdout one is the dangerous half: it doesn’t stop anything, it doesn’t
look like an error to a program, and it arrives inside the data stream. Every
consumer that treats stdout as structured output — awk, jq, grep -c, a
Python subprocess.run(...).stdout — silently ingests a diagnostic as content.
And the two compound in the worst possible way, which is the part I’d want another builder to take away:
# a completely normal scripting reflex
gwcli audit --limit 12 2>/dev/null | awk '$2=="agent_run"'
2>/dev/null suppresses the one failure that would have told me what was wrong,
and leaves the one that corrupts the answer. A clean-looking pipeline, producing
wrong data, with no visible error anywhere. That is a very hard thing to
distrust at 6 a.m.
What I tried, and why it failed
Attempt 1: prefix the env only where the secret is needed. My first mental model was “this command reads the audit log, it doesn’t need the gateway token, so it doesn’t need the env.” Wrong — the config is parsed on every invocation, so an unresolvable reference warns on every invocation, whether or not the subcommand touches it:
$ gwcli audit --limit 12 | awk '$2=="agent_run"' # env not prefixed
# (nothing — the box isn't a match)
$ set -a; . ~/.gwcli/secrets.env; set +a
$ gwcli audit --limit 12 | awk '$2=="agent_run"' # env prefixed
2026-08-10T06:12:04Z agent_run watch ok
2026-08-10T06:24:11Z agent_run watch ok
Same command, same server, same healthy lane. The only variable was my shell.
Attempt 2: a wrapper, so the prefix stops being something to remember. A tiny script that sources the env and execs the real binary:
#!/usr/bin/env bash
# gw — run gwcli with secrets resolved
set -euo pipefail
set -a; . "$HOME/.gwcli/secrets.env"; set +a
exec gwcli "$@"
This works, and it survived the day. But it only helps the caller who remembers
to type gw instead of gwcli, which is the same class of discipline that
failed in the first place.
Attempt 3: a --selftest that asserts the environment instead of trusting
it — meant for the top of any script that parses this CLI’s output. This is
the one worth writing down, because it passed while genuinely broken.
The CLI reports the identical condition in two different formats depending on the subcommand:
$ gwcli audit
┌ Config warnings ───────────────────────────────────┐
│ ! hooks.token: Missing env var GW_HOOKS_TOKEN │
└────────────────────────────────────────────────────┘
$ gwcli config validate
1 warning(s): ! hooks.token: Missing env var GW_HOOKS_TOKEN
I wrote the check against config validate — the subcommand whose entire job is
to answer this question — and grepped for the string I’d been staring at all
morning:
# before — passes with the token genuinely missing
out="$(gwcli config validate 2>/dev/null || true)"
if grep -qi "Config warnings" <<<"$out"; then
echo "SELFTEST FAIL"; exit 1
fi
echo "SELFTEST PASS"
config validate never prints Config warnings. The grep cannot match. The
selftest passed instantly and cleanly with a required credential absent — the
exact state it existed to catch. Note it also carries the 2>/dev/null reflex,
so the loud channel was gone there too.
What caught it was not review and not a test. It was running the negative control: unsetting the variable on purpose and watching whether the check went red. It didn’t.
# after — match the substring both formats actually contain
if grep -qi "missing env var" <<<"$out"; then
The fix
The real fix landed a day later, and it deleted the wrapper’s entire reason to exist.
Buried in the gateway’s own configuration docs: the CLI auto-loads a dotenv file from its config directory on every invocation — daemon, CLI, cron, service unit, all of them. The secrets file already existed. It was just named something the tool didn’t know to read.
mv ~/.gwcli/secrets.env ~/.gwcli/.env # that exact filename is the fix
chmod 600 ~/.gwcli/.env
$ gwcli audit --limit 12 | awk '$2=="agent_run"' # plain shell, no prefix, no wrapper
2026-08-10T06:12:04Z agent_run watch ok
2026-08-10T06:24:11Z agent_run watch ok
Both credentials now resolve for anyone who runs the binary, from anywhere. The
launcher script stopped sourcing anything. The wrapper survives for exactly one
unrelated reason — PATH, because the Node version manager puts the binary in a
directory that only an interactive shell’s rc file adds, so ssh host 'gwcli …'
gets command not found. That’s a different bug wearing the same coat.
The selftest stayed, because renaming a file doesn’t stop it going missing or a key rotating out:
gwcli config validate 2>/dev/null | grep -qi "missing env var" && exit 1
Why it matters, and the gotchas around it
Prefix the env for any command whose output you parse, not just the ones that need the secret. That’s the rule I’d hand to anyone else running a CLI inside a pipeline. It’s counter-intuitive precisely because the failing subcommand had nothing to do with the missing credential.
Diagnostics belong on stderr — and you are not in control of that. This is
the oldest rule in the Unix toolbox and every CLI breaks it eventually, usually
via a pretty-printing layer that wraps console.log. Assume a tool you depend
on will one day put a banner in your data. The defences are cheap: pass whatever
--json / --quiet / --no-color flag exists and parse a structured format
rather than a table; or fail loudly when the shape is wrong, instead of treating
an empty match as a fact.
An empty result is not evidence. awk matching zero rows means “no rows
matched”, which covers there is no data, the data moved, and what I handed
you wasn’t data at all. My health check couldn’t tell those apart, and the one
it silently chose was the alarming one. A parse that can return empty needs a
sanity assertion — did the header row arrive? — before its emptiness is allowed
to mean anything.
2>/dev/null is a load-bearing decision, not punctuation. In this incident
it was the difference between a five-second diagnosis and a false outage report.
If you’re silencing stderr because a command is chatty, silence it after
you’ve checked the exit code, or route it to a file you actually read.
You haven’t tested a check until you’ve watched it fail. Break the thing on purpose, confirm red, put it back, confirm green. A selftest that passes when broken is worse than no selftest — it converts an unknown into a false known, and it is exactly the kind of green tick you’d cite while reporting an outage that isn’t happening.
One deliberate non-fix. The two-form asymmetry in the config — one field a
typed secret-reference object, the other a ${VAR} string — is schema-forced,
not style drift: the second field’s schema is string-only and cannot take the
object form, which is why it’s the one that emits a warning rather than
throwing. That’s now a comment in the config, because it reads as an
inconsistency and the obvious “cleanup” would put the whole failure straight
back.
This is part of the agent stack I’m building for an all-electric charter catamaran that doesn’t exist yet — the same lane the false report was about is the one that carries SignalK alarms to a phone. It stayed up. My confidence in my own health check did not.
Related: Monitor the delivery path, not just the alarm — the same alarm lane failing for real, and why a quiet system produces nothing to notice; and Running OpenClaw on a Raspberry Pi alongside SignalK — the gateway this CLI drives.