From 10a031e7cb419cd87f61a19c09190f792aa6edd7 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Mon, 24 Aug 2026 19:22:15 +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 | 5 +- .devcontainer/ci/devcontainer.json | 12 ++ .devcontainer/claude-code/README.md | 204 +++++++++---------- .devcontainer/claude-code/TROUBLESHOOTING.md | 126 ++++++------ .devcontainer/claude-code/install.sh | 6 +- .devcontainer/devcontainer.json | 111 +++++++--- 6 files changed, 261 insertions(+), 203 deletions(-) create mode 100644 .devcontainer/ci/devcontainer.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 93fc51c..ec622e6 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,6 @@ FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 -ARG PIXI_VERSION=v0.62.2 +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 \ @@ -14,6 +14,3 @@ 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 .ssh directory with proper permissions for SSH config mounts -RUN mkdir -p /home/vscode/.ssh && chmod 700 /home/vscode/.ssh 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 index 37eb3b5..4e358b1 100644 --- a/.devcontainer/claude-code/README.md +++ b/.devcontainer/claude-code/README.md @@ -1,96 +1,96 @@ # Claude Code CLI - Local Dev Container Feature -A local Dev Container Feature that installs the Claude Code CLI and configures it with read-only mounts to your host machine's Claude configuration. +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**: Installs the `@anthropic-ai/claude-code` npm package globally -2. **Configuration Mounting**: Mounts your host machine's Claude configuration files into the container as read-only binds +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 +- **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**: Creates `.claude/` structure in the container +- **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 -The following files and directories from your **host machine** are mounted into the container: +One mount, and it is the whole directory: -### Read-Only Mounts (Security-Protected) -- `~/.claude/CLAUDE.md` → Global project instructions -- `~/.claude/settings.json` → Claude CLI settings -- `~/.claude/agents/` → Custom agent configurations -- `~/.claude/commands/` → Command definitions -- `~/.claude/hooks/` → Event-driven shell hooks +``` +source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind +``` -These are **read-only** (`ro` flag) to prevent: -- Prompt injection attacks that could modify your Claude configuration -- Accidental modification of shared configuration from within containers -- Security issues related to hook manipulation +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. -### Read-Write Mounts (Authentication & State) -- `~/.claude/.credentials.json` → OAuth access/refresh tokens -- `~/.claude/.claude.json` → Account info, user ID, workspace setup tracking +### What That Means -These files **must be writable** to enable: -- OAuth authentication flow and token refresh -- Workspace setup state tracking (`projectOnboardingSeenCount`) -- Session continuity across container rebuilds +`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. -### Why These Must Be Writable +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. -**`.credentials.json`**: OAuth tokens need to be refreshed periodically. Claude writes updated tokens to this file. +### Why It Is Still One Read-Write Directory -**`.claude.json`**: Claude tracks per-workspace setup state here. The `projectOnboardingSeenCount` field must be writable so Claude doesn't show the setup wizard on every launch. +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. -⚠️ **Security Note**: These files contain sensitive data and are mounted read-write by necessity. They are only accessible by the container user and stored with `600` permissions. Only use this feature with trusted repositories. +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 -Add this feature to your `devcontainer.json`: +The feature is declared where the image is built, in `.devcontainer/ci/devcontainer.json`: ```json { + "build": { + "dockerfile": "../Dockerfile", + "context": "." + }, "features": { - "./claude-code": {} + "../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" }, - "runArgs": ["--network=host"] + "mounts": [ + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind" + ] } ``` -**Note**: Node.js is automatically installed via the `installsAfter` dependency mechanism - you don't need to explicitly add it to your features. +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 `--network=host` is Required +### Why You Might Opt Into `--network=host` -The `runArgs: ["--network=host"]` is **critical for OAuth authentication** to work in containers. +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: -**How OAuth works:** -1. You run `claude` → starts OAuth flow -2. Opens browser → you click "Authorize" -3. Browser redirects to `http://localhost:/callback` -4. OAuth server running in container receives the callback +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" -**The problem without host networking:** -- OAuth server runs on port X **inside container** -- Browser callback goes to port X on **host's localhost** -- ❌ Container's port is not accessible from host → **callback fails** +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. -**The solution:** -- With `--network=host`, container shares host's network namespace -- OAuth server on port X in container = port X on host -- ✅ Browser callback reaches the container → **authentication succeeds** +Two costs come with it, and they are why this template does not set it: -**Security note:** Host networking gives the container full network access. Only use in trusted environments. +- **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. -**Alternative (if host networking is not acceptable):** -- Authenticate Claude on your host machine first -- Credentials in `~/.claude/.credentials.json` are automatically shared with container -- No OAuth flow needed in container +Host networking also gives the container full access to the host's network, so only use it in environments you trust. ### Build the Container @@ -107,7 +107,7 @@ With VS Code: ### Host Machine -You should have these files/directories on your host machine (they will be created if they don't exist): +`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/ @@ -118,7 +118,7 @@ You should have these files/directories on your host machine (they will be creat └── hooks/ # Optional: event hooks ``` -**Note**: If these don't exist on your host, the container will still build successfully, but you may see mount warnings. You can create them with: +**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} @@ -128,7 +128,7 @@ touch ~/.claude/settings.json ### Container -- **Node.js 18+** and **npm** are automatically installed via the `installsAfter` dependency mechanism +- **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 @@ -161,13 +161,15 @@ touch ~/.claude/settings.json ### Testing Install Script -You can test the install script standalone: +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: @@ -180,22 +182,26 @@ Check mounted files: ls -la ~/.claude/ ``` -Verify mounts are read-only: +Verify the config directory is mounted, writable, and pointed at: ```bash -echo "test" >> ~/.claude/CLAUDE.md # Should fail with "Read-only file system" +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` in the container and follow the OAuth flow: - - The CLI will provide an OAuth URL - - Open the URL in your browser (on your host machine) +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 should complete automatically, or you may need to paste the code - - Credentials are saved to `~/.claude/.credentials.json` on your host + - 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 @@ -208,7 +214,7 @@ The OAuth flow opens a local callback server. In containers, this can behave dif **"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 rebuild the container +- 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:** @@ -222,32 +228,26 @@ This happens because Claude tracks setup completion **per-workspace**, not globa **Quick fix:** ```bash # On your HOST machine: -# Set the onboarding flag for your workspace -jq '.projects["/workspaces/pythontemplate"].projectOnboardingSeenCount = 1' ~/.claude/.claude.json > ~/.claude/.claude.json.tmp +# 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 -# Rebuild container -devpod up . --recreate +# 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/pythontemplate"].projectOnboardingSeenCount`. When this is `0`, the setup wizard runs. Set it to `1` to mark setup as complete. +**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. -**For future workspaces:** Replace `/workspaces/pythontemplate` with your actual container workspace path. +**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 -Configuration files (except credentials) are read-only. You **cannot** modify Claude settings from within the container. +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. -To change configuration: - -1. Edit files on your **host machine**: `~/.claude/settings.json`, `~/.claude/CLAUDE.md`, etc. -2. Restart or rebuild the container to see changes - -This is by design for security (prevents prompt injection attacks). +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 @@ -309,7 +309,7 @@ Users would then reference it as: } ``` -Instead of `"./claude-code": {}` +Instead of `"../claude-code": {}` ## Optional: Future Composition @@ -377,23 +377,15 @@ Then use both: **Problem**: Browser clicks "Authorize" but container never receives the callback. -**Solution**: Add `--network=host` to your `devcontainer.json`: - -```json -{ - "runArgs": ["--network=host"] -} -``` - -See "Why `--network=host` is Required" section above for details. +**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**: Without `--network=host`, OAuth callbacks can't reach the container. +**Root cause**: Interactive mode tried to start an OAuth flow, which means it found no usable credentials under `CLAUDE_CONFIG_DIR`. -**Solution**: Add `"runArgs": ["--network=host"]` to devcontainer.json. +**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` @@ -404,9 +396,9 @@ See "Why `--network=host` is Required" section above for details. 2. **Authenticate on host**, mount credentials, remove runArgs (no OAuth needed in container) 3. **Manually install extensions** after container starts -### Mount warnings about missing files +### `~/.claude` missing on the host -**Solution**: Create the directories on your 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} @@ -415,33 +407,27 @@ touch ~/.claude/CLAUDE.md ~/.claude/settings.json ## Security Notes -This implementation makes conscious security trade-offs to enable OAuth authentication and persistent setup state: +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 -### What's Protected (Read-Only Mounts) -- **CLAUDE.md**: Prevents prompt injection attacks that could modify your global instructions -- **settings.json**: Prevents config tampering -- **agents/**, **commands/**, **hooks/**: Prevents malicious code execution through modified hooks +`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. -### What's Writable (Necessary Trade-off) -- **`.credentials.json`**: OAuth tokens must be writable for token refresh to work -- **`.claude.json`**: Workspace state must be writable to persist `projectOnboardingSeenCount` and other setup tracking +### 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. -### Security Mitigations -- Files have `600` permissions (user-only access) -- Only use this feature in **trusted repositories** -- Container user isolation provides some protection -- Writable files are limited to authentication/state only -- All configuration and code execution files remain read-only +### 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. -### Known Risks -- A malicious process in the container could exfiltrate OAuth tokens from `.credentials.json` -- A malicious process could modify workspace state in `.claude.json` -- **Recommendation**: Only use in repositories you trust, as you would with any dev container configuration +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) -- Original read-only approach: [PR #25](https://github.com/anthropics/devcontainer-features/pull/25) +- Per-file read-only approach this feature does not implement: [PR #25](https://github.com/anthropics/devcontainer-features/pull/25) ## Reference diff --git a/.devcontainer/claude-code/TROUBLESHOOTING.md b/.devcontainer/claude-code/TROUBLESHOOTING.md index a4b0f00..59e2156 100644 --- a/.devcontainer/claude-code/TROUBLESHOOTING.md +++ b/.devcontainer/claude-code/TROUBLESHOOTING.md @@ -2,37 +2,42 @@ ## Quick Reference -### Files That Must Exist on Host +### 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/ -├── .credentials.json # OAuth tokens (must be writable) -├── .claude.json # Account info, setup state (must be writable) -├── CLAUDE.md # Global instructions (read-only) -├── settings.json # Settings (read-only) -├── agents/ # Custom agents (read-only) -├── commands/ # Custom commands (read-only) -└── hooks/ # Event hooks (read-only) +~/.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 { - "features": { - "ghcr.io/devcontainers/features/node:1": {}, - "./claude-code": {} - }, - "runArgs": ["--network=host"], + "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 @@ -47,17 +52,19 @@ Claude tracks setup completion per-workspace in `.claude.json`: ```json { "projects": { - "/workspaces/pythontemplate": { + "/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/pythontemplate"].projectOnboardingSeenCount = 999' \ +jq '.projects["/workspaces/"].projectOnboardingSeenCount = 999' \ ~/.claude/.claude.json > ~/.claude/.claude.json.tmp mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json @@ -65,16 +72,15 @@ mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json jq '. + {themeMode: "dark"}' ~/.claude/.claude.json > ~/.claude/.claude.json.tmp mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json -# Rebuild container -devpod up . --recreate +# 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 -# In container -devpod ssh pythontemplate +# From the host, shell into the container +devpod ssh claude # Should go straight to interactive mode without wizard ``` @@ -89,6 +95,9 @@ claude # Should go straight to interactive mode without wizard 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 @@ -99,11 +108,8 @@ Add `--network=host` to devcontainer.json: This makes the container share the host's network namespace, so ports inside the container are accessible from the host browser. -**Trade-off:** -Using `--network=host` gives the container full network access and may prevent VS Code extensions from installing (known issue: [#9212](https://github.com/microsoft/vscode-remote-release/issues/9212)). - -**Workaround if you can't use --network=host:** -Authenticate on your host machine first, then credentials are shared via mounts. +**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 @@ -128,20 +134,21 @@ Two different issues: - Have to authenticate again **Root Cause:** -`.credentials.json` or `.claude.json` is not mounted, or is mounted read-only. +`~/.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 mounts in container:** +1. **Verify the mount in the container:** ```bash - devpod ssh pythontemplate + # From the host, shell into the container (`devpod list` shows ``, + # `dl --ls` for workspaces devlaunch created) + devpod ssh mount | grep claude ``` - Should show: + Should show one bind of the directory, read-write: ``` - /dev/... on /home/vscode/.claude/.credentials.json type ext4 (rw,...) - /dev/... on /home/vscode/.claude/.claude.json type ext4 (rw,...) + /dev/... on /home/vscode/.claude type ext4 (rw,...) ``` 2. **Check files exist on host:** @@ -149,26 +156,20 @@ Two different issues: ls -la ~/.claude/.credentials.json ~/.claude/.claude.json ``` -3. **Verify files are writable (not ro):** - The mounts MUST be read-write for auth to persist. +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: "Read-only file system" Error +### Issue 5: A Container Changed the Host's Claude Configuration **Symptoms:** -- Error when trying to write to `~/.claude/CLAUDE.md` or similar -- Operations fail with "Read-only file system" - -**Expected Behavior:** -This is intentional! Security files are mounted read-only: -- `CLAUDE.md`, `settings.json`, `agents/`, `commands/`, `hooks/` → Read-only +- `~/.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 -**Why?** -Prevents prompt injection attacks that could modify your Claude configuration. +**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:** -Edit these files on your HOST machine, then restart/rebuild the container. - -Only `.credentials.json` and `.claude.json` are read-write (needed for auth and state). +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) @@ -203,7 +204,7 @@ cat ~/.claude/.claude.json | jq '.oauthAccount.emailAddress' ```bash # In container mount | grep claude -# Should show all mounted files/directories +# Should show one bind of /home/vscode/.claude, rw ls -la ~/.claude/ # Should show files from your host @@ -235,7 +236,7 @@ echo "what is 2+2" | claude --print ```bash # On HOST -cat ~/.claude/.claude.json | jq '.projects["/workspaces/pythontemplate"]' +cat ~/.claude/.claude.json | jq '.projects["/workspaces/"]' ``` Look for: @@ -247,16 +248,15 @@ Look for: ```bash # On HOST docker inspect | jq '.[0].HostConfig.NetworkMode' -# Should show: "host" +# "host" only if you opted into --network=host; otherwise the default bridge network ``` ## Complete Setup Checklist When setting up a new workspace: -- [ ] Node.js feature added to devcontainer.json -- [ ] `./claude-code` feature added -- [ ] `runArgs: ["--network=host"]` added +- [ ] `../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 @@ -290,7 +290,7 @@ Contains account info, feature flags, and per-workspace state. Key fields: "userID": "...", "themeMode": "dark", "projects": { - "/workspaces/pythontemplate": { + "/workspaces/": { "projectOnboardingSeenCount": 999, "hasTrustDialogAccepted": false, ... @@ -332,20 +332,16 @@ watch -n 1 'stat ~/.claude/.claude.json | grep Modify' ## Security Considerations -### What's Protected (Read-Only) -- `CLAUDE.md` - Prevents prompt injection -- `settings.json` - Prevents config tampering -- `agents/`, `commands/`, `hooks/` - Prevents malicious modifications +### 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. -### What's Writable (Necessary Risk) -- `.credentials.json` - OAuth tokens (necessary for auth) -- `.claude.json` - Setup state (necessary to skip wizard) +### 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. -### Mitigation -- Only use in trusted repositories -- Files have `600` permissions (user-only access) -- Container user isolation -- Regular review of `.claude.json` changes +Keeping `~/.claude` under version control is the one practical measure here: it makes a change from a container visible instead of silent. ## Known Limitations diff --git a/.devcontainer/claude-code/install.sh b/.devcontainer/claude-code/install.sh index 020018f..a52d2b4 100755 --- a/.devcontainer/claude-code/install.sh +++ b/.devcontainer/claude-code/install.sh @@ -1,5 +1,5 @@ -#!/bin/sh -set -eu +#!/bin/bash +set -euo pipefail # Claude Code CLI Local Feature Install Script # Installs Claude Code via pixi and sets up configuration directories @@ -83,6 +83,7 @@ install_claude_code() { # 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" @@ -93,6 +94,7 @@ install_claude_code() { # 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" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2080381..fab71f7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,10 +1,45 @@ { - "name": "dev_template", - "build": { - "dockerfile": "Dockerfile", - "context": ".." - }, + // 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": {}, @@ -14,33 +49,63 @@ "jjjermiah.pixi-vscode", "charliermarsh.ruff", "tamasfe.even-better-toml", - "mhutchie.git-graph" - ] + "mhutchie.git-graph", + "anthropic.claude-code" + ] } }, - "features": { - "./claude-code": {} - // "ghcr.io/devcontainers/features/docker-in-docker:2": {} - // "ghcr.io/devcontainers/features/common-utils:2": {}, - // "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}, - // "ghcr.io/jsburckhardt/devcontainer-features/codex:latest": {} - }, - "runArgs": [ - "--network=host" - ], "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", - "SSH_AUTH_SOCK": "/ssh-agent" + "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}/.ssh/known_hosts,target=/home/vscode/.ssh/known_hosts,type=bind,ro", - "source=${localEnv:HOME}/.ssh/config,target=/home/vscode/.ssh/config,type=bind,ro", - "source=${localEnv:SSH_AUTH_SOCK},target=/ssh-agent,type=bind" + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind" ], - "postCreateCommand": "sudo chown vscode .pixi && pixi install" + + "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": "dev_template" } From 6fe08da2da585b1d32d48b49dd5a35aa9771a09e Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Mon, 24 Aug 2026 19:50:07 +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 fab71f7..b3e1823 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 aeefa0f7ebc57f0cf9175ef8b77ac4099bd4f270 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Mon, 24 Aug 2026 22:12:06 +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 b3e1823..f80fa66 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