|
| 1 | +# Design Spec: Decouple Harness Installation from Container Image |
| 2 | + |
| 3 | +**Date:** 2026-05-06 |
| 4 | +**Branch:** sandboxing-execution |
| 5 | +**Status:** Implemented |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## Problem |
| 10 | + |
| 11 | +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: |
| 12 | + |
| 13 | +1. **Licensing**: Publishing an image containing Claude Code (a proprietary CLI) raises redistribution concerns. |
| 14 | +2. **Extensibility**: Supporting a second harness (Mistral Vibe, OpenCode) required a separate published image per harness, bloating the registry. |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## Solution |
| 19 | + |
| 20 | +Publish one neutral `lightcone-sandbox` base image (system tools only). On first `lc launch <harness>`, install the harness inside a running container and commit the result as a local image (`lightcone-<harness>:<version>`). Subsequent launches detect the committed image and skip straight to exec. |
| 21 | + |
| 22 | +--- |
| 23 | + |
| 24 | +## Architecture |
| 25 | + |
| 26 | +### Base Image (`lightcone-sandbox.Containerfile`) |
| 27 | + |
| 28 | +Renamed from `claude-env.Containerfile`. Key changes: |
| 29 | +- Removed: `npm install -g @anthropic-ai/claude-code` and `ENTRYPOINT ["claude"]` |
| 30 | +- Added: `unzip` to apt-get (required by OpenCode install script) |
| 31 | +- Changed: `ENTRYPOINT ["bash"]` |
| 32 | +- Kept: Python 3.12-slim-bookworm, FUSE, Apptainer 1.4.0, buildah, Node.js LTS, uv, lightcone-cli, `LIGHTCONE_CONTAINER=1` |
| 33 | + |
| 34 | +Published as: `ghcr.io/lightconeresearch/lightcone-sandbox:<version>` |
| 35 | +No harness-specific images are published. |
| 36 | + |
| 37 | +### LaunchTarget Dataclass |
| 38 | + |
| 39 | +Two new fields added to `LaunchTarget` (frozen dataclass in `launcher.py`): |
| 40 | + |
| 41 | +```python |
| 42 | +install_cmds: list[str] # shell commands joined with " && " and run via sh -c |
| 43 | +committed_tag_prefix: str # e.g. "lightcone-claude" → tag "lightcone-claude:<lc_version>" |
| 44 | +``` |
| 45 | + |
| 46 | +The `entrypoint` field (pre-existing) now carries the full binary + args, e.g. `["claude", "--dangerously-skip-permissions"]`. The launcher passes `--entrypoint <binary>` before the image tag and appends remaining args after. |
| 47 | + |
| 48 | +### Harness Targets |
| 49 | + |
| 50 | +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. |
| 51 | + |
| 52 | +| Field | claude | mistral-vibe | opencode | |
| 53 | +|---|---|---|---| |
| 54 | +| `install_cmds` | `npm install -g @anthropic-ai/claude-code` | `uv tool install mistral-vibe` | `npm install -g opencode-ai` | |
| 55 | +| `committed_tag_prefix` | `lightcone-claude` | `lightcone-mistral-vibe` | `lightcone-opencode` | |
| 56 | +| `entrypoint` | `["claude", "--dangerously-skip-permissions"]` | `["vibe"]` | `["opencode"]` | |
| 57 | +| `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` | |
| 58 | +| `run_as_host_user` | `True` | `False` | `False` | |
| 59 | + |
| 60 | +### Home Mounts (Granular Strategy) |
| 61 | + |
| 62 | +`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()`. |
| 63 | + |
| 64 | +**Excluded** (never mounted — sensitive or ephemeral): |
| 65 | +- claude: `.claude/projects/`, `.claude/logs/`, `.claude/statsig/` |
| 66 | +- mistral-vibe: `.vibe/logs/`, `.vibe/.env` |
| 67 | +- opencode: `.local/share/opencode/storage/` |
| 68 | + |
| 69 | +### `_ensure_harness_image()` Flow |
| 70 | + |
| 71 | +``` |
| 72 | +committed_tag = f"{target.committed_tag_prefix}:{lc_version}" |
| 73 | +
|
| 74 | +if not reinstall and image_exists_locally(committed_tag): |
| 75 | + return committed_tag # fast path: second+ launch |
| 76 | +
|
| 77 | +if reinstall: |
| 78 | + rmi committed_tag # remove old image to avoid dangling layers |
| 79 | +
|
| 80 | +tmp = "lc-install-<name>-<uuid8>" |
| 81 | +try: |
| 82 | + docker run --name tmp base_image sh -c install_cmd |
| 83 | + docker commit tmp committed_tag (capture_output=True) |
| 84 | +except CalledProcessError: |
| 85 | + raise ContainerBuildError(...) |
| 86 | +finally: |
| 87 | + docker rm -f tmp (check=False, capture_output=True) |
| 88 | +
|
| 89 | +return committed_tag |
| 90 | +``` |
| 91 | + |
| 92 | +`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. |
| 93 | + |
| 94 | +### `--reinstall` Flag |
| 95 | + |
| 96 | +``` |
| 97 | +lc launch claude --reinstall |
| 98 | +``` |
| 99 | + |
| 100 | +Forces re-installation: removes the existing committed image (to avoid dangling layer accumulation), then runs install again and commits a fresh image. |
| 101 | + |
| 102 | +--- |
| 103 | + |
| 104 | +## Files Changed |
| 105 | + |
| 106 | +| File | Change | |
| 107 | +|---|---| |
| 108 | +| `claude/lightcone/containers/lightcone-sandbox.Containerfile` | Renamed from `claude-env.Containerfile`; stripped harness install and ENTRYPOINT; added `unzip` | |
| 109 | +| `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()` | |
| 110 | +| `src/lightcone/cli/commands.py` | Added `--reinstall` flag; updated `lc launch` help text | |
| 111 | +| `tests/test_launcher.py` | Tests for all new helpers and harness targets; `TestLaunchTargetEnsureHarness` for end-to-end wiring | |
| 112 | +| `tests/test_cli.py` | `test_launch_reinstall_forwarded_to_launch_target` | |
| 113 | + |
| 114 | +--- |
| 115 | + |
| 116 | +## Key Invariants |
| 117 | + |
| 118 | +- The base `lightcone-sandbox` image contains no proprietary software — safe to publish. |
| 119 | +- Harness images are local-only (never pushed); they are rebuilt by `--reinstall` or when the committed tag is absent. |
| 120 | +- Sensitive config paths (logs, session history, API key files) are never mounted into the container. |
| 121 | +- `_ensure_harness_image` always cleans up the temp container (via `finally`), even on install failure. |
| 122 | +- `CalledProcessError` from subprocess is always wrapped as `ContainerBuildError` — consistent with the rest of the launcher module. |
0 commit comments