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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Test dev container features

on:
pull_request:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
feature:
- agents
- docker
- pnpm
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Install devcontainer CLI
run: npm install -g @devcontainers/cli

- name: Test ${{ matrix.feature }} feature
run: |
devcontainer features test \
--features ${{ matrix.feature }} \
--base-image mcr.microsoft.com/devcontainers/javascript-node:24 \
.
98 changes: 89 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,32 +13,112 @@ Installs the AI coding agents we standardize on:
`ghcr.io/anthropics/devcontainer-features/claude-code` feature)
- **GitHub CLI** (`gh`, via `dependsOn` on the official
`ghcr.io/devcontainers/features/github-cli` feature)
- **1Password CLI** (`op`, installed from 1Password's apt repo) — used to inject a
GitHub token for `gh` without persisting credentials on the host

### Persistent Claude + `gh` logins
### Persistent Claude login

The feature mounts named volumes (`devcontainer-claude-config` at `/home/node/.claude`,
`devcontainer-gh-config` at `/home/node/.config/gh`) and sets `CLAUDE_CONFIG_DIR`, so the
Claude and `gh auth login` sessions survive container **rebuilds** — you log in once
instead of after every rebuild. A `postCreateCommand` `chown`s the volumes so the `node`
user can write to them.
The feature mounts a named volume (`devcontainer-claude-config` at `/home/node/.claude`)
and sets `CLAUDE_CONFIG_DIR`, so the Claude session survives container **rebuilds** — you
log in once instead of after every rebuild. A `postCreateCommand` `chown`s the volume so
the `node` user can write to it.

> The volumes are **shared across all repos** that use this feature (these logins are
> The volume is **shared across all repos** that use this feature (the Claude login is
> account-level, not repo-level), so logging in from one container carries over to the
> others. The mount paths assume the `node` remote user (the base image we standardize on).
> others. The mount path assumes the `node` remote user (the base image we standardize on).

### `gh` auth via 1Password (no host-side credentials)

Earlier versions persisted the `gh` login in a `devcontainer-gh-config` volume, which left
a long-lived GitHub token sitting on the host indefinitely. As of **v2.0.0** that volume is
gone. `gh` authenticates from a `GH_TOKEN`/`GITHUB_TOKEN` **environment variable** instead,
sourced from 1Password — nothing is written to the host.

The one fact that makes this non-obvious: **1Password's desktop-app integration (Touch ID
unlock) does _not_ work inside a container.** The `op` CLI's app integration talks to the
desktop app over a host-only socket that the container can't reach. So the question is
always *"where does `op` actually run?"* — and that splits into two patterns.

#### Pattern A — Local dev container: resolve on the host, forward the token in (recommended)

Your **host** has the 1Password app + Touch ID. Resolve the GitHub token there and forward
just that token into the container. `gh` reads `GITHUB_TOKEN` directly, so you do **not**
set `ghTokenSecretRef` — the feature's in-container `op` step stays dormant.

```jsonc
"features": {
"ghcr.io/rocicorp/devcontainer-features/agents:2": { "codexVersion": "0.139.0" }
},
// gh reads GITHUB_TOKEN; forward it from the host (resolved there via 1Password)
"remoteEnv": { "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}" }
```

One-time **host** setup (the part that tripped us up — note the gotchas):

1. **Install the `op` CLI** — no package manager required. Download the macOS package or
the standalone universal binary from <https://1password.com/downloads/command-line>,
then enable **1Password app → Settings → Developer → Integrate with 1Password CLI**
(Touch ID). Verify with `op vault list`.
2. **Export the token from your shell rc** — for zsh this is **`~/.zshrc`** (not `~/.zsh_rc`,
which zsh never sources):
```bash
export GITHUB_TOKEN="$(op read 'op://Employee/GitHub Personal Access Token/token')"
```
Use the item's exact [secret reference][secret-ref] — in the 1Password app, right-click
the field → **Copy Secret Reference**. Reload and check: `source ~/.zshrc` then
`echo ${#GITHUB_TOKEN}` should be non-zero.
3. **Launch the editor so it inherits the variable.** `${localEnv:...}` reads from the
editor *process* environment, and GUI/Dock launches do **not** read `~/.zshrc`. Either
quit the editor and start it from a terminal that has `GITHUB_TOKEN` (e.g. `code`), or
run `launchctl setenv GITHUB_TOKEN "$(op read '…')"` so GUI launches inherit it too.
Then build/reopen the container and check `gh auth status`.

> Why this shape: only a short-lived *GitHub* token ever enters the container (not a
> credential that can read your whole vault), the secret still originates in 1Password, and
> nothing is persisted on a host volume. If `GITHUB_TOKEN` isn't set, `gh` is simply
> unauthenticated — a clean fallback; run `gh auth login` manually if you like.

#### Pattern B — Headless (Codespaces / CI): `op read` inside the container

When there's no desktop app to lean on, run `op` inside the container with a
[service-account token][service-account]. Set `ghTokenSecretRef` and forward
`OP_SERVICE_ACCOUNT_TOKEN`:

```jsonc
"features": {
"ghcr.io/rocicorp/devcontainer-features/agents:2": {
"ghTokenSecretRef": "op://Engineering/GitHub CLI/token"
}
},
"remoteEnv": { "OP_SERVICE_ACCOUNT_TOKEN": "${localEnv:OP_SERVICE_ACCOUNT_TOKEN}" }
```

On each login shell, `/etc/profile.d/10-gh-op-token.sh` runs `op read "$ghTokenSecretRef"`
and exports the result as `GH_TOKEN`. If `ghTokenSecretRef` is empty, `op` is
unauthenticated, or the reference can't be resolved, the snippet is a no-op and you fall
back to `gh auth login` / a plain `GITHUB_TOKEN`. Scope the service account to **only** the
GitHub-token item, since that token lives in the container env.

[secret-ref]: https://developer.1password.com/docs/cli/secret-references/
[service-account]: https://developer.1password.com/docs/service-accounts/

### Usage

In any repo's `.devcontainer/devcontainer.json`:

```jsonc
"features": {
"ghcr.io/rocicorp/devcontainer-features/agents:1": {
"ghcr.io/rocicorp/devcontainer-features/agents:2": {
// optional — override the pinned Codex version
"codexVersion": "0.139.0"
}
}
```

For `gh` authentication, add the `remoteEnv` (Pattern A) or `ghTokenSecretRef` + service
account (Pattern B) wiring from [`gh` auth via 1Password](#gh-auth-via-1password-no-host-side-credentials)
above — Pattern A is the right default for local dev containers.

This single line replaces the official `claude-code` and `github-cli` feature lines, the
inline `npm install -g @openai/codex`, **and** the `.claude` volume / `CLAUDE_CONFIG_DIR` /
`chown` wiring that otherwise lives in each repo's `devcontainer.json` + `post-create.sh`.
Expand Down
16 changes: 8 additions & 8 deletions src/agents/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
{
"id": "agents",
"version": "1.2.0",
"version": "2.0.0",
"name": "AI Coding Agents (Codex + Claude Code + GitHub CLI)",
"description": "Installs the OpenAI Codex CLI, Claude Code, and the GitHub CLI so every dev container ships with the agents pre-installed. Persists the Claude and `gh` logins across rebuilds via named volumes.",
"description": "Installs the OpenAI Codex CLI, Claude Code, the GitHub CLI, and the 1Password CLI so every dev container ships with the agents pre-installed. Persists the Claude login across rebuilds via a named volume. Authenticates `gh` by injecting a short-lived token from 1Password at shell start instead of persisting GitHub credentials on the host.",
"documentationURL": "https://github.com/rocicorp/devcontainer-features/tree/main/src/agents",
"options": {
"codexVersion": {
"type": "string",
"default": "0.139.0",
"description": "Version of @openai/codex to install (npm dist-tag or exact version). Pin an exact version for reproducibility."
},
"ghTokenSecretRef": {
"type": "string",
"default": "",
"description": "1Password secret reference (op://vault/item/field) for a GitHub token. When set, GH_TOKEN is resolved from 1Password fresh at each shell start via `op read`, so no GitHub credentials are persisted on the host. Requires `op` to be authenticated in the container (set OP_SERVICE_ACCOUNT_TOKEN). Leave empty to fall back to `gh auth login` or a GITHUB_TOKEN env var."
}
},
"dependsOn": {
Expand All @@ -19,17 +24,12 @@
"CLAUDE_CONFIG_DIR": "/home/node/.claude"
},
"mounts": [
{
"source": "devcontainer-gh-config",
"target": "/home/node/.config/gh",
"type": "volume"
},
{
"source": "devcontainer-claude-config",
"target": "/home/node/.claude",
"type": "volume"
}
],
"postCreateCommand": "sudo chown -R node:node /home/node/.config/gh /home/node/.claude 2>/dev/null || true",
"postCreateCommand": "sudo chown -R node:node /home/node/.claude 2>/dev/null || true",
"installsAfter": ["ghcr.io/devcontainers/features/node"]
}
73 changes: 71 additions & 2 deletions src/agents/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ set -euo pipefail

# Feature options are passed in as uppercased env vars.
CODEX_VERSION="${CODEXVERSION:-latest}"
GH_TOKEN_OP_REF="${GHTOKENSECRETREF:-}"

# --- OpenAI Codex CLI ---------------------------------------------------------
if ! command -v npm >/dev/null 2>&1; then
echo "ERROR: npm is required to install @openai/codex but was not found on PATH." >&2
echo " Add a Node.js feature (or use a node base image) before this feature." >&2
Expand All @@ -12,7 +14,74 @@ fi

echo "Installing @openai/codex@${CODEX_VERSION} ..."
npm install -g "@openai/codex@${CODEX_VERSION}"

echo "Done. Installed:"
codex --version || true
# Claude Code is provided by the dependsOn feature (anthropics/claude-code).
# GitHub CLI is provided by the dependsOn feature (devcontainers/github-cli).

# --- 1Password CLI (op) -------------------------------------------------------
# Bundled so `gh` can authenticate from a short-lived token resolved out of
# 1Password at shell start, instead of persisting GitHub credentials on the host.
install_op() {
if command -v op >/dev/null 2>&1; then
echo "1Password CLI already installed: $(op --version)"
return 0
fi
if ! command -v apt-get >/dev/null 2>&1; then
echo "WARNING: apt-get not found; skipping 1Password CLI install." >&2
echo " Install 'op' manually if you want gh-via-1Password auth." >&2
return 0
fi

local arch keyring
arch="$(dpkg --print-architecture)"
keyring=/usr/share/keyrings/1password-archive-keyring.gpg
export DEBIAN_FRONTEND=noninteractive

echo "Installing the 1Password CLI ..."
apt-get update
apt-get install -y --no-install-recommends curl gnupg ca-certificates

curl -sS https://downloads.1password.com/linux/keys/1password.asc \
| gpg --dearmor --yes --output "$keyring"
echo "deb [arch=${arch} signed-by=${keyring}] https://downloads.1password.com/linux/debian/${arch} stable main" \
> /etc/apt/sources.list.d/1password.list

# debsig verification policy (per 1Password's official install docs).
mkdir -p /etc/debsig/policies/AC2D62742012EA22/
curl -sS https://downloads.1password.com/linux/debian/debsig/1password.pol \
> /etc/debsig/policies/AC2D62742012EA22/1password.pol
mkdir -p /usr/share/debsig/keyrings/AC2D62742012EA22
curl -sS https://downloads.1password.com/linux/keys/1password.asc \
| gpg --dearmor --yes --output /usr/share/debsig/keyrings/AC2D62742012EA22/debsig.gpg

apt-get update
apt-get install -y 1password-cli
op --version || true
}
install_op

# --- gh token injection via 1Password ----------------------------------------
# Drop a login-shell profile script that resolves GH_TOKEN from a 1Password
# secret reference (op://vault/item/field) fresh per shell. Requires `op` to be
# authenticated in the container (set OP_SERVICE_ACCOUNT_TOKEN). When no secret
# reference is configured, or op can't resolve it, this is a no-op and you can
# fall back to `gh auth login` or a GITHUB_TOKEN env var. Nothing is persisted
# on the host.
profile_script=/etc/profile.d/10-gh-op-token.sh
cat > "$profile_script" <<EOF
# Managed by the rocicorp 'agents' dev container feature — do not edit.
# Resolve a GitHub token for the gh CLI from 1Password, fresh per shell, so no
# GitHub credentials are persisted on the host.
GH_TOKEN_OP_REF="${GH_TOKEN_OP_REF}"
if [ -n "\${GH_TOKEN_OP_REF}" ] \\
&& command -v op >/dev/null 2>&1 \\
&& [ -z "\${GH_TOKEN:-}" ] && [ -z "\${GITHUB_TOKEN:-}" ]; then
if _gh_tok="\$(op read "\${GH_TOKEN_OP_REF}" 2>/dev/null)"; then
export GH_TOKEN="\${_gh_tok}"
fi
unset _gh_tok
fi
EOF
chmod 0644 "$profile_script"

echo "Done."
2 changes: 2 additions & 0 deletions test/agents/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ source dev-container-features-test-lib
check "codex is installed" bash -c "codex --version"
check "claude is installed" bash -c "claude --version"
check "gh is installed" bash -c "gh --version"
check "op is installed" bash -c "op --version"
check "gh-op-token profile script is present" bash -c "test -f /etc/profile.d/10-gh-op-token.sh"

reportResults