Confirmed prod bug: §5 keyed idempotency on configs/credentials.json only.
Re-running the installer on a pending-then-approved node (the exact flow the
script's own output tells you to do) found no credentials.json and fell
through to a fresh POST /nodes/enroll — which enrollment.py's CRITICAL GUARD
answers with 403 for an already-approved node_id, rendered as "use Reissue
key". The working path (GET /nodes/{id}/credentials with the saved
pickup_secret — no already-approved guard on that endpoint) was only ever
tried within a single run.
§5 rewritten as a decision tree that runs before any POST /nodes/enroll:
- credentials.json has api_key -> skip (unchanged)
- configs/pickup_secret exists -> GET /credentials with it:
200 + api_key -> write credentials.json, done
200, no key -> say "approve it, re-run"; clean exit, start stack
401 (rotated) -> re-enroll iff --token, else specific die
404 (deleted) -> re-enroll iff --token, else specific die
000 -> connectivity die
- no creds, no pickup_secret -> fresh enroll (do_fresh_enroll)
Also: fresh-enroll 403/401/429 handlers are now specific and point at
pickup-secret recovery, not just "Reissue key"; --wait-approval polling now
applies on the re-run path; §7's "re-run the installer" banner is
conditional on pickup_secret existing.
Response shapes verified against enrollment.py @ v1. approve_node mints
node_keys/{id}.api_key synchronously — no second bug; assigning a system is
independent and not required for a key. bash -n clean.
Recovery for a node stuck by the old behaviour (approved, no credentials.json,
pickup_secret on disk): re-run the patched install.sh (no --token needed), or
curl -fsS $C2_URL/nodes/$NODE_ID/credentials \
-H "X-Pickup-Secret: $(cat configs/pickup_secret)" | jq '{api_key}' > configs/credentials.json
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>