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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,10 @@ environment.
describe that option as controlling the volume.
- **Keep the path identical in three places** — `containerEnv.GH_CONFIG_DIR`, the mount
target and `GH_CONFIG_DIR_PATH` in `install.sh`; `pnpm features:validate` fails on a
mismatch. `install.sh` must create it owned by the remote user, because Docker seeds
ownership only into an empty volume.
mismatch. `install.sh` creates it owned by the remote user, but that is not enough: the
CLI remaps the remote user to the host UID and re-owns only `$HOME`, so post-create
repairs the directory with `sudo -n chown` (`devbase_setup_gh_config_dir`) and reports
and skips when it cannot.
- **`installsAfter` orders, `dependsOn` installs.** `installsAfter` is only a hint: it
sequences a feature the consumer already listed and does nothing when they did not, so
it suits the language runtimes — devbase must not install those, only run after them. A
Expand Down
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,16 @@ The Feature now keeps the login in a **per-container named volume**:
- **Reset:** remove the volume while the container is gone —
`docker volume rm devbase-gh-<devcontainerId>` — and rebuild.

**Ownership failure.** Docker copies the image's ownership into a volume only when the
volume is _empty_. `install.sh` creates the directory owned by the remote user, so a fresh
volume is writable. A volume that already existed with another owner stays that way, and
post-create then reports `GH_CONFIG_DIR ... is missing or not writable`. Fix it with
`sudo chown -R "$(id -un)" /var/lib/devbase/gh`, or remove the volume and rebuild.
**Ownership.** The volume has to be writable by the remote user, and post-create repairs
that on every create with `sudo -n chown` of the directory (the devcontainers base images
grant passwordless sudo). It is needed because the Dev Containers CLI remaps the remote user
to the host's UID when it differs from the image's (any Linux host whose UID is not 1000,
GitHub runners included) and re-owns only the home directory, so a directory `install.sh`
created at build time keeps the old owner and a fresh volume inherits it. Docker also seeds
ownership only into an _empty_ volume, so a volume that already existed keeps its old owner.
Where sudo is missing or wants a password, post-create reports
`GH_CONFIG_DIR ... could not be repaired` and carries on; fix it with
`sudo chown "$(id -u):$(id -g)" /var/lib/devbase/gh`, or remove the volume and rebuild.

**No option switches the volume off**, and that includes `ghCredentialHelper: false`:
`mounts` and `containerEnv` are static JSON with no option substitution, so a flag could
Expand Down Expand Up @@ -714,8 +719,8 @@ mount that needed it.
logged out, and the credential helper has nothing to hand git. Run `gh auth login` once; the
login lives in the `devbase-gh-${devcontainerId}` volume and survives rebuilds. The attach
warning exists to say exactly this. If the login does not stick, or post-create reports
`GH_CONFIG_DIR ... not writable`, the volume predates the Feature's ownership setup — see
[The gh login](#the-gh-login).
`GH_CONFIG_DIR ... could not be repaired`, post-create could not fix the volume's ownership
(it needs passwordless sudo) — see [The gh login](#the-gh-login).

**`pnpm: command not found` in post-create.** No Node toolchain in the container. Add
`ghcr.io/devcontainers/features/node:2` — `installsAfter` then guarantees it is installed
Expand Down
15 changes: 10 additions & 5 deletions features/src/devbase/NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,16 @@ The Feature now keeps the login in a **per-container named volume**:
- **Reset:** remove the volume while the container is gone —
`docker volume rm devbase-gh-<devcontainerId>` — and rebuild.

**Ownership failure.** Docker copies the image's ownership into a volume only when the
volume is _empty_. `install.sh` creates the directory owned by the remote user, so a fresh
volume is writable. A volume that already existed with another owner stays that way, and
post-create then reports `GH_CONFIG_DIR ... is missing or not writable`. Fix it with
`sudo chown -R "$(id -un)" /var/lib/devbase/gh`, or remove the volume and rebuild.
**Ownership.** The volume has to be writable by the remote user, and post-create repairs
that on every create with `sudo -n chown` of the directory (the devcontainers base images
grant passwordless sudo). It is needed because the Dev Containers CLI remaps the remote user
to the host's UID when it differs from the image's (any Linux host whose UID is not 1000,
GitHub runners included) and re-owns only the home directory, so a directory `install.sh`
created at build time keeps the old owner and a fresh volume inherits it. Docker also seeds
ownership only into an _empty_ volume, so a volume that already existed keeps its old owner.
Where sudo is missing or wants a password, post-create reports
`GH_CONFIG_DIR ... could not be repaired` and carries on; fix it with
`sudo chown "$(id -u):$(id -g)" /var/lib/devbase/gh`, or remove the volume and rebuild.

**No option switches the volume off**, and that includes `ghCredentialHelper: false`:
`mounts` and `containerEnv` are static JSON with no option substitution, so a flag could
Expand Down
15 changes: 10 additions & 5 deletions features/src/devbase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,16 @@ The Feature now keeps the login in a **per-container named volume**:
- **Reset:** remove the volume while the container is gone —
`docker volume rm devbase-gh-<devcontainerId>` — and rebuild.

**Ownership failure.** Docker copies the image's ownership into a volume only when the
volume is _empty_. `install.sh` creates the directory owned by the remote user, so a fresh
volume is writable. A volume that already existed with another owner stays that way, and
post-create then reports `GH_CONFIG_DIR ... is missing or not writable`. Fix it with
`sudo chown -R "$(id -un)" /var/lib/devbase/gh`, or remove the volume and rebuild.
**Ownership.** The volume has to be writable by the remote user, and post-create repairs
that on every create with `sudo -n chown` of the directory (the devcontainers base images
grant passwordless sudo). It is needed because the Dev Containers CLI remaps the remote user
to the host's UID when it differs from the image's (any Linux host whose UID is not 1000,
GitHub runners included) and re-owns only the home directory, so a directory `install.sh`
created at build time keeps the old owner and a fresh volume inherits it. Docker also seeds
ownership only into an _empty_ volume, so a volume that already existed keeps its old owner.
Where sudo is missing or wants a password, post-create reports
`GH_CONFIG_DIR ... could not be repaired` and carries on; fix it with
`sudo chown "$(id -u):$(id -g)" /var/lib/devbase/gh`, or remove the volume and rebuild.

**No option switches the volume off**, and that includes `ghCredentialHelper: false`:
`mounts` and `containerEnv` are static JSON with no option substitution, so a flag could
Expand Down
4 changes: 2 additions & 2 deletions features/src/devbase/bin/devbase-post-create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ main() {
fi

# Unconditional, unlike the helper above: the gh login volume is declared in the
# manifest and mounts whatever the option says, so its ownership is checked regardless.
devbase_check_gh_config_dir
# manifest and mounts whatever the option says, so its ownership is repaired regardless.
devbase_setup_gh_config_dir

if [ "${DEVBASE_SSH_COMMIT_SIGNING:-true}" = "true" ]; then
devbase_setup_ssh_commit_signing
Expand Down
6 changes: 6 additions & 0 deletions features/src/devbase/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ chmod 0644 "${SHARE_DIR}"/*.sh
# the image's content at the mount target, ownership and mode included. Without this the
# volume comes up root-owned and `gh auth login` dies with "permission denied". Mode 0700:
# it holds an OAuth token.
#
# Necessary but not sufficient. The devcontainer CLI's updateRemoteUserUID layer remaps the
# remote user to the host's UID and re-owns only $HOME, so on a host whose UID is not the
# image's this directory stays owned by the old UID and the volume inherits it.
# devbase_setup_gh_config_dir repairs that at post-create; this still covers the common
# case where no remap happens.
GH_CONFIG_DIR_PATH="/var/lib/devbase/gh"
echo "devbase: creating ${GH_CONFIG_DIR_PATH} for the gh login volume"
install -d -m 0700 -o "${USERNAME}" -g "${USERNAME}" "${GH_CONFIG_DIR_PATH}"
Expand Down
62 changes: 45 additions & 17 deletions features/src/devbase/lib/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -502,36 +502,64 @@ devbase_setup_gh_credential_helper() {
fi
}

# devbase_check_gh_config_dir — verify the gh login volume is usable by this user.
# _devbase_dir_writable <dir> — true only when a file can really be created in dir.
#
# A write probe rather than `[ -w ]`: -w says nothing about a read-only mount or a full
# disk, and is always true for root.
_devbase_dir_writable() {
local probe
[ -d "$1" ] || return 1
probe="$(mktemp "$1/.devbase-probe.XXXXXX" 2>/dev/null)" || return 1
rm -f "${probe}"
}

# devbase_setup_gh_config_dir — make the gh login volume writable by this user, repairing
# its ownership when it is not.
#
# The manifest mounts a named volume at GH_CONFIG_DIR so the gh login survives a rebuild.
# install.sh creates the mount target owned by the remote user, and Docker copies that
# ownership into an *empty* volume — but only an empty one. A volume that already existed
# (an earlier devbase, another user, a manual `docker run`) keeps whatever owner it had, and
# then `gh auth login` fails with "permission denied" far from the cause. Unconditional,
# like the volume itself: it does not depend on ghCredentialHelper.
#
# Report-and-skip: never sudo, never fatal. Success is logged only after a real write, since
# `[ -w ]` says nothing about a read-only mount or a full disk.
devbase_check_gh_config_dir() {
# install.sh creates the mount target owned by the remote user, but that is not enough, and
# the main reason is the devcontainer CLI's updateRemoteUserUID layer: when the host UID
# differs from the image's (any Linux host whose UID is not 1000, which includes GitHub
# runners at 1001) it remaps the remote user to the host UID and re-owns only $HOME. A
# directory created at build time stays owned by the old UID, Docker seeds the new volume
# with that owner, and `gh auth login` then fails with "permission denied" far from the
# cause. The second cause is that Docker seeds ownership only into an *empty* volume, so a
# volume that already existed keeps whatever owner it had.
#
# Hence a repair rather than a check, run on every create and so on every rebuild. It uses
# `sudo -n` because the devcontainers base images grant the remote user passwordless sudo;
# without sudo, or when sudo wants a password, it reports and skips. Only the directory is
# chowned, not its contents: the directory is what blocks gh, and files inside were written
# by the same user on the same host. Unconditional, like the volume itself: it does not
# depend on ghCredentialHelper. Never fatal, and success is logged only after a second
# write probe.
devbase_setup_gh_config_dir() {
local dir="${GH_CONFIG_DIR:-}"
if [ -z "${dir}" ]; then
log_detail "GH_CONFIG_DIR is not set — gh keeps its login in ~/.config/gh"
return 0
fi

local probe=""
if [ -d "${dir}" ] && [ -w "${dir}" ]; then
probe="$(mktemp "${dir}/.devbase-probe.XXXXXX" 2>/dev/null)" || probe=""
if _devbase_dir_writable "${dir}"; then
log_success "gh config directory ${dir} is writable by $(id -un)"
return 0
fi

local old_uid="" repaired=false
if [ -d "${dir}" ] && command_exists sudo; then
old_uid="$(stat -c %u "${dir}" 2>/dev/null || true)"
sudo -n chown "$(id -u):$(id -g)" "${dir}" 2>/dev/null || true
sudo -n chmod 0700 "${dir}" 2>/dev/null || true
_devbase_dir_writable "${dir}" && repaired=true
fi

if [ -z "${probe}" ]; then
log_error "GH_CONFIG_DIR ${dir} is missing or not writable by $(id -un) — the gh login cannot be saved. Likely the volume devbase-gh-<devcontainerId> already existed with another owner (Docker sets ownership only on an empty volume). Fix: sudo chown -R \"$(id -un)\" ${dir}, or remove that volume and rebuild"
if [ "${repaired}" = "true" ]; then
log_success "gh config directory ${dir} was owned by uid ${old_uid:-unknown}; repaired to $(id -u) ($(id -un))"
return 0
fi

rm -f "${probe}"
log_success "gh config directory ${dir} is writable by $(id -un)"
log_error "GH_CONFIG_DIR ${dir} is missing or not writable by $(id -un) and could not be repaired (needs passwordless sudo, and a writable mount) — the gh login cannot be saved. Fix: sudo chown \"$(id -u):$(id -g)\" ${dir} && sudo chmod 0700 ${dir}, or remove the volume devbase-gh-<devcontainerId> and rebuild"
return 0
}

# devbase_check_gh_login — warn on attach when gh holds no login.
Expand Down
47 changes: 34 additions & 13 deletions features/test/devbase/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,12 @@ check "GH_CONFIG_DIR points at the devbase gh volume path" \
check "the gh config directory is a mount, not just a directory" \
bash -c 'grep -qF " /var/lib/devbase/gh " /proc/self/mountinfo'

# Writable by the remote user, proven by writing. This is the ownership failure the
# install.sh `install -d -o` exists for: a root-owned volume makes `gh auth login` fail with
# "permission denied", and nothing before this check would have noticed.
# Writable by the remote user, proven by writing, on the real volume. install.sh creates the
# directory owned by the build-time user, but the CLI's updateRemoteUserUID layer remaps that
# user to the host UID (1001 on GitHub runners) and re-owns only $HOME, so the volume is
# seeded with a stale owner and `gh auth login` would fail with "permission denied". It
# passes because post-create's devbase_setup_gh_config_dir repaired the ownership, which
# makes this the end-to-end check on a remapped-UID host.
check "the gh config directory is writable by the remote user" bash -c '
set -e
probe="${GH_CONFIG_DIR}/.test-probe"
Expand All @@ -381,16 +384,34 @@ check "a logged-in gh is not warned about" bash -c '
out="$(env -u GITHUB_TOKEN -u CI -u GITHUB_ACTIONS GH_TOKEN=dummy GH_CONFIG_DIR="$(mktemp -d)" bash -c ". /usr/local/share/devbase/setup.sh; devbase_check_gh_login" 2>&1)"
! printf "%s" "${out}" | grep -qF "gh auth login"'

# The ownership failure, reproduced with a read-only directory. Skipped under root, for
# which `-w` is always true and the directory could never be unwritable — the check would
# pass for the wrong reason, so it passes by declining instead.
check "an unwritable gh config directory is reported, not fatal" bash -c '
set -e
if [ "$(id -u)" -eq 0 ]; then exit 0; fi
d="$(mktemp -d)"; chmod 0500 "${d}"
out="$(GH_CONFIG_DIR="${d}" bash -c ". /usr/local/share/devbase/setup.sh; devbase_check_gh_config_dir" 2>&1)"
chmod 0700 "${d}"; rmdir "${d}"
printf "%s" "${out}" | grep -qF "not writable"'
# The repair, on the failure the UID remap produces: a directory owned by someone else. Both
# this and the next check decline under root (where every directory is writable, so there is
# nothing to repair) and when passwordless sudo is unavailable, since setting the scene needs
# it too — they pass by declining rather than for the wrong reason. Asserted on the effect:
# the directory is now ours and a file can be created in it.
check "a gh config directory owned by someone else is repaired" bash -c '
set -e
if [ "$(id -u)" -eq 0 ] || ! sudo -n true 2>/dev/null; then exit 0; fi
d="$(mktemp -d)"
sudo chown root:root "${d}"; sudo chmod 0755 "${d}"
GH_CONFIG_DIR="${d}" bash -c ". /usr/local/share/devbase/setup.sh; devbase_setup_gh_config_dir" >/dev/null 2>&1
[ -O "${d}" ]
: >"${d}/.probe"
rm -rf "${d}"'

# When the repair cannot happen (no passwordless sudo), the function must say so and still
# return 0 — a container that refuses to come up over this is not fixable from a terminal.
# A stub `sudo` that always fails stands in for that; `set -e` makes a non-zero return fail
# the check, and the directory is cleaned up with the real sudo.
check "an unrepairable gh config directory is reported, not fatal" bash -c '
set -e
if [ "$(id -u)" -eq 0 ] || ! sudo -n true 2>/dev/null; then exit 0; fi
d="$(mktemp -d)"; stubs="$(mktemp -d)"
printf "#!/bin/sh\nexit 1\n" >"${stubs}/sudo"; chmod +x "${stubs}/sudo"
sudo chown root:root "${d}"; sudo chmod 0755 "${d}"
out="$(PATH="${stubs}:${PATH}" GH_CONFIG_DIR="${d}" bash -c ". /usr/local/share/devbase/setup.sh; devbase_setup_gh_config_dir" 2>&1)"
sudo rm -rf "${d}"; rm -rf "${stubs}"
printf "%s" "${out}" | grep -qF "could not be repaired"'

# devbase_setup_ssh_commit_signing repairs the one failure a mounted host ~/.gitconfig
# guarantees: it names a user.signingkey under the host's ~/.ssh, which the container does
Expand Down
Loading