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

```bash
# 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.

```javascript
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

```javascript
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

```javascript
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:**
```javascript
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.

```javascript
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:
```json
{
  "access_token": "sk-ant-oat01-...",
  "refresh_token": "...",
  "expires_in": 3600,
  "scope": "org:create_api_key user:profile ..."
}
```

Error responses:
```json
{ "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:
```javascript
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

```javascript
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:

```bash
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`:

```bash
# 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:

```bash
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:

```bash
# 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.

```python
# 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:

```bash
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

```bash
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
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:

```bash
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 `|| true` for optional commands |

## 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:

```bash
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:

```bash
# 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`:

```bash
# 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:

```bash
# 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:
   ```bash
   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:

```bash
# 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
