diff --git a/AGENTS.md b/AGENTS.md index 2a8b073..fe371fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ version bump" below. | `git-absorb` | 1.2.3 | git-absorb from GitHub releases | | `jj` | 1.0.0 | jj CLI from jj-vcs/jj GitHub releases + VisualJJ extension; git identity/signing mirrored into `/etc/jj/conf.d` on attach, repos with jj commits in their history turned colocated (`initRepos: auto`), org-workspace names marked `(jj)`, `~/.config/jj` in a named volume, non-interactive config for AI agents + Claude Code skill | | `bitwarden-secrets-manager` | 1.2.4 | `bws` CLI from bitwarden/sdk-sm GitHub releases, token-only auth, no persisted state | -| `openbao-env` | 1.0.0 | Loads OpenBao KV v2 secrets into every process's env: POSIX loader sourced by profile.d/bash.bashrc/zshrc (so the userEnvProbe sees them before any lifecycle hook), memory-only cache in `/dev/shm/openbao`, AppRole via host-dropped wrapped secret_id or env, `openbao-env` CLI, optional `bao` CLI | +| `openbao-env` | 1.0.1 | Loads OpenBao KV v2 secrets into every process's env: POSIX loader sourced by profile.d/bash.bashrc/zshrc (so the userEnvProbe sees them before any lifecycle hook), memory-only cache in `/dev/shm/openbao`, AppRole via host-dropped wrapped secret_id or env, `openbao-env` CLI, optional `bao` CLI | | `dotfiles-sync` | 1.2.3 | Sync Git/SSH/GPG/npm/gh config from host; SSH key files opt-in (dependsOn helpers4-common) | | `shell-history` | 1.0.0 | Shell history (bash/zsh/fish) kept across rebuilds in a named volume per devcontainer | | `org-workspace` | 1.2.0 | Clones every repo of a GitHub org into sibling `/workspaces` folders + generates/merges a multi-root `.code-workspace` (dependsOn helpers4-common, github-dev) | diff --git a/src/openbao-env/README.md b/src/openbao-env/README.md index c0fab18..69d5d04 100644 --- a/src/openbao-env/README.md +++ b/src/openbao-env/README.md @@ -106,4 +106,5 @@ After `refresh`, open a new terminal, or run **Developer: Reload Window** for VS ## Version History +- **v1.0.1**: Remove every temp file (curl bodies, token headers) when `openbao-env` exits, is interrupted or killed; 1.0.0 left them in `/dev/shm/openbao`. Leftovers older than 2 minutes are purged. - **v1.0.0**: Initial release. diff --git a/src/openbao-env/devcontainer-feature.json b/src/openbao-env/devcontainer-feature.json index 65f273e..809760b 100644 --- a/src/openbao-env/devcontainer-feature.json +++ b/src/openbao-env/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "openbao-env", - "version": "1.0.0", + "version": "1.0.1", "name": "OpenBao secrets", "description": "Loads secrets from OpenBao (KV v2) into the environment of every process in the container: VS Code and its extensions, terminals, the lifecycle hooks of other features, AI agents. The container fetches its own secrets (AppRole, response-wrapped secret_id or token) into a memory-only cache, so nothing depends on the attaching client's localEnv. Optionally installs the bao CLI. Also self-heals your devcontainer setup.", "documentationURL": "https://github.com/helpers4/devcontainer/tree/main/src/openbao-env", diff --git a/src/openbao-env/openbao-env b/src/openbao-env/openbao-env index bf925c5..792b441 100755 --- a/src/openbao-env/openbao-env +++ b/src/openbao-env/openbao-env @@ -52,21 +52,31 @@ RETRY_AFTER="${OPENBAO_ENV_RETRY_AFTER:-300}" TOKEN_SOURCE="" HDR_FILE="" DEADLINE=0 -TMP_FILES=() +TMP_DIR="" +TMP_N=0 warn() { printf 'openbao-env: %s\n' "$*" >&2; } +# Every temp file (curl bodies, token headers) lives in one per-run directory, removed on +# any exit, Ctrl-C or kill (only SIGKILL escapes: ensure_dir purges what that leaves). cleanup() { - local f - for f in "${TMP_FILES[@]+"${TMP_FILES[@]}"}"; do rm -f "${f}"; done + [ -n "${TMP_DIR}" ] && rm -rf -- "${TMP_DIR}" + TMP_DIR="" } trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP +# tmpfile → REPLY = a new empty 0600 file. Must NOT be called as $(tmpfile): a command +# substitution is a subshell, so the parent would never learn about (nor delete) the dir. tmpfile() { - local f - f="$(mktemp "${DIR}/.tmp.XXXXXX")" || return 1 - TMP_FILES+=("${f}") - printf '%s' "${f}" + if [ -z "${TMP_DIR}" ]; then + TMP_DIR="$(mktemp -d "${DIR}/.tmp.XXXXXX")" || { TMP_DIR=""; return 1; } + fi + TMP_N=$((TMP_N + 1)) + REPLY="${TMP_DIR}/${TMP_N}" + : > "${REPLY}" } now() { date +%s; } @@ -98,6 +108,9 @@ ensure_dir() { fi fi chmod 700 "${DIR}" 2> /dev/null || true + # Temp dirs of a run killed with SIGKILL (or left by 1.0.0, which leaked them): a run + # lasts a few seconds, so anything older than 2 minutes is abandoned. + find "${DIR}" -maxdepth 1 -name '.tmp.*' -mmin +2 -exec rm -rf -- {} + 2> /dev/null || true } # A root shell (sudo -i, a root lifecycle hook) must not leave root-owned files behind @@ -142,12 +155,14 @@ describe_code() { set_header_from_file() { local tok tok="$(tr -d '[:space:]' < "$1")" - HDR_FILE="$(tmpfile)" || return 1 + tmpfile || return 1 + HDR_FILE="${REPLY}" printf 'X-Vault-Token: %s\n' "${tok}" > "${HDR_FILE}" } set_header_from_env() { - HDR_FILE="$(tmpfile)" || return 1 + tmpfile || return 1 + HDR_FILE="${REPLY}" printf 'X-Vault-Token: %s\n' "$(printf '%s' "${BAO_TOKEN}" | tr -d '[:space:]')" > "${HDR_FILE}" } @@ -169,19 +184,22 @@ have_credentials() { # approle_login BODYFILE → stores the token in TOKEN_FILE, sets HDR_FILE. approle_login() { local body="$1" out code ttl - out="$(tmpfile)" || return 1 + tmpfile || return 1 + out="${REPLY}" HDR_FILE="" code="$(api POST "auth/${APPROLE_MOUNT}/login" "${out}" "${body}")" if [ "${code}" != "200" ]; then LOGIN_ERROR="AppRole login failed: $(describe_code "${code}")" return 1 fi - if ! jq -er '.auth.client_token' "${out}" > "${TOKEN_FILE}.new" 2> /dev/null; then - rm -f "${TOKEN_FILE}.new" + local new_token + tmpfile || return 1 + new_token="${REPLY}" + if ! jq -er '.auth.client_token' "${out}" > "${new_token}" 2> /dev/null; then LOGIN_ERROR="AppRole login answered without a token" return 1 fi - mv -f "${TOKEN_FILE}.new" "${TOKEN_FILE}" + mv -f "${new_token}" "${TOKEN_FILE}" ttl="$(jq -r '.auth.lease_duration // 0' "${out}" 2> /dev/null)" case "${ttl}" in '' | *[!0-9]*) ttl=0 ;; esac if [ "${ttl}" -gt 0 ]; then echo "$(($(now) + ttl))" > "${TOKEN_EXPIRY_FILE}"; else echo 0 > "${TOKEN_EXPIRY_FILE}"; fi @@ -191,7 +209,8 @@ approle_login() { # The host drops role-id + a response-wrapped secret_id; unwrap it (single use), then log in. login_wrapped() { local out code body - out="$(tmpfile)" || return 1 + tmpfile || return 1 + out="${REPLY}" set_header_from_file "${WRAPPED_FILE}" || return 1 code="$(api POST sys/wrapping/unwrap "${out}")" # Whatever the server answered, the wrapping token is spent (or was never valid): drop @@ -202,7 +221,8 @@ login_wrapped() { LOGIN_ERROR="unwrapping ${WRAPPED_FILE} failed: $(describe_code "${code}")" return 1 fi - body="$(tmpfile)" || return 1 + tmpfile || return 1 + body="${REPLY}" if ! jq -e --rawfile r "${ROLE_ID_FILE}" \ '{role_id: ($r | gsub("\\s"; "")), secret_id: (.data.secret_id // empty)}' \ "${out}" > "${body}" 2> /dev/null; then @@ -215,7 +235,8 @@ login_wrapped() { login_env_approle() { local body - body="$(tmpfile)" || return 1 + tmpfile || return 1 + body="${REPLY}" jq -n '{role_id: env.BAO_ROLE_ID, secret_id: env.BAO_SECRET_ID}' > "${body}" approle_login "${body}" } @@ -255,12 +276,14 @@ FETCH_ERROR="" FAILED_PATHS="" read_paths() { local p out code ok=() keys_tmp - keys_tmp="$(tmpfile)" || return 1 + tmpfile || return 1 + keys_tmp="${REPLY}" FAILED_PATHS="" LAST_CODE="" NO_ANSWER=0 while IFS= read -r p; do - out="$(tmpfile)" || return 1 + tmpfile || return 1 + out="${REPLY}" code="$(api GET "${MOUNT}/data/${p}" "${out}")" LAST_CODE="${code}" [ "${code}" = "000" ] && NO_ANSWER=$((NO_ANSWER + 1)) @@ -280,7 +303,8 @@ read_paths() { fi local env_tmp stamp - env_tmp="$(tmpfile)" || return 1 + tmpfile || return 1 + env_tmp="${REPLY}" stamp="$(now)-$$" # Names a shell or the dynamic linker treats specially are never taken from the server. if ! jq -rs --arg stamp "${stamp}" ' @@ -461,7 +485,8 @@ cmd_status() { local out code ttl DEADLINE=$(($(now) + TIMEOUT)) set_header_from_env - out="$(tmpfile)" + tmpfile + out="${REPLY}" code="$(api GET auth/token/lookup-self "${out}")" if [ "${code}" = "200" ]; then ttl="$(jq -r '.data.ttl // 0' "${out}")" diff --git a/test/openbao-env/dev_server.sh b/test/openbao-env/dev_server.sh index 77341d2..2987b49 100755 --- a/test/openbao-env/dev_server.sh +++ b/test/openbao-env/dev_server.sh @@ -90,6 +90,23 @@ pass "wrapped-secret-id deleted after use" [ "$(stat -c '%a %U' "${D}/env")" = "600 ${U}" ] || fail "cache: $(stat -c '%a %U' "${D}/env")" [ "$(stat -c '%a %U' "${D}/token")" = "600 ${U}" ] || fail "token: $(stat -c '%a %U' "${D}/token")" pass "dir 700, cache and token 600, owned by ${U}" +leftover="$(find "${D}" -maxdepth 1 -name '.tmp.*' | wc -l)" +[ "${leftover}" -eq 0 ] || fail "${leftover} temp file(s) left in ${D} after the first load" +pass "no temp file left after the first load" + +# Concurrent shells (one fetches, the others wait for the lock): still no leftovers. +rm -f "${D}/env" +pids="" +for _ in 1 2 3 4; do + as_user bash -lc 'true' 2> /dev/null & + pids="${pids} $!" +done +# Not a bare `wait`: that would also wait for the dev server. +for pid in ${pids}; do wait "${pid}"; done +leftover="$(find "${D}" -maxdepth 1 -name '.tmp.*' | wc -l)" +[ "${leftover}" -eq 0 ] || fail "${leftover} temp file(s) left in ${D} after concurrent loads" +[ -f "${D}/env" ] || fail "concurrent loads produced no cache" +pass "no temp file left after concurrent loads" grep -q "BAD-NAME" "${D}/env" && fail "invalid name written to the cache" pass "invalid variable name skipped"