mirror of
https://github.com/trailofbits/claude-code-devcontainer
synced 2026-06-21 14:11:48 +00:00
438 lines
13 KiB
Python
438 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Post-install configuration for AI coding agent devcontainer.
|
|
|
|
Runs on container creation to set up:
|
|
- Onboarding bypass (when CLAUDE_CODE_OAUTH_TOKEN is set)
|
|
- Claude settings (bypassPermissions mode)
|
|
- Codex settings and optional headless auth
|
|
- Tmux configuration (200k history, mouse support)
|
|
- Directory ownership fixes for mounted volumes
|
|
"""
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def setup_onboarding_bypass():
|
|
"""Bypass the interactive onboarding wizard when CLAUDE_CODE_OAUTH_TOKEN is set.
|
|
|
|
Runs `claude -p` to seed ~/.claude.json with auth state. The subprocess
|
|
writes the config file during startup before the API call completes, so
|
|
a timeout is expected and acceptable. After the subprocess finishes (or
|
|
times out), we check whether ~/.claude.json was populated and only then
|
|
set hasCompletedOnboarding.
|
|
|
|
Workaround for https://github.com/anthropics/claude-code/issues/8938.
|
|
"""
|
|
token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
|
|
if not token:
|
|
print(
|
|
"[post_install] No CLAUDE_CODE_OAUTH_TOKEN set, skipping onboarding bypass",
|
|
file=sys.stderr,
|
|
)
|
|
return
|
|
|
|
# When `CLAUDE_CONFIG_DIR` is set, as is done in `devcontainer.json`, `claude` unexpectedly
|
|
# looks for `.claude.json` in *that* folder, instead of in `~`, contradicting the documentation.
|
|
# See https://github.com/anthropics/claude-code/issues/3833#issuecomment-3694918874
|
|
claude_json_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home()))
|
|
claude_json = claude_json_dir / ".claude.json"
|
|
|
|
print("[post_install] Running claude -p to populate auth state...", file=sys.stderr)
|
|
try:
|
|
result = subprocess.run(
|
|
["claude", "-p", "ok"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
if result.returncode != 0:
|
|
print(
|
|
f"[post_install] claude -p exited {result.returncode}: "
|
|
f"{result.stderr.strip()}",
|
|
file=sys.stderr,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
print(
|
|
"[post_install] claude -p timed out (expected on cold start)",
|
|
file=sys.stderr,
|
|
)
|
|
except (FileNotFoundError, OSError) as e:
|
|
print(
|
|
f"[post_install] Warning: could not run claude ({e}) — "
|
|
"onboarding bypass skipped",
|
|
file=sys.stderr,
|
|
)
|
|
return
|
|
|
|
if not claude_json.exists():
|
|
print(
|
|
f"[post_install] Warning: {claude_json} not created by claude -p — "
|
|
"onboarding bypass skipped",
|
|
file=sys.stderr,
|
|
)
|
|
return
|
|
|
|
config: dict = {}
|
|
try:
|
|
config = json.loads(claude_json.read_text())
|
|
except json.JSONDecodeError as e:
|
|
print(
|
|
f"[post_install] Warning: {claude_json} has invalid JSON ({e}), "
|
|
"starting fresh",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
config["hasCompletedOnboarding"] = True
|
|
|
|
claude_json.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
print(
|
|
f"[post_install] Onboarding bypass configured: {claude_json}", file=sys.stderr
|
|
)
|
|
|
|
|
|
def setup_claude_settings():
|
|
"""Configure Claude Code with bypassPermissions enabled."""
|
|
claude_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude"))
|
|
claude_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
settings_file = claude_dir / "settings.json"
|
|
|
|
# Load existing settings or start fresh
|
|
settings = {}
|
|
if settings_file.exists():
|
|
with contextlib.suppress(json.JSONDecodeError):
|
|
settings = json.loads(settings_file.read_text())
|
|
|
|
# Set bypassPermissions mode
|
|
if "permissions" not in settings:
|
|
settings["permissions"] = {}
|
|
settings["permissions"]["defaultMode"] = "bypassPermissions"
|
|
|
|
settings_file.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
|
|
print(
|
|
f"[post_install] Claude settings configured: {settings_file}", file=sys.stderr
|
|
)
|
|
|
|
|
|
def has_top_level_toml_key(text: str, key: str) -> bool:
|
|
"""Return whether a TOML document has a top-level key."""
|
|
for line in text.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
if stripped.startswith("["):
|
|
return False
|
|
if stripped.startswith(f"{key} ") or stripped.startswith(f"{key}="):
|
|
return True
|
|
return False
|
|
|
|
|
|
def insert_top_level_toml_defaults(text: str, defaults: dict[str, str]) -> str:
|
|
"""Insert missing top-level TOML defaults before the first table."""
|
|
missing = {
|
|
key: value
|
|
for key, value in defaults.items()
|
|
if not has_top_level_toml_key(text, key)
|
|
}
|
|
if not missing:
|
|
return text
|
|
|
|
default_lines = [
|
|
"# Container defaults: the devcontainer is the sandbox boundary.",
|
|
*[f'{key} = "{value}"' for key, value in missing.items()],
|
|
]
|
|
|
|
lines = text.splitlines()
|
|
insert_at = len(lines)
|
|
for index, line in enumerate(lines):
|
|
if line.strip().startswith("["):
|
|
insert_at = index
|
|
break
|
|
|
|
if insert_at == 0:
|
|
lines = default_lines + [""] + lines
|
|
elif insert_at == len(lines):
|
|
if lines and lines[-1].strip():
|
|
lines.append("")
|
|
lines.extend(default_lines)
|
|
else:
|
|
lines = lines[:insert_at] + default_lines + [""] + lines[insert_at:]
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def setup_codex_settings():
|
|
"""Configure Codex CLI for the externally sandboxed devcontainer."""
|
|
codex_home = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
|
|
codex_home.mkdir(parents=True, exist_ok=True)
|
|
|
|
config_file = codex_home / "config.toml"
|
|
defaults = {
|
|
"approval_policy": "never",
|
|
"sandbox_mode": "danger-full-access",
|
|
"cli_auth_credentials_store": "file",
|
|
}
|
|
|
|
existing = config_file.read_text(encoding="utf-8") if config_file.exists() else ""
|
|
updated = insert_top_level_toml_defaults(existing, defaults)
|
|
|
|
if updated != existing:
|
|
config_file.write_text(updated, encoding="utf-8")
|
|
print(
|
|
f"[post_install] Codex settings configured: {config_file}",
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
print(
|
|
f"[post_install] Codex settings already configured: {config_file}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def setup_codex_auth():
|
|
"""Persist Codex auth when a headless token or API key is provided."""
|
|
access_token = os.environ.get("CODEX_ACCESS_TOKEN", "").strip()
|
|
api_key = os.environ.get("OPENAI_API_KEY", "").strip()
|
|
|
|
if access_token:
|
|
run_codex_login("--with-access-token", access_token, "CODEX_ACCESS_TOKEN")
|
|
elif api_key:
|
|
run_codex_login("--with-api-key", api_key, "OPENAI_API_KEY")
|
|
else:
|
|
print(
|
|
"[post_install] No Codex auth env var set, skipping Codex login",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def run_codex_login(flag: str, secret: str, source_name: str):
|
|
"""Run codex login without printing credential material."""
|
|
print(f"[post_install] Running codex login from {source_name}...", file=sys.stderr)
|
|
try:
|
|
result = subprocess.run(
|
|
["codex", "login", flag],
|
|
input=secret,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
print("[post_install] Warning: codex login timed out", file=sys.stderr)
|
|
return
|
|
except (FileNotFoundError, OSError) as e:
|
|
print(
|
|
f"[post_install] Warning: could not run codex ({e}) - Codex login skipped",
|
|
file=sys.stderr,
|
|
)
|
|
return
|
|
|
|
if result.returncode != 0:
|
|
print(
|
|
f"[post_install] Warning: codex login exited {result.returncode}; "
|
|
"Codex auth was not persisted",
|
|
file=sys.stderr,
|
|
)
|
|
return
|
|
|
|
print("[post_install] Codex auth configured", file=sys.stderr)
|
|
|
|
|
|
def setup_tmux_config():
|
|
"""Configure tmux with 200k history, mouse support, and vi keys."""
|
|
tmux_conf = Path.home() / ".tmux.conf"
|
|
|
|
if tmux_conf.exists():
|
|
print("[post_install] Tmux config exists, skipping", file=sys.stderr)
|
|
return
|
|
|
|
config = """\
|
|
# 200k line scrollback history
|
|
set-option -g history-limit 200000
|
|
|
|
# Enable mouse support
|
|
set -g mouse on
|
|
|
|
# Use vi keys in copy mode
|
|
setw -g mode-keys vi
|
|
|
|
# Start windows and panes at 1, not 0
|
|
set -g base-index 1
|
|
setw -g pane-base-index 1
|
|
|
|
# Renumber windows when one is closed
|
|
set -g renumber-windows on
|
|
|
|
# Faster escape time for vim
|
|
set -sg escape-time 10
|
|
|
|
# True color support
|
|
set -g default-terminal "tmux-256color"
|
|
set -ag terminal-overrides ",xterm-256color:RGB"
|
|
|
|
# Terminal features (ghostty, cursor shape in vim)
|
|
set -as terminal-features ",xterm-ghostty:RGB"
|
|
set -as terminal-features ",xterm*:RGB"
|
|
set -ga terminal-overrides ",xterm*:colors=256"
|
|
set -ga terminal-overrides '*:Ss=\\E[%p1%d q:Se=\\E[ q'
|
|
|
|
# Status bar
|
|
set -g status-style 'bg=#333333 fg=#ffffff'
|
|
set -g status-left '[#S] '
|
|
set -g status-right '%Y-%m-%d %H:%M'
|
|
"""
|
|
tmux_conf.write_text(config, encoding="utf-8")
|
|
print(f"[post_install] Tmux configured: {tmux_conf}", file=sys.stderr)
|
|
|
|
|
|
def fix_directory_ownership():
|
|
"""Fix ownership of mounted volumes that may have root ownership."""
|
|
uid = os.getuid()
|
|
gid = os.getgid()
|
|
|
|
dirs_to_fix = [
|
|
Path.home() / ".claude",
|
|
Path.home() / ".codex",
|
|
Path("/commandhistory"),
|
|
Path.home() / ".config" / "gh",
|
|
]
|
|
|
|
for dir_path in dirs_to_fix:
|
|
if dir_path.exists():
|
|
try:
|
|
# Use sudo to fix ownership if needed
|
|
stat_info = dir_path.stat()
|
|
if stat_info.st_uid != uid:
|
|
subprocess.run(
|
|
["sudo", "chown", "-R", f"{uid}:{gid}", str(dir_path)],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
print(
|
|
f"[post_install] Fixed ownership: {dir_path}", file=sys.stderr
|
|
)
|
|
except (PermissionError, subprocess.CalledProcessError) as e:
|
|
print(
|
|
f"[post_install] Warning: Could not fix ownership of {dir_path}: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def setup_global_gitignore():
|
|
"""Set up global gitignore and local git config.
|
|
|
|
Since ~/.gitconfig is mounted read-only from host, we create a local
|
|
config file that includes the host config and adds container-specific
|
|
settings like core.excludesfile and delta configuration.
|
|
|
|
GIT_CONFIG_GLOBAL env var (set in devcontainer.json) points git to this
|
|
local config as the "global" config.
|
|
"""
|
|
home = Path.home()
|
|
gitignore = home / ".gitignore_global"
|
|
local_gitconfig = home / ".gitconfig.local"
|
|
host_gitconfig = home / ".gitconfig"
|
|
|
|
# Create global gitignore with common patterns
|
|
patterns = """\
|
|
# Agent CLIs
|
|
.claude/
|
|
.codex/
|
|
|
|
# macOS
|
|
.DS_Store
|
|
.AppleDouble
|
|
.LSOverride
|
|
._*
|
|
|
|
# Python
|
|
*.pyc
|
|
*.pyo
|
|
__pycache__/
|
|
*.egg-info/
|
|
.eggs/
|
|
*.egg
|
|
.venv/
|
|
venv/
|
|
.mypy_cache/
|
|
.ruff_cache/
|
|
|
|
# Node
|
|
node_modules/
|
|
.npm/
|
|
|
|
# Editors
|
|
*.swp
|
|
*.swo
|
|
*~
|
|
.idea/
|
|
.vscode/
|
|
*.sublime-*
|
|
|
|
# Misc
|
|
*.log
|
|
.env.local
|
|
.env.*.local
|
|
"""
|
|
gitignore.write_text(patterns, encoding="utf-8")
|
|
print(f"[post_install] Global gitignore created: {gitignore}", file=sys.stderr)
|
|
|
|
# Create local git config that includes host config and sets excludesfile + delta
|
|
# Delta config is included here so it works even if host doesn't have it configured
|
|
local_config = f"""\
|
|
# Container-local git config
|
|
# Includes host config (mounted read-only) and adds container settings
|
|
|
|
[include]
|
|
path = {host_gitconfig}
|
|
|
|
[core]
|
|
excludesfile = {gitignore}
|
|
pager = delta
|
|
|
|
[interactive]
|
|
diffFilter = delta --color-only
|
|
|
|
[delta]
|
|
navigate = true
|
|
light = false
|
|
line-numbers = true
|
|
side-by-side = false
|
|
|
|
[merge]
|
|
conflictstyle = diff3
|
|
|
|
[diff]
|
|
colorMoved = default
|
|
|
|
[gpg "ssh"]
|
|
program = /usr/bin/ssh-keygen
|
|
"""
|
|
local_gitconfig.write_text(local_config, encoding="utf-8")
|
|
print(
|
|
f"[post_install] Local git config created: {local_gitconfig}", file=sys.stderr
|
|
)
|
|
|
|
|
|
def main():
|
|
"""Run all post-install configuration."""
|
|
print("[post_install] Starting post-install configuration...", file=sys.stderr)
|
|
|
|
fix_directory_ownership()
|
|
setup_onboarding_bypass()
|
|
setup_claude_settings()
|
|
setup_codex_settings()
|
|
setup_codex_auth()
|
|
setup_tmux_config()
|
|
setup_global_gitignore()
|
|
|
|
print("[post_install] Configuration complete!", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|