Skip to content

fix(devbase): accept a single-file history bind, and stop mounting the host home - #12

Merged
KaiSchwarz-cnic merged 3 commits into
mainfrom
RSRMID-3052/narrow-host-mounts
Sep 8, 2026
Merged

fix(devbase): accept a single-file history bind, and stop mounting the host home#12
KaiSchwarz-cnic merged 3 commits into
mainfrom
RSRMID-3052/narrow-host-mounts

Conversation

@KaiSchwarz-cnic

Copy link
Copy Markdown
Contributor

Part of RSRMID-3052 — every devcontainer in the fleet binds the whole host home read-write at /WSL_USER to reach one file in it, ~/.zsh_history, which is all devbase reads there.

Two commits, deliberately separate

fix(devbase) — the Feature needs no change to accept the narrow single-file bind: its history step only ever reads /WSL_USER/.zsh_history, and that function is unchanged since 1.7.0, which is the version every consumer's lock pins. The shell history is linked when the host mount exists test has always built exactly that shape.

What it did need is a branch for the narrow bind's own way of going wrong, and this turned out to be worse than expected. A file bind whose source does not exist on the host is created by Docker as a directory, on the host. /WSL_USER is then neither absent nor empty, so nothing upstream catches it, and the old code reported SUCCESS:

  • touch on an existing directory succeeds (it sets the mtime), so the bootstrap check short-circuited instead of failing
  • ln -sf linked ~/.zsh_history to that directory
  • the verification used -e, which a symlink to a directory satisfies

So the container logged History linked while zsh could not write a single line of history. That is the defect class this suite exists to prevent, and it would have shipped fleet-wide alongside the mount change.

Fixed at two widths: the directory check names the cause a reader can act on (the frame is missing its initializeCommand touch), and the success verification moves from -e to -f, which closes the class rather than the instance.

ci(devcontainer) — this repository's own two frames stop mounting the host home. ~/.gitconfig also goes readonly: devbase writes credential and signing config with git config --local, never --global, precisely so the host's file stays untouched, so nothing loses an ability it was using. Narrowing the mount retires the ${localEnv:HOME}${localEnv:USERPROFILE} idiom along with the line that carried it.

Per the dogfooding rule, this does not rely on an unreleased guarantee — the published 1.7.0 both locks pin already links a single-file bind correctly. The new check improves diagnostics when the touch is missing; it is not what makes the frames work.

Verification

pnpm features:lint and pnpm prettier are clean. The container suite runs here in CI (features/** is touched).

The function was also exercised directly against a relocated mount path for all five shapes:

shape result
narrow single-file bind links, SUCCESS
source is a directory is a directory, names initializeCommand, no symlink
mount absent detail, stays container-local
mount empty is empty, unchanged
legacy whole-home mount still bootstraps and links — unmigrated repositories keep working

And per the "check a new assertion against a broken implementation" rule, the source-is-a-directory case was re-run with the guard stripped out: it reports SUCCESS and the new assertion goes red, so the check cannot silently pass.

Release

fix(devbase) — a patch. Consumers on :1 with no lock pick it up on their next rebuild; the eight repositories that commit a lock keep 1.7.0 until someone re-resolves, which is fine because 1.7.0 already accepts the narrow bind.

…inking it

Frames are moving from binding the whole host home at /WSL_USER to binding the
host's ~/.zsh_history as a single file at /WSL_USER/.zsh_history, because
mounting a home directory to reach one file in it put every container within
read-write reach of the host's SSH keys and credentials — and, in the workspace
repository, made the readonly flag on its GitHub token directory decorative
(RSRMID-3052).

The Feature needs no change to *accept* that: the history step only ever reads
/WSL_USER/.zsh_history, so a single-file bind satisfies every branch, and the
"host mount exists" test has always built exactly that shape. What it does need
is a branch for the narrow bind's own way of going wrong.

A file bind whose source does not exist on the host is created by Docker as a
*directory*, on the host, where it outlives the container. /WSL_USER is then
neither absent nor empty, so nothing upstream catches it, and the old code
reported SUCCESS:

  - `touch` on an existing directory succeeds — it sets the mtime — so the
    bootstrap check short-circuited instead of failing,
  - `ln -sf` linked ~/.zsh_history to that directory,
  - and the verification used -e, which a symlink to a directory satisfies.

So the container logged "History linked" while zsh could not write a single
line of history to it. Exactly the defect class this suite exists to prevent,
and it would have shipped fleet-wide with the mount change.

Two fixes, deliberately at different widths. The directory check names the
cause the reader can act on: the frame is missing the initializeCommand touch
that guarantees the host file exists before the bind. And the verification moves
from -e to -f, which closes the class rather than the instance — the only thing
worth logging here is a link to a file that can be written.

Verified by exercising the function against a relocated mount path for all five
shapes (narrow bind, source-is-a-directory, mount absent, mount empty, and the
legacy whole-home mount, which still bootstraps and links so unmigrated
repositories keep working), and by re-running the source-is-a-directory case
with the guard stripped out to confirm the new assertion goes red.
Both frames bound ${localEnv:HOME} at /WSL_USER to reach one file in it,
~/.zsh_history, which is all devbase reads there. That handed the container
read-write reach over the host's SSH keys, shell profiles and every other
repository on the machine. In the workspace repository the same pattern made
the readonly flag on its GitHub token directory decorative — the same inodes
were writable by the second path — which is what RSRMID-3052 is closing across
the fleet.

Bind the single file at the exact path the Feature reads, and touch it in
initializeCommand alongside ~/.gitconfig, for the reason already documented
there: a missing bind source becomes a root-owned directory on the host, and
without the touch the preceding commit's new check is what a rebuild would hit.

~/.gitconfig also becomes readonly. devbase writes credential and signing
config with `git config --local`, never `--global`, precisely so the host's
file stays untouched, so nothing here loses an ability it was using.
~/.zsh_history and ~/.claude stay read-write because both are written.

Narrowing the mount retires the ${localEnv:HOME}${localEnv:USERPROFILE} idiom
along with the line that carried it. It had no conditional to work with and
relied on exactly one of the two being empty; a host defining both yielded a
nonexistent path, Docker created it, and history silently stopped surviving
rebuilds. The other mounts here already used bare ${localEnv:HOME}.

This does not rely on an unreleased guarantee: the published 1.7.0 both locks
pin already links a single-file bind correctly — its history step reads only
/WSL_USER/.zsh_history and that function is unchanged since 1.7.0. The new
check from the preceding commit improves the diagnostics when the touch is
missing; it is not what makes this work.
@KaiSchwarz-cnic

Copy link
Copy Markdown
Contributor Author

Feature: devbase is red for a reason unrelated to this PR

Every history test passes, including the new one. From this PR's run:

✅  Passed 'shell history is linked when the host mount exists'
✅  Passed 'shell history stays container-local without the host mount'
✅  Passed 'shell history is skipped in CI'
✅  Passed 'shell history bootstraps when the host has no history file'
✅  Passed 'a directory where the history file belongs names the missing touch'   <- new

The single failure is the node_project scenario's installs Node dependencies from a real package.json.

It is pre-existing. I dispatched Test features on main unchanged as a control (run 34218813164) and it fails identically — same scenario, same check, same three passing scenarios. This PR's setup.sh diff is two hunks, both inside devbase_setup_history_persistence; devbase_setup_project_dependencies and node_project.sh are untouched.

Root cause: pnpm 12 made --frozen-lockfile strict, and pnpmVersion: latest floated us onto it. CI now runs pnpm 12.3.4. Reproduced directly:

$ # pnpm 11.25.0, package.json with no dependencies and no lockfile
$ pnpm install --frozen-lockfile --silent ; echo "exit=$?"
exit=0
$ ls -a
. .. node_modules package.json pnpm-lock.yaml      # tolerated: lockfile created

$ # pnpm 12.3.4, identical input
$ pnpm dlx pnpm@12.3.4 install --frozen-lockfile --silent
Error: ERR_PNPM_NO_LOCKFILE
  × installing dependencies
  ╰─▶ Headless installation requires a pnpm-lock.yaml file, but none was
      found. Run `pnpm install` without --frozen-lockfile to create one.

So in devbase_setup_project_dependencies the first branch now fails, control falls to the --no-frozen-lockfile retry, that succeeds, and it logs

Installed without the frozen lockfile — commit the updated pnpm-lock.yaml

via log_detail — not log_success "Node dependencies installed". The check greps for the latter, so it fails.

Worth being clear about what is and is not broken: the container behaviour is correct. The stale-lockfile fallback did exactly the job it was added for, and node_modules is installed. What broke is the assertion, which passed on pnpm 11 only because pnpm tolerated a missing lockfile — the scenario's probe project is created with a package.json and no lockfile, then asserted against the frozen path's success message. pnpm 12 turned that latent mismatch into a failure.

Last green run on this workflow was 2026-08-28, so nothing had exercised it since the pnpm major landed.

I have deliberately not folded a fix into this PR — it is a separate defect in a separate function, and it wants its own change and its own review. Flagging it here so the red check is not read as this PR's doing.

`node_project`'s "installs Node dependencies from a real package.json" check
has been failing on main since pnpm 12 reached CI through the node feature's
`pnpmVersion: latest`. Not caused by the mount work in this branch — verified
by dispatching this workflow on main unchanged (run 34218813164), which fails
identically.

devbase tries `pnpm install --frozen-lockfile` first, and that is a headless
install, so it needs a lockfile. The probe shipped a package.json alone and
still reached the success branch, because pnpm 11 quietly created the missing
lockfile rather than refusing. pnpm 12 made it strict:

  $ # pnpm 11.25.0, no lockfile
  $ pnpm install --frozen-lockfile --silent ; echo $?
  0                                     # lockfile created for us

  $ # pnpm 12.3.4, same input
  $ pnpm install --frozen-lockfile --silent
  Error: ERR_PNPM_NO_LOCKFILE
    ╰─▶ Headless installation requires a pnpm-lock.yaml file, but none was
        found. Run `pnpm install` without --frozen-lockfile to create one.

So the frozen attempt now fails, the `--no-frozen-lockfile` retry takes over
and succeeds, and it reports through log_detail — "Installed without the frozen
lockfile" — rather than log_success "Node dependencies installed", which is
what this check greps for.

Worth being clear that the container behaviour was never broken. The retry did
exactly the job it was added for and node_modules is installed; what broke is
an assertion that had been passing on pnpm's leniency.

Two changes:

- The probe commits a lockfile, so it exercises the branch it names. Hand
  written rather than generated: an empty importer set is all a manifest with
  no dependencies needs, it reaches no network, and it satisfies
  --frozen-lockfile on pnpm 11 and 12 alike — which is the point, because
  `latest` floats and whichever major CI lands on next has to pass unchanged.

- A check for the no-lockfile case, which only pnpm 12 made distinct from the
  above. It accepts either log line for the same version-agnostic reason, and
  it guards a specific temptation: reading the frozen attempt as a hard
  requirement and making a missing lockfile fatal would stop a container over a
  file the retry can produce.

Both failing greps now dump the log on failure. The original reported only
"check failed", which is why the cause took a control run and two local pnpm
versions to establish rather than being readable from CI.
@KaiSchwarz-cnic

Copy link
Copy Markdown
Contributor Author

Changed course: the fix is now in this PR

Above I said I was deliberately keeping this out. I have folded it in as one self-contained commit, test(devbase): give the frozen-install probe the lockfile it needs, for two reasons:

  1. main is red, so this is not a follow-up that can wait behind anything — the suite gates nothing until it is fixed, including this PR.
  2. I could not open a separately-numbered branch. Branch naming here requires a Jira issue ID, and Atlassian writes are currently returning 403 — the app is not installed on this instance, so I cannot file the ticket that a separate branch would need.

It is one commit touching one file, so splitting it back out is trivial if you would rather it went separately.

What the fix does

The probe now commits a lockfile, so it exercises the frozen branch it names. Written by hand rather than generated — an empty importer set is all a dependency-less manifest needs, it reaches no network, and it satisfies --frozen-lockfile on both majors:

$ printf "lockfileVersion: '9.0'\n\nimporters:\n\n  .: {}\n" > pnpm-lock.yaml
$ pnpm install --frozen-lockfile --silent            # 11.25.0 -> rc=0, node_modules present
$ pnpm dlx pnpm@12.3.4 install --frozen-lockfile --silent   # 12.3.4 -> rc=0, node_modules present

That cross-major property is the point rather than a detail: the node feature installs pnpm latest, so whichever major CI lands on next has to pass this unchanged. Pinning the expectation to one pnpm is what produced this failure in the first place.

Second, a check for the no-lockfile case, which only pnpm 12 made distinct — it is refused now instead of silently created, so devbase reaches the refreshed retry and the container still comes up. It accepts either log line, for the same version-agnostic reason, and it guards a specific temptation: reading the frozen attempt as a hard requirement and making a missing lockfile fatal would stop a container over a file the retry can produce.

Both failing greps now cat the log. The original reported only check failed, which is why establishing the cause took a control run on main plus two local pnpm versions instead of being readable from the CI output.

Still worth a ticket

The test was the thing that broke, but the underlying hazard is that pnpmVersion: latest floated a major into CI unobserved — the last green run on this workflow was 2026-08-28, so nothing exercised it for eleven days. That is the same class of drift devbase_setup_pnpm exists to remove for consumers, still present in the Feature's own test environment. I cannot file it while Jira writes are 403.

@KaiSchwarz-cnic
KaiSchwarz-cnic merged commit 9fff75f into main Sep 8, 2026
9 checks passed
@KaiSchwarz-cnic
KaiSchwarz-cnic deleted the RSRMID-3052/narrow-host-mounts branch September 8, 2026 12:31
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 1.7.1 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant