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

  1. No localhost callback — containers don't have a browser, and the port isn't accessible from outside
  2. Cloudflare blocks server-side requests — the token endpoint may return a Cloudflare challenge page instead of JSON
  3. 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.
  4. The authorization code includes state — when the user copies the code from the browser, it's CODE#STATE and the #STATE suffix must be stripped
  5. The state parameter is required in the token body — omitting it causes Invalid 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:

  1. Browser console snippet — The user pastes a fetch() call into the browser console on platform.claude.com, which does the token exchange client-side (same origin, no Cloudflare issues). The snippet copies the credentials to clipboard.

  2. Existing token — From claude setup-token on another machine where Claude Code is already authenticated. Tokens start with sk-ant-oat01-.

  3. API key — From console.anthropic.com/settings/keys. Goes in ANTHROPIC_API_KEY env 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) != v check.
  • Defaults — only set if missing (theme, model). Use k not in s check.

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:

  1. VS Code extension (so the panel is available)
  2. VS Code settings (disable Copilot, configure Claude Code)
  3. Claude Code CLI (needed for auth fallback and squad)
  4. GitHub CLI (needed to clone private repos)

Phase 2: Authentication

Order matters:

  1. GitHub first (needed to clone gallia repo, and to download the PKCE auth script if gallia isn't cloned yet)
  2. Claude Code second (PKCE script → CLI fallback)

The auth script can come from three places (checked in order):

  1. Same directory as bootstrap (if run from a local checkout)
  2. ~/gallia/scripts/claude-auth.mjs (if gallia was already cloned)
  3. Downloaded via gh api or curl to /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.):

  1. Edit code in gallia on your dev container
  2. Push to GitHub: cd ~/gallia && git push
  3. 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