Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
1 change: 1 addition & 0 deletions src/openbao-env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion src/openbao-env/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
65 changes: 45 additions & 20 deletions src/openbao-env/openbao-env
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}"
}

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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}"
}
Expand Down Expand Up @@ -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))
Expand All @@ -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}" '
Expand Down Expand Up @@ -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}")"
Expand Down
17 changes: 17 additions & 0 deletions test/openbao-env/dev_server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading