Skip to content

Latest commit

 

History

History
516 lines (470 loc) · 24.5 KB

File metadata and controls

516 lines (470 loc) · 24.5 KB

Baseline Bake Examples

CodeyBox:MultipassExtraRuncmd is operator-supplied shell that bakes tools into the sandbox image before work starts. Install the tools your projects need; CodeyBox does not assume one privileged language stack.

The examples below use Multipass's historical top-level keys. Incus exposes the same explicit bake inputs under its own provider section:

Bake input Multipass key Incus key
First-boot commands CodeyBox:MultipassExtraRuncmd CodeyBox:Incus:ExtraRuncmd
Package-cache files or directories CodeyBox:MultipassPackageCacheSeeds CodeyBox:Incus:PackageCacheSeeds
Host-staged executables CodeyBox:MultipassExecutableProvisions CodeyBox:Incus:ExecutableProvisions

The providers never inherit or fall back to each other's bake configuration. When switching backends, copy the intended entries deliberately and review guest paths and size limits. Both baseline paths derive their final credential-free CLI verification commands from configured agents and the shared in-VM probe catalog; a missing required CLI fails the bake before the baseline becomes cloneable. Incus applies its explicit cache/executable inputs to both a baked baseline and the full-launch path used when baseline images are disabled, so the latter does not silently lose required tools.

Ordinary unit tests do not start Incus VMs. The destructive real-host requires_incus scenario remains opt-in and skips unless CODEYBOX_RUN_INCUS_INTEGRATION=1 is set explicitly; configuration examples in this document do not enable it.

If a language auditor is enabled but its tool is missing, the auditor usually emits an Info finding and skips instead of blocking the work item. The built-in security tool auditors emit Warning on missing tools so lost secret/SAST coverage is visible without hard-blocking audits by default. Install the missing tool and re-run audit to get enforcement.

Incus Package-Cache Seed

An Incus seed may be one bounded file or directory. VmDestPath is a normalized absolute non-root canonical guest directory: directory contents land beneath it, while a file lands at VmDestPath/<source basename>. The provider stages the source privately, assigns the installed tree to the configured guest UID/GID, and applies the same input to a baseline bake or a full launch:

{
  "CodeyBox": {
    "Incus": {
      "PackageCacheSeeds": [
        {
          "HostSourcePath": "/srv/codeybox/package-cache/nuget",
          "VmDestPath": "/var/cache/codeybox/nuget",
          "MaxSizeMB": 2048
        }
      ],
      "MaxPackageCacheSeedBytes": 4294967296,
      "MaxAggregatePackageCacheSeedBytes": 8589934592,
      "MaxPackageCacheSeedEntries": 100000
    }
  }
}

MaxSizeMB is measured in MiB (1,048,576 bytes) and narrows the provider-wide per-seed byte bound; it cannot enlarge it. Guest filesystem aliases and paths under /dev, /proc, /run, or /sys are rejected. Missing files, traversal beyond the entry limit, and byte-limit breaches fail provisioning instead of producing a partially seeded VM.

Seeds destined for the guest NuGet home ({GuestHome}/.nuget/..., for example /home/ubuntu/.nuget/packages) are served as a read-only shared fallback folder instead of being copied into the per-sandbox writable package root, so a multi-gigabyte package cache is transferred once per host rather than once per sandbox. Full-launch VMs mount the host directory read-only and advertise it through NUGET_FALLBACK_PACKAGES; baselines bake the content into the image so clones inherit it, and seeds that cannot be shared (file sources, sources outside the allowed host mount roots) keep the copy path — served from a once-per-host archive cache under the staging directory. Restore writes only packages the cache does not carry into the writable root, and the required build gate reports the shared-versus-fetched split after a passing build. Tune the folder with CodeyBox:Incus:NuGetFallbackGuestPath, or set CodeyBox:Incus:ShareNuGetPackageSeedsAsFallback to false to keep the legacy per-VM copy for every seed.

Polyglot Sandbox

{
  "CodeyBox": {
    "MultipassExtraRuncmd": [
      "apt-get update",
      "apt-get install -y curl ca-certificates git python3 python3-pip python3-venv nodejs npm golang rustup",
      "curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh",
      "bash /tmp/dotnet-install.sh --version 10.0.301 --install-dir /usr/share/dotnet",
      "ln -sf /usr/share/dotnet/dotnet /usr/local/bin/dotnet",
      "dotnet --version | grep -Fx 10.0.301",
      "python3 -m pip install --break-system-packages ruff mypy pytest pip-audit",
      "npm install -g prettier eslint",
      "go install golang.org/x/vuln/cmd/govulncheck@latest",
      "cargo install cargo-audit",
      "rustup component add rustfmt clippy"
    ]
  }
}

C# Minimal

{
  "CodeyBox": {
    "MultipassExtraRuncmd": [
      "apt-get update",
      "apt-get install -y curl ca-certificates",
      "curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh",
      "bash /tmp/dotnet-install.sh --version 10.0.301 --install-dir /usr/share/dotnet",
      "ln -sf /usr/share/dotnet/dotnet /usr/local/bin/dotnet",
      "dotnet --version | grep -Fx 10.0.301"
    ]
  }
}

Pinning .NET SDKs

Do not bake C# baselines from a floating .NET SDK channel such as dotnet-install.sh --channel 10.0 or an unpinned dotnet-sdk-10.0 package. dotnet format behavior can change across SDK feature bands, and audit-tool sandboxes must use the same formatter version as the mechanical dotnet-format fixer. Pin an exact SDK version in MultipassExtraRuncmd, verify it with dotnet --version, and deliberately bump the version in both the baseline config and any developer/CI images when you want formatter rules to change.

Python Minimal

{
  "CodeyBox": {
    "MultipassExtraRuncmd": [
      "apt-get update",
      "apt-get install -y python3 python3-pip python3-venv",
      "python3 -m pip install --break-system-packages ruff mypy pytest pip-audit"
    ]
  }
}

Node Minimal

{
  "CodeyBox": {
    "MultipassExtraRuncmd": [
      "apt-get update",
      "apt-get install -y nodejs npm",
      "npm install -g prettier eslint"
    ]
  }
}

Go Minimal

{
  "CodeyBox": {
    "MultipassExtraRuncmd": [
      "apt-get update",
      "apt-get install -y golang",
      "go install golang.org/x/vuln/cmd/govulncheck@latest"
    ]
  }
}

Rust Minimal

{
  "CodeyBox": {
    "MultipassExtraRuncmd": [
      "apt-get update",
      "apt-get install -y rustup",
      "rustup default stable",
      "rustup component add rustfmt clippy",
      "cargo install cargo-audit"
    ]
  }
}

Agent CLIs

Every AgentKind registered in an agent class needs its CLI baked into the sandbox image. The orchestrator does not install agent binaries; missing binaries fail the baseline bake during the post-install in-VM verification (--version) checks for configured agents. Bake them at baseline time so the first dispatch can actually run.

{
  "CodeyBox": {
    "MultipassExtraRuncmd": [
      "apt-get update",
      "apt-get install -y curl ca-certificates nodejs npm python3",
      "npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli",
      "npm install -g @qwen-code/qwen-code@0.24.0",
      "npm install -g --ignore-scripts autohand-cli@0.9.7",
      "npm install -g cline@3.0.62",
      "npm install -g @kilocode/cli@7.7.2",
      "npm install -g command-code@1.54.2",
      "npm install -g @charmland/crush@0.95.0",
      "curl -fsSL https://omp.sh/install | sh -s -- --binary --ref v18.2.2",
      "npm install -g @continuedev/cli@1.5.47",
      "mkdir -p /home/ubuntu/.continue && touch /home/ubuntu/.continue/.onboarding_complete && chown -R ubuntu:ubuntu /home/ubuntu/.continue",
      "curl -fsSL https://cursor.com/install | bash",
      "curl -fsSL https://aider.chat/install.sh | bash",
      "UV_TOOL_BIN_DIR=/usr/local/bin uv tool install --python python3.12 aider-chat==0.86.2",
      "curl -fsSL https://app.primeintellect.ai/prime-agent/install.sh | sh"
    ],
    "MultipassExecutableProvisions": [
      {
        "HostSourcePath": "/home/<operator>/.codeybox/agy-seed/agy",
        "VmDestPath": "/home/ubuntu/.local/bin/agy",
        "VmSymlinks": ["/usr/local/bin/agy"],
        "Label": "antigravity"
      }
    ]
  }
}

The equivalent Incus executable provisioning is provider-local:

{
  "CodeyBox": {
    "SandboxProvider": "incus",
    "Incus": {
      "UseBaselineImages": true,
      "ExtraRuncmd": [
        "apt-get update",
        "apt-get install -y curl ca-certificates nodejs npm python3",
        "npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli",
        "npm install -g @qwen-code/qwen-code@0.24.0",
        "npm install -g --ignore-scripts autohand-cli@0.9.7",
        "npm install -g cline@3.0.62",
        "npm install -g @kilocode/cli@7.7.2",
        "npm install -g command-code@1.54.2",
        "npm install -g @charmland/crush@0.95.0",
        "curl -fsSL https://omp.sh/install | sh -s -- --binary --ref v18.2.2",
        "npm install -g @continuedev/cli@1.5.47",
        "mkdir -p /home/ubuntu/.continue && touch /home/ubuntu/.continue/.onboarding_complete && chown -R ubuntu:ubuntu /home/ubuntu/.continue",
        "curl -fsSL https://cursor.com/install | bash",
        "curl -fsSL https://aider.chat/install.sh | bash",
        "UV_TOOL_BIN_DIR=/usr/local/bin uv tool install --python python3.12 aider-chat==0.86.2",
        "curl -fsSL https://app.primeintellect.ai/prime-agent/install.sh | sh"
      ],
      "ExecutableProvisions": [
        {
          "HostSourcePath": "/home/<operator>/.codeybox/agy-seed/agy",
          "VmDestPath": "/home/ubuntu/.local/bin/agy",
          "VmSymlinks": ["/usr/local/bin/agy"],
          "Label": "antigravity"
        }
      ]
    }
  }
}

Pick the subset that matches the agents you have registered. For Gemini, reasoning level is encoded in the model id (e.g. gemini-3-flash-preview), not a CLI flag — there is no --thinking flag to pin a version against. The aider lines install via uv (the aider.chat/install.sh script installs uv itself, then uv tool install aider-chat); the trailing line re-pins to the exact verified version (aider-chat==0.86.2) so the baked binary is deterministic even though the vendor script tracks latest — bump the pin when re-verifying against a newer aider. UV_TOOL_BIN_DIR pins the aider binary onto /usr/local/bin because bake runcmds run as root and uv tool install would otherwise land it in root's home directory, off the sandbox user's PATH. For Goose, add the shell-installer line to the selected provider's runcmd, pinning the installer script by tag with a SHA256 check before executing (verified GOOSE_VERSION=v1.50.1; the installer honors GOOSE_VERSION to pin the binary download too, with no stable fallback when it is set):

{
  "CodeyBox": {
    "MultipassExtraRuncmd": [
      "set -eux\nGOOSE_VERSION=v1.50.1\nGOOSE_INSTALLER=/tmp/goose-download_cli.sh\ncurl -fsSL -o \"$GOOSE_INSTALLER\" \"https://github.com/aaif-goose/goose/releases/download/${GOOSE_VERSION}/download_cli.sh\"\nprintf '%s  %s\\n' \"ab5ae40513348ec4e6047cc7338040aab2df5246800c111d22065766ba6013f0\" \"$GOOSE_INSTALLER\" | sha256sum -c -\nGOOSE_VERSION=\"${GOOSE_VERSION}\" bash \"$GOOSE_INSTALLER\"\nrm \"$GOOSE_INSTALLER\"",
      "goose --version"
    ]
  }
}

Goose runs one-shot headless (goose run -i - --output-format stream-json --no-session); unlike the interactive TUI some third-party harnesses drive, no PTY or keep-alive flag is needed, so nothing else must be baked for it. Autohand runs one-shot headless (autohand -p with the prompt on stdin, --output-format stream-json --bare --offline --yes --unrestricted); --bare is what keeps the run from blocking on the interactive Autohand-account device login, and no vendor account, hook toolchain, LSP server, or AGENTS.md discovery is needed in the image — bare mode skips all of them. The post-install in-VM verification runs autohand --version plus a --help assertion for --output-format stream-json (the runner's only transport; a build that dropped it is benched at bake time, not first dispatch). Provider credentials are NOT baked: the runner seeds the guest ~/.autohand/config.json from the CODEYBOX_AUTOHAND_API_KEY bundle at dispatch time.

For Vibe, add the installer line to the selected provider's runcmd and pin the version for deterministic bakes (verified mistral-vibe==2.25.4 via uv; bump the pin when re-verifying against a newer vibe — the runner asserts --output streaming support at smoke time, so a CLI that drops the flag benches loudly instead of dispatching into plaintext):

{
  "CodeyBox": {
    "MultipassExtraRuncmd": [
      "set -eux\ncurl -LsSf https://mistral.ai/vibe/install.sh -o /tmp/vibe-install.sh\nuv tool install --python python3.12 mistral-vibe==2.25.4\nvibe --version",
      "mkdir -p ~/.vibe && cat > ~/.vibe/config.toml <<'EOF'\nactive_model = \"nemotron-free\"\n\n[[providers]]\nname = \"openrouter\"\napi_base = \"https://openrouter.ai/api/v1\"\napi_key_env_var = \"OPENROUTER_API_KEY\"\napi_style = \"openai\"\nbackend = \"generic\"\n\n[[models]]\nname = \"nvidia/nemotron-3.5-lightning:free\"\nprovider = \"openrouter\"\nalias = \"nemotron-free\"\nEOF"
    ]
  }
}

Vibe needs no vendor account: the guest ~/.vibe/config.toml above points at OpenRouter with the key arriving per-dispatch as OPENROUTER_API_KEY (the shipped credential mapping), and the model alias (nemotron-free) must match the VIBE_ACTIVE_MODEL value the runner sends (CodeyBox:AgentDefaults[vibe]). A baked config whose alias is missing fails loudly at dispatch (exit 1, Active model … not found), never silently. UV_TOOL_BIN_DIR advice from the aider entry applies equally: bake runcmds run as root, so pin the vibe binary onto the sandbox user's PATH (e.g. /usr/local/bin) or the in-VM smoke probe's --version step fails the bake.

Cline runs one-shot headless (cline --json -P <provider> [-m <model>] --auto-approve true "<prompt>" with a positional prompt — the only prompt transport this CLI version accepts; piped stdin was probed and rejected). The post-install in-VM verification runs cline --version plus a --help assertion for --json (the runner's only transport; a build that dropped it is benched at bake time, not first dispatch). The provider key is NOT baked: it arrives through the CODEYBOX_CLINE_API_KEY → OPENROUTER_API_KEY credential mapping at dispatch time (operators may additionally pre-seed ~/.cline/data/settings/providers.json during provisioning, but the env key alone suffices — cline auth needs a TTY so it never runs at dispatch).

Kilo runs one-shot headless (kilo run --auto --format json with the prompt on stdin, -m openai-compatible/<model-id>): --auto is what keeps the run from auto-rejecting its own permission requests and exiting 1, and --format json is the runner's only transport. The post-install in-VM verification runs kilo --version plus a run --help assertion for --format/json and --auto (a build that dropped the transport or the autonomy flag is benched at bake time, not first dispatch). Provider credentials are NOT baked: the runner seeds the guest ~/.config/kilo/kilo.jsonc (openai-compatible provider block plus the mandatory models allowlist entry) from the CODEYBOX_KILO_API_KEY bundle at dispatch time.

OMP runs one-shot headless (omp -p --mode json --no-session with the prompt on stdin, --model <model-id>): a bare omp "prompt" is interactive, so the -p/--print flag is the one-shot contract, and --mode json is the runner's only transport. The post-install in-VM verification runs omp --version plus a --help assertion for --mode/json and -p/--print (a build that dropped the transport or the one-shot flag is benched at bake time, not first dispatch). Pin the install (--binary --ref v18.2.2 — the oh-my-pi binary, not the oh-omp fork) and re-verify the headless contract before bumping. Provider credentials are NOT baked: the provider API key arrives through the CODEYBOX_OMP_API_KEY → OPENROUTER_API_KEY credential mapping at dispatch time. Continue runs one-shot headless (cn --print --auto with the prompt on stdin — no positional prompt argument): --auto allows every tool (the sandbox VM is throwaway with no human to approve anything), and --print is the runner's only transport — --format json is deliberately NOT passed (it coerces the model's answer into JSON rather than framing the transport). The post-install in-VM verification runs cn --version plus a --help assertion for --print and --auto (a build that dropped the transport or the autonomy flag is benched at bake time, not first dispatch). The bake must also create the first-run onboarding marker for the sandbox user (the provisioning runcmd runs as root, so ~ would be the wrong home): mkdir -p /home/ubuntu/.continue && touch /home/ubuntu/.continue/.onboarding_complete && chown -R ubuntu:ubuntu /home/ubuntu/.continue. Provider credentials are NOT baked: the runner seeds the guest ~/.continue/config.yaml (single-model openrouter entry with apiBase + literal apiKey, mode 0600) from the CODEYBOX_CONTINUE_API_KEY bundle at dispatch time. Qwen runs one-shot headless (qwen --approval-mode yolo --auth-type openai --output-format stream-json -m <model-id> with the prompt on stdin — no positional prompt argument, never the deprecated -p flag): --approval-mode yolo auto-approves every tool call (the sandbox VM is the permission boundary), --auth-type openai pins env-key routing (Qwen OAuth is discontinued), and --output-format stream-json is the runner's only transport. The post-install in-VM verification runs qwen --version plus a --help assertion for --output-format/stream-json (a build that dropped the transport is benched at bake time, not first dispatch). Pin the install (@qwen-code/qwen-code@0.24.0 — needs Node.js 22+ on the image) and re-verify the headless contract before bumping. Provider credentials are NOT baked: the key, base URL, and model fallback arrive through the CODEYBOX_QWEN_* → OPENAI_* credential mappings at dispatch time. Cursor installs the binary as agent (not cursor-agent) — verify it lands on $PATH after the bake. Prime installs a self-contained prime-agent binary on PATH (no runtime needed); the installer resolves the latest stable release unless PRIME_AGENT_VERSION is set, so pin it (PRIME_AGENT_VERSION=0.9.5 curl -fsSL … | sh) for deterministic bakes and bump the pin when re-verifying against a newer prime-agent. Antigravity (agy) is provisioned via the selected provider's executable-provision list, NOT a curl … | bash runcmd entry: the upstream installer URL (https://antigravity.google/cli/install.sh) no longer returns a shell script — as of 2026-06-17 it serves the Antigravity landing HTML, which fails bash parsing and would silently slip past a … || true runcmd. Stage a vetted copy of the binary on the host (e.g. extracted from a known-good Multipass VM) and point HostSourcePath at it; the provisioner sets mode 0755 and ownership root:root deterministically via install -m 0755 -o root -g root, then drops a /usr/local/bin/agy symlink onto the non-login sandbox PATH. The baseline bake runs the registered --version checks for every configured CLI-backed agent before marking the image ready to clone, so a missing agy (or a missing host-staged file) fails the bake instead of surfacing as dispatch exit 127. The standalone Copilot CLI (binary name copilot) is operator-supplied; do not substitute gh extension install github/gh-copilot, which produces a gh copilot subcommand and will not satisfy the runner. opencode does not yet ship a runner in this repo; operators tracking the integration can pre-stage with curl -fsSL https://opencode.ai/install | bash, but the orchestrator will not dispatch to it until a runner is registered. dotnet-opencode needs a heavier baseline than most agents: the exact .NET 11 preview SDK (11.0.100-preview.7.26381.103 — the tool disables runtime roll-forward, so a newer 11.x runtime refuses to launch it), the prerelease global tool itself, and ripgrep. All verified against dotnet-opencode 0.1.0-ci.20260905083303.33955573552.1:

# .NET 11 preview SDK first (dotnet-install.sh; apt feeds do not carry previews)
dotnet-install.sh --channel 11.0 --quality preview --version 11.0.100-preview.7.26381.103 --install-dir /usr/share/dotnet
dotnet tool install --global dotnet-opencode --prerelease --version 0.1.0-ci.20260905083303.33955573552.1
export PATH="$PATH:$HOME/.dotnet/tools"
command -v rg || apt-get install -y ripgrep  # or set OPENCODE_DOTNET_RIPGREP to an absolute rg path

Without the shim on PATH every dispatch fails with exit 127; without the pinned runtime the shim fails with "You must install or update .NET"; without ripgrep run fails fast with "Ripgrep is unavailable on PATH". The in-VM smoke probe checks all three (--version, run --help | grep -q -- --format, rg presence), so a missing piece benches the member with a named cause instead of surfacing as an empty "no changes" run. Changing bake-affecting provisioning values changes that provider's content-addressed baseline identity, so the next sandbox creation bakes the corresponding new image rather than reusing another provider's baseline.

Security Tooling

The default appsettings.json bakes pinned gitleaks and semgrep versions into Multipass baselines. If you override MultipassExtraRuncmd, keep equivalent pinned install steps so security:gitleaks and security:semgrep execute instead of reporting a missing-tool Warning:

{
  "CodeyBox": {
    "MultipassExtraRuncmd": [
      "apt-get update",
      "apt-get install -y curl ca-certificates tar python3 python3-pip",
      "set -eux\nGITLEAKS_VERSION=8.29.0\ncase \"$(uname -m)\" in\n  x86_64|amd64) GITLEAKS_ARCH=x64; GITLEAKS_SHA256=39e07ad810336fd0ae80d0bd61c60d0521f628173e7583583b5df4a38738522c ;;\n  aarch64|arm64) GITLEAKS_ARCH=arm64; GITLEAKS_SHA256=4c811a7c23296c7163ebab166ec67cd8a31eb9922caf06a7cac4d6dd872e4159 ;;\n  *) echo \"unsupported gitleaks architecture: $(uname -m)\" >&2; exit 1 ;;\nesac\nGITLEAKS_TGZ=/tmp/gitleaks_${GITLEAKS_VERSION}_linux_${GITLEAKS_ARCH}.tar.gz\ncurl -fsSL -o \"$GITLEAKS_TGZ\" \"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${GITLEAKS_ARCH}.tar.gz\"\nprintf '%s  %s\\n' \"$GITLEAKS_SHA256\" \"$GITLEAKS_TGZ\" | sha256sum -c -\ntar -xzf \"$GITLEAKS_TGZ\" -C /usr/local/bin gitleaks\nchmod 0755 /usr/local/bin/gitleaks\nrm \"$GITLEAKS_TGZ\"",
      "python3 -m pip install --break-system-packages --no-cache-dir --only-binary=:all: semgrep==1.168.0",
      "gitleaks version | grep -Fx 8.29.0",
      "semgrep --version | grep -Fx 1.168.0"
    ]
  }
}

Command Code runs one-shot headless (cmd --local-only -p --output-format json --no-session --skip-onboarding --yolo with the prompt on stdin, -m openrouter/<model-id>): -p is the one-shot contract (a bare cmd "prompt" is interactive), --output-format json is the runner's only transport, --yolo is what keeps headless mode from silently changing nothing (it blocks writes/edits/shell by default), and --local-only keeps BYOK traffic provider-side with no Command Code plan required. The post-install in-VM verification runs cmd --version plus a -p --help assertion for --output-format/json and --yolo (a build that dropped the transport or the autonomy flag is benched at bake time, not first dispatch). Provider credentials are NOT baked: the provider key arrives through the CODEYBOX_CMD_API_KEY → OPENROUTER_API_KEY credential mapping at dispatch time (resolved through the seeded providers.json $OPENROUTER_API_KEY reference — never a raw key), and the runner seeds the guest ~/.commandcode/ files during provisioning (providers.json: openrouter entry plus the model metadata; auth.json: a non-credential presence placeholder the print-mode gate requires for plan-less --local-only runs — see CmdConfigBuilder). The baseline image should pre-seed the same two files (same shapes, same placeholder, no secrets) so ad-hoc runs work:

mkdir -p ~/.commandcode
cat > ~/.commandcode/providers.json << 'JSON'
{
  "provider": {
    "openrouter": {
      "name": "OpenRouter",
      "baseURL": "https://openrouter.ai/api/v1",
      "apiKey": "$OPENROUTER_API_KEY",
      "models": { "nvidia/nemotron-3.5-lightning:free": {} }
    }
  }
}
JSON
cat > ~/.commandcode/auth.json << 'JSON'
{
  "apiKey": "codeybox-local-only"
}
JSON