Skip to content

ccpod Internals Reference

Implementation details for contributors and advanced users.

PackagePurpose
cittyCLI framework (lightweight, Bun-native)
zodSchema validation for config files
yamlYAML parse/serialize
deepmergeDeep-merge for settings.json
@inquirer/promptsInteractive wizard prompts
simple-gitGit operations for profile sync
chalkTerminal output colouring

Docker operations use the docker CLI via Bun.spawn — no Docker SDK dependency. Captured commands support optional kill-and-reap timeouts. Resolved env for run/create is transported with a private temporary env-file, never in the Docker client’s environment or argv, then cleaned up after the child completes; values containing line breaks (which the line-based file format cannot carry) fall back to a bare -e KEY read from the Docker client’s environment. Directories are PID-tagged and dead-owner leftovers from SIGKILL are swept on the next start.

Most shared types live in src/types/index.ts. The ContainerSpec type is the exception — it is declared in src/container/builder.ts next to its builder.

export type SyncStrategy = 'always' | 'daily' | 'pin';
export type PermissionsPreset = 'conservative' | 'moderate' | 'permissive';
type MergeStrategy = 'deep' | 'override';
type StateMode = 'ephemeral' | 'persistent';
type NetworkPolicy = 'full' | 'restricted';
type AuthType = 'api-key' | 'oauth' | 'proxy';
type ClaudeMdMerge = 'append' | 'override';
interface PortsConfig {
autoDetectMcp: boolean;
list: string[];
}
interface PortMapping {
host: number;
container: number;
}
export interface ServiceConfig {
image: string;
env?: Record<string, string>;
volumes?: string[];
ports?: string[];
}
export interface ProfileConfig {
name: string;
description?: string;
claudeArgs: string[];
config: {
source: 'local' | 'git';
path?: string;
repo?: string;
sync?: SyncStrategy;
ref?: string;
};
image: {
use: string;
dockerfile?: string;
};
init: string[];
auth: {
type: AuthType;
keyEnv?: string;
keyFile?: string;
};
permissions?: PermissionsPreset;
plugins: string[];
state: StateMode;
ssh: {
agentForward: boolean;
mountSshDir: boolean;
};
isolation: boolean;
network: {
policy: NetworkPolicy;
allow: string[];
};
ports: PortsConfig;
services: Record<string, ServiceConfig>;
env: string[];
allowProjectHostMounts: boolean;
allowProjectEnvForward: string[];
allowProjectInit: boolean;
allowProjectServices: boolean;
}
export interface ProjectConfig {
profile?: string;
merge?: MergeStrategy;
claudeArgs?: string[];
init?: string[];
config?: {
claudeMd?: ClaudeMdMerge;
};
network?: Partial<ProfileConfig['network']>;
ports?: Partial<PortsConfig>;
services?: Record<string, ServiceConfig>;
env?: string[];
}
// Fully resolved config after all layers merged
export interface ResolvedConfig {
profileName: string;
image: string;
dockerfile?: string;
auth: ProfileConfig['auth'];
autoDetectMcp: boolean;
state: StateMode;
ssh: ProfileConfig['ssh'];
network: ProfileConfig['network'];
ports: PortMapping[];
services: Record<string, ServiceConfig>;
env: Record<string, string>;
init: string[];
plugins: string[];
mergedConfigDir: string;
claudeArgs: string[];
}

ContainerSpec is consumed by src/container/runner.ts and by buildContainerSpec callers in src/cli/commands/.

Four mount points feed ~/.claude/ inside the container. docker/entrypoint.sh assembles the final directory at startup:

Host mounts Inside container ~/.claude/ result
───────────────── ───────────────── ─────────────────────
${TMPDIR}/ccpod-u<uid>/ccpod-<hash>/ ──ro──► /ccpod/config/ ──► settings.json (copied)
settings.json CLAUDE.md (copied)
CLAUDE.md skills/ (copied)
hooks/ hooks/ (copied)
skills/ extensions/ (copied)
extensions/ … (copied)
~/.ccpod/creds/<p>/ ──rw──► /ccpod/credentials/ ──► *.json auth files
(overlays config)
ccpod-plugins-<p> (volume) ► /ccpod/plugins/ ──► plugins/ ← symlink
~/.ccpod/state/<p>/ (bind rw)► /ccpod/state/ ──► projects/ ← symlink
todos/ ← symlink
statsig/ ← symlink
$PWD ──rw──► /workspace/

docker/entrypoint.sh (full source: docker/entrypoint.sh) runs as root for setup, then drops to the node user with gosu. In order:

  1. Root PATH and gosu. Root-side commands run with a fixed, root-owned PATH; gosu is resolved once under it. The image/profile PATH (USER_PATH) is only given to the node user, so nothing node can write is ever executed as root.
  2. Host uid remap (Linux). If CCPOD_HOST_UID/CCPOD_HOST_GID are set (ccpod sets them on native Linux), node is remapped to the host user’s ids so bind-mounted dirs keep your ownership.
  3. Seed config. /ccpod/config (read-only) is copied into ~/.claude.
  4. Restore auth files (.credentials.json, .claude.json) from /ccpod/credentials — skipped in proxy mode.
  5. Plugins and state. ~/.claude/plugins and projects/todos/statsig become symlinks into /ccpod/plugins and /ccpod/state.
  6. Ownership. chown -R node:node on the Claude dir, $HOME, /ccpod/plugins and /ccpod/state — never /ccpod/credentials, which only root touches.
  7. Init commands. If /ccpod/config/post-init.sh exists (generated only from trust-gated init: commands) it runs as node in /workspace.
  8. Delta plugin install from CCPOD_PLUGINS_TO_INSTALL.
  9. Network restriction (CCPOD_NETWORK_POLICY=restricted, needs NET_ADMIN, applied after steps 6–7). Fails closed: loopback/established/default-deny rules for IPv4 and (when the kernel has IPv6) ip6tables; DNS only to the /etc/resolv.conf nameservers; declared hosts resolved to IPs; in proxy mode, only the auth proxy’s single port on the host gateway (parsed from ANTHROPIC_BASE_URL).
  10. Launch. Shell mode (CCPOD_SHELL_MODE=1) execs directly so bash gets TTY job control. Normal mode runs the command as a background job with signal forwarding, then copies the auth files back to /ccpod/credentials before exiting with the command’s status.

Credential persistence: two auth files survive container removal via the bind-mounted credentials dir:

FileLocation inside containerContains
.credentials.json$CLAUDE_CONFIG_DIR/.credentials.jsonOAuth access/refresh tokens
.claude.json$HOME/.claude.json (ignores CLAUDE_CONFIG_DIR)Account metadata, migration flags

Proxy auth mode (auth.type: proxy): when a profile uses proxy auth, the credential bind mount and copy-in/copy-out are skipped entirely. Instead, ccpod run starts a local HTTP proxy (src/auth/proxy.ts) before the container. The container receives ANTHROPIC_BASE_URL (pointing at the proxy via host.docker.internal) and a format-valid sentinel ANTHROPIC_API_KEY (sk-ant-api03-ccpod-proxy-...). Claude runs in API-key mode — no .credentials.json, no refresh token, no OAuth refresh flow inside the container. The proxy strips the sentinel x-api-key header, adds Authorization: Bearer <real-oauth-access-token>, and forwards to api.anthropic.com. The proxy holds the only OAuth session (read from the host Keychain or ~/.claude/.credentials.json), refreshes the access token with a single-flight lock (proactively before expiry and on-demand on 401), and writes refreshed tokens back to the host store. This eliminates the refresh-token rotation race that occurs when multiple containers or a container + native claude share the same OAuth login.

load_profile_config(name)
→ detect source (local | git)
→ git: sync_if_needed(strategy) → read files
→ local: read files directly
→ parse profile.yml (Zod validation; a leading `~` in config.path is expanded to $HOME)
load_project_config($PWD)
→ find .ccpod.yml walking up from $PWD
→ parse (Zod validation)
→ determine merge strategy (default: deep)
merge(profile_assets, project_overrides, strategy):
settings.json → deepmerge(profile, project) // project wins on conflict
CLAUDE.md → claudeMdMerge(profile, project, mode) // append or override
skills/ → union(profile_skills, project_skills) // skip symlinks
enabledPlugins → union(...) // or replace if strategy=override
hooks/ → mergeArraysByEventType(profile, project)
marketplaces → { ...profile_markets, ...project_markets }
write_merged_config(result) → ${TMPDIR}/ccpod-u<uid>/ccpod-<sha256(content)>/
// deterministic path: same content = same dir = skip re-write

syncGitConfig verifies every existing cached config directory before reuse, even when the sync policy is daily or pin: the path cannot be a symlink, must resolve to the Git worktree root, must have the configured origin (a trailing / and scheme/host casing are treated as spelling; a .git suffix, scp-style, and ssh:// forms are not, since they can name different repositories), and must have no tracked modifications. Untracked files are ignored when reusing a checkout, but a reset is refused when an untracked file, directory, or ignored path would collide with the incoming revision. It verifies again after fetch and immediately before reset --hard FETCH_HEAD. Unsafe checkouts fail closed without reset or use. A failed remote fetch may use the previously verified clean checkout; partial initial clones are made in a temporary directory and renamed into place.

src/auth/resolver.ts:resolveEnvForwarding collapses profile.env, projectConfig.env, and CLI --env overrides into a single Record<string, string>. The runtime transports that map through a private temporary env-file and removes it after the Docker command exits; values carrying line breaks use a bare -e KEY with the value in the Docker client’s own environment instead, because the env-file format is line-based. Each entry has one of three forms:

  • KEY — forward process.env.KEY (entry skipped if unset on host)
  • KEY=value — literal value
  • KEY=...${VAR}... / KEY=...${VAR:-default}... — interpolate host vars into the value

Interpolation is governed by INTERPOLATION_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g. Missing host vars without :-default resolve to empty string and warn once per unique name. Interpolation runs on env values only — other config string fields are taken verbatim by design (limits attack surface from project-controlled .ccpod.yml).

Project entries cannot interpolate. Because a repo’s .ccpod.yml is untrusted input, ${VAR} in a project-sourced entry throws an error rather than reading from process.env. Profile- and CLI-sourced entries retain full interpolation. KEY=literal works everywhere. Bare KEY forwarding works in profile and CLI entries; in project entries it is ignored with a warning unless the profile lists the name in allowProjectEnvForward (forwarding a host value by name is the same exfiltration path as ${VAR}).

Project entries cannot set redirect/injection/TLS keys. Project-sourced entries whose key (matched case-insensitively) is on PROJECT_ENV_DENYLIST are ignored with a warning. The list groups into: Anthropic credential/endpoint (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL / ANTHROPIC_BEDROCK_BASE_URL / ANTHROPIC_VERTEX_BASE_URL), proxy (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY), code injection (NODE_OPTIONS / BUN_OPTIONS), binary/library hijack (PATH, HOME, SHELL, BASH_ENV, ENV, IFS, CLAUDE_CONFIG_DIR, and every LD_* / DYLD_* key), and TLS trust (NODE_EXTRA_CA_CERTS, NODE_TLS_REJECT_UNAUTHORIZED, SSL_CERT_FILE, SSL_CERT_DIR, CURL_CA_BUNDLE, REQUESTS_CA_BUNDLE). Otherwise a repo could redirect API traffic to exfiltrate the profile’s resolved credential, inject code into the credential-bearing Node process, or disable TLS verification. Profile and CLI entries are trusted and may set them.

Project entries cannot set CCPOD_* or DOCKER_* keys. Project-sourced entries whose key starts (case-insensitively) with CCPOD_ or DOCKER_ are ignored with a warning. CCPOD_* could override ccpod’s own control vars (e.g. CCPOD_NETWORK_POLICY=full to bypass the profile’s restricted network), and DOCKER_* (e.g. DOCKER_HOST=tcp://attacker:2375) could redirect the docker CLI itself to an attacker-controlled daemon. builder.ts additionally strips CCPOD_* and DOCKER_* from secretEnv as defense-in-depth, so even a profile-sourced value cannot reach the container as a -e flag that would override ccpod’s constructed control vars.

Source precedence: profile → project → CLI override (later wins).

A repo’s .ccpod.yml ships with the codebase being run inside the sandbox, so it is treated as untrusted by default. The config walk (findProjectConfig in src/config/loader.ts) searches upward from the current directory but stops at the user’s home directory — a .ccpod.yml found above $HOME (e.g. at / on macOS) is not loaded, preventing a stray parent config from overriding profile settings for every child project. mergeConfigs enforces:

  • services from project are ignored entirely unless the profile sets allowProjectServices: true. A cloned repo could otherwise start arbitrary container images on the same bridge network as the credential-bearing main container. When opted in, the volume and port sanitization below still applies (unless allowProjectHostMounts is also set).
  • services[].volumes from project must be named volumes (<name>:<path>[:opts]). Host-path mounts (/foo, ./foo, ~/foo) are rejected.
  • services[].ports from project may bind only to 127.0.0.1 / localhost / ::1. Two-part host:container entries are auto-rewritten to 127.0.0.1:host:container. Bracketed IPv6 is parsed explicitly so [::1]:host:container loopback is accepted and every ::-expanding wildcard ([::], [0::], [::0:0], [0:0:0:0:0:0:0:0], …) is rejected with an “all IPv6 interfaces” error.
  • top-level ports.list (main container) and auto-detected .mcp.json ports from project are published on 127.0.0.1 only. parsePorts tags project-sourced mappings with hostIp: '127.0.0.1', which the runner renders as -p 127.0.0.1:<host>:<container>; profile-sourced ports carry no hostIp and keep Docker’s default 0.0.0.0 bind.
  • env from project may not use ${VAR} interpolation (see above) or set a PROJECT_ENV_DENYLIST key (see above). CCPOD_* and DOCKER_* prefix keys are also blocked (see above).
  • network: (policy / allow) is profile-owned. Project network keys are ignored with a warning regardless of merge strategy, so a repo cannot downgrade a restricted profile to full or extend its allow-list.
  • init: from project is dropped (with a one-line console.warn).
  • Project .claude/, CLAUDE.md and .claude/settings.json are ignored (with a warning) when they are symlinks, and post-init.sh is never copied from any config directory — it is generated only from trust-gated init commands.
  • profile: in .ccpod.yml needs one-time interactive approval per (project, profile), remembered in ~/.ccpod/trusted-projects.json (0600). Non-interactive runs fail unless already approved; --profile skips the prompt.
  • A profile’s relative image.dockerfile resolves against the profile directory, never the project.

To opt out, the profile may set allowProjectHostMounts: true (for sidecar volumes/ports), allowProjectInit: true (for init commands), allowProjectServices: true (for project-declared sidecar services), or list host variable names in allowProjectEnvForward: [NAME, ...] (bare NAME forwarding from project env). The booleans default to false; the list defaults to empty. There is no opt-out for the network or main-container port controls — they are always profile-owned.

ccpod update requires each release to publish a SHASUMS256.txt asset alongside the binaries. The updater fetches SHASUMS256.txt and the platform asset in parallel, then streams the response body through createHash('sha256') into a write pipeline (node:stream/promises#pipeline). The hash is computed as bytes arrive, so the 50–80 MB binary is never buffered twice in memory; it is compared to the entry for the platform asset before the temp file is moved into place. A missing SHASUMS256.txt, a missing entry, or a mismatch all refuse the install with a clear error and leave nothing on disk.

The updater stages under the destination directory’s parent, streams and hashes the download, applies executable permissions, and atomically renames the verified file into place; any failure removes the staging directory without replacing the existing binary. The install.sh bootstrap performs the same verification: after downloading the binary it fetches SHASUMS256.txt, computes the digest with sha256sum (or shasum -a 256), and aborts on mismatch. It only warns-and-proceeds when the release definitively has no checksum asset (HTTP 404 — a release predating it). A mismatch, any other download error, a missing entry, or a missing sha256 tool aborts the install; set CCPOD_INSTALL_SKIP_VERIFY=1 to override the latter group explicitly.

Releases are Changeset-driven and PR-gated. Merging PRs with .changeset/*.md files to main makes release.yml open or update a chore: version packages PR (version bump, CHANGELOG.md, lockfile). Merging that PR is the approval: the workflow re-runs bun run verify, cross-compiles the four platform binaries, writes SHASUMS256.txt, signs a build-provenance attestation for each binary (gh attestation verify <binary> --repo yorch/ccpod), creates the vX.Y.Z tag and GitHub release on the exact commit, then calls docker.yml to push the versioned GHCR image (a GITHUB_TOKEN-created tag would not trigger a tag-push workflow). The publish step only runs for commits from a merged changeset-release/* PR and refuses to replace the assets of an existing release. Nobody bumps package.json or pushes tags by hand.

Over time, stopped containers, orphaned networks, and unreferenced plugin volumes accumulate. ccpod prune cleans them up:

  • Stopped containers — any ccpod-labeled container that is not running, paused, or restarting is removed. Use --profile to restrict to one profile.
  • Orphaned networks — ccpod-net-* networks with no attached endpoints are removed.
  • Unreferenced plugin volumes — ccpod-plugins-<profile> volumes that no container references and whose profile no longer exists on disk are removed. With --profile, the profile-exists check is skipped (the caller explicitly wants that profile’s volume). Volume removal prompts for confirmation unless --force is given.
  • Orphaned per-project state dirs — ~/.ccpod/state/<profile>/<projectHash>/ directories with no remaining ccpod containers referencing that project hash are removed. Only applies to stateIsolation: per-project profiles. State dir removal prompts for confirmation unless --force is given.

--dry-run lists what would be removed without executing any docker rm/network rm/volume rm/rm. The --profile flag is validated with PROFILE_NAME_REGEX before any Docker command is constructed.

By default, persistent state is shared across all projects using the same profile (stateIsolation: per-profile). The state directory is ~/.ccpod/state/<profile>/, containing Claude’s projects/, todos/, and statsig/ directories.

When stateIsolation: per-project is set, each project gets its own state directory at ~/.ccpod/state/<profile>/<projectHash>/, where <projectHash> is the first 16 hex chars of SHA-256 over the canonical project path (same hash used for container names and network names). This prevents cross-project state leakage — conversation history, todos, and project metadata from one project are not visible to another project using the same profile.

ccpod state clear clears the current project’s state by default; --all clears all state for the profile. ccpod prune cleans orphaned per-project state dirs — those whose recorded project path (.ccpod-project marker) no longer exists; dirs without a marker are kept.

Every CLI command that accepts --profile validates the name with validateProfileArg() (src/cli/validate.ts) before passing it to profileExists, getProfileDir, getStateDir, or any Docker filter. This prevents path traversal (../etc) and shell metacharacter injection through the profile name. The shared setup helper setupContainer also validates, covering ccpod run and ccpod shell. The validation uses the same PROFILE_NAME_REGEX (/^[a-zA-Z0-9_-]{1,64}$/) as the Zod schema in schema.ts.

setupContainer (src/cli/commands/_setup.ts) throws Error on failure (missing profile, missing auth, missing OAuth credentials) instead of calling process.exit. The calling commands (run, shell) catch the error, print it, and exit. This makes the setup pipeline testable with await expect(...).rejects.toThrow(...). The global unhandledRejection/uncaughtException handler in cli/index.ts remains as a last-resort backstop.

ccpod run [-- claude-args]
│
├─ 1. Detect container runtime (first matching socket wins)
│ try: OrbStack (~/.orbstack/run/docker.sock)
│ Docker ($DOCKER_SOCKET_PATH or /var/run/docker.sock, ~/.docker/run/docker.sock)
│ Colima (~/.colima/default/docker.sock, ~/.colima/docker.sock)
│ Podman ($XDG_RUNTIME_DIR/podman/podman.sock)
│ error if none available
│
├─ 2. Resolve profile name
│ --profile flag > .ccpod.yml profile: field > "default"
│ if profile missing → trigger ccpod init, exit
│
├─ 3. Load + merge config
│ load profile → sync config source if needed
│ load .ccpod.yml (optional) → merge layers
│ apply --no-state (forces state: ephemeral for this run)
│ → ResolvedConfig
│
├─ 4. Ensure image
│ if dockerfile: {{profile_dir}} placeholder expanded to ~/.ccpod/profiles/<profile>/
│ tag = ccpod-local-<profile>-<sha256(dockerfile)>
│ context = dirname(resolved dockerfile); relative paths anchor at the profile dir
│ build if tag absent (or --rebuild)
│ else: check locally; pull if absent
│
├─ 5. Ensure volumes
│ credentials dir: mkdir -p ~/.ccpod/credentials/<profile>/
│ plugins volume: docker volume create ccpod-plugins-<profile> (idempotent)
│ state dir: mkdir -p ~/.ccpod/state/<profile>/ (if persistent)
│ OR: --tmpfs /ccpod/state (if ephemeral)
│
├─ 6. Plugin install prep
│ set CCPOD_PLUGINS_TO_INSTALL=<comma-list>
│ entrypoint does delta-install (skips dirs that already exist)
│
├─ 7. Parse .mcp.json (if present and ports.autoDetectMcp: true)
│ lstatSync rejects symlinks (untrusted project could point at host files)
│ extract HTTP/SSE entries → additional port mappings
│
├─ 8. Start sidecars (if services: declared)
│ validate dependency graph before Docker changes; stable topological order
│ create network: ccpod-net-<sha256($PWD)>
│ docker run -d --network <network> per service
│ optional healthcheck readiness with bounded inspections and timeout
│ rollback only IDs created by this invocation on failure
│ labels: ccpod.profile, ccpod.project, ccpod.type=<service-name>
│
├─ 9. Build ContainerSpec
│ image, workdir, env, mounts, network, ports, tty, labels
│
├─ 10. Start Claude container
│ entrypoint assembles ~/.claude/
│ plugin delta-install runs
│ exec claude [args]
│
└─ 11. Attach
interactive → raw TTY stdin/stdout/stderr
headless → pipe stdout/stderr, capture exit code

Config merging (merger.ts, schema.ts) are pure functions — fully unit-testable without Docker.

Container logic (builder.ts) is tested with a mock docker subprocess via mock.module().

Integration tests require a real Docker socket and a minimal test image.

tests/
├── unit/ # 24 unit-test files; run without Docker
│ ├── auth/resolver.test.ts
│ ├── cli/config/{get,set}.test.ts
│ ├── config/{loader,merger,permissions,schema,writer}.test.ts
│ ├── container/{builder,runner}.test.ts
│ ├── global/config.test.ts
│ ├── image/{hash,manager}.test.ts
│ ├── init/wizard.test.ts
│ ├── mcp/parser.test.ts
│ ├── profile/{exporter,git-sync,installer,installer.git,lock,manager}.test.ts
│ ├── runtime/detector.test.ts
│ └── update/{checker,updater}.test.ts
├── integration/
│ └── container/docker.test.ts # requires a real Docker socket

src/runtime/detector.ts tries these sockets in order until one is reachable. OrbStack is preferred over Docker because OrbStack also installs the standard /var/run/docker.sock symlink, so this order ensures the native OrbStack socket is chosen when both exist:

const RUNTIME_CANDIDATES = [
{
name: 'orbstack',
sockets: [
`${HOME}/.orbstack/run/docker.sock`,
],
},
{
name: 'docker',
sockets: [
// Honors $DOCKER_SOCKET_PATH first, falling back to the default path
DOCKER_SOCKET_PATH ?? '/var/run/docker.sock',
`${HOME}/.docker/run/docker.sock`,
],
},
{
name: 'colima',
sockets: [
`${HOME}/.colima/default/docker.sock`,
`${HOME}/.colima/docker.sock`,
],
},
{
name: 'podman',
sockets: [
`${XDG_RUNTIME_DIR}/podman/podman.sock`,
`${HOME}/.local/share/containers/podman/machine/podman.sock`,
],
},
];

Override the Docker socket path entirely by setting DOCKER_SOCKET_PATH in your environment (useful for non-standard Docker installs and tests).

Once a runtime is detected, src/runtime/docker.ts selects the CLI binary: podman when the runtime name is podman, docker for everything else. No podman-docker shim required.

The ghcr.io/yorch/ccpod image is published by .github/workflows/docker.yml:

Git eventTags pushed
Push to main:main, :latest
Push tag v1.2.3:1.2.3, :1.2

Pin to a specific release in your profile with image.use: ghcr.io/yorch/ccpod:1.2.3.