diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6e4a9add9..08bc3653d 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -88,15 +88,25 @@ if [ "$(dpkg-divert --truename /usr/bin/man)" = "/usr/bin/man.REAL" ]; then fi INSTALL_MAN_TOOLS -# Install podman client and socat inside the container to support nested +# Install dev utils # container communication and provide a 'docker' command alias. -RUN bash <<'INSTALL_PODMAN_CLIENT' +RUN bash <<'INSTALL_DEV_UTILS' set -euo pipefail apt-get update -apt-get install -y --no-install-recommends ssh podman socat +apt-get install -y --no-install-recommends jq podman ssh socat tree yq +apt purge nodejs npm -y && apt autoremove -y || true +apt-get install -y curl --no-install-recommends --autoremove +curl -fsSL https://deb.nodesource.com/setup_26.x | bash - +apt-get install -y nodejs apt-get clean rm -rf /var/lib/apt/lists/* -INSTALL_PODMAN_CLIENT +INSTALL_DEV_UTILS + +RUN bash <<'INSTALL_NPM_PACKAGES' +set -euo pipefail +npm config set allow-scripts=@kilocode/cli --location=user +npm install -g @kilocode/cli +INSTALL_NPM_PACKAGES # Wire up the root user's .bashrc to source the Spack environment via # /entrypoint.sh on every interactive shell. diff --git a/.devcontainer/codespace.code-workspace b/.devcontainer/codespace.code-workspace index c208ed9c2..b7b289bf1 100644 --- a/.devcontainer/codespace.code-workspace +++ b/.devcontainer/codespace.code-workspace @@ -114,7 +114,6 @@ "extensions": { "recommendations": [ "charliermarsh.ruff", - "github.copilot-chat", "github.vscode-github-actions", "github.vscode-pull-request-github", "ms-vscode.cmake-tools", diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c60f4acb5..91b3276d7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,47 +1,17 @@ { "name": "Phlex CI Dev Container", - "build": { - "dockerfile": "Dockerfile" - }, - "runArgs": [], + "dockerComposeFile": "docker-compose.yml", + "service": "phlex-dev", "workspaceFolder": "/workspaces/phlex", "remoteUser": "root", "remoteEnv": { - // KILO_CONFIG_CONTENT_DOCKER rewrites 127.0.0.1:PORT to - // host.docker.internal:RELAY_PORT for the socat relay used when - // headroom runs as a local proxy. Passed through here so that - // post-create.sh can wire it into the container's .bashrc - // conditionally: if non-empty it sets KILO_CONFIG_CONTENT to this - // value; otherwise KILO_CONFIG_CONTENT is left unset and Kilo falls - // back to ~/.config/kilo/kilo.jsonc (bind-mounted from the host). - "KILO_CONFIG_CONTENT_DOCKER": "${localEnv:KILO_CONFIG_CONTENT_DOCKER}", "KILO_API_KEY": "${localEnv:HEADROOM_UPSTREAM_KEY}" }, - "containerEnv": { - "CMAKE_GENERATOR": "Ninja", - "GH_CONFIG_DIR": "/root/.config/gh", - "DOCKER_HOST": "unix:///tmp/podman.sock", - "CONTAINER_HOST": "unix:///tmp/podman.sock", - "GNUPGHOME": "/root/.gnupg" - }, - "mounts": [ - "source=${localWorkspaceFolder}/../phlex-coding-guidelines,target=/workspaces/phlex-coding-guidelines,type=bind", - "source=${localWorkspaceFolder}/../phlex-design,target=/workspaces/phlex-design,type=bind", - "source=${localWorkspaceFolder}/../phlex-examples,target=/workspaces/phlex-examples,type=bind", - "source=${localWorkspaceFolder}/../phlex-spack-recipes,target=/workspaces/phlex-spack-recipes,type=bind", - "source=${localEnv:HOME}/.aws,target=/root/.aws,type=bind", - "source=${localEnv:HOME}/.config/gh,target=/root/.config/gh,type=bind,readonly", - "source=${localEnv:HOME}/.config/kilo,target=/root/.config/kilo,type=bind", - "source=${localEnv:HOME}/.gnupg,target=/root/.gnupg,type=bind", - "source=${localEnv:HOME}/.kiro,target=/root/.kiro,type=bind", - "source=phlex-kilo-data,target=/root/.local/share/kilo,type=volume", - "source=${localEnv:HOME}/.podman-proxy/podman.sock,target=/tmp/podman.sock,type=bind", - "source=${localEnv:HOME}/.vscode-remote-user-data,target=/root/.vscode-server-insiders/data/User,type=bind" - ], "initializeCommand": "bash .devcontainer/ensure-repos.sh", "onCreateCommand": "bash .devcontainer/setup-repos.sh /workspaces", "postCreateCommand": "bash -lc 'bash .devcontainer/post-create.sh'", "customizations": { + "vscode": { "settings": { "terminal.integrated.defaultProfile.linux": "bash", @@ -88,7 +58,6 @@ "**/.venv/**": true, "**/py_virtual_env/**": true }, - "github.copilot-chat.usePreReleaseVersion": false, "kilocode.new.extraCaCerts": "" }, "extensions": [ @@ -99,7 +68,6 @@ "donjayamanne.githistory", "dotjoshjohnson.xml", "eamodio.gitlens", - "github.copilot-chat", "github.vscode-github-actions", "github.vscode-pull-request-github", "jebbs.plantuml", @@ -114,13 +82,11 @@ "ms-python.vscode-pylance", "ms-python.vscode-python-envs", "ms-vscode.cmake-tools", - "ms-vscode.cpptools", "ms-vscode.cpptools-extension-pack", "ms-vscode.cpptools-themes", "ms-vscode.hexeditor", "ms-vscode.live-server", "ms-vscode.makefile-tools", - "ms-vscode.vscode-websearchforcopilot", "redhat.vscode-yaml", "shd101wyy.markdown-preview-enhanced", "swyddfa.esbonio", diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 000000000..49ac938b8 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,31 @@ +version: "3.8" + +services: + phlex-dev: + build: + context: . + dockerfile: Dockerfile + volumes: + - ..:/workspaces/phlex + - ../../phlex-coding-guidelines:/workspaces/phlex-coding-guidelines + - ../../phlex-design:/workspaces/phlex-design + - ../../phlex-examples:/workspaces/phlex-examples + - ../../phlex-spack-recipes:/workspaces/phlex-spack-recipes + - ${HOME}/.config/gh:/root/.config/gh:ro + - ${HOME}/.config/kilo:/root/.config/kilo + - ${HOME}/.gnupg:/root/.gnupg + - ${HOME}/.podman-proxy/podman.sock:/tmp/podman.sock + - ${HOME}/.local/share/kilo:/root/.local/share/kilo:ro + - phlex-vscode-user-data:/root/.vscode-server-insiders/data/User + environment: + - CMAKE_GENERATOR=Ninja + - GH_CONFIG_DIR=/root/.config/gh + - DOCKER_HOST=unix:///tmp/podman.sock + - CONTAINER_HOST=unix:///tmp/podman.sock + - GNUPGHOME=/root/.gnupg + user: root + tty: true + stdin_open: true + +volumes: + phlex-vscode-user-data: diff --git a/.devcontainer/ensure-repos.sh b/.devcontainer/ensure-repos.sh index 9303c9ddf..ef8ea76aa 100755 --- a/.devcontainer/ensure-repos.sh +++ b/.devcontainer/ensure-repos.sh @@ -150,44 +150,60 @@ s.bind('${PROXY_SOCKET}') " 2>/dev/null || touch "${PROXY_SOCKET}" fi -# --- Headroom Proxy Relay for Devcontainer --- +# --- Headroom Proxy Relays for Devcontainer --- # -# The headroom proxy is an SSH-tunnelled port bound only to 127.0.0.1 on this -# host. Rootless Podman uses pasta for container networking, so containers -# reach the host via host.docker.internal (169.254.1.2) rather than via a -# bridge interface. However, pasta maps host.docker.internal to the host's -# loopback only for ports that are actually listening on all interfaces -- -# headroom's port is bound to 127.0.0.1 only and is therefore unreachable. +# The headroom proxy was split into TWO SSH-tunnelled ports, each bound only to +# 127.0.0.1 on this host: +# HEADROOM_AZURE_PORT (9797) : optimized proxy, Kilo provider fnal-azure +# HEADROOM_OW_PORT (9798) : passthrough proxy, Kilo provider fnal-ow +# Rootless Podman uses pasta for container networking, so containers reach the +# host via host.docker.internal (169.254.1.2) rather than via a bridge +# interface. However, pasta maps host.docker.internal to the host's loopback +# only for ports that are actually listening on all interfaces -- headroom's +# ports are bound to 127.0.0.1 only and are therefore unreachable. # -# We relay headroom onto a different port on 0.0.0.0 so that containers can -# reach it via host.docker.internal:$HEADROOM_RELAY_PORT. A different port is -# required because 0.0.0.0:$HEADROOM_PORT would conflict with the existing -# 127.0.0.1:$HEADROOM_PORT listener. KILO_CONFIG_CONTENT_DOCKER is passed into the -# devcontainer and post-create.sh wires it into /root/.bashrc to point at -# host.docker.internal:$HEADROOM_RELAY_PORT. - -HEADROOM_PORT="${HEADROOM_PORT:-9797}" -HEADROOM_RELAY_PORT=$(( HEADROOM_PORT + 10000 )) -HEADROOM_LOCAL="127.0.0.1:${HEADROOM_PORT}" - -if ss -tlnp 2>/dev/null | grep -q "127.0.0.1:${HEADROOM_PORT}"; then - start_socat_relay \ - "Headroom proxy" \ - "socat TCP-LISTEN:${HEADROOM_RELAY_PORT}" \ - "TCP-LISTEN:${HEADROOM_RELAY_PORT},fork,reuseaddr" \ - "TCP:${HEADROOM_LOCAL}" \ - "/tmp/socat-headroom.log" \ - "ss -tlnp 2>/dev/null | grep -q ':${HEADROOM_RELAY_PORT} '" || true -else - echo "WARNING: headroom proxy not detected at ${HEADROOM_LOCAL}; skipping relay" >&2 - echo " Ensure the SSH tunnel is active (headroom running on your laptop)" >&2 -fi +# We relay EACH headroom port onto a different port on 0.0.0.0 so that +# containers can reach them via host.docker.internal:. A different +# port is required because 0.0.0.0: would conflict with the existing +# 127.0.0.1: listener. KILO_CONFIG_CONTENT_DOCKER (built in the shell rc) +# rewrites both loopback ports to their host.docker.internal relay ports and is +# wired into /root/.bashrc by post-create.sh. + +HEADROOM_AZURE_PORT="${HEADROOM_AZURE_PORT:-9797}" +HEADROOM_OW_PORT="${HEADROOM_OW_PORT:-9798}" + +# relay_headroom_port ROLE PROXY_PORT +# Relay 127.0.0.1:PROXY_PORT to 0.0.0.0:(PROXY_PORT+10000) if the proxy is +# listening; otherwise warn and skip. Each role gets an independent relay +# and log file so the two never collide. +relay_headroom_port() { + local role="$1" + local proxy_port="$2" + local relay_port=$(( proxy_port + 10000 )) + local local_addr="127.0.0.1:${proxy_port}" + + if ss -tlnp 2>/dev/null | grep -q "127.0.0.1:${proxy_port}"; then + start_socat_relay \ + "Headroom ${role} proxy" \ + "socat TCP-LISTEN:${relay_port}" \ + "TCP-LISTEN:${relay_port},fork,reuseaddr" \ + "TCP:${local_addr}" \ + "/tmp/socat-headroom-${role}.log" \ + "ss -tlnp 2>/dev/null | grep -q ':${relay_port} '" || true + else + echo "WARNING: headroom ${role} proxy not detected at ${local_addr}; skipping relay" >&2 + echo " Ensure the SSH tunnel is active (headroom-${role} running on your laptop)" >&2 + fi +} + +relay_headroom_port azure "${HEADROOM_AZURE_PORT}" +relay_headroom_port ow "${HEADROOM_OW_PORT}" # Ensure remaining source bind mount points exist. -ensure_bind_dir "$HOME/.aws" ensure_bind_dir "$HOME/.config/"{gh,kilo} -ensure_bind_dir "$HOME/.gnupg" -ensure_bind_dir "$HOME/.kiro" -ensure_bind_dir "$HOME/.vscode-remote-user-data" +ensure_bind_dir -m 0700 "$HOME/.gnupg" +ensure_bind_dir -m 0700 "$HOME/.vscode-remote-user-data" +ensure_bind_dir -m 0700 "$HOME/.local/share/kilo" +ensure_bind_dir -m 0700 "$HOME/.phlex-devcontainer-tmp" echo "SUCCESS: .devcontainer/ensure-repos.sh completed successfully" diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index f7f35673f..75fecde39 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -17,15 +17,6 @@ EOF # installation on every rebuild. rm -f /root/.vscode-server-insiders/data/Machine/.installExtensionsMarker -# Set KILO_CONFIG_CONTENT for interactive shells. When KILO_CONFIG_CONTENT_DOCKER -# is non-empty (headroom local proxy via socat relay), use it so that the baseURL -# points at host.docker.internal rather than 127.0.0.1. Otherwise leave -# KILO_CONFIG_CONTENT unset so Kilo falls back to ~/.config/kilo/kilo.jsonc, -# which is bind-mounted from the host and may point at an external provider. -if [ -n "${KILO_CONFIG_CONTENT_DOCKER:-}" ]; then - printf 'export KILO_CONFIG_CONTENT=%q\n' "${KILO_CONFIG_CONTENT_DOCKER}" >> /root/.bashrc -fi - # Seed the Kilo Code auth token into the container-private data volume. # The volume is not shared with the host to avoid SQLite conflicts between # the Remote-SSH and devcontainer Kilo Code instances. The API key is @@ -39,10 +30,17 @@ import json import os from pathlib import Path +# The single fnal-litellm provider was split into fnal-azure (optimized) and +# fnal-ow (passthrough); both share the same upstream gateway key. Seed both +# provider keys so Kilo resolves whichever provider a model is routed through. +key = os.environ["KILO_API_KEY"] p = Path("/root/.local/share/kilo/auth.json") p.write_text( json.dumps( - {"fnal-litellm": {"type": "api", "key": os.environ["KILO_API_KEY"]}}, + { + "fnal-azure": {"type": "api", "key": key}, + "fnal-ow": {"type": "api", "key": key}, + }, indent=2, ) + "\n", @@ -100,5 +98,32 @@ elif command -v pre-commit >/dev/null 2>&1; then pre-commit install || true fi -# Install kiro-cli and set up shell integrations. -curl -fsSL https://cli.kiro.dev/install | bash +# Configure Kilo to use host.docker.internal for headroom proxy ports. +# The headroom SSH tunnels bind to 127.0.0.1 only, but rootless Podman's +# pasta networking only routes host.docker.internal to ports listening on +# 0.0.0.0. We use socat relays (set up in ensure-repos.sh) to forward +# 127.0.0.1:9797 -> 0.0.0.0:19797 and 127.0.0.1:9798 -> 0.0.0.0:19798. +# This code rewrites Kilo's baseURL from 127.0.0.1: to +# host.docker.internal: for the relays to work. +cat >> /root/.bashrc <<'EOF' + +# Unset any previous KILO_CONFIG_CONTENT to ensure a clean slate. +unset KILO_CONFIG_CONTENT + +# If the headroom proxy ports (9797, 9798) are active via socat relays, +# Kilo needs a modified config with baseURL pointing to host.docker.internal. +# Parse 'kilo debug config', rewrite baseURLs, and export as KILO_CONFIG_CONTENT. +if command -v kilo >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then + # Only rewrite if we have a working Kilo config to work with. + if KILO_CONFIG_CONTENT="$(perl -pe 's{("(?:[^"\\]|\\.)*"|'"'"'(?:[^'"'"'\\]|\\.)*'"'"')|//.*}{$1 // ""}ge' "${HOME}/.config/kilo/kilo.jsonc" | jq -c '.provider |= with_entries( + .value.options.baseURL = + (if .value.options.baseURL == null then null + elif (.value.options.baseURL | test("^https?://127\\.0\\.0\\.1:")) + then (.value.options.baseURL | capture("https?://127\\.0\\.0\\.1:(?[0-9]+)(?.*)") | "http://host.docker.internal:" + ((.port | tonumber + 10000) | tostring) + .rest) + else .value.options.baseURL + end) + )')"; then + export KILO_CONFIG_CONTENT + fi +fi +EOF