From 8b4af32a2ff7decfaa20f8cb34159ceb42a07e18 Mon Sep 17 00:00:00 2001 From: Alexandre Boucaud Date: Mon, 4 May 2026 16:36:39 +0200 Subject: [PATCH 01/21] feat: add lc launch for sandboxed Claude Code container - New `lc launch claude` command builds (or reuses) a content-addressed OCI container with Claude Code, buildah, and apptainer pre-installed, then exec's into it interactively with the project directory mounted. - Container is cached as an OCI tarball under .lightcone/images/; rebuilt only when the Containerfile or context changes. - Dev/PR builds (not on PyPI) get a local wheel baked in automatically; stable releases pull from PyPI. - Singularity supported alongside apptainer as a daemonless runtime. - Mounts ~/.claude.json and ~/.claude/ for auth/settings persistence. - Passes ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, CLAUDE_CODE_OAUTH_TOKEN. - Runs as host UID:GID so --dangerously-skip-permissions is accepted. Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.md | 4 +- .../containers/claude-env.Containerfile | 63 +++ .../guides/lightcone-cli-reference.md | 16 +- claude/lightcone/skills/lc-migrate/SKILL.md | 3 + claude/lightcone/skills/lc-new/SKILL.md | 8 +- container-design.md | 332 +++++++++++++ src/lightcone/cli/commands.py | 90 +++- src/lightcone/engine/container.py | 139 +++++- src/lightcone/engine/launcher.py | 334 +++++++++++++ src/lightcone/engine/manifest.py | 11 + tests/test_cli.py | 6 +- tests/test_container.py | 310 ++++++++++-- tests/test_launcher.py | 441 ++++++++++++++++++ 13 files changed, 1706 insertions(+), 51 deletions(-) create mode 100644 claude/lightcone/containers/claude-env.Containerfile create mode 100644 container-design.md create mode 100644 src/lightcone/engine/launcher.py create mode 100644 tests/test_launcher.py diff --git a/CLAUDE.md b/CLAUDE.md index 1fcfdfab..8c21df1a 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 @@ -138,6 +139,7 @@ astra.yaml ── snakefile.generate() ──> .lightcone/Snakefile + .lightcone - `lc verify` — chain integrity check - `lc build` — pre-build container images from Containerfiles + Global config (`~/.lightcone/config.yaml`) is auto-created with defaults on first invocation. ## Extending the Codebase diff --git a/claude/lightcone/containers/claude-env.Containerfile b/claude/lightcone/containers/claude-env.Containerfile new file mode 100644 index 00000000..cfb74ac0 --- /dev/null +++ b/claude/lightcone/containers/claude-env.Containerfile @@ -0,0 +1,63 @@ +FROM ubuntu:24.04 + +# 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. +# Handles OCI archive execution: apptainer exec oci-archive: +# Both apt blocks are merged into one RUN so the apt lists remain live for the +# .deb install and /var/log/apt/eipp.log.xz is not left stale between layers +# (dpkg opens it with O_CREAT|O_EXCL; a pre-existing file from a prior layer +# causes exit code 2 on NERSC / podman rootless builds). +ARG APPTAINER_VERSION=1.4.0 +RUN apt-get update && apt-get install -y --no-install-recommends \ + fuse3 \ + libfuse2t64 \ + squashfuse \ + buildah \ + fakeroot \ + git \ + curl \ + ca-certificates \ + python3 \ + python3-dev \ + build-essential \ + && 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/* + +# Python + uv + lightcone-cli. +# LIGHTCONE_VERSION is substituted at render time (lc launch writes a rendered +# copy to .lightcone/containers/claude.Containerfile with the value filled in). +RUN curl -LsSf https://astral.sh/uv/install.sh | sh +ENV PATH="/root/.local/bin:$PATH" +ARG LIGHTCONE_VERSION +# python3 (installed above) is the system Python that uv --system targets. +# ubuntu:24.04 base does not include Python by default; adding it via apt +# ensures uv finds a system interpreter and can use pre-built manylinux wheels +# (avoiding source-build failures for C-extension deps like immutables). +# Dev/local builds are not published to PyPI; the ARG is still baked in for +# content-addressed tag computation so the image rebuilds when lc is upgraded. +# 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 + 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 read by lc build / lc run to detect containerized operation. +ENV LIGHTCONE_CONTAINER=1 + +WORKDIR /workspace +ENTRYPOINT ["claude"] diff --git a/claude/lightcone/guides/lightcone-cli-reference.md b/claude/lightcone/guides/lightcone-cli-reference.md index c9156663..125feac5 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) @@ -24,6 +25,19 @@ container: **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: @@ -36,7 +50,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/claude/lightcone/skills/lc-new/SKILL.md b/claude/lightcone/skills/lc-new/SKILL.md index 78073ad9..42f756f1 100644 --- a/claude/lightcone/skills/lc-new/SKILL.md +++ b/claude/lightcone/skills/lc-new/SKILL.md @@ -147,7 +147,13 @@ Show summary table: | sub_analysis | ... | ... | ... | ``` -Then tell the user the spec is ready and they can begin implementation. Recommend running `/clear` first — the scoping conversation consumes significant context, and everything needed to continue is captured in `astra.yaml` and `CLAUDE.md`. +Then show a Next Up block (see ui-brand.md) with: + +- Run `/clear` to free context, then `lc launch claude` to enter the sandboxed container +- Inside the container: `/lc-build` to start building (or `/lc-build [description]` to guide focus) +- Also available inside the container: `/lc-verify` + +Prompt the user to `/clear` before starting implementation. The scoping conversation consumes significant context. Everything needed to continue is captured in `astra.yaml` and `CLAUDE.md`. --- diff --git a/container-design.md b/container-design.md new file mode 100644 index 00000000..5cb425c9 --- /dev/null +++ b/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 ubuntu:24.04 + +# 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/src/lightcone/cli/commands.py b/src/lightcone/cli/commands.py index 1de8bd78..0a40d0d7 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"): + console.print(_CONTAINER_WARNING) + PERMISSION_TIERS: dict[str, dict[str, list[str]]] = { "yolo": { @@ -378,6 +389,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 @@ -719,18 +731,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() @@ -767,12 +785,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 @@ -806,17 +827,72 @@ 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)) +# ============================================================================= +# lc launch +# ============================================================================= + + +@main.command("launch") +@click.argument("target") +def launch(target: str) -> None: + """Launch an interactive containerized environment for this project. + + \b + Targets: + claude Claude Code with lightcone-cli, buildah, and apptainer pre-installed. + Recipes build and execute inside nested containers from this environment. + + \b + Example: + lc launch claude # first run builds the container (~5 min), then drops into Claude Code + """ + from lightcone.engine import launcher + from lightcone.engine.container import 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, podman-hpc, or apptainer/singularity with buildah); " + f"got {choice.runtime!r}. " + "Install docker or podman, or set container.runtime in " + "~/.lightcone/config.yaml." + ) + + try: + launcher.launch_target(target, choice=choice, project_root=project) + 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..faa66662 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,46 @@ 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) + try: + build_proc = subprocess.run( + [ + "buildah", "build", + "--format=oci", + f"--tag={tag}", + str(context), + ], + 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 +647,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 +676,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 +723,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 +854,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..8e746830 --- /dev/null +++ b/src/lightcone/engine/launcher.py @@ -0,0 +1,334 @@ +"""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 +import sys +from dataclasses import dataclass, field +from pathlib import Path + +from lightcone.engine.container import ( + ContainerBuildError, + RuntimeChoice, + build_image, + compute_image_tag, + image_exists_locally, + load_image_from_tarball, + save_image_as_tarball, + tarball_path_for_tag, +) +from lightcone.engine.manifest import lc_version as _lc_version + + +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. Only mounted when the path exists on the host. + 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 + + +def _make_builtin_targets() -> dict[str, LaunchTarget]: + try: + containers_dir = _package_containers_dir() + except ContainerBuildError: + return {} + return { + "claude": LaunchTarget( + name="claude", + containerfile=containers_dir / "claude-env.Containerfile", + # The Containerfile ENTRYPOINT is already "claude"; arguments here + # are appended to it. --dangerously-skip-permissions suppresses the + # folder-trust prompt — appropriate because the container IS the + # sandbox. It also fixes the accidental "claude claude" invocation + # that occurred when "claude" was listed as its own entrypoint arg. + entrypoint=["--dangerously-skip-permissions"], + env_passthrough=[ + "ANTHROPIC_API_KEY", + "ANTHROPIC_BASE_URL", + "CLAUDE_CODE_OAUTH_TOKEN", + "HOME", + "TERM", + ], + devices=["/dev/fuse"], + # Mount the host Claude Code config so settings, accepted terms, + # and API-key auth are available without re-running setup. + # ~/.claude.json — primary config file (API key, auth tokens) + # ~/.claude/ — settings, backups, conversation history + home_mounts=[".claude.json", ".claude"], + # 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, + ), + } + + +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] + 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. +_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 / f"{target.name}.Containerfile" + + 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 launch_target( + name: str, + *, + choice: RuntimeChoice, + project_root: Path, +) -> None: + """Build (if needed) and exec the named launch target interactively. + + 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(): + _print(f"Building {name} container (first run — this may take a few minutes)…") + 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): + load_image_from_tarball(tarball, runtime=choice.runtime) + + _exec_interactive(target, tag, choice, project_root) + + +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: + val = os.environ.get(var) + if val is not None: + cmd += ["-e", f"{var}={val}"] + + home = os.environ.get("HOME") + if home: + for subdir in target.home_mounts: + host_path = str(Path(home) / subdir) + if Path(host_path).exists(): + cmd += ["-v", f"{host_path}:{host_path}"] + + for device in target.devices: + if Path(device).exists(): + cmd += ["--device", device] + + if target.run_as_host_user: + cmd += ["--user", f"{os.getuid()}:{os.getgid()}"] + + if choice.runtime == "podman-hpc": + cmd.append("--no-setns") + + cmd.append(tag) + cmd.extend(target.entrypoint) + + os.execvp(cmd[0], cmd) + + +def _print(msg: str) -> None: + print(msg, file=sys.stderr) diff --git a/src/lightcone/engine/manifest.py b/src/lightcone/engine/manifest.py index 9ba4798a..6f8eaa48 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 0bd6472b..28fe826d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -15,6 +15,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.""" @@ -30,7 +31,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 @@ -38,6 +40,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 @@ -101,6 +104,7 @@ def test_verify_clean_project_returns_zero( assert result.exit_code == 0 + # ---- lc run command building ------------------------------------------------ diff --git a/tests/test_container.py b/tests/test_container.py index 2fb10a74..6c090944 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -261,7 +261,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") @@ -389,13 +390,17 @@ 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 +445,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 +458,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 +543,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,7 +565,8 @@ 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() @@ -654,7 +656,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.""" @@ -699,6 +702,7 @@ def test_runtime_none_skips_existence_check(self, project: Path) -> None: assert s.exists is None + # ---- is_containerfile ----------------------------------------------------- @@ -708,3 +712,237 @@ def test_existing_file(self, project: Path) -> None: 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..2d200700 --- /dev/null +++ b/tests/test_launcher.py @@ -0,0 +1,441 @@ +"""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, + _build_dev_wheel, + _is_dev_version, + _lc_version, + _render_containerfile, + resolve_launch_target, +) + + +@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 ubuntu:24.04\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 == ["--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" in t.home_mounts + assert t.run_as_host_user is True + + +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 ubuntu:24.04\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 ubuntu:24.04\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 + + @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_adds_no_setns( + 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 = 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" 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: + import os + + from lightcone.engine.launcher import launch_target + + target = LaunchTarget( + name="fake", + containerfile=tmp_path / "fake.Containerfile", + entrypoint=["--dangerously-skip-permissions"], + run_as_host_user=True, + ) + (tmp_path / "fake.Containerfile").write_text( + "FROM ubuntu:24.04\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()}" + + @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") + assert "HOME=/home/testuser" in cmd[idx + 1] + + @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_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 ubuntu:24.04\nARG LIGHTCONE_VERSION\n") + target = LaunchTarget( + name="fake", + containerfile=cf, + entrypoint=["bash"], + home_mounts=[".claude"], + ) + 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) From e16dd7a111dad7d29f79d004d5970aeb08a57565 Mon Sep 17 00:00:00 2001 From: Alexandre Boucaud Date: Mon, 4 May 2026 17:09:30 +0200 Subject: [PATCH 02/21] feat: slim container image, GHCR pull-first, and rebase onto main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch claude-env.Containerfile from ubuntu:24.04 to python:3.12-slim (Debian Bookworm based): removes ~90 MB from the base layer. Adjust libfuse2t64 → libfuse2 (Debian package name) and drop python3/python3-dev from apt (already provided by the base image). - Add GHCR pull-first to lc launch: for non-dev releases, launcher.py now tries pulling ghcr.io/lightconeresearch/claude-env: before falling back to a local build. Daemonless runtimes (apptainer/singularity) skip the pull silently. - Add .github/workflows/container-publish.yaml: builds and pushes claude-env to ghcr.io/lightconeresearch/claude-env on every v*.*.* tag release. - Rebase onto origin/main (resolved conflicts in lightcone-cli-reference.md and lc-new/SKILL.md; accepted deletion of lc-build/assets/loop-prompt.md). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/container-publish.yaml | 49 ++++++++++++++ .../containers/claude-env.Containerfile | 19 +++--- src/lightcone/engine/launcher.py | 66 +++++++++++++++++-- 3 files changed, 120 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/container-publish.yaml diff --git a/.github/workflows/container-publish.yaml b/.github/workflows/container-publish.yaml new file mode 100644 index 00000000..881355e8 --- /dev/null +++ b/.github/workflows/container-publish.yaml @@ -0,0 +1,49 @@ +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 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 }} + 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/lightcone/containers/claude-env.Containerfile b/claude/lightcone/containers/claude-env.Containerfile index cfb74ac0..74ce3ab0 100644 --- a/claude/lightcone/containers/claude-env.Containerfile +++ b/claude/lightcone/containers/claude-env.Containerfile @@ -1,4 +1,4 @@ -FROM ubuntu:24.04 +FROM python:3.12-slim # FUSE support — required by both Apptainer overlay and buildah overlay storage. # squashfuse enables SquashFS mounts used by Apptainer for OCI images. @@ -8,18 +8,20 @@ FROM ubuntu:24.04 # .deb install and /var/log/apt/eipp.log.xz is not left stale between layers # (dpkg opens it with O_CREAT|O_EXCL; a pre-existing file from a prior layer # causes exit code 2 on NERSC / podman rootless builds). +# python:3.12-slim is Debian Bookworm-based and ships Python pre-installed, so +# python3/python3-dev are omitted from the apt block. build-essential is kept +# as a safety net for any C-extension deps that lack pre-built wheels. +# Note: Debian uses libfuse2 (not libfuse2t64 which is Ubuntu-specific). ARG APPTAINER_VERSION=1.4.0 RUN apt-get update && apt-get install -y --no-install-recommends \ fuse3 \ - libfuse2t64 \ + libfuse2 \ squashfuse \ buildah \ fakeroot \ git \ curl \ ca-certificates \ - python3 \ - python3-dev \ build-essential \ && ARCH="$(dpkg --print-architecture)" \ && if [ "$ARCH" = "amd64" ]; then \ @@ -31,16 +33,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ fi \ && rm -rf /var/lib/apt/lists/* -# Python + uv + lightcone-cli. +# uv + lightcone-cli. # LIGHTCONE_VERSION is substituted at render time (lc launch writes a rendered # copy to .lightcone/containers/claude.Containerfile with the value filled in). RUN curl -LsSf https://astral.sh/uv/install.sh | sh ENV PATH="/root/.local/bin:$PATH" ARG LIGHTCONE_VERSION -# python3 (installed above) is the system Python that uv --system targets. -# ubuntu:24.04 base does not include Python by default; adding it via apt -# ensures uv finds a system interpreter and can use pre-built manylinux wheels -# (avoiding source-build failures for C-extension deps like immutables). +# python:3.12-slim ships /usr/local/bin/python3.12 which uv --system targets. +# pre-built manylinux_2_17 wheels are accepted on Debian Bookworm (glibc 2.36), +# avoiding source-build failures for C-extension deps like immutables. # Dev/local builds are not published to PyPI; the ARG is still baked in for # content-addressed tag computation so the image rebuilds when lc is upgraded. # For non-release strings we install the latest stable release from PyPI. diff --git a/src/lightcone/engine/launcher.py b/src/lightcone/engine/launcher.py index 8e746830..5e539149 100644 --- a/src/lightcone/engine/launcher.py +++ b/src/lightcone/engine/launcher.py @@ -19,17 +19,22 @@ from pathlib import Path 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" + def _package_containers_dir() -> Path: """Return the path to the bundled ``containers/`` directory. @@ -262,6 +267,50 @@ def _render_containerfile(target: LaunchTarget, project_root: Path) -> Path: 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 _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: + _print(f"Pulling {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 launch_target( name: str, *, @@ -270,8 +319,9 @@ def launch_target( ) -> None: """Build (if needed) and exec the named launch target interactively. - Replaces the current process via ``os.execvp`` — this function does not - return on success. + 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) @@ -280,9 +330,15 @@ def launch_target( tarball = tarball_path_for_tag(tag, project_root) if not tarball.exists(): - _print(f"Building {name} container (first run — this may take a few minutes)…") - build_image(tag, rendered_cf, rendered_cf.parent, runtime=choice.runtime) - save_image_as_tarball(tag, tarball, runtime=choice.runtime) + version = _lc_version() + pulled = False + if not _is_dev_version(version): + registry_ref = _registry_image_ref(target.name, version) + pulled = _try_pull_and_cache(tag, registry_ref, tarball, runtime=choice.runtime) + if not pulled: + _print(f"Building {name} container (first run — this may take a few minutes)…") + 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): load_image_from_tarball(tarball, runtime=choice.runtime) From a56862d423ffd59e767f69eff67f74f17e8d0d64 Mon Sep 17 00:00:00 2001 From: Alexandre Boucaud Date: Mon, 4 May 2026 17:16:47 +0200 Subject: [PATCH 03/21] revert: restore lc-new Done section to origin/main wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lc-new skill runs inside the sandboxed container — it must not instruct users to invoke the container from within it. Co-Authored-By: Claude Sonnet 4.6 --- claude/lightcone/skills/lc-new/SKILL.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/claude/lightcone/skills/lc-new/SKILL.md b/claude/lightcone/skills/lc-new/SKILL.md index 42f756f1..78073ad9 100644 --- a/claude/lightcone/skills/lc-new/SKILL.md +++ b/claude/lightcone/skills/lc-new/SKILL.md @@ -147,13 +147,7 @@ Show summary table: | sub_analysis | ... | ... | ... | ``` -Then show a Next Up block (see ui-brand.md) with: - -- Run `/clear` to free context, then `lc launch claude` to enter the sandboxed container -- Inside the container: `/lc-build` to start building (or `/lc-build [description]` to guide focus) -- Also available inside the container: `/lc-verify` - -Prompt the user to `/clear` before starting implementation. The scoping conversation consumes significant context. Everything needed to continue is captured in `astra.yaml` and `CLAUDE.md`. +Then tell the user the spec is ready and they can begin implementation. Recommend running `/clear` first — the scoping conversation consumes significant context, and everything needed to continue is captured in `astra.yaml` and `CLAUDE.md`. --- From 6b7700a2bf3e2d426ad2ee41b201bcf5a55d2b0a Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 15:42:38 +0000 Subject: [PATCH 04/21] fix: address review items 1-7 from lc launch PR - Item 1: Reject apptainer/singularity in `lc launch` with a clear error explaining daemonless runtimes are for recipe execution, not interactive sessions. Remove the false claim from the error message. - Item 2: Stage buildah build context into a tmpdir (same as daemon runtimes) to avoid NERSC DVS `llistxattr EPROTO` failures on home/CFS filesystems. Also pass `-f ` explicitly for non-default Containerfile names. - Item 3: Pass env vars as `-e VAR` (name only) in _exec_interactive so secrets like ANTHROPIC_API_KEY are not embedded in argv where /proc//cmdline is world-readable. Docker/podman inherit the value from the current environment. - Item 4: Add comment to _LIGHTCONE_INSTALL_RE explaining it is intentionally coupled to claude-env.Containerfile's specific install block shape. - Item 5: Store the ContainerBuildError from _make_builtin_targets and surface it in resolve_launch_target instead of the misleading "Available: (none)". - Item 6: Move container-design.md from repo root to docs/. - Item 7: Add tests for _try_pull_and_cache (success, failure, daemonless short-circuit) and the GHCR pull-first path in launch_target (release pulls before building, pull failure falls back to local build, dev skips pull). Co-authored-by: Alexandre Boucaud --- .../container-design.md | 0 src/lightcone/cli/commands.py | 13 +- src/lightcone/engine/container.py | 59 +++-- src/lightcone/engine/launcher.py | 27 +- tests/test_launcher.py | 233 +++++++++++++++++- 5 files changed, 297 insertions(+), 35 deletions(-) rename container-design.md => docs/container-design.md (100%) diff --git a/container-design.md b/docs/container-design.md similarity index 100% rename from container-design.md rename to docs/container-design.md diff --git a/src/lightcone/cli/commands.py b/src/lightcone/cli/commands.py index 0a40d0d7..ae8151d5 100644 --- a/src/lightcone/cli/commands.py +++ b/src/lightcone/cli/commands.py @@ -869,7 +869,7 @@ def launch(target: str) -> None: lc launch claude # first run builds the container (~5 min), then drops into Claude Code """ from lightcone.engine import launcher - from lightcone.engine.container import ContainerBuildError, load_runtime + from lightcone.engine.container import ContainerBuildError, _DAEMONLESS_RUNTIMES, load_runtime project = _project_root() @@ -881,12 +881,21 @@ def launch(target: str) -> None: if choice.runtime == "none": raise click.ClickException( "lc launch requires a host container runtime " - "(docker, podman, podman-hpc, or apptainer/singularity with buildah); " + "(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) except ContainerBuildError as e: diff --git a/src/lightcone/engine/container.py b/src/lightcone/engine/container.py index faa66662..77eb7f56 100644 --- a/src/lightcone/engine/container.py +++ b/src/lightcone/engine/container.py @@ -596,37 +596,42 @@ def build_image( "Pass the desired .tar output path." ) tarball_path.parent.mkdir(parents=True, exist_ok=True) - try: - build_proc = subprocess.run( - [ - "buildah", "build", - "--format=oci", - f"--tag={tag}", - str(context), - ], + 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, ) - 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}" - ) + 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: diff --git a/src/lightcone/engine/launcher.py b/src/lightcone/engine/launcher.py index 5e539149..2b64d228 100644 --- a/src/lightcone/engine/launcher.py +++ b/src/lightcone/engine/launcher.py @@ -86,10 +86,18 @@ class LaunchTarget: run_as_host_user: bool = False +#: 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: + except ContainerBuildError as e: + _builtin_targets_error = str(e) return {} return { "claude": LaunchTarget( @@ -136,6 +144,11 @@ def resolve_launch_target(name: str, project_root: Path | None = None) -> Launch """ 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)'}" @@ -148,6 +161,10 @@ def resolve_launch_target(name: str, project_root: Path | None = None) -> Launch # Matches the comment + RUN block that handles lightcone-cli installation. # Present only in Containerfiles that use the dev-wheel fallback pattern. +# Intentionally coupled to claude-env.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) @@ -359,9 +376,11 @@ def _exec_interactive( cmd += ["-v", f"{project_abs}:{project_abs}", "-w", project_abs] for var in target.env_passthrough: - val = os.environ.get(var) - if val is not None: - cmd += ["-e", f"{var}={val}"] + 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: diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 2d200700..4d6751f9 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -2,7 +2,7 @@ from __future__ import annotations from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch import pytest @@ -14,6 +14,7 @@ _is_dev_version, _lc_version, _render_containerfile, + _try_pull_and_cache, resolve_launch_target, ) @@ -368,7 +369,9 @@ def test_env_passthrough( cmd = mock_exec.call_args[0][1] assert "-e" in cmd idx = cmd.index("-e") - assert "HOME=/home/testuser" in cmd[idx + 1] + # 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") @@ -397,6 +400,42 @@ def test_builds_when_tarball_absent( 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") @@ -439,3 +478,193 @@ def test_home_mounts_added_when_dir_exists( cmd = mock_exec.call_args[0][1] expected = str(claude_dir) assert f"{expected}:{expected}" in " ".join(cmd) + + +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.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() From 0c220a41dad11e97a13e0cd21d3e0d23b1a99c9b Mon Sep 17 00:00:00 2001 From: Alexandre Boucaud Date: Mon, 4 May 2026 18:19:35 +0200 Subject: [PATCH 05/21] Improve consistency --- claude/lightcone/guides/lightcone-cli-reference.md | 2 +- docs/container-design.md | 2 +- src/lightcone/cli/commands.py | 2 +- tests/test_launcher.py | 10 +++++----- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/claude/lightcone/guides/lightcone-cli-reference.md b/claude/lightcone/guides/lightcone-cli-reference.md index 125feac5..545287c0 100644 --- a/claude/lightcone/guides/lightcone-cli-reference.md +++ b/claude/lightcone/guides/lightcone-cli-reference.md @@ -20,7 +20,7 @@ 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. diff --git a/docs/container-design.md b/docs/container-design.md index 5cb425c9..3455c335 100644 --- a/docs/container-design.md +++ b/docs/container-design.md @@ -90,7 +90,7 @@ host ## New file: `claude/lightcone/containers/claude-env.Containerfile` ```dockerfile -FROM ubuntu:24.04 +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 \ diff --git a/src/lightcone/cli/commands.py b/src/lightcone/cli/commands.py index ae8151d5..507aa143 100644 --- a/src/lightcone/cli/commands.py +++ b/src/lightcone/cli/commands.py @@ -732,7 +732,7 @@ def verify(universe: str | None) -> None: "--runtime", default=None, help=( - "docker | podman | podman-hpc | apptainer | singularity " + "docker | podman | podman-hpc | apptainer | singularity" "(overrides ~/.lightcone/config.yaml)" ), ) diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 4d6751f9..750d14a5 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -30,7 +30,7 @@ def project(tmp_path: Path) -> Path: def fake_target(tmp_path: Path) -> LaunchTarget: cf = tmp_path / "fake.Containerfile" cf.write_text( - "FROM ubuntu:24.04\n" + "FROM python:3.12-slim\n" "ARG LIGHTCONE_VERSION\n" "RUN echo ${LIGHTCONE_VERSION}\n" ) @@ -96,7 +96,7 @@ def test_substitutes_existing_default( ) -> None: cf = tmp_path / "with_default.Containerfile" cf.write_text( - "FROM ubuntu:24.04\nARG LIGHTCONE_VERSION=0.0.0\nRUN echo ${LIGHTCONE_VERSION}\n" + "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) @@ -121,7 +121,7 @@ def install_target(tmp_path: Path) -> LaunchTarget: """Target whose Containerfile includes the lightcone install block.""" cf = tmp_path / "install.Containerfile" cf.write_text( - "FROM ubuntu:24.04\n" + "FROM python:3.12-slim\n" "ARG LIGHTCONE_VERSION\n" + _INSTALL_BLOCK ) @@ -323,7 +323,7 @@ def test_run_as_host_user_adds_user_flag( run_as_host_user=True, ) (tmp_path / "fake.Containerfile").write_text( - "FROM ubuntu:24.04\nARG LIGHTCONE_VERSION\n" + "FROM python:3.12-slim\nARG LIGHTCONE_VERSION\n" ) mock_resolve.return_value = target tarball = tmp_path / "lc-fake-abc123.tar" @@ -459,7 +459,7 @@ def test_home_mounts_added_when_dir_exists( claude_dir = home_dir / ".claude" claude_dir.mkdir(parents=True) cf = tmp_path / "fake.Containerfile" - cf.write_text("FROM ubuntu:24.04\nARG LIGHTCONE_VERSION\n") + cf.write_text("FROM python:3.12-slim\nARG LIGHTCONE_VERSION\n") target = LaunchTarget( name="fake", containerfile=cf, From 636dd8ee16543bad35d0dbd8915a644b6b69970d Mon Sep 17 00:00:00 2001 From: Alexandre Boucaud Date: Mon, 4 May 2026 18:46:17 +0200 Subject: [PATCH 06/21] Address item 8 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 8c21df1a..7e0f8a70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,6 +138,7 @@ 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. From 55924feb19dcfca5d13d6766ea2d52556874f1b5 Mon Sep 17 00:00:00 2001 From: Alexandre Boucaud Date: Mon, 4 May 2026 18:46:24 +0200 Subject: [PATCH 07/21] Address item 9 --- tests/test_container.py | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/tests/test_container.py b/tests/test_container.py index 6c090944..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: @@ -358,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: @@ -398,9 +390,6 @@ def test_unsupported_runtime_raises(self) -> None: # ---- detect_runtime / load_runtime --------------------------------------- - - - class TestDetectRuntime: @pytest.fixture(autouse=True) def _generic_hostname(self) -> Iterator[None]: @@ -572,8 +561,6 @@ def test_unknown_runtime_raises( # ---- resolve_image_for_run ----------------------------------------------- - - class TestResolveImageForRun: def test_none_returns_none(self, project: Path) -> None: assert resolve_image_for_run( @@ -599,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" @@ -667,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") @@ -702,10 +685,7 @@ def test_runtime_none_skips_existence_check(self, project: Path) -> None: assert s.exists is None - # ---- is_containerfile ----------------------------------------------------- - - class TestIsContainerfile: def test_existing_file(self, project: Path) -> None: assert is_containerfile("Containerfile", project) is True @@ -713,9 +693,8 @@ def test_existing_file(self, project: Path) -> None: def test_missing_file(self, project: Path) -> None: assert is_containerfile("python:3.12-slim", project) is False -# ---- tarball helpers ------------------------------------------------------- - +# ---- tarball helpers ------------------------------------------------------- class TestTarballPathForTag: def test_canonical_path(self, tmp_path: Path) -> None: from lightcone.engine.container import tarball_path_for_tag @@ -788,8 +767,6 @@ def test_failure_raises(self, mock_run: MagicMock, tmp_path: Path) -> None: # ---- 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 From 58ea985f20acb82801ba1467370190db080a6fd4 Mon Sep 17 00:00:00 2001 From: Alexandre Boucaud Date: Mon, 4 May 2026 18:48:44 +0200 Subject: [PATCH 08/21] Address item 10 --- tests/test_launcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 750d14a5..f4c55191 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -12,11 +12,11 @@ LaunchTarget, _build_dev_wheel, _is_dev_version, - _lc_version, _render_containerfile, _try_pull_and_cache, resolve_launch_target, ) +from lightcone.engine.manifest import lc_version as _lc_version @pytest.fixture From 1d00fec0d11725c6b94a99dfa70cd05548742299 Mon Sep 17 00:00:00 2001 From: Alexandre Boucaud Date: Mon, 4 May 2026 22:47:09 +0200 Subject: [PATCH 09/21] Address item 11 --- .github/workflows/container-publish.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/container-publish.yaml b/.github/workflows/container-publish.yaml index 881355e8..75b35d0c 100644 --- a/.github/workflows/container-publish.yaml +++ b/.github/workflows/container-publish.yaml @@ -26,6 +26,9 @@ jobs: 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 @@ -41,6 +44,7 @@ jobs: 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 }} From 55a75589c0a6a395d591656bc4b0ff26c4eb58be Mon Sep 17 00:00:00 2001 From: dkn16 Date: Tue, 5 May 2026 16:24:06 -0700 Subject: [PATCH 10/21] fix(container): pin claude-env base to python:3.12-slim-bookworm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base image `python:3.12-slim` rolled from Debian 12 (Bookworm) to Debian 13 (Trixie). In Trixie, `libfuse2` was renamed to `libfuse2t64` as part of the t64 ABI transition — apt-get install fails with exit status 100 because the requested package no longer exists. Repro: docker run --rm python:3.12-slim apt-cache search '^libfuse2$' # (Trixie) — libfuse2t64 - Filesystem in Userspace (library) # libfuse2 itself: not found The Containerfile's apt block (libfuse2 + apptainer 1.4 .deb) was written against Bookworm and depends on those package names. Pinning the base to `-bookworm` restores the original behaviour deterministically without chasing the rolling tag. Trade-offs considered: - Replace `libfuse2` with `libfuse2t64`: works on Trixie but breaks on Bookworm where `libfuse2t64` doesn't exist. Requires conditional logic. - Try-then-fallback (`libfuse2t64 || libfuse2`): adds a shell layer for no benefit while we're targeting a single distro. - Pin `-bookworm`: simplest, deterministic, matches the assumptions baked into the rest of the apt block. Bookworm is supported until June 2028 (LTS to 2030). Discovered while testing `lc launch claude` on Perlmutter login nodes. Co-Authored-By: Claude Opus 4.7 (1M context) --- claude/lightcone/containers/claude-env.Containerfile | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/claude/lightcone/containers/claude-env.Containerfile b/claude/lightcone/containers/claude-env.Containerfile index 74ce3ab0..945337d6 100644 --- a/claude/lightcone/containers/claude-env.Containerfile +++ b/claude/lightcone/containers/claude-env.Containerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim +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. @@ -8,10 +8,12 @@ FROM python:3.12-slim # .deb install and /var/log/apt/eipp.log.xz is not left stale between layers # (dpkg opens it with O_CREAT|O_EXCL; a pre-existing file from a prior layer # causes exit code 2 on NERSC / podman rootless builds). -# python:3.12-slim is Debian Bookworm-based and ships Python pre-installed, so -# python3/python3-dev are omitted from the apt block. build-essential is kept -# as a safety net for any C-extension deps that lack pre-built wheels. -# Note: Debian uses libfuse2 (not libfuse2t64 which is Ubuntu-specific). +# python:3.12-slim-bookworm ships Python pre-installed, so python3/python3-dev +# are omitted from the apt block. build-essential is kept as a safety net for +# any C-extension deps that lack pre-built wheels. +# 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 \ From fac74bd7b018e044cf2c77187247794cd7d27a97 Mon Sep 17 00:00:00 2001 From: dkn16 Date: Tue, 5 May 2026 16:30:04 -0700 Subject: [PATCH 11/21] fix(launcher): drop unsupported --no-setns flag for podman-hpc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launcher unconditionally appended --no-setns when the runtime was podman-hpc: if choice.runtime == "podman-hpc": cmd.append("--no-setns") Modern podman-hpc (5.3.x on current Perlmutter) doesn't recognize this flag and aborts with: Error: unknown flag: --no-setns See 'podman run --help' The flag was added defensively in the initial sandboxing-execution commit (8b4af32) without a comment explaining the original need. podman-hpc is a wrapper around podman that already handles the HPC-specific concerns (squash, image migration, namespace setup) on its own — no extra `run` flag is required for plain interactive use. HPC features like --gpu / --mpi / --scratch / --cfs are opt-in for recipe execution, not for `lc launch`. Updated test_podman_hpc_adds_no_setns -> test_podman_hpc_no_extra_flags to assert the inverse: the cmd uses `podman-hpc run` plainly with no flag added beyond the cross-runtime ones (--rm, -it, -v, -e, ...). Discovered while testing `lc launch claude` on Perlmutter login nodes right after the bookworm pin commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lightcone/engine/launcher.py | 3 --- tests/test_launcher.py | 12 ++++++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/lightcone/engine/launcher.py b/src/lightcone/engine/launcher.py index 2b64d228..b71968e2 100644 --- a/src/lightcone/engine/launcher.py +++ b/src/lightcone/engine/launcher.py @@ -396,9 +396,6 @@ def _exec_interactive( if target.run_as_host_user: cmd += ["--user", f"{os.getuid()}:{os.getgid()}"] - if choice.runtime == "podman-hpc": - cmd.append("--no-setns") - cmd.append(tag) cmd.extend(target.entrypoint) diff --git a/tests/test_launcher.py b/tests/test_launcher.py index f4c55191..32b52b15 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -273,7 +273,7 @@ def test_exec_called_with_runtime( @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_adds_no_setns( + def test_podman_hpc_no_extra_flags( self, mock_resolve: MagicMock, mock_tag: MagicMock, @@ -284,6 +284,12 @@ def test_podman_hpc_adds_no_setns( 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 @@ -295,7 +301,9 @@ def test_podman_hpc_adds_no_setns( launch_target(fake_target.name, choice=choice, project_root=project) cmd = mock_exec.call_args[0][1] - assert "--no-setns" in cmd + 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) From 260ad6e60082a60fec076a4712dddf8ad035fe9a Mon Sep 17 00:00:00 2001 From: dkn16 Date: Tue, 5 May 2026 16:31:48 -0700 Subject: [PATCH 12/21] fix(launcher): use --userns=keep-id for rootless podman/podman-hpc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When run_as_host_user=True (the claude target), the launcher unconditionally appended --user $UID:$GID. That works for rootful docker but fails on rootless podman/podman-hpc with: Error: OCI runtime error: crun: setgroups: Invalid argument Rootless containers run inside a user namespace whose subuid/subgid range almost never includes the host UID, so the kernel rejects setgroups when --user tries to switch into it. Podman's idiomatic replacement is --userns=keep-id: build an idmap that preserves the host UID inside the container without requiring subuid configuration. Same effect as --user from the user's perspective (files written to bind-mounts belong to the host user; processes inside don't run as root) but compatible with rootless mode. Detection rule: - docker -> --user $UID:$GID (rootful daemon, the common case) - podman -> --userns=keep-id (rootless by default) - podman-hpc -> --userns=keep-id (rootless wrapper around podman) Discovered while testing `lc launch claude` on Perlmutter login nodes. This is the third issue surfacing in the same launch attempt — see lc-launch-bookworm-pin.md for the libfuse2 / Trixie issue, and the preceding commit for --no-setns. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lightcone/engine/launcher.py | 16 ++++++++++- tests/test_launcher.py | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/lightcone/engine/launcher.py b/src/lightcone/engine/launcher.py index b71968e2..b44bdacd 100644 --- a/src/lightcone/engine/launcher.py +++ b/src/lightcone/engine/launcher.py @@ -394,7 +394,21 @@ def _exec_interactive( cmd += ["--device", device] if target.run_as_host_user: - cmd += ["--user", f"{os.getuid()}:{os.getgid()}"] + # 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 / podman-hpc are rootless on every system that ships them + # by default, where --user $UID:$GID fails with + # "crun: setgroups: Invalid argument" + # because the user namespace doesn't have the subuid/subgid range + # mapped. --userns=keep-id is the rootless-native equivalent: it + # builds an idmap that preserves the host UID inside the container. + if choice.runtime in ("podman", "podman-hpc"): + cmd += ["--userns=keep-id"] + else: + cmd += ["--user", f"{os.getuid()}:{os.getgid()}"] cmd.append(tag) cmd.extend(target.entrypoint) diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 32b52b15..cd0253a4 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -320,6 +320,7 @@ def test_run_as_host_user_adds_user_flag( project: Path, tmp_path: Path, ) -> None: + """Docker (rootful) gets --user $UID:$GID directly.""" import os from lightcone.engine.launcher import launch_target @@ -345,6 +346,52 @@ def test_run_as_host_user_adds_user_flag( 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: + """Podman/podman-hpc are rootless: --user $UID:$GID would fail 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=["--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 + + for runtime in ("podman", "podman-hpc"): + mock_exec.reset_mock() + choice = RuntimeChoice(runtime=runtime, explicit=True) + launch_target(target.name, choice=choice, project_root=project) + + cmd = mock_exec.call_args[0][1] + assert "--userns=keep-id" in cmd, f"missing for {runtime}" + assert "--user" not in cmd, f"--user should not be set for {runtime}" @patch("lightcone.engine.launcher.os.execvp") @patch("lightcone.engine.launcher.image_exists_locally", return_value=True) From 5a14c285e0298bc840508c3dbc7ed453dfb11d99 Mon Sep 17 00:00:00 2001 From: dkn16 Date: Tue, 5 May 2026 21:20:28 -0700 Subject: [PATCH 13/21] fix(launcher): drop --userns=keep-id and --dangerously-skip-permissions for podman-hpc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The combination ``--userns=keep-id`` + real TTY (-it with an actual terminal) reproducibly fails on podman-hpc with:: Error: crun: open `/tmp/_hpc/storage/overlay//merged`: Permission denied: OCI permission denied Root cause: crun creates ``/dev/console`` for the TTY inside the fuse-overlayfs merged mount. With keep-id active, the merged dir is remapped via the user namespace and the TTY device creation fails on the remapped path. Confirmed via bisect: the cmd succeeds without ``--userns=keep-id`` (running as UID 0 inside) and fails with it (running as host UID inside) every single time. Fix: skip ``--userns=keep-id`` for podman-hpc. The container then runs as UID 0 inside, but podman-hpc is rootless so the host-side ownership of bind-mount writes is still the host user — the internal-UID-0 vs external-rootless-host-UID mapping is podman-hpc's default and Just Works for the project + ``~/.claude`` mounts. Side effect: Claude Code refuses ``--dangerously-skip-permissions`` when running as root, so we drop that flag too. The user accepts the folder-trust prompt manually once; the answer persists in ``~/.claude.json`` for subsequent launches. Other runtimes are unaffected: - docker -> --user $UID:$GID (rootful, no keep-id needed) - podman -> --userns=keep-id (rootless, no real-TTY/keep-id bug) The existing migrate_image_to_squash() commit also picked up a small addition: after squashing, remove the overlay-store copy so ``podman-hpc run`` can't pick the wrong one. Discovered while testing ``lc launch claude`` on a Perlmutter compute node inside a SLURM allocation. This is the fourth issue in the same launch attempt; see lc-launch-bookworm-pin.md for the full sequence (libfuse2/Trixie, --no-setns, --userns=keep-id+TTY, overlay-vs-squashed image preference). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lightcone/engine/launcher.py | 35 ++++++++++++++---- tests/test_launcher.py | 63 ++++++++++++++++++++++++++++---- 2 files changed, 82 insertions(+), 16 deletions(-) diff --git a/src/lightcone/engine/launcher.py b/src/lightcone/engine/launcher.py index b44bdacd..1e36f7e1 100644 --- a/src/lightcone/engine/launcher.py +++ b/src/lightcone/engine/launcher.py @@ -399,19 +399,38 @@ def _exec_interactive( # subuid) and so Claude Code doesn't see itself running as root. # # docker (rootful, the common case) accepts --user directly. - # podman / podman-hpc are rootless on every system that ships them - # by default, where --user $UID:$GID fails with - # "crun: setgroups: Invalid argument" - # because the user namespace doesn't have the subuid/subgid range - # mapped. --userns=keep-id is the rootless-native equivalent: it - # builds an idmap that preserves the host UID inside the container. - if choice.runtime in ("podman", "podman-hpc"): + # 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()}"] cmd.append(tag) - cmd.extend(target.entrypoint) + # 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 + 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)}") os.execvp(cmd[0], cmd) diff --git a/tests/test_launcher.py b/tests/test_launcher.py index cd0253a4..64ec503e 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -363,7 +363,7 @@ def test_run_as_host_user_uses_userns_keepid_for_podman( project: Path, tmp_path: Path, ) -> None: - """Podman/podman-hpc are rootless: --user $UID:$GID would fail with + """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. @@ -384,14 +384,61 @@ def test_run_as_host_user_uses_userns_keepid_for_podman( tarball.write_bytes(b"fake") mock_tarball_path.return_value = tarball - for runtime in ("podman", "podman-hpc"): - mock_exec.reset_mock() - choice = RuntimeChoice(runtime=runtime, explicit=True) - launch_target(target.name, choice=choice, project_root=project) + 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 - cmd = mock_exec.call_args[0][1] - assert "--userns=keep-id" in cmd, f"missing for {runtime}" - assert "--user" not in cmd, f"--user should not be set for {runtime}" + 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=["--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 "--userns=keep-id" not in cmd + assert "--user" not 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) From b0a3a8463ddb27676b98b9b3ac308c5b82073f86 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 05:05:32 +0000 Subject: [PATCH 14/21] fix(launcher): correct GHCR registry ref, suppress warning, project_path, help text - LaunchTarget gains registry_name field; set to "claude-env" for the claude target so _registry_image_ref produces ghcr.io/lightconeresearch/claude-env instead of ghcr.io/lightconeresearch/claude (CI publishes claude-env). - Pass project_path=project_root to image_exists_locally in launch_target for consistency with daemon-based runtimes. - _warn_if_not_containerized now respects LC_NO_CONTAINER_WARN=1 to silence the warning for users who intentionally run outside the container. - Fix missing space in build --runtime help string ("singularity(overrides") - Add test_registry_name_overrides_target_name asserting correct GHCR ref. - Add registry_name assertion to test_claude_target_fields. Co-authored-by: Kangning Diao --- src/lightcone/cli/commands.py | 4 ++-- src/lightcone/engine/launcher.py | 11 +++++++++-- tests/test_launcher.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/lightcone/cli/commands.py b/src/lightcone/cli/commands.py index 507aa143..669be754 100644 --- a/src/lightcone/cli/commands.py +++ b/src/lightcone/cli/commands.py @@ -42,7 +42,7 @@ 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"): + if not os.environ.get("LIGHTCONE_CONTAINER") and not os.environ.get("LC_NO_CONTAINER_WARN"): console.print(_CONTAINER_WARNING) @@ -732,7 +732,7 @@ def verify(universe: str | None) -> None: "--runtime", default=None, help=( - "docker | podman | podman-hpc | apptainer | singularity" + "docker | podman | podman-hpc | apptainer | singularity " "(overrides ~/.lightcone/config.yaml)" ), ) diff --git a/src/lightcone/engine/launcher.py b/src/lightcone/engine/launcher.py index 1e36f7e1..c737e853 100644 --- a/src/lightcone/engine/launcher.py +++ b/src/lightcone/engine/launcher.py @@ -84,6 +84,10 @@ class LaunchTarget: #: 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. target "claude" is published as "claude-env"). + registry_name: str | None = None #: Set when _make_builtin_targets() catches a ContainerBuildError so @@ -126,6 +130,9 @@ def _make_builtin_targets() -> dict[str, LaunchTarget]: # running as the host UID/GID also ensures correct ownership on # the mounted project directory. run_as_host_user=True, + # CI publishes as "claude-env"; the target name stays "claude" for + # the CLI surface (`lc launch claude`). + registry_name="claude-env", ), } @@ -350,14 +357,14 @@ def launch_target( version = _lc_version() pulled = False if not _is_dev_version(version): - registry_ref = _registry_image_ref(target.name, 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: _print(f"Building {name} container (first run — this may take a few minutes)…") 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): + if not image_exists_locally(tag, runtime=choice.runtime, project_path=project_root): load_image_from_tarball(tarball, runtime=choice.runtime) _exec_interactive(target, tag, choice, project_root) diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 64ec503e..fb34527a 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -57,6 +57,8 @@ def test_claude_target_fields(self) -> None: assert ".claude.json" in t.home_mounts assert ".claude" in t.home_mounts assert t.run_as_host_user is True + # registry_name must match the CI-published image name, not the target name + assert t.registry_name == "claude-env" class TestResolveTarget: @@ -683,6 +685,34 @@ def test_tag_failure_returns_false( class TestLaunchTargetGhcrPull: """Tests for the GHCR pull-first path in launch_target.""" + @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-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_exec: MagicMock, + project: Path, + ) -> None: + """claude target uses registry_name='claude-env', 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/claude-env: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") From 1388a7e5405e684c68983ff5b1f8863ac747b1b2 Mon Sep 17 00:00:00 2001 From: Alexandre Boucaud Date: Wed, 6 May 2026 12:36:39 +0200 Subject: [PATCH 15/21] feat(launcher): tag launch images as lightcone-: for discoverability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the container image is confirmed loaded, apply a second human-readable tag (lightcone-:) so the image appears with a meaningful name in `docker images` / `podman images`. The content-addressed tag (lc--) is preserved for all internal caching logic. Tagging failure is silently swallowed — the tracking tag is cosmetic. Co-Authored-By: Claude Sonnet 4.6 --- src/lightcone/engine/launcher.py | 26 +++++++++++ tests/test_launcher.py | 74 ++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/src/lightcone/engine/launcher.py b/src/lightcone/engine/launcher.py index c737e853..6bebd299 100644 --- a/src/lightcone/engine/launcher.py +++ b/src/lightcone/engine/launcher.py @@ -299,6 +299,30 @@ def _registry_image_ref(target_name: str, version: str) -> str: 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, @@ -367,6 +391,8 @@ def launch_target( if not image_exists_locally(tag, runtime=choice.runtime, project_path=project_root): load_image_from_tarball(tarball, runtime=choice.runtime) + _apply_tracking_tag(tag, _tracking_image_ref(project_root, _lc_version()), choice.runtime) + _exec_interactive(target, tag, choice, project_root) diff --git a/tests/test_launcher.py b/tests/test_launcher.py index fb34527a..6e46f164 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -10,9 +10,11 @@ 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, ) @@ -800,3 +802,75 @@ def test_dev_version_skips_pull( 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 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") From 5ee7c8becd84ed8f43dae83ce44a4f4a65260422 Mon Sep 17 00:00:00 2001 From: Alexandre Boucaud Date: Wed, 6 May 2026 16:41:22 +0200 Subject: [PATCH 16/21] feat(containers): rename lightcone-sandbox Containerfile; strip proprietary harness from base image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed claude-env.Containerfile → lightcone-sandbox.Containerfile - Removed npm install -g @anthropic-ai/claude-code and ENTRYPOINT ["claude"] - Added unzip (required by OpenCode install script) - Changed ENTRYPOINT to ["bash"] — image is now harness-agnostic - Published as ghcr.io/lightconeresearch/lightcone-sandbox: Co-Authored-By: Claude Sonnet 4.6 --- ...erfile => lightcone-sandbox.Containerfile} | 25 ++++++------------- src/lightcone/engine/launcher.py | 11 +++----- 2 files changed, 10 insertions(+), 26 deletions(-) rename claude/lightcone/containers/{claude-env.Containerfile => lightcone-sandbox.Containerfile} (60%) diff --git a/claude/lightcone/containers/claude-env.Containerfile b/claude/lightcone/containers/lightcone-sandbox.Containerfile similarity index 60% rename from claude/lightcone/containers/claude-env.Containerfile rename to claude/lightcone/containers/lightcone-sandbox.Containerfile index 945337d6..3ee96036 100644 --- a/claude/lightcone/containers/claude-env.Containerfile +++ b/claude/lightcone/containers/lightcone-sandbox.Containerfile @@ -3,14 +3,7 @@ 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. -# Handles OCI archive execution: apptainer exec oci-archive: -# Both apt blocks are merged into one RUN so the apt lists remain live for the -# .deb install and /var/log/apt/eipp.log.xz is not left stale between layers -# (dpkg opens it with O_CREAT|O_EXCL; a pre-existing file from a prior layer -# causes exit code 2 on NERSC / podman rootless builds). -# python:3.12-slim-bookworm ships Python pre-installed, so python3/python3-dev -# are omitted from the apt block. build-essential is kept as a safety net for -# any C-extension deps that lack pre-built wheels. +# 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. @@ -25,6 +18,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ ca-certificates \ build-essential \ + unzip \ && ARCH="$(dpkg --print-architecture)" \ && if [ "$ARCH" = "amd64" ]; then \ curl -fsSL \ @@ -37,30 +31,25 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # uv + lightcone-cli. # LIGHTCONE_VERSION is substituted at render time (lc launch writes a rendered -# copy to .lightcone/containers/claude.Containerfile with the value filled in). +# 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 -# python:3.12-slim ships /usr/local/bin/python3.12 which uv --system targets. -# pre-built manylinux_2_17 wheels are accepted on Debian Bookworm (glibc 2.36), -# avoiding source-build failures for C-extension deps like immutables. -# Dev/local builds are not published to PyPI; the ARG is still baked in for -# content-addressed tag computation so the image rebuilds when lc is upgraded. -# For non-release strings we install the latest stable release from PyPI. +# 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 + Claude Code CLI +# 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 \ - && npm install -g @anthropic-ai/claude-code \ && rm -rf /var/lib/apt/lists/* # Marker read by lc build / lc run to detect containerized operation. ENV LIGHTCONE_CONTAINER=1 WORKDIR /workspace -ENTRYPOINT ["claude"] +ENTRYPOINT ["bash"] diff --git a/src/lightcone/engine/launcher.py b/src/lightcone/engine/launcher.py index 6bebd299..5fc1bfbb 100644 --- a/src/lightcone/engine/launcher.py +++ b/src/lightcone/engine/launcher.py @@ -106,12 +106,7 @@ def _make_builtin_targets() -> dict[str, LaunchTarget]: return { "claude": LaunchTarget( name="claude", - containerfile=containers_dir / "claude-env.Containerfile", - # The Containerfile ENTRYPOINT is already "claude"; arguments here - # are appended to it. --dangerously-skip-permissions suppresses the - # folder-trust prompt — appropriate because the container IS the - # sandbox. It also fixes the accidental "claude claude" invocation - # that occurred when "claude" was listed as its own entrypoint arg. + containerfile=containers_dir / "lightcone-sandbox.Containerfile", entrypoint=["--dangerously-skip-permissions"], env_passthrough=[ "ANTHROPIC_API_KEY", @@ -168,7 +163,7 @@ def resolve_launch_target(name: str, project_root: Path | None = None) -> Launch # Matches the comment + RUN block that handles lightcone-cli installation. # Present only in Containerfiles that use the dev-wheel fallback pattern. -# Intentionally coupled to claude-env.Containerfile's specific install block +# 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. @@ -268,7 +263,7 @@ def _render_containerfile(target: LaunchTarget, project_root: Path) -> Path: """ dest_dir = project_root / ".lightcone" / "containers" dest_dir.mkdir(parents=True, exist_ok=True) - dest = dest_dir / f"{target.name}.Containerfile" + dest = dest_dir / target.containerfile.name version = _lc_version() content = target.containerfile.read_text() From f62c62ff58be44e461ee40af4b627a1a4f9b2bd4 Mon Sep 17 00:00:00 2001 From: Alexandre Boucaud Date: Thu, 7 May 2026 00:27:45 +0200 Subject: [PATCH 17/21] =?UTF-8?q?feat(launcher):=20decouple=20harness=20in?= =?UTF-8?q?stall=20=E2=80=94=20commit-on-first-launch=20pattern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publish one neutral base image; install harness on first launch and commit as a local image (lightcone-:). Subsequent launches skip the install step and exec directly into the committed image. - LaunchTarget gains install_cmds and committed_tag_prefix fields - entrypoint carries full binary + args; --entrypoint overrides ENTRYPOINT ["bash"] - home_mounts uses trailing-slash convention; _ensure_host_path() auto-creates missing paths - _SANDBOX_IMAGE_NAME constant shared across harness targets - _image_exists() checks local image store - _ensure_harness_image() installs in temp container, commits, cleans up via finally - CalledProcessError wrapped as ContainerBuildError (consistent with rest of module) - claude, mistral-vibe and opencode targets defined with scoped home mounts - launch_target() wired to call _ensure_harness_image() when committed_tag_prefix is set Co-Authored-By: Claude Sonnet 4.6 --- src/lightcone/cli/commands.py | 2 +- src/lightcone/engine/launcher.py | 169 ++++++++++++++-- tests/test_launcher.py | 333 ++++++++++++++++++++++++++++++- 3 files changed, 475 insertions(+), 29 deletions(-) diff --git a/src/lightcone/cli/commands.py b/src/lightcone/cli/commands.py index 669be754..90d5bc62 100644 --- a/src/lightcone/cli/commands.py +++ b/src/lightcone/cli/commands.py @@ -869,7 +869,7 @@ def launch(target: str) -> None: lc launch claude # first run builds the container (~5 min), then drops into Claude Code """ from lightcone.engine import launcher - from lightcone.engine.container import ContainerBuildError, _DAEMONLESS_RUNTIMES, load_runtime + from lightcone.engine.container import _DAEMONLESS_RUNTIMES, ContainerBuildError, load_runtime project = _project_root() diff --git a/src/lightcone/engine/launcher.py b/src/lightcone/engine/launcher.py index 5fc1bfbb..23d5decf 100644 --- a/src/lightcone/engine/launcher.py +++ b/src/lightcone/engine/launcher.py @@ -17,6 +17,7 @@ import sys from dataclasses import dataclass, field from pathlib import Path +from uuid import uuid4 from lightcone.engine.container import ( _DAEMONLESS_RUNTIMES, @@ -35,6 +36,9 @@ # Registry where pre-built release images are published. _GHCR_PREFIX = "ghcr.io/lightconeresearch" +# 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. @@ -78,7 +82,8 @@ class LaunchTarget: 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. Only mounted when the path exists on the host. + #: 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) @@ -86,8 +91,14 @@ class LaunchTarget: 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. target "claude" is published as "claude-env"). + #: 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 @@ -106,8 +117,8 @@ def _make_builtin_targets() -> dict[str, LaunchTarget]: return { "claude": LaunchTarget( name="claude", - containerfile=containers_dir / "lightcone-sandbox.Containerfile", - entrypoint=["--dangerously-skip-permissions"], + containerfile=containers_dir / f"{_SANDBOX_IMAGE_NAME}.Containerfile", + entrypoint=["claude", "--dangerously-skip-permissions"], env_passthrough=[ "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", @@ -116,18 +127,60 @@ def _make_builtin_targets() -> dict[str, LaunchTarget]: "TERM", ], devices=["/dev/fuse"], - # Mount the host Claude Code config so settings, accepted terms, - # and API-key auth are available without re-running setup. - # ~/.claude.json — primary config file (API key, auth tokens) - # ~/.claude/ — settings, backups, conversation history - home_mounts=[".claude.json", ".claude"], + home_mounts=[ + ".claude.json", + ".claude/settings.json", + ".claude/settings.local.json", + ".claude/keybindings.json", + ], # 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, - # CI publishes as "claude-env"; the target name stays "claude" for - # the CLI surface (`lc launch claude`). - registry_name="claude-env", + registry_name=_SANDBOX_IMAGE_NAME, + install_cmds=["npm install -g @anthropic-ai/claude-code"], + 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"], + committed_tag_prefix="lightcone-opencode", ), } @@ -354,6 +407,58 @@ def _try_pull_and_cache( 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. + """ + committed_tag = f"{target.committed_tag_prefix}:{lc_version}" + + if not reinstall and _image_exists(committed_tag, runtime): + return committed_tag + + tmp_name = f"lc-install-{target.name}-{uuid4().hex[:8]}" + install_cmd = " && ".join(target.install_cmds) + _print(f"Installing {target.name} harness (first run — this may take a few minutes)…") + try: + subprocess.run( + [runtime, "run", "--name", tmp_name, base_image, "sh", "-c", install_cmd], + check=True, + ) + 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, *, @@ -386,11 +491,35 @@ def launch_target( 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(), + ) + _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, @@ -412,10 +541,12 @@ def _exec_interactive( home = os.environ.get("HOME") if home: - for subdir in target.home_mounts: - host_path = str(Path(home) / subdir) - if Path(host_path).exists(): - cmd += ["-v", f"{host_path}:{host_path}"] + 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(): @@ -444,13 +575,17 @@ def _exec_interactive( 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 + entrypoint_args = target.entrypoint[1:] if choice.runtime == "podman-hpc": entrypoint_args = [ a for a in entrypoint_args if a != "--dangerously-skip-permissions" diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 6e46f164..e2e40bea 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -2,7 +2,7 @@ from __future__ import annotations from pathlib import Path -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, patch import pytest @@ -52,15 +52,56 @@ def test_claude_is_registered(self) -> None: def test_claude_target_fields(self) -> None: t = BUILTIN_TARGETS["claude"] assert t.name == "claude" - assert t.entrypoint == ["--dangerously-skip-permissions"] + 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" in t.home_mounts assert t.run_as_host_user is True - # registry_name must match the CI-published image name, not the target name - assert t.registry_name == "claude-env" + assert t.registry_name == "lightcone-sandbox" + assert t.install_cmds == ["npm install -g @anthropic-ai/claude-code"] + 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"] + 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: @@ -271,6 +312,9 @@ def test_exec_called_with_runtime( 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) @@ -332,7 +376,7 @@ def test_run_as_host_user_adds_user_flag( target = LaunchTarget( name="fake", containerfile=tmp_path / "fake.Containerfile", - entrypoint=["--dangerously-skip-permissions"], + entrypoint=["claude", "--dangerously-skip-permissions"], run_as_host_user=True, ) (tmp_path / "fake.Containerfile").write_text( @@ -377,7 +421,7 @@ def test_run_as_host_user_uses_userns_keepid_for_podman( target = LaunchTarget( name="fake", containerfile=tmp_path / "fake.Containerfile", - entrypoint=["--dangerously-skip-permissions"], + entrypoint=["claude", "--dangerously-skip-permissions"], run_as_host_user=True, ) (tmp_path / "fake.Containerfile").write_text( @@ -426,7 +470,7 @@ def test_podman_hpc_neither_userns_nor_skip_permissions( target = LaunchTarget( name="fake", containerfile=tmp_path / "fake.Containerfile", - entrypoint=["--dangerously-skip-permissions"], + entrypoint=["claude", "--dangerously-skip-permissions"], run_as_host_user=True, ) (tmp_path / "fake.Containerfile").write_text( @@ -440,8 +484,10 @@ def test_podman_hpc_neither_userns_nor_skip_permissions( 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") @@ -570,7 +616,7 @@ def test_home_mounts_added_when_dir_exists( name="fake", containerfile=cf, entrypoint=["bash"], - home_mounts=[".claude"], + home_mounts=[".claude/"], # trailing slash = directory ) mock_resolve.return_value = target tarball = tmp_path / "lc-fake-abc123.tar" @@ -586,6 +632,137 @@ def test_home_mounts_added_when_dir_exists( 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", + ) + # _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") + @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.""" @@ -688,6 +865,7 @@ 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") @@ -700,10 +878,11 @@ def test_registry_name_overrides_target_name( mock_build: MagicMock, mock_save: MagicMock, mock_exists: MagicMock, + mock_ensure: MagicMock, mock_exec: MagicMock, project: Path, ) -> None: - """claude target uses registry_name='claude-env', not 'claude', for GHCR ref.""" + """claude target uses registry_name='lightcone-sandbox', not 'claude', for GHCR ref.""" from lightcone.engine.launcher import launch_target # No tarball — forces pull path @@ -713,7 +892,7 @@ def test_registry_name_overrides_target_name( # _try_pull_and_cache(tag, registry_ref, tarball, runtime=...) registry_ref = mock_pull.call_args[0][1] - assert registry_ref == "ghcr.io/lightconeresearch/claude-env:1.2.3" + 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) @@ -840,6 +1019,39 @@ def test_tracking_tag_applied_before_exec( ) +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" @@ -874,3 +1086,102 @@ 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_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) + assert calls[0][0] == "docker" + assert calls[0][1] == "run" + 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() + assert mock_run.call_count == 3 + + 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 From 6bff004fe395562b03917f0c72884622c6b16964 Mon Sep 17 00:00:00 2001 From: Alexandre Boucaud Date: Thu, 7 May 2026 23:32:39 +0200 Subject: [PATCH 18/21] feat(cli): add --reinstall flag to lc launch Forces re-installation of the harness layer on the base image. Removes the existing committed image first to avoid accumulating dangling layers. Co-Authored-By: Claude Sonnet 4.6 --- src/lightcone/cli/commands.py | 13 +++++--- src/lightcone/engine/launcher.py | 6 ++++ tests/test_cli.py | 31 ++++++++++++++++++ tests/test_launcher.py | 55 +++++++++++++++++++++++++++++++- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/lightcone/cli/commands.py b/src/lightcone/cli/commands.py index 90d5bc62..298e5618 100644 --- a/src/lightcone/cli/commands.py +++ b/src/lightcone/cli/commands.py @@ -856,17 +856,22 @@ def _ensure_images(project: Path, *, runtime: str, force: bool = False) -> None: @main.command("launch") @click.argument("target") -def launch(target: str) -> None: +@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. - Recipes build and execute inside nested containers from this environment. + 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 @@ -897,7 +902,7 @@ def launch(target: str) -> None: ) try: - launcher.launch_target(target, choice=choice, project_root=project) + launcher.launch_target(target, choice=choice, project_root=project, reinstall=reinstall) except ContainerBuildError as e: raise click.ClickException(str(e)) diff --git a/src/lightcone/engine/launcher.py b/src/lightcone/engine/launcher.py index 23d5decf..8a944a83 100644 --- a/src/lightcone/engine/launcher.py +++ b/src/lightcone/engine/launcher.py @@ -438,6 +438,10 @@ def _ensure_harness_image( 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) _print(f"Installing {target.name} harness (first run — this may take a few minutes)…") @@ -464,6 +468,7 @@ def launch_target( *, choice: RuntimeChoice, project_root: Path, + reinstall: bool = False, ) -> None: """Build (if needed) and exec the named launch target interactively. @@ -497,6 +502,7 @@ def launch_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) diff --git a/tests/test_cli.py b/tests/test_cli.py index 28fe826d..096453ea 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -156,6 +156,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_launcher.py b/tests/test_launcher.py index e2e40bea..d85d637f 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -679,6 +679,7 @@ def test_ensure_harness_called_when_committed_tag_prefix_set( 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" @@ -686,6 +687,55 @@ def test_ensure_harness_called_when_committed_tag_prefix_set( 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) @@ -1158,7 +1208,10 @@ def test_reinstall_skips_image_exists_check( ) mock_exists.assert_not_called() - assert mock_run.call_count == 3 + # 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 From d15c903b0b2dc8b11fbdc12f8a43b591a61919e3 Mon Sep 17 00:00:00 2001 From: Alexandre Boucaud Date: Fri, 8 May 2026 11:01:01 +0200 Subject: [PATCH 19/21] fix(launcher): harden harness install (entrypoint override, OCI tag, session-env, npm noise) Fixes found during smoke testing: - Add --entrypoint sh to bypass ENTRYPOINT ["bash"] in the install container - Sanitize lc_version for OCI tags: replace '+' with '-' (PEP 440 local versions) - Mount .claude/session-env/ so SessionStart hook can write inside the container - Suppress npm output with --loglevel=error --no-update-notifier Co-Authored-By: Claude Sonnet 4.6 --- src/lightcone/engine/launcher.py | 14 ++++++++++---- tests/test_launcher.py | 29 ++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/lightcone/engine/launcher.py b/src/lightcone/engine/launcher.py index 8a944a83..fa416f7f 100644 --- a/src/lightcone/engine/launcher.py +++ b/src/lightcone/engine/launcher.py @@ -132,13 +132,16 @@ def _make_builtin_targets() -> dict[str, LaunchTarget]: ".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"], + install_cmds=[ + "npm install -g @anthropic-ai/claude-code --loglevel=error --no-update-notifier" + ], committed_tag_prefix="lightcone-claude", ), "mistral-vibe": LaunchTarget( @@ -179,7 +182,7 @@ def _make_builtin_targets() -> dict[str, LaunchTarget]: ".config/opencode/AGENTS.md", ], registry_name=_SANDBOX_IMAGE_NAME, - install_cmds=["npm install -g opencode-ai"], + install_cmds=["npm install -g opencode-ai --loglevel=error --no-update-notifier"], committed_tag_prefix="lightcone-opencode", ), } @@ -433,7 +436,9 @@ def _ensure_harness_image( result as ``:``, and removes the temp container. On subsequent calls the existing committed image is reused. """ - committed_tag = f"{target.committed_tag_prefix}:{lc_version}" + # 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 @@ -447,7 +452,8 @@ def _ensure_harness_image( _print(f"Installing {target.name} harness (first run — this may take a few minutes)…") try: subprocess.run( - [runtime, "run", "--name", tmp_name, base_image, "sh", "-c", install_cmd], + [runtime, "run", "--entrypoint", "sh", "--name", tmp_name, + base_image, "-c", install_cmd], check=True, ) subprocess.run( diff --git a/tests/test_launcher.py b/tests/test_launcher.py index d85d637f..55371e17 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -57,9 +57,12 @@ def test_claude_target_fields(self) -> None: 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"] + 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: @@ -85,7 +88,9 @@ def test_opencode_is_registered(self) -> None: def test_opencode_target_fields(self) -> None: t = BUILTIN_TARGETS["opencode"] assert t.name == "opencode" - assert t.install_cmds == ["npm install -g opencode-ai"] + 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 @@ -1165,6 +1170,22 @@ def test_returns_committed_tag_when_image_exists( 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: @@ -1179,9 +1200,11 @@ def test_installs_and_commits_when_image_absent( assert result == "lightcone-claude:1.2.3" calls = [c[0][0] for c in mock_run.call_args_list] - # First call: docker run (install) + # 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" From e3826afb9f90430aeb22d59eb4752b5cb39598ee Mon Sep 17 00:00:00 2001 From: Alexandre Boucaud Date: Fri, 8 May 2026 11:45:33 +0200 Subject: [PATCH 20/21] feat(launcher): rich progress spinners and /lc-new welcome panel - Replace stderr prints with Console.status() spinners (disappear on completion) - Install subprocess output captured; stderr surfaced in red on failure - Show a rich Panel before exec suggesting /lc-new to new users Co-Authored-By: Claude Sonnet 4.6 --- src/lightcone/engine/launcher.py | 71 ++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/src/lightcone/engine/launcher.py b/src/lightcone/engine/launcher.py index fa416f7f..6d245e79 100644 --- a/src/lightcone/engine/launcher.py +++ b/src/lightcone/engine/launcher.py @@ -14,11 +14,12 @@ import os import re import subprocess -import sys 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, @@ -36,6 +37,8 @@ # 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" @@ -394,16 +397,16 @@ def _try_pull_and_cache( if runtime in _DAEMONLESS_RUNTIMES: return False try: - _print(f"Pulling {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) + 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.") @@ -449,18 +452,23 @@ def _ensure_harness_image( tmp_name = f"lc-install-{target.name}-{uuid4().hex[:8]}" install_cmd = " && ".join(target.install_cmds) - _print(f"Installing {target.name} harness (first run — this may take a few minutes)…") try: - subprocess.run( - [runtime, "run", "--entrypoint", "sh", "--name", tmp_name, - base_image, "-c", install_cmd], - check=True, - ) - subprocess.run( - [runtime, "commit", tmp_name, committed_tag], - check=True, - capture_output=True, - ) + 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: @@ -495,9 +503,9 @@ def launch_target( 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: - _print(f"Building {name} container (first run — this may take a few minutes)…") - build_image(tag, rendered_cf, rendered_cf.parent, runtime=choice.runtime) - save_image_as_tarball(tag, tarball, runtime=choice.runtime) + 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) @@ -607,8 +615,19 @@ def _exec_interactive( 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: - print(msg, file=sys.stderr) + _console.print(msg) From ef7cd5e59c83470fcec080d828165a67e38c272a Mon Sep 17 00:00:00 2001 From: Alexandre Boucaud Date: Thu, 7 May 2026 23:36:32 +0200 Subject: [PATCH 21/21] docs: add design spec for harness-decoupling feature Co-Authored-By: Claude Sonnet 4.6 --- ...06-decouple-harness-installation-design.md | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-06-decouple-harness-installation-design.md 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.