diff --git a/.github/workflows/container-publish.yaml b/.github/workflows/container-publish.yaml new file mode 100644 index 00000000..75b35d0c --- /dev/null +++ b/.github/workflows/container-publish.yaml @@ -0,0 +1,53 @@ +name: Publish Claude container + +# Builds and pushes claude-env to ghcr.io on every version tag. +# lc launch claude will pull this image instead of building locally. +on: + push: + tags: + - 'v*.*.*' + +jobs: + push: + name: Build and push claude-env + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract version from tag + id: version + # Strip the leading 'v' so LIGHTCONE_VERSION matches the PyPI version string. + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: claude/lightcone/containers + file: claude/lightcone/containers/claude-env.Containerfile + build-args: | + LIGHTCONE_VERSION=${{ steps.version.outputs.version }} + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/lightconeresearch/claude-env:${{ steps.version.outputs.version }} + ghcr.io/lightconeresearch/claude-env:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/CLAUDE.md b/CLAUDE.md index 50232314..ad9d224f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,8 @@ astra.yaml ── snakefile generator ──> .lightcone/Snakefile src/lightcone/ # namespace — NO __init__.py ├── cli/ # Click surface │ ├── __init__.py # exposes main() -│ ├── commands.py # init, run, status, verify, build + +│ ├── commands.py # init, run, status, verify, build, launch, setup │ ├── plugin.py # get_plugin_source_dir │ └── claude/ # force-included Claude plugin bundle (in installed wheel only) ├── engine/ # execution substrate — Snakemake-based @@ -136,6 +137,8 @@ astra.yaml ── snakefile.generate() ──> .lightcone/Snakefile + .lightcone - `lc status` — manifest-driven status report - `lc verify` — chain integrity check - `lc build` — pre-build container images from Containerfiles +- `lc launch claude` – start Claude Code in a sandbox container for increased safety + Global config (`~/.lightcone/config.yaml`) is auto-created with defaults on first invocation. diff --git a/claude/lightcone/containers/lightcone-sandbox.Containerfile b/claude/lightcone/containers/lightcone-sandbox.Containerfile new file mode 100644 index 00000000..3ee96036 --- /dev/null +++ b/claude/lightcone/containers/lightcone-sandbox.Containerfile @@ -0,0 +1,55 @@ +FROM python:3.12-slim-bookworm + +# FUSE support — required by both Apptainer overlay and buildah overlay storage. +# squashfuse enables SquashFS mounts used by Apptainer for OCI images. +# Apptainer — pinned version, installed from the official .deb. +# unzip — required by the OpenCode install script on Linux. +# Note: we pin -bookworm explicitly because plain `python:3.12-slim` flipped to +# Debian 13 (Trixie) where libfuse2 was renamed to libfuse2t64 and would break +# this apt block. Bookworm + libfuse2 is what apptainer 1.4 expects. +ARG APPTAINER_VERSION=1.4.0 +RUN apt-get update && apt-get install -y --no-install-recommends \ + fuse3 \ + libfuse2 \ + squashfuse \ + buildah \ + fakeroot \ + git \ + curl \ + ca-certificates \ + build-essential \ + unzip \ + && ARCH="$(dpkg --print-architecture)" \ + && if [ "$ARCH" = "amd64" ]; then \ + curl -fsSL \ + "https://github.com/apptainer/apptainer/releases/download/v${APPTAINER_VERSION}/apptainer_${APPTAINER_VERSION}_amd64.deb" \ + -o /tmp/apptainer.deb \ + && apt-get install -y /tmp/apptainer.deb \ + && rm /tmp/apptainer.deb; \ + fi \ + && rm -rf /var/lib/apt/lists/* + +# uv + lightcone-cli. +# LIGHTCONE_VERSION is substituted at render time (lc launch writes a rendered +# copy to .lightcone/containers/lightcone-sandbox.Containerfile). +RUN curl -LsSf https://astral.sh/uv/install.sh | sh +ENV PATH="/root/.local/bin:$PATH" +ARG LIGHTCONE_VERSION +# Dev/local builds are not published to PyPI; for non-release strings we +# install the latest stable release from PyPI. +RUN case "${LIGHTCONE_VERSION}" in \ + *dev*|*+*|dev) uv pip install --system --break-system-packages lightcone-cli ;; \ + *) uv pip install --system --break-system-packages "lightcone-cli==${LIGHTCONE_VERSION}" ;; \ + esac + +# Node.js LTS — required by Claude Code (npm) and OpenCode (npm). +ARG NODE_VERSION=22 +RUN curl -fsSL "https://deb.nodesource.com/setup_${NODE_VERSION}.x" | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Marker read by lc build / lc run to detect containerized operation. +ENV LIGHTCONE_CONTAINER=1 + +WORKDIR /workspace +ENTRYPOINT ["bash"] diff --git a/claude/lightcone/guides/lightcone-cli-reference.md b/claude/lightcone/guides/lightcone-cli-reference.md index 7868ceff..2ff32939 100644 --- a/claude/lightcone/guides/lightcone-cli-reference.md +++ b/claude/lightcone/guides/lightcone-cli-reference.md @@ -6,6 +6,7 @@ Reference for lightcone-cli execution: CLI commands, development workflow, statu ```bash lc init [DIR] [--permissions yolo|recommended|minimal] [--scratch PATH] # Scaffold a new ASTRA project +lc launch # Enter the sandboxed container (lc launch claude) lc run [OUTPUTS...] [--universe NAME] [--force] [--verbose] [--rerun-triggers TRIGGERS] # Materialize outputs lc build [--force] [--runtime docker] # Build container images from specs lc status [--universe NAME] [--json] # Materialization status (text or JSON) @@ -20,11 +21,24 @@ The first `lc` invocation auto-creates `~/.lightcone/config.yaml`: ```yaml container: - runtime: auto # or: docker | podman | podman-hpc | none + runtime: auto # or: docker | podman | podman-hpc | apptainer | singularity | none ``` **Always run via `lc`.** Recipes must execute through `lc run` so that container builds, option resolution, resource limits, and result paths are applied. Treat the underlying execution engine as a black box — never invoke schedulers or container runtimes directly, that will bypass reproducibility guarantees. +## Container Environment + +`lc launch claude` is the canonical entry point for all analysis work. After `lc init`, always launch the container before doing anything else: + +```bash +lc init my-project && cd my-project +lc launch claude # builds the container on first run (~5 min), then drops into Claude Code +``` + +Inside the container, `lc build`, `lc run`, `lc status`, and `lc verify` all work normally. Running them outside the container prints a warning — host-side execution is still possible but bypasses the reproducibility sandbox. + +The container mounts the project directory at the same absolute path so all output paths, tarball paths, and manifest paths are identical inside and outside. + ## Creating Sub-Analyses Sub-analyses are scaffolded by hand, since each one is just another `astra.yaml` nested in a directory. To add one: @@ -37,7 +51,7 @@ Populate the sub-analysis's `astra.yaml` with inputs, outputs, and decisions. Us ## Development Workflow -Three overlapping phases: +All three phases happen **inside the container** (entered via `lc launch claude`): 1. **Write & Debug** — Run scripts directly (`python src/compute.py`) to iterate. Write them recipe-ready from the start: parameterize decisions, write to convention paths, one script per output. 2. **Integrate** — Add `recipe:` blocks to outputs in `astra.yaml`. Track with `lc status` (`alias` / `missing` / `stale` / `ok`). Set `container:` at analysis level or per-recipe — pass an image name (e.g., `python:3.12-slim`) or a path to a Containerfile (e.g., `Containerfile`). diff --git a/claude/lightcone/skills/lc-migrate/SKILL.md b/claude/lightcone/skills/lc-migrate/SKILL.md index d5f42389..f0354756 100644 --- a/claude/lightcone/skills/lc-migrate/SKILL.md +++ b/claude/lightcone/skills/lc-migrate/SKILL.md @@ -100,7 +100,10 @@ Whatever approach you use: ## Phase 3: Run & Debug +Enter the container if not already inside it, then materialize: + ```bash +lc launch claude # skip if already inside the container lc run --universe baseline ``` diff --git a/docs/container-design.md b/docs/container-design.md new file mode 100644 index 00000000..3455c335 --- /dev/null +++ b/docs/container-design.md @@ -0,0 +1,332 @@ +# Design: Containerized Claude Code environment (`lc launch`) + +> Companion to `redesign.md`. Extends the Snakemake-based execution layer with a +> fully containerized development environment: `lc launch claude` spawns a sandboxed +> Claude Code session with `lc` tools and a nested container runtime pre-installed. +> All recipe building and execution happens inside this environment. + +--- + +## Context + +The redesign.md establishes Snakemake as the execution backbone and content-addressed +OCI manifests as the integrity layer. The missing piece is the **entry point**: today, +a user who opens a project has no standardised, sandboxed environment in which to run +the agent. They rely on host-installed tools, host Python, and host container runtimes — +none of which are version-pinned or reproducible across machines or collaborators. + +This design adds `lc launch ` as the canonical entry point. After +`lc init my-project && cd my-project`, the first and only command is +`lc launch claude`. Everything else — planning the workflow with the agent, +building recipe images, running analyses — happens inside the container. + +--- + +## Design decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Nested container strategy | True nested (option C) | Full reproducibility: each recipe runs in its own image | +| Image format | OCI tarballs (`.lightcone/images/*.tar`) | Runtime-neutral, portable, file-based provenance | +| Claude container source | Built locally from bundled Containerfile | No registry dependency; version-controlled alongside `lc` | +| Inner build tool | `buildah` | Daemonless, rootless, OCI-native; pairs with Apptainer | +| Inner execution tool | `apptainer exec oci-archive:` | Reads OCI tarballs directly; no load step; HPC-native | +| HPC compatibility | `/dev/fuse` passthrough + `podman-hpc` outer runtime | Perlmutter-tested pattern; FUSE overlay enables both tools | +| `lc launch` scope | General dispatch (`lc launch `) | `claude` is first target; pattern extensible | +| Enforcement | `LIGHTCONE_CONTAINER=1` env var + warning | Host-side still works; in-container use is encouraged | + +--- + +## User-facing workflow + +``` +lc init my-project && cd my-project +lc launch claude ← always the first step + [inside container] + [Claude: define question, plan workflow, write astra.yaml + Containerfiles] + lc build ← buildah builds recipe images → .lightcone/images/*.tar + lc run ← snakemake + apptainer exec oci-archive:... per rule + lc status / lc verify ← offline manifest checks (unchanged) + exit +lc launch claude ← resume; .lightcone/images/ tarballs persist on host +``` + +--- + +## Architecture + +``` +host +├── lc launch claude +│ ├── detect host runtime (docker / podman / podman-hpc) +│ ├── render .lightcone/containers/claude-env.Containerfile (LIGHTCONE_VERSION substituted) +│ ├── build lc-claude-env- if tarball absent ← host runtime builds it +│ ├── save to .lightcone/images/lc-claude-env-.tar +│ └── exec -it +│ -v : -w +│ -e ANTHROPIC_API_KEY --device /dev/fuse +│ +└── [inside claude container] + ├── claude (TUI) + ├── lc build → buildah build → buildah push oci-archive:.lightcone/images/.tar + └── lc run → snakemake + └── per rule: apptainer exec oci-archive:.lightcone/images/.tar + write_manifest() (host-side Python) +``` + +**What runs where:** + +| Component | Location | Tool | +|---|---|---| +| `lc launch` itself | host | docker / podman / podman-hpc | +| Claude Code TUI | inside Claude container | — | +| `lc build` (recipe images) | inside Claude container | `buildah` | +| `lc run` / snakemake orchestration | inside Claude container | — | +| Per-recipe shell commands | nested recipe container | `apptainer exec oci-archive:` | +| `write_manifest()` | inside Claude container | Python (host-side of rule) | + +--- + +## New file: `claude/lightcone/containers/claude-env.Containerfile` + +```dockerfile +FROM python:3.12-slim + +# FUSE support — required by both Apptainer and buildah overlay storage +RUN apt-get update && apt-get install -y --no-install-recommends \ + fuse3 libfuse2 squashfuse \ + buildah \ + git curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Apptainer — pinned, installed from .deb +ARG APPTAINER_VERSION=1.4.0 +RUN curl -fsSL \ + https://github.com/apptainer/apptainer/releases/download/v${APPTAINER_VERSION}/apptainer_${APPTAINER_VERSION}_amd64.deb \ + -o /tmp/apptainer.deb \ + && dpkg -i /tmp/apptainer.deb && rm /tmp/apptainer.deb + +# Python + uv + lightcone-cli (version injected at render time, not build-time ARG) +RUN curl -LsSf https://astral.sh/uv/install.sh | sh +ENV PATH="/root/.local/bin:$PATH" +ARG LIGHTCONE_VERSION +RUN uv pip install --system "lightcone-cli==${LIGHTCONE_VERSION}" + +# Node.js LTS + Claude Code CLI +ARG NODE_VERSION=22 +RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - \ + && apt-get install -y nodejs \ + && npm install -g @anthropic-ai/claude-code \ + && rm -rf /var/lib/apt/lists/* + +# Marker checked by lc build / lc run +ENV LIGHTCONE_CONTAINER=1 + +WORKDIR /workspace +ENTRYPOINT ["claude"] +``` + +**Version pinning:** `lc launch` renders a copy of this Containerfile to +`.lightcone/containers/claude-env.Containerfile` with `${LIGHTCONE_VERSION}` +substituted to the running `lc` version string. `compute_image_tag()` hashes +the rendered file, so upgrading `lc` automatically invalidates the cached image. + +--- + +## New module: `src/lightcone/engine/launcher.py` (~150 LOC) + +```python +@dataclass(frozen=True) +class LaunchTarget: + name: str + containerfile: Path # source Containerfile (in lightcone package) + entrypoint: list[str] + env_passthrough: list[str] + devices: list[str] # e.g. ["/dev/fuse"] + +BUILTIN_TARGETS: dict[str, LaunchTarget] = { + "claude": LaunchTarget( + name="claude", + containerfile=_package_containers_dir() / "claude-env.Containerfile", + entrypoint=["claude"], + env_passthrough=["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "HOME", "TERM"], + devices=["/dev/fuse"], + ), +} +``` + +`resolve_launch_target(name, project_root)` — looks up built-in targets first, +then `.lightcone/launch/.yaml` for project-local targets. + +`_package_containers_dir() -> Path` — returns the path to +`claude/lightcone/containers/` inside the installed lightcone package +(resolved via `importlib.resources` or `Path(__file__).parent / "containers"`). + +`_render_containerfile(target, project_root) -> Path` — copies the source +Containerfile to `.lightcone/containers/.Containerfile`, substituting +`ARG LIGHTCONE_VERSION` with `ARG LIGHTCONE_VERSION=` so +the content hash (and therefore image tag) changes whenever `lc` is upgraded. + +`_compute_launch_image_tag(rendered_cf) -> str` — calls `compute_image_tag()` +with `project_name=target.name`, `containerfile=rendered_cf`, and +`project_path=rendered_cf.parent` (the `.lightcone/containers/` dir has no +additional dependency files to hash, which is correct — the rendered Containerfile +already encodes the `lc` version and Apptainer version). + +`launch_target(name, *, choice, project_root)`: +1. Resolve target definition +2. Render Containerfile → `.lightcone/containers/.Containerfile` +3. Compute image tag from rendered Containerfile +4. Build + save tarball if absent (`tarball_path_for_tag()` in `.lightcone/images/`) +5. Load into runtime store if not already present (`load_image_from_tarball()`) +6. `exec` the interactive container (replaces the current process, never returns) + +Mount strategy: `-v : -w `. +Snakemake output paths, manifest paths, and tarball paths are identical +inside and outside the container — no path translation. + +`podman-hpc` specifics: `_exec_interactive()` adds `--no-setns` and ensures +`/dev/fuse` is in the device list (already used elsewhere in the codebase). + +--- + +## Changes to `src/lightcone/engine/container.py` + +### New runtime: `"apptainer"` + +```python +RUNTIMES = ("podman", "docker", "podman-hpc", "apptainer") +# detect_runtime() checks shutil.which() for each in order +``` + +### `build_image()` — apptainer branch + +```python +if runtime == "apptainer": + subprocess.run( + ["buildah", "build", "--format=oci", f"--tag={tag}", str(context)], + check=True, + ) + tarball = tarball_path_for_tag(tag, project_path) + subprocess.run(["buildah", "push", tag, f"oci-archive:{tarball}"], check=True) +``` + +### `build_image()` — existing docker/podman/podman-hpc branch + +Unchanged except one added line after the existing build call: +```python +save_image_as_tarball(tag, tarball_path_for_tag(tag, project_path), runtime=runtime) +``` + +### `image_exists_locally()` — apptainer branch + +```python +if runtime == "apptainer": + return tarball_path_for_tag(tag, project_path).exists() +``` + +### `wrap_recipe()` — apptainer branch + +```python +if runtime == "apptainer": + tarball = f".lightcone/images/{image}.tar" + return f"apptainer exec --fakeroot oci-archive:{tarball} bash -c {shlex.quote(recipe)}" +``` + +### New helpers + +```python +def save_image_as_tarball(tag: str, tarball_path: Path, *, runtime: str) -> None: + """ save > (streaming, no RAM buffer)""" + +def load_image_from_tarball(tarball_path: Path, *, runtime: str) -> None: + """ load -qi — loads into the runtime's local store""" + +def tarball_path_for_tag(tag: str, project_path: Path) -> Path: + return project_path / ".lightcone" / "images" / f"{tag}.tar" +``` + +--- + +## Changes to `src/lightcone/cli/commands.py` + +### New command: `lc launch ` + +```python +@main.command("launch") +@click.argument("target") +def launch(target: str): + """Launch an interactive containerized environment for this project.""" + project = _project_root() + choice = load_runtime(project_path=project) + launcher.launch_target(target, choice=choice, project_root=project) +``` + +### Enforcement warning in `lc build` and `lc run` + +```python +_CONTAINER_WARNING = ( + "⚠ Running outside the Claude container. " + "Use [bold]lc launch claude[/bold] for the full sandboxed workflow." +) + +def _warn_if_not_containerized(console: Console) -> None: + if not os.environ.get("LIGHTCONE_CONTAINER"): + console.print(_CONTAINER_WARNING) +``` + +Called at the top of both `build()` and `run()` command functions. + +--- + +## Files changed / created + +| File | Change | +|---|---| +| `src/lightcone/engine/launcher.py` | NEW (~150 LOC) — LaunchTarget, resolve, launch | +| `claude/lightcone/containers/claude-env.Containerfile` | NEW — Claude Code environment | +| `src/lightcone/engine/container.py` | Add apptainer runtime, buildah build path, tarball helpers | +| `src/lightcone/cli/commands.py` | Add `lc launch`, add `_warn_if_not_containerized()` | +| `tests/test_launcher.py` | NEW — target resolution, render, tag, launch smoke test | +| `tests/test_container.py` | Add apptainer/buildah branch tests, tarball helper tests | + +Files **not** changed: `snakefile.py`, `manifest.py`, `status.py`, `verify.py`. +The `runtime` parameter already flows through `snakefile.generate()`; adding +`"apptainer"` to `container.py` is sufficient. + +--- + +## Open questions (resolved) + +1. **Nested strategy** → Option C (true nested containers). ✓ +2. **Image format** → OCI tarballs in `.lightcone/images/`. ✓ +3. **Claude container source** → Built locally from bundled Containerfile. ✓ +4. **Inner build tool** → `buildah` (installed in claude-env). ✓ +5. **Inner execution tool** → `apptainer exec oci-archive:`. ✓ +6. **`lc build` host-side** → Still works, prints enforcement warning. ✓ +7. **`lc launch` scope** → General dispatch; `claude` is first built-in target. ✓ + +--- + +## Verification + +End-to-end test path: + +1. `lc init test-project && cd test-project` +2. `lc launch claude` — should detect host runtime, build `lc-claude-env-.tar`, + launch container, drop into Claude Code TUI +3. Inside container: `lc build` — should produce `.lightcone/images/lc--.tar` +4. Inside container: `lc run` — snakemake rule should invoke + `apptainer exec oci-archive:.lightcone/images/...tar`, manifest written afterward +5. Inside container: `lc verify` — chain should validate +6. Exit container; re-enter with `lc launch claude` — tarballs still present, + `lc build` skips (already built), `lc run` executes normally + +Unit tests: +- `test_launcher.py`: `resolve_launch_target("claude")`, `_render_containerfile()`, + `compute_image_tag()` stability, `launch_target()` smoke (mock subprocess) +- `test_container.py`: `tarball_path_for_tag()`, `image_exists_locally()` for apptainer, + `wrap_recipe()` apptainer branch, `build_image()` apptainer branch (mock buildah) + +Perlmutter-specific: `lc launch claude` with `runtime=podman-hpc` should add +`--no-setns` and `--device /dev/fuse` to the outer run command. diff --git a/docs/superpowers/specs/2026-05-06-decouple-harness-installation-design.md b/docs/superpowers/specs/2026-05-06-decouple-harness-installation-design.md new file mode 100644 index 00000000..199f1a6c --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-decouple-harness-installation-design.md @@ -0,0 +1,122 @@ +# Design Spec: Decouple Harness Installation from Container Image + +**Date:** 2026-05-06 +**Branch:** sandboxing-execution +**Status:** Implemented + +--- + +## Problem + +The original `lc launch claude` command shipped a single `claude-env.Containerfile` that bundled both system tooling (Apptainer, buildah, Node.js, uv) and the Claude Code CLI into one published image. This caused two problems: + +1. **Licensing**: Publishing an image containing Claude Code (a proprietary CLI) raises redistribution concerns. +2. **Extensibility**: Supporting a second harness (Mistral Vibe, OpenCode) required a separate published image per harness, bloating the registry. + +--- + +## Solution + +Publish one neutral `lightcone-sandbox` base image (system tools only). On first `lc launch `, install the harness inside a running container and commit the result as a local image (`lightcone-:`). Subsequent launches detect the committed image and skip straight to exec. + +--- + +## Architecture + +### Base Image (`lightcone-sandbox.Containerfile`) + +Renamed from `claude-env.Containerfile`. Key changes: +- Removed: `npm install -g @anthropic-ai/claude-code` and `ENTRYPOINT ["claude"]` +- Added: `unzip` to apt-get (required by OpenCode install script) +- Changed: `ENTRYPOINT ["bash"]` +- Kept: Python 3.12-slim-bookworm, FUSE, Apptainer 1.4.0, buildah, Node.js LTS, uv, lightcone-cli, `LIGHTCONE_CONTAINER=1` + +Published as: `ghcr.io/lightconeresearch/lightcone-sandbox:` +No harness-specific images are published. + +### LaunchTarget Dataclass + +Two new fields added to `LaunchTarget` (frozen dataclass in `launcher.py`): + +```python +install_cmds: list[str] # shell commands joined with " && " and run via sh -c +committed_tag_prefix: str # e.g. "lightcone-claude" → tag "lightcone-claude:" +``` + +The `entrypoint` field (pre-existing) now carries the full binary + args, e.g. `["claude", "--dangerously-skip-permissions"]`. The launcher passes `--entrypoint ` before the image tag and appends remaining args after. + +### Harness Targets + +All three harnesses share the `lightcone-sandbox.Containerfile` as their base and `registry_name="lightcone-sandbox"` for GHCR pull. The constant `_SANDBOX_IMAGE_NAME = "lightcone-sandbox"` is used throughout. + +| Field | claude | mistral-vibe | opencode | +|---|---|---|---| +| `install_cmds` | `npm install -g @anthropic-ai/claude-code` | `uv tool install mistral-vibe` | `npm install -g opencode-ai` | +| `committed_tag_prefix` | `lightcone-claude` | `lightcone-mistral-vibe` | `lightcone-opencode` | +| `entrypoint` | `["claude", "--dangerously-skip-permissions"]` | `["vibe"]` | `["opencode"]` | +| `env_passthrough` | `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `CLAUDE_CODE_OAUTH_TOKEN`, `HOME`, `TERM` | `MISTRAL_API_KEY` | `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `MISTRAL_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY` | +| `run_as_host_user` | `True` | `False` | `False` | + +### Home Mounts (Granular Strategy) + +`home_mounts` is an explicit list of sub-paths of `$HOME` to bind-mount. Trailing `/` = directory, others = file. Missing host paths are auto-created before mounting via `_ensure_host_path()`. + +**Excluded** (never mounted — sensitive or ephemeral): +- claude: `.claude/projects/`, `.claude/logs/`, `.claude/statsig/` +- mistral-vibe: `.vibe/logs/`, `.vibe/.env` +- opencode: `.local/share/opencode/storage/` + +### `_ensure_harness_image()` Flow + +``` +committed_tag = f"{target.committed_tag_prefix}:{lc_version}" + +if not reinstall and image_exists_locally(committed_tag): + return committed_tag # fast path: second+ launch + +if reinstall: + rmi committed_tag # remove old image to avoid dangling layers + +tmp = "lc-install--" +try: + docker run --name tmp base_image sh -c install_cmd + docker commit tmp committed_tag (capture_output=True) +except CalledProcessError: + raise ContainerBuildError(...) +finally: + docker rm -f tmp (check=False, capture_output=True) + +return committed_tag +``` + +`launch_target()` calls `_ensure_harness_image()` after loading the base image and before `_exec_interactive()`. The returned committed tag replaces the base image tag for both the exec and the tracking tag. + +### `--reinstall` Flag + +``` +lc launch claude --reinstall +``` + +Forces re-installation: removes the existing committed image (to avoid dangling layer accumulation), then runs install again and commits a fresh image. + +--- + +## Files Changed + +| File | Change | +|---|---| +| `claude/lightcone/containers/lightcone-sandbox.Containerfile` | Renamed from `claude-env.Containerfile`; stripped harness install and ENTRYPOINT; added `unzip` | +| `src/lightcone/engine/launcher.py` | Added `install_cmds`, `committed_tag_prefix` to `LaunchTarget`; added `_SANDBOX_IMAGE_NAME`, `_image_exists()`, `_ensure_host_path()`, `_ensure_harness_image()`; defined 3 harness targets; updated `launch_target()` and `_exec_interactive()` | +| `src/lightcone/cli/commands.py` | Added `--reinstall` flag; updated `lc launch` help text | +| `tests/test_launcher.py` | Tests for all new helpers and harness targets; `TestLaunchTargetEnsureHarness` for end-to-end wiring | +| `tests/test_cli.py` | `test_launch_reinstall_forwarded_to_launch_target` | + +--- + +## Key Invariants + +- The base `lightcone-sandbox` image contains no proprietary software — safe to publish. +- Harness images are local-only (never pushed); they are rebuilt by `--reinstall` or when the committed tag is absent. +- Sensitive config paths (logs, session history, API key files) are never mounted into the container. +- `_ensure_harness_image` always cleans up the temp container (via `finally`), even on install failure. +- `CalledProcessError` from subprocess is always wrapped as `ContainerBuildError` — consistent with the rest of the launcher module. diff --git a/src/lightcone/cli/commands.py b/src/lightcone/cli/commands.py index e528c6fd..025bd9fc 100644 --- a/src/lightcone/cli/commands.py +++ b/src/lightcone/cli/commands.py @@ -34,6 +34,17 @@ console = Console() logger = logging.getLogger(__name__) +_CONTAINER_WARNING = ( + "⚠ Running outside the Claude container. " + "Use [bold]lc launch claude[/bold] for the full sandboxed workflow." +) + + +def _warn_if_not_containerized() -> None: + """Print a warning when lc build/run are invoked outside the Claude container.""" + if not os.environ.get("LIGHTCONE_CONTAINER") and not os.environ.get("LC_NO_CONTAINER_WARN"): + console.print(_CONTAINER_WARNING) + PERMISSION_TIERS: dict[str, dict[str, list[str]]] = { "yolo": { @@ -224,7 +235,10 @@ def init( if not no_venv: if shutil.which("uv"): with console.status("[dim]Creating virtual environment…[/dim]"): - subprocess.run(["uv", "venv", "--python", "3.12", ".venv"], cwd=directory, check=False, capture_output=True) + subprocess.run( + ["uv", "venv", "--python", "3.12", ".venv"], + cwd=directory, check=False, capture_output=True, + ) with console.status("[dim]Installing lightcone-cli…[/dim]"): subprocess.run( ["uv", "pip", "install", "--python", ".venv/bin/python", "lightcone-cli"], @@ -234,7 +248,10 @@ def init( ) else: with console.status("[dim]Creating virtual environment…[/dim]"): - subprocess.run(["python", "-m", "venv", ".venv"], cwd=directory, check=False, capture_output=True) + subprocess.run( + ["python", "-m", "venv", ".venv"], + cwd=directory, check=False, capture_output=True, + ) with console.status("[dim]Installing lightcone-cli…[/dim]"): subprocess.run( [".venv/bin/python", "-m", "pip", "install", "-q", "lightcone-cli"], @@ -402,6 +419,7 @@ def run( existing scheduler if ``DASK_SCHEDULER_ADDRESS`` is set. """ _abort_on_perlmutter_login() + _warn_if_not_containerized() from lightcone.engine.container import load_runtime from lightcone.engine.dask_cluster import cluster_for_run @@ -743,18 +761,24 @@ def verify(universe: str | None) -> None: @click.option( "--runtime", default=None, - help="docker | podman | podman-hpc (overrides ~/.lightcone/config.yaml)", + help=( + "docker | podman | podman-hpc | apptainer | singularity " + "(overrides ~/.lightcone/config.yaml)" + ), ) def build(force: bool, runtime: str | None) -> None: """Build container images declared in astra.yaml. - Containerfile syntax is Dockerfile syntax — we use ``docker``, - ``podman``, or ``podman-hpc`` directly. Each Containerfile builds to - an OCI image tagged ``lc--`` in the runtime's local - image store. Pre-built registry images (``python:3.12-slim``, + Containerfile syntax is Dockerfile syntax. For daemon-based runtimes + (``docker``, ``podman``, ``podman-hpc``), images are built into the + runtime's local store. For daemonless runtimes (``apptainer``, + ``singularity``), ``buildah`` is used to produce OCI tarballs in + ``.lightcone/images/``. Pre-built registry images (``python:3.12-slim``, ``ghcr.io/foo/bar:tag``) are skipped — the runtime pulls them at ``lc run`` time. """ + _warn_if_not_containerized() + from lightcone.engine.container import ContainerBuildError, load_runtime project = _project_root() @@ -791,12 +815,15 @@ def _ensure_images(project: Path, *, runtime: str, force: bool = False) -> None: from astra.helpers import load_yaml, resolve_analysis_tree from lightcone.engine.container import ( + _DAEMONLESS_RUNTIMES, ContainerBuildError, build_image, compute_image_tag, image_exists_locally, is_containerfile, pull_image, + save_image_as_tarball, + tarball_path_for_tag, ) from lightcone.engine.tree import collect_tree_outputs @@ -830,13 +857,24 @@ def _ensure_images(project: Path, *, runtime: str, force: bool = False) -> None: containerfile = project / spec_str tag = compute_image_tag(project_name, containerfile, project) - if image_exists_locally(tag, runtime=runtime) and not force: + if image_exists_locally(tag, runtime=runtime, project_path=project) and not force: + console.print(f"[dim]Cached[/dim] {spec_str} → {tag}") continue console.print( f"[cyan]Building[/cyan] {spec_str} → {tag} [dim](via {runtime})[/dim]" ) + tarball = tarball_path_for_tag(tag, project) try: - build_image(tag, containerfile, project, runtime=runtime) + if runtime in _DAEMONLESS_RUNTIMES: + # buildah writes the OCI tarball directly during build — no + # separate save step needed (and save_image_as_tarball would + # run ' save' which apptainer/singularity don't support). + build_image( + tag, containerfile, project, runtime=runtime, tarball_path=tarball + ) + else: + build_image(tag, containerfile, project, runtime=runtime) + save_image_as_tarball(tag, tarball, runtime=runtime) except ContainerBuildError as e: raise click.ClickException(str(e)) @@ -869,7 +907,7 @@ def export() -> None: @click.option( "--author", default=None, - help="Author override, e.g. \"Name \". Default: git config.", + help='Author override, e.g. "Name ". Default: git config.', ) @click.option( "--license", @@ -948,6 +986,63 @@ def export_wrroc_cmd( ) +# ============================================================================= +# lc launch +# ============================================================================= + + +@main.command("launch") +@click.argument("target") +@click.option( + "--reinstall", is_flag=True, default=False, help="Force reinstall of the harness layer." +) +def launch(target: str, reinstall: bool) -> None: + """Launch an interactive containerized environment for this project. + + \b + Targets: + claude Claude Code with lightcone-cli, buildah, and apptainer pre-installed. + mistral-vibe Mistral Vibe with lightcone-cli pre-installed. + opencode OpenCode with lightcone-cli pre-installed. + + \b + Example: + lc launch claude # first run builds the container (~5 min), then drops into Claude Code + lc launch claude --reinstall # force re-installation of the harness + """ + from lightcone.engine import launcher + from lightcone.engine.container import _DAEMONLESS_RUNTIMES, ContainerBuildError, load_runtime + + project = _project_root() + + try: + choice = load_runtime(project_path=project) + except ContainerBuildError as e: + raise click.ClickException(str(e)) + + if choice.runtime == "none": + raise click.ClickException( + "lc launch requires a host container runtime " + "(docker, podman, or podman-hpc); " + f"got {choice.runtime!r}. " + "Install docker or podman, or set container.runtime in " + "~/.lightcone/config.yaml." + ) + + if choice.runtime in _DAEMONLESS_RUNTIMES: + raise click.ClickException( + f"lc launch does not support the {choice.runtime!r} runtime. " + "Daemonless runtimes (apptainer, singularity) are used for recipe " + "execution inside the container, not for interactive launch sessions. " + "Install docker or podman, or set container.runtime to docker or podman " + "in ~/.lightcone/config.yaml." + ) + + try: + launcher.launch_target(target, choice=choice, project_root=project, reinstall=reinstall) + except ContainerBuildError as e: + raise click.ClickException(str(e)) + # Register eval subgroup (requires optional 'eval' extra) try: from lightcone.eval.cli import eval_group diff --git a/src/lightcone/engine/container.py b/src/lightcone/engine/container.py index bf114787..77eb7f56 100644 --- a/src/lightcone/engine/container.py +++ b/src/lightcone/engine/container.py @@ -51,7 +51,10 @@ #: podman would build images compute nodes can't read; then podman #: (rootless, no daemon); docker last, gated behind a ``docker info`` #: probe so a down daemon doesn't silently win over a healthy podman. -RUNTIMES: tuple[str, ...] = ("podman-hpc", "podman", "docker") +RUNTIMES: tuple[str, ...] = ("podman-hpc", "podman", "docker", "apptainer", "singularity") + +#: Runtimes that don't have a daemon and use buildah for builds + OCI tarballs for storage. +_DAEMONLESS_RUNTIMES: frozenset[str] = frozenset({"apptainer", "singularity"}) #: Files whose contents contribute to the image tag hash. DEPENDENCY_FILES = ( @@ -161,6 +164,9 @@ def detect_runtime() -> str | None: continue if runtime == "docker" and not _docker_daemon_up(): continue + # Daemonless runtimes need buildah for image builds. + if runtime in _DAEMONLESS_RUNTIMES and shutil.which("buildah") is None: + continue return runtime return None @@ -474,8 +480,24 @@ def is_containerfile(spec: str, project_path: Path) -> bool: # --------------------------------------------------------------------------- -def image_exists_locally(tag: str, *, runtime: str) -> bool: - """Check whether *tag* exists in the runtime's local image store.""" +def image_exists_locally( + tag: str, + *, + runtime: str, + project_path: Path | None = None, +) -> bool: + """Check whether *tag* exists locally. + + For docker/podman/podman-hpc: queries the runtime's image store. + For apptainer/singularity: checks whether the OCI tarball exists at + ``tarball_path_for_tag(tag, project_path)``. Returns ``False`` when + *project_path* is not provided (conservative — callers that know the + path should pass it). + """ + if runtime in _DAEMONLESS_RUNTIMES: + if project_path is None: + return False + return tarball_path_for_tag(tag, project_path).exists() if runtime == "podman-hpc": return image_exists_podman_hpc(tag) try: @@ -550,12 +572,15 @@ def build_image( *, runtime: str, build_args: dict[str, str] | None = None, + tarball_path: Path | None = None, ) -> ContainerBuildResult: """Build a container image with the given *runtime*. The build context is staged into a fresh tempdir before invocation (see :func:`_populate_build_context`). For ``podman-hpc``, the image is automatically migrated after build so compute nodes can access it. + For ``apptainer``/``singularity``, uses ``buildah`` to build and push + an OCI tarball to *tarball_path*. Raises :class:`ContainerBuildError` on failure. """ @@ -564,6 +589,51 @@ def build_image( f"Unsupported build runtime {runtime!r}; expected one of {RUNTIMES}." ) + if runtime in _DAEMONLESS_RUNTIMES: + if tarball_path is None: + raise ContainerBuildError( + f"tarball_path is required when building with the {runtime!r} runtime. " + "Pass the desired .tar output path." + ) + tarball_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="lc-build-") as staged_str: + staged = Path(staged_str) + _populate_build_context(staged, containerfile, context) + staged_cf = staged / containerfile.name + try: + build_proc = subprocess.run( + [ + "buildah", "build", + "--format=oci", + f"--tag={tag}", + "-f", str(staged_cf), + str(staged), + ], + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + raise ContainerBuildError( + "buildah is not installed or not on PATH. " + f"Install buildah to build container images with {runtime!r}." + ) + if build_proc.returncode != 0: + raise ContainerBuildError( + f"buildah build failed (exit {build_proc.returncode}):\n{build_proc.stderr}" + ) + push_proc = subprocess.run( + ["buildah", "push", tag, f"oci-archive:{tarball_path}"], + capture_output=True, + text=True, + check=False, + ) + if push_proc.returncode != 0: + raise ContainerBuildError( + f"buildah push failed (exit {push_proc.returncode}):\n{push_proc.stderr}" + ) + return ContainerBuildResult(tag=tag, already_existed=False) + with tempfile.TemporaryDirectory(prefix="lc-build-") as staged_str: staged = Path(staged_str) _populate_build_context(staged, containerfile, context) @@ -582,8 +652,11 @@ def build_image( ) if proc.returncode != 0: + # Include stdout too: build-step failures (e.g. pip errors) are + # written to the build log (stdout), not just stderr. + detail = "\n".join(filter(None, [proc.stdout.strip(), proc.stderr.strip()])) raise ContainerBuildError( - f"{runtime} build failed (exit code {proc.returncode}):\n{proc.stderr}" + f"{runtime} build failed (exit code {proc.returncode}):\n{detail}" ) if runtime == "podman-hpc": @@ -608,6 +681,11 @@ def pull_image(image: str, *, runtime: str) -> None: Raises :class:`ContainerBuildError` on failure or if *runtime* isn't on PATH. """ + if runtime in _DAEMONLESS_RUNTIMES: + raise ContainerBuildError( + f"pull_image is not supported for the {runtime!r} runtime. " + "Use a Containerfile instead of a registry image reference." + ) if runtime not in RUNTIMES: raise ContainerBuildError( f"Unsupported runtime {runtime!r}; expected one of {RUNTIMES}." @@ -650,6 +728,60 @@ def _podman_hpc_migrate(tag: str) -> None: logger.info("podman-hpc migrate %s succeeded.", tag) +# --------------------------------------------------------------------------- +# OCI tarball helpers +# --------------------------------------------------------------------------- + + +def tarball_path_for_tag(tag: str, project_path: Path) -> Path: + """Return the canonical OCI tarball path for *tag* in *project_path*.""" + return project_path / ".lightcone" / "images" / f"{tag}.tar" + + +def save_image_as_tarball(tag: str, tarball_path: Path, *, runtime: str) -> None: + """Export *tag* from the runtime's image store to an OCI tarball. + + Creates parent directories as needed. Raises :class:`ContainerBuildError` + on failure. The tarball is deleted on error to avoid leaving a partial file. + """ + tarball_path.parent.mkdir(parents=True, exist_ok=True) + try: + with tarball_path.open("wb") as fout: + result = subprocess.run( + [runtime, "save", tag], + stdout=fout, + stderr=subprocess.PIPE, + check=False, + ) + except Exception as exc: + tarball_path.unlink(missing_ok=True) + raise ContainerBuildError(f"{runtime} save {tag} failed: {exc}") from exc + if result.returncode != 0: + tarball_path.unlink(missing_ok=True) + raise ContainerBuildError( + f"{runtime} save {tag} failed (exit {result.returncode}): " + f"{result.stderr.decode(errors='replace')}" + ) + + +def load_image_from_tarball(tarball_path: Path, *, runtime: str) -> None: + """Load an OCI tarball into the runtime's local image store. + + Raises :class:`ContainerBuildError` on failure. + """ + result = subprocess.run( + [runtime, "load", "-qi", str(tarball_path)], + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise ContainerBuildError( + f"{runtime} load from {tarball_path} failed " + f"(exit {result.returncode}): " + f"{result.stderr.decode(errors='replace')}" + ) + + # --------------------------------------------------------------------------- # Run-time recipe wrap # --------------------------------------------------------------------------- @@ -727,6 +859,10 @@ def wrap_recipe( """ if image is None or runtime == "none": return recipe + if runtime in _DAEMONLESS_RUNTIMES: + tarball = f".lightcone/images/{image}.tar" + inner = shlex.quote(recipe) + return f"{runtime} exec oci-archive:{tarball} bash -c {inner}" if runtime not in RUNTIMES: raise ContainerBuildError( f"Unsupported run runtime {runtime!r}; expected one of {RUNTIMES} or 'none'." diff --git a/src/lightcone/engine/launcher.py b/src/lightcone/engine/launcher.py new file mode 100644 index 00000000..6d245e79 --- /dev/null +++ b/src/lightcone/engine/launcher.py @@ -0,0 +1,633 @@ +"""Backend for `lc launch ` — interactive containerized environments. + +``lc launch claude`` detects the host runtime, builds (or reuses) a cached +Claude Code environment container, and exec's into it interactively with +the project directory mounted at the same absolute path. + +Inside the container: + - ``lc build`` uses ``buildah`` to produce OCI tarballs. + - ``lc run`` wraps recipes with ``apptainer exec oci-archive:``. + - ``LIGHTCONE_CONTAINER=1`` is set so commands know they're sandboxed. +""" +from __future__ import annotations + +import os +import re +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from uuid import uuid4 + +from rich.console import Console + +from lightcone.engine.container import ( + _DAEMONLESS_RUNTIMES, + ContainerBuildError, + RuntimeChoice, + build_image, + compute_image_tag, + image_exists_locally, + load_image_from_tarball, + pull_image, + save_image_as_tarball, + tarball_path_for_tag, +) +from lightcone.engine.manifest import lc_version as _lc_version + +# Registry where pre-built release images are published. +_GHCR_PREFIX = "ghcr.io/lightconeresearch" + +_console = Console(stderr=True) + +# Local image name for the shared sandbox base image. +_SANDBOX_IMAGE_NAME = "lightcone-sandbox" + + +def _package_containers_dir() -> Path: + """Return the path to the bundled ``containers/`` directory. + + Two layouts are supported: + + * **Editable install** (``uv sync`` / ``pip install -e .``): the package + source lives at ``src/lightcone/engine/launcher.py`` inside the project + tree, so climbing four parents reaches the project root, then + ``claude/lightcone/containers/`` is a sibling directory. + + * **Wheel install** (``pip install lightcone-cli`` or any regular install): + ``pyproject.toml`` uses ``force-include`` to bundle the plugin directory + as ``lightcone/cli/claude/lightcone/``, so ``containers/`` sits two + parents above this file inside site-packages. + """ + candidates = [ + # Wheel install: site-packages/lightcone/engine/launcher.py + # → site-packages/lightcone/cli/claude/lightcone/containers/ + Path(__file__).parent.parent / "cli" / "claude" / "lightcone" / "containers", + # Editable install: src/lightcone/engine/launcher.py + # → /claude/lightcone/containers/ + Path(__file__).parent.parent.parent.parent / "claude" / "lightcone" / "containers", + ] + for c in candidates: + if c.is_dir(): + return c + raise ContainerBuildError( + "Could not locate the bundled containers directory. " + "Is lightcone-cli installed correctly?" + ) + + +@dataclass(frozen=True) +class LaunchTarget: + """Descriptor for a named interactive container environment.""" + + name: str + containerfile: Path + entrypoint: list[str] + env_passthrough: list[str] = field(default_factory=list) + devices: list[str] = field(default_factory=list) + #: Sub-paths of ``$HOME`` to bind-mount at the same absolute path inside + #: the container. Entries ending with ``/`` are directories; others are + #: files. Missing paths are created automatically before mounting. + home_mounts: list[str] = field(default_factory=list) + #: When True, pass ``--user :`` so the container process runs as + #: the calling user rather than root. Required for tools (e.g. Claude Code) + #: that refuse ``--dangerously-skip-permissions`` under root. + run_as_host_user: bool = False + #: Override the GHCR image name used for pull-first. Defaults to ``name`` + #: when None. Needed when the published image name differs from the target + #: name (e.g. all harnesses share the base ``"lightcone-sandbox"`` image). + registry_name: str | None = None + #: Shell commands run inside the base image to install the harness. + #: Joined with `` && `` and passed to ``sh -c``. + install_cmds: list[str] = field(default_factory=list) + #: Prefix for the local committed image tag, e.g. ``"lightcone-claude"``. + #: Full tag: ``:``. + committed_tag_prefix: str = "" + + +#: Set when _make_builtin_targets() catches a ContainerBuildError so +#: resolve_launch_target can surface a helpful installation error instead of +#: the misleading "Available: (none)" message. +_builtin_targets_error: str | None = None + + +def _make_builtin_targets() -> dict[str, LaunchTarget]: + global _builtin_targets_error + try: + containers_dir = _package_containers_dir() + except ContainerBuildError as e: + _builtin_targets_error = str(e) + return {} + return { + "claude": LaunchTarget( + name="claude", + containerfile=containers_dir / f"{_SANDBOX_IMAGE_NAME}.Containerfile", + entrypoint=["claude", "--dangerously-skip-permissions"], + env_passthrough=[ + "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", + "CLAUDE_CODE_OAUTH_TOKEN", + "HOME", + "TERM", + ], + devices=["/dev/fuse"], + home_mounts=[ + ".claude.json", + ".claude/settings.json", + ".claude/settings.local.json", + ".claude/keybindings.json", + ".claude/session-env/", + ], + # Claude Code refuses --dangerously-skip-permissions as root; + # running as the host UID/GID also ensures correct ownership on + # the mounted project directory. + run_as_host_user=True, + registry_name=_SANDBOX_IMAGE_NAME, + install_cmds=[ + "npm install -g @anthropic-ai/claude-code --loglevel=error --no-update-notifier" + ], + committed_tag_prefix="lightcone-claude", + ), + "mistral-vibe": LaunchTarget( + name="mistral-vibe", + containerfile=containers_dir / f"{_SANDBOX_IMAGE_NAME}.Containerfile", + entrypoint=["vibe"], + env_passthrough=["MISTRAL_API_KEY"], + home_mounts=[ + ".vibe/config.toml", + ".vibe/agents/", + ".vibe/prompts/", + ".vibe/skills/", + ".vibe/tools/", + ], + registry_name=_SANDBOX_IMAGE_NAME, + install_cmds=["uv tool install mistral-vibe"], + committed_tag_prefix="lightcone-mistral-vibe", + ), + "opencode": LaunchTarget( + name="opencode", + containerfile=containers_dir / f"{_SANDBOX_IMAGE_NAME}.Containerfile", + entrypoint=["opencode"], + env_passthrough=[ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "MISTRAL_API_KEY", + "GEMINI_API_KEY", + "GROQ_API_KEY", + ], + home_mounts=[ + ".config/opencode/opencode.json", + ".config/opencode/tui.json", + ".config/opencode/agents/", + ".config/opencode/commands/", + ".config/opencode/modes/", + ".config/opencode/plugins/", + ".config/opencode/themes/", + ".config/opencode/AGENTS.md", + ], + registry_name=_SANDBOX_IMAGE_NAME, + install_cmds=["npm install -g opencode-ai --loglevel=error --no-update-notifier"], + committed_tag_prefix="lightcone-opencode", + ), + } + + +BUILTIN_TARGETS: dict[str, LaunchTarget] = _make_builtin_targets() + + +def resolve_launch_target(name: str, project_root: Path | None = None) -> LaunchTarget: + """Return the :class:`LaunchTarget` for *name*. + + Checks built-in targets first, then ``.lightcone/launch/.yaml`` + for future project-local targets (not yet implemented; *project_root* + is accepted now so call sites don't need updating when it is). + + Raises :class:`ContainerBuildError` if the target is unknown. + """ + if name in BUILTIN_TARGETS: + return BUILTIN_TARGETS[name] + if not BUILTIN_TARGETS and _builtin_targets_error: + raise ContainerBuildError( + f"Unknown launch target {name!r}: built-in targets are unavailable. " + f"{_builtin_targets_error}" + ) + raise ContainerBuildError( + f"Unknown launch target {name!r}. " + f"Available: {', '.join(BUILTIN_TARGETS) or '(none)'}" + ) + + +# Matches ``ARG LIGHTCONE_VERSION`` with or without a trailing ``=``, +# so we handle both the bare form and an existing default. +_ARG_VERSION_RE = re.compile(r"^ARG LIGHTCONE_VERSION(=[^\n]*)?\n", re.MULTILINE) + +# Matches the comment + RUN block that handles lightcone-cli installation. +# Present only in Containerfiles that use the dev-wheel fallback pattern. +# Intentionally coupled to lightcone-sandbox.Containerfile's specific install block +# shape: the pattern ends at the first `esac\n`, so inserting a second case +# statement before this block would truncate the match — update the anchor if +# the Containerfile layout changes. +_LIGHTCONE_INSTALL_RE = re.compile(r"# Dev/local builds.*?esac\n", re.DOTALL) + + +def _is_dev_version(version: str) -> bool: + """Return True if *version* is a dev/local build not published to PyPI.""" + return version == "dev" or ".dev" in version or "+" in version + + +def _find_source_root() -> Path | None: + """Return the lightcone-cli project root for editable installs, or None. + + Two discovery strategies are tried in order: + + 1. **``LIGHTCONE_SRC`` environment variable** — set this to the project + root when running ``lc`` from a ``uv tool install`` (non-editable). + Must contain ``pyproject.toml``; silently ignored otherwise. + + 2. **Editable-install heuristic** — for ``uv sync`` / ``pip install -e`` + the layout is ``/src/lightcone/engine/launcher.py`` so + ``Path(__file__).parents[3]`` is the project root. For a regular + installed package the same path resolves to a site-packages parent, + which won't contain ``pyproject.toml`` — in that case we return + ``None``. + """ + env_src = os.environ.get("LIGHTCONE_SRC") + if env_src: + candidate = Path(env_src) + if (candidate / "pyproject.toml").exists(): + return candidate + + candidate = Path(__file__).parents[3] + if (candidate / "pyproject.toml").exists(): + return candidate + return None + + +def _build_dev_wheel(dest_dir: Path) -> Path | None: + """Build a wheel from the current source tree into *dest_dir*. + + Used when the running version is a dev/PR build that is not on PyPI so + ``uv pip install lightcone-cli==`` would fail. The wheel + is placed in the Containerfile build context so the ``COPY`` step can + pick it up, and its contents feed into the image tag hash — meaning the + container auto-rebuilds whenever the local source changes. + + If a wheel for the *current* version already exists in *dest_dir* it is + returned immediately without rebuilding. This is important: wheel builds + embed zip timestamps, so running ``uv build`` twice on unchanged source + produces different bytes → a different context hash → a different image + tag → a spurious full container rebuild on every ``lc launch``. + + Returns the wheel :class:`Path`, or ``None`` if the source root cannot be + located (non-editable install) or the build fails. + """ + src_root = _find_source_root() + if src_root is None: + return None + + version = _lc_version() + # Reuse a wheel that was already built for this exact version. + existing = sorted(dest_dir.glob(f"lightcone_cli-{version}-*.whl")) + if existing: + return existing[-1] + + # No matching wheel — remove stale wheels from other versions, then build. + for old in dest_dir.glob("lightcone_cli-*.whl"): + old.unlink(missing_ok=True) + try: + subprocess.run( + ["uv", "build", "--wheel", "--out-dir", str(dest_dir)], + cwd=src_root, + check=True, + capture_output=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return None + wheels = sorted(dest_dir.glob("lightcone_cli-*.whl")) + return wheels[-1] if wheels else None + + +def _render_containerfile(target: LaunchTarget, project_root: Path) -> Path: + """Write a rendered copy of the target's Containerfile to .lightcone/containers/. + + Substitutes ``ARG LIGHTCONE_VERSION`` (bare or with a default) with + ``ARG LIGHTCONE_VERSION=`` so the content hash — and therefore + the image tag — changes when ``lc`` is upgraded. + + For dev/PR builds (version not on PyPI), if the Containerfile contains the + lightcone install block and the source tree is reachable, a wheel is built + from the local source and the install block is replaced with a direct + ``COPY /tmp/ + uv pip install`` so the exact in-development code + ends up inside the container. If the wheel build fails the existing + case-based fallback (latest stable from PyPI) is preserved. + """ + dest_dir = project_root / ".lightcone" / "containers" + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / target.containerfile.name + + version = _lc_version() + content = target.containerfile.read_text() + content = _ARG_VERSION_RE.sub(f"ARG LIGHTCONE_VERSION={version}\n", content) + + if _is_dev_version(version) and _LIGHTCONE_INSTALL_RE.search(content): + wheel = _build_dev_wheel(dest_dir) + if wheel is not None: + # Install the dev wheel directly. uv resolves all current deps + # from PyPI; the system Python (apt-installed python3) is the + # target, so pre-built manylinux_2_17 wheels are accepted without + # any source compilation. + wheel_block = ( + f"COPY {wheel.name} /tmp/{wheel.name}\n" + f"RUN uv pip install --system --break-system-packages /tmp/{wheel.name}\n" + ) + content = _LIGHTCONE_INSTALL_RE.sub(wheel_block, content) + + dest.write_text(content) + return dest + + +def _registry_image_ref(target_name: str, version: str) -> str: + """Return the GHCR image reference for a published release of *target_name*. + + Pattern: ``ghcr.io/lightconeresearch/:`` + """ + return f"{_GHCR_PREFIX}/{target_name}:{version}" + + +def _tracking_image_ref(project_root: Path, version: str) -> str: + """Return the human-readable image ref for ``docker/podman images`` visibility. + + Format: ``lightcone-:`` + """ + return f"lightcone-{project_root.name}:{version}" + + +def _apply_tracking_tag(content_tag: str, tracking_ref: str, runtime: str) -> None: + """Tag *content_tag* with *tracking_ref* for human-readable image listings. + + Failure is silently swallowed — the tracking tag is cosmetic and must not + block the launch. + """ + try: + subprocess.run( + [runtime, "tag", content_tag, tracking_ref], + check=True, + capture_output=True, + ) + except (subprocess.CalledProcessError, OSError): + pass + + +def _try_pull_and_cache( + tag: str, + registry_ref: str, + tarball: Path, + *, + runtime: str, +) -> bool: + """Attempt to pull *registry_ref* from GHCR and cache it locally. + + On success the pulled image is retagged to the content-addressed *tag*, + saved as *tarball*, and ``True`` is returned. Any failure (network + unavailable, image not published, unsupported runtime) silently returns + ``False`` so the caller can fall back to a local build. + + Daemonless runtimes (apptainer, singularity) cannot pull registry images + directly; this function returns ``False`` immediately for them. + """ + if runtime in _DAEMONLESS_RUNTIMES: + return False + try: + with _console.status(f"Pulling [bold]{registry_ref}[/] from registry…"): + pull_image(registry_ref, runtime=runtime) + # Retag to the content-addressed local tag so the rest of the launch + # pipeline (image_exists_locally, _exec_interactive) works unchanged. + subprocess.run( + [runtime, "tag", registry_ref, tag], + check=True, + capture_output=True, + ) + save_image_as_tarball(tag, tarball, runtime=runtime) + return True + except (ContainerBuildError, subprocess.CalledProcessError, OSError): + _print("Registry pull failed — falling back to local build.") + return False + + +def _image_exists(tag: str, runtime: str) -> bool: + """Return True if *tag* exists in the local image store.""" + try: + result = subprocess.run( + [runtime, "image", "inspect", tag], + capture_output=True, + ) + return result.returncode == 0 + except OSError: + return False + + +def _ensure_harness_image( + target: LaunchTarget, + base_image: str, + runtime: str, + lc_version: str, + reinstall: bool = False, +) -> str: + """Return the local committed image tag for *target*, installing if absent. + + On first call (or when *reinstall* is True): spins up *base_image* in a + temporary container, runs ``target.install_cmds`` inside it, commits the + result as ``:``, and removes the temp + container. On subsequent calls the existing committed image is reused. + """ + # OCI tags allow [a-zA-Z0-9_.-] only — replace '+' from PEP 440 local versions. + safe_version = lc_version.replace("+", "-") + committed_tag = f"{target.committed_tag_prefix}:{safe_version}" + + if not reinstall and _image_exists(committed_tag, runtime): + return committed_tag + + if reinstall: + # Remove the old committed image so docker commit doesn't leave a dangling layer. + subprocess.run([runtime, "rmi", committed_tag], check=False, capture_output=True) + + tmp_name = f"lc-install-{target.name}-{uuid4().hex[:8]}" + install_cmd = " && ".join(target.install_cmds) + try: + with _console.status(f"Installing [bold]{target.name}[/] harness…"): + result = subprocess.run( + [runtime, "run", "--entrypoint", "sh", "--name", tmp_name, + base_image, "-c", install_cmd], + capture_output=True, + text=True, + ) + if result.returncode != 0: + if result.stderr: + _console.print(result.stderr.strip(), style="red") + raise subprocess.CalledProcessError(result.returncode, result.args) + subprocess.run( + [runtime, "commit", tmp_name, committed_tag], + check=True, + capture_output=True, + ) + except subprocess.CalledProcessError as exc: + raise ContainerBuildError(f"Harness install failed for {target.name}: {exc}") from exc + finally: + subprocess.run([runtime, "rm", "-f", tmp_name], check=False, capture_output=True) + + return committed_tag + + +def launch_target( + name: str, + *, + choice: RuntimeChoice, + project_root: Path, + reinstall: bool = False, +) -> None: + """Build (if needed) and exec the named launch target interactively. + + For non-dev versions, tries to pull the pre-built image from GHCR before + falling back to a local build. Replaces the current process via + ``os.execvp`` — this function does not return on success. + """ + target = resolve_launch_target(name, project_root) + + rendered_cf = _render_containerfile(target, project_root) + tag = compute_image_tag(target.name, rendered_cf, rendered_cf.parent) + tarball = tarball_path_for_tag(tag, project_root) + + if not tarball.exists(): + version = _lc_version() + pulled = False + if not _is_dev_version(version): + registry_ref = _registry_image_ref(target.registry_name or target.name, version) + pulled = _try_pull_and_cache(tag, registry_ref, tarball, runtime=choice.runtime) + if not pulled: + with _console.status(f"Building [bold]{name}[/] container…"): + build_image(tag, rendered_cf, rendered_cf.parent, runtime=choice.runtime) + save_image_as_tarball(tag, tarball, runtime=choice.runtime) + + if not image_exists_locally(tag, runtime=choice.runtime, project_path=project_root): + load_image_from_tarball(tarball, runtime=choice.runtime) + + if target.committed_tag_prefix: + tag = _ensure_harness_image( + target, + base_image=tag, + runtime=choice.runtime, + lc_version=_lc_version(), + reinstall=reinstall, + ) + + _apply_tracking_tag(tag, _tracking_image_ref(project_root, _lc_version()), choice.runtime) + + _exec_interactive(target, tag, choice, project_root) + + +def _ensure_host_path(path: Path, *, is_dir: bool) -> None: + """Create *path* on the host if it does not exist. + + Entries in ``home_mounts`` ending with ``/`` are directories; all others + are files. Creating the path before bind-mounting prevents Docker/Podman + from silently creating a directory when the intended target is a file. + """ + if path.exists(): + return + path.parent.mkdir(parents=True, exist_ok=True) + if is_dir: + path.mkdir(parents=True, exist_ok=True) + else: + path.touch() + + +def _exec_interactive( + target: LaunchTarget, + tag: str, + choice: RuntimeChoice, + project_root: Path, +) -> None: + """Build the docker/podman run command and exec it, replacing this process.""" + project_abs = str(project_root.resolve()) + + cmd: list[str] = [choice.runtime, "run", "--rm", "-it"] + cmd += ["-v", f"{project_abs}:{project_abs}", "-w", project_abs] + + for var in target.env_passthrough: + if var in os.environ: + # Pass only the name so docker/podman inherit the value from the + # current process environment — avoids embedding secrets in the + # argv list where /proc//cmdline is world-readable. + cmd += ["-e", var] + + home = os.environ.get("HOME") + if home: + for subpath in target.home_mounts: + is_dir = subpath.endswith("/") + host_path = Path(home) / subpath.rstrip("/") + _ensure_host_path(host_path, is_dir=is_dir) + path_str = str(host_path) + cmd += ["-v", f"{path_str}:{path_str}"] + + for device in target.devices: + if Path(device).exists(): + cmd += ["--device", device] + + if target.run_as_host_user: + # Map the container process to the host UID/GID so files written to + # the bind-mounted project belong to the user (not to a remapped + # subuid) and so Claude Code doesn't see itself running as root. + # + # docker (rootful, the common case) accepts --user directly. + # podman (rootless) needs --userns=keep-id, the rootless equivalent + # that builds an idmap preserving the host UID inside the container. + # podman-hpc (NERSC) cannot use --userns=keep-id with a real TTY: + # the combination triggers + # crun: open .../merged: Permission denied + # during rootfs setup (TTY device creation in the fuse-overlayfs + # remapped merged dir fails). podman-hpc is rootless and already + # runs as the host UID externally, so for our purposes we leave the + # container's internal UID at 0 and rely on rootless mapping for + # bind-mount ownership. + if choice.runtime == "podman": + cmd += ["--userns=keep-id"] + elif choice.runtime == "podman-hpc": + pass # see comment above + else: + cmd += ["--user", f"{os.getuid()}:{os.getgid()}"] + + if target.entrypoint: + cmd += ["--entrypoint", target.entrypoint[0]] + + cmd.append(tag) + + # On podman-hpc we cannot use --userns=keep-id (see above), so the + # container runs as UID 0. Claude Code rejects + # --dangerously-skip-permissions when invoked as root, so drop the + # flag — the user will see the folder-trust prompt once and accept + # it manually. + entrypoint_args = target.entrypoint[1:] + if choice.runtime == "podman-hpc": + entrypoint_args = [ + a for a in entrypoint_args if a != "--dangerously-skip-permissions" + ] + cmd.extend(entrypoint_args) + + if os.environ.get("LIGHTCONE_LAUNCH_DEBUG"): + _print(f"[lc launch debug] {' '.join(cmd)}") + + from rich.panel import Panel + + _console.print( + Panel( + "Run [bold cyan]/lc-new[/] to scaffold a new project and get started.", + title="[bold]lightcone sandbox[/]", + border_style="cyan", + expand=False, + ) + ) + + os.execvp(cmd[0], cmd) + + +def _print(msg: str) -> None: + _console.print(msg) diff --git a/src/lightcone/engine/manifest.py b/src/lightcone/engine/manifest.py index 27749fbb..7950ad9e 100644 --- a/src/lightcone/engine/manifest.py +++ b/src/lightcone/engine/manifest.py @@ -43,12 +43,23 @@ "SCHEMA_VERSION", "code_version", "fingerprint_external", + "lc_version", "read_manifest", "sha256_dir", "write_manifest", ] +def lc_version() -> str: + """Return the installed lightcone-cli version string, or ``'dev'`` if unavailable.""" + try: + from importlib.metadata import version + + return version("lightcone-cli") + except Exception: + return "dev" + + def _hash_file(path: Path, h: hashlib._Hash) -> None: with open(path, "rb") as f: for chunk in iter(lambda: f.read(64 * 1024), b""): diff --git a/tests/test_cli.py b/tests/test_cli.py index 5f5f0053..fddf6608 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -18,6 +18,7 @@ def runner() -> CliRunner: @pytest.fixture(autouse=True) + def _isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Redirect ``~/.lightcone/`` to a temp dir so tests don't pollute the user's real config. The global config is auto-created on first ``lc`` invocation.""" @@ -33,7 +34,8 @@ def _isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: def test_help_lists_core_commands(runner: CliRunner) -> None: result = runner.invoke(main, ["--help"]) assert result.exit_code == 0 - for cmd in ("init", "run", "status", "verify", "build"): + + for cmd in ("init", "launch", "run", "status", "verify", "build"): assert cmd in result.output @@ -41,6 +43,7 @@ def test_help_does_not_advertise_removed_commands(runner: CliRunner) -> None: result = runner.invoke(main, ["--help"]) assert " dev " not in result.output assert " cluster " not in result.output + assert " setup " not in result.output @@ -144,6 +147,7 @@ def test_verify_clean_project_returns_zero( assert result.exit_code == 0 + # ---- lc run command building ------------------------------------------------ @@ -195,6 +199,37 @@ def test_run_cmd_no_separator_when_no_targets() -> None: assert "--" not in cmd +# ---- lc launch --reinstall ----------------------------------------------- + + +def test_launch_reinstall_forwarded_to_launch_target( + runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """--reinstall must be forwarded as reinstall=True to launch_target().""" + from unittest.mock import patch + + from lightcone.engine.container import RuntimeChoice + + project = tmp_path / "proj" + project.mkdir() + (project / "astra.yaml").write_text("name: test\n") + (project / ".lightcone").mkdir() + monkeypatch.chdir(project) + + choice = RuntimeChoice(runtime="docker", explicit=True) + + with patch("lightcone.engine.container.load_runtime", return_value=choice): + with patch("lightcone.engine.launcher.launch_target") as mock_launch_target: + runner.invoke(main, ["launch", "claude", "--reinstall"]) + + # launch_target should have been called with the correct target and reinstall=True + assert mock_launch_target.called + args, kwargs = mock_launch_target.call_args + assert args == ("claude",) + assert kwargs.get("reinstall") is True + assert kwargs.get("project_root") is not None + + def test_run_cmd_multiple_triggers_all_before_separator() -> None: """All four trigger tokens must precede the '--' separator.""" from lightcone.cli.commands import _build_snakemake_cmd diff --git a/tests/test_container.py b/tests/test_container.py index 2fb10a74..bce912d2 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -46,8 +46,6 @@ def project_with_deps(project: Path) -> Path: # ---- find_dependency_files / compute_image_tag ---------------------------- - - class TestFindDependencyFiles: def test_finds_requirements_txt(self, project: Path) -> None: (project / "requirements.txt").write_text("numpy\n") @@ -177,8 +175,6 @@ def test_swap_dep_file_names_not_collision(self, project: Path) -> None: # ---- image_exists_locally / image_exists_podman_hpc ----------------------- - - class TestImageExistsLocally: @patch("lightcone.engine.container.subprocess.run") def test_docker_exists(self, mock_run: MagicMock) -> None: @@ -219,8 +215,6 @@ def test_not_installed(self, mock_run: MagicMock) -> None: # ---- build_image ---------------------------------------------------------- - - class TestBuildImage: @patch("lightcone.engine.container.subprocess.run") def test_docker_success(self, mock_run: MagicMock, project: Path) -> None: @@ -261,7 +255,8 @@ def test_runtime_missing_raises(self, mock_run: MagicMock, project: Path) -> Non def test_unsupported_runtime_raises(self, project: Path) -> None: with pytest.raises(ContainerBuildError, match="Unsupported build runtime"): build_image( - "lc-test", project / "Containerfile", project, runtime="apptainer" + + "lc-test", project / "Containerfile", project, runtime="noop" ) @patch("lightcone.engine.container.subprocess.run") @@ -357,8 +352,6 @@ def test_build_cleans_stage_on_failure( # ---- pull_image ----------------------------------------------------------- - - class TestPullImage: @patch("lightcone.engine.container.subprocess.run") def test_pull_success_docker(self, mock_run: MagicMock) -> None: @@ -389,13 +382,14 @@ def test_pull_failure_raises(self, mock_run: MagicMock) -> None: pull_image("python:3.12-slim", runtime="docker") def test_unsupported_runtime_raises(self) -> None: - with pytest.raises(ContainerBuildError, match="Unsupported runtime"): + + with pytest.raises( + ContainerBuildError, match="not supported for the 'apptainer' runtime" + ): pull_image("img", runtime="apptainer") # ---- detect_runtime / load_runtime --------------------------------------- - - class TestDetectRuntime: @pytest.fixture(autouse=True) def _generic_hostname(self) -> Iterator[None]: @@ -440,6 +434,7 @@ def test_docker_skipped_when_daemon_down(self, mock_which: MagicMock) -> None: def test_docker_daemon_down_falls_through_to_podman( self, mock_which: MagicMock ) -> None: + mock_which.side_effect = lambda name: ( None if name == "podman-hpc" else f"/usr/bin/{name}" ) @@ -452,50 +447,43 @@ def test_docker_daemon_down_falls_through_to_podman( def test_none_available(self, mock_which: MagicMock) -> None: assert detect_runtime() is None - def test_no_apptainer(self) -> None: - # Apptainer/singularity must NOT be in the supported runtimes list — - # we own container invocation and only support OCI runtimes. - assert "apptainer" not in RUNTIMES - assert "singularity" not in RUNTIMES + def test_apptainer_in_runtimes(self) -> None: + # apptainer is supported as a nested execution runtime (inside lc launch containers) + assert "apptainer" in RUNTIMES + + def test_singularity_in_runtimes(self) -> None: + assert "singularity" in RUNTIMES -class TestSiteAwareDetection: @patch("lightcone.engine.container.shutil.which") - @patch( - "lightcone.engine.site_registry.socket.gethostname", - return_value="login29.chn.perlmutter.nersc.gov", - ) - def test_perlmutter_picks_podman_hpc( - self, _hostname: MagicMock, mock_which: MagicMock + def test_apptainer_detected_when_buildah_present( + self, mock_which: MagicMock ) -> None: - mock_which.side_effect = lambda name: f"/usr/bin/{name}" - assert detect_runtime() == "podman-hpc" + # Only apptainer + buildah on PATH — should be selected. + mock_which.side_effect = lambda name: ( + f"/usr/bin/{name}" if name in ("apptainer", "buildah") else None + ) + assert detect_runtime() == "apptainer" @patch("lightcone.engine.container.shutil.which") - @patch( - "lightcone.engine.site_registry.socket.gethostname", - return_value="login29.chn.perlmutter.nersc.gov", - ) - def test_falls_through_when_site_runtime_missing( - self, _hostname: MagicMock, mock_which: MagicMock + def test_singularity_detected_when_buildah_present( + self, mock_which: MagicMock ) -> None: - # Site preference is a hint — explicit user config goes through - # load_runtime, which DOES error on missing binary. + # Only singularity + buildah on PATH — should be selected. mock_which.side_effect = lambda name: ( - None if name == "podman-hpc" else f"/usr/bin/{name}" + f"/usr/bin/{name}" if name in ("singularity", "buildah") else None ) - assert detect_runtime() == "podman" + assert detect_runtime() == "singularity" @patch("lightcone.engine.container.shutil.which") - @patch( - "lightcone.engine.site_registry.socket.gethostname", - return_value="generic-laptop", - ) - def test_unknown_site_uses_default_order( - self, _hostname: MagicMock, mock_which: MagicMock + def test_daemonless_skipped_when_buildah_absent( + self, mock_which: MagicMock ) -> None: - mock_which.side_effect = lambda name: f"/usr/bin/{name}" - assert detect_runtime() == RUNTIMES[0] + # apptainer/singularity present but buildah absent → not selected. + mock_which.side_effect = lambda name: ( + f"/usr/bin/{name}" if name in ("apptainer", "singularity") else None + ) + assert detect_runtime() is None class TestLoadRuntime: @@ -544,11 +532,13 @@ def test_explicit_runtime_present( "lightcone.engine.container.shutil.which", lambda name: f"/usr/bin/{name}" if name == "podman" else None, ) + self._write_config(tmp_path, {"container": {"runtime": "podman"}}) choice = load_runtime() assert choice.runtime == "podman" assert choice.explicit is True + def test_explicit_runtime_missing_on_path_raises( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -564,14 +554,13 @@ def test_unknown_runtime_raises( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(Path, "home", lambda: tmp_path) - self._write_config(tmp_path, {"container": {"runtime": "apptainer"}}) + + self._write_config(tmp_path, {"container": {"runtime": "noop"}}) with pytest.raises(ContainerBuildError, match="Unknown container.runtime"): load_runtime() # ---- resolve_image_for_run ----------------------------------------------- - - class TestResolveImageForRun: def test_none_returns_none(self, project: Path) -> None: assert resolve_image_for_run( @@ -597,8 +586,6 @@ def test_containerfile_resolves_to_tag(self, project: Path) -> None: # ---- wrap_recipe ---------------------------------------------------------- - - class TestWrapRecipe: def test_no_image_passthrough(self) -> None: assert wrap_recipe("echo hi", image=None, runtime="podman") == "echo hi" @@ -654,7 +641,8 @@ def test_preserves_recipe_with_single_quotes(self) -> None: def test_unsupported_runtime_raises(self) -> None: with pytest.raises(ContainerBuildError, match="Unsupported run runtime"): - wrap_recipe("echo", image="img:v1", runtime="apptainer") + + wrap_recipe("echo", image="img:v1", runtime="noop") def test_bind_mounts_pwd(self) -> None: """Recipes that write to relative paths need $PWD bind-mounted.""" @@ -664,8 +652,6 @@ def test_bind_mounts_pwd(self) -> None: # ---- get_container_status ------------------------------------------------- - - class TestGetContainerStatus: def test_none(self, project: Path) -> None: s = get_container_status(None, project, "test", runtime="docker") @@ -700,11 +686,240 @@ def test_runtime_none_skips_existence_check(self, project: Path) -> None: # ---- is_containerfile ----------------------------------------------------- - - class TestIsContainerfile: def test_existing_file(self, project: Path) -> None: assert is_containerfile("Containerfile", project) is True def test_missing_file(self, project: Path) -> None: assert is_containerfile("python:3.12-slim", project) is False + + +# ---- tarball helpers ------------------------------------------------------- +class TestTarballPathForTag: + def test_canonical_path(self, tmp_path: Path) -> None: + from lightcone.engine.container import tarball_path_for_tag + + p = tarball_path_for_tag("lc-foo-abc123", tmp_path) + assert p == tmp_path / ".lightcone" / "images" / "lc-foo-abc123.tar" + + def test_different_tags_differ(self, tmp_path: Path) -> None: + from lightcone.engine.container import tarball_path_for_tag + + p1 = tarball_path_for_tag("lc-a-111111", tmp_path) + p2 = tarball_path_for_tag("lc-b-222222", tmp_path) + assert p1 != p2 + + +class TestSaveImageAsTarball: + @patch("lightcone.engine.container.subprocess.run") + def test_calls_runtime_save(self, mock_run: MagicMock, tmp_path: Path) -> None: + from lightcone.engine.container import save_image_as_tarball + + mock_run.return_value = MagicMock(returncode=0) + tarball = tmp_path / ".lightcone" / "images" / "lc-foo.tar" + save_image_as_tarball("lc-foo", tarball, runtime="docker") + cmd = mock_run.call_args[0][0] + assert cmd[0] == "docker" + assert cmd[1] == "save" + assert "lc-foo" in cmd + + @patch("lightcone.engine.container.subprocess.run") + def test_creates_parent_dir(self, mock_run: MagicMock, tmp_path: Path) -> None: + from lightcone.engine.container import save_image_as_tarball + + mock_run.return_value = MagicMock(returncode=0) + tarball = tmp_path / "deep" / "nested" / "img.tar" + save_image_as_tarball("lc-foo", tarball, runtime="podman") + assert tarball.parent.exists() + + @patch("lightcone.engine.container.subprocess.run") + def test_failure_raises(self, mock_run: MagicMock, tmp_path: Path) -> None: + from lightcone.engine.container import ContainerBuildError, save_image_as_tarball + + mock_run.return_value = MagicMock(returncode=1) + tarball = tmp_path / "img.tar" + with pytest.raises(ContainerBuildError, match="save"): + save_image_as_tarball("lc-foo", tarball, runtime="docker") + + +class TestLoadImageFromTarball: + @patch("lightcone.engine.container.subprocess.run") + def test_calls_runtime_load(self, mock_run: MagicMock, tmp_path: Path) -> None: + from lightcone.engine.container import load_image_from_tarball + + mock_run.return_value = MagicMock(returncode=0, stderr="") + tarball = tmp_path / "img.tar" + tarball.write_bytes(b"fake") + load_image_from_tarball(tarball, runtime="podman") + cmd = mock_run.call_args[0][0] + assert cmd[0] == "podman" + assert "load" in cmd + + @patch("lightcone.engine.container.subprocess.run") + def test_failure_raises(self, mock_run: MagicMock, tmp_path: Path) -> None: + from lightcone.engine.container import ContainerBuildError, load_image_from_tarball + + mock_run.return_value = MagicMock(returncode=1, stderr=b"bad tarball") + tarball = tmp_path / "img.tar" + tarball.write_bytes(b"fake") + with pytest.raises(ContainerBuildError, match="load"): + load_image_from_tarball(tarball, runtime="docker") + + +# ---- apptainer runtime support --------------------------------------------- +class TestApptainerRuntime: + def test_image_exists_locally_checks_tarball(self, tmp_path: Path) -> None: + from lightcone.engine.container import image_exists_locally, tarball_path_for_tag + + assert ( + image_exists_locally("lc-foo-abc", runtime="apptainer", project_path=tmp_path) is False + ) + tarball = tarball_path_for_tag("lc-foo-abc", tmp_path) + tarball.parent.mkdir(parents=True) + tarball.write_bytes(b"fake") + assert ( + image_exists_locally("lc-foo-abc", runtime="apptainer", project_path=tmp_path) is True + ) + + def test_image_exists_locally_no_project_path_returns_false(self) -> None: + from lightcone.engine.container import image_exists_locally + + assert image_exists_locally("lc-foo", runtime="apptainer") is False + + @patch("lightcone.engine.container.subprocess.run") + def test_build_image_apptainer_uses_buildah( + self, mock_run: MagicMock, tmp_path: Path + ) -> None: + from lightcone.engine.container import build_image + + (tmp_path / "Containerfile").write_text("FROM python:3.12-slim\n") + tarball = tmp_path / ".lightcone" / "images" / "lc-test.tar" + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = build_image( + "lc-test", + tmp_path / "Containerfile", + tmp_path, + runtime="apptainer", + tarball_path=tarball, + ) + assert result.tag == "lc-test" + # First call: buildah build + first_cmd = mock_run.call_args_list[0][0][0] + assert first_cmd[0] == "buildah" + assert "build" in first_cmd + # Second call: buildah push oci-archive: + second_cmd = mock_run.call_args_list[1][0][0] + assert second_cmd[0] == "buildah" + assert "push" in second_cmd + assert f"oci-archive:{tarball}" in " ".join(second_cmd) + + def test_build_image_apptainer_requires_tarball_path(self, tmp_path: Path) -> None: + from lightcone.engine.container import ContainerBuildError, build_image + + (tmp_path / "Containerfile").write_text("FROM python:3.12-slim\n") + with pytest.raises(ContainerBuildError, match="tarball_path is required"): + build_image( + "lc-test", + tmp_path / "Containerfile", + tmp_path, + runtime="apptainer", + ) + + def test_wrap_recipe_apptainer(self) -> None: + from lightcone.engine.container import wrap_recipe + + wrapped = wrap_recipe("echo hi", image="lc-foo-abc123", runtime="apptainer") + assert wrapped.startswith("apptainer exec oci-archive:") + assert "lc-foo-abc123.tar" in wrapped + assert shlex.quote("echo hi") in wrapped + + def test_wrap_recipe_apptainer_preserves_placeholders(self) -> None: + from lightcone.engine.container import wrap_recipe + + wrapped = wrap_recipe( + "python run.py --out {output[0]}", image="lc-foo", runtime="apptainer" + ) + assert "{output[0]}" in wrapped + + def test_pull_image_apptainer_raises(self) -> None: + from lightcone.engine.container import ContainerBuildError, pull_image + + with pytest.raises( + ContainerBuildError, match="not supported for the 'apptainer' runtime" + ): + pull_image("python:3.12-slim", runtime="apptainer") + + def test_image_exists_locally_singularity_checks_tarball( + self, tmp_path: Path + ) -> None: + from lightcone.engine.container import image_exists_locally, tarball_path_for_tag + + assert ( + image_exists_locally( + "lc-foo-abc", runtime="singularity", project_path=tmp_path + ) + is False + ) + tarball = tarball_path_for_tag("lc-foo-abc", tmp_path) + tarball.parent.mkdir(parents=True) + tarball.write_bytes(b"fake") + assert ( + image_exists_locally( + "lc-foo-abc", runtime="singularity", project_path=tmp_path + ) + is True + ) + + @patch("lightcone.engine.container.subprocess.run") + def test_build_image_singularity_uses_buildah( + self, mock_run: MagicMock, tmp_path: Path + ) -> None: + from lightcone.engine.container import build_image + + (tmp_path / "Containerfile").write_text("FROM python:3.12-slim\n") + tarball = tmp_path / ".lightcone" / "images" / "lc-test.tar" + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + result = build_image( + "lc-test", + tmp_path / "Containerfile", + tmp_path, + runtime="singularity", + tarball_path=tarball, + ) + assert result.tag == "lc-test" + first_cmd = mock_run.call_args_list[0][0][0] + assert first_cmd[0] == "buildah" + assert "build" in first_cmd + second_cmd = mock_run.call_args_list[1][0][0] + assert second_cmd[0] == "buildah" + assert "push" in second_cmd + + def test_build_image_singularity_requires_tarball_path( + self, tmp_path: Path + ) -> None: + from lightcone.engine.container import ContainerBuildError, build_image + + (tmp_path / "Containerfile").write_text("FROM python:3.12-slim\n") + with pytest.raises(ContainerBuildError, match="tarball_path is required"): + build_image( + "lc-test", + tmp_path / "Containerfile", + tmp_path, + runtime="singularity", + ) + + def test_wrap_recipe_singularity(self) -> None: + from lightcone.engine.container import wrap_recipe + + wrapped = wrap_recipe("echo hi", image="lc-foo-abc123", runtime="singularity") + assert wrapped.startswith("singularity exec oci-archive:") + assert "lc-foo-abc123.tar" in wrapped + assert shlex.quote("echo hi") in wrapped + + def test_pull_image_singularity_raises(self) -> None: + from lightcone.engine.container import ContainerBuildError, pull_image + + with pytest.raises( + ContainerBuildError, match="not supported for the 'singularity' runtime" + ): + pull_image("python:3.12-slim", runtime="singularity") diff --git a/tests/test_launcher.py b/tests/test_launcher.py new file mode 100644 index 00000000..55371e17 --- /dev/null +++ b/tests/test_launcher.py @@ -0,0 +1,1263 @@ +"""Tests for the lc launch backend.""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from lightcone.engine.container import ContainerBuildError, RuntimeChoice +from lightcone.engine.launcher import ( + BUILTIN_TARGETS, + LaunchTarget, + _apply_tracking_tag, + _build_dev_wheel, + _is_dev_version, + _render_containerfile, + _tracking_image_ref, + _try_pull_and_cache, + resolve_launch_target, +) +from lightcone.engine.manifest import lc_version as _lc_version + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + (tmp_path / "astra.yaml").write_text("name: test\n") + (tmp_path / ".lightcone").mkdir() + return tmp_path + + +@pytest.fixture +def fake_target(tmp_path: Path) -> LaunchTarget: + cf = tmp_path / "fake.Containerfile" + cf.write_text( + "FROM python:3.12-slim\n" + "ARG LIGHTCONE_VERSION\n" + "RUN echo ${LIGHTCONE_VERSION}\n" + ) + return LaunchTarget( + name="fake", + containerfile=cf, + entrypoint=["bash"], + env_passthrough=["HOME"], + devices=[], + ) + + +class TestBuiltinTargets: + def test_claude_is_registered(self) -> None: + assert "claude" in BUILTIN_TARGETS + + def test_claude_target_fields(self) -> None: + t = BUILTIN_TARGETS["claude"] + assert t.name == "claude" + assert t.entrypoint == ["claude", "--dangerously-skip-permissions"] + assert "ANTHROPIC_API_KEY" in t.env_passthrough + assert "CLAUDE_CODE_OAUTH_TOKEN" in t.env_passthrough + assert "/dev/fuse" in t.devices + assert ".claude.json" in t.home_mounts + assert ".claude/session-env/" in t.home_mounts + assert t.run_as_host_user is True + assert t.registry_name == "lightcone-sandbox" + assert t.install_cmds == [ + "npm install -g @anthropic-ai/claude-code --loglevel=error --no-update-notifier" + ] + assert t.committed_tag_prefix == "lightcone-claude" + + def test_mistral_vibe_is_registered(self) -> None: + assert "mistral-vibe" in BUILTIN_TARGETS + + def test_mistral_vibe_target_fields(self) -> None: + t = BUILTIN_TARGETS["mistral-vibe"] + assert t.name == "mistral-vibe" + assert t.install_cmds == ["uv tool install mistral-vibe"] + assert t.committed_tag_prefix == "lightcone-mistral-vibe" + assert t.entrypoint == ["vibe"] + assert t.env_passthrough == ["MISTRAL_API_KEY"] + assert ".vibe/config.toml" in t.home_mounts + assert ".vibe/agents/" in t.home_mounts + assert ".vibe/prompts/" in t.home_mounts + assert ".vibe/skills/" in t.home_mounts + assert ".vibe/tools/" in t.home_mounts + assert t.registry_name == "lightcone-sandbox" + + def test_opencode_is_registered(self) -> None: + assert "opencode" in BUILTIN_TARGETS + + def test_opencode_target_fields(self) -> None: + t = BUILTIN_TARGETS["opencode"] + assert t.name == "opencode" + assert t.install_cmds == [ + "npm install -g opencode-ai --loglevel=error --no-update-notifier" + ] + assert t.committed_tag_prefix == "lightcone-opencode" + assert t.entrypoint == ["opencode"] + assert "OPENAI_API_KEY" in t.env_passthrough + assert "ANTHROPIC_API_KEY" in t.env_passthrough + assert "MISTRAL_API_KEY" in t.env_passthrough + assert "GEMINI_API_KEY" in t.env_passthrough + assert "GROQ_API_KEY" in t.env_passthrough + assert ".config/opencode/opencode.json" in t.home_mounts + assert ".config/opencode/tui.json" in t.home_mounts + assert ".config/opencode/agents/" in t.home_mounts + assert ".config/opencode/commands/" in t.home_mounts + assert ".config/opencode/modes/" in t.home_mounts + assert ".config/opencode/plugins/" in t.home_mounts + assert ".config/opencode/themes/" in t.home_mounts + assert ".config/opencode/AGENTS.md" in t.home_mounts + assert t.registry_name == "lightcone-sandbox" + + +class TestResolveTarget: + def test_resolves_claude(self, project: Path) -> None: + t = resolve_launch_target("claude", project) + assert t.name == "claude" + + def test_unknown_raises(self, project: Path) -> None: + with pytest.raises(ContainerBuildError, match="Unknown launch target"): + resolve_launch_target("nonexistent", project) + + +class TestRenderContainerfile: + def test_substitutes_lightcone_version( + self, fake_target: LaunchTarget, project: Path + ) -> None: + rendered = _render_containerfile(fake_target, project) + content = rendered.read_text() + version = _lc_version() + assert f"ARG LIGHTCONE_VERSION={version}" in content + assert "ARG LIGHTCONE_VERSION\n" not in content + + def test_renders_to_lightcone_containers( + self, fake_target: LaunchTarget, project: Path + ) -> None: + rendered = _render_containerfile(fake_target, project) + assert rendered.parent == project / ".lightcone" / "containers" + assert rendered.name == "fake.Containerfile" + + def test_idempotent(self, fake_target: LaunchTarget, project: Path) -> None: + r1 = _render_containerfile(fake_target, project) + r2 = _render_containerfile(fake_target, project) + assert r1.read_text() == r2.read_text() + + def test_substitutes_existing_default( + self, tmp_path: Path, project: Path + ) -> None: + cf = tmp_path / "with_default.Containerfile" + cf.write_text( + "FROM python:3.12-slim\nARG LIGHTCONE_VERSION=0.0.0\nRUN echo ${LIGHTCONE_VERSION}\n" + ) + target = LaunchTarget(name="with_default", containerfile=cf, entrypoint=["bash"]) + rendered = _render_containerfile(target, project) + content = rendered.read_text() + version = _lc_version() + assert f"ARG LIGHTCONE_VERSION={version}" in content + assert "ARG LIGHTCONE_VERSION=0.0.0" not in content + + +_BSP = "--break-system-packages" +_INSTALL_BLOCK = ( + "# Dev/local builds are not published to PyPI ...\n" + "RUN case \"${LIGHTCONE_VERSION}\" in \\\n" + f" *dev*|*+*|dev) uv pip install --system {_BSP} lightcone-cli ;; \\\n" + f" *) uv pip install --system {_BSP} \"lightcone-cli==${{LIGHTCONE_VERSION}}\" ;; \\\n" + " esac\n" +) + + +@pytest.fixture +def install_target(tmp_path: Path) -> LaunchTarget: + """Target whose Containerfile includes the lightcone install block.""" + cf = tmp_path / "install.Containerfile" + cf.write_text( + "FROM python:3.12-slim\n" + "ARG LIGHTCONE_VERSION\n" + + _INSTALL_BLOCK + ) + return LaunchTarget(name="install", containerfile=cf, entrypoint=["bash"]) + + +class TestIsDevVersion: + def test_clean_release_is_not_dev(self) -> None: + assert _is_dev_version("1.2.3") is False + + def test_dev_string_is_dev(self) -> None: + assert _is_dev_version("dev") is True + + def test_dev_suffix_is_dev(self) -> None: + assert _is_dev_version("0.1.0.dev0+gabc123") is True + + def test_local_identifier_is_dev(self) -> None: + assert _is_dev_version("1.0.0+local") is True + + def test_rc_version_is_not_dev(self) -> None: + assert _is_dev_version("1.0.0rc1") is False + + +class TestRenderContainerfileDevWheel: + def test_injects_copy_and_wheel_install_when_dev( + self, install_target: LaunchTarget, project: Path, tmp_path: Path + ) -> None: + wheel = tmp_path / "lightcone_cli-0.1.0.dev0-py3-none-any.whl" + wheel.write_bytes(b"fake wheel") + + with patch("lightcone.engine.launcher._lc_version", return_value="0.1.0.dev0+gabc"): + with patch("lightcone.engine.launcher._build_dev_wheel", return_value=wheel): + rendered = _render_containerfile(install_target, project) + + content = rendered.read_text() + assert f"COPY {wheel.name} /tmp/{wheel.name}" in content + assert f"uv pip install --system --break-system-packages /tmp/{wheel.name}" in content + assert "--no-deps" not in content + assert "case" not in content + + def test_fallback_to_case_when_wheel_build_fails( + self, install_target: LaunchTarget, project: Path + ) -> None: + with patch("lightcone.engine.launcher._lc_version", return_value="0.1.0.dev0+gabc"): + with patch("lightcone.engine.launcher._build_dev_wheel", return_value=None): + rendered = _render_containerfile(install_target, project) + + content = rendered.read_text() + assert "case" in content + assert "COPY" not in content + + def test_no_wheel_logic_for_release_version( + self, install_target: LaunchTarget, project: Path + ) -> None: + with patch("lightcone.engine.launcher._lc_version", return_value="1.2.3"): + with patch("lightcone.engine.launcher._build_dev_wheel") as mock_build: + rendered = _render_containerfile(install_target, project) + + mock_build.assert_not_called() + content = rendered.read_text() + assert "case" in content + + def test_no_wheel_logic_without_install_block( + self, fake_target: LaunchTarget, project: Path + ) -> None: + """Containerfiles without the install block are not touched.""" + with patch("lightcone.engine.launcher._lc_version", return_value="0.1.0.dev0+gabc"): + with patch("lightcone.engine.launcher._build_dev_wheel") as mock_build: + _render_containerfile(fake_target, project) + + mock_build.assert_not_called() + + +class TestBuildDevWheelReuse: + """_build_dev_wheel reuses an existing wheel for the same version.""" + + def test_reuses_existing_wheel_same_version(self, tmp_path: Path) -> None: + version = "0.1.0.dev0+gabc123" + wheel = tmp_path / f"lightcone_cli-{version}-py3-none-any.whl" + wheel.write_bytes(b"original wheel bytes") + + with patch("lightcone.engine.launcher._find_source_root", return_value=tmp_path): + with patch("lightcone.engine.launcher._lc_version", return_value=version): + with patch("lightcone.engine.launcher.subprocess.run") as mock_run: + result = _build_dev_wheel(tmp_path) + + # subprocess.run must NOT be called — wheel was reused + mock_run.assert_not_called() + assert result == wheel + + def test_rebuilds_when_version_changed(self, tmp_path: Path) -> None: + old_wheel = tmp_path / "lightcone_cli-0.0.1.dev0+gold-py3-none-any.whl" + old_wheel.write_bytes(b"stale") + new_version = "0.1.0.dev0+gnew" + new_wheel = tmp_path / f"lightcone_cli-{new_version}-py3-none-any.whl" + + def fake_build(*args: object, **kwargs: object) -> MagicMock: + new_wheel.write_bytes(b"fresh wheel") + return MagicMock(returncode=0) + + with patch("lightcone.engine.launcher._find_source_root", return_value=tmp_path): + with patch("lightcone.engine.launcher._lc_version", return_value=new_version): + with patch("lightcone.engine.launcher.subprocess.run", side_effect=fake_build): + result = _build_dev_wheel(tmp_path) + + assert result == new_wheel + # Stale wheel should have been removed + assert not old_wheel.exists() + + +class TestLaunchTarget: + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.tarball_path_for_tag") + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-fake-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_exec_called_with_runtime( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_tarball_path: MagicMock, + mock_exists: MagicMock, + mock_exec: MagicMock, + fake_target: LaunchTarget, + project: Path, + tmp_path: Path, + ) -> None: + from lightcone.engine.launcher import launch_target + + mock_resolve.return_value = fake_target + # Tarball already exists — skip build + tarball = tmp_path / "lc-fake-abc123.tar" + tarball.write_bytes(b"fake") + mock_tarball_path.return_value = tarball + + choice = RuntimeChoice(runtime="docker", explicit=True) + launch_target(fake_target.name, choice=choice, project_root=project) + + # os.execvp was called + assert mock_exec.called + exec_args = mock_exec.call_args[0] + cmd = exec_args[1] + assert cmd[0] == "docker" + assert "run" in cmd + assert "-it" in cmd + # --entrypoint must appear before the image tag + if "--entrypoint" in cmd: + assert cmd.index("--entrypoint") < cmd.index("lc-fake-abc123") + + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.tarball_path_for_tag") + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-fake-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_podman_hpc_no_extra_flags( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_tarball_path: MagicMock, + mock_exists: MagicMock, + mock_exec: MagicMock, + fake_target: LaunchTarget, + project: Path, + tmp_path: Path, + ) -> None: + """podman-hpc takes the same plain ``run`` options as podman. + + Earlier versions of the launcher added ``--no-setns`` here, but + modern podman-hpc (5.x+) rejects that flag. We rely on the + wrapper's defaults instead. + """ + from lightcone.engine.launcher import launch_target + + mock_resolve.return_value = fake_target + tarball = tmp_path / "lc-fake-abc123.tar" + tarball.write_bytes(b"fake") + mock_tarball_path.return_value = tarball + + choice = RuntimeChoice(runtime="podman-hpc", explicit=True) + launch_target(fake_target.name, choice=choice, project_root=project) + + cmd = mock_exec.call_args[0][1] + assert "--no-setns" not in cmd + assert cmd[0] == "podman-hpc" + assert "run" in cmd + + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.tarball_path_for_tag") + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-fake-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_run_as_host_user_adds_user_flag( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_tarball_path: MagicMock, + mock_exists: MagicMock, + mock_exec: MagicMock, + project: Path, + tmp_path: Path, + ) -> None: + """Docker (rootful) gets --user $UID:$GID directly.""" + import os + + from lightcone.engine.launcher import launch_target + + target = LaunchTarget( + name="fake", + containerfile=tmp_path / "fake.Containerfile", + entrypoint=["claude", "--dangerously-skip-permissions"], + run_as_host_user=True, + ) + (tmp_path / "fake.Containerfile").write_text( + "FROM python:3.12-slim\nARG LIGHTCONE_VERSION\n" + ) + mock_resolve.return_value = target + tarball = tmp_path / "lc-fake-abc123.tar" + tarball.write_bytes(b"fake") + mock_tarball_path.return_value = tarball + + choice = RuntimeChoice(runtime="docker", explicit=True) + launch_target(target.name, choice=choice, project_root=project) + + cmd = mock_exec.call_args[0][1] + assert "--user" in cmd + idx = cmd.index("--user") + assert cmd[idx + 1] == f"{os.getuid()}:{os.getgid()}" + assert "--userns=keep-id" not in cmd + + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.tarball_path_for_tag") + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-fake-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_run_as_host_user_uses_userns_keepid_for_podman( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_tarball_path: MagicMock, + mock_exists: MagicMock, + mock_exec: MagicMock, + project: Path, + tmp_path: Path, + ) -> None: + """Rootless podman: --user $UID:$GID fails with + ``crun: setgroups: Invalid argument`` because the user namespace + has no subuid/subgid mapping. --userns=keep-id is the rootless + equivalent that maps the host UID into the container. + """ + from lightcone.engine.launcher import launch_target + + target = LaunchTarget( + name="fake", + containerfile=tmp_path / "fake.Containerfile", + entrypoint=["claude", "--dangerously-skip-permissions"], + run_as_host_user=True, + ) + (tmp_path / "fake.Containerfile").write_text( + "FROM python:3.12-slim\nARG LIGHTCONE_VERSION\n" + ) + mock_resolve.return_value = target + tarball = tmp_path / "lc-fake-abc123.tar" + tarball.write_bytes(b"fake") + mock_tarball_path.return_value = tarball + + choice = RuntimeChoice(runtime="podman", explicit=True) + launch_target(target.name, choice=choice, project_root=project) + cmd = mock_exec.call_args[0][1] + assert "--userns=keep-id" in cmd + assert "--user" not in cmd + # Plain podman has no real-TTY/keep-id bug, so the dangerously- + # skip-permissions arg stays. + assert "--dangerously-skip-permissions" in cmd + + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.tarball_path_for_tag") + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-fake-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_podman_hpc_neither_userns_nor_skip_permissions( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_tarball_path: MagicMock, + mock_exists: MagicMock, + mock_exec: MagicMock, + project: Path, + tmp_path: Path, + ) -> None: + """podman-hpc rejects --userns=keep-id with a real TTY (the launcher + always allocates one with -it). Drop the flag to avoid:: + + crun: open .../merged: Permission denied + + Claude Code then runs as UID 0 inside the container, which means + --dangerously-skip-permissions is rejected — drop that too. The + user accepts the folder-trust prompt manually once. + """ + from lightcone.engine.launcher import launch_target + + target = LaunchTarget( + name="fake", + containerfile=tmp_path / "fake.Containerfile", + entrypoint=["claude", "--dangerously-skip-permissions"], + run_as_host_user=True, + ) + (tmp_path / "fake.Containerfile").write_text( + "FROM python:3.12-slim\nARG LIGHTCONE_VERSION\n" + ) + mock_resolve.return_value = target + tarball = tmp_path / "lc-fake-abc123.tar" + tarball.write_bytes(b"fake") + mock_tarball_path.return_value = tarball + + choice = RuntimeChoice(runtime="podman-hpc", explicit=True) + launch_target(target.name, choice=choice, project_root=project) + cmd = mock_exec.call_args[0][1] + assert cmd[0] == "podman-hpc" + assert "--userns=keep-id" not in cmd + assert "--user" not in cmd + assert "--entrypoint" in cmd + assert "--dangerously-skip-permissions" not in cmd + + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.tarball_path_for_tag") + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-fake-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_env_passthrough( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_tarball_path: MagicMock, + mock_exists: MagicMock, + mock_exec: MagicMock, + fake_target: LaunchTarget, + project: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + from lightcone.engine.launcher import launch_target + + mock_resolve.return_value = fake_target + tarball = tmp_path / "lc-fake-abc123.tar" + tarball.write_bytes(b"fake") + mock_tarball_path.return_value = tarball + monkeypatch.setenv("HOME", "/home/testuser") + + choice = RuntimeChoice(runtime="docker", explicit=True) + launch_target(fake_target.name, choice=choice, project_root=project) + + cmd = mock_exec.call_args[0][1] + assert "-e" in cmd + idx = cmd.index("-e") + # Only the variable name is passed (no embedded value) to avoid + # secrets appearing in /proc//cmdline. + assert cmd[idx + 1] == "HOME" + + @patch("lightcone.engine.launcher.build_image") + @patch("lightcone.engine.launcher.save_image_as_tarball") + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-fake-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_builds_when_tarball_absent( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_exists: MagicMock, + mock_exec: MagicMock, + mock_save: MagicMock, + mock_build: MagicMock, + fake_target: LaunchTarget, + project: Path, + ) -> None: + from lightcone.engine.launcher import launch_target + + mock_resolve.return_value = fake_target + # No tarball on disk — build should be called + choice = RuntimeChoice(runtime="docker", explicit=True) + launch_target(fake_target.name, choice=choice, project_root=project) + + mock_build.assert_called_once() + mock_save.assert_called_once() + + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.tarball_path_for_tag") + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-fake-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_env_passthrough_name_only( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_tarball_path: MagicMock, + mock_exists: MagicMock, + mock_exec: MagicMock, + fake_target: LaunchTarget, + project: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """env vars are passed as -e VAR (no value) to avoid cmdline leaks.""" + from lightcone.engine.launcher import launch_target + + mock_resolve.return_value = fake_target + tarball = tmp_path / "lc-fake-abc123.tar" + tarball.write_bytes(b"fake") + mock_tarball_path.return_value = tarball + monkeypatch.setenv("HOME", "/home/secureuser") + + choice = RuntimeChoice(runtime="docker", explicit=True) + launch_target(fake_target.name, choice=choice, project_root=project) + + cmd = mock_exec.call_args[0][1] + # Must not embed the value in argv. + assert "HOME=/home/secureuser" not in " ".join(cmd) + assert "-e" in cmd + idx = cmd.index("-e") + assert cmd[idx + 1] == "HOME" + + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.tarball_path_for_tag") + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-fake-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_home_mounts_added_when_dir_exists( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_tarball_path: MagicMock, + mock_exists: MagicMock, + mock_exec: MagicMock, + project: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + from lightcone.engine.launcher import launch_target + + # Target with a home_mount subdir + home_dir = tmp_path / "home" + claude_dir = home_dir / ".claude" + claude_dir.mkdir(parents=True) + cf = tmp_path / "fake.Containerfile" + cf.write_text("FROM python:3.12-slim\nARG LIGHTCONE_VERSION\n") + target = LaunchTarget( + name="fake", + containerfile=cf, + entrypoint=["bash"], + home_mounts=[".claude/"], # trailing slash = directory + ) + mock_resolve.return_value = target + tarball = tmp_path / "lc-fake-abc123.tar" + tarball.write_bytes(b"fake") + mock_tarball_path.return_value = tarball + monkeypatch.setenv("HOME", str(home_dir)) + + choice = RuntimeChoice(runtime="docker", explicit=True) + launch_target(target.name, choice=choice, project_root=project) + + cmd = mock_exec.call_args[0][1] + expected = str(claude_dir) + assert f"{expected}:{expected}" in " ".join(cmd) + + +class TestLaunchTargetEnsureHarness: + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher._ensure_harness_image", return_value="lightcone-claude:1.2.3") + @patch("lightcone.engine.launcher._apply_tracking_tag") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.tarball_path_for_tag") + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-sandbox-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_ensure_harness_called_when_committed_tag_prefix_set( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_tarball_path: MagicMock, + mock_exists: MagicMock, + mock_tracking: MagicMock, + mock_ensure: MagicMock, + mock_exec: MagicMock, + project: Path, + tmp_path: Path, + ) -> None: + """launch_target() calls _ensure_harness_image() and uses the returned tag.""" + from lightcone.engine.launcher import launch_target + + target = LaunchTarget( + name="claude", + containerfile=tmp_path / "lightcone-sandbox.Containerfile", + entrypoint=["claude", "--dangerously-skip-permissions"], + install_cmds=["npm install -g @anthropic-ai/claude-code"], + committed_tag_prefix="lightcone-claude", + ) + (tmp_path / "lightcone-sandbox.Containerfile").write_text( + "FROM python:3.12-slim\nARG LIGHTCONE_VERSION\n" + ) + mock_resolve.return_value = target + tarball = tmp_path / "lc-sandbox-abc123.tar" + tarball.write_bytes(b"fake") + mock_tarball_path.return_value = tarball + + with patch("lightcone.engine.launcher._lc_version", return_value="1.2.3"): + choice = RuntimeChoice(runtime="docker", explicit=True) + launch_target(target.name, choice=choice, project_root=project) + + mock_ensure.assert_called_once_with( + target, + base_image="lc-sandbox-abc123", + runtime="docker", + lc_version="1.2.3", + reinstall=False, + ) + # _apply_tracking_tag must receive the committed harness tag, not the base image tag + assert mock_tracking.call_args[0][0] == "lightcone-claude:1.2.3" + # The harness image tag must be passed to _exec_interactive (via os.execvp) + cmd = mock_exec.call_args[0][1] + assert "lightcone-claude:1.2.3" in cmd + + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher._ensure_harness_image", return_value="lightcone-claude:1.2.3") + @patch("lightcone.engine.launcher._apply_tracking_tag") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.tarball_path_for_tag") + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-sandbox-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_reinstall_forwarded_to_ensure_harness_image( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_tarball_path: MagicMock, + mock_exists: MagicMock, + mock_tracking: MagicMock, + mock_ensure: MagicMock, + mock_exec: MagicMock, + project: Path, + tmp_path: Path, + ) -> None: + """launch_target() forwards reinstall=True to _ensure_harness_image().""" + from lightcone.engine.launcher import launch_target + + target = LaunchTarget( + name="claude", + containerfile=tmp_path / "lightcone-sandbox.Containerfile", + entrypoint=["claude", "--dangerously-skip-permissions"], + install_cmds=["npm install -g @anthropic-ai/claude-code"], + committed_tag_prefix="lightcone-claude", + ) + (tmp_path / "lightcone-sandbox.Containerfile").write_text( + "FROM python:3.12-slim\nARG LIGHTCONE_VERSION\n" + ) + mock_resolve.return_value = target + tarball = tmp_path / "lc-sandbox-abc123.tar" + tarball.write_bytes(b"fake") + mock_tarball_path.return_value = tarball + + with patch("lightcone.engine.launcher._lc_version", return_value="1.2.3"): + choice = RuntimeChoice(runtime="docker", explicit=True) + launch_target(target.name, choice=choice, project_root=project, reinstall=True) + + mock_ensure.assert_called_once_with( + target, + base_image="lc-sandbox-abc123", + runtime="docker", + lc_version="1.2.3", + reinstall=True, + ) + + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher._ensure_harness_image") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.tarball_path_for_tag") + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-fake-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_ensure_harness_not_called_when_no_committed_tag_prefix( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_tarball_path: MagicMock, + mock_exists: MagicMock, + mock_ensure: MagicMock, + mock_exec: MagicMock, + fake_target: LaunchTarget, + project: Path, + tmp_path: Path, + ) -> None: + """launch_target() skips _ensure_harness_image() when committed_tag_prefix is empty.""" + from lightcone.engine.launcher import launch_target + + mock_resolve.return_value = fake_target + tarball = tmp_path / "lc-fake-abc123.tar" + tarball.write_bytes(b"fake") + mock_tarball_path.return_value = tarball + + choice = RuntimeChoice(runtime="docker", explicit=True) + launch_target(fake_target.name, choice=choice, project_root=project) + + mock_ensure.assert_not_called() + # The base tag must be used directly + cmd = mock_exec.call_args[0][1] + assert "lc-fake-abc123" in cmd + + +class TestEnsureHostPath: + def test_creates_file_when_missing(self, tmp_path: Path) -> None: + from lightcone.engine.launcher import _ensure_host_path + + target = tmp_path / "settings.json" + assert not target.exists() + _ensure_host_path(target, is_dir=False) + assert target.is_file() + + def test_creates_directory_when_missing(self, tmp_path: Path) -> None: + from lightcone.engine.launcher import _ensure_host_path + + target = tmp_path / "agents" + assert not target.exists() + _ensure_host_path(target, is_dir=True) + assert target.is_dir() + + def test_does_not_clobber_existing_file(self, tmp_path: Path) -> None: + from lightcone.engine.launcher import _ensure_host_path + + target = tmp_path / "config.toml" + target.write_text("existing content") + _ensure_host_path(target, is_dir=False) + assert target.read_text() == "existing content" + + def test_does_not_clobber_existing_dir(self, tmp_path: Path) -> None: + from lightcone.engine.launcher import _ensure_host_path + + target = tmp_path / "agents" + target.mkdir() + (target / "my_agent.toml").write_text("agent") + _ensure_host_path(target, is_dir=True) + assert (target / "my_agent.toml").exists() + + def test_creates_parent_dirs(self, tmp_path: Path) -> None: + from lightcone.engine.launcher import _ensure_host_path + + target = tmp_path / "a" / "b" / "settings.json" + _ensure_host_path(target, is_dir=False) + assert target.is_file() + + +class TestTryPullAndCache: + """Tests for the GHCR pull-first behaviour in _try_pull_and_cache.""" + + def test_daemonless_returns_false_immediately(self, tmp_path: Path) -> None: + tarball = tmp_path / "lc-fake-abc123.tar" + with patch("lightcone.engine.launcher.pull_image") as mock_pull: + result = _try_pull_and_cache( + "lc-fake-abc123", + "ghcr.io/lightconeresearch/claude:1.0.0", + tarball, + runtime="apptainer", + ) + assert result is False + mock_pull.assert_not_called() + + def test_daemonless_singularity_returns_false(self, tmp_path: Path) -> None: + tarball = tmp_path / "lc-fake-abc123.tar" + with patch("lightcone.engine.launcher.pull_image") as mock_pull: + result = _try_pull_and_cache( + "lc-fake-abc123", + "ghcr.io/lightconeresearch/claude:1.0.0", + tarball, + runtime="singularity", + ) + assert result is False + mock_pull.assert_not_called() + + @patch("lightcone.engine.launcher.save_image_as_tarball") + @patch("lightcone.engine.launcher.subprocess.run") + @patch("lightcone.engine.launcher.pull_image") + def test_successful_pull_saves_tarball( + self, + mock_pull: MagicMock, + mock_subprocess: MagicMock, + mock_save: MagicMock, + tmp_path: Path, + ) -> None: + mock_subprocess.return_value = MagicMock(returncode=0) + tarball = tmp_path / "lc-fake-abc123.tar" + registry_ref = "ghcr.io/lightconeresearch/claude:1.0.0" + + result = _try_pull_and_cache( + "lc-fake-abc123", + registry_ref, + tarball, + runtime="docker", + ) + + assert result is True + mock_pull.assert_called_once_with(registry_ref, runtime="docker") + mock_subprocess.assert_called_once_with( + ["docker", "tag", registry_ref, "lc-fake-abc123"], + check=True, + capture_output=True, + ) + mock_save.assert_called_once_with("lc-fake-abc123", tarball, runtime="docker") + + @patch("lightcone.engine.launcher.pull_image") + def test_pull_failure_returns_false( + self, + mock_pull: MagicMock, + tmp_path: Path, + ) -> None: + mock_pull.side_effect = ContainerBuildError("registry unreachable") + tarball = tmp_path / "lc-fake-abc123.tar" + + result = _try_pull_and_cache( + "lc-fake-abc123", + "ghcr.io/lightconeresearch/claude:1.0.0", + tarball, + runtime="docker", + ) + + assert result is False + + @patch("lightcone.engine.launcher.subprocess.run") + @patch("lightcone.engine.launcher.pull_image") + def test_tag_failure_returns_false( + self, + mock_pull: MagicMock, + mock_subprocess: MagicMock, + tmp_path: Path, + ) -> None: + import subprocess + + mock_subprocess.side_effect = subprocess.CalledProcessError(1, "docker tag") + tarball = tmp_path / "lc-fake-abc123.tar" + + result = _try_pull_and_cache( + "lc-fake-abc123", + "ghcr.io/lightconeresearch/claude:1.0.0", + tarball, + runtime="docker", + ) + + assert result is False + + +class TestLaunchTargetGhcrPull: + """Tests for the GHCR pull-first path in launch_target.""" + + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher._ensure_harness_image", return_value="lightcone-claude:1.2.3") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.save_image_as_tarball") + @patch("lightcone.engine.launcher.build_image") + @patch("lightcone.engine.launcher._try_pull_and_cache", return_value=True) + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-claude-abc123") + def test_registry_name_overrides_target_name( + self, + mock_tag: MagicMock, + mock_pull: MagicMock, + mock_build: MagicMock, + mock_save: MagicMock, + mock_exists: MagicMock, + mock_ensure: MagicMock, + mock_exec: MagicMock, + project: Path, + ) -> None: + """claude target uses registry_name='lightcone-sandbox', not 'claude', for GHCR ref.""" + from lightcone.engine.launcher import launch_target + + # No tarball — forces pull path + with patch("lightcone.engine.launcher._lc_version", return_value="1.2.3"): + choice = RuntimeChoice(runtime="docker", explicit=True) + launch_target("claude", choice=choice, project_root=project) + + # _try_pull_and_cache(tag, registry_ref, tarball, runtime=...) + registry_ref = mock_pull.call_args[0][1] + assert registry_ref == "ghcr.io/lightconeresearch/lightcone-sandbox:1.2.3" + + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.save_image_as_tarball") + @patch("lightcone.engine.launcher.build_image") + @patch("lightcone.engine.launcher._try_pull_and_cache", return_value=True) + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-fake-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_release_version_tries_pull_before_build( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_pull: MagicMock, + mock_build: MagicMock, + mock_save: MagicMock, + mock_exists: MagicMock, + mock_exec: MagicMock, + fake_target: LaunchTarget, + project: Path, + ) -> None: + from lightcone.engine.launcher import launch_target + + mock_resolve.return_value = fake_target + # No tarball on disk — should attempt pull, succeed, skip build. + with patch("lightcone.engine.launcher._lc_version", return_value="1.2.3"): + choice = RuntimeChoice(runtime="docker", explicit=True) + launch_target(fake_target.name, choice=choice, project_root=project) + + mock_pull.assert_called_once() + mock_build.assert_not_called() + + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.save_image_as_tarball") + @patch("lightcone.engine.launcher.build_image") + @patch("lightcone.engine.launcher._try_pull_and_cache", return_value=False) + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-fake-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_pull_failure_falls_back_to_local_build( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_pull: MagicMock, + mock_build: MagicMock, + mock_save: MagicMock, + mock_exists: MagicMock, + mock_exec: MagicMock, + fake_target: LaunchTarget, + project: Path, + ) -> None: + from lightcone.engine.launcher import launch_target + + mock_resolve.return_value = fake_target + with patch("lightcone.engine.launcher._lc_version", return_value="1.2.3"): + choice = RuntimeChoice(runtime="docker", explicit=True) + launch_target(fake_target.name, choice=choice, project_root=project) + + mock_pull.assert_called_once() + mock_build.assert_called_once() + + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.save_image_as_tarball") + @patch("lightcone.engine.launcher.build_image") + @patch("lightcone.engine.launcher._try_pull_and_cache") + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-fake-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_dev_version_skips_pull( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_pull: MagicMock, + mock_build: MagicMock, + mock_save: MagicMock, + mock_exists: MagicMock, + mock_exec: MagicMock, + fake_target: LaunchTarget, + project: Path, + ) -> None: + from lightcone.engine.launcher import launch_target + + mock_resolve.return_value = fake_target + with patch("lightcone.engine.launcher._lc_version", return_value="0.3.5.dev0+gabc"): + choice = RuntimeChoice(runtime="docker", explicit=True) + launch_target(fake_target.name, choice=choice, project_root=project) + + mock_pull.assert_not_called() + mock_build.assert_called_once() + + @patch("lightcone.engine.launcher._apply_tracking_tag") + @patch("lightcone.engine.launcher.os.execvp") + @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) + @patch("lightcone.engine.launcher.tarball_path_for_tag") + @patch("lightcone.engine.launcher.compute_image_tag", return_value="lc-fake-abc123") + @patch("lightcone.engine.launcher.resolve_launch_target") + def test_tracking_tag_applied_before_exec( + self, + mock_resolve: MagicMock, + mock_tag: MagicMock, + mock_tarball_path: MagicMock, + mock_exists: MagicMock, + mock_exec: MagicMock, + mock_tracking: MagicMock, + fake_target: LaunchTarget, + project: Path, + tmp_path: Path, + ) -> None: + from lightcone.engine.launcher import launch_target + from lightcone.engine.manifest import lc_version as _lc_version + + mock_resolve.return_value = fake_target + tarball = tmp_path / "lc-fake-abc123.tar" + tarball.write_bytes(b"fake") + mock_tarball_path.return_value = tarball + + choice = RuntimeChoice(runtime="docker", explicit=True) + launch_target(fake_target.name, choice=choice, project_root=project) + + version = _lc_version() + mock_tracking.assert_called_once_with( + "lc-fake-abc123", + f"lightcone-{project.name}:{version}", + "docker", + ) + + +class TestImageExists: + def test_returns_true_when_image_found(self) -> None: + from lightcone.engine.launcher import _image_exists + + with patch("lightcone.engine.launcher.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + result = _image_exists("lightcone-claude:0.5.0", "docker") + + mock_run.assert_called_once_with( + ["docker", "image", "inspect", "lightcone-claude:0.5.0"], + capture_output=True, + ) + assert result is True + + def test_returns_false_when_image_not_found(self) -> None: + from lightcone.engine.launcher import _image_exists + + with patch("lightcone.engine.launcher.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=1) + result = _image_exists("lightcone-claude:0.5.0", "docker") + + assert result is False + + def test_returns_false_on_oserror(self) -> None: + from lightcone.engine.launcher import _image_exists + + with patch("lightcone.engine.launcher.subprocess.run") as mock_run: + mock_run.side_effect = OSError("not found") + result = _image_exists("lightcone-claude:0.5.0", "docker") + + assert result is False + + +class TestTrackingTag: + def test_tracking_image_ref_uses_project_dir_name(self, tmp_path: Path) -> None: + project = tmp_path / "my-analysis" + project.mkdir() + ref = _tracking_image_ref(project, "1.2.3") + assert ref == "lightcone-my-analysis:1.2.3" + + def test_tracking_image_ref_dev_version(self, tmp_path: Path) -> None: + project = tmp_path / "my-analysis" + project.mkdir() + ref = _tracking_image_ref(project, "dev") + assert ref == "lightcone-my-analysis:dev" + + def test_apply_tracking_tag_calls_runtime_tag(self, tmp_path: Path) -> None: + with patch("lightcone.engine.launcher.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + _apply_tracking_tag("lc-fake-abc123", "lightcone-proj:1.0.0", "docker") + mock_run.assert_called_once_with( + ["docker", "tag", "lc-fake-abc123", "lightcone-proj:1.0.0"], + check=True, + capture_output=True, + ) + + def test_apply_tracking_tag_swallows_errors(self, tmp_path: Path) -> None: + import subprocess as _sp + + with patch("lightcone.engine.launcher.subprocess.run") as mock_run: + mock_run.side_effect = _sp.CalledProcessError(1, "docker tag") + _apply_tracking_tag("lc-fake-abc123", "lightcone-proj:1.0.0", "docker") + + def test_apply_tracking_tag_swallows_oserror(self, tmp_path: Path) -> None: + with patch("lightcone.engine.launcher.subprocess.run") as mock_run: + mock_run.side_effect = OSError("no such file") + _apply_tracking_tag("lc-fake-abc123", "lightcone-proj:1.0.0", "docker") + + +class TestEnsureHarnessImage: + @pytest.fixture + def harness_target(self, tmp_path: Path) -> LaunchTarget: + cf = tmp_path / "lightcone-sandbox.Containerfile" + cf.write_text("FROM python:3.12-slim\nARG LIGHTCONE_VERSION\n") + return LaunchTarget( + name="claude", + containerfile=cf, + entrypoint=["claude", "--dangerously-skip-permissions"], + install_cmds=["npm install -g @anthropic-ai/claude-code"], + committed_tag_prefix="lightcone-claude", + ) + + def test_returns_committed_tag_when_image_exists( + self, harness_target: LaunchTarget + ) -> None: + from lightcone.engine.launcher import _ensure_harness_image + + with patch("lightcone.engine.launcher._image_exists", return_value=True): + with patch("lightcone.engine.launcher.subprocess.run") as mock_run: + result = _ensure_harness_image( + harness_target, "lc-lightcone-sandbox-abc", "docker", "1.2.3" + ) + + assert result == "lightcone-claude:1.2.3" + mock_run.assert_not_called() + + def test_plus_in_version_replaced_for_oci_tag( + self, harness_target: LaunchTarget + ) -> None: + from lightcone.engine.launcher import _ensure_harness_image + + # PEP 440 local versions (e.g. 0.3.5.dev33+g8e1ae4ad0) contain '+' which + # is not valid in OCI image tags — must be replaced with '-'. + dev_version = "0.3.5.dev33+g8e1ae4ad0" + with patch("lightcone.engine.launcher._image_exists", return_value=True): + result = _ensure_harness_image( + harness_target, "lc-lightcone-sandbox-abc", "docker", dev_version + ) + + assert "+" not in result + assert result == "lightcone-claude:0.3.5.dev33-g8e1ae4ad0" + + def test_installs_and_commits_when_image_absent( + self, harness_target: LaunchTarget + ) -> None: + from lightcone.engine.launcher import _ensure_harness_image + + with patch("lightcone.engine.launcher._image_exists", return_value=False): + with patch("lightcone.engine.launcher.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + result = _ensure_harness_image( + harness_target, "lc-lightcone-sandbox-abc", "docker", "1.2.3" + ) + + assert result == "lightcone-claude:1.2.3" + calls = [c[0][0] for c in mock_run.call_args_list] + # First call: docker run (install) — must use --entrypoint sh to bypass ENTRYPOINT ["bash"] + assert calls[0][0] == "docker" + assert calls[0][1] == "run" + assert "--entrypoint" in calls[0] + assert calls[0][calls[0].index("--entrypoint") + 1] == "sh" + assert "npm install -g @anthropic-ai/claude-code" in calls[0] + # Second call: docker commit + assert calls[1][0] == "docker" + assert calls[1][1] == "commit" + assert calls[1][-1] == "lightcone-claude:1.2.3" + # Third call: docker rm (cleanup) + assert calls[2][0] == "docker" + assert calls[2][1] == "rm" + + def test_reinstall_skips_image_exists_check( + self, harness_target: LaunchTarget + ) -> None: + from lightcone.engine.launcher import _ensure_harness_image + + with patch("lightcone.engine.launcher._image_exists") as mock_exists: + with patch("lightcone.engine.launcher.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + _ensure_harness_image( + harness_target, + "lc-lightcone-sandbox-abc", + "docker", + "1.2.3", + reinstall=True, + ) + + mock_exists.assert_not_called() + # rmi (remove old) + run (install) + commit + rm (cleanup temp) = 4 calls + assert mock_run.call_count == 4 + rmi_cmd = mock_run.call_args_list[0][0][0] + assert rmi_cmd[1] == "rmi" # first call removes the old committed image + + def test_removes_tmp_container_on_install_failure( + self, harness_target: LaunchTarget + ) -> None: + import subprocess as _sp + + from lightcone.engine.launcher import _ensure_harness_image + + def side_effect(cmd: list[str], **kwargs: object) -> MagicMock: + if cmd[1] == "run": # docker run (install step) fails + raise _sp.CalledProcessError(1, cmd) + return MagicMock(returncode=0) + + with patch("lightcone.engine.launcher._image_exists", return_value=False): + with patch( + "lightcone.engine.launcher.subprocess.run", side_effect=side_effect + ) as mock_run: + with pytest.raises(ContainerBuildError): + _ensure_harness_image( + harness_target, "lc-lightcone-sandbox-abc", "docker", "1.2.3" + ) + + # docker rm must still have been called despite the failure (finally block) + assert mock_run.call_count == 2 # run (failed) + rm (cleanup) + rm_cmd = mock_run.call_args_list[1][0][0] + assert rm_cmd[1] == "rm" # second call is cleanup, not commit