Container Bootstrap
UnreviewedGuide for building and maintaining one-liner bootstrap scripts that set up fresh Adom containers with Claude Code, Gallia, and Claude Squad.
name: container-bootstrap
description: Use when the user wants to build a container bootstrap/installer script, set up Claude Code OAuth in a container, troubleshoot container auth issues, create a one-liner install, or asks about how the Adom bootstrap works. Covers the full bootstrap architecture, OAuth PKCE auth in containers, PATH setup, and deployment.
Container Bootstrap Installer
Guide for building and maintaining one-liner bootstrap scripts that set up fresh Adom containers with Claude Code, Gallia, and Claude Squad.
Reference implementation: ~/gallia/scripts/bootstrap.sh
Architecture Overview
The bootstrap is a single bash script designed for curl | bash one-liner execution:
# Full install (all workspaces, ~233MB node_modules):
bash <(curl -fsSL <gallia-host>/bootstrap.sh)
# Service container (only server + named workspace, ~12MB node_modules):
bash <(curl -fsSL <gallia-host>/bootstrap.sh) --service mouser
bash <(curl -fsSL <gallia-host>/bootstrap.sh) --service jlcpcb
The <gallia-host> is the proxy URL of any running gallia container (no auth required):
https://coder.john-service-jlcpcb-9a8b6c0328533a9b.containers.adom.inc/proxy/8775
When the gallia repo goes public, use https://raw.githubusercontent.com/adom-inc/gallia/main/scripts instead.
The --service <name> flag tells the bootstrap to install only the server + viewer + named npm workspace instead of all workspaces. This cuts node_modules from ~233MB to ~30MB by skipping heavy deps like Babylon.js (79MB) and three.js (24MB). The viewer workspace is always included so Gallia Viewer (GV) is available on service containers for displaying content.
It runs in 4 sequential phases, each idempotent (safe to re-run):
| Phase | Purpose |
|---|---|
| 1. Prerequisites | Install VS Code extension, Claude Code CLI, GitHub CLI, configure VS Code settings |
| 2. Authentication | GitHub OAuth (web-based), Claude Code OAuth (PKCE script + CLI fallback) |
| 3. Install Gallia | Clone repo, npm install (scoped if --service), run installer |
| 3b. Start Service | (only with --service) Run start-<name>.sh and verify health |
| 4. Claude Squad | Launch 4 RC sessions in named tmux sessions |
Design Principles
- Idempotent: Every step checks if already done before acting. Safe to run multiple times.
- Progressive: Each phase builds on the previous. If auth fails, the user can re-run.
- Dual-method auth: PKCE script first (fast), Claude Code CLI fallback (reliable).
- Visible progress: Color-coded output with
✓,⚠,✗status indicators.
Claude Code OAuth in Containers (The Hard Part)
Standard OAuth flows redirect to localhost, which doesn't work inside containers. The solution is a manual PKCE (Proof Key for Code Exchange) flow.
Reference implementation: ~/gallia/scripts/claude-auth.mjs
Why It's Tricky
- No localhost callback — containers don't have a browser, and the port isn't accessible from outside
- Cloudflare blocks server-side requests — the token endpoint may return a Cloudflare challenge page instead of JSON
- Two different OAuth implementations in the CLI — the MCP OAuth uses form-encoded bodies, but Claude Code's own auth uses JSON. You MUST use JSON.
- The authorization code includes state — when the user copies the code from the browser, it's
CODE#STATEand the#STATEsuffix must be stripped - The
stateparameter is required in the token body — omitting it causesInvalid request format
OAuth Constants
These are extracted from the Claude Code CLI bundle. They are stable but may change with major updates.
const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback';
const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
const SCOPES = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers';
PKCE Flow (Step by Step)
1. Generate PKCE verifier and challenge
import { createHash, randomBytes } from 'crypto';
const verifier = randomBytes(48).toString('base64url').slice(0, 64);
const challenge = createHash('sha256').update(verifier).digest('base64url');
const state = randomBytes(24).toString('base64url');
2. Build the authorization URL
const params = new URLSearchParams({
code: 'true', // Tells the server to show a copyable code instead of redirecting
client_id: CLIENT_ID,
response_type: 'code',
redirect_uri: REDIRECT_URI,
scope: SCOPES,
code_challenge: challenge,
code_challenge_method: 'S256',
state: state,
});
const authUrl = `https://claude.ai/oauth/authorize?${params}`;
The code: 'true' parameter is critical — it tells the authorization server to display the code on screen instead of redirecting to localhost.
3. User opens URL, signs in, copies code
The user sees a code like gvESNNGPEqIV.... When they copy it, it may include #evCTWEQr0f6g... (the state value) appended after a #.
Always strip the #state suffix:
const code = rawCode.trim().split('#')[0];
4. Exchange code for tokens
CRITICAL: Use application/json, NOT application/x-www-form-urlencoded. Include state in the body.
const tokenBody = JSON.stringify({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier,
state, // ← REQUIRED — omitting causes "Invalid request format"
});
const req = request({
hostname: 'platform.claude.com',
path: '/v1/oauth/token',
method: 'POST',
headers: {
'Content-Type': 'application/json', // ← NOT form-encoded
'Content-Length': Buffer.byteLength(tokenBody),
'Accept': 'application/json',
},
}, callback);
Wrong content types and their errors:
| Content-Type | Result |
|---|---|
application/json (with state) |
Works |
application/json (without state) |
Invalid request format |
application/x-www-form-urlencoded |
Invalid request format |
5. Handle the response
Successful response:
{
"access_token": "sk-ant-oat01-...",
"refresh_token": "...",
"expires_in": 3600,
"scope": "org:create_api_key user:profile ..."
}
Error responses:
{ "error": { "type": "invalid_grant", "message": "Code expired or already used" } }
{ "error": { "type": "invalid_request_error", "message": "Invalid request format" } }
Note: error can be either a string (standard OAuth) or an object (Claude's custom format). Handle both:
const errType = typeof r.error === 'object' ? r.error.type || JSON.stringify(r.error) : r.error;
const errMsg = typeof r.error === 'object' ? r.error.message : r.error_description;
6. Write credentials
const creds = {
claudeAiOauth: {
accessToken: r.access_token,
refreshToken: r.refresh_token || '',
expiresAt: Date.now() + (r.expires_in || 3600) * 1000,
scopes: typeof r.scope === 'string' ? r.scope.split(' ') : (r.scope || []),
subscriptionType: null,
rateLimitTier: null,
},
};
mkdirSync(join(homedir(), '.claude'), { recursive: true });
writeFileSync(join(homedir(), '.claude', '.credentials.json'), JSON.stringify(creds), { mode: 0o600 });
Cloudflare Fallback Methods
When Cloudflare blocks the server-side token exchange, offer these fallbacks:
Browser console snippet — The user pastes a
fetch()call into the browser console onplatform.claude.com, which does the token exchange client-side (same origin, no Cloudflare issues). The snippet copies the credentials to clipboard.Existing token — From
claude setup-tokenon another machine where Claude Code is already authenticated. Tokens start withsk-ant-oat01-.API key — From
console.anthropic.com/settings/keys. Goes inANTHROPIC_API_KEYenv var in.bashrc, not.credentials.json.
CLI Fallback
If the PKCE script fails, fall back to the Claude Code CLI's built-in auth:
claude < /dev/tty # The < /dev/tty is required in curl|bash context for interactive input
The CLI shows an interactive auth prompt that handles the full flow. It works because it opens a browser URL with code: true and prompts the user to paste the code — essentially the same PKCE flow but built into the CLI.
Container Environment Gotchas
hostname Returns Docker Container ID
Inside Docker, hostname returns something like bb2913f64500, NOT the Adom hostname (john-service-jlcpcb-9a8b6c0328533a9b).
To get the repo name, parse from VSCODE_PROXY_URI:
# VSCODE_PROXY_URI = https://coder.john-service-jlcpcb-9a8b6c0328533a9b.containers.adom.inc/proxy/{{port}}/
# Pattern: coder.{user}-{repo}-{hash}.containers.adom.inc
REPO_NAME=""
if [ -n "${VSCODE_PROXY_URI:-}" ]; then
ADOM_HOST="${VSCODE_PROXY_URI#*coder.}" # john-service-jlcpcb-9a8b6c0328533a9b
ADOM_HOST="${ADOM_HOST%%.*}" # same (strip .containers...)
REPO_NAME="${ADOM_HOST#*-}" # service-jlcpcb-9a8b6c0328533a9b
REPO_NAME="${REPO_NAME%-*}" # service-jlcpcb
fi
[ -z "$REPO_NAME" ] && REPO_NAME="adom"
PATH Not Persisted After Script Exits
export PATH=... in a script only affects the script's subshell. To persist:
export PATH="$HOME/.local/bin:$HOME/.claude/bin:$PATH"
# Also persist in .bashrc for future shells
if ! grep -q '/.local/bin' "$HOME/.bashrc" 2>/dev/null; then
echo 'export PATH="$HOME/.local/bin:$HOME/.claude/bin:$PATH"' >> "$HOME/.bashrc"
fi
The Claude Code CLI installs to ~/.local/bin/claude (not ~/.claude/bin/claude). Include both paths.
Interactive Input in curl | bash
When running via curl | bash, stdin is the curl stream, not the terminal. For interactive prompts:
# Redirect from /dev/tty for interactive input
node "$AUTH_SCRIPT" < /dev/tty
claude < /dev/tty
gh auth login -p https -h github.com -w # -w flag uses web-based auth (device flow)
VS Code Settings
Configure Claude Code defaults and kill Copilot Chat. The settings file is at:
$HOME/.local/share/code-server/User/settings.json
Important: Settings are split into two categories:
- Forced — always overwritten (security, Copilot suppression). Use
s.get(k) != vcheck. - Defaults — only set if missing (theme, model). Use
k not in scheck.
If you only use if k not in s for everything, code-server's defaults (e.g. chat.agent.enabled: true) will never get overwritten.
# Force-set critical settings (overwrite even if present)
forced = {
'github.copilot.chat.enabled': False,
'github.copilot.enable': {'*': False},
'chat.agent.enabled': False,
'chat.agentsControl.enabled': False,
'chat.unifiedAgentsBar.enabled': False,
'workbench.secondarySideBar.defaultVisibility': 'hidden',
'workbench.navigationControl.enabled': False,
'claudeCode.allowDangerouslySkipPermissions': True,
'claudeCode.initialPermissionMode': 'bypassPermissions',
}
for k, v in forced.items():
if s.get(k) != v:
s[k] = v
changed = True
# Defaults (only set if missing — user can customize)
defaults = {
'workbench.secondarySideBar.visible': False,
'workbench.colorTheme': 'Default Dark Modern',
'claudeCode.preferredLocation': 'panel',
'claudeCode.selectedModel': 'opus',
}
for k, v in defaults.items():
if k not in s:
s[k] = v
changed = True
Killing the Copilot Chat Panel
The "Build with Agent" / Copilot Chat panel is built into code-server — it's not an extension you can uninstall. Key settings:
| Setting | What it does |
|---|---|
workbench.secondarySideBar.defaultVisibility: "hidden" |
Hides the entire secondary sidebar (where Chat lives) |
chat.agent.enabled: false |
Disables Agent mode (but doesn't hide the panel alone) |
chat.agentsControl.enabled: false |
Removes agent controls from title bar |
workbench.navigationControl.enabled: false |
Removes the Copilot sparkle icon from title bar |
github.copilot.enable: {"*": false} |
Disables completions |
Note: github.copilot.chat.enabled is NOT a real built-in setting — it has no effect. The real panel is controlled by workbench.secondarySideBar.defaultVisibility.
Correct Setting Keys (Common Mistakes)
| Wrong | Correct |
|---|---|
claudeCode.dangerouslySkipPermissions |
claudeCode.allowDangerouslySkipPermissions |
github.copilot.chat.enabled (no effect) |
workbench.secondarySideBar.defaultVisibility: "hidden" |
Phase-by-Phase Implementation Notes
Phase 1: Prerequisites
Check-then-install pattern for each tool:
if command -v claude &>/dev/null; then
ok "Claude Code CLI installed ($(claude --version 2>/dev/null | head -1))"
else
echo " Installing Claude Code CLI..."
curl -fsSL https://claude.ai/install.sh | bash 2>/dev/null
# ... PATH setup ...
fi
Order matters:
- VS Code extension (so the panel is available)
- VS Code settings (disable Copilot, configure Claude Code)
- Claude Code CLI (needed for auth fallback and squad)
- GitHub CLI (needed to clone private repos)
Phase 2: Authentication
Order matters:
- GitHub first (needed to clone gallia repo, and to download the PKCE auth script if gallia isn't cloned yet)
- Claude Code second (PKCE script → CLI fallback)
The auth script can come from three places (checked in order):
- Same directory as bootstrap (if run from a local checkout)
~/gallia/scripts/claude-auth.mjs(if gallia was already cloned)- Downloaded via
gh apiorcurlto/tmp/(first run)
Phase 3: Install Gallia
gh repo clone adom-inc/gallia ~/gallia
cd ~/gallia
# Full install (default — all workspaces):
npm install --no-audit --no-fund
# OR service container (--service flag — server + viewer + named workspace):
npm install --workspace=server --workspace=viewer --workspace=mouser --no-audit --no-fund
node install.mjs --project ~/project
The installer deploys skills, MCP servers, and CLAUDE.md to the project directory.
Phase 3b: Start Service (if --service)
When --service <name> is passed, the bootstrap auto-starts the service:
bash ~/gallia/services/<name>/start-<name>.sh
# Wait 2s, then check health endpoint
curl -sf http://127.0.0.1:<port>/health
The port is auto-detected from services/<name>/server.js.
Phase 4: Claude Squad
Launch 4 tmux sessions with repo-named identifiers:
SESSIONS=("${REPO_NAME}-alpha" "${REPO_NAME}-beta" "${REPO_NAME}-gamma" "${REPO_NAME}-delta")
for s in "${SESSIONS[@]}"; do
tmux new-session -d -s "$s" -c /home/adom/project "$CLAUDE_BIN --dangerously-skip-permissions rc"
done
Wait 5 seconds for RC sessions to initialize, then capture URLs from each pane.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Invalid request format on token exchange |
Wrong Content-Type or missing state |
Must use application/json with state in body |
invalid_grant error |
Code expired or already used | Codes are single-use and expire quickly. Re-run auth to get a new code |
[object Object] in error output |
Error field is an object, not string | Use typeof r.error === 'object' check |
| Code includes extra characters | User copied CODE#STATE |
Strip with code.split('#')[0] |
claude: command not found after bootstrap |
PATH not persisted to .bashrc |
Add export PATH="$HOME/.local/bin:..." to .bashrc |
| Squad sessions named with container ID | Using hostname instead of VSCODE_PROXY_URI |
Parse repo name from VSCODE_PROXY_URI |
| Cloudflare challenge instead of JSON | Token exchange blocked by WAF | Use browser console fallback or CLI fallback |
gh auth login hangs |
Stdin consumed by curl pipe | Use < /dev/tty or -w flag for web/device auth |
| Settings not taking effect | VS Code needs reload | Tell user: Ctrl+Shift+P → "Reload Window" |
set -euo pipefail exits on optional failure |
Unguarded command that can fail | Use ` |
Running the Bootstrap on a New Container
The gallia repo is private, so curl from raw.githubusercontent.com won't work without auth. Serve the bootstrap from any existing gallia container instead.
Recommended: Serve from an existing gallia container
On any container that already has gallia installed, start a file server:
cd ~/gallia/scripts && python3 -m http.server 9999 --bind 0.0.0.0 &
The current production URL (no auth required, served from service-jlcpcb on port 8775):
https://coder.john-service-jlcpcb-9a8b6c0328533a9b.containers.adom.inc/proxy/8775/bootstrap.sh
Or serve from any container with a file server. Then on the new container, run:
# Full install (dev containers):
bash <(curl -fsSL https://coder.<existing-container-slug>.containers.adom.inc/proxy/9999/bootstrap.sh)
# Service container (lightweight — only needed workspaces):
bash <(curl -fsSL https://coder.<existing-container-slug>.containers.adom.inc/proxy/9999/bootstrap.sh) --service mouser
For example, if your gallia dev container is john-gallia-f280e93ffec7e79d:
# Full install:
bash <(curl -fsSL https://coder.john-gallia-f280e93ffec7e79d.containers.adom.inc/proxy/9999/bootstrap.sh)
# Service container:
bash <(curl -fsSL https://coder.john-gallia-f280e93ffec7e79d.containers.adom.inc/proxy/9999/bootstrap.sh) --service mouser
bash <(curl -fsSL https://coder.john-gallia-f280e93ffec7e79d.containers.adom.inc/proxy/9999/bootstrap.sh) --service wiki
Any gallia container works — your dev container, the JLCPCB service container, etc. Just pick one that's running.
Important: The gallia repo is private on GitHub. You cannot use raw.githubusercontent.com URLs to fetch the bootstrap — they require auth. Always serve the bootstrap from an existing gallia container via the /proxy/9999/ static file server (started with cd ~/gallia/scripts && python3 -m http.server 9999).
Alternative: Install gh first, then bootstrap
If no gallia container is available, install the GitHub CLI manually, authenticate, then fetch the bootstrap via the GitHub API:
# 1. Install gh
(type -p wget &>/dev/null || sudo apt install wget -y) && sudo mkdir -p -m 755 /etc/apt/keyrings && wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null && sudo apt update -qq && sudo apt install gh -y -qq
# 2. Auth (opens browser)
gh auth login -p https -h github.com -w
# 3. Run bootstrap
bash <(gh api repos/adom-inc/gallia/contents/scripts/bootstrap.sh --jq '.content' | base64 -d)
Updating a Running Service Container
The deployment flow for standalone services (JLCPCB, Mouser, Wiki, etc.):
- Edit code in gallia on your dev container
- Push to GitHub:
cd ~/gallia && git push - On the service container, pull and restart:
cd ~/gallia && git pull npm install --workspace=server --workspace=viewer --workspace=<name> --no-audit --no-fund # Restart the service (kill old process + start new one) pkill -f 'node.*services/<name>/server.js' || true bash ~/gallia/services/<name>/start-<name>.sh
Or re-run the full bootstrap — it's idempotent and will pull latest + restart.
The gallia repo is always the source of truth. Service containers don't have local edits — they just run whatever's in the repo.
How to Find OAuth Constants
If the constants change in a future Claude Code update, find them in the CLI bundle:
# Find the CLI bundle
CLI_JS=$(ls -t ~/.local/share/code-server/extensions/anthropic.claude-code-*/resources/claude-code/cli.js | head -1)
# Extract constants
grep -oP 'CLIENT_ID["\s:=]+["\x27]([^"\x27]+)' "$CLI_JS"
grep -oP 'TOKEN_URL["\s:=]+["\x27]([^"\x27]+)' "$CLI_JS"
grep -oP 'MANUAL_REDIRECT_URL["\s:=]+["\x27]([^"\x27]+)' "$CLI_JS"
The CLI bundle is minified (~15MB). Key patterns:
F$8/exchangeCodeForTokens— the function that does Claude Code's own auth (JSON body)N6Y— MCP OAuth helper (form-encoded body, different function — do NOT use this format)g8=axios— the HTTP client used internally