cogs-env-doctor
@cogs/env-doctor diagnoses and fixes quote-wrapped secret values on Render environment variables — the recurring bug where FOO='abc123' is copy-pasted verbatim out of a shell-quoted .env file into Render's dashboard or API, where there is no shell to strip the quotes, so the running app reads the literal string 'abc123' instead of abc123.
It's private to this monorepo and is never published to npm. Run it with pnpm -w exec cogs-env-doctor … from inside a clone of cogs.
:::danger STOP — fix is not cleared to run against a real service
The AC0 Render API capability spike has not been run. See spikes/render-env-var-capability.md in the package — it is explicitly marked "SPIKE NOT YET RUN", and every one of its claims (a)–(e) is UNVERIFIED.
Until that spike is run and the document is replaced with real evidence:
- Do not run
cogs-env-doctor fixagainst any real Render service — production or otherwise. Every safety guarantee below (read-back verification, rollback, the exit-code outcome matrix) depends on unverified assumptions about Render's API: that list reads return plaintext values rather than redacted/hashed ones, that a single-key read-back endpoint exists and returns plaintext, that a single-keyPUTround-trips with body{ value }alone, that aPUTdoes not auto-trigger a deploy, and that the bare-pathPUTreally is the bulk-replace operation the negative safety test guards against. - Treat
checkresults as provisional. If Render turns out to redact values on read, no value would ever look quote-wrapped andcheckwould report a confident, wrong "clean." - The spike is write-capable and must be run against a disposable Render service created for the purpose — never
cogs-send-service.
Everything in this package is unit-tested against a mocked fetchImpl. No test makes a real network call. Mocked tests prove the code does what it intends to do; they cannot prove Render behaves as assumed. That is exactly what the spike is for.
:::
What it does
- Greps every var for wrapping
'/"viacheck. - Fixes via a single-var
PUT—PUT /v1/services/{serviceId}/env-vars/{key}, body{ value }. It never bulk-replaces (PUT /v1/services/{serviceId}/env-vars, the bare path, would replace the entire list and delete anything omitted) — enforced by a negative unit test, not just convention. - Reads back after every write, including on the rollback path, and, when the stripped value is JWT-shaped, decodes and validates it to confirm the fix actually worked.
Usage
# Diagnose (read-only, never mutates)
pnpm -w exec cogs-env-doctor check --service cogs-send-service --json
# Preview a fix (dry-run — this is the default; no write happens)
pnpm -w exec cogs-env-doctor fix --service cogs-send-service --key SUPABASE_ANON_KEY
# Apply it (requires an explicit --yes)
pnpm -w exec cogs-env-doctor fix --service cogs-send-service --key SUPABASE_ANON_KEY --yes
--help output
Real output from node packages/env-doctor/dist/cli.js --help in this repo:
cogs-env-doctor — Render env-var quoted-secret diagnosis and single-key fix
1. grep every var for wrapping ' or "
2. fix via single-var PUT — NEVER bulk replace
3. read back (including on rollback) and, when JWT-shaped, decode + validate
Usage:
cogs-env-doctor check --service <id|name> [--json]
cogs-env-doctor fix --service <id|name> --key <NAME> [--yes] [--json]
Options:
--service <id|name> Required. A Render service id, or a "name" from
render-deploy.config.json. No --all / fleet mode exists.
--key <NAME> Required for fix. Exactly one env-var key.
--yes Required before any network write. Without it, fix is a
dry run (exit 4).
--api-key-env <NAME> Env var holding the Render API key. Resolution order:
this flag > the matched config entry's renderApiKeyEnv
(matched-but-absent is a usage error, never a fallthrough)
> the literal RENDER_API_KEY (only when nothing matched).
--config <path> render-deploy.config.json path. Default: ./render-deploy.config.json
--json Emit the pinned JSON envelope on stdout.
-h, --help Show this help.
--version Print the installed version and exit.
Exit codes:
0 clean / no-op / fix confirmed (incl. a write that landed despite a client error)
1 unexpected or transport error
2 usage error (no network write attempted)
3 check: NOT verified clean — findings present and/or coverage incomplete
4 fix dry-run preview (no mutation)
5 mismatch after write; rollback issued AND read-back confirmed
6 STATE UNKNOWN — read-back unresolvable (possibly on the rollback path)
7 write failed transport-side; original value confirmed unchanged (safe to retry)
8 suspected concurrent out-of-band edit (no rollback attempted)
WARNING: the AC0 Render API capability spike has NOT been run. Do not run `fix`
against a real Render service until spikes/render-env-var-capability.md is
replaced with real evidence.
Flags
| Flag | Applies to | Meaning |
|---|---|---|
--service <id|name> | both | Required. A Render service id (srv-…) or a name from render-deploy.config.json. There is no --all / fleet-wide mode. |
--key <NAME> | fix | Required for fix. Exactly one env-var key. Zero or more than one is a usage error (exit 2). |
--yes | fix | Required before any network write. Without it, fix is a dry run (exit 4). |
--api-key-env <NAME> | both | Name of the env var holding the Render API key. See resolution order below. |
--config <path> | both | Path to render-deploy.config.json. Defaults to render-deploy.config.json relative to cwd. |
--json | both | Emit the pinned JSON envelope on stdout (human text is suppressed / routed to stderr). |
-h, --help | both | Usage. |
--version | both | Print the version read from this package's own package.json and exit — before any other validation runs. |
--json envelope
Pinned. Both subcommands emit this shape:
{
"tool": "cogs-env-doctor",
"version": "0.0.0",
"command": "check" | "fix",
"service": "srv-…",
"exitCode": 0,
"outcome": "…", // stable machine-readable outcome slug
"findings": [ // check only
{ "key": "SUPABASE_ANON_KEY", "quoteChar": "'" }
],
"unchecked": [ // check only — scopes this tool cannot inspect
{ "scope": "env-group", "id": "evg-…" }
],
"partial": true, // check only — true whenever `unchecked` is non-empty
"readback": { // fix only, when a write was attempted
"intended": "****bcd1",
"preWrite": "****cd1'",
"observed": "****bcd1",
"rollbackAttempted": false,
"rollbackValue": "****cd1'",
"rollbackConfirmed": true
}
}
Every value-bearing readback field is masked to last-4 (**** + the last four characters, or **** outright for values of 4 characters or fewer).
--service and --api-key-env resolution
--service accepts either a Render service id or a name from render-deploy.config.json's services[], matched against both the renderServiceId and name fields.
The Render API key's env var name resolves in this order:
--api-key-env <NAME>if given.- Otherwise, the
renderApiKeyEnvfield of the matchedrender-deploy.config.jsonentry. If the entry exists but has norenderApiKeyEnv, this is a usage error (exit2) — it does not fall through to step 3. - The literal
RENDER_API_KEY— used only when--servicematched no config entry at all.
Known limitations
- Not live until redeploy — and this claim itself is unverified (see the warning above). Render does not apply an env-var change to a running service until it is redeployed, so a
fixthat reports exit0has corrected the stored value, not necessarily the running one — unless aPUTturns out to auto-trigger a deploy, in which case this caveat inverts. - Environment groups are invisible to this tool. Vars set via a linked Render environment group live behind a separate API surface this package does not implement.
checkfails closed: when a service has linked env groups (or the env-group listing call fails), it reports them inunchecked, setspartial: true, and returns exit3— never a bare0. - The redactor has an 8-character registration floor, ported from
packages/supabase-sync: values shorter than 8 characters are not registered for redaction, so a stripped secret shorter than that will not be redacted from error output. - No rate-limit / backoff handling for paginated Render API listing calls.
- Local
.envfile linting is out of scope for this tool. That's whatconfig-lintis for. - Vercel is not supported.
See the package README for the full reference, including the complete exit-code precedence table and rollback semantics.