Skip to content
Merged
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
110 changes: 85 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ 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
- **1Password CLI** (`op`, installed from 1Password's apt repo) — so `gh` can authenticate
from a 1Password-sourced token instead of persisting credentials on the host

### Persistent Claude login

Expand All @@ -31,35 +31,94 @@ 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 `GITHUB_TOKEN` **environment variable** instead, resolved
from 1Password on your host and forwarded into the container — 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 reason it's forwarded from the host rather than resolved inside the container:
**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
the container can't reach. So `op` runs on your **host**, and only the resulting token
crosses into the container — `gh` reads `GITHUB_TOKEN` directly.

```jsonc
"features": {
"ghcr.io/rocicorp/devcontainer-features/agents:2": {
"ghTokenSecretRef": "op://Engineering/GitHub CLI/token"
}
"ghcr.io/rocicorp/devcontainer-features/agents:2": { "codexVersion": "0.139.0" }
},
// pass the 1Password service-account token through from the host environment
"remoteEnv": {
"OP_SERVICE_ACCOUNT_TOKEN": "${localEnv:OP_SERVICE_ACCOUNT_TOKEN}"
}
// gh reads GITHUB_TOKEN; forward it from the host (resolved there via 1Password)
"remoteEnv": { "GITHUB_TOKEN": "${localEnv:GITHUB_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.
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. **Make the variable visible to the editor process.** `${localEnv:...}` is read from the
editor's *process* environment, and **GUI/Dock/Spotlight launches do _not_ read
`~/.zshrc`** — so a Dock-launched editor won't see `GITHUB_TOKEN`. Two ways to fix it:

- **Launch from a terminal** (scopes the variable to that editor instance). Fully quit
the editor first, then from a terminal where `echo ${#GITHUB_TOKEN}` is non-zero start
it (`code`). Best when you open a folder/workspace from the CLI.
- **`launchctl setenv`** (works with Dock/Spotlight launches — and with the no-checkout
"Clone Repository in Container Volume" flow, where you never open a folder from the
CLI):
```bash
launchctl setenv GITHUB_TOKEN "$(op read 'op://Employee/GitHub Personal Access Token/token')"
```
This puts the variable into your **GUI login session**, so anything launched afterward
(including a Dock-launched editor) inherits it. Caveats:
- **Relaunch** any already-running editor — it only picks up the value on a fresh launch.
- **Not persistent** — `launchctl setenv` is cleared on logout/restart; re-run it each
session, or automate it with a login LaunchAgent (below).
- **Session-wide** — it's readable by *all* GUI apps in your login session, not just the
editor. If you'd rather keep it scoped, use the terminal launch instead.

Then build/reopen the container and check `gh auth status`.

<details><summary>Optional: re-apply it automatically at login (LaunchAgent)</summary>

Create `~/Library/LaunchAgents/com.rocicorp.github-token.plist`:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.rocicorp.github-token</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>launchctl setenv GITHUB_TOKEN "$(/usr/local/bin/op read 'op://Employee/GitHub Personal Access Token/token')"</string>
</array>
<key>RunAtLoad</key><true/>
</dict>
</plist>
```

Load it with `launchctl load ~/Library/LaunchAgents/com.rocicorp.github-token.plist`.
Caveat: it runs `op read` non-interactively at login, which only succeeds if 1Password can
authorize without a prompt (e.g. the app is unlocked / CLI integration allows it) —
otherwise just run the `launchctl setenv` line by hand each session.
</details>

> 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.

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

### Usage

Expand All @@ -69,13 +128,14 @@ 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` `GITHUB_TOKEN` passthrough from
[`gh` auth via 1Password](#gh-auth-via-1password-no-host-side-credentials) above.

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
Loading