Skip to content

Commit 5a2b92a

Browse files
akshithgclaude
andauthored
feat: add non-interactive auth via CLAUDE_CODE_OAUTH_TOKEN (#32)
* feat: add non-interactive auth via CLAUDE_CODE_OAUTH_TOKEN Bypass the interactive onboarding wizard when CLAUDE_CODE_OAUTH_TOKEN is set. On container create, post_install.py runs `claude -p` to populate auth state and sets hasCompletedOnboarding so the TUI starts without the login wizard. Workaround for anthropics/claude-code#8938. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use remoteEnv instead of containerEnv for secrets containerEnv bakes values into the image as ENV instructions, visible in docker inspect/history. remoteEnv is set at runtime only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: improve error handling in onboarding bypass - Handle timeout as expected (claude -p writes config before API call) - Catch FileNotFoundError/OSError if claude is not installed - Check returncode explicitly instead of dead CalledProcessError catch - Guard on ~/.claude.json existence before writing onboarding flag - Replace contextlib.suppress with explicit try/except that logs - Update module docstring and README wording Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6dc4b4f commit 5a2b92a

File tree

3 files changed

+105
-3
lines changed

3 files changed

+105
-3
lines changed

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,22 @@ cd client-repo-1
114114
claude # Ready to work
115115
```
116116

117+
## Token-Based Auth (Headless)
118+
119+
For headless servers or to skip the interactive login wizard:
120+
121+
```bash
122+
claude setup-token # run on host, one-time
123+
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
124+
devc rebuild # rebuilds with token
125+
```
126+
127+
The token is forwarded into the container. On each container creation, `post_install.py` runs a one-shot auth handshake so `claude` starts without the login wizard.
128+
129+
This works around Claude Code's interactive onboarding wizard always showing in containers, even with valid credentials ([#8938](https://github.com/anthropics/claude-code/issues/8938)).
130+
131+
If you don't set a token, the interactive login flow works as before.
132+
117133
## CLI Helper Commands
118134

119135
```

devcontainer.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@
6363
"PYTHONDONTWRITEBYTECODE": "1",
6464
"PIP_DISABLE_PIP_VERSION_CHECK": "1"
6565
},
66+
"remoteEnv": {
67+
"CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN:}",
68+
"ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY:}"
69+
},
6670
"initializeCommand": "test -f \"$HOME/.gitconfig\" || touch \"$HOME/.gitconfig\"",
6771
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
6872
"workspaceFolder": "/workspace",

post_install.py

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"""Post-install configuration for Claude Code devcontainer.
33
44
Runs on container creation to set up:
5+
- Onboarding bypass (when CLAUDE_CODE_OAUTH_TOKEN is set)
56
- Claude settings (bypassPermissions mode)
67
- Tmux configuration (200k history, mouse support)
78
- Directory ownership fixes for mounted volumes
@@ -15,6 +16,80 @@
1516
from pathlib import Path
1617

1718

19+
def setup_onboarding_bypass():
20+
"""Bypass the interactive onboarding wizard when CLAUDE_CODE_OAUTH_TOKEN is set.
21+
22+
Runs `claude -p` to seed ~/.claude.json with auth state. The subprocess
23+
writes the config file during startup before the API call completes, so
24+
a timeout is expected and acceptable. After the subprocess finishes (or
25+
times out), we check whether ~/.claude.json was populated and only then
26+
set hasCompletedOnboarding.
27+
28+
Workaround for https://github.com/anthropics/claude-code/issues/8938.
29+
"""
30+
token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
31+
if not token:
32+
print(
33+
"[post_install] No CLAUDE_CODE_OAUTH_TOKEN set, skipping onboarding bypass",
34+
file=sys.stderr,
35+
)
36+
return
37+
38+
claude_json = Path.home() / ".claude.json"
39+
40+
print("[post_install] Running claude -p to populate auth state...", file=sys.stderr)
41+
try:
42+
result = subprocess.run(
43+
["claude", "-p", "ok"],
44+
capture_output=True,
45+
text=True,
46+
timeout=30,
47+
)
48+
if result.returncode != 0:
49+
print(
50+
f"[post_install] claude -p exited {result.returncode}: "
51+
f"{result.stderr.strip()}",
52+
file=sys.stderr,
53+
)
54+
except subprocess.TimeoutExpired:
55+
print(
56+
"[post_install] claude -p timed out (expected on cold start)",
57+
file=sys.stderr,
58+
)
59+
except (FileNotFoundError, OSError) as e:
60+
print(
61+
f"[post_install] Warning: could not run claude ({e}) — "
62+
"onboarding bypass skipped",
63+
file=sys.stderr,
64+
)
65+
return
66+
67+
if not claude_json.exists():
68+
print(
69+
f"[post_install] Warning: {claude_json} not created by claude -p — "
70+
"onboarding bypass skipped",
71+
file=sys.stderr,
72+
)
73+
return
74+
75+
config: dict = {}
76+
try:
77+
config = json.loads(claude_json.read_text())
78+
except json.JSONDecodeError as e:
79+
print(
80+
f"[post_install] Warning: {claude_json} has invalid JSON ({e}), "
81+
"starting fresh",
82+
file=sys.stderr,
83+
)
84+
85+
config["hasCompletedOnboarding"] = True
86+
87+
claude_json.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
88+
print(
89+
f"[post_install] Onboarding bypass configured: {claude_json}", file=sys.stderr
90+
)
91+
92+
1893
def setup_claude_settings():
1994
"""Configure Claude Code with bypassPermissions enabled."""
2095
claude_dir = Path.home() / ".claude"
@@ -34,7 +109,9 @@ def setup_claude_settings():
34109
settings["permissions"]["defaultMode"] = "bypassPermissions"
35110

36111
settings_file.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
37-
print(f"[post_install] Claude settings configured: {settings_file}", file=sys.stderr)
112+
print(
113+
f"[post_install] Claude settings configured: {settings_file}", file=sys.stderr
114+
)
38115

39116

40117
def setup_tmux_config():
@@ -106,7 +183,9 @@ def fix_directory_ownership():
106183
check=True,
107184
capture_output=True,
108185
)
109-
print(f"[post_install] Fixed ownership: {dir_path}", file=sys.stderr)
186+
print(
187+
f"[post_install] Fixed ownership: {dir_path}", file=sys.stderr
188+
)
110189
except (PermissionError, subprocess.CalledProcessError) as e:
111190
print(
112191
f"[post_install] Warning: Could not fix ownership of {dir_path}: {e}",
@@ -204,13 +283,16 @@ def setup_global_gitignore():
204283
program = /usr/bin/ssh-keygen
205284
"""
206285
local_gitconfig.write_text(local_config, encoding="utf-8")
207-
print(f"[post_install] Local git config created: {local_gitconfig}", file=sys.stderr)
286+
print(
287+
f"[post_install] Local git config created: {local_gitconfig}", file=sys.stderr
288+
)
208289

209290

210291
def main():
211292
"""Run all post-install configuration."""
212293
print("[post_install] Starting post-install configuration...", file=sys.stderr)
213294

295+
setup_onboarding_bypass()
214296
setup_claude_settings()
215297
setup_tmux_config()
216298
fix_directory_ownership()

0 commit comments

Comments
 (0)