From 39e2092c832adf793f8b4135362459ad2fc860b6 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Mon, 24 Aug 2026 19:20:40 +0100 Subject: [PATCH 1/3] fix(devcontainer): sync to python_template's zero-build config The devcontainer now pulls ghcr.io/blooop/python_template/devcontainer:latest instead of building the image locally. - The duplicate claude-code `features` block is gone. The feature is baked into that image already (ci/devcontainer.json is what CI builds it from, and that declares ../claude-code), so declaring it here made the spec build a derived image on the first launch of every branch and reinstall what the pulled image already had. - `--network=host` is gone. A container per branch is the point of `dl`, and host networking undoes it: every port a dev server binds becomes a host port, so two branches collide on the first one they share. - The `~/.ssh` and `~/.config/gh` mounts are gone. `dl` supplies GH_TOKEN, and the gh mount never worked in the first place because gh keeps its token in the system keyring, so the mounted hosts.yml carried no oauth_token. - `name` moved to the end of the file. It is the only per-repo line here, and sitting next to the image block -- the block the template changes most -- it conflicted on every `pixi run update-from-template-repo`. The tree is byte-identical to blooop/python_template#170 apart from that name. --- .devcontainer/Dockerfile | 9 +- .devcontainer/ci/devcontainer.json | 12 + .devcontainer/claude-code/README.md | 441 ++++++++++++++++++ .devcontainer/claude-code/TROUBLESHOOTING.md | 381 +++++++++++++++ .../claude-code/devcontainer-feature.json | 22 + .devcontainer/claude-code/init-host.sh | 6 + .devcontainer/claude-code/install.sh | 184 ++++++++ .devcontainer/devcontainer.json | 139 ++++-- 8 files changed, 1161 insertions(+), 33 deletions(-) create mode 100644 .devcontainer/ci/devcontainer.json create mode 100644 .devcontainer/claude-code/README.md create mode 100644 .devcontainer/claude-code/TROUBLESHOOTING.md create mode 100644 .devcontainer/claude-code/devcontainer-feature.json create mode 100755 .devcontainer/claude-code/init-host.sh create mode 100755 .devcontainer/claude-code/install.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 8d9fb5e..ec622e6 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,6 @@ -FROM mcr.microsoft.com/devcontainers/base:jammy +FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 -ARG PIXI_VERSION=v0.32.1 +ARG PIXI_VERSION=v0.75.0 RUN curl -L -o /usr/local/bin/pixi -fsSL --compressed "https://github.com/prefix-dev/pixi/releases/download/${PIXI_VERSION}/pixi-$(uname -m)-unknown-linux-musl" \ && chmod +x /usr/local/bin/pixi \ @@ -10,4 +10,7 @@ RUN curl -L -o /usr/local/bin/pixi -fsSL --compressed "https://github.com/prefix USER vscode WORKDIR /home/vscode -RUN echo 'eval "$(pixi completion -s bash)"' >> /home/vscode/.bashrc +RUN echo 'eval "$(pixi completion -s bash)"' >> /home/vscode/.bashrc \ + && echo 'export PATH="$HOME/.pixi/bin:$PATH"' >> /home/vscode/.profile \ + && echo '# Workaround: pixi trampoline fails for bash scripts, so add env bin directly' >> /home/vscode/.profile \ + && echo '[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"' >> /home/vscode/.profile diff --git a/.devcontainer/ci/devcontainer.json b/.devcontainer/ci/devcontainer.json new file mode 100644 index 0000000..884dff1 --- /dev/null +++ b/.devcontainer/ci/devcontainer.json @@ -0,0 +1,12 @@ +{ + "name": "python_template-build", + "build": { + "dockerfile": "../Dockerfile", + "context": "." + }, + "features": { + "../claude-code": {}, + // "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + // "ghcr.io/devcontainers/features/common-utils:2": {} + } +} diff --git a/.devcontainer/claude-code/README.md b/.devcontainer/claude-code/README.md new file mode 100644 index 0000000..4e358b1 --- /dev/null +++ b/.devcontainer/claude-code/README.md @@ -0,0 +1,441 @@ +# Claude Code CLI - Local Dev Container Feature + +A local Dev Container Feature that installs the Claude Code CLI and bind-mounts your host machine's Claude configuration directory into the container. + +## What This Feature Does + +This feature combines two capabilities: + +1. **CLI Installation**: `install.sh` runs `pixi global install --channel https://prefix.dev/blooop claude-shim`, and downloads pixi to `/usr/local/bin/pixi` first if the base image does not already carry it +2. **Configuration Mounting**: Bind-mounts your host machine's `~/.claude` directory into the container, read-write + +## What Gets Installed + +- **Claude Code CLI**: The `claude` command becomes available in your container. It comes from the `claude-shim` package on the `blooop` prefix.dev channel, so that channel is a dependency of this feature. `install.sh` checks only that the pixi trampoline exists -- the binary it points at is downloaded on the first `claude` run. +- **VS Code Extension**: Automatically installs the `anthropic.claude-code` extension +- **Configuration Directories**: `install.sh` creates the `.claude/` tree, though it does so while the image is built -- at runtime the host's bind mount covers it + +## What Gets Mounted + +One mount, and it is the whole directory: + +``` +source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind +``` + +There is no `ro` flag and no per-file mount. The host and every container of every branch share one `~/.claude`, read-write, so anything running in a container can modify any of it -- `CLAUDE.md`, `settings.json`, `agents/`, `commands/` and `hooks/` included. + +### What That Means + +`hooks/` and `settings.json` are executed by Claude Code wherever it runs. Content a container writes there therefore runs on the **host**, the next time Claude Code starts on the host, and a `postCreateCommand` from a repository you have not read is enough to put it there. + +It is not a confidentiality boundary either: code running in the container holds the live Claude credentials the mount carries, and under `dl` a `GH_TOKEN` with repo and workflow scopes. + +### Why It Is Still One Read-Write Directory + +Sharing the directory is what makes credentials work across the host and every branch container. `.credentials.json` has to be writable because the access token is short-lived and a refresh has to persist -- a read-only or copied arrangement drifts into a re-auth. `.claude.json` has to be writable because Claude tracks per-workspace onboarding and trust state there, so a container that cannot write it re-onboards on every launch. + +Splitting the rest of the directory into separate read-only binds is possible and is not what this feature does today. What the container buys as it stands is reproducible dependencies and non-colliding concurrent work, not safety against hostile code. `blooop/wayfinder`'s `.devcontainer/devcontainer.json` states the same threat model in the comment above its `mounts` block. + +## Usage + +### Setup + +The feature is declared where the image is built, in `.devcontainer/ci/devcontainer.json`: + +```json +{ + "build": { + "dockerfile": "../Dockerfile", + "context": "." + }, + "features": { + "../claude-code": {} + } +} +``` + +CI publishes that image, and the `devcontainer.json` every branch launches from pulls it and declares neither `features` nor `runArgs`: + +```json +{ + "image": "ghcr.io/blooop/python_template/devcontainer:latest", + "containerEnv": { + "CLAUDE_CONFIG_DIR": "/home/vscode/.claude" + }, + "mounts": [ + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind" + ] +} +``` + +Declaring the feature a second time there makes the devcontainer spec build a derived image on the first launch of every branch, reinstalling what the pulled image already carries. + +### Authentication Uses Host Credentials, Not Host Networking + +No OAuth flow runs inside the container. You authenticate `claude` once on the host, and the `~/.claude` bind mount plus `CLAUDE_CONFIG_DIR=/home/vscode/.claude` point the container at those same credentials, refresh tokens included. Every container of every branch reads them, and nothing has to reach the host's network to do it. + +### Why You Might Opt Into `--network=host` + +The one thing the bind mount does not give you is the interactive OAuth login *from inside* the container, which needs host networking to complete: + +1. You run `claude` → it starts a callback server on a random port in the container +2. Your browser opens the authorize page, you click "Authorize", and it redirects to `http://localhost:/callback` +3. On the default bridge network that port belongs to the container, not the host, so the browser cannot reach it and the CLI sits at "Paste code here" + +With `--network=host` the container shares the host's network namespace, port X in the container *is* port X on the host, and the callback lands. + +Two costs come with it, and they are why this template does not set it: + +- **VS Code extensions stop installing**: [vscode-remote-release#9212](https://github.com/microsoft/vscode-remote-release/issues/9212), covered again under Troubleshooting below. +- **Every port the container binds becomes a host port.** A container per branch is the reason these repos are launched with `dl`, and two branch containers on the host's network namespace collide on the first port they share. + +Host networking also gives the container full access to the host's network, so only use it in environments you trust. + +### Build the Container + +With DevPod: +```bash +devpod up . --recreate +``` + +With VS Code: +- Open the folder in VS Code +- Run: "Dev Containers: Rebuild Container" + +## Requirements + +### Host Machine + +`init-host.sh` runs on the host as the `initializeCommand` and creates `~/.claude` if it is missing. Nothing creates the contents below; they are optional, and the container starts without them: + +```bash +~/.claude/ +├── CLAUDE.md # Optional: global instructions +├── settings.json # Optional: Claude settings +├── agents/ # Optional: custom agents +├── commands/ # Optional: custom commands +└── hooks/ # Optional: event hooks +``` + +**Note**: The mount is the `~/.claude` directory itself, so a missing subdirectory or file costs nothing at launch. To create them anyway: + +```bash +mkdir -p ~/.claude/{agents,commands,hooks} +touch ~/.claude/CLAUDE.md +touch ~/.claude/settings.json +``` + +### Container + +- **pixi**, which the `Dockerfile` installs to `/usr/local/bin/pixi`; `install.sh` downloads it there itself if it is missing +- No manual configuration required + +## Assumptions + +1. **Container User**: This feature assumes the container user is `vscode` (standard for Dev Containers) + - Configuration files are mounted to `/home/vscode/.claude/` + - If your container uses a different user (e.g., `root`, `codespace`), you'll need to customize the mounts in your `devcontainer.json` + +2. **HOME Environment Variable**: Must be set on the host machine (standard on Unix systems) + +3. **Persistence**: Your host machine's `~/.claude/` directory should persist across container rebuilds + +4. **Platform**: Designed for Linux/macOS hosts + - Windows WSL2 should work + - Windows native may require path adjustments + +## How to Iterate Locally + +### Quick Changes + +1. Edit files in `.devcontainer/claude-code/`: + - `devcontainer-feature.json` - Change mounts, extensions, or metadata + - `install.sh` - Modify installation logic + - `README.md` - Update documentation + +2. Rebuild the container: + ```bash + devpod up . --recreate + ``` + +### Testing Install Script + +You can test the install script standalone, from inside the container: + +```bash +cd .devcontainer/claude-code +sudo ./install.sh +``` + +It resolves its target from `_REMOTE_USER` and `_REMOTE_USER_HOME` and falls back to `vscode`, so on a host with no `/home/vscode` it exits with an error rather than doing anything. + +### Debugging + +Check if Claude is installed: +```bash +claude --version +``` + +Check mounted files: +```bash +ls -la ~/.claude/ +``` + +Verify the config directory is mounted, writable, and pointed at: +```bash +env | grep CLAUDE_CONFIG_DIR # /home/vscode/.claude +mount | grep /home/vscode/.claude # one bind, rw +touch ~/.claude/.mount-check && rm ~/.claude/.mount-check && echo writable +``` + +The write test uses a throwaway file on purpose. Do not test the mount by appending to `CLAUDE.md`, `settings.json` or anything under `hooks/`: the mount is read-write, so the write lands on the host's real configuration and is loaded into every later Claude Code session. + +## Authentication + +### How It Works + +1. **Already Authenticated on Host**: If you have Claude Code set up on your host machine, credentials are automatically shared with the container +2. **First-Time Setup**: Run `claude` on the **host** and follow the OAuth flow there: + - The CLI provides an OAuth URL + - Open the URL in your browser + - Click "Authorize" + - The callback completes, because the CLI and the browser are both on the host + - Credentials are saved to `~/.claude/.credentials.json`, and the mount carries them into every container + +### OAuth Callback Behavior + +The OAuth flow opens a local callback server. In containers, this can behave differently: +- **VS Code Dev Containers**: Usually handles port forwarding automatically +- **DevPod**: May require manual code pasting if callback doesn't complete +- **SSH/Remote**: Callback URL opens in your local browser + +### Troubleshooting Authentication + +**"Paste code here" prompt hangs forever:** +- Check that `~/.claude/.credentials.json` exists on your host with proper permissions (`600`) +- Try authenticating on your host machine first, then restart `claude` in the container -- the mount is live, so no rebuild is needed +- If the callback fails, look for the authorization code in the URL after clicking "Authorize" + +**Credentials not persisting:** +- Ensure the `.credentials.json` file exists on your host before rebuilding +- Check file permissions: `chmod 600 ~/.claude/.credentials.json` + +**Setup wizard runs on every rebuild (theme selection, OAuth):** + +This happens because Claude tracks setup completion **per-workspace**, not globally. + +**Quick fix:** +```bash +# On your HOST machine: +# Set the onboarding flag for your workspace (`devpod list` shows its name) +jq '.projects["/workspaces/"].projectOnboardingSeenCount = 1' ~/.claude/.claude.json > ~/.claude/.claude.json.tmp +mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json + +# Also ensure themeMode is set (if needed) +jq '. + {themeMode: "dark"}' ~/.claude/.claude.json > ~/.claude/.claude.json.tmp +mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json + +# Then restart `claude` in the container -- the mount is live, so no rebuild is needed +``` + +**Root cause:** Claude tracks setup wizard completion per-workspace in `.claude.json` under `.projects["/workspaces/"].projectOnboardingSeenCount`. When this is `0`, the setup wizard runs. Set it to `1` to mark setup as complete. + +**Finding ``:** it is the devpod workspace name, and the container mounts the repo at `/workspaces/`. `devpod list` shows the name, and `dl --ls` shows it for workspaces devlaunch created. + +## Modifying Configuration + +The container writes to the same `~/.claude` as the host, so an edit made in either place is an edit to the one shared configuration. + +Editing from the **host** is still the better habit: `~/.claude/settings.json`, `~/.claude/CLAUDE.md` and the rest are yours across every branch container, and a change made on the host is one you meant to make. The mount is live, so restarting `claude` picks up a change -- no rebuild needed. + +## What Would Change Before Publishing to GHCR + +If you wanted to publish this feature to GitHub Container Registry later: + +### 1. Repository Structure + +Move from `.devcontainer/claude-code/` to a dedicated repo: + +``` +anthropics/devcontainer-features/ +└── src/ + └── claude-code/ + ├── devcontainer-feature.json + ├── install.sh + └── README.md +``` + +### 2. Metadata Updates + +In `devcontainer-feature.json`: + +```json +{ + "id": "claude-code", + "version": "1.0.0", // Semantic versioning + "documentationURL": "https://github.com/anthropics/devcontainer-features/tree/main/src/claude-code", + // ... rest of config +} +``` + +### 3. Testing Infrastructure + +Add GitHub Actions workflow (`.github/workflows/test.yaml`): + +```yaml +- name: "Create test prerequisites" + run: | + mkdir -p ~/.claude/agents + mkdir -p ~/.claude/commands + mkdir -p ~/.claude/hooks + touch ~/.claude/settings.json + touch ~/.claude/CLAUDE.md +``` + +### 4. Publishing Workflow + +Add release workflow to build and push to `ghcr.io/anthropics/devcontainer-features/claude-code:1` + +### 5. Reference Change + +Users would then reference it as: + +```json +{ + "features": { + "ghcr.io/anthropics/devcontainer-features/claude-code:1": {} + } +} +``` + +Instead of `"../claude-code": {}` + +## Optional: Future Composition + +### Splitting into Modular Features + +This feature could be split into: + +1. **`claude-code-core`**: Just CLI installation, no mounts + ```json + { + "features": { + "./claude-code-core": {} + } + } + ``` + +2. **`claude-code-mounts`**: Just configuration mounts (requires `claude-code-core`) + ```json + { + "features": { + "./claude-code-core": {}, + "./claude-code-mounts": {} + } + } + ``` + +Benefits: +- Users can install CLI without mounts (useful for Codespaces or CI) +- More flexible composition +- Easier to maintain and test separately + +### Composition with Custom Features + +You could create a personal feature that extends this: + +```json +// .devcontainer/my-claude-setup/devcontainer-feature.json +{ + "id": "my-claude-setup", + "installsAfter": ["./claude-code"], + "customizations": { + "vscode": { + "settings": { + "claude.someCustomSetting": "value" + } + } + } +} +``` + +Then use both: + +```json +{ + "features": { + "./claude-code": {}, + "./my-claude-setup": {} + } +} +``` + +## Troubleshooting + +### OAuth callback hangs at "Paste code here" + +**Problem**: Browser clicks "Authorize" but container never receives the callback. + +**Solution**: Run `claude` on the host instead and let the container read the credentials it writes to `~/.claude`. If you need the login to happen inside the container, add `--network=host` and accept its costs -- see "Why You Might Opt Into `--network=host`" above. + +### Interactive `claude` asks for authentication but `claude --print` works + +**Problem**: You're authenticated (credentials mounted) but interactive mode prompts for login. + +**Root cause**: Interactive mode tried to start an OAuth flow, which means it found no usable credentials under `CLAUDE_CONFIG_DIR`. + +**Solution**: Authenticate on the host so `~/.claude/.credentials.json` holds a live token, and check that `~/.claude` is actually mounted and `CLAUDE_CONFIG_DIR` points at it. + +### VS Code extensions don't install with `--network=host` + +**Known Issue**: [Using runArgs network=host prevents extensions from installing](https://github.com/microsoft/vscode-remote-release/issues/9212) + +**Workarounds:** +1. **Rebuild without runArgs first**, let extensions install, then add runArgs (extensions persist) +2. **Authenticate on host**, mount credentials, remove runArgs (no OAuth needed in container) +3. **Manually install extensions** after container starts + +### `~/.claude` missing on the host + +**Solution**: The `initializeCommand` (`init-host.sh`) creates it before the container starts. To lay out the rest yourself: + +```bash +mkdir -p ~/.claude/{agents,commands,hooks} +touch ~/.claude/CLAUDE.md ~/.claude/settings.json +``` + +## Security Notes + +The whole `~/.claude` directory is bind-mounted read-write as one mount, so nothing in it is held back from the container. + +### What Code in the Container Can Read and Write +- **`.credentials.json`**: the live OAuth access and refresh tokens +- **`.claude.json`**: account info, user ID, per-workspace onboarding and trust state +- **`CLAUDE.md`**, **`settings.json`**, **`agents/`**, **`commands/`**, **`hooks/`**: the host's copies, in place + +`install.sh` runs when the image is built, so the two `600` credential files it creates live in the image and the bind mount covers them at runtime. Permissions on the host's real files are whatever the host set -- see Issue 6 in TROUBLESHOOTING.md. Nothing here restricts code running inside the container, which runs as the user those files belong to. + +### The Consequence Worth Naming +`hooks/` and `settings.json` are executed by Claude Code wherever it runs. Code in the container that writes there gets its content executed on the **host**, the next time Claude Code starts there -- a `postCreateCommand` from a repository you have not read reaches that far. The container also carries the live Claude credentials and, under `dl`, a `GH_TOKEN` with repo and workflow scopes, so it is not a confidentiality boundary either. + +### Why It Is Accepted +One shared config directory is what makes auth work across the host and every branch container without a re-auth, and it is why a container never re-onboards. That is the trade; the isolation buys reproducible dependencies and non-colliding concurrent work, not protection from hostile code. `blooop/wayfinder`'s `.devcontainer/devcontainer.json` writes out the same threat model above its `mounts` block. Treat a repository you launch this way as code you are running with your own credentials, because that is what it is. + +Whether the non-credential paths should become read-only binds is an open question, not a settled one. + +See related security discussions: +- [anthropics/claude-code#4478](https://github.com/anthropics/claude-code/issues/4478) +- [anthropics/claude-code#2350](https://github.com/anthropics/claude-code/issues/2350) +- Per-file read-only approach this feature does not implement: [PR #25](https://github.com/anthropics/devcontainer-features/pull/25) + +## Reference + +- **Dev Container Features Spec**: https://containers.dev/implementers/features/ +- **Local Features**: https://containers.dev/implementers/features/#local-features +- **Based on PR**: https://github.com/anthropics/devcontainer-features/pull/25 +- **Upstream Features**: https://github.com/anthropics/devcontainer-features + +## License + +Based on the Anthropic devcontainer-features repository (MIT License). diff --git a/.devcontainer/claude-code/TROUBLESHOOTING.md b/.devcontainer/claude-code/TROUBLESHOOTING.md new file mode 100644 index 0000000..59e2156 --- /dev/null +++ b/.devcontainer/claude-code/TROUBLESHOOTING.md @@ -0,0 +1,381 @@ +# Claude Code Dev Container Feature - Troubleshooting Guide + +## Quick Reference + +### Files on the Host + +`init-host.sh` creates `~/.claude` as the `initializeCommand`. Everything inside it is optional, and only `.credentials.json` is load-bearing for an authenticated `claude`. + +```bash +~/.claude/ # one bind mount, read-write, shared with every container +├── .credentials.json # OAuth tokens +├── .claude.json # Account info, setup state +├── CLAUDE.md # Global instructions +├── settings.json # Settings +├── agents/ # Custom agents +├── commands/ # Custom commands +└── hooks/ # Event hooks +``` + +Every one of these is writable from inside the container, and a write lands on the host. See "Security Considerations" below for what follows from that. + +### Critical Configuration in devcontainer.json + +```json +{ + "image": "ghcr.io/blooop/python_template/devcontainer:latest", + "containerEnv": { + "CLAUDE_CONFIG_DIR": "/home/vscode/.claude", + "XDG_CONFIG_HOME": "/home/vscode/.config", + "XDG_CACHE_HOME": "/home/vscode/.cache", + "XDG_DATA_HOME": "/home/vscode/.local/share" + }, + "mounts": [ + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind" + ] +} +``` + +The `../claude-code` feature is declared in `.devcontainer/ci/devcontainer.json`, the config CI builds the image from, so there is no `features` block here -- and no `runArgs` either. + +## Common Issues and Solutions + +### Issue 1: Setup Wizard Runs on Every Container Rebuild + +**Symptoms:** +- Interactive `claude` shows theme selection screen +- After selecting theme, asks for OAuth authentication +- Happens every time you rebuild the container + +**Root Cause:** +Claude tracks setup completion per-workspace in `.claude.json`: +```json +{ + "projects": { + "/workspaces/": { + "projectOnboardingSeenCount": 0 // ← This! + } + } +} +``` + +`` is the devpod workspace name -- `devpod list` shows it, and `dl --ls` shows it for workspaces devlaunch created. + +**Solution:** +```bash +# On HOST machine, set a high count to skip wizard +jq '.projects["/workspaces/"].projectOnboardingSeenCount = 999' \ + ~/.claude/.claude.json > ~/.claude/.claude.json.tmp +mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json + +# Also ensure themeMode is set (global setting) +jq '. + {themeMode: "dark"}' ~/.claude/.claude.json > ~/.claude/.claude.json.tmp +mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json + +# Then restart `claude` in the container -- the mount is live, so no rebuild is needed +``` + +**Why 999?** The field is `projectOnboardingSeenCount` - it increments each time you see the wizard. Setting it high tells Claude "this workspace has been onboarded many times, skip the wizard." + +**Verification:** +```bash +# From the host, shell into the container +devpod ssh +claude # Should go straight to interactive mode without wizard +``` + +### Issue 2: OAuth Callback Hangs at "Paste code here" + +**Symptoms:** +- Browser opens, you click "Authorize" +- CLI shows "Paste code here >" and waits forever +- Browser callback URL fails to connect + +**Root Cause:** +OAuth callback server runs inside container on a random port (e.g., `localhost:35673`). Your browser tries to connect to that port on the HOST, but the container's port isn't accessible. + +**Solution:** +Authenticate on the host, where the browser can reach the callback port, and let the container read the resulting credentials through the `~/.claude` bind mount. No OAuth flow then runs in the container at all. + +**Alternative, if you want the login to happen inside the container:** +Add `--network=host` to devcontainer.json: + +```json +{ + "runArgs": ["--network=host"] +} +``` + +This makes the container share the host's network namespace, so ports inside the container are accessible from the host browser. + +**What that costs:** +VS Code extensions stop installing (known issue: [#9212](https://github.com/microsoft/vscode-remote-release/issues/9212)), the container gets full access to the host's network, and every port the container binds becomes a host port -- so two branch containers of the same repo collide on the first port they share. + +### Issue 3: `claude --print` Works But Interactive `claude` Asks for Login + +**Symptoms:** +- `echo "test" | claude --print` works without authentication +- Running just `claude` shows setup wizard or login prompt + +**Root Cause:** +Two different issues: +1. **Setup wizard** (theme/onboarding) - see Issue 1 +2. **Print mode skips workspace trust dialogs** - expected behavior + +**Solution:** +- For setup wizard: See Issue 1 +- For workspace trust: Use `--dangerously-skip-permissions` in trusted containers + +### Issue 4: Authentication Doesn't Persist After Container Rebuild + +**Symptoms:** +- You authenticate in the container +- Rebuild the container +- Have to authenticate again + +**Root Cause:** +`~/.claude` is not mounted, so `claude` wrote its credentials into the container's own filesystem and they went away with the container. + +**Solution:** + +1. **Verify the mount in the container:** + ```bash + # From the host, shell into the container (`devpod list` shows ``, + # `dl --ls` for workspaces devlaunch created) + devpod ssh + mount | grep claude + ``` + + Should show one bind of the directory, read-write: + ``` + /dev/... on /home/vscode/.claude type ext4 (rw,...) + ``` + +2. **Check files exist on host:** + ```bash + ls -la ~/.claude/.credentials.json ~/.claude/.claude.json + ``` + +3. **Check the mount is read-write:** + A refresh has to persist, so `rw` in the line above is load-bearing. The feature declares no `ro` flag, so a read-only mount means something outside it added one. + +### Issue 5: A Container Changed the Host's Claude Configuration + +**Symptoms:** +- `~/.claude/CLAUDE.md`, `settings.json` or a file under `hooks/` differs from what you left on the host +- A hook or setting you did not write takes effect when you start `claude` on the host + +**Root Cause:** +Not a malfunction. `~/.claude` is one read-write bind of the whole directory, so the host and every container share it and anything in a container can write any of it. `hooks/` and `settings.json` are executed by Claude Code wherever it runs, so what a container leaves there runs on the host next time. + +**Solution:** +Restore the files from wherever your configuration lives -- keeping `~/.claude` under version control is what makes a change like this visible and reversible. Then look at what put it there: a `postCreateCommand`, a hook, or an agent session in the container all reach that far. + +### Issue 6: File Permission Errors (600 vs 664) + +**Symptoms:** +- Cannot read credentials file +- Permission denied errors + +**Solution:** +```bash +# On HOST +chmod 600 ~/.claude/.credentials.json +chmod 600 ~/.claude/.claude.json +``` + +These files contain sensitive data and should only be readable by you. + +## Debugging Commands + +### Check Authentication Status + +```bash +# In container +cat ~/.claude/.credentials.json | jq '.claudeAiOauth.accessToken' | head -c 30 +# Should show: sk-ant-oat01-... + +cat ~/.claude/.claude.json | jq '.oauthAccount.emailAddress' +# Should show your email +``` + +### Verify Mounts + +```bash +# In container +mount | grep claude +# Should show one bind of /home/vscode/.claude, rw + +ls -la ~/.claude/ +# Should show files from your host +``` + +### Check Environment Variables + +```bash +# In container +env | grep -E "(CLAUDE|XDG)" | sort +``` + +Should show: +``` +CLAUDE_CONFIG_DIR=/home/vscode/.claude +XDG_CACHE_HOME=/home/vscode/.cache +XDG_CONFIG_HOME=/home/vscode/.config +XDG_DATA_HOME=/home/vscode/.local/share +``` + +### Test Claude Without Authentication + +```bash +# This should work if you're authenticated +echo "what is 2+2" | claude --print +``` + +### Check Setup State + +```bash +# On HOST +cat ~/.claude/.claude.json | jq '.projects["/workspaces/"]' +``` + +Look for: +- `projectOnboardingSeenCount`: Should be > 0 (e.g., 999) +- Check your actual workspace path matches + +### Verify Network Mode + +```bash +# On HOST +docker inspect | jq '.[0].HostConfig.NetworkMode' +# "host" only if you opted into --network=host; otherwise the default bridge network +``` + +## Complete Setup Checklist + +When setting up a new workspace: + +- [ ] `../claude-code` feature declared in `.devcontainer/ci/devcontainer.json`, so the published image carries it +- [ ] `claude` authenticated on the host, so no OAuth flow runs in the container +- [ ] Environment variables added (CLAUDE_CONFIG_DIR, XDG_*) +- [ ] Files exist on host: `.credentials.json`, `.claude.json` +- [ ] File permissions: `chmod 600` on sensitive files +- [ ] `projectOnboardingSeenCount` set to 999 in `.claude.json` +- [ ] `themeMode` set (e.g., "dark") in `.claude.json` +- [ ] Container rebuilt: `devpod up . --recreate` +- [ ] Test: `claude --print "test"` works +- [ ] Test: `claude` goes to interactive mode without wizard + +## File Explanation + +### `.credentials.json` +Contains OAuth access and refresh tokens. Format: +```json +{ + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-...", + "refreshToken": "sk-ant-ort01-...", + "expiresAt": 1234567890000 + } +} +``` + +**Why writable:** Tokens need to be refreshed periodically. + +### `.claude.json` +Contains account info, feature flags, and per-workspace state. Key fields: +```json +{ + "oauthAccount": { ... }, + "userID": "...", + "themeMode": "dark", + "projects": { + "/workspaces/": { + "projectOnboardingSeenCount": 999, + "hasTrustDialogAccepted": false, + ... + } + } +} +``` + +**Why writable:** Claude updates `projectOnboardingSeenCount` and other workspace state. + +## Advanced Debugging + +### Capture Complete Claude Startup + +```bash +# In container +script -qec "timeout 3 claude 2>&1" /tmp/claude-startup.log +cat /tmp/claude-startup.log +``` + +### Compare Config Before/After + +```bash +# Before operation +cp ~/.claude/.claude.json ~/.claude/.claude.json.before + +# Do operation (e.g., run claude) + +# After +diff <(jq -S . ~/.claude/.claude.json.before) <(jq -S . ~/.claude/.claude.json) +``` + +### Check What Changed on Host + +```bash +# On HOST, monitor file changes +watch -n 1 'stat ~/.claude/.claude.json | grep Modify' +``` + +## Security Considerations + +### What the Mount Actually Is +One read-write bind of the whole `~/.claude` directory. No `ro` flag, no per-file mounts. `.credentials.json`, `.claude.json`, `CLAUDE.md`, `settings.json`, `agents/`, `commands/` and `hooks/` are all writable from inside the container, and a write lands on the host's copy. + +### The Consequence +`hooks/` and `settings.json` are executed by Claude Code wherever it runs, so content a container writes there runs on the **host** the next time Claude Code starts there -- a `postCreateCommand` from a repository nobody read is enough. The container also holds the live Claude credentials from the mount and, under `dl`, a `GH_TOKEN` carrying repo and workflow scopes. It is not a confidentiality or integrity boundary. + +### Why It Is Accepted +Sharing one config directory is what makes credentials work across the host and every branch container: the access token is short-lived, so a read-only or copied arrangement drifts into a re-auth, and a container that cannot write `.claude.json` re-onboards on every launch. The isolation buys reproducible dependencies and non-colliding concurrent work, not safety against hostile code. `blooop/wayfinder`'s `.devcontainer/devcontainer.json` writes out the same threat model above its `mounts` block. + +Keeping `~/.claude` under version control is the one practical measure here: it makes a change from a container visible instead of silent. + +## Known Limitations + +1. **VS Code extensions may not install with --network=host** + - Issue: https://github.com/microsoft/vscode-remote-release/issues/9212 + - Workaround: Build without runArgs first, then add it + +2. **Per-workspace setup tracking** + - Each workspace path needs its own `projectOnboardingSeenCount` + - Renaming workspace requires updating the flag + +3. **No credential isolation** + - All containers share same host credentials + - Can't use different Claude accounts per container + +4. **OAuth callback browser routing** + - Requires `--network=host` or manual code pasting + - May not work in some network environments + +## Getting Help + +If issues persist: + +1. Check `/tmp/claude/debug.log` in container +2. Run `claude --debug` for verbose output +3. Review this guide with an AI agent: + - Share: `.devcontainer/claude-code/TROUBLESHOOTING.md` + - Include: Output of debugging commands above + - Describe: Exact symptoms and when they occur + +## References + +- Dev Container Features: https://containers.dev/implementers/features/ +- Claude Code Docs: https://code.claude.com/docs/ +- deps_rocker reference: https://github.com/blooop/deps_rocker +- OAuth callback issue: https://github.com/anthropics/claude-code/issues/1529 +- Network=host issue: https://github.com/microsoft/vscode-remote-release/issues/9212 diff --git a/.devcontainer/claude-code/devcontainer-feature.json b/.devcontainer/claude-code/devcontainer-feature.json new file mode 100644 index 0000000..06b9ff5 --- /dev/null +++ b/.devcontainer/claude-code/devcontainer-feature.json @@ -0,0 +1,22 @@ +{ + "name": "Claude Code CLI", + "id": "claude-code", + "version": "0.2.0", + "description": "Installs Claude Code CLI via pixi with persistent configuration", + "options": {}, + "documentationURL": "https://github.com/anthropics/devcontainer-features", + "licenseURL": "https://github.com/anthropics/devcontainer-features/blob/main/LICENSE", + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code" + ] + } + }, + "containerEnv": { + "CLAUDE_CONFIG_DIR": "/home/vscode/.claude" + }, + "mounts": [ + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind" + ] +} diff --git a/.devcontainer/claude-code/init-host.sh b/.devcontainer/claude-code/init-host.sh new file mode 100755 index 0000000..7e123bf --- /dev/null +++ b/.devcontainer/claude-code/init-host.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# Initialize Claude Code host directory for devcontainer bind mount +# This script runs on the HOST before the container is created. +# mkdir -p is idempotent - it only creates if missing, won't clobber existing. + +mkdir -p "$HOME/.claude" diff --git a/.devcontainer/claude-code/install.sh b/.devcontainer/claude-code/install.sh new file mode 100755 index 0000000..a52d2b4 --- /dev/null +++ b/.devcontainer/claude-code/install.sh @@ -0,0 +1,184 @@ +#!/bin/bash +set -euo pipefail + +# Claude Code CLI Local Feature Install Script +# Installs Claude Code via pixi and sets up configuration directories + +# Global variables set by resolve_target_home +TARGET_USER="" +TARGET_HOME="" + +# Function to resolve target user and home directory with validation +# Sets TARGET_USER and TARGET_HOME global variables +resolve_target_home() { + TARGET_USER="${_REMOTE_USER:-vscode}" + TARGET_HOME="${_REMOTE_USER_HOME:-}" + + # If _REMOTE_USER_HOME is not set, try to infer from current user or /home/ + if [ -z "${TARGET_HOME}" ]; then + if [ "$(id -un 2>/dev/null)" = "${TARGET_USER}" ] && [ -n "${HOME:-}" ]; then + TARGET_HOME="${HOME}" + elif [ -d "/home/${TARGET_USER}" ]; then + TARGET_HOME="/home/${TARGET_USER}" + fi + fi + + # If TARGET_HOME is set but doesn't exist, try fallbacks + if [ -n "${TARGET_HOME}" ] && [ ! -d "${TARGET_HOME}" ]; then + if [ -n "${HOME:-}" ] && [ -d "$HOME" ]; then + echo "Warning: TARGET_HOME '${TARGET_HOME}' does not exist, falling back to \$HOME: $HOME" >&2 + TARGET_HOME="$HOME" + elif [ -d "/home/${TARGET_USER}" ]; then + echo "Warning: TARGET_HOME '${TARGET_HOME}' does not exist, falling back to /home/${TARGET_USER}" >&2 + TARGET_HOME="/home/${TARGET_USER}" + fi + fi + + # Ensure we ended up with a valid, existing home directory + if [ -z "${TARGET_HOME}" ] || [ ! -d "${TARGET_HOME}" ]; then + echo "Error: could not determine a valid home directory for user '${TARGET_USER}'." >&2 + echo "Checked _REMOTE_USER_HOME ('${_REMOTE_USER_HOME:-}'), \$HOME ('${HOME:-}'), and /home/${TARGET_USER}." >&2 + exit 1 + fi +} + +# Function to install pixi if not found +install_pixi() { + echo "Installing pixi..." + + # Detect architecture + case "$(uname -m)" in + x86_64|amd64) ARCH="x86_64" ;; + aarch64|arm64) ARCH="aarch64" ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac + + # Download and install pixi + curl -fsSL "https://github.com/prefix-dev/pixi/releases/latest/download/pixi-${ARCH}-unknown-linux-musl" -o /usr/local/bin/pixi + chmod +x /usr/local/bin/pixi + + echo "pixi installed successfully" + pixi --version +} + +# Function to install Claude Code CLI via pixi +install_claude_code() { + echo "Installing Claude Code CLI via pixi..." + + # Install pixi if not available + if ! command -v pixi >/dev/null; then + install_pixi + fi + + # Resolve target user and home (sets TARGET_USER and TARGET_HOME) + resolve_target_home + + # Install with pixi global from blooop channel + # Run as target user so it installs to their home directory + if [ "$(id -u)" -eq 0 ] && [ "$TARGET_USER" != "root" ]; then + su - "$TARGET_USER" -c "pixi global install --channel https://prefix.dev/blooop claude-shim" + else + pixi global install --channel https://prefix.dev/blooop claude-shim + fi + + # Add pixi bin path to user's profile if not already there + local profile="$TARGET_HOME/.profile" + # shellcheck disable=SC2016 + local pixi_path_line='export PATH="$HOME/.pixi/bin:$PATH"' + if [ -f "$profile" ] && ! grep -q '\.pixi/bin' "$profile"; then + echo "$pixi_path_line" >> "$profile" + elif [ ! -f "$profile" ]; then + echo "$pixi_path_line" > "$profile" + chown "$TARGET_USER:$TARGET_USER" "$profile" 2>/dev/null || true + fi + + # Workaround: pixi trampoline fails for bash scripts, so add env bin directly + # This conditionally adds the path only if the env exists + # shellcheck disable=SC2016 + local env_path_line='[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"' + if [ -f "$profile" ] && ! grep -q 'pixi/envs/claude-shim' "$profile"; then + echo "# Workaround: pixi trampoline fails for bash scripts" >> "$profile" + echo "$env_path_line" >> "$profile" + fi + + # Verify installation by checking the trampoline exists (don't run it - that triggers download) + local pixi_bin_path="$TARGET_HOME/.pixi/bin" + local claude_bin="$pixi_bin_path/claude" + if [ -x "$claude_bin" ]; then + echo "Claude Code CLI installed successfully!" + echo "(Claude binary will be downloaded on first run)" + return 0 + else + echo "ERROR: Claude Code CLI installation failed! Binary not found at $claude_bin" + return 1 + fi +} + +# Function to create Claude configuration directories +create_claude_directories() { + echo "Creating Claude configuration directories..." + + # Resolve target user and home (sets TARGET_USER and TARGET_HOME) + resolve_target_home + + echo "Target home directory: $TARGET_HOME" + echo "Target user: $TARGET_USER" + + # Create the main .claude directory and subdirectories + mkdir -p "$TARGET_HOME/.claude" + mkdir -p "$TARGET_HOME/.claude/agents" + mkdir -p "$TARGET_HOME/.claude/commands" + mkdir -p "$TARGET_HOME/.claude/hooks" + + # Create empty config files if they don't exist + if [ ! -f "$TARGET_HOME/.claude/.credentials.json" ]; then + echo "{}" > "$TARGET_HOME/.claude/.credentials.json" + chmod 600 "$TARGET_HOME/.claude/.credentials.json" + fi + + if [ ! -f "$TARGET_HOME/.claude/.claude.json" ]; then + echo "{}" > "$TARGET_HOME/.claude/.claude.json" + chmod 600 "$TARGET_HOME/.claude/.claude.json" + fi + + # Set proper ownership + if [ "$(id -u)" -eq 0 ]; then + chown -R "$TARGET_USER:$TARGET_USER" "$TARGET_HOME/.claude" || true + fi + + echo "Claude directories created successfully" +} + +# Main script +main() { + echo "=========================================" + echo "Activating feature 'claude-code' (local)" + echo "=========================================" + + # Resolve target user and home (sets TARGET_USER and TARGET_HOME) + resolve_target_home + + local claude_bin="$TARGET_HOME/.pixi/bin/claude" + + # Install Claude Code CLI + if [ -x "$claude_bin" ]; then + echo "Claude Code CLI is already installed" + else + install_claude_code || exit 1 + fi + + # Create Claude configuration directories + create_claude_directories + + echo "=========================================" + echo "Claude Code feature activated successfully!" + echo "=========================================" + echo "" + echo "Configuration is bind-mounted from the host (~/.claude)" + echo "and persists across container rebuilds." + echo "" + echo "To authenticate, run 'claude' and follow the OAuth flow." + echo "" +} + +main diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e8bda1f..4c843ec 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,32 +1,111 @@ { - "name": "python_template", - "build": { - "dockerfile": "Dockerfile", - "context": ".." - }, - "customizations": { - "vscode": { - "extensions": [ - "ms-python.python", - "ms-python.vscode-pylance", - "ms-python.pylint", - "njpwerner.autodocstring", - "charliermarsh.ruff", - "mhutchie.git-graph", - "eamodio.gitlens", - "tamasfe.even-better-toml", - "Codium.codium", - "ms-azuretools.vscode-docker", - "ryanluker.vscode-coverage-gutters" - ] - } - }, - //docker in docker is included in the pixi devcontainer example, but disabling for the moment due to security issues - // "features": { - // "ghcr.io/devcontainers/features/docker-in-docker:2": {} - // }, - "mounts": [ - "source=${localWorkspaceFolderBasename}-pixi,target=${containerWorkspaceFolder}/.pixi,type=volume" - ], - "postCreateCommand": "sudo chown vscode .pixi && pixi install" + // Uses the template's prebuilt image by default for fast startup. + // To build locally: pixi run dev-use-local + // To use this repo's own prebuilt image: pixi run dev-use-prebuilt + // "build": { + // "dockerfile": "Dockerfile", + // "context": ".." + // }, + "image": "ghcr.io/blooop/python_template/devcontainer:latest", + + // There is deliberately no "features" block. The claude-code feature is + // already baked into the image above: .devcontainer/ci/devcontainer.json, + // which is the config CI builds that image from, declares + // "../claude-code". Declaring it again here made the devcontainer spec + // build a derived image on the first launch of every branch, reinstalling + // something the pulled image already carried -- so the "prebuilt image for + // fast startup" above still paid for a build. With this block gone the + // launch is a pull and nothing else. + // + // Adding a feature back here is allowed and costs that derived build again. + // If that ever becomes the normal case, the fix is devlaunch's design -- + // a "build:" block plus customizations.devpod.prebuildRepository, which + // caches the fully assembled image, features included. That needs CI to + // publish with `devpod build` rather than the devcontainers/ci action; + // devpod's prebuild hash is computed from the Dockerfile and build context, + // so it does not apply to an "image:" config like this one at all. + + "initializeCommand": ".devcontainer/claude-code/init-host.sh", + + // There is deliberately no "runArgs": ["--network=host"]. + // + // A container per branch is the reason these repos are launched with `dl`, + // and host networking takes it away: the container joins the host's network + // namespace, so every port a test or dev server binds is a host port and + // two branches of this repo collide on the first one they share. The + // default bridge network keeps them apart at no cost -- nothing in this + // template reaches for the host's network. + // + // Two sibling repos keep the flag on purpose and should not be "fixed" to + // match this file: homeassistant-config (mDNS/discovery needs the host LAN) + // and colcon-runner (ROS 2 DDS multicast discovery). + + "customizations": { + "vscode": { + "settings": {}, + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "jjjermiah.pixi-vscode", + "charliermarsh.ruff", + "tamasfe.even-better-toml", + "mhutchie.git-graph", + "anthropic.claude-code" + ] + } + }, + + "containerEnv": { + "CLAUDE_CONFIG_DIR": "/home/vscode/.claude", + "XDG_CONFIG_HOME": "/home/vscode/.config", + "XDG_CACHE_HOME": "/home/vscode/.cache", + "XDG_DATA_HOME": "/home/vscode/.local/share" + }, + + // Two mounts, and the two that are gone were removed for reasons rather + // than tidiness -- both follow the precedent already reasoned out in + // blooop/wayfinder's devcontainer.json. + // + // ~/.config/gh is gone because it never worked: `gh` keeps its token in the + // system keyring, so the mounted hosts.yml carries no oauth_token and + // `gh auth status` inside the container reports the token as invalid. + // GitHub auth arrives as GH_TOKEN instead, which `dl` forwards into every + // workspace it starts (from GH_TOKEN, GITHUB_TOKEN or `gh auth token`, + // whichever answers first). Opened by something other than `dl` -- a plain + // `devpod up`, or VS Code's Reopen in Container -- this container has no + // `gh` login; export GH_TOKEN yourself for those. + // + // ~/.ssh is gone because mounting the directory put entries on the + // developer's real config that nothing outside the container could honour: + // devpod running in here writes `Host .devpod` blocks whose + // ProxyCommand names a binary that exists only inside this container, and + // those outlived the container they pointed at. It also handed over the + // private key, which was never load-bearing -- devpod forwards git + // credentials and can forward an ssh agent, which lends the use of a key + // without copying it. SSH_AUTH_SOCK is gone from containerEnv with it, + // rather than being left as a path nothing fills. + "mounts": [ + "source=${localWorkspaceFolderBasename}-pixi,target=${containerWorkspaceFolder}/.pixi,type=volume", + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind" + ], + + "postCreateCommand": "sudo chown vscode .pixi && pixi install && pixi run prek-install", + + // Last in the file on purpose, and it is the one line here that is load + // bearing for every repo cut from this template. + // + // "name" is the only per-repo value in this file, so it is the only line a + // child repo edits -- and while it sat on the line above the image/build + // block, which is the block this template changes most, every + // `pixi run update-from-template-repo` conflicted on this file. git cannot + // split adjacent hunks, so the child's one-line name edit collided with the + // template's block edit every time. Measured against blooop/dbw: reverting + // only the name made the same merge clean. + // + // At the end of the file the two edits are separate hunks and merge without + // conflict -- the child keeps its name, and template changes to the image, + // the mounts and postCreateCommand all land on their own. Moving it costs + // one restructuring conflict per child, once; leaving it here costs a + // conflict on this file forever. + "name": "scratch_env" } From 15d324b8afa37c4ca43a29a5ede9bdff457d130a Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Mon, 24 Aug 2026 19:49:25 +0100 Subject: [PATCH 2/3] fix(devcontainer): restore the gh config mount, which does work The earlier commit on this branch removed the ~/.config/gh bind mount and justified it with the claim that the mount never worked -- that `gh` keeps its token in the system keyring, so the mounted hosts.yml carries no oauth_token. That claim is false on this machine. ~/.config/gh/hosts.yml carries a real oauth_token, and in a running container using that mount, with GH_TOKEN and GITHUB_TOKEN unset, `gh auth status` reports: Logged in to github.com account blooop (/home/vscode/.config/gh/hosts.yml) gh writes to a keyring only where one is available and falls back to the file otherwise, so the keyring claim held for some environment but not this one, and it got generalised into the config as if it always held. Nothing broke under `dl`, which is why it went unnoticed: `dl` forwards GH_TOKEN, devpod applies workspace env after the devcontainer's own, and the env token wins wherever both are present. The entry paths that lost gh auth they previously had are the non-`dl` ones -- a plain `devpod up`, and VS Code's Reopen in Container. So the mount comes back. The ~/.ssh removal is unaffected and stays removed. It was verified separately in a live container: devpod forwards an ssh agent at a socket path of its own, `ssh-add -l` lists the host key, and `git ls-remote` against a git@github.com: origin succeeds with an empty ~/.ssh. The old hardcoded SSH_AUTH_SOCK=/home/vscode/.ssh/agent.sock actively overrode that working forwarded socket, so it stays gone too. --- .devcontainer/Dockerfile | 3 +++ .devcontainer/devcontainer.json | 46 +++++++++++++++++++-------------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ec622e6..b410c74 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -14,3 +14,6 @@ RUN echo 'eval "$(pixi completion -s bash)"' >> /home/vscode/.bashrc \ && echo 'export PATH="$HOME/.pixi/bin:$PATH"' >> /home/vscode/.profile \ && echo '# Workaround: pixi trampoline fails for bash scripts, so add env bin directly' >> /home/vscode/.profile \ && echo '[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"' >> /home/vscode/.profile + +# Create .config/gh so the host's GitHub CLI config mounts cleanly onto it +RUN mkdir -p /home/vscode/.config/gh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4c843ec..7ef4896 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -62,30 +62,36 @@ "XDG_DATA_HOME": "/home/vscode/.local/share" }, - // Two mounts, and the two that are gone were removed for reasons rather - // than tidiness -- both follow the precedent already reasoned out in - // blooop/wayfinder's devcontainer.json. + // Three mounts. The pixi volume keeps the environment out of the bind- + // mounted workspace; the other two share host credentials. // - // ~/.config/gh is gone because it never worked: `gh` keeps its token in the - // system keyring, so the mounted hosts.yml carries no oauth_token and - // `gh auth status` inside the container reports the token as invalid. - // GitHub auth arrives as GH_TOKEN instead, which `dl` forwards into every - // workspace it starts (from GH_TOKEN, GITHUB_TOKEN or `gh auth token`, - // whichever answers first). Opened by something other than `dl` -- a plain - // `devpod up`, or VS Code's Reopen in Container -- this container has no - // `gh` login; export GH_TOKEN yourself for those. + // ~/.config/gh is here because it works. `gh` uses a system keyring when + // one is available and falls back to hosts.yml when it is not, and on this + // host it falls back: hosts.yml carries a real oauth_token, and inside a + // container built from this config -- with GH_TOKEN and GITHUB_TOKEN unset + // -- `gh auth status` reports "Logged in to github.com account blooop + // (/home/vscode/.config/gh/hosts.yml)". This mount is the only thing that + // gives `gh` a login in a container opened WITHOUT `dl`: a plain + // `devpod up`, or VS Code's Reopen in Container. Under `dl` it is + // redundant but harmless -- `dl` forwards GH_TOKEN from the host (from + // GH_TOKEN, GITHUB_TOKEN or `gh auth token`, whichever answers first), and + // devpod applies workspace env after the devcontainer's own, so where both + // are present the forwarded token is the one `gh` uses. // - // ~/.ssh is gone because mounting the directory put entries on the - // developer's real config that nothing outside the container could honour: - // devpod running in here writes `Host .devpod` blocks whose - // ProxyCommand names a binary that exists only inside this container, and - // those outlived the container they pointed at. It also handed over the - // private key, which was never load-bearing -- devpod forwards git - // credentials and can forward an ssh agent, which lends the use of a key - // without copying it. SSH_AUTH_SOCK is gone from containerEnv with it, - // rather than being left as a path nothing fills. + // ~/.ssh is deliberately not mounted, for reasons that have nothing to do + // with the above. devpod forwards an ssh agent of its own, at a socket path + // it chooses -- verified in a live container: `ssh-add -l` lists the host's + // key and `git ls-remote` against a git@github.com: origin succeeds with an + // empty ~/.ssh -- so the private key does not need to be in here at all. + // Mounting the directory also wrote `Host .devpod` blocks onto the + // developer's real ssh config, naming a ProxyCommand binary that exists + // only inside the container, and those outlived the container. SSH_AUTH_SOCK + // is gone from containerEnv for the same reason: hardcoding + // /home/vscode/.ssh/agent.sock overrode the socket devpod actually + // forwards with a path nothing fills. "mounts": [ "source=${localWorkspaceFolderBasename}-pixi,target=${containerWorkspaceFolder}/.pixi,type=volume", + "source=${localEnv:HOME}/.config/gh,target=/home/vscode/.config/gh,type=bind", "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind" ], From 680d2493348138b89893ff0cd40eafdf563b3e56 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Mon, 24 Aug 2026 22:11:55 +0100 Subject: [PATCH 3/3] fix(devcontainer): move postCreate into a script, and stop assuming prek-install Synced from blooop/python_template. The previous postCreateCommand ended in `pixi run prek-install`. This repo does not define that task, so `pixi run` exited 127, the lifecycle hook failed, and container creation failed outright -- the workspace never came up. Found by launching the container; neither JSON validation nor CI sees it. postCreateCommand now calls .devcontainer/post-create.sh, which runs the task only where it exists. The script also seeds known_hosts. devpod forwards an ssh agent, so the host's ~/.ssh does not need mounting -- but the agent carries authentication, not host identity, and on a fresh container the first git operation over an ssh remote died with "Host key verification failed". Interactive users can click through that; scripts, agents and in-container CI cannot. --- .devcontainer/devcontainer.json | 2 +- .devcontainer/post-create.sh | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100755 .devcontainer/post-create.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7ef4896..c933c93 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -95,7 +95,7 @@ "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind" ], - "postCreateCommand": "sudo chown vscode .pixi && pixi install && pixi run prek-install", + "postCreateCommand": ".devcontainer/post-create.sh", // Last in the file on purpose, and it is the one line here that is load // bearing for every repo cut from this template. diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 0000000..fc32480 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# postCreateCommand for this repo and every repo cut from this template. +# +# It lives in a script rather than inline in devcontainer.json for two reasons: +# the steps below have to differ per repo (not every descendant defines every +# pixi task), and a one-line string in the JSON is a permanent merge-conflict +# surface for children pulling template updates. +set -euo pipefail + +# 1. The .pixi volume is created by docker, owned by root. +sudo chown vscode .pixi + +# 2. Seed known_hosts for the forge this clone actually uses. +# +# devpod forwards an ssh agent, so auth works without the host's ~/.ssh being +# mounted -- but host identity does not come with it. Without a known_hosts +# entry the first git operation over an ssh remote fails with "Host key +# verification failed": an interactive user gets a yes/no prompt, anything +# non-interactive (a script, an agent, CI in the container) just dies. +host=$(git config --get remote.origin.url 2>/dev/null | + sed -nE 's#^(ssh://)?git@([^:/]+).*#\2#p') || true +if [ -n "${host:-}" ]; then + mkdir -p ~/.ssh && chmod 700 ~/.ssh + touch ~/.ssh/known_hosts && chmod 600 ~/.ssh/known_hosts + if ! ssh-keygen -F "$host" >/dev/null 2>&1; then + if scanned=$(ssh-keyscan -T 10 -t rsa,ecdsa,ed25519 "$host" 2>/dev/null) && + [ -n "$scanned" ]; then + printf '%s\n' "$scanned" >> ~/.ssh/known_hosts + sort -u -o ~/.ssh/known_hosts ~/.ssh/known_hosts + echo "post-create: seeded known_hosts for $host" + else + # Non-fatal: no network at postCreate must not fail container creation. + echo "post-create: could not reach $host, skipping known_hosts" >&2 + fi + fi +fi + +# 3. The environment itself. +pixi install + +# 4. Optional tasks. A descendant that does not define one simply skips it -- +# hardcoding `pixi run prek-install` here fails container creation outright with +# exit 127 on every repo that lacks the task, which is most of them. +# `pixi task list` prints to stderr, not stdout -- redirecting it to /dev/null +# silently yields an empty list and skips tasks the repo really does define. +tasks=$(pixi task list --summary 2>&1 | tr ' ,' '\n\n' || true) +for task in prek-install; do + if grep -qx "$task" <<<"$tasks"; then + echo "post-create: running '$task'" + pixi run "$task" + else + echo "post-create: no '$task' task in this repo, skipping" + fi +done