From 50268c0055a85a6cc3cd2c2d053ed913847115f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 07:06:57 +0000 Subject: [PATCH 1/4] feat(agents)!: authenticate gh via 1Password instead of persisting credentials (v2.0.0) Replace the host-side devcontainer-gh-config volume (which persisted a long-lived GitHub token indefinitely) with on-demand token injection from 1Password: - Drop the devcontainer-gh-config mount and its chown; Claude config volume stays persisted. - Bundle the 1Password CLI (op) in install.sh. - Add a ghTokenSecretRef option; a /etc/profile.d snippet resolves GH_TOKEN from the 1Password secret reference fresh per shell (no host persistence). No-op when unset/unauthenticated, falling back to gh auth login / GITHUB_TOKEN. - Update README and tests; bump feature to v2.0.0 (removes gh persistence). --- README.md | 57 ++++++++++++++++++---- src/agents/devcontainer-feature.json | 16 +++--- src/agents/install.sh | 73 +++++++++++++++++++++++++++- test/agents/test.sh | 2 + 4 files changed, 128 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index eab60e4..11d60c5 100644 --- a/README.md +++ b/README.md @@ -13,18 +13,53 @@ 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 @@ -32,9 +67,11 @@ 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" } } ``` diff --git a/src/agents/devcontainer-feature.json b/src/agents/devcontainer-feature.json index 3b09924..bc23b2c 100644 --- a/src/agents/devcontainer-feature.json +++ b/src/agents/devcontainer-feature.json @@ -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": { @@ -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"] } diff --git a/src/agents/install.sh b/src/agents/install.sh index a272442..b07b19b 100755 --- a/src/agents/install.sh +++ b/src/agents/install.sh @@ -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 @@ -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" </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." diff --git a/test/agents/test.sh b/test/agents/test.sh index 0f86a7f..64d793f 100755 --- a/test/agents/test.sh +++ b/test/agents/test.sh @@ -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 From 451b5088a9952063612eeafef4fd02d0f4fbf68e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 07:18:28 +0000 Subject: [PATCH 2/4] ci: add feature test workflow for the agents feature Runs 'devcontainer features test' on pull requests and pushes so the agents feature (codex/claude/gh/op + gh-token profile script) is verified in CI, with no local tooling required. Pins actions/checkout to a commit SHA per org policy. --- .github/workflows/test.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fd7238e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: Test dev container features + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install devcontainer CLI + run: npm install -g @devcontainers/cli + + - name: Test agents feature + run: | + devcontainer features test \ + --features agents \ + --base-image mcr.microsoft.com/devcontainers/javascript-node:24 \ + . From 5c3cb1be24db714db2413d12d485d3988d4de919 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 07:20:12 +0000 Subject: [PATCH 3/4] ci: cover all three features (agents, docker, pnpm) via matrix --- .github/workflows/test.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fd7238e..167b25e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,15 +13,22 @@ permissions: 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 agents feature + - name: Test ${{ matrix.feature }} feature run: | devcontainer features test \ - --features agents \ + --features ${{ matrix.feature }} \ --base-image mcr.microsoft.com/devcontainers/javascript-node:24 \ . From 577f9cb31edaf82df3adb83608f6a2feb3c81538 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 08:53:51 +0000 Subject: [PATCH 4/4] docs(agents): document gh-via-1Password local vs headless patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify the non-obvious bits from real setup: 1Password desktop integration doesn't cross the container boundary, so document two patterns — Pattern A (local: resolve on host, forward GITHUB_TOKEN via remoteEnv; recommended) and Pattern B (headless: in-container op read via ghTokenSecretRef + service account) — including the op-install/.zshrc/GUI-launch gotchas. --- README.md | 81 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 62 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 11d60c5..f84430e 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,58 @@ the `node` user can write to it. 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. +gone. `gh` authenticates from a `GH_TOKEN`/`GITHUB_TOKEN` **environment variable** instead, +sourced from 1Password — 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: +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 , + 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": { @@ -45,18 +90,14 @@ the container: "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}" -} +"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. +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/ @@ -69,13 +110,15 @@ In any repo's `.devcontainer/devcontainer.json`: "features": { "ghcr.io/rocicorp/devcontainer-features/agents:2": { // optional — override the pinned Codex version - "codexVersion": "0.139.0", - // optional — 1Password secret reference for the gh token (see above) - "ghTokenSecretRef": "op://Engineering/GitHub CLI/token" + "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`.