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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 \
.
57 changes: 47 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,65 @@ 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. Instead, `gh` reads `GH_TOKEN` from a token resolved out of 1Password fresh at each
shell start — nothing is written to the host.

Set the `ghTokenSecretRef` option to a [1Password secret reference][secret-ref]
(`op://vault/item/field`) pointing at a GitHub token, and provide an
[`OP_SERVICE_ACCOUNT_TOKEN`][service-account] so `op` can authenticate headlessly inside
the container:

```jsonc
"features": {
"ghcr.io/rocicorp/devcontainer-features/agents:2": {
"ghTokenSecretRef": "op://Engineering/GitHub CLI/token"
}
},
// pass the 1Password service-account token through from the host environment
"remoteEnv": {
"OP_SERVICE_ACCOUNT_TOKEN": "${localEnv:OP_SERVICE_ACCOUNT_TOKEN}"
}
```

On each (login) shell, a `/etc/profile.d/10-gh-op-token.sh` snippet runs
`op read "$ghTokenSecretRef"` and exports the result as `GH_TOKEN`, which `gh` uses
automatically. If `ghTokenSecretRef` is empty, `op` is unauthenticated, or the reference
can't be resolved, the snippet is a no-op and you can fall back to `gh auth login` or set
`GITHUB_TOKEN` yourself. Use a token with a short expiry / least-privilege scope so the
injected credential is genuinely short-lived.

[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"
"codexVersion": "0.139.0",
// optional — 1Password secret reference for the gh token (see above)
"ghTokenSecretRef": "op://Engineering/GitHub CLI/token"
}
}
```
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
Loading