Commit graph

8 commits

Author SHA1 Message Date
dan-claude-bot
7f0e886851 fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites
The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.

The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.

Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.

Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.

Refs #117
2026-07-21 12:58:14 +00:00
dan-claude-bot
8bf47954f8 fix: CAST_AGE_KEY_FILE_<ENV> maps to a name a shell can set (#102)
envName.toUpperCase() alone turned env 'drill-b' into
CAST_AGE_KEY_FILE_DRILL-B — a variable no POSIX shell can export, so the
injected-key channel (and its process-substitution trick) was unreachable
for every hyphenated environment name, while the refusal advertised it
anyway. Characters outside [A-Z0-9] now map to _; the standing-key path
keeps the exact env name, so names that collide on the variable still
resolve their own keys on disk.

Fixes #102

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 13:09:17 +00:00
Daniel Marin
6828651506
Merge pull request #94 from dan-claude-bot/fix/age-key-read-once
fix(secrets): read the age identity once per process so <(...) keys survive --all
2026-07-18 21:23:41 +01:00
dan-claude-bot
a504ac2b54 test(secrets): make the read-once comment path-neutral so #90's /dev/fd rename cannot stale it
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:22:16 +00:00
claude-hdb
8c396736c8 fix(secrets): read the age identity once per process so <(...) keys survive --all (#36)
A process substitution (`CAST_AGE_KEY_FILE_<ENV>=<(pm read ...)`) is a
read-once pipe, but `diff --all` / `apply --all` call decryptSecrets once
per project. The first project drained the pipe; every later project
re-read the key file, handed age an empty identity, and failed — the
fleet loop then misreported the project as unreachable (diff) or aborted
the fleet (apply). Latent today because only one registered project has
a prod store; real the moment a second one gains one.

Cache the key bytes by key path, module-level, so the identity is read
exactly once per process. Exposure is unchanged: the key already
transits this process's memory on every call.

The regression test uses a FIFO, which really drains — unlike the
existing /proc/self/fd test, whose regular file re-opens at offset 0 on
every read. A second writer serves emptiness after the first decrypt so
a regression fails loudly (age: no secret keys found) instead of
blocking the suite on a writerless FIFO open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:27:53 +00:00
claude-hdb
ec4cf7df27 fix(test): use /dev/fd instead of /proc/self/fd in fd-path regression test (#37)
/proc does not exist on macOS, and cast runs on the operator's
workstation. /dev/fd resolves on both platforms (on Linux it is a
symlink to /proc/self/fd) and is closer to what bash expands <(...) to.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:14:20 +00:00
claude-hdb
b9ded195da fix: hand the age identity to age on stdin — fd paths resolve only in cast's process
CAST_AGE_KEY_FILE_PROD=<(pm read …) — the documented way to inject a prod
key that never touches disk — expands to /proc/self/fd/N, a path meaningful
only inside the process holding the fd. cast passed that string to a
freshly-spawned age, which resolved it against its own fd table and failed
with ENOENT, for every password manager, on every shell.

node owns the fd, so cast now reads the identity itself and hands it to age
as `-i -` on stdin. The key still never becomes a file, never appears in
argv, and never enters the environment. Not `-i /dev/stdin`: node closes
the pipe before age re-opens it by path (ENXIO).

The regression test reproduces the shape exactly — a key path that only
this process can resolve — and fails against the old code with the same
age ENOENT hit live during the incubator prod migration.

Fixes #34

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:43:00 +00:00
a10349d835 feat: cast — the Coolify executor, extracted from the infra state repo
Public tool, private state. cast holds no hostnames, no bindings, no
secrets: it joins a product repo's .infra/ manifest with a state directory
you point it at, and makes Coolify match.

Extracted from heavy-duty/infra, which was half tool and half state — the
inconsistency that made it impossible to say whether "infra" named a CLI
or a runbook. rig builds the boxes; cast fills them; infra is what they
are filled with.

Two changes were required to make it genuinely stateless and publishable:

- The implicit cwd contract (environments.yaml / secrets/ / .coolify.env
  resolved against the working directory, silently reading the wrong file
  from the wrong place) is now an explicit --state <dir> / $CAST_STATE.
- BANNED_IN_PROD — a hardcoded list of one product's ALLOW_* flags, the
  only product knowledge in the executor — becomes the generic, operator-
  owned environments.<env>.forbidden_var_patterns. The guard now lives in
  private state, so a product-side change cannot lower its own guard, and
  it is a pattern rather than a list, so it catches unforeseen siblings.

Age identities resolve as $CAST_AGE_KEY_FILE_<ENV> then
~/.config/cast/age-<env>.key — which is the entire attended-vs-unattended
apply mechanism, with no environment names known to the tool.

Instance identity (org names, the GitHub App name, founder domains) is out
of the fixtures and out of register-github-app.sh, which took APP_NAME and
ORG as arguments rather than baking them in.

69 tests green; bin/cast + curl installer mirror rig's shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:25:44 +00:00