diff --git a/.github/workflows/build-bobshell.yml b/.github/workflows/build-bobshell.yml new file mode 100644 index 0000000..1da2272 --- /dev/null +++ b/.github/workflows/build-bobshell.yml @@ -0,0 +1,158 @@ +name: Build Bobshell Container + +on: + push: + branches: + - main + paths: + - 'infra/bobshell/**' + - '.github/workflows/build-bobshell.yml' + pull_request: + branches: + - main + paths: + - 'infra/bobshell/**' + - '.github/workflows/build-bobshell.yml' + release: + types: + - published + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE: ghcr.io/ibm/cfex-bobshell + +jobs: + build: + name: Build ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + suffix: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + suffix: arm64 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + version: latest + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=raw,value=${{ matrix.suffix }}-${{ github.sha }} + + - name: Build and push container image + id: build + uses: docker/build-push-action@v5 + with: + context: ./infra/bobshell + file: ./infra/bobshell/Containerfile + platforms: ${{ matrix.platform }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=bobshell-${{ matrix.suffix }} + cache-to: type=gha,mode=max,scope=bobshell-${{ matrix.suffix }} + provenance: false + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + echo "Digest for bobshell-${{ matrix.suffix }}: $digest" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digest-bobshell-${{ matrix.suffix }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + manifest: + name: Create Manifest + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + version: latest + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest + run: | + SHA=${{ github.sha }} + IMAGE=${{ env.IMAGE }} + + echo "Creating multiplatform manifest for bobshell..." + + IMAGES=( + "${IMAGE}:amd64-${SHA}" + "${IMAGE}:arm64-${SHA}" + ) + + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + # Release publication: tag with the release name and latest. + docker buildx imagetools create \ + --tag "${IMAGE}:${SHA}" \ + --tag "${IMAGE}:${GITHUB_REF_NAME}" \ + --tag "${IMAGE}:latest" \ + "${IMAGES[@]}" + elif [[ "${{ github.event_name }}" == "push" ]] || [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + docker buildx imagetools create \ + --tag "${IMAGE}:${SHA}" \ + --tag "${IMAGE}:latest" \ + "${IMAGES[@]}" + else + docker buildx imagetools create \ + --tag "${IMAGE}:${SHA}" \ + "${IMAGES[@]}" + fi + + echo "Manifest created successfully" + + - name: Inspect manifest + run: | + echo "Inspecting multiplatform manifest..." + docker buildx imagetools inspect "${{ env.IMAGE }}:${{ github.sha }}" diff --git a/infra/bobshell/.dockerignore b/infra/bobshell/.dockerignore new file mode 100644 index 0000000..f801658 --- /dev/null +++ b/infra/bobshell/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +.env +secrets/ +workspace/ +docker-compose.yml +README.md diff --git a/infra/bobshell/.gitignore b/infra/bobshell/.gitignore new file mode 100644 index 0000000..f9f2ecb --- /dev/null +++ b/infra/bobshell/.gitignore @@ -0,0 +1,4 @@ +# Never commit secrets or local state +secrets/ +workspace/ +.env diff --git a/infra/bobshell/Containerfile b/infra/bobshell/Containerfile new file mode 100644 index 0000000..4c6367a --- /dev/null +++ b/infra/bobshell/Containerfile @@ -0,0 +1,63 @@ +# syntax=docker/dockerfile:1 +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# bobshell - IBM Bob Shell (agentic CLI) container image +# +# Based on ubi10/nodejs-24, which provides Node.js 24 (Bob Shell requires +# Node.js >= 22.15). Bob Shell is installed with the official installer, the +# IBM license is pre-accepted, and the /workspace directory is pre-trusted so +# the CLI runs non-interactively. +# +# Build (from this directory): +# docker build -f Containerfile -t bobshell . +# +# Pin a Bob Shell version: +# docker build --build-arg BOB_VERSION=2.0.5 -t bobshell . +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +FROM registry.access.redhat.com/ubi10/nodejs-24-minimal:latest + +# Workspace Bob Shell operates in. Owned by the default user (1001), group +# root with g=u so arbitrary UIDs in gid 0 (OpenShift-style) can also use it. +USER root +RUN mkdir -p /workspace \ + && chown 1001:0 /workspace \ + && chmod g=u /workspace +COPY --chmod=0755 entrypoint.sh /usr/local/bin/bob-entrypoint +USER 1001 + +# Install Bob Shell via the official installer. The installer verifies the +# package SHA256 and runs `npm install -g`; the image's npm global prefix +# (/opt/app-root/src/.npm-global) is writable by user 1001 and on PATH. +ARG BOB_VERSION="" +RUN set -eu; \ + curl -fsSL https://bob.ibm.com/download/bobshell.sh -o /tmp/bobshell.sh; \ + if [ -n "${BOB_VERSION}" ]; then \ + bash /tmp/bobshell.sh --pm npm --version "${BOB_VERSION}"; \ + else \ + bash /tmp/bobshell.sh --pm npm; \ + fi; \ + rm -f /tmp/bobshell.sh /tmp/bobshell-*.tgz /tmp/bobshell-install.log; \ + command -v bob; \ + bob --version + +# Pre-accept the IBM license and pre-trust /workspace from the command line +# so Bob Shell never blocks on first-run prompts. State file formats match +# what Bob Shell 2.0.x itself writes: +# ~/.bob/settings/settings.json <- "licenseConsent": true (`bob --accept-license`) +# ~/.bob/trustedFolders.json <- path -> "TRUST_PARENT" (`bob chat --trust`) +# The default CMD also passes --accept-license/--trust, so a fresh volume +# mounted over $HOME (shadowing these files) still starts without prompts. +RUN mkdir -p "${HOME}/.bob/settings" \ + && printf '{\n "licenseConsent": true\n}\n' > "${HOME}/.bob/settings/settings.json" \ + && printf '{\n "/workspace": "TRUST_PARENT"\n}\n' > "${HOME}/.bob/trustedFolders.json" + +WORKDIR /workspace + +LABEL org.opencontainers.image.title="bobshell" +LABEL org.opencontainers.image.description="IBM Bob Shell agentic CLI on UBI 10 / Node.js 24" +LABEL org.opencontainers.image.source="https://github.com/IBM/contextforge-examples" +LABEL org.opencontainers.image.vendor="IBM" +LABEL org.opencontainers.image.licenses="Apache-2.0" + +ENTRYPOINT ["bob-entrypoint"] +CMD ["bob", "chat", "--accept-license", "--trust"] diff --git a/infra/bobshell/README.md b/infra/bobshell/README.md new file mode 100644 index 0000000..5995a9b --- /dev/null +++ b/infra/bobshell/README.md @@ -0,0 +1,105 @@ +# Bobshell Container + +[IBM Bob Shell](https://bob.ibm.com/docs/shell) (the `bob` agentic CLI) packaged +as a container image on `registry.access.redhat.com/ubi10/nodejs-24`. + +- Bob Shell installed via the [official installer](https://bob.ibm.com/docs/shell/getting-started/install-and-setup) + (SHA256-verified, `npm install -g`). +- IBM license pre-accepted and `/workspace` pre-trusted at image build time, so + `bob` never blocks on first-run prompts (see + [trusted folders](https://bob.ibm.com/docs/shell/security/trusted-folders)). +- Runs as the non-root default user (UID 1001). +- Defaults to an interactive `bob chat` session when the container starts. + +## Configuration + +The container accepts two configurations: + +| Configuration | Mechanism | Container location | Effect | +| ------------- | --------- | ------------------ | ------ | +| Bobshell API secret | Compose `secrets:` (or `BOB_API_KEY` env var) | `/run/secrets/bob_api_key` | Exported as `BOB_API_KEY`; used for [API key authentication](https://bob.ibm.com/docs/shell/getting-started/install-and-setup#api-key-authentication) | +| MCP configuration | Compose `configs:` | `/etc/bob/mcp.json` | Installed as `~/.bob/settings/mcp.json` (user-global [`mcpServers`](https://bob.ibm.com/docs/shell/configuration/mcp/mcp-bobshell) config) unless one already exists | + +An API key (scope **Inference**) is created in the +[Bob web portal](https://bob.ibm.com/login). + +## Quick start (Docker Compose) + +```bash +cd infra/bobshell + +# Provide the API secret and a project directory +mkdir -p secrets workspace +printf '%s' 'your-bob-api-key' > secrets/bob_api_key.txt +# Change permission on the secrets directory: +chmod 700 secrets + +# Change permissions on the secrets file: +chmod 600 secrets/bob_api_key.txt +# Edit the sample MCP configuration to taste +$EDITOR mcp.json + +# Interactive session (image is pulled or built locally) +docker compose run --rm bobshell + +# Headless task +docker compose run --rm bobshell bob run --accept-license --trust "Explain this project" + +# Build locally instead of pulling the published image +docker compose build +``` + +`docker-compose.yml` mounts `./workspace` at `/workspace` (the pre-trusted +working directory) and persists `~/.bob` (sessions, tasks, settings) in the +`bob-state` named volume. + +## Plain Docker + +```bash +docker build -f Containerfile -t bobshell . + +# Interactive +docker run --rm -it \ + -e BOB_API_KEY="your-bob-api-key" \ + -v "$PWD/workspace:/workspace" \ + bobshell + +# Headless, with MCP config mounted +docker run --rm \ + -e BOB_API_KEY="your-bob-api-key" \ + -v "$PWD/mcp.json:/etc/bob/mcp.json:ro" \ + -v "$PWD/workspace:/workspace" \ + bobshell bob run --accept-license --trust "Summarize this repo" +``` + +Any command after the image name replaces the default `bob chat` invocation; +a leading flag (e.g. `--version`) is passed to `bob` itself. + +## Version pinning + +The image installs the latest Bob Shell release by default. Pin a version at +build time: + +```bash +docker build --build-arg BOB_VERSION=2.0.5 -t bobshell . +``` + +## Publishing (CI) + +`.github/workflows/build-bobshell.yml` builds `linux/amd64` and `linux/arm64` +images and publishes a multi-arch manifest to +`ghcr.io/ibm/cfex-bobshell` on: + +- pushes to `main` touching `infra/bobshell/**` (tagged with the commit SHA and `latest`), +- **release publication** (additionally tagged with the release tag), +- manual `workflow_dispatch`. + +## Security notes + +- Never commit `secrets/` or set your API key in any tracked file; the local + `.gitignore` excludes them, and `.dockerignore` keeps them out of the build + context. +- The pre-trust entry covers `/workspace` only; Bob Shell's folder-trust + protections remain intact for everything else. +- Run the container with least privilege: it needs no host access beyond the + mounted workspace and outbound network to `bob.ibm.com`. diff --git a/infra/bobshell/docker-compose.yml b/infra/bobshell/docker-compose.yml new file mode 100644 index 0000000..ce48d0b --- /dev/null +++ b/infra/bobshell/docker-compose.yml @@ -0,0 +1,61 @@ +# Sample Docker Compose configuration for the bobshell container. +# +# The container takes two configurations, both wired up below with Compose +# mechanisms: +# +# 1. Bobshell API secret -> Compose `secrets:` (file-mounted at +# /run/secrets/bob_api_key, exported as BOB_API_KEY by the entrypoint). +# Alternative: set BOB_API_KEY in the environment instead (see below). +# +# 2. MCP configuration -> Compose `configs:` (file-mounted at +# /etc/bob/mcp.json, installed as bobshell's user-global +# ~/.bob/settings/mcp.json by the entrypoint). +# +# Prerequisites: +# mkdir -p secrets workspace +# printf '%s' 'your-bob-api-key' > secrets/bob_api_key.txt +# +# Interactive session: +# docker compose run --rm bobshell +# +# Headless task: +# docker compose run --rm bobshell bob run --accept-license --trust "Explain this project" +# +# Build from source instead of pulling: +# docker compose build + +services: + bobshell: + image: ghcr.io/ibm/cfex-bobshell:latest + build: + context: . + dockerfile: Containerfile + init: true + tty: true + stdin_open: true + working_dir: /workspace + volumes: + # Project Bob Shell works on (pre-trusted inside the container). + - ./workspace:/workspace + # Persist sessions, tasks, and settings across runs. On first use the + # image's baked-in license consent and trust entries are copied in. + - bob-state:/opt/app-root/src/.bob + configs: + - source: bob-mcp-config + target: /etc/bob/mcp.json + secrets: + - bob_api_key + # Alternative to the secrets block above: + # environment: + # BOB_API_KEY: ${BOB_API_KEY:?set BOB_API_KEY or create secrets/bob_api_key.txt} + +configs: + bob-mcp-config: + file: ./mcp.json + +secrets: + bob_api_key: + file: ./secrets/bob_api_key.txt + +volumes: + bob-state: diff --git a/infra/bobshell/entrypoint.sh b/infra/bobshell/entrypoint.sh new file mode 100755 index 0000000..e342eea --- /dev/null +++ b/infra/bobshell/entrypoint.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# Bobshell container entrypoint. +# +# Wires up the two supported configurations, both provided through Docker +# Compose mechanisms: +# secrets: /run/secrets/bob_api_key -> exported as BOB_API_KEY +# configs: /etc/bob/mcp.json -> ~/.bob/settings/mcp.json (user-global) +set -eu + +BOB_STATE_DIR="${HOME}/.bob" + +# API key: an explicit BOB_API_KEY env var wins; otherwise read the mounted +# Docker secret. Whitespace/newlines around the key are stripped. +if [ -z "${BOB_API_KEY:-}" ] && [ -r /run/secrets/bob_api_key ]; then + BOB_API_KEY="$(tr -d '[:space:]' < /run/secrets/bob_api_key)" + export BOB_API_KEY +fi + +# MCP servers: seed the user-global config from the mounted Docker config, +# unless one already exists (e.g. a persisted state volume or an image +# baked with its own mcp.json). +if [ -r /etc/bob/mcp.json ] && [ ! -e "${BOB_STATE_DIR}/settings/mcp.json" ]; then + mkdir -p "${BOB_STATE_DIR}/settings" + cp /etc/bob/mcp.json "${BOB_STATE_DIR}/settings/mcp.json" +fi + +# Default: interactive Bob Shell session. The flags keep a fresh container +# (e.g. empty volume over $HOME) non-interactive: accept the IBM license and +# mark the current folder trusted. +if [ "$#" -eq 0 ]; then + set -- bob chat --accept-license --trust +fi + +# Allow `docker run --version`-style invocations: a leading flag +# means the user wants bob itself with those flags. +if [ "${1#-}" != "$1" ]; then + set -- bob "$@" +fi + +exec "$@" diff --git a/infra/bobshell/mcp.json b/infra/bobshell/mcp.json new file mode 100644 index 0000000..3edf732 --- /dev/null +++ b/infra/bobshell/mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "disabled": false + }, + "example-remote": { + "httpURL": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer replace-me" + }, + "disabled": true + } + } +}