From 8262b5f39d021507c52c44afc9f6d85995372a34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= Date: Mon, 21 Sep 2026 14:31:12 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=B0=EF=B8=8F=20export=20from=20upstrea?= =?UTF-8?q?m=20(18069bd)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/hooks.json | 15 + .gitattributes | 6 + .gitignore | 33 + .sandcage/.gitignore | 1 + .sandcage/config.toml | 18 + AGENTS.md | 60 + Cargo.lock | 2801 +++++++++++++++++++ Cargo.toml | 3 + README.md | 166 ++ config-layers.svg | 82 + crates/sandcage/Cargo.toml | 50 + crates/sandcage/src/acp/mod.rs | 231 ++ crates/sandcage/src/acp/registry.rs | 456 +++ crates/sandcage/src/backend/bollard.rs | 728 +++++ crates/sandcage/src/backend/compose.rs | 112 + crates/sandcage/src/backend/mod.rs | 57 + crates/sandcage/src/backend/network.rs | 103 + crates/sandcage/src/backend/orchestrator.rs | 148 + crates/sandcage/src/backend/runtime.rs | 187 ++ crates/sandcage/src/config.rs | 1090 ++++++++ crates/sandcage/src/config_source.rs | 304 ++ crates/sandcage/src/docker.rs | 1892 +++++++++++++ crates/sandcage/src/forwarding.rs | 831 ++++++ crates/sandcage/src/init.rs | 493 ++++ crates/sandcage/src/lib.rs | 18 + crates/sandcage/src/main.rs | 412 +++ crates/sandcage/src/recipe.rs | 298 ++ crates/sandcage/src/service/builtin.rs | 166 ++ crates/sandcage/src/service/compose.rs | 154 + crates/sandcage/src/service/mod.rs | 266 ++ crates/sandcage/src/service/registry.rs | 237 ++ crates/sandcage/src/setup.rs | 1358 +++++++++ crates/sandcage/src/shadow.rs | 314 +++ crates/sandcage/src/spoof.rs | 170 ++ crates/sandcage/src/ssh_config.rs | 449 +++ crates/sandcage/src/tasks.rs | 97 + crates/sandcage/src/workspace.rs | 114 + crates/sandcage/src/worktree.rs | 224 ++ crates/thumper/Cargo.toml | 13 + crates/thumper/src/client.rs | 311 ++ crates/thumper/src/main.rs | 44 + crates/thumper/src/protocol.rs | 113 + docs/commands.md | 434 +++ docs/configuration.md | 577 ++++ docs/docker-image.md | 113 + docs/images/tty-decision-tree.svg | 88 + docs/interactive-mode.md | 91 + docs/ssh.md | 224 ++ justfile | 22 + sandcage_logo.png | Bin 0 -> 401720 bytes templates/claude/settings.json | 1 + topology.svg | 113 + 52 files changed, 16288 insertions(+) create mode 100644 .codex/hooks.json create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .sandcage/.gitignore create mode 100644 .sandcage/config.toml create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 config-layers.svg create mode 100644 crates/sandcage/Cargo.toml create mode 100644 crates/sandcage/src/acp/mod.rs create mode 100644 crates/sandcage/src/acp/registry.rs create mode 100644 crates/sandcage/src/backend/bollard.rs create mode 100644 crates/sandcage/src/backend/compose.rs create mode 100644 crates/sandcage/src/backend/mod.rs create mode 100644 crates/sandcage/src/backend/network.rs create mode 100644 crates/sandcage/src/backend/orchestrator.rs create mode 100644 crates/sandcage/src/backend/runtime.rs create mode 100644 crates/sandcage/src/config.rs create mode 100644 crates/sandcage/src/config_source.rs create mode 100644 crates/sandcage/src/docker.rs create mode 100644 crates/sandcage/src/forwarding.rs create mode 100644 crates/sandcage/src/init.rs create mode 100644 crates/sandcage/src/lib.rs create mode 100644 crates/sandcage/src/main.rs create mode 100644 crates/sandcage/src/recipe.rs create mode 100644 crates/sandcage/src/service/builtin.rs create mode 100644 crates/sandcage/src/service/compose.rs create mode 100644 crates/sandcage/src/service/mod.rs create mode 100644 crates/sandcage/src/service/registry.rs create mode 100644 crates/sandcage/src/setup.rs create mode 100644 crates/sandcage/src/shadow.rs create mode 100644 crates/sandcage/src/spoof.rs create mode 100644 crates/sandcage/src/ssh_config.rs create mode 100644 crates/sandcage/src/tasks.rs create mode 100644 crates/sandcage/src/workspace.rs create mode 100644 crates/sandcage/src/worktree.rs create mode 100644 crates/thumper/Cargo.toml create mode 100644 crates/thumper/src/client.rs create mode 100644 crates/thumper/src/main.rs create mode 100644 crates/thumper/src/protocol.rs create mode 100644 docs/commands.md create mode 100644 docs/configuration.md create mode 100644 docs/docker-image.md create mode 100644 docs/images/tty-decision-tree.svg create mode 100644 docs/interactive-mode.md create mode 100644 docs/ssh.md create mode 100644 justfile create mode 100644 sandcage_logo.png create mode 100644 templates/claude/settings.json create mode 100644 topology.svg diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..efc8c42 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|Read|Edit|Write", + "hooks": [ + { + "type": "command", + "command": "fermata hook --harness codex" + } + ] + } + ] + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c834034 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +*.md text eol=lf +*.rs text eol=lf +*.py text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68a2473 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Generated by Cargo +# will have compiled files and executables +/target +.DS_Store +.env + +# Patterns +*.rs.bk +*.tmp +*.bak +*.pyc +*.pyo +__pycache__ +*.log +*.pdb + + +# Python / uv +.venv/ + +# Export worktrees +.export-worktrees/ + +# Build pipelines and scratch +/dist/ +/local/** + +# Misc +/.vscode/* +.obsidian +settings.local.json +.claude/worktrees/ +CLAUDE.local.md diff --git a/.sandcage/.gitignore b/.sandcage/.gitignore new file mode 100644 index 0000000..51b4cfd --- /dev/null +++ b/.sandcage/.gitignore @@ -0,0 +1 @@ +local/ diff --git a/.sandcage/config.toml b/.sandcage/config.toml new file mode 100644 index 0000000..899df4b --- /dev/null +++ b/.sandcage/config.toml @@ -0,0 +1,18 @@ +# Sandcage project configuration +# Docs: https://github.com/user/sandcage + +# Detected ecosystem: rust + +packages = ["ripgrep", "fd-find"] + +[toolchains] +rust = true +# uv powers the project's Python tooling (see CLAUDE.md); keep it in the dev image. +uv = true + +[agent_args] +claude = ["--dangerously-skip-permissions"] + +# shell = "zsh" + +# container_workspace = "/workspace/my-project" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b0c4a6c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# Sandcage + +Rust CLI project for managing Docker-based development environments. + +## Project structure + +- `crates/sandcage/` — Main CLI binary (Cargo workspace member) +- `crates/sandcage-test/` — Integration/functional tests (not exported) +- `images/` — Base Dockerfile, fragments, and per-service entrypoint scripts +- `templates/` — Project templates for `sandcage init` +- `scripts/` — Python scripts for tooling, deployment, and automation +- `docs/` — Documentation + +## Python / uv + +This is **not** a Python project. The `pyproject.toml` and uv setup exist to provide a +managed Python environment for tooling: deploy scripts, data processing, automation recipes, +CI helpers, and any ad-hoc scripting that skills or plugins need. + +Run Python scripts via uv: + +```sh +uv run scripts/some_script.py +uv run python -c "..." +``` + +Add dependencies as needed: + +```sh +uv add +uv add --dev +``` + +## Just commands + +Project tasks are run via `just`. Run `just` to see all available recipes. + +When asked to "export", use: + +```sh +just export-dry # preview locally +just export # push to public repo +``` + +Configuration: `scripts/export.toml` (excluded paths, asset overlay directory). +Public-facing assets (README, etc.): `scripts/export_assets/`. + +## Testing + +There is no CI pipeline. Do not plan or implement CI-based builds, test matrices, or +CI config files unless explicitly asked. + +After finishing a feature implementation, run `just test-functional` (requires Docker + +built images) at least once before reporting the work as done. If images need rebuilding +first, use `just test-functional-full`. This catches regressions that unit tests miss — +like container launch, TTY attach, and mount behavior. + +## Conventions + +See `CONVENTION.md` for commit message format (gitmoji). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..36ec025 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2801 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bollard" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d0a013e3d3ee4edd61e779adf117944c08902d375f18630a0c5b8f95659734" +dependencies = [ + "base64", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.53.1-rc.29.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889" +dependencies = [ + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sandcage" +version = "0.1.0" +dependencies = [ + "async-trait", + "bollard", + "clap", + "crossterm", + "dialoguer", + "dirs", + "figment", + "flate2", + "futures-util", + "hex", + "indexmap", + "miette", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tar", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml", + "toml_edit", + "walkdir", + "which", +] + +[[package]] +name = "sandcage-test" +version = "0.0.0" +dependencies = [ + "assert_cmd", + "bollard", + "futures-util", + "predicates", + "sandcage", + "serde_yaml", + "tempfile", + "tokio", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thumper" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix 1.1.4", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cf86b2d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ['crates/sandcage', 'crates/thumper'] +resolver = "3" diff --git a/README.md b/README.md new file mode 100644 index 0000000..b8d7b63 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +

+ Sandcage +

+ +

Sandcage

+ +

+ Run AI coding agents in isolated Docker containers — your machine stays yours. +

+ +--- + +> [!CAUTION] +> **Unreleased Software** +> +> This project is under active development and **not yet released**. APIs, configuration formats, and behavior may change without notice. Please **do not use without contacting the author** about the current state of the project. + +> [!WARNING] +> **Development Tool Only** +> +> Sandcage is designed for **local development use**. Do **not** use it in CI pipelines or production environments — container isolation is not yet hardened for those contexts. + +--- + +## Why Sandcage? + +AI coding agents need broad access to do their work: shell, filesystem, network. Letting them run directly on your machine means they share your credentials, your session history, and your entire environment. + +Sandcage gives each agent its own container with the tools it needs. Your project is mounted in, changes are visible on the host, but the agent never touches your shell config, your SSH agent, or anything else outside the sandbox. + +Multiple agents can run side by side. A persistent home directory means config and credentials survive between sessions, so you are not re-authenticating every time. Named home profiles (`--home work`, `--home personal`) let you switch between different accounts without re-logging in. + + + +## Quick Start + +**Prerequisites:** Docker (running) and a Rust toolchain (cargo). + +```bash +# Install +cargo install --git https://github.com/dirigence/sandcage + +# Build the container image +sandcage build + +# Run Claude Code in the current project +sandcage claude +``` + +That's it. Sandcage resolves your project to its git root, mounts it into the container, and drops you into the agent. + +## Usage + +``` +sandcage claude # Claude Code agent +sandcage codex # Codex agent +sandcage gemini # Gemini CLI agent +sandcage shell # interactive shell, same environment +sandcage claude -p ~/project # run in a specific project +sandcage claude -- --resume # forward args to the agent +sandcage claude --worktree # run in a disposable git worktree +sandcage claude --home work # use a named home profile +sandcage claude --shell # shell for debugging +sandcage build # build/rebuild container image +sandcage init # scaffold .sandcage/ directory for your project +sandcage setup ssh # configure SSH key access for containers +sandcage worktree list # list managed worktrees +sandcage worktree clean # remove managed worktrees +sandcage acp run claude # ACP relay mode (bidirectional stdio) +sandcage acp list # list agents in the ACP registry +sandcage acp install # install an agent from the registry +sandcage acp installed # list locally installed agents +``` + +## How It Works + +

+ Sandcage topology — host, Docker, container, volume mounts +

+ +1. You run `sandcage claude` from your project directory +2. Sandcage resolves the workspace, loads layered config, and generates a compose definition +3. Your project, persistent home profile, and (optionally) SSH keys are mounted into the container +4. The agent runs as the container entrypoint, working in the mounted workspace +5. All file changes are immediately visible on your host + +## ACP Support + +Sandcage includes built-in support for the Agent Control Protocol (ACP). Running `sandcage acp run ` starts the named agent container and establishes a bidirectional stdio relay, letting any ACP-compatible client communicate with the agent over standard I/O. Agents can be installed from the ACP registry with `sandcage acp install ` and browsed with `sandcage acp list`. + +## Configuration + +

+ Configuration layering — defaults, global, project, local, CLI flags +

+ +Configuration is layered — each level overrides the one below: + +| Layer | File | Format | +|-------|------|--------| +| Global | `~/.sandcage/config.toml` | TOML | +| Project | `.sandcage/sandcage.yml` | YAML | +| Local | `.sandcage/local/sandcage.local.yml` | YAML | + +Run `sandcage init` to scaffold a `.sandcage/` directory — it detects your project ecosystem (Rust, Node, Python, Go) and suggests appropriate toolchains and packages. + +``` +.sandcage/ +├── sandcage.yml # project config (committed) +├── overlay/ # files bind-mounted over originals in container +├── local/ # gitignored +│ ├── sandcage.local.yml +│ └── overlay/ +└── .gitignore # contains "local/" +``` + +```yaml +# .sandcage/sandcage.yml — minimal example +toolchains: + node: "20" +packages: + - ripgrep +services: + gemini: + enabled: false +``` + +See **[Configuration Reference](docs/configuration.md)** for all available options. + +## Documentation + +| Document | Description | +|----------|-------------| +| [Configuration Reference](docs/configuration.md) | All config fields, merge behavior, and examples | +| [Command Reference](docs/commands.md) | Every subcommand, flag, and usage pattern | +| [Interactive Mode](docs/interactive-mode.md) | How terminal sessions work, backend selection, and Windows compatibility | +| [Architecture](docs/architecture.md) | Isolation model, UID/GID mapping, and container design | +| [Docker Image](docs/docker-image.md) | Base image contents, building, custom Dockerfiles | +| [SSH Key Access](docs/ssh.md) | Setting up SSH for git inside containers | + +## Key Features + +- **Named home profiles** — `--home ` switches between separate agent identities (e.g., personal vs work accounts) without re-authenticating +- **Worktree mode** — `--worktree` creates a disposable git worktree for each run, keeping your main working tree untouched +- **Host forwarding** — containers can execute approved commands on the host via a per-run forwarding daemon, with audit logging +- **Tasks** — named operations (`[tasks]` config) that run on the host or in-cage, accessible as `just` recipes inside the container +- **Tool modes** — control how CLI tools behave in the container: run normally, replace with a message (spoof), or forward to the host with allow/deny argument filtering +- **Security profiles** — AppArmor and seccomp profiles applied to containers via `[profiles]` config +- **Shadow directories** — redirect build artifact dirs (`target/`, `.venv/`, `node_modules/`) into container-local storage + +## Planned Features + +- **Support for custom harnesses** — bring your own agent runtime beyond the built-in Claude Code, Codex, and Gemini CLI +- **Full encapsulation hardening** — for worker and CI environments, ensuring complete sandboxing of file system, network, and credentials + +## Cross-Platform + +Sandcage works on **Linux**, **macOS**, and **Windows** (PowerShell, cmd, and Git Bash). On Windows with WSL, it works from both the Windows and Linux sides. + +## License + +MIT diff --git a/config-layers.svg b/config-layers.svg new file mode 100644 index 0000000..4c573fa --- /dev/null +++ b/config-layers.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Configuration Layering + + + + Higher priority overrides + + + + Compiled Defaults + Built into the sandcage binary + Base values + always present + + + + Global Config + ~/.sandcage/config.toml + User-wide + SSH, shell, mounts + + + + Project Config + .sandcage.yml — checked into version control + Per-project + packages, toolchains, env + + + + Local Config + .sandcage.local.yml — gitignored, personal overrides + Per-developer + SSH keys, secrets, mounts + + + + CLI Flags + sandcage claude -p ~/project -- --resume + Per-invocation + highest priority + + + Each layer overrides the one below it. Only set what you need — sensible defaults handle the rest. + diff --git a/crates/sandcage/Cargo.toml b/crates/sandcage/Cargo.toml new file mode 100644 index 0000000..04f64b3 --- /dev/null +++ b/crates/sandcage/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "sandcage" +version = "0.1.0" +edition = "2024" +description = "Sandboxed containers for AI coding agents" +license = "MIT" +keywords = ["docker", "sandbox", "ai", "agent"] + +[features] +default = ["bollard"] +bollard = [ + "dep:bollard", + "dep:futures-util", + "dep:reqwest", + "dep:flate2", + "dep:tar", + "dep:crossterm", +] + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +figment = { version = "0.10", features = ["toml", "env"] } +miette = { version = "7", features = ["fancy"] } +thiserror = "2" +which = "7" +dirs = "6" +sha2 = "0.10" +hex = "0.4" +indexmap = { version = "2", features = ["serde"] } +tempfile = "3" +walkdir = "2" +dialoguer = "0.11" +toml = "0.8" +toml_edit = "0.22" +async-trait = "0.1" +tokio = { version = "1", features = [ + "rt-multi-thread", "io-std", "io-util", + "signal", "time", "macros", "process", +] } + +# Feature-gated +bollard = { version = "0.21", optional = true } +futures-util = { version = "0.3", optional = true } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } +flate2 = { version = "1", optional = true } +tar = { version = "0.4", optional = true } +crossterm = { version = "0.28", optional = true } diff --git a/crates/sandcage/src/acp/mod.rs b/crates/sandcage/src/acp/mod.rs new file mode 100644 index 0000000..21ea17e --- /dev/null +++ b/crates/sandcage/src/acp/mod.rs @@ -0,0 +1,231 @@ +pub mod registry; + +use clap::Subcommand; +use std::path::PathBuf; + +use crate::backend::ContainerBackend; +use crate::docker::DockerError; +use crate::service; +use crate::workspace; + +#[derive(Subcommand, Debug)] +pub enum AcpCommands { + /// Run a service in ACP relay mode (bidirectional stdio) + Run { + /// Service name (e.g., claude, codex, gemini) + service: String, + + /// Path to the project directory (defaults to current directory) + #[arg(long, short)] + path: Option, + + /// Arguments forwarded to the agent inside the container + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + agent_args: Vec, + }, + /// Install an agent from the ACP registry + Install { + /// Agent ID from the registry (e.g., claude-code) + agent_id: String, + /// Force re-download even if already installed + #[arg(long)] + force: bool, + }, + /// List available agents in the ACP registry + List { + /// Force refresh of the registry cache + #[arg(long)] + refresh: bool, + }, + /// List locally installed ACP agents + Installed, +} + +pub async fn handle( + cmd: AcpCommands, + backend: &dyn ContainerBackend, + workspace_path: Option, +) -> std::result::Result<(), DockerError> { + match cmd { + AcpCommands::Run { + service, + path, + agent_args, + } => { + let effective_path = path.or(workspace_path); + let workspace = workspace::resolve_workspace(effective_path.as_deref()) + .map_err(|e| DockerError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )))?; + + crate::init::preseed() + .map_err(|e| DockerError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )))?; + + let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?; + let global_config = home.join(".sandcage").join("config.toml"); + let config_src = crate::config_source::resolve_config_source(&workspace); + let config_root = config_src.as_ref().map(|s| s.root.clone()).unwrap_or_else(|| workspace.clone()); + let project_config = config_root.join(".sandcage").join("sandcage.yml"); + let local_config = config_root.join(".sandcage").join("local").join("sandcage.local.yml"); + let cfg = crate::config::resolve_config( + Some(&global_config), + Some(&project_config), + Some(&local_config), + ) + .map_err(|e| DockerError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )))?; + + let registry = service::registry::build_default_registry(&cfg); + let ctx = crate::docker::build_compose_context(&workspace, &cfg, &config_root).await?; + + let svc = registry.get(&service).ok_or_else(|| DockerError::UnknownService { + service: service.clone(), + available: registry.names().collect::>().join(", "), + })?; + + if !svc.enabled() { + return Err(DockerError::ServiceDisabled { + service: service.clone(), + }); + } + + eprintln!("sandcage: workspace \u{2192} {}", workspace.display()); + eprintln!("sandcage: acp relay \u{2192} {service}"); + + let exit_code = backend.run_acp_relay(svc, &ctx, &cfg, &agent_args).await?; + + if exit_code != 0 { + return Err(DockerError::ServiceFailed { + service, + code: exit_code, + }); + } + + Ok(()) + } + AcpCommands::Install { agent_id, force } => { + handle_install(&agent_id, force).await.map_err(|e| { + DockerError::SpawnFailed(std::io::Error::new(std::io::ErrorKind::Other, e)) + }) + } + AcpCommands::List { refresh } => { + handle_list(refresh).await.map_err(|e| { + DockerError::SpawnFailed(std::io::Error::new(std::io::ErrorKind::Other, e)) + }) + } + AcpCommands::Installed => { + handle_installed().map_err(|e| { + DockerError::SpawnFailed(std::io::Error::new(std::io::ErrorKind::Other, e)) + }) + } + } +} + +async fn handle_install(agent_id: &str, force: bool) -> std::result::Result<(), String> { + let index = registry::fetch_registry(false).await?; + let entry = registry::find_agent(&index, agent_id) + .ok_or_else(|| format!("Agent '{}' not found in registry", agent_id))?; + + let platform = registry::platform_key(); + let target = entry + .distribution + .binary + .as_ref() + .and_then(|b| b.get(platform)) + .ok_or_else(|| { + format!( + "Agent '{}' has no binary for platform '{}'", + agent_id, platform + ) + })?; + + if !force && registry::is_agent_installed(agent_id, &entry.version) { + eprintln!("sandcage: {} v{} already installed", entry.name, entry.version); + return Ok(()); + } + + if force { + if let Some(dir) = registry::agent_install_dir(agent_id, &entry.version) { + std::fs::remove_dir_all(&dir).ok(); + } + } + + registry::install_agent(entry, target).await?; + Ok(()) +} + +async fn handle_list(refresh: bool) -> std::result::Result<(), String> { + let index = registry::fetch_registry(refresh).await?; + let platform = registry::platform_key(); + + println!("{:<25} {:<20} {:<10} {}", "ID", "NAME", "VERSION", "PLATFORM"); + println!("{}", "-".repeat(70)); + + for agent in &index.agents { + let has_binary = agent + .distribution + .binary + .as_ref() + .map(|b| b.contains_key(platform)) + .unwrap_or(false); + + let platform_status = if has_binary { + "binary" + } else if agent.distribution.npx.is_some() { + "npx" + } else { + "none" + }; + + println!( + "{:<25} {:<20} {:<10} {}", + agent.id, agent.name, agent.version, platform_status + ); + } + + Ok(()) +} + +fn handle_installed() -> std::result::Result<(), String> { + let base = dirs::home_dir() + .map(|h| h.join(".sandcage").join("agents")) + .ok_or("cannot determine home directory")?; + + if !base.exists() { + println!("No agents installed."); + return Ok(()); + } + + println!("{:<25} {:<15} {}", "AGENT", "VERSION", "PATH"); + println!("{}", "-".repeat(60)); + + let entries = std::fs::read_dir(&base).map_err(|e| e.to_string())?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let agent_id = entry.file_name().to_string_lossy().to_string(); + if agent_id == "registry.json" || agent_id.ends_with(".timestamp") { + continue; + } + + let versions = std::fs::read_dir(&path).map_err(|e| e.to_string())?; + for ver_entry in versions.flatten() { + let ver_path = ver_entry.path(); + if !ver_path.is_dir() { + continue; + } + let version = ver_entry.file_name().to_string_lossy().to_string(); + println!("{:<25} {:<15} {}", agent_id, version, ver_path.display()); + } + } + + Ok(()) +} diff --git a/crates/sandcage/src/acp/registry.rs b/crates/sandcage/src/acp/registry.rs new file mode 100644 index 0000000..c990e36 --- /dev/null +++ b/crates/sandcage/src/acp/registry.rs @@ -0,0 +1,456 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +const REGISTRY_URL: &str = + "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; +const REFRESH_INTERVAL_SECS: u64 = 3600; +const FETCH_TIMEOUT_SECS: u64 = 30; + +#[derive(Debug, Deserialize)] +pub struct RegistryIndex { + #[serde(rename = "version")] + pub _version: String, + pub agents: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RegistryEntry { + pub id: String, + pub name: String, + pub version: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub repository: Option, + #[serde(default)] + pub website: Option, + #[serde(default)] + pub icon: Option, + pub distribution: RegistryDistribution, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RegistryDistribution { + #[serde(default)] + pub binary: Option>, + #[serde(default)] + pub npx: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RegistryBinaryTarget { + pub archive: String, + pub cmd: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: HashMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RegistryNpxDistribution { + pub package: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: HashMap, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AgentMeta { + pub cmd: String, + pub args: Vec, + pub env: HashMap, + pub version: String, + pub installed_at: String, +} + +fn agents_dir() -> Option { + dirs::home_dir().map(|h| h.join(".sandcage").join("agents")) +} + +fn cache_path() -> Option { + agents_dir().map(|d| d.join("registry.json")) +} + +fn timestamp_path() -> Option { + agents_dir().map(|d| d.join("registry.json.timestamp")) +} + +fn is_cache_fresh() -> bool { + let Some(ts_path) = timestamp_path() else { + return false; + }; + let Ok(content) = std::fs::read_to_string(&ts_path) else { + return false; + }; + let Ok(ts) = content.trim().parse::() else { + return false; + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now.saturating_sub(ts) < REFRESH_INTERVAL_SECS +} + +fn write_timestamp() { + if let Some(ts_path) = timestamp_path() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + std::fs::write(&ts_path, now.to_string()).ok(); + } +} + +pub async fn fetch_registry(force_refresh: bool) -> std::result::Result { + let cache = cache_path().ok_or("cannot determine home directory")?; + + if !force_refresh && is_cache_fresh() { + if let Ok(data) = std::fs::read_to_string(&cache) { + if let Ok(index) = serde_json::from_str::(&data) { + return Ok(index); + } + } + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS)) + .build() + .map_err(|e| format!("HTTP client error: {e}"))?; + + let resp = client + .get(REGISTRY_URL) + .send() + .await + .map_err(|e| format!("Failed to fetch registry: {e}"))?; + + let body = resp + .text() + .await + .map_err(|e| format!("Failed to read registry response: {e}"))?; + + let index: RegistryIndex = + serde_json::from_str(&body).map_err(|e| format!("Failed to parse registry JSON: {e}"))?; + + if let Some(dir) = agents_dir() { + std::fs::create_dir_all(&dir).ok(); + } + if let Some(ref path) = cache_path() { + std::fs::write(path, &body).ok(); + } + write_timestamp(); + + Ok(index) +} + +pub fn find_agent<'a>(index: &'a RegistryIndex, agent_id: &str) -> Option<&'a RegistryEntry> { + index.agents.iter().find(|a| a.id == agent_id) +} + +pub fn platform_key() -> &'static str { + if cfg!(target_arch = "x86_64") { + "linux-x86_64" + } else if cfg!(target_arch = "aarch64") { + "linux-aarch64" + } else { + "linux-x86_64" + } +} + +pub fn agent_install_dir(agent_id: &str, version: &str) -> Option { + let sanitized_id = agent_id.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-"); + agents_dir().map(|d| d.join(&sanitized_id).join(version)) +} + +pub fn is_agent_installed(agent_id: &str, version: &str) -> bool { + agent_install_dir(agent_id, version) + .map(|d| d.exists()) + .unwrap_or(false) +} + +pub async fn install_agent( + entry: &RegistryEntry, + target: &RegistryBinaryTarget, +) -> std::result::Result { + let install_dir = agent_install_dir(&entry.id, &entry.version) + .ok_or("cannot determine install directory")?; + + if install_dir.exists() { + return Ok(install_dir); + } + + eprintln!("sandcage: downloading {} v{}...", entry.name, entry.version); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(300)) + .build() + .map_err(|e| format!("HTTP client error: {e}"))?; + + let resp = client + .get(&target.archive) + .send() + .await + .map_err(|e| format!("Failed to download agent: {e}"))?; + + let bytes = resp + .bytes() + .await + .map_err(|e| format!("Failed to read agent archive: {e}"))?; + + let parent = install_dir.parent().ok_or("invalid install path")?; + std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?; + + let url_lower = target.archive.to_lowercase(); + if url_lower.ends_with(".tar.gz") || url_lower.ends_with(".tgz") { + let decoder = flate2::read::GzDecoder::new(&bytes[..]); + let mut archive = tar::Archive::new(decoder); + archive + .unpack(&install_dir) + .map_err(|e| format!("Failed to extract tar.gz: {e}"))?; + } else if url_lower.ends_with(".tar.bz2") || url_lower.ends_with(".tbz2") { + return Err("tar.bz2 extraction not yet supported".to_string()); + } else if url_lower.ends_with(".zip") { + return Err("zip extraction not yet supported — use tar.gz agents".to_string()); + } else { + return Err(format!("Unknown archive format: {}", target.archive)); + } + + let meta = AgentMeta { + cmd: target.cmd.clone(), + args: target.args.clone(), + env: target.env.clone(), + version: entry.version.clone(), + installed_at: chrono_free_now(), + }; + let meta_path = install_dir.join(".sandcage-meta.json"); + let meta_json = serde_json::to_string_pretty(&meta) + .map_err(|e| format!("Failed to serialize meta: {e}"))?; + std::fs::write(&meta_path, meta_json) + .map_err(|e| format!("Failed to write meta: {e}"))?; + + cleanup_old_versions(&entry.id, &entry.version); + + eprintln!("sandcage: installed {} v{}", entry.name, entry.version); + Ok(install_dir) +} + +fn chrono_free_now() -> String { + let dur = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + format!("{}", dur.as_secs()) +} + +fn cleanup_old_versions(agent_id: &str, current_version: &str) { + let sanitized_id = agent_id.replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-"); + let Some(base) = agents_dir().map(|d| d.join(&sanitized_id)) else { + return; + }; + let Ok(entries) = std::fs::read_dir(&base) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str != current_version && entry.path().is_dir() && name_str != ".sandcage-meta.json" { + std::fs::remove_dir_all(entry.path()).ok(); + } + } +} + +pub fn resolve_agent_command( + install_dir: &Path, + target: &RegistryBinaryTarget, +) -> std::result::Result<(PathBuf, Vec, HashMap), String> { + let cmd = target.cmd.strip_prefix("./").unwrap_or(&target.cmd); + + if cmd.contains("..") { + return Err("path traversal in agent cmd is not allowed".to_string()); + } + + let bin_path = install_dir.join(cmd); + if !bin_path.exists() { + return Err(format!( + "agent binary not found at {}", + bin_path.display() + )); + } + + Ok((bin_path, target.args.clone(), target.env.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_registry_json() -> &'static str { + r#"{ + "version": "1", + "agents": [ + { + "id": "test-agent", + "name": "Test Agent", + "version": "1.0.0", + "description": "A test agent", + "distribution": { + "binary": { + "linux-x86_64": { + "archive": "https://example.com/test-agent-linux-x86_64.tar.gz", + "cmd": "./test-agent", + "args": ["--acp"], + "env": {"FOO": "bar"} + } + } + } + }, + { + "id": "npx-only-agent", + "name": "NPX Agent", + "version": "2.0.0", + "description": "NPX only", + "distribution": { + "npx": { + "package": "@test/agent@2.0.0", + "args": ["--stdio"], + "env": {} + } + } + } + ] + }"# + } + + #[test] + fn parse_registry_index() { + let index: RegistryIndex = + serde_json::from_str(sample_registry_json()).expect("parse registry"); + assert_eq!(index.agents.len(), 2); + assert_eq!(index.agents[0].id, "test-agent"); + assert_eq!(index.agents[1].id, "npx-only-agent"); + } + + #[test] + fn find_agent_by_id() { + let index: RegistryIndex = + serde_json::from_str(sample_registry_json()).expect("parse"); + assert!(find_agent(&index, "test-agent").is_some()); + assert!(find_agent(&index, "nonexistent").is_none()); + } + + #[test] + fn binary_target_has_correct_fields() { + let index: RegistryIndex = + serde_json::from_str(sample_registry_json()).expect("parse"); + let agent = find_agent(&index, "test-agent").unwrap(); + let target = agent + .distribution + .binary + .as_ref() + .unwrap() + .get("linux-x86_64") + .unwrap(); + assert_eq!(target.cmd, "./test-agent"); + assert_eq!(target.args, vec!["--acp"]); + assert_eq!(target.env.get("FOO"), Some(&"bar".to_string())); + } + + #[test] + fn npx_agent_has_package() { + let index: RegistryIndex = + serde_json::from_str(sample_registry_json()).expect("parse"); + let agent = find_agent(&index, "npx-only-agent").unwrap(); + let npx = agent.distribution.npx.as_ref().unwrap(); + assert_eq!(npx.package, "@test/agent@2.0.0"); + } + + #[test] + fn resolve_command_strips_dot_slash() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("test-agent"); + std::fs::write(&bin, "#!/bin/sh\necho ok").unwrap(); + + let target = RegistryBinaryTarget { + archive: String::new(), + cmd: "./test-agent".to_string(), + args: vec!["--acp".to_string()], + env: HashMap::new(), + }; + + let (path, args, _env) = resolve_agent_command(tmp.path(), &target).unwrap(); + assert_eq!(path, bin); + assert_eq!(args, vec!["--acp"]); + } + + #[test] + fn resolve_command_rejects_path_traversal() { + let tmp = tempfile::tempdir().unwrap(); + let target = RegistryBinaryTarget { + archive: String::new(), + cmd: "../../../etc/passwd".to_string(), + args: vec![], + env: HashMap::new(), + }; + + let result = resolve_agent_command(tmp.path(), &target); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("path traversal")); + } + + #[test] + fn resolve_command_fails_for_missing_binary() { + let tmp = tempfile::tempdir().unwrap(); + let target = RegistryBinaryTarget { + archive: String::new(), + cmd: "./nonexistent".to_string(), + args: vec![], + env: HashMap::new(), + }; + + let result = resolve_agent_command(tmp.path(), &target); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); + } + + #[test] + fn agent_install_dir_sanitizes_id() { + let dir = agent_install_dir("my@agent/test", "1.0.0").unwrap(); + let dir_name = dir.parent().unwrap().file_name().unwrap().to_string_lossy(); + assert!(!dir_name.contains('@')); + assert!(!dir_name.contains('/')); + } + + #[test] + fn parse_registry_with_missing_optional_fields() { + let json = r#"{ + "version": "1", + "agents": [{ + "id": "minimal", + "name": "Minimal", + "version": "0.1.0", + "description": "", + "distribution": { + "binary": { + "linux-x86_64": { + "archive": "https://example.com/a.tar.gz", + "cmd": "./agent" + } + } + } + }] + }"#; + + let index: RegistryIndex = serde_json::from_str(json).expect("parse"); + let agent = &index.agents[0]; + assert!(agent.repository.is_none()); + assert!(agent.website.is_none()); + assert!(agent.icon.is_none()); + + let target = agent.distribution.binary.as_ref().unwrap().get("linux-x86_64").unwrap(); + assert!(target.args.is_empty()); + assert!(target.env.is_empty()); + } +} diff --git a/crates/sandcage/src/backend/bollard.rs b/crates/sandcage/src/backend/bollard.rs new file mode 100644 index 0000000..89b189f --- /dev/null +++ b/crates/sandcage/src/backend/bollard.rs @@ -0,0 +1,728 @@ +use std::collections::HashMap; + +use async_trait::async_trait; +use bollard::container::LogOutput; +use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountType}; +use bollard::Docker; +use crossterm::terminal; +use futures_util::StreamExt; +use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; + +use crate::config::SandcageConfig; +use crate::docker::{self, DockerError}; +use crate::service::registry::ServiceRegistry; +use crate::service::{ComposeContext, ComposeServiceDef, Service}; + +use super::Result; + +/// RAII guard that restores terminal from raw mode on drop. +struct RawModeGuard; + +impl RawModeGuard { + fn enter() -> std::result::Result { + terminal::enable_raw_mode()?; + Ok(Self) + } +} + +impl Drop for RawModeGuard { + fn drop(&mut self) { + terminal::disable_raw_mode().ok(); + } +} + +pub struct BollardBackend { + docker: Docker, + runtime_info: tokio::sync::OnceCell, +} + +impl BollardBackend { + pub fn try_new() -> super::Result { + let docker = Self::connect_docker().map_err(|e| { + DockerError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + format!( + "Cannot connect to Docker daemon: {e}. \ + Check that Docker is running and $DOCKER_HOST is set correctly." + ), + )) + })?; + Ok(Self { + docker, + runtime_info: tokio::sync::OnceCell::new(), + }) + } + + fn connect_docker() -> std::result::Result { + if std::env::var("DOCKER_HOST").is_ok() { + return Docker::connect_with_local_defaults(); + } + if let Some(socket) = Self::socket_from_docker_context() { + eprintln!("sandcage: using docker socket from context → {socket}"); + return Docker::connect_with_socket(&socket, 120, bollard::API_DEFAULT_VERSION); + } + Docker::connect_with_local_defaults() + } + + fn socket_from_docker_context() -> Option { + let output = std::process::Command::new("docker") + .args(["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let host = String::from_utf8(output.stdout).ok()?.trim().to_string(); + let path = host.strip_prefix("unix://")?; + if std::path::Path::new(path).exists() { + Some(path.to_string()) + } else { + None + } + } +} + +#[async_trait] +impl super::ContainerBackend for BollardBackend { + // Bollard-native interactive TTY with crossterm raw mode. + // Falls back to compose if raw mode is unavailable (e.g. MinTTY/Git Bash). + async fn run_interactive( + &self, + service: &dyn Service, + ctx: &ComposeContext, + config: &SandcageConfig, + registry: &ServiceRegistry, + shell_override: bool, + extra_args: &[String], + ) -> Result { + if !self.can_use_bollard_tty() { + let compose = super::compose::ComposeBackend; + return compose + .run_interactive(service, ctx, config, registry, shell_override, extra_args) + .await; + } + + let mut compose_def = service.compose_service(ctx); + if shell_override { + compose_def.entrypoint = vec!["/bin/zsh".to_string()]; + } + self.run_interactive_tty(&compose_def, config, &ctx.config_root, extra_args, ctx.forwarding_socket.as_deref()) + .await + } + + async fn run_acp_relay( + &self, + service: &dyn Service, + ctx: &ComposeContext, + config: &SandcageConfig, + extra_args: &[String], + ) -> Result { + let compose_def = service.compose_service(ctx); + self.acp_relay(&compose_def, config, &ctx.config_root, extra_args).await + } + + async fn image_exists(&self, image: &str) -> Result { + Ok(self.docker.inspect_image(image).await.is_ok()) + } + + async fn volume_exists(&self, name: &str) -> Result { + Ok(self.docker.inspect_volume(name).await.is_ok()) + } + + async fn check_availability(&self) -> Result<()> { + self.docker.ping().await.map_err(|e| { + DockerError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + format!("Cannot connect to Docker: {e}"), + )) + })?; + Ok(()) + } + + async fn runtime_info(&self) -> Result { + let info = self + .runtime_info + .get_or_init(|| super::runtime::detect_runtime(&self.docker)) + .await; + Ok(info.clone()) + } +} + +impl BollardBackend { + fn build_container_config( + &self, + compose_def: &ComposeServiceDef, + config: &SandcageConfig, + config_root: &std::path::Path, + extra_args: &[String], + interactive: bool, + forwarding_socket: Option<&std::path::Path>, + security_opts: &[String], + ) -> ContainerCreateBody { + let mut env: Vec = compose_def.environment.clone(); + if let Some(ref env_map) = config.env { + for (key, value) in env_map { + env.push(format!("{key}={value}")); + } + } + + let mut binds: Vec = compose_def.volumes.clone(); + if let Some(ref mount_list) = config.mounts { + for mount in mount_list { + if mount.contains(':') { + binds.push(docker::expand_mount_path(mount)); + } + } + } + + let mut mounts: Vec = Vec::new(); + match config.ssh_mode.as_deref() { + Some("volume") => { + mounts.push(Mount { + target: Some("/home/agent/.ssh".to_string()), + source: Some("sandcage-ssh".to_string()), + typ: Some(MountType::VOLUME), + read_only: Some(true), + ..Default::default() + }); + } + Some("bind") => { + let mount = docker::expand_mount_path("~/.ssh:/home/agent/.ssh:ro"); + binds.push(mount); + } + _ => {} + } + + // Overlay mounts from .sandcage/overlay/ and .sandcage/local/overlay/ + let overlay_enabled = config.overlay.as_ref() + .and_then(|o| o.enabled) + .unwrap_or(true); + if overlay_enabled { + for mount in docker::collect_overlay_mounts(config_root, &compose_def.working_dir) { + binds.push(mount); + } + } + + // Shadow mounts (volume + directory as -v binds; tmpfs folded into tmpfs_map below). + let shadow_mounts = + docker::collect_shadow_mounts(config, config_root, &compose_def.working_dir); + binds.extend(crate::shadow::shadow_bind_strings(&shadow_mounts)); + + if let Some(spoof) = docker::collect_spoof_mount(config_root) { + binds.push(spoof); + } + + // Forwarding socket mount + if let Some(ref sock) = forwarding_socket { + binds.push(format!( + "{}:{}", + sock.display(), + crate::forwarding::CONTAINER_SOCKET_PATH + )); + } + + let cmd: Option> = if extra_args.is_empty() { + None + } else { + Some(extra_args.to_vec()) + }; + + let mut labels = HashMap::new(); + labels.insert("sandcage.managed".to_string(), "true".to_string()); + labels.insert( + "sandcage.mode".to_string(), + if interactive { "interactive" } else { "acp" }.to_string(), + ); + + // Build tmpfs map for .sandcage/ exclusion plus any tmpfs-backed shadow dirs. + let mut tmpfs_paths: Vec = compose_def.tmpfs.clone(); + tmpfs_paths.extend(crate::shadow::shadow_tmpfs_paths(&shadow_mounts)); + let tmpfs_map: Option> = if tmpfs_paths.is_empty() { + None + } else { + Some( + tmpfs_paths + .into_iter() + .map(|p| (p, String::new())) + .collect(), + ) + }; + + ContainerCreateBody { + image: Some(compose_def.image.clone()), + entrypoint: Some(compose_def.entrypoint.clone()), + cmd, + working_dir: Some(compose_def.working_dir.clone()), + user: Some(compose_def.user.clone()), + env: Some(env), + labels: Some(labels), + tty: Some(interactive), + open_stdin: Some(true), + attach_stdin: Some(true), + attach_stdout: Some(true), + attach_stderr: Some(true), + stdin_once: Some(false), + host_config: Some(HostConfig { + binds: Some(binds), + mounts: if mounts.is_empty() { None } else { Some(mounts) }, + tmpfs: tmpfs_map, + auto_remove: Some(true), + security_opt: if security_opts.is_empty() { + None + } else { + Some(security_opts.to_vec()) + }, + ..Default::default() + }), + ..Default::default() + } + } + + /// Check whether bollard-native TTY is usable in the current terminal. + /// On Windows, MinTTY (Git Bash) uses MSYS PTY pipes instead of a real + /// Windows console handle, so `GetConsoleMode` on stdin fails. + fn can_use_bollard_tty(&self) -> bool { + #[cfg(windows)] + { + // MinTTY (Git Bash / MSYS2) provides a Windows console handle, so + // GetConsoleMode and crossterm raw mode both succeed — but MinTTY's + // terminal emulator can't render ANSI output in raw console mode, + // producing garbled escape sequences. Detect MSYS2/MinGW by MSYSTEM + // env var and fall back to compose, which lets MinTTY handle the PTY. + if std::env::var_os("MSYSTEM").is_some() { + eprintln!("sandcage: tty probe → MSYS2/MinGW detected (MSYSTEM set) — using compose"); + return false; + } + + use std::os::windows::io::AsRawHandle; + unsafe extern "system" { + fn GetConsoleMode(handle: *mut std::ffi::c_void, mode: *mut u32) -> i32; + } + let handle = std::io::stdin().as_raw_handle(); + let mut mode: u32 = 0; + if unsafe { GetConsoleMode(handle as *mut _, &mut mode) } == 0 { + eprintln!("sandcage: tty probe → stdin is not a Windows console handle — using compose"); + return false; + } + eprintln!("sandcage: tty probe → Windows console handle OK (mode=0x{mode:x})"); + } + match terminal::enable_raw_mode() { + Ok(()) => { + terminal::disable_raw_mode().ok(); + eprintln!("sandcage: tty probe → crossterm raw mode OK — using bollard TTY"); + true + } + Err(e) => { + eprintln!("sandcage: tty probe → crossterm raw mode failed ({e}) — using compose"); + false + } + } + } + + /// Bollard-native interactive session with crossterm raw mode and resize handling. + async fn run_interactive_tty( + &self, + compose_def: &ComposeServiceDef, + config: &SandcageConfig, + config_root: &std::path::Path, + extra_args: &[String], + forwarding_socket: Option<&std::path::Path>, + ) -> Result { + self.cleanup_orphans().await; + + // Verify image exists + let image = &compose_def.image; + self.docker.inspect_image(image).await.map_err(|_| { + DockerError::ImageNotFound { + image: image.clone(), + } + })?; + + // Create and start container with TTY + let security_opts = docker::resolve_security_opts(config, config_root); + let container_config = + self.build_container_config(compose_def, config, config_root, extra_args, true, forwarding_socket, &security_opts); + + let container = self + .docker + .create_container( + None::, + container_config, + ) + .await + .map_err(|e| { + DockerError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to create container: {e}"), + )) + })?; + let id = container.id.clone(); + eprintln!("sandcage: container created → {}", &id[..12.min(id.len())]); + + self.docker + .start_container( + &id, + None::, + ) + .await + .map_err(|e| { + DockerError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to start container: {e}"), + )) + })?; + eprintln!("sandcage: container started"); + + // Initial terminal resize + if let Ok((w, h)) = terminal::size() { + eprintln!("sandcage: terminal size {w}x{h} — resizing container TTY"); + self.docker + .resize_container_tty( + &id, + bollard::query_parameters::ResizeContainerTTYOptionsBuilder::default() + .w(w as i32) + .h(h as i32) + .build(), + ) + .await + .ok(); + } + + // Enter raw mode (guard restores on drop, including panics) + let _guard = RawModeGuard::enter().map_err(|e| { + DockerError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to enter raw mode: {e}"), + )) + })?; + eprintln!("sandcage: raw mode entered"); + + // Attach to container + let attach_options = bollard::query_parameters::AttachContainerOptionsBuilder::default() + .stdin(true) + .stdout(true) + .stderr(true) + .stream(true) + .build(); + + let bollard::container::AttachContainerResults { mut output, mut input } = self + .docker + .attach_container(&id, Some(attach_options)) + .await + .map_err(|e| { + DockerError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to attach to container: {e}"), + )) + })?; + eprintln!("sandcage: attached — spawning relay tasks"); + + // Container wait task + let docker_wait = self.docker.clone(); + let id_wait = id.clone(); + let wait_handle = tokio::spawn(async move { + let opts = bollard::query_parameters::WaitContainerOptionsBuilder::default() + .condition("not-running") + .build(); + let mut stream = docker_wait.wait_container(&id_wait, Some(opts)); + if let Some(Ok(resp)) = stream.next().await { + resp.status_code + } else { + -1 + } + }); + + // Stdin relay task + let stdin_task = tokio::spawn(async move { + let mut host_stdin = io::stdin(); + let mut buf = vec![0u8; 8192]; + let mut total_bytes: usize = 0; + loop { + match host_stdin.read(&mut buf).await { + Ok(0) => { + eprintln!("sandcage: stdin → EOF after {total_bytes} bytes"); + input.shutdown().await.ok(); + break; + } + Ok(n) => { + total_bytes += n; + if input.write_all(&buf[..n]).await.is_err() { + eprintln!("sandcage: stdin → write to container failed after {total_bytes} bytes"); + break; + } + if input.flush().await.is_err() { + eprintln!("sandcage: stdin → flush to container failed after {total_bytes} bytes"); + break; + } + } + Err(e) => { + eprintln!("sandcage: stdin → read error: {e}"); + break; + } + } + } + }); + + // Output relay — TTY mode sends Console variant, non-TTY sends StdOut/StdErr + let output_task = tokio::spawn(async move { + let mut host_stdout = io::stdout(); + let mut host_stderr = io::stderr(); + let mut chunk_count: usize = 0; + let mut total_bytes: usize = 0; + while let Some(result) = output.next().await { + match result { + Ok(LogOutput::Console { message }) + | Ok(LogOutput::StdOut { message }) => { + chunk_count += 1; + total_bytes += message.len(); + host_stdout.write_all(&message).await.ok(); + host_stdout.flush().await.ok(); + } + Ok(LogOutput::StdErr { message }) => { + chunk_count += 1; + total_bytes += message.len(); + host_stderr.write_all(&message).await.ok(); + host_stderr.flush().await.ok(); + } + Ok(other) => { + eprintln!("sandcage: output → unexpected variant: {other:?}"); + } + Err(e) => { + eprintln!("sandcage: output → stream error: {e}"); + break; + } + } + } + eprintln!("sandcage: output → stream ended ({chunk_count} chunks, {total_bytes} bytes)"); + }); + + // Resize poller — checks terminal size every 250ms + let docker_resize = self.docker.clone(); + let id_resize = id.clone(); + let resize_task = tokio::spawn(async move { + let mut last_size = terminal::size().unwrap_or((80, 24)); + loop { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + if let Ok(current) = terminal::size() { + if current != last_size { + last_size = current; + docker_resize + .resize_container_tty( + &id_resize, + bollard::query_parameters::ResizeContainerTTYOptionsBuilder::default() + .w(current.0 as i32) + .h(current.1 as i32) + .build(), + ) + .await + .ok(); + } + } + } + }); + + // Signal handler — Ctrl+C stops the container, lets wait_handle return naturally + let docker_signal = self.docker.clone(); + let id_signal = id.clone(); + let signal_task = tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + eprintln!("sandcage: received interrupt, stopping container..."); + let opts = bollard::query_parameters::StopContainerOptionsBuilder::default() + .t(2) + .build(); + docker_signal + .stop_container(&id_signal, Some(opts)) + .await + .ok(); + }); + + // Wait for output to finish (container exited), then clean up tasks + let _ = output_task.await; + stdin_task.abort(); + signal_task.abort(); + resize_task.abort(); + + let exit_code = wait_handle.await.unwrap_or(-1); + + // _guard drops here, restoring terminal from raw mode + Ok(exit_code as i32) + } + + async fn acp_relay( + &self, + compose_def: &ComposeServiceDef, + config: &SandcageConfig, + config_root: &std::path::Path, + extra_args: &[String], + ) -> Result { + self.run_container(compose_def, config, config_root, extra_args, false).await + } + + async fn run_container( + &self, + compose_def: &ComposeServiceDef, + config: &SandcageConfig, + config_root: &std::path::Path, + extra_args: &[String], + interactive: bool, + ) -> Result { + self.cleanup_orphans().await; + + let image = &compose_def.image; + self.docker.inspect_image(image).await.map_err(|_| { + DockerError::ImageNotFound { + image: image.clone(), + } + })?; + + let container_config = + self.build_container_config(compose_def, config, config_root, extra_args, interactive, None, &[]); + + let container = self + .docker + .create_container( + None::, + container_config, + ) + .await + .map_err(|e| { + DockerError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to create container: {e}"), + )) + })?; + let id = container.id.clone(); + + let docker_signal = self.docker.clone(); + let id_signal = id.clone(); + let signal_task = tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + eprintln!("sandcage: received interrupt, stopping container..."); + let opts = bollard::query_parameters::RemoveContainerOptionsBuilder::default() + .force(true) + .build(); + docker_signal + .remove_container(&id_signal, Some(opts)) + .await + .ok(); + std::process::exit(130); + }); + + self.docker + .start_container( + &id, + None::, + ) + .await + .map_err(|e| { + DockerError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to start container: {e}"), + )) + })?; + + let attach_options = bollard::query_parameters::AttachContainerOptionsBuilder::default() + .stdin(true) + .stdout(true) + .stderr(true) + .stream(true) + .build(); + + let bollard::container::AttachContainerResults { mut output, mut input } = self + .docker + .attach_container(&id, Some(attach_options)) + .await + .map_err(|e| { + DockerError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to attach to container: {e}"), + )) + })?; + + let docker_wait = self.docker.clone(); + let id_wait = id.clone(); + let wait_handle = tokio::spawn(async move { + let opts = bollard::query_parameters::WaitContainerOptionsBuilder::default() + .condition("not-running") + .build(); + let mut stream = docker_wait.wait_container(&id_wait, Some(opts)); + if let Some(Ok(resp)) = stream.next().await { + resp.status_code + } else { + -1 + } + }); + + let stdin_task = tokio::spawn(async move { + let mut host_stdin = io::stdin(); + let mut buf = vec![0u8; 8192]; + loop { + match host_stdin.read(&mut buf).await { + Ok(0) => { + input.shutdown().await.ok(); + break; + } + Ok(n) => { + if input.write_all(&buf[..n]).await.is_err() { + break; + } + if input.flush().await.is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + let output_task = tokio::spawn(async move { + let mut host_stdout = io::stdout(); + let mut host_stderr = io::stderr(); + while let Some(result) = output.next().await { + match result { + Ok(LogOutput::StdOut { message }) => { + host_stdout.write_all(&message).await.ok(); + host_stdout.flush().await.ok(); + } + Ok(LogOutput::StdErr { message }) => { + host_stderr.write_all(&message).await.ok(); + host_stderr.flush().await.ok(); + } + Ok(_) => {} + Err(e) => { + eprintln!("sandcage: container stream error: {e}"); + break; + } + } + } + }); + + let _ = output_task.await; + stdin_task.abort(); + signal_task.abort(); + + let exit_code = wait_handle.await.unwrap_or(-1); + Ok(exit_code as i32) + } + + async fn cleanup_orphans(&self) { + let mut filters = HashMap::new(); + filters.insert("label", vec!["sandcage.managed=true"]); + let opts = bollard::query_parameters::ListContainersOptionsBuilder::default() + .all(true) + .filters(&filters) + .build(); + + if let Ok(containers) = self.docker.list_containers(Some(opts)).await { + for container in containers { + if let Some(id) = container.id { + eprintln!("sandcage: cleaning up orphaned container {}", &id[..12.min(id.len())]); + let rm_opts = bollard::query_parameters::RemoveContainerOptionsBuilder::default() + .force(true) + .build(); + self.docker.remove_container(&id, Some(rm_opts)).await.ok(); + } + } + } + } +} diff --git a/crates/sandcage/src/backend/compose.rs b/crates/sandcage/src/backend/compose.rs new file mode 100644 index 0000000..9763662 --- /dev/null +++ b/crates/sandcage/src/backend/compose.rs @@ -0,0 +1,112 @@ +use async_trait::async_trait; +use tokio::process::Command; + +use crate::config::SandcageConfig; +use crate::docker::{self, DockerError}; +use crate::service::registry::ServiceRegistry; +use crate::service::{ComposeContext, Service}; + +use super::Result; + +pub struct ComposeBackend; + +#[async_trait] +impl super::ContainerBackend for ComposeBackend { + async fn run_interactive( + &self, + service: &dyn Service, + ctx: &ComposeContext, + config: &SandcageConfig, + registry: &ServiceRegistry, + shell_override: bool, + extra_args: &[String], + ) -> Result { + let docker_path = docker::require_docker()?; + docker::require_compose(&docker_path).await?; + + let image_tag = format!("{}:latest", ctx.image); + docker::require_image(&docker_path, &image_tag).await?; + + if config.ssh_mode.as_deref() == Some("volume") { + let vol_check = Command::new(&docker_path) + .args(["volume", "inspect", "sandcage-ssh"]) + .output() + .await; + if !vol_check.map(|o| o.status.success()).unwrap_or(false) { + eprintln!( + "sandcage: SSH volume not found — run 'sandcage setup ssh --refresh' to populate it" + ); + } + } + + let compose_content = crate::service::compose::generate_compose(registry, ctx); + let compose_file = docker::write_compose_tempfile(&compose_content)?; + let compose_path = compose_file.path().to_string_lossy().into_owned(); + + let security_opts = docker::resolve_security_opts(config, &ctx.config_root); + let run_args = docker::build_run_args( + service.name(), + &compose_path, + config, + &ctx.config_root, + &ctx.container_dir, + shell_override, + extra_args, + ctx.forwarding_socket.as_deref(), + &security_opts, + ); + + let mut cmd = Command::new(&docker_path); + cmd.args(&run_args); + cmd.stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + + let status = cmd.status().await.map_err(DockerError::SpawnFailed)?; + Ok(status.code().unwrap_or(-1)) + } + + async fn run_acp_relay( + &self, + _service: &dyn Service, + _ctx: &ComposeContext, + _config: &SandcageConfig, + _extra_args: &[String], + ) -> Result { + Err(DockerError::AcpRequiresBollard) + } + + async fn image_exists(&self, image: &str) -> Result { + let docker_path = docker::require_docker()?; + let status = Command::new(&docker_path) + .args(["image", "inspect", image]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .map_err(DockerError::SpawnFailed)?; + Ok(status.success()) + } + + async fn volume_exists(&self, name: &str) -> Result { + let docker_path = docker::require_docker()?; + let status = Command::new(&docker_path) + .args(["volume", "inspect", name]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .map_err(DockerError::SpawnFailed)?; + Ok(status.success()) + } + + async fn check_availability(&self) -> Result<()> { + let docker_path = docker::require_docker()?; + docker::require_compose(&docker_path).await?; + Ok(()) + } + + async fn runtime_info(&self) -> Result { + Ok(super::runtime::RuntimeInfo::default()) + } +} diff --git a/crates/sandcage/src/backend/mod.rs b/crates/sandcage/src/backend/mod.rs new file mode 100644 index 0000000..bdb84b0 --- /dev/null +++ b/crates/sandcage/src/backend/mod.rs @@ -0,0 +1,57 @@ +pub mod compose; +pub mod runtime; +#[cfg(feature = "bollard")] +pub mod bollard; +#[cfg(feature = "bollard")] +pub mod network; +#[cfg(feature = "bollard")] +pub mod orchestrator; + +use async_trait::async_trait; + +use crate::config::SandcageConfig; +use crate::docker::DockerError; +use crate::service::{ComposeContext, Service}; + +pub type Result = std::result::Result; + +#[async_trait] +pub trait ContainerBackend: Send + Sync { + async fn run_interactive( + &self, + service: &dyn Service, + ctx: &ComposeContext, + config: &SandcageConfig, + registry: &crate::service::registry::ServiceRegistry, + shell_override: bool, + extra_args: &[String], + ) -> Result; + + async fn run_acp_relay( + &self, + service: &dyn Service, + ctx: &ComposeContext, + config: &SandcageConfig, + extra_args: &[String], + ) -> Result; + + async fn image_exists(&self, image: &str) -> Result; + + async fn volume_exists(&self, name: &str) -> Result; + + async fn check_availability(&self) -> Result<()>; + + async fn runtime_info(&self) -> Result; +} + +pub fn default_backend() -> Result> { + #[cfg(feature = "bollard")] + { + Ok(Box::new(bollard::BollardBackend::try_new()?)) + } + + #[cfg(not(feature = "bollard"))] + { + Ok(Box::new(compose::ComposeBackend)) + } +} diff --git a/crates/sandcage/src/backend/network.rs b/crates/sandcage/src/backend/network.rs new file mode 100644 index 0000000..8fc06fb --- /dev/null +++ b/crates/sandcage/src/backend/network.rs @@ -0,0 +1,103 @@ +use std::collections::HashMap; +use std::path::Path; + +use bollard::Docker; +use sha2::{Digest, Sha256}; + +use crate::docker::DockerError; + +/// Generate a deterministic network name from the workspace path. +pub fn network_name(workspace: &Path) -> String { + let mut hasher = Sha256::new(); + hasher.update(workspace.to_string_lossy().as_bytes()); + let hash = hex::encode(hasher.finalize()); + format!("sandcage-{}", &hash[..12]) +} + +/// Create or reuse a project-scoped Docker network. Returns the network name. +pub async fn ensure_network( + docker: &Docker, + workspace: &Path, +) -> Result { + let name = network_name(workspace); + + // Check if it already exists + if docker.inspect_network(&name, None::).await.is_ok() { + return Ok(name); + } + + // Create it + let mut labels = HashMap::new(); + labels.insert("sandcage.managed".to_string(), "true".to_string()); + + let config = bollard::models::NetworkCreateRequest { + name: name.clone(), + driver: Some("bridge".to_string()), + labels: Some(labels), + ..Default::default() + }; + + docker.create_network(config).await.map_err(|e| { + DockerError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to create network {name}: {e}"), + )) + })?; + + Ok(name) +} + +/// Remove a project network. Only removes if it has the sandcage.managed label. +pub async fn cleanup_network( + docker: &Docker, + network_name: &str, +) -> Result<(), DockerError> { + // Verify it's ours before removing + match docker + .inspect_network(network_name, None::) + .await + { + Ok(info) => { + let is_ours = info + .labels + .as_ref() + .map(|l| l.get("sandcage.managed").map(|v| v == "true").unwrap_or(false)) + .unwrap_or(false); + if !is_ours { + return Ok(()); // Not our network, don't touch it + } + } + Err(_) => return Ok(()), // Already gone + } + + docker.remove_network(network_name).await.map_err(|e| { + DockerError::SpawnFailed(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to remove network {network_name}: {e}"), + )) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn network_name_is_deterministic() { + let p = Path::new("/home/user/projects/my-app"); + let name1 = network_name(p); + let name2 = network_name(p); + assert_eq!(name1, name2); + assert!(name1.starts_with("sandcage-")); + assert_eq!(name1.len(), "sandcage-".len() + 12); + } + + #[test] + fn different_paths_produce_different_names() { + let a = network_name(Path::new("/a")); + let b = network_name(Path::new("/b")); + assert_ne!(a, b); + } +} diff --git a/crates/sandcage/src/backend/orchestrator.rs b/crates/sandcage/src/backend/orchestrator.rs new file mode 100644 index 0000000..3f307f0 --- /dev/null +++ b/crates/sandcage/src/backend/orchestrator.rs @@ -0,0 +1,148 @@ +use std::path::Path; + +use crate::service::ComposeServiceDef; + +use super::network; + +/// A plan for running one or more containers on a shared network. +#[derive(Debug)] +pub struct OrchestrationPlan { + pub network_name: String, + pub services: Vec, + /// Which service to attach stdin/stdout to. + pub primary: String, +} + +/// A single container to be started as part of an orchestration. +#[derive(Debug)] +pub struct ServiceInstance { + pub name: String, + pub compose_def: ComposeServiceDef, + pub depends_on: Vec, +} + +impl OrchestrationPlan { + /// Create a simple plan for a single service (no network needed). + pub fn single(name: &str, compose_def: ComposeServiceDef) -> Self { + Self { + network_name: String::new(), + services: vec![ServiceInstance { + name: name.to_string(), + compose_def, + depends_on: vec![], + }], + primary: name.to_string(), + } + } + + /// Create a plan with multiple services on a shared network. + pub fn multi( + workspace: &Path, + services: Vec, + primary: &str, + ) -> Self { + Self { + network_name: network::network_name(workspace), + services, + primary: primary.to_string(), + } + } + + /// Whether this plan requires a Docker network. + pub fn needs_network(&self) -> bool { + self.services.len() > 1 + } + + /// Return services in dependency order (simple topological sort). + /// Services with no dependencies come first. + pub fn startup_order(&self) -> Vec<&ServiceInstance> { + let mut result: Vec<&ServiceInstance> = Vec::new(); + let mut placed: Vec<&str> = Vec::new(); + + // Simple iterative approach — works for small service counts + let mut remaining: Vec<&ServiceInstance> = self.services.iter().collect(); + while !remaining.is_empty() { + let before = remaining.len(); + remaining.retain(|s| { + if s.depends_on.iter().all(|dep| placed.contains(&dep.as_str())) { + result.push(s); + placed.push(&s.name); + false // remove from remaining + } else { + true // keep in remaining + } + }); + if remaining.len() == before { + // Circular dependency — just append the rest + result.extend(remaining.drain(..)); + break; + } + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::ComposeServiceDef; + + fn dummy_def() -> ComposeServiceDef { + ComposeServiceDef { + image: "test:latest".to_string(), + entrypoint: vec!["/bin/sh".to_string()], + working_dir: "/workspace".to_string(), + user: "1000:1000".to_string(), + volumes: vec![], + environment: vec![], + tmpfs: vec![], + tty: true, + stdin_open: true, + } + } + + #[test] + fn single_plan_has_one_service() { + let plan = OrchestrationPlan::single("claude", dummy_def()); + assert_eq!(plan.services.len(), 1); + assert_eq!(plan.primary, "claude"); + assert!(!plan.needs_network()); + } + + #[test] + fn multi_plan_needs_network() { + let plan = OrchestrationPlan::multi( + Path::new("/workspace"), + vec![ + ServiceInstance { name: "db".into(), compose_def: dummy_def(), depends_on: vec![] }, + ServiceInstance { name: "app".into(), compose_def: dummy_def(), depends_on: vec!["db".into()] }, + ], + "app", + ); + assert!(plan.needs_network()); + assert_eq!(plan.services.len(), 2); + } + + #[test] + fn startup_order_respects_dependencies() { + let plan = OrchestrationPlan::multi( + Path::new("/workspace"), + vec![ + ServiceInstance { name: "app".into(), compose_def: dummy_def(), depends_on: vec!["db".into()] }, + ServiceInstance { name: "db".into(), compose_def: dummy_def(), depends_on: vec![] }, + ], + "app", + ); + let order = plan.startup_order(); + assert_eq!(order[0].name, "db"); + assert_eq!(order[1].name, "app"); + } + + #[test] + fn startup_order_handles_no_deps() { + let plan = OrchestrationPlan::single("solo", dummy_def()); + let order = plan.startup_order(); + assert_eq!(order.len(), 1); + assert_eq!(order[0].name, "solo"); + } +} diff --git a/crates/sandcage/src/backend/runtime.rs b/crates/sandcage/src/backend/runtime.rs new file mode 100644 index 0000000..7c98031 --- /dev/null +++ b/crates/sandcage/src/backend/runtime.rs @@ -0,0 +1,187 @@ +/// Runtime detection for rootless/rootful Docker and Podman. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeMode { + RootlessDocker, + RootfulDocker, + Podman, + Unknown, +} + +impl std::fmt::Display for RuntimeMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::RootlessDocker => write!(f, "docker (rootless)"), + Self::RootfulDocker => write!(f, "docker (rootful)"), + Self::Podman => write!(f, "podman (rootless)"), + Self::Unknown => write!(f, "unknown"), + } + } +} + +#[derive(Debug, Clone)] +pub struct RuntimeInfo { + pub mode: RuntimeMode, + pub cgroup_v2: bool, + pub api_version: Option, + pub server_version: Option, +} + +impl Default for RuntimeInfo { + fn default() -> Self { + Self { + mode: RuntimeMode::Unknown, + cgroup_v2: false, + api_version: None, + server_version: None, + } + } +} + +impl RuntimeInfo { + pub fn supports_resource_limits(&self) -> bool { + self.cgroup_v2 + } + + pub fn supports_privileged(&self) -> bool { + self.mode == RuntimeMode::RootfulDocker + } + + pub fn supports_low_ports(&self) -> bool { + self.mode == RuntimeMode::RootfulDocker + } +} + +#[cfg(feature = "bollard")] +fn detect_cgroup_v2() -> bool { + #[cfg(target_os = "linux")] + { + std::path::Path::new("/sys/fs/cgroup/cgroup.controllers").exists() + } + #[cfg(not(target_os = "linux"))] + { + false + } +} + +#[cfg(feature = "bollard")] +pub async fn detect_runtime(docker: &bollard::Docker) -> RuntimeInfo { + let info = match docker.info().await { + Ok(info) => info, + Err(_) => return RuntimeInfo::default(), + }; + + // Determine mode + let mode = if let Some(ref sec_opts) = info.security_options { + if sec_opts.iter().any(|s| s.contains("rootless")) { + RuntimeMode::RootlessDocker + } else { + detect_mode_from_root_dir(&info) + } + } else { + detect_mode_from_root_dir(&info) + }; + + let server_version = info.server_version.clone(); + + // Get API version from docker.version() + let api_version = match docker.version().await { + Ok(ver) => ver.api_version, + Err(_) => None, + }; + + RuntimeInfo { + mode, + cgroup_v2: detect_cgroup_v2(), + api_version, + server_version, + } +} + +#[cfg(feature = "bollard")] +fn detect_mode_from_root_dir(info: &bollard::models::SystemInfo) -> RuntimeMode { + if let Some(ref root_dir) = info.docker_root_dir { + if root_dir.contains(".local/share/docker") { + return RuntimeMode::RootlessDocker; + } + } + RuntimeMode::RootfulDocker +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_runtime_mode() { + assert_eq!(RuntimeMode::RootlessDocker.to_string(), "docker (rootless)"); + assert_eq!(RuntimeMode::RootfulDocker.to_string(), "docker (rootful)"); + assert_eq!(RuntimeMode::Podman.to_string(), "podman (rootless)"); + assert_eq!(RuntimeMode::Unknown.to_string(), "unknown"); + } + + #[test] + fn supports_resource_limits_requires_cgroup_v2() { + let info = RuntimeInfo { + mode: RuntimeMode::RootfulDocker, + cgroup_v2: false, + api_version: None, + server_version: None, + }; + assert!(!info.supports_resource_limits()); + + let info = RuntimeInfo { + cgroup_v2: true, + ..info + }; + assert!(info.supports_resource_limits()); + } + + #[test] + fn supports_privileged_only_rootful() { + let rootful = RuntimeInfo { + mode: RuntimeMode::RootfulDocker, + ..Default::default() + }; + assert!(rootful.supports_privileged()); + + let rootless = RuntimeInfo { + mode: RuntimeMode::RootlessDocker, + ..Default::default() + }; + assert!(!rootless.supports_privileged()); + + let podman = RuntimeInfo { + mode: RuntimeMode::Podman, + ..Default::default() + }; + assert!(!podman.supports_privileged()); + + let unknown = RuntimeInfo::default(); + assert!(!unknown.supports_privileged()); + } + + #[test] + fn supports_low_ports_only_rootful() { + let rootful = RuntimeInfo { + mode: RuntimeMode::RootfulDocker, + ..Default::default() + }; + assert!(rootful.supports_low_ports()); + + let rootless = RuntimeInfo { + mode: RuntimeMode::RootlessDocker, + ..Default::default() + }; + assert!(!rootless.supports_low_ports()); + } + + #[test] + fn default_runtime_info() { + let info = RuntimeInfo::default(); + assert_eq!(info.mode, RuntimeMode::Unknown); + assert!(!info.cgroup_v2); + assert!(info.api_version.is_none()); + assert!(info.server_version.is_none()); + } +} diff --git a/crates/sandcage/src/config.rs b/crates/sandcage/src/config.rs new file mode 100644 index 0000000..7815f35 --- /dev/null +++ b/crates/sandcage/src/config.rs @@ -0,0 +1,1090 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use figment::providers::{Format, Serialized, Toml}; +use figment::Figment; +use miette::Diagnostic; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error, Diagnostic)] +pub enum ConfigError { + #[error("Failed to read config file {0}: {1}")] + #[diagnostic(code(sandcage::config::read_failed))] + ReadFailed(PathBuf, #[source] std::io::Error), + + #[error("Failed to parse TOML in {0}: {1}")] + #[diagnostic(code(sandcage::config::parse_failed))] + ParseFailed(PathBuf, #[source] toml::de::Error), + + #[error("Failed to parse TOML: {0}")] + #[diagnostic(code(sandcage::config::parse_failed_str))] + #[allow(dead_code)] + ParseFailedStr(#[source] toml::de::Error), + + #[error("Failed to merge configuration layers: {0}")] + #[diagnostic(code(sandcage::config::merge_failed))] + MergeFailed(#[source] Box), +} + +pub type Result = std::result::Result; + +// --------------------------------------------------------------------------- +// Sub-structs +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct SshKeyEntry { + pub host: String, + pub identity_file: String, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ServiceOverride { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct OverlayConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ShadowDir { + #[serde(skip_serializing_if = "Option::is_none")] + pub backend: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub shared: bool, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ShadowConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub backend: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dirs: Option>, +} + +fn is_false(b: &bool) -> bool { + !*b +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ProfilesConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub apparmor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub seccomp: Option, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct ToolConfig { + /// "in-cage" | "spoof" | "host". + #[serde(skip_serializing_if = "Option::is_none")] + pub mode: Option, + /// Message printed by a spoof script. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + /// Host-disposition guardrails (enforced by the forwarding track). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deny: Vec, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct TaskConfig { + #[serde(rename = "where", skip_serializing_if = "Option::is_none")] + pub location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub run: Option, +} + +// --------------------------------------------------------------------------- +// Raw deserialization type — captures unknown keys +// --------------------------------------------------------------------------- + +/// Intermediate representation that keeps leftover keys so we can warn about them. +#[derive(Debug, Deserialize)] +struct RawConfig { + #[serde(default)] + env: Option>, + #[serde(default)] + packages: Option>, + #[serde(default)] + toolchains: Option>, + #[serde(default)] + mounts: Option>, + #[serde(default)] + shell: Option, + #[serde(default)] + justfile: Option, + #[serde(default)] + dockerfiles: Option>, + #[serde(default)] + trusted_projects: Option>, + #[serde(default)] + container_workspace: Option, + #[serde(default)] + agent_args: Option>>, + #[serde(default)] + ssh_mode: Option, + #[serde(default)] + ssh_keys: Option>, + #[serde(default)] + services: Option>, + #[serde(default)] + default_services: Option>, + #[serde(default)] + image: Option, + #[serde(default)] + runtime: Option, + #[serde(default)] + compose_backend: Option, + #[serde(default)] + overlay: Option, + #[serde(default)] + shadow: Option, + #[serde(default)] + tools: Option>, + #[serde(default)] + forward: Option, + #[serde(default)] + tasks: Option>, + #[serde(default)] + profiles: Option, + #[serde(default)] + home: Option, + + /// Absorb everything else so we can warn about unknown fields. + #[serde(flatten)] + extra: HashMap, +} + +// --------------------------------------------------------------------------- +// Public config struct +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct SandcageConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub env: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub packages: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub toolchains: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub mounts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub shell: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub justfile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dockerfiles: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub trusted_projects: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub container_workspace: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_args: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_keys: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub services: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_services: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub compose_backend: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub overlay: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub shadow: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tasks: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub profiles: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub home: Option, +} + +// --------------------------------------------------------------------------- +// Known keys — used to filter the flattened `extra` map +// --------------------------------------------------------------------------- + +const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys", "services", "default_services", "image", "runtime", "compose_backend", "overlay", "forward", "tasks", "profiles", "redirect", "shadow", "tools", "home"]; + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +/// Returns `true` if a mount string is well-formed (must contain `:`). +fn is_valid_mount(mount: &str) -> bool { + mount.contains(':') +} + +/// Validate and warn; does not return an error — warnings are non-fatal. +fn validate_and_warn(raw: &RawConfig) { + // Warn about unknown top-level keys + for key in raw.extra.keys() { + if !KNOWN_KEYS.contains(&key.as_str()) { + eprintln!( + "sandcage: warning: unknown config key '{key}' will be ignored. \ + Known keys: {}", + KNOWN_KEYS.join(", ") + ); + } + } + + // Warn if redirect appears alongside other config keys + if raw.extra.contains_key("redirect") { + let has_other_keys = raw.env.is_some() + || raw.packages.is_some() + || raw.toolchains.is_some() + || raw.mounts.is_some() + || raw.shell.is_some() + || raw.overlay.is_some(); + if has_other_keys { + eprintln!( + "sandcage: warning: 'redirect' is set alongside other config keys — \ + non-redirect keys will be ignored" + ); + } + } + + // Emit notes for reserved sections + if raw.forward.is_some() { + eprintln!( + "sandcage: note: 'forward' section is reserved for a future release and will be ignored" + ); + } + + // Validate runtime value + if let Some(rt) = &raw.runtime { + const VALID_RUNTIMES: &[&str] = &["auto", "rootless", "rootful"]; + if !VALID_RUNTIMES.contains(&rt.as_str()) { + eprintln!( + "sandcage: warning: unrecognized runtime value '{rt}'. \ + Valid values: {}", + VALID_RUNTIMES.join(", ") + ); + } + } + + // Validate compose_backend value + if let Some(cb) = &raw.compose_backend { + const VALID_COMPOSE_BACKENDS: &[&str] = &["auto", "bollard", "compose"]; + if !VALID_COMPOSE_BACKENDS.contains(&cb.as_str()) { + eprintln!( + "sandcage: warning: unrecognized compose_backend value '{cb}'. \ + Valid values: {}", + VALID_COMPOSE_BACKENDS.join(", ") + ); + } + } + + // Validate mount strings + if let Some(mounts) = &raw.mounts { + for mount in mounts { + if !is_valid_mount(mount) { + eprintln!( + "sandcage: warning: mount '{mount}' is missing ':' separator. \ + Expected format: : or ::" + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// Conversion from raw to public struct +// --------------------------------------------------------------------------- + +fn from_raw(raw: RawConfig) -> SandcageConfig { + SandcageConfig { + env: raw.env, + packages: raw.packages, + toolchains: raw.toolchains, + mounts: raw.mounts, + shell: raw.shell, + justfile: raw.justfile, + dockerfiles: raw.dockerfiles, + trusted_projects: raw.trusted_projects, + container_workspace: raw.container_workspace, + agent_args: raw.agent_args, + ssh_mode: raw.ssh_mode, + ssh_keys: raw.ssh_keys, + services: raw.services, + default_services: raw.default_services, + image: raw.image, + runtime: raw.runtime, + compose_backend: raw.compose_backend, + overlay: raw.overlay, + shadow: raw.shadow, + tools: raw.tools, + tasks: raw.tasks, + profiles: raw.profiles, + home: raw.home, + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Parse a sandcage TOML config from the given path. +pub fn load(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| ConfigError::ReadFailed(path.to_path_buf(), e))?; + + let raw: RawConfig = toml::from_str(&content) + .map_err(|e| ConfigError::ParseFailed(path.to_path_buf(), e))?; + + validate_and_warn(&raw); + Ok(from_raw(raw)) +} + +#[cfg(test)] +pub fn load_from_str(content: &str) -> Result { + // Empty / whitespace-only content → all fields None + if content.trim().is_empty() { + return Ok(SandcageConfig::default()); + } + + let raw: RawConfig = + toml::from_str(content).map_err(ConfigError::ParseFailedStr)?; + + validate_and_warn(&raw); + Ok(from_raw(raw)) +} + +/// Load trusted_projects from the global config only. +/// This must never be read from project-level config (a project cannot self-trust). +pub fn load_trusted_projects(global_config_path: &Path) -> Vec { + if !global_config_path.exists() { + return Vec::new(); + } + + let cfg: SandcageConfig = Figment::from(Serialized::defaults(SandcageConfig::default())) + .merge(Toml::file(global_config_path)) + .extract() + .unwrap_or_default(); + + cfg.trusted_projects.unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// Layered config resolution via figment +// --------------------------------------------------------------------------- + +/// Resolve the final `SandcageConfig` by merging all configuration layers in order: +/// +/// 1. Compiled defaults (all `None`) +/// 2. Global config from `global_config_path` (TOML, e.g. `~/.sandcage/config.toml`) +/// 3. Project config from `project_config_path` (TOML, e.g. `.sandcage/config.toml`) +/// 4. Local project config from `local_config_path` (TOML, e.g. `.sandcage/local/config.local.toml`) +/// +/// Later layers win. Pass `None` for any path to skip that layer. +pub fn resolve_config( + global_config_path: Option<&Path>, + project_config_path: Option<&Path>, + local_config_path: Option<&Path>, +) -> Result { + // Start with compiled defaults — all fields None. + let mut figment = Figment::from(Serialized::defaults(SandcageConfig::default())); + + if let Some(global_path) = global_config_path + && global_path.exists() + { + figment = figment.merge(Toml::file(global_path)); + } + + if let Some(project_path) = project_config_path + && project_path.exists() + { + let project_cfg = load(project_path)?; + figment = figment.merge(Serialized::defaults(project_cfg)); + } + + if let Some(local_path) = local_config_path + && local_path.exists() + { + let local_cfg = load(local_path)?; + figment = figment.merge(Serialized::defaults(local_cfg)); + } + + figment + .extract() + .map_err(|e| ConfigError::MergeFailed(Box::new(e))) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + const FULL_CONFIG: &str = r#" +packages = ["ripgrep", "fd-find"] +mounts = ["/data/models:/models:ro", "/tmp/cache:/cache"] +shell = "zsh" +justfile = "./project.justfile" + +[env] +RUST_LOG = "debug" +NODE_ENV = "development" + +[toolchains] +rust = true +node = true +"#; + + // ----------------------------------------------------------------------- + // Happy-path tests + // ----------------------------------------------------------------------- + + #[test] + fn parse_full_config() { + let cfg = load_from_str(FULL_CONFIG).expect("parse full config"); + + // env + let env = cfg.env.expect("env should be Some"); + assert_eq!(env.get("RUST_LOG").map(String::as_str), Some("debug")); + assert_eq!(env.get("NODE_ENV").map(String::as_str), Some("development")); + + // packages + let pkgs = cfg.packages.expect("packages should be Some"); + assert_eq!(pkgs, vec!["ripgrep", "fd-find"]); + + // toolchains + let tc = cfg.toolchains.expect("toolchains should be Some"); + assert_eq!(tc.get("rust"), Some(&toml::Value::Boolean(true))); + assert_eq!(tc.get("node"), Some(&toml::Value::Boolean(true))); + + // mounts + let mounts = cfg.mounts.expect("mounts should be Some"); + assert_eq!(mounts[0], "/data/models:/models:ro"); + assert_eq!(mounts[1], "/tmp/cache:/cache"); + + // shell + assert_eq!(cfg.shell.as_deref(), Some("zsh")); + + // justfile + assert_eq!(cfg.justfile, Some(PathBuf::from("./project.justfile"))); + } + + #[test] + fn parse_minimal_config_only_env() { + let yaml = "[env]\nKEY = \"value\"\n"; + let cfg = load_from_str(yaml).expect("parse minimal config"); + + let env = cfg.env.expect("env should be Some"); + assert_eq!(env.get("KEY").map(String::as_str), Some("value")); + + assert!(cfg.packages.is_none()); + assert!(cfg.toolchains.is_none()); + assert!(cfg.mounts.is_none()); + assert!(cfg.shell.is_none()); + assert!(cfg.justfile.is_none()); + } + + #[test] + fn parse_empty_file_all_fields_none() { + let cfg = load_from_str("").expect("parse empty file"); + assert!(cfg.env.is_none()); + assert!(cfg.packages.is_none()); + assert!(cfg.toolchains.is_none()); + assert!(cfg.mounts.is_none()); + assert!(cfg.shell.is_none()); + assert!(cfg.justfile.is_none()); + } + + #[test] + fn parse_whitespace_only_file() { + let cfg = load_from_str(" \n\n \t\n").expect("parse whitespace-only file"); + assert!(cfg.env.is_none()); + } + + #[test] + fn load_from_file() { + let mut tmp = NamedTempFile::new().expect("create tmpfile"); + write!(tmp, "{FULL_CONFIG}").expect("write tmpfile"); + + let cfg = load(tmp.path()).expect("load from file"); + assert!(cfg.env.is_some()); + } + + #[test] + fn load_missing_file_returns_error() { + let result = load(Path::new("/nonexistent/path/.sandcage.yml")); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, ConfigError::ReadFailed(..))); + } + + // ----------------------------------------------------------------------- + // Error / warning path tests + // ----------------------------------------------------------------------- + + #[test] + fn invalid_toml_returns_parse_error() { + let bad_toml = "env = [this is not valid toml"; + let result = load_from_str(bad_toml); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, ConfigError::ParseFailedStr(..)), + "expected ParseFailedStr, got: {err:?}" + ); + } + + #[test] + fn unknown_keys_are_absorbed_without_hard_error() { + // Unknown keys should not cause a parse failure — they emit warnings. + let yaml = "[env]\nKEY = \"val\"\n\n[unknown_field]\nfoo = \"surprise\"\n"; + let result = load_from_str(yaml); + assert!( + result.is_ok(), + "unknown keys should not cause a hard error: {result:?}" + ); + let cfg = result.unwrap(); + // Legitimate fields still parsed + assert!(cfg.env.is_some()); + } + + #[test] + fn invalid_mount_no_colon_does_not_hard_error() { + // A mount without ':' is a warning, not an error. + let yaml = "mounts = [\"/data/models\", \"/valid:/mount\"]\n"; + let result = load_from_str(yaml); + assert!(result.is_ok(), "invalid mount should not be a hard error"); + let cfg = result.unwrap(); + let mounts = cfg.mounts.unwrap(); + assert_eq!(mounts.len(), 2); + } + + #[test] + fn is_valid_mount_helper() { + assert!(is_valid_mount("/src:/dst")); + assert!(is_valid_mount("/src:/dst:ro")); + assert!(!is_valid_mount("/no_colon_here")); + assert!(!is_valid_mount("")); + } + + #[test] + fn all_valid_mounts_are_accepted() { + let yaml = "mounts = [\"/data:/mnt/data\", \"/tmp:/tmp:ro\"]\n"; + let cfg = load_from_str(yaml).unwrap(); + let mounts = cfg.mounts.unwrap(); + assert!(mounts.iter().all(|m| is_valid_mount(m))); + } + + #[test] + fn extra_fields_detected() { + // Verify the `extra` map in RawConfig captures unknown keys correctly. + let yaml = "foo = \"bar\"\nbaz = \"42\"\n\n[env]\nA = \"1\"\n"; + let raw: RawConfig = toml::from_str(yaml).unwrap(); + assert!(raw.extra.contains_key("foo"), "extra should contain 'foo'"); + assert!(raw.extra.contains_key("baz"), "extra should contain 'baz'"); + assert!(!raw.extra.contains_key("env"), "env should not be in extra"); + } + + // ----------------------------------------------------------------------- + // resolve_config layered merge tests + // ----------------------------------------------------------------------- + + #[test] + fn resolve_defaults_only_all_none() { + let cfg = resolve_config(None, None, None).expect("resolve with no files"); + assert!(cfg.env.is_none()); + assert!(cfg.packages.is_none()); + assert!(cfg.toolchains.is_none()); + assert!(cfg.mounts.is_none()); + assert!(cfg.shell.is_none()); + assert!(cfg.justfile.is_none()); + } + + #[test] + fn resolve_global_config_only() { + let toml_content = r#" +shell = "zsh" + +[env] +EDITOR = "vim" +"#; + let mut global_tmp = NamedTempFile::new().expect("create global tmpfile"); + write!(global_tmp, "{toml_content}").expect("write global tmpfile"); + + let cfg = resolve_config(Some(global_tmp.path()), None, None).expect("resolve global only"); + assert_eq!(cfg.shell.as_deref(), Some("zsh")); + let env = cfg.env.expect("env should be Some from global"); + assert_eq!(env.get("EDITOR").map(String::as_str), Some("vim")); + assert!(cfg.packages.is_none()); + } + + #[test] + fn resolve_project_config_only() { + let yaml_content = "shell = \"bash\"\npackages = [\"ripgrep\"]\n"; + let mut project_tmp = NamedTempFile::new().expect("create project tmpfile"); + write!(project_tmp, "{yaml_content}").expect("write project tmpfile"); + + let cfg = resolve_config(None, Some(project_tmp.path()), None).expect("resolve project only"); + assert_eq!(cfg.shell.as_deref(), Some("bash")); + let pkgs = cfg.packages.expect("packages should be Some"); + assert_eq!(pkgs, vec!["ripgrep"]); + assert!(cfg.env.is_none()); + } + + #[test] + fn resolve_project_overrides_global() { + // Global sets shell=zsh; project sets shell=bash — project wins. + let global_toml = r#"shell = "zsh""#; + let project_yaml = "shell = \"bash\"\n"; + + let mut global_tmp = NamedTempFile::new().expect("create global tmpfile"); + write!(global_tmp, "{global_toml}").expect("write global tmpfile"); + + let mut project_tmp = NamedTempFile::new().expect("create project tmpfile"); + write!(project_tmp, "{project_yaml}").expect("write project tmpfile"); + + let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None) + .expect("resolve both"); + assert_eq!(cfg.shell.as_deref(), Some("bash"), "project shell should override global"); + } + + #[test] + fn resolve_partial_overlap_fields_merge() { + // Global sets shell + env; project sets packages only. + // Expect: shell from global, env from global, packages from project. + let global_toml = "shell = \"zsh\"\n[env]\nEDITOR = \"vim\"\n"; + let project_yaml = "packages = [\"fd-find\"]\n"; + + let mut global_tmp = NamedTempFile::new().expect("create global tmpfile"); + write!(global_tmp, "{global_toml}").expect("write global tmpfile"); + + let mut project_tmp = NamedTempFile::new().expect("create project tmpfile"); + write!(project_tmp, "{project_yaml}").expect("write project tmpfile"); + + let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None) + .expect("resolve partial overlap"); + assert_eq!(cfg.shell.as_deref(), Some("zsh"), "shell should come from global"); + let env = cfg.env.expect("env should be Some from global"); + assert_eq!(env.get("EDITOR").map(String::as_str), Some("vim")); + let pkgs = cfg.packages.expect("packages should be Some from project"); + assert_eq!(pkgs, vec!["fd-find"]); + } + + #[test] + fn resolve_nonexistent_paths_skipped() { + // Paths that don't exist should be silently skipped (not errors). + let cfg = resolve_config( + Some(Path::new("/nonexistent/config.toml")), + Some(Path::new("/nonexistent/.sandcage.yml")), + None, + ) + .expect("nonexistent files should not error"); + assert!(cfg.shell.is_none()); + } + + #[test] + fn parse_container_workspace() { + let yaml = "container_workspace = \"/workspace/my-app\"\n"; + let cfg = load_from_str(yaml).expect("parse container_workspace"); + assert_eq!(cfg.container_workspace.as_deref(), Some("/workspace/my-app")); + } + + #[test] + fn container_workspace_defaults_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.container_workspace.is_none()); + } + + #[test] + fn agent_args_defaults_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.agent_args.is_none()); + } + + #[test] + fn resolve_agent_args_project_overrides_global() { + let global_toml = r#" +[agent_args] +claude = ["--global-flag"] +"#; + let project_yaml = r#" +[agent_args] +claude = ["--project-flag"] +"#; + let mut global_tmp = NamedTempFile::new().expect("create global tmpfile"); + write!(global_tmp, "{global_toml}").expect("write global tmpfile"); + let mut project_tmp = NamedTempFile::new().expect("create project tmpfile"); + write!(project_tmp, "{project_yaml}").expect("write project tmpfile"); + + let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None) + .expect("resolve"); + let agent_args = cfg.agent_args.expect("agent_args should be Some"); + let claude_args = agent_args.get("claude").expect("claude args"); + assert_eq!(claude_args, &vec!["--project-flag"], "project should override global"); + } + + #[test] + fn parse_agent_args() { + let yaml = r#" +[agent_args] +claude = ["--dangerously-skip-permissions"] +codex = ["--full-auto"] +"#; + let cfg = load_from_str(yaml).expect("parse agent_args"); + let agent_args = cfg.agent_args.expect("agent_args should be Some"); + let claude_args = agent_args.get("claude").expect("claude args"); + assert_eq!(claude_args, &vec!["--dangerously-skip-permissions"]); + let codex_args = agent_args.get("codex").expect("codex args"); + assert_eq!(codex_args, &vec!["--full-auto"]); + } + + #[test] + fn parse_ssh_mode() { + let yaml = "ssh_mode = \"volume\"\n"; + let cfg = load_from_str(yaml).expect("parse ssh_mode"); + assert_eq!(cfg.ssh_mode.as_deref(), Some("volume")); + } + + #[test] + fn parse_ssh_keys() { + let yaml = r#" +ssh_mode = "volume" + +[[ssh_keys]] +host = "github.com" +identity_file = "~/.ssh/id_ed25519" + +[[ssh_keys]] +host = "gitea" +identity_file = "~/.ssh/work_gitea" +"#; + let cfg = load_from_str(yaml).expect("parse ssh_keys"); + let keys = cfg.ssh_keys.expect("ssh_keys should be Some"); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0].host, "github.com"); + assert_eq!(keys[0].identity_file, "~/.ssh/id_ed25519"); + assert_eq!(keys[1].host, "gitea"); + } + + #[test] + fn ssh_mode_defaults_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.ssh_mode.is_none()); + assert!(cfg.ssh_keys.is_none()); + } + + #[test] + fn resolve_ssh_mode_project_overrides_global() { + let global_toml = r#"ssh_mode = "bind""#; + let project_yaml = "ssh_mode = \"volume\"\n"; + let mut global_tmp = NamedTempFile::new().expect("create global tmpfile"); + write!(global_tmp, "{global_toml}").expect("write global tmpfile"); + let mut project_tmp = NamedTempFile::new().expect("create project tmpfile"); + write!(project_tmp, "{project_yaml}").expect("write project tmpfile"); + let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None).expect("resolve"); + assert_eq!(cfg.ssh_mode.as_deref(), Some("volume")); + } + + #[test] + fn parse_services_override() { + let yaml = r#" +[services.codex] +enabled = false + +[services.claude] +enabled = true +"#; + let cfg = load_from_str(yaml).expect("parse services"); + let services = cfg.services.expect("services should be Some"); + assert_eq!(services.get("codex").unwrap().enabled, Some(false)); + assert_eq!(services.get("claude").unwrap().enabled, Some(true)); + } + + #[test] + fn parse_default_services() { + let yaml = r#" +default_services = ["claude", "shell"] +"#; + let cfg = load_from_str(yaml).expect("parse default_services"); + let defaults = cfg.default_services.expect("default_services should be Some"); + assert_eq!(defaults, vec!["claude", "shell"]); + } + + #[test] + fn services_defaults_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.services.is_none()); + assert!(cfg.default_services.is_none()); + } + + #[test] + fn resolve_services_project_overrides_global() { + let global_toml = r#" +[services.codex] +enabled = true +"#; + let project_yaml = r#" +[services.codex] +enabled = false +"#; + let mut global_tmp = NamedTempFile::new().expect("create global tmpfile"); + write!(global_tmp, "{global_toml}").expect("write global tmpfile"); + let mut project_tmp = NamedTempFile::new().expect("create project tmpfile"); + write!(project_tmp, "{project_yaml}").expect("write project tmpfile"); + + let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()), None) + .expect("resolve"); + let services = cfg.services.expect("services should be Some"); + assert_eq!( + services.get("codex").unwrap().enabled, + Some(false), + "project should override global" + ); + } + + #[test] + fn parse_image_field() { + let yaml = "image = \"my-custom\"\n"; + let cfg = load_from_str(yaml).expect("parse image field"); + assert_eq!(cfg.image.as_deref(), Some("my-custom")); + } + + #[test] + fn image_defaults_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.image.is_none()); + } + + #[test] + fn parse_runtime_and_compose_backend() { + let yaml = "runtime = \"rootless\"\ncompose_backend = \"bollard\"\n"; + let cfg = load_from_str(yaml).expect("parse runtime and compose_backend"); + assert_eq!(cfg.runtime.as_deref(), Some("rootless")); + assert_eq!(cfg.compose_backend.as_deref(), Some("bollard")); + } + + #[test] + fn runtime_and_compose_backend_default_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.runtime.is_none()); + assert!(cfg.compose_backend.is_none()); + } + + #[test] + fn unrecognized_runtime_and_compose_backend_still_parse() { + let yaml = "runtime = \"podman\"\ncompose_backend = \"swarm\"\n"; + let cfg = load_from_str(yaml).expect("unrecognized values should not hard-error"); + assert_eq!(cfg.runtime.as_deref(), Some("podman")); + assert_eq!(cfg.compose_backend.as_deref(), Some("swarm")); + } + + #[test] + fn parse_all_valid_runtime_values() { + for val in &["auto", "rootless", "rootful"] { + let yaml = format!("runtime = \"{val}\"\n"); + let cfg = load_from_str(&yaml).expect("parse valid runtime"); + assert_eq!(cfg.runtime.as_deref(), Some(*val)); + } + } + + #[test] + fn parse_all_valid_compose_backend_values() { + for val in &["auto", "bollard", "compose"] { + let yaml = format!("compose_backend = \"{val}\"\n"); + let cfg = load_from_str(&yaml).expect("parse valid compose_backend"); + assert_eq!(cfg.compose_backend.as_deref(), Some(*val)); + } + } + + #[test] + fn reserved_sections_parse_without_error() { + let yaml = r#" +[forward] +commands = ["gh", "docker compose"] + +[tasks.test] +where = "inside" + +[profiles] +apparmor = "default" +"#; + let result = load_from_str(yaml); + assert!(result.is_ok(), "reserved sections should not cause errors: {result:?}"); + } + + #[test] + fn overlay_config_parses() { + let yaml = "[overlay]\nenabled = false\n"; + let cfg = load_from_str(yaml).expect("parse overlay config"); + assert_eq!(cfg.overlay.as_ref().unwrap().enabled, Some(false)); + } + + #[test] + fn overlay_defaults_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.overlay.is_none()); + } + + #[test] + fn redirect_only_parses_without_error() { + let yaml = "redirect = \"/some/path\"\n"; + let result = load_from_str(yaml); + assert!(result.is_ok(), "redirect-only config should parse: {result:?}"); + } + + #[test] + fn redirect_is_known_key() { + let yaml = "redirect = \"/some/path\"\n"; + let raw: RawConfig = toml::from_str(yaml).unwrap(); + assert!(raw.extra.contains_key("redirect")); + // Redirect is in KNOWN_KEYS so it should not trigger unknown-key warning + assert!(KNOWN_KEYS.contains(&"redirect")); + } + + #[test] + fn parse_shadow_config() { + let yaml = r#" +[shadow] +backend = "volume" + +[shadow.dirs] +".venv" = {} +"target" = { backend = "directory" } +".cargo/registry" = { shared = true } +"#; + let cfg = load_from_str(yaml).expect("parse shadow config"); + let shadow = cfg.shadow.expect("shadow should be Some"); + assert_eq!(shadow.backend.as_deref(), Some("volume")); + let dirs = shadow.dirs.expect("dirs should be Some"); + assert!(dirs.contains_key(".venv")); + assert_eq!(dirs["target"].backend.as_deref(), Some("directory")); + assert!(dirs[".cargo/registry"].shared); + assert!(!dirs[".venv"].shared); + } + + #[test] + fn shadow_defaults_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.shadow.is_none()); + } + + #[test] + fn parse_tools_config() { + let yaml = r#" +[tools.cargo] +mode = "spoof" +message = "Use `just build` to compile this project." + +[tools.docker] +mode = "host" +deny = ["* prune *", "* rm -f *"] + +[tools.uv] +mode = "in-cage" +"#; + let cfg = load_from_str(yaml).expect("parse tools"); + let tools = cfg.tools.expect("tools should be Some"); + assert_eq!(tools["cargo"].mode.as_deref(), Some("spoof")); + assert_eq!( + tools["cargo"].message.as_deref(), + Some("Use `just build` to compile this project.") + ); + assert_eq!(tools["docker"].mode.as_deref(), Some("host")); + assert_eq!(tools["docker"].deny, vec!["* prune *".to_string(), "* rm -f *".to_string()]); + assert_eq!(tools["uv"].mode.as_deref(), Some("in-cage")); + } + + #[test] + fn tools_defaults_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.tools.is_none()); + } + + #[test] + fn parse_tasks_config() { + let toml = r#" +[tasks.build] +where = "host" +run = "cargo build --release" + +[tasks.test] +where = "host" +run = "cargo test" + +[tasks.lint] +where = "in-cage" +run = "clippy" +"#; + let cfg = load_from_str(toml).expect("parse tasks"); + let tasks = cfg.tasks.expect("tasks should be Some"); + assert_eq!(tasks["build"].location.as_deref(), Some("host")); + assert_eq!(tasks["build"].run.as_deref(), Some("cargo build --release")); + assert_eq!(tasks["lint"].location.as_deref(), Some("in-cage")); + } + + #[test] + fn tasks_defaults_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.tasks.is_none()); + } + + #[test] + fn parse_profiles_config() { + let toml = r#" +[profiles] +apparmor = ".sandcage/profiles/apparmor.conf" +seccomp = ".sandcage/profiles/seccomp.json" +"#; + let cfg = load_from_str(toml).expect("parse profiles"); + let profiles = cfg.profiles.expect("profiles should be Some"); + assert_eq!(profiles.apparmor.as_deref(), Some(".sandcage/profiles/apparmor.conf")); + assert_eq!(profiles.seccomp.as_deref(), Some(".sandcage/profiles/seccomp.json")); + } + + #[test] + fn profiles_defaults_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.profiles.is_none()); + } + + #[test] + fn parse_home_field() { + let toml = "home = \"work\"\n"; + let cfg = load_from_str(toml).expect("parse home"); + assert_eq!(cfg.home.as_deref(), Some("work")); + } + + #[test] + fn home_defaults_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.home.is_none()); + } + + #[test] + fn resolve_home_cli_overrides_config() { + let global = "home = \"personal\"\n"; + let mut global_tmp = NamedTempFile::new().expect("create global tmpfile"); + write!(global_tmp, "{global}").expect("write global tmpfile"); + + let cfg = resolve_config(Some(global_tmp.path()), None, None).expect("resolve"); + assert_eq!(cfg.home.as_deref(), Some("personal")); + } +} diff --git a/crates/sandcage/src/config_source.rs b/crates/sandcage/src/config_source.rs new file mode 100644 index 0000000..97ff60f --- /dev/null +++ b/crates/sandcage/src/config_source.rs @@ -0,0 +1,304 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug)] +pub struct ConfigSource { + pub root: PathBuf, + pub origin: ConfigOrigin, +} + +#[derive(Debug)] +pub enum ConfigOrigin { + EnvVar, + Workspace, + Redirect { from: PathBuf }, + MainWorktree, +} + +impl std::fmt::Display for ConfigOrigin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfigOrigin::EnvVar => write!(f, "SANDCAGE_CONFIG_DIR"), + ConfigOrigin::Workspace => write!(f, "workspace"), + ConfigOrigin::Redirect { from } => write!(f, "redirect from {}", from.display()), + ConfigOrigin::MainWorktree => write!(f, "main worktree"), + } + } +} + +/// Resolve the directory from which `.sandcage/` config is loaded. +/// +/// Resolution order: +/// 1. `SANDCAGE_CONFIG_DIR` env var +/// 2. `.sandcage/config.toml` in workspace +/// 3. Redirect file (`sandcage.toml` at workspace root with `redirect =` key) +/// 4. Main worktree root (if workspace is a git worktree) +/// 5. None — no project config found +pub fn resolve_config_source(workspace: &Path) -> Option { + if let Ok(dir) = std::env::var("SANDCAGE_CONFIG_DIR") { + let expanded = expand_tilde(&dir); + if expanded.join(".sandcage").join("config.toml").exists() { + return Some(ConfigSource { root: expanded, origin: ConfigOrigin::EnvVar }); + } + eprintln!( + "sandcage: warning: SANDCAGE_CONFIG_DIR={dir} does not contain \ + .sandcage/config.toml, ignoring" + ); + } + + if workspace.join(".sandcage").join("config.toml").exists() { + return Some(ConfigSource { + root: workspace.to_path_buf(), + origin: ConfigOrigin::Workspace, + }); + } + + if let Some(target) = check_redirect(workspace) { + return Some(ConfigSource { + root: target, + origin: ConfigOrigin::Redirect { from: workspace.to_path_buf() }, + }); + } + + if let Some(main_root) = find_main_worktree_root(workspace) { + if main_root != workspace { + if main_root.join(".sandcage").join("config.toml").exists() { + return Some(ConfigSource { + root: main_root, + origin: ConfigOrigin::MainWorktree, + }); + } + if let Some(target) = check_redirect(&main_root) { + return Some(ConfigSource { + root: target, + origin: ConfigOrigin::Redirect { from: main_root }, + }); + } + } + } + + None +} + +fn expand_tilde(path: &str) -> PathBuf { + if path == "~" { + dirs::home_dir().unwrap_or_else(|| PathBuf::from(path)) + } else if let Some(suffix) = path.strip_prefix("~/") { + match dirs::home_dir() { + Some(home) => home.join(suffix), + None => PathBuf::from(path), + } + } else { + PathBuf::from(path) + } +} + +fn check_redirect(dir: &Path) -> Option { + let redirect_file = dir.join("sandcage.toml"); + if !redirect_file.exists() { + return None; + } + + let content = std::fs::read_to_string(&redirect_file).ok()?; + let parsed: toml::Value = toml::from_str(&content).ok()?; + let redirect = parsed.get("redirect")?.as_str()?; + + let target = expand_tilde(redirect); + let resolved = if target.is_relative() { + dir.join(&target) + } else { + target + }; + + if resolved.join(".sandcage").join("config.toml").exists() { + Some(resolved) + } else { + eprintln!( + "sandcage: warning: redirect in {} points to {}, which does not contain \ + .sandcage/config.toml", + redirect_file.display(), + resolved.display() + ); + None + } +} + +fn find_main_worktree_root(from: &Path) -> Option { + let common = Command::new("git") + .args(["rev-parse", "--git-common-dir"]) + .current_dir(from) + .output() + .ok()?; + if !common.status.success() { + return None; + } + + let git_dir = Command::new("git") + .args(["rev-parse", "--git-dir"]) + .current_dir(from) + .output() + .ok()?; + if !git_dir.status.success() { + return None; + } + + let common_path = PathBuf::from(String::from_utf8_lossy(&common.stdout).trim()); + let git_dir_path = PathBuf::from(String::from_utf8_lossy(&git_dir.stdout).trim()); + + let common_abs = from.join(&common_path).canonicalize().ok()?; + let git_dir_abs = from.join(&git_dir_path).canonicalize().ok()?; + + if common_abs == git_dir_abs { + return None; + } + + common_abs.parent().map(|p| p.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn make_git_repo() -> (TempDir, PathBuf) { + let dir = TempDir::new().expect("create tempdir"); + let path = dir.path().to_path_buf(); + Command::new("git") + .args(["init"]) + .current_dir(&path) + .status() + .expect("git init"); + // Need at least one commit for worktrees to work + Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(&path) + .status() + .expect("git commit"); + (dir, path) + } + + fn scaffold_sandcage(root: &Path) { + let dir = root.join(".sandcage"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("config.toml"), "# test config\n").unwrap(); + } + + #[test] + fn resolves_from_workspace() { + let dir = TempDir::new().unwrap(); + scaffold_sandcage(dir.path()); + let result = resolve_config_source(dir.path()); + assert!(result.is_some()); + let src = result.unwrap(); + assert!(matches!(src.origin, ConfigOrigin::Workspace)); + assert_eq!(src.root, dir.path().to_path_buf()); + } + + #[test] + fn returns_none_when_no_config() { + let dir = TempDir::new().unwrap(); + let result = resolve_config_source(dir.path()); + assert!(result.is_none()); + } + + #[test] + fn resolves_redirect() { + let dir = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + scaffold_sandcage(target.path()); + + let redirect_content = format!( + "redirect = \"{}\"", + target.path().to_string_lossy().replace('\\', "/") + ); + std::fs::write(dir.path().join("sandcage.toml"), redirect_content).unwrap(); + + let result = resolve_config_source(dir.path()); + assert!(result.is_some()); + let src = result.unwrap(); + assert!(matches!(src.origin, ConfigOrigin::Redirect { .. })); + } + + #[test] + fn invalid_redirect_returns_none() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("sandcage.toml"), + "redirect = \"/nonexistent/path\"\n", + ) + .unwrap(); + let result = resolve_config_source(dir.path()); + assert!(result.is_none()); + } + + #[test] + fn direct_config_wins_over_redirect() { + let dir = TempDir::new().unwrap(); + scaffold_sandcage(dir.path()); + // Also place a redirect — should be ignored since .sandcage/ exists + std::fs::write(dir.path().join("sandcage.toml"), "redirect = \"/somewhere\"\n").unwrap(); + + let result = resolve_config_source(dir.path()); + assert!(result.is_some()); + assert!(matches!(result.unwrap().origin, ConfigOrigin::Workspace)); + } + + #[test] + fn resolves_from_main_worktree() { + let (_guard, repo_root) = make_git_repo(); + scaffold_sandcage(&repo_root); + + let wt_path = repo_root.parent().unwrap().join("test-worktree"); + let status = Command::new("git") + .args([ + "worktree", + "add", + &wt_path.to_string_lossy(), + "-b", + "test-wt", + ]) + .current_dir(&repo_root) + .status() + .expect("git worktree add"); + assert!(status.success(), "git worktree add failed"); + + let result = resolve_config_source(&wt_path); + assert!(result.is_some(), "should find config from main worktree"); + let src = result.unwrap(); + assert!( + matches!(src.origin, ConfigOrigin::MainWorktree), + "origin should be MainWorktree, got {:?}", + src.origin + ); + + // Cleanup worktree + Command::new("git") + .args(["worktree", "remove", &wt_path.to_string_lossy()]) + .current_dir(&repo_root) + .status() + .ok(); + } + + #[test] + fn main_worktree_not_triggered_when_config_in_workspace() { + let (_guard, repo_root) = make_git_repo(); + scaffold_sandcage(&repo_root); + + // In normal (non-worktree) case, should resolve as Workspace + let result = resolve_config_source(&repo_root); + assert!(result.is_some()); + assert!(matches!(result.unwrap().origin, ConfigOrigin::Workspace)); + } + + #[test] + fn expand_tilde_expands_home() { + let home = dirs::home_dir().unwrap(); + assert_eq!(expand_tilde("~/foo"), home.join("foo")); + assert_eq!(expand_tilde("~"), home); + } + + #[test] + fn expand_tilde_leaves_absolute_unchanged() { + assert_eq!(expand_tilde("/absolute/path"), PathBuf::from("/absolute/path")); + } +} diff --git a/crates/sandcage/src/docker.rs b/crates/sandcage/src/docker.rs new file mode 100644 index 0000000..9525e48 --- /dev/null +++ b/crates/sandcage/src/docker.rs @@ -0,0 +1,1892 @@ +#![allow(unused_assignments)] +use std::collections::HashMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use tokio::process::Command; + +use miette::Diagnostic; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::config::SandcageConfig; +use crate::shadow::ShadowMount; +use crate::spoof::SPOOF_CONTAINER_DIR; + +// --------------------------------------------------------------------------- +// Bundled Dockerfiles +// --------------------------------------------------------------------------- + +pub const DOCKERFILE_BASE: &str = include_str!("../../../images/base/Dockerfile"); + +const FRAGMENT_MARKER: &str = "# SANDCAGE_TOOLCHAIN_FRAGMENTS — injected by sandcage build"; + +use crate::recipe::resolve_recipes; + +/// Drop custom (table-valued) toolchains from an untrusted project's config. +/// Built-in toolchains (boolean / string values) are always allowed; only custom +/// recipes — which carry arbitrary fragment build instructions — require trust. +fn filter_untrusted_toolchains( + mut toolchains: std::collections::HashMap, + is_trusted: bool, +) -> std::collections::HashMap { + if is_trusted { + return toolchains; + } + toolchains.retain(|name, value| { + let is_custom = matches!(value, toml::Value::Table(_)); + if is_custom { + eprintln!( + "sandcage: warning: custom toolchain '{name}' ignored (project not in trusted_projects)" + ); + } + !is_custom + }); + toolchains +} + +pub fn compose_dockerfile(config: &SandcageConfig, project_root: &Path) -> String { + let mut fragments = String::new(); + + if let Some(ref toolchains) = config.toolchains { + match resolve_recipes(toolchains, project_root) { + Ok(recipes) => { + for recipe in recipes { + match recipe.fragment_text() { + Ok(text) => { + fragments.push('\n'); + fragments.push_str(&text); + } + Err(e) => eprintln!("sandcage: warning: {e}"), + } + } + } + Err(e) => eprintln!("sandcage: warning: {e}"), + } + } + + DOCKERFILE_BASE.replace(FRAGMENT_MARKER, &fragments) +} + + +#[derive(Debug, Error, Diagnostic)] +pub enum DockerError { + #[error("docker executable not found in PATH")] + #[diagnostic( + code(sandcage::docker::not_found), + help("Install Docker: https://docs.docker.com/get-docker/") + )] + DockerNotFound, + + #[error("docker compose plugin not available")] + #[diagnostic( + code(sandcage::docker::compose_not_found), + help("Docker Compose V2 is required. Install it via Docker Desktop or 'docker compose' plugin.") + )] + ComposeNotFound, + + #[error("Failed to determine current user UID/GID: {0}")] + #[diagnostic(code(sandcage::docker::id_failed))] + IdFailed(String), + + #[error("Cannot determine home directory")] + #[diagnostic(code(sandcage::docker::no_home_dir))] + NoHomeDir, + + #[error("Failed to write temporary compose file: {0}")] + #[diagnostic(code(sandcage::docker::tempfile_failed))] + TempfileFailed(#[source] std::io::Error), + + #[error("Failed to spawn docker compose: {0}")] + #[diagnostic(code(sandcage::docker::spawn_failed))] + SpawnFailed(#[source] std::io::Error), + + #[error("Service '{service}' exited with status {code}")] + #[diagnostic(code(sandcage::docker::service_failed))] + ServiceFailed { service: String, code: i32 }, + + #[error("Failed to create temporary build directory: {0}")] + #[diagnostic(code(sandcage::docker::temp_dir_failed))] + TempDirFailed(#[source] std::io::Error), + + #[error("Failed to write Dockerfile to temp directory: {0}")] + #[diagnostic(code(sandcage::docker::dockerfile_write_failed))] + DockerfileWriteFailed(#[source] std::io::Error), + + #[error("Failed to read build-hashes file: {0}")] + #[diagnostic(code(sandcage::docker::hash_read_failed))] + HashReadFailed(#[source] std::io::Error), + + #[error("Failed to write build-hashes file: {0}")] + #[diagnostic(code(sandcage::docker::hash_write_failed))] + HashWriteFailed(#[source] std::io::Error), + + #[error("docker build for '{image}' exited with status {code}")] + #[diagnostic(code(sandcage::docker::build_failed))] + BuildFailed { image: String, code: i32 }, + + #[error("Image '{image}' not found locally")] + #[diagnostic( + code(sandcage::docker::image_not_found), + help("Run `sandcage build` to build the required images.") + )] + ImageNotFound { image: String }, + + #[error("Service '{service}' is not registered")] + #[diagnostic( + code(sandcage::docker::unknown_service), + help("Available services: {available}") + )] + UnknownService { service: String, available: String }, + + #[error("Service '{service}' is disabled in config")] + #[diagnostic( + code(sandcage::docker::service_disabled), + help("Enable it with: services.{service}.enabled: true") + )] + ServiceDisabled { service: String }, + + #[error("ACP relay requires the 'bollard' feature")] + #[diagnostic( + code(sandcage::docker::acp_requires_bollard), + help("Rebuild sandcage with: cargo build --features bollard") + )] + AcpRequiresBollard, +} + +pub type Result = std::result::Result; + +pub fn require_docker() -> Result { + which::which("docker").map_err(|_| DockerError::DockerNotFound) +} + +pub async fn require_compose(docker: &Path) -> Result<()> { + let output = Command::new(docker) + .args(["compose", "version"]) + .output() + .await + .map_err(|_| DockerError::ComposeNotFound)?; + + if !output.status.success() { + return Err(DockerError::ComposeNotFound); + } + Ok(()) +} + +async fn id_flag(flag: &str) -> Result { + let output = Command::new("id") + .arg(flag) + .output() + .await + .map_err(|e| DockerError::IdFailed(e.to_string()))?; + + if !output.status.success() { + return Err(DockerError::IdFailed(format!( + "id {flag} exited with {}", + output.status + ))); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +pub async fn build_compose_env(workspace: &Path, config: &SandcageConfig) -> Result> { + let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?; + let sandcage_home = home.join(".sandcage"); + + // On Windows, UID/GID passthrough is meaningless — use the container's + // built-in agent user (1000:1000). On Linux, match the host user. + let (uid, gid) = if cfg!(windows) { + ("1000".to_string(), "1000".to_string()) + } else { + (id_flag("-u").await?, id_flag("-g").await?) + }; + + let container_dir = match &config.container_workspace { + Some(path) if path.starts_with('/') => path.clone(), + Some(path) => { + eprintln!( + "sandcage: warning: container_workspace '{}' is not absolute, ignoring", + path + ); + default_container_dir(workspace) + } + None => default_container_dir(workspace), + }; + + let mut env = HashMap::new(); + env.insert("SANDCAGE_UID".into(), uid); + env.insert("SANDCAGE_GID".into(), gid); + env.insert( + "SANDCAGE_WORKSPACE".into(), + workspace.to_string_lossy().into_owned(), + ); + env.insert( + "SANDCAGE_HOME".into(), + sandcage_home.to_string_lossy().into_owned(), + ); + env.insert("SANDCAGE_CONTAINER_DIR".into(), container_dir); + + Ok(env) +} + +pub async fn build_compose_context(workspace: &Path, config: &SandcageConfig, config_root: &Path) -> Result { + let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?; + let sandcage_home = home.join(".sandcage"); + + let (uid, gid) = if cfg!(windows) { + ("1000".to_string(), "1000".to_string()) + } else { + (id_flag("-u").await?, id_flag("-g").await?) + }; + + let container_dir = match &config.container_workspace { + Some(path) if path.starts_with('/') => path.clone(), + Some(path) => { + eprintln!( + "sandcage: warning: container_workspace '{}' is not absolute, ignoring", + path + ); + default_container_dir(workspace) + } + None => default_container_dir(workspace), + }; + + let image = config.image.as_deref().unwrap_or("sandcage").to_string(); + + let agent_home = resolve_agent_home(&sandcage_home, config.home.as_deref()); + + Ok(crate::service::ComposeContext { + uid, + gid, + workspace: workspace.to_path_buf(), + container_dir, + sandcage_home, + image, + config_root: config_root.to_path_buf(), + forwarding_socket: None, + agent_home, + }) +} + +/// Resolve the agent home directory for a given home profile name. +/// +/// Uses `~/.sandcage/homes//`. If the legacy `~/.sandcage/home/` exists +/// and `~/.sandcage/homes/` does not, migrates it to `homes/default/`. +pub fn resolve_agent_home(sandcage_home: &Path, profile: Option<&str>) -> PathBuf { + let name = profile.unwrap_or("default"); + let homes_dir = sandcage_home.join("homes"); + let legacy_home = sandcage_home.join("home"); + + if !homes_dir.exists() && legacy_home.exists() { + if let Err(e) = migrate_legacy_home(&legacy_home, &homes_dir) { + eprintln!("sandcage: warning: failed to migrate home/ → homes/default/: {e}"); + return legacy_home; + } + eprintln!("sandcage: migrated home/ → homes/default/"); + } + + let agent_home = homes_dir.join(name); + if !agent_home.exists() { + if let Err(e) = std::fs::create_dir_all(&agent_home) { + eprintln!("sandcage: warning: failed to create home profile '{name}': {e}"); + } else { + eprintln!("sandcage: created home profile '{name}'"); + } + } + + if name != "default" { + eprintln!("sandcage: home profile → {name}"); + } + + agent_home +} + +fn migrate_legacy_home(legacy: &Path, homes_dir: &Path) -> std::result::Result<(), std::io::Error> { + let default_dir = homes_dir.join("default"); + std::fs::create_dir_all(homes_dir)?; + std::fs::rename(legacy, &default_dir)?; + Ok(()) +} + +fn default_container_dir(workspace: &Path) -> String { + match workspace.file_name() { + Some(name) => format!("/workspace/{}", name.to_string_lossy()), + None => "/workspace".to_string(), + } +} + +pub fn write_compose_tempfile(content: &str) -> Result { + let mut tmp = tempfile::Builder::new() + .prefix("sandcage-compose-") + .suffix(".yml") + .tempfile() + .map_err(DockerError::TempfileFailed)?; + + tmp.write_all(content.as_bytes()) + .map_err(DockerError::TempfileFailed)?; + tmp.flush().map_err(DockerError::TempfileFailed)?; + + Ok(tmp) +} + +pub async fn require_image(docker: &Path, image: &str) -> Result<()> { + let status = Command::new(docker) + .args(["image", "inspect", image]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await; + + match status { + Ok(s) if s.success() => Ok(()), + _ => Err(DockerError::ImageNotFound { + image: image.to_string(), + }), + } +} + +pub fn expand_mount_path(mount: &str) -> String { + let Some(colon_pos) = mount.find(':') else { + return mount.to_string(); + }; + + let host_path = &mount[..colon_pos]; + let rest = &mount[colon_pos..]; + + let expanded = if host_path == "~" { + match dirs::home_dir() { + Some(home) => home.to_string_lossy().into_owned(), + None => return mount.to_string(), + } + } else if let Some(suffix) = host_path.strip_prefix("~/") { + match dirs::home_dir() { + Some(home) => format!("{}/{suffix}", home.to_string_lossy()), + None => return mount.to_string(), + } + } else { + return mount.to_string(); + }; + + let normalized = expanded.replace('\\', "/"); + format!("{normalized}{rest}") +} + +/// Walk `.sandcage/overlay/` and `.sandcage/local/overlay/` and produce +/// bind-mount strings mapping each file into the container workspace. +/// Local overlay files appear later in the list so Docker uses them +/// (last mount wins). +pub fn collect_overlay_mounts(workspace: &Path, container_dir: &str) -> Vec { + let mut mounts = Vec::new(); + + let overlay_dirs = [ + workspace.join(".sandcage").join("overlay"), + workspace.join(".sandcage").join("local").join("overlay"), + ]; + + for overlay_dir in &overlay_dirs { + if !overlay_dir.is_dir() { + continue; + } + for entry in walkdir::WalkDir::new(overlay_dir) + .min_depth(1) + .into_iter() + .filter_map(|e| e.ok()) + { + if !entry.file_type().is_file() { + continue; + } + let rel = entry.path().strip_prefix(overlay_dir).unwrap(); + let host_path = entry.path().to_string_lossy().replace('\\', "/"); + let container_path = format!( + "{}/{}", + container_dir, + rel.to_string_lossy().replace('\\', "/") + ); + mounts.push(format!("{host_path}:{container_path}:ro")); + } + } + + mounts +} + +/// Resolve shadow mounts for a launch: recipe-contributed dirs + explicit `[shadow]` config. +pub fn collect_shadow_mounts( + config: &SandcageConfig, + config_root: &Path, + container_dir: &str, +) -> Vec { + let recipes = config + .toolchains + .as_ref() + .map(|tc| crate::recipe::resolve_recipes(tc, config_root).unwrap_or_default()) + .unwrap_or_default(); + let recipe_dirs = crate::shadow::recipe_shadow_dirs(&recipes); + crate::shadow::resolve_shadow_mounts(config.shadow.as_ref(), &recipe_dirs, config_root, container_dir) +} + +/// Project-scoped directory holding generated spoof scripts (gitignored via `local/`). +pub fn spoof_bin_dir(config_root: &Path) -> PathBuf { + config_root.join(".sandcage").join("local").join("spoof").join("bin") +} + +/// The bind-mount string placing generated spoof scripts on PATH (read-only), +/// or `None` if no spoof dir exists for this project. +pub fn collect_spoof_mount(config_root: &Path) -> Option { + let dir = spoof_bin_dir(config_root); + if dir.is_dir() { + Some(format!("{}:{}:ro", dir.display(), SPOOF_CONTAINER_DIR)) + } else { + None + } +} + +/// Regenerate spoof scripts for the merged config into the project spoof dir. +/// Clears any stale scripts first. No-op (and removes the dir) if no tools are configured. +pub fn generate_spoof_dir(config: &SandcageConfig, config_root: &Path) -> Result<()> { + let dir = spoof_bin_dir(config_root); + if dir.exists() { + std::fs::remove_dir_all(&dir).map_err(DockerError::TempDirFailed)?; + } + let Some(tools) = &config.tools else { return Ok(()) }; + let any_script = tools.values().any(|t| { + matches!( + t.mode.as_deref().map(crate::spoof::ToolMode::parse), + Some(crate::spoof::ToolMode::Spoof) | Some(crate::spoof::ToolMode::Host) + ) + }); + if !any_script { + return Ok(()); + } + std::fs::create_dir_all(&dir).map_err(DockerError::TempDirFailed)?; + crate::spoof::generate_spoof_scripts(tools, &dir) + .map_err(|e| DockerError::TempDirFailed(std::io::Error::other(e.to_string())))?; + Ok(()) +} + +/// Create host directories for `directory`-backend shadow mounts. +pub fn ensure_shadow_dirs(mounts: &[ShadowMount]) -> Result<()> { + for m in mounts { + if let Some(host) = &m.host_path { + std::fs::create_dir_all(host).map_err(DockerError::TempDirFailed)?; + } + } + Ok(()) +} + +pub fn resolve_security_opts(config: &SandcageConfig, project_root: &Path) -> Vec { + let Some(profiles) = &config.profiles else { + return vec![]; + }; + let mut opts = Vec::new(); + + if let Some(ref apparmor_path) = profiles.apparmor { + let resolved = project_root.join(apparmor_path); + if resolved.exists() { + let profile_name = extract_apparmor_name(&resolved) + .unwrap_or_else(|| "sandcage-cage".to_string()); + opts.push(format!("apparmor={profile_name}")); + } else { + eprintln!( + "sandcage: warning: AppArmor profile '{}' not found, skipping", + resolved.display() + ); + } + } + + if let Some(ref seccomp_path) = profiles.seccomp { + let resolved = project_root.join(seccomp_path); + if resolved.exists() { + let abs = resolved.canonicalize().unwrap_or(resolved); + opts.push(format!("seccomp={}", abs.display())); + } else { + eprintln!( + "sandcage: warning: seccomp profile '{}' not found, skipping", + resolved.display() + ); + } + } + + opts +} + +fn extract_apparmor_name(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + for line in content.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("profile ") { + let name = rest.split_whitespace().next()?; + return Some(name.to_string()); + } + } + None +} + +pub fn build_run_args( + service: &str, + compose_path: &str, + config: &SandcageConfig, + config_root: &Path, + container_dir: &str, + shell_override: bool, + extra_args: &[String], + forwarding_socket: Option<&Path>, + security_opts: &[String], +) -> Vec { + let mut args = vec![ + "compose".to_string(), + "-f".to_string(), + compose_path.to_string(), + "run".to_string(), + "--rm".to_string(), + ]; + + if let Some(ref env_map) = config.env { + for (key, value) in env_map { + args.push("-e".to_string()); + args.push(format!("{key}={value}")); + } + } + + if let Some(ref mount_list) = config.mounts { + for mount in mount_list { + if mount.contains(':') { + args.push("-v".to_string()); + args.push(expand_mount_path(mount)); + } + } + } + + // SSH mode-aware mount + match config.ssh_mode.as_deref() { + Some("volume") => { + args.push("-v".to_string()); + args.push("sandcage-ssh:/home/agent/.ssh:ro".to_string()); + } + Some("bind") => { + let mount = expand_mount_path("~/.ssh:/home/agent/.ssh:ro"); + args.push("-v".to_string()); + args.push(mount); + } + _ => {} // "none" or absent: no SSH mount + } + + // Overlay mounts from .sandcage/overlay/ and .sandcage/local/overlay/ + let overlay_enabled = config.overlay.as_ref() + .and_then(|o| o.enabled) + .unwrap_or(true); + if overlay_enabled { + for mount in collect_overlay_mounts(config_root, container_dir) { + args.push("-v".to_string()); + args.push(mount); + } + } + + // Shadow mounts (volume + directory as -v; tmpfs unsupported on `docker compose run`). + let shadow_mounts = collect_shadow_mounts(config, config_root, container_dir); + for bind in crate::shadow::shadow_bind_strings(&shadow_mounts) { + args.push("-v".to_string()); + args.push(bind); + } + for path in crate::shadow::shadow_tmpfs_paths(&shadow_mounts) { + eprintln!( + "sandcage: warning: tmpfs shadow dir '{path}' is not supported on the compose backend; \ + use the default (bollard) backend for tmpfs shadows — skipping" + ); + } + + if let Some(spoof) = collect_spoof_mount(config_root) { + args.push("-v".to_string()); + args.push(spoof); + } + + if let Some(sock) = forwarding_socket { + args.push("-v".to_string()); + args.push(format!( + "{}:{}", + sock.display(), + crate::forwarding::CONTAINER_SOCKET_PATH + )); + } + + if shell_override { + args.push("--entrypoint".to_string()); + args.push("/bin/zsh".to_string()); + } + + for opt in security_opts { + args.push("--security-opt".to_string()); + args.push(opt.clone()); + } + + args.push(service.to_string()); + + if !shell_override + && let Some(ref all_agent_args) = config.agent_args + && let Some(default_args) = all_agent_args.get(service) + { + for arg in default_args { + args.push(arg.clone()); + } + } + + for arg in extra_args { + args.push(arg.clone()); + } + + args +} + +pub async fn run_service( + service: &str, + workspace: &Path, + config: &SandcageConfig, + config_root: &Path, + registry: &crate::service::registry::ServiceRegistry, + shell_override: bool, + extra_args: &[String], +) -> Result<()> { + let svc = registry.get(service).ok_or_else(|| DockerError::UnknownService { + service: service.to_string(), + available: registry.names().collect::>().join(", "), + })?; + + if !svc.enabled() { + return Err(DockerError::ServiceDisabled { + service: service.to_string(), + }); + } + + let ctx = build_compose_context(workspace, config, config_root).await?; + + ensure_shadow_dirs(&collect_shadow_mounts(config, config_root, &ctx.container_dir))?; + + generate_spoof_dir(config, config_root)?; + + // --- Forwarding daemon lifecycle --- + let sandcage_home = dirs::home_dir() + .map(|h| h.join(".sandcage")) + .unwrap_or_else(|| PathBuf::from(".sandcage")); + + let has_host_tools = config + .tools + .as_ref() + .map(|t| { + t.values().any(|tc| { + tc.mode.as_deref().map(crate::spoof::ToolMode::parse) + == Some(crate::spoof::ToolMode::Host) + }) + }) + .unwrap_or(false); + + let forwarding_session: Option<(String, PathBuf, tokio::task::JoinHandle<()>)> = if has_host_tools { + let session_id = crate::forwarding::generate_session_id(); + crate::forwarding::create_session_dir(&sandcage_home, &session_id) + .map_err(|e| DockerError::TempDirFailed(e))?; + let socket = crate::forwarding::socket_path(&sandcage_home, &session_id); + + eprintln!("sandcage: forwarding daemon → {}", socket.display()); + + #[cfg(unix)] + let daemon_handle = { + let audit = crate::forwarding::audit_log_path(&sandcage_home, &session_id); + let mapping = crate::forwarding::PathMapping { + container_prefix: ctx.container_dir.clone(), + host_prefix: workspace.to_string_lossy().to_string(), + }; + let daemon_socket = socket.clone(); + let config_for_daemon = config.clone(); + tokio::spawn(async move { + if let Err(e) = crate::forwarding::serve_loop( + &daemon_socket, + &audit, + &mapping, + &config_for_daemon, + true, + ).await { + eprintln!("sandcage: forwarding daemon error: {e}"); + } + }) + }; + #[cfg(not(unix))] + let daemon_handle = { + eprintln!("sandcage: forwarding daemon not available on this platform"); + tokio::spawn(async {}) + }; + + Some((session_id, socket, daemon_handle)) + } else { + None + }; + + let forwarding_socket = forwarding_session.as_ref().map(|(_, sock, _)| sock.clone()); + + if let Some(ref profiles) = config.profiles { + if let Some(ref seccomp_path) = profiles.seccomp { + let resolved = config_root.join(seccomp_path); + if crate::forwarding::seccomp_uses_notify(&resolved) { + eprintln!("sandcage: seccomp profile uses SCMP_ACT_NOTIFY — fermata hook available"); + } + } + } + + let mut ctx = ctx; + ctx.forwarding_socket = forwarding_socket; + + let backend: Box = match config.compose_backend.as_deref() { + Some("compose") => Box::new(crate::backend::compose::ComposeBackend), + _ => crate::backend::default_backend()?, + }; + backend.check_availability().await?; + + let image_tag = format!("{}:latest", ctx.image); + if !backend.image_exists(&image_tag).await? { + return Err(DockerError::ImageNotFound { image: image_tag }); + } + + if config.ssh_mode.as_deref() == Some("volume") { + if !backend.volume_exists("sandcage-ssh").await? { + eprintln!( + "sandcage: SSH volume not found — run 'sandcage setup ssh --refresh' to populate it" + ); + } + } + + let exit_code = backend + .run_interactive(svc, &ctx, config, registry, shell_override, extra_args) + .await?; + + // --- Forwarding daemon cleanup --- + if let Some((session_id, _, daemon_handle)) = forwarding_session { + daemon_handle.abort(); + crate::forwarding::cleanup_session_dir(&sandcage_home, &session_id); + } + + if exit_code != 0 { + return Err(DockerError::ServiceFailed { + service: service.to_string(), + code: exit_code, + }); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Build with cache awareness +// --------------------------------------------------------------------------- + +/// SHA-256 hex digest of the given string content. +fn sha256_hex(content: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(content.as_bytes()); + hex::encode(hasher.finalize()) +} + +/// Path to the build-hashes file: `~/.sandcage/.build-hashes`. +fn build_hashes_path() -> Result { + let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?; + Ok(home.join(".sandcage").join(".build-hashes")) +} + +/// Read the stored hashes from `~/.sandcage/.build-hashes`. +/// Returns an empty map if the file does not exist. +fn read_stored_hashes(path: &Path) -> Result> { + if !path.exists() { + return Ok(HashMap::new()); + } + + let content = std::fs::read_to_string(path).map_err(DockerError::HashReadFailed)?; + let mut map = HashMap::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some((name, hash)) = line.split_once(':') { + map.insert(name.to_string(), hash.to_string()); + } + } + Ok(map) +} + +/// Persist the full hashes map back to the file. +fn write_stored_hashes(path: &Path, hashes: &HashMap) -> Result<()> { + // Ensure parent directory exists. + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(DockerError::HashWriteFailed)?; + } + + // Build deterministic output (sorted keys). + let mut lines: Vec = hashes + .iter() + .map(|(k, v)| format!("{k}:{v}")) + .collect(); + lines.sort(); + let content = lines.join("\n") + "\n"; + + std::fs::write(path, content).map_err(DockerError::HashWriteFailed)?; + Ok(()) +} + +/// Build a single image from inline Dockerfile content (temp build context). +async fn build_one_image_from_content(docker: &Path, image: &str, dockerfile_content: &str, no_cache: bool) -> Result<()> { + let tmp_dir = tempfile::Builder::new() + .prefix("sandcage-build-") + .tempdir() + .map_err(DockerError::TempDirFailed)?; + + let dockerfile_path = tmp_dir.path().join("Dockerfile"); + std::fs::write(&dockerfile_path, dockerfile_content) + .map_err(DockerError::DockerfileWriteFailed)?; + + for svc in crate::service::builtin::all() { + if let (Some(name), Some(script)) = (svc.entrypoint_name.as_ref(), svc.entrypoint_script.as_ref()) + && name.starts_with("sandcage-") + { + let dir = tmp_dir.path().join("images").join(&svc.name); + std::fs::create_dir_all(&dir).map_err(DockerError::TempDirFailed)?; + std::fs::write(dir.join("entrypoint.sh"), script) + .map_err(DockerError::DockerfileWriteFailed)?; + } + } + + run_docker_build(docker, image, tmp_dir.path(), None, no_cache).await +} + +/// Build a single image from a user-provided path. +/// If the path is a directory, it's used as the build context. +/// If the path is a file, it's used as the Dockerfile with a temp context. +async fn build_one_image_from_path(docker: &Path, image: &str, override_path: &Path, no_cache: bool) -> Result<()> { + if override_path.is_dir() { + run_docker_build(docker, image, override_path, None, no_cache).await + } else { + let tmp_dir = tempfile::Builder::new() + .prefix("sandcage-build-") + .tempdir() + .map_err(DockerError::TempDirFailed)?; + + run_docker_build(docker, image, tmp_dir.path(), Some(override_path), no_cache).await + } +} + +async fn run_docker_build( + docker: &Path, + image: &str, + context: &Path, + dockerfile: Option<&Path>, + no_cache: bool, +) -> Result<()> { + let mut cmd = Command::new(docker); + cmd.args(["build", "-t", &format!("{image}:latest")]); + + if no_cache { + cmd.arg("--no-cache"); + } + + if let Some(df) = dockerfile { + cmd.args(["-f", &df.to_string_lossy()]); + } + + cmd.arg(context) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + + let status = cmd.status().await.map_err(DockerError::SpawnFailed)?; + + if !status.success() { + return Err(DockerError::BuildFailed { + image: image.to_string(), + code: status.code().unwrap_or(-1), + }); + } + + Ok(()) +} + +/// Resolve the Dockerfile content for hashing purposes. +/// Returns the content string for override files/dirs, or None if the path is invalid. +fn read_dockerfile_at(path: &Path) -> Result { + let dockerfile_path = if path.is_dir() { + path.join("Dockerfile") + } else { + path.to_path_buf() + }; + std::fs::read_to_string(&dockerfile_path).map_err(DockerError::HashReadFailed) +} + +/// Build all three sandcage images with cache awareness. +/// +/// * Resolves layered config to check for Dockerfile overrides. +/// * When `force` is `true`, every image is rebuilt regardless of the stored hash. +/// * Otherwise, an image is skipped when its Dockerfile hash matches the stored value. +/// * After a successful build the hash is updated in `~/.sandcage/.build-hashes`. +pub async fn build_images(force: bool, service_filter: &[String]) -> Result<()> { + let docker = require_docker()?; + + let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?; + let global_config = home.join(".sandcage").join("config.toml"); + let project_dir = std::env::current_dir().unwrap_or_default(); + let project_config = project_dir.join(".sandcage").join("config.toml"); + + // Global overrides are always trusted. + let global_cfg = crate::config::resolve_config(Some(&global_config), None, None) + .unwrap_or_default(); + + // Validate service names if provided + if !service_filter.is_empty() { + let registry = crate::service::registry::build_default_registry(&global_cfg); + let known: Vec<&str> = registry.names().collect(); + for name in service_filter { + if !known.contains(&name.as_str()) { + return Err(DockerError::UnknownService { + service: name.clone(), + available: known.join(", "), + }); + } + } + eprintln!("sandcage: building for services: {}", service_filter.join(", ")); + } + + let mut overrides = global_cfg.dockerfiles.clone().unwrap_or_default(); + + // Project overrides require the project to be in trusted_projects. + let project_cfg = if project_config.exists() { + crate::config::load(&project_config).ok() + } else { + None + }; + + // Compute trust once; used for both dockerfiles and toolchains overrides. + let trusted = crate::config::load_trusted_projects(&global_config); + let is_trusted = trusted.iter().any(|p| { + let canonical_trusted = std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()); + let canonical_project = std::fs::canonicalize(&project_dir) + .unwrap_or_else(|_| project_dir.clone()); + canonical_project.starts_with(canonical_trusted) + }); + + if let Some(ref pcfg) = project_cfg + && pcfg.dockerfiles.is_some() + { + if is_trusted { + overrides.extend(pcfg.dockerfiles.clone().unwrap_or_default()); + } else { + eprintln!( + "sandcage: warning: project Dockerfile overrides ignored (not in trusted_projects)" + ); + eprintln!( + "sandcage: add this project to trusted_projects in ~/.sandcage/config.toml" + ); + } + } + + let merged_cfg = if let Some(ref pcfg) = project_cfg { + let mut merged = global_cfg.clone(); + if let Some(ref tc) = pcfg.toolchains { + merged.toolchains = Some(filter_untrusted_toolchains(tc.clone(), is_trusted)); + } + merged + } else { + global_cfg.clone() + }; + + let image_name = global_cfg.image.as_deref().unwrap_or("sandcage"); + let composed = compose_dockerfile(&merged_cfg, &project_dir); + let images: Vec<(&str, &str)> = vec![ + (image_name, &composed), + ]; + + let hashes_path = build_hashes_path()?; + let mut stored = read_stored_hashes(&hashes_path)?; + + for (image, bundled_dockerfile) in images { + let short_name = image.strip_prefix("sandcage-").unwrap_or(image); + let override_path = overrides.get(short_name); + + let current_hash = if let Some(path) = override_path { + sha256_hex(&read_dockerfile_at(path)?) + } else { + sha256_hex(bundled_dockerfile) + }; + + let needs_build = force || stored.get(image) != Some(¤t_hash); + + if needs_build { + if force { + eprintln!("{image}: rebuilding (--force)"); + } else if override_path.is_some() { + eprintln!("{image}: rebuilding (custom Dockerfile changed)"); + } else { + eprintln!("{image}: rebuilding (Dockerfile changed)"); + } + + if let Some(path) = override_path { + build_one_image_from_path(&docker, image, path, force).await?; + } else { + build_one_image_from_content(&docker, image, bundled_dockerfile, force).await?; + } + + stored.insert(image.to_string(), current_hash); + write_stored_hashes(&hashes_path, &stored)?; + } else { + eprintln!("{image}: up to date"); + } + } + + Ok(()) +} + +/// Volume-name prefix identifying this project's per-project shadow volumes. +pub fn project_volume_prefix(project_root: &Path) -> String { + format!( + "sandcage-{}-{}-", + crate::shadow::project_slug(project_root), + crate::shadow::project_hash(project_root) + ) +} + +/// List and (optionally) remove this project's shadow volumes. `apply = false` is a dry run. +pub async fn clean(project_root: &Path, apply: bool) -> Result<()> { + let docker = require_docker()?; + let prefix = project_volume_prefix(project_root); + + let output = Command::new(&docker) + .args(["volume", "ls", "--quiet", "--filter", &format!("name={prefix}")]) + .output() + .await + .map_err(DockerError::SpawnFailed)?; + + let names: Vec = String::from_utf8_lossy(&output.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| l.starts_with(&prefix)) // docker filter is substring; re-assert prefix + .collect(); + + if names.is_empty() { + eprintln!("sandcage: no shadow volumes for this project"); + return Ok(()); + } + + for name in &names { + if apply { + let status = Command::new(&docker) + .args(["volume", "rm", "-f", name]) + .status() + .await + .map_err(DockerError::SpawnFailed)?; + eprintln!("{}", if status.success() { format!("removed {name}") } else { format!("sandcage: warning: failed to remove {name}") }); + } else { + eprintln!("would remove {name}"); + } + } + + if !apply { + eprintln!("\nsandcage: dry run — re-run with `--yes` to remove {} volume(s)", names.len()); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn generated_compose_contains_all_services() { + use crate::service::ComposeContext; + use crate::service::registry::build_default_registry; + use crate::service::compose::generate_compose; + + let config = SandcageConfig::default(); + let registry = build_default_registry(&config); + let ctx = ComposeContext { + uid: "1000".to_string(), + gid: "1000".to_string(), + workspace: PathBuf::from("/tmp/test"), + container_dir: "/workspace/test".to_string(), + sandcage_home: PathBuf::from("/home/user/.sandcage"), + image: "sandcage".to_string(), + config_root: PathBuf::from("/tmp/test"), + forwarding_socket: None, + agent_home: PathBuf::from("/home/user/.sandcage/homes/default"), + }; + let yaml = generate_compose(®istry, &ctx); + + assert!(yaml.contains("claude:"), "should contain claude service"); + assert!(yaml.contains("codex:"), "should contain codex service"); + assert!(yaml.contains("gemini:"), "should contain gemini service"); + assert!(yaml.contains("shell:"), "should contain shell service"); + } + + #[tokio::test] + async fn build_compose_env_contains_required_keys() { + let workspace = PathBuf::from("/tmp/test-workspace"); + let env = build_compose_env(&workspace, &SandcageConfig::default()).await.expect("build_compose_env"); + + assert!(env.contains_key("SANDCAGE_UID"), "missing SANDCAGE_UID"); + assert!(env.contains_key("SANDCAGE_GID"), "missing SANDCAGE_GID"); + assert!(env.contains_key("SANDCAGE_WORKSPACE"), "missing SANDCAGE_WORKSPACE"); + assert!(env.contains_key("SANDCAGE_HOME"), "missing SANDCAGE_HOME"); + assert!(env.contains_key("SANDCAGE_CONTAINER_DIR"), "missing SANDCAGE_CONTAINER_DIR"); + } + + #[tokio::test] + async fn build_compose_env_workspace_matches() { + let workspace = PathBuf::from("/my/project"); + let env = build_compose_env(&workspace, &SandcageConfig::default()).await.expect("build_compose_env"); + assert_eq!(env["SANDCAGE_WORKSPACE"], "/my/project"); + } + + #[tokio::test] + async fn build_compose_env_home_ends_with_sandcage() { + let workspace = PathBuf::from("/tmp"); + let env = build_compose_env(&workspace, &SandcageConfig::default()).await.expect("build_compose_env"); + assert!( + env["SANDCAGE_HOME"].ends_with(".sandcage"), + "SANDCAGE_HOME should end with .sandcage, got: {}", + env["SANDCAGE_HOME"] + ); + } + + #[tokio::test] + async fn uid_gid_are_numeric() { + let workspace = PathBuf::from("/tmp"); + let env = build_compose_env(&workspace, &SandcageConfig::default()).await.expect("build_compose_env"); + + let uid: u32 = env["SANDCAGE_UID"] + .parse() + .expect("UID should be numeric"); + let gid: u32 = env["SANDCAGE_GID"] + .parse() + .expect("GID should be numeric"); + + // Basic sanity — UIDs/GIDs are non-negative (they're u32) + assert!(uid < 1_000_000, "UID seems unreasonably large: {uid}"); + assert!(gid < 1_000_000, "GID seems unreasonably large: {gid}"); + } + + #[test] + fn write_compose_tempfile_creates_valid_yaml() { + let content = "services:\n test:\n image: test:latest\n"; + let tmp = write_compose_tempfile(content).expect("write_compose_tempfile"); + let read_back = std::fs::read_to_string(tmp.path()).expect("read tempfile"); + assert_eq!(read_back, content, "tempfile content should match input"); + } + + #[test] + fn require_docker_finds_docker_or_fails_cleanly() { + // This test documents the behavior — it either finds docker or returns + // DockerNotFound. We don't assert which, since CI may or may not have docker. + let result = require_docker(); + match result { + Ok(path) => assert!(path.to_string_lossy().contains("docker")), + Err(DockerError::DockerNotFound) => { /* expected on machines without docker */ } + Err(e) => panic!("unexpected error: {e}"), + } + } + + // ----------------------------------------------------------------------- + // build_images / cache-awareness tests + // ----------------------------------------------------------------------- + + #[test] + fn dockerfiles_are_bundled() { + assert!(!DOCKERFILE_BASE.is_empty(), "base Dockerfile should not be empty"); + assert!(DOCKERFILE_BASE.contains(FRAGMENT_MARKER), "base should contain fragment marker"); + } + + #[test] + fn compose_dockerfile_injects_fragments() { + let mut config = SandcageConfig::default(); + let mut tc = std::collections::HashMap::new(); + tc.insert("uv".to_string(), toml::Value::Boolean(true)); + config.toolchains = Some(tc); + + let result = compose_dockerfile(&config, std::path::Path::new("/proj")); + assert!(result.contains("astral.sh/uv"), "uv fragment should be injected"); + assert!(!result.contains(FRAGMENT_MARKER), "marker should be replaced"); + } + + #[test] + fn compose_dockerfile_no_toolchains() { + let config = SandcageConfig::default(); + let result = compose_dockerfile(&config, std::path::Path::new("/proj")); + assert!(!result.contains(FRAGMENT_MARKER), "marker should always be replaced"); + } + + #[test] + fn compose_dockerfile_disabled_toolchain() { + let mut config = SandcageConfig::default(); + let mut tc = std::collections::HashMap::new(); + tc.insert("uv".to_string(), toml::Value::Boolean(false)); + config.toolchains = Some(tc); + + let result = compose_dockerfile(&config, std::path::Path::new("/proj")); + assert!(!result.contains("astral.sh/uv"), "disabled toolchain should not be injected"); + } + + #[test] + fn custom_toolchain_table_is_dropped_when_untrusted() { + use crate::recipe::FragmentSource; + + // Project config with a custom (table) toolchain and a built-in (bool) toolchain. + let mut tc = std::collections::HashMap::new(); + tc.insert("uv".to_string(), toml::Value::Boolean(true)); + let mut table = toml::value::Table::new(); + table.insert("fragment".into(), toml::Value::String("zig.Dockerfile".into())); + tc.insert("zig".to_string(), toml::Value::Table(table)); + + let filtered = filter_untrusted_toolchains(tc, false); + assert!(filtered.contains_key("uv"), "built-in bool toolchain kept"); + assert!(!filtered.contains_key("zig"), "custom table toolchain dropped when untrusted"); + + // sanity: FragmentSource still importable (keeps the recipe dep wired) + let _ = FragmentSource::Embedded("x"); + } + + #[test] + fn custom_toolchain_table_is_kept_when_trusted() { + let mut tc = std::collections::HashMap::new(); + let mut table = toml::value::Table::new(); + table.insert("fragment".into(), toml::Value::String("zig.Dockerfile".into())); + tc.insert("zig".to_string(), toml::Value::Table(table)); + + let filtered = filter_untrusted_toolchains(tc, true); + assert!(filtered.contains_key("zig"), "custom toolchain kept when trusted"); + } + + #[test] + fn sha256_hex_is_deterministic() { + let content = "FROM ubuntu:22.04\nRUN echo hello\n"; + let h1 = sha256_hex(content); + let h2 = sha256_hex(content); + assert_eq!(h1, h2, "sha256_hex must be deterministic"); + // SHA-256 produces 64 hex chars + assert_eq!(h1.len(), 64, "sha256_hex should produce 64-char hex string"); + } + + #[test] + fn sha256_hex_differs_for_different_input() { + let h1 = sha256_hex("FROM ubuntu:22.04\n"); + let h2 = sha256_hex("FROM debian:bookworm\n"); + assert_ne!(h1, h2, "different content must yield different hash"); + } + + #[test] + fn hash_file_roundtrip() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let path = tmp.path().join(".build-hashes"); + + let mut hashes = HashMap::new(); + hashes.insert("sandcage".to_string(), "aabbcc".to_string()); + hashes.insert("sandcage-claude".to_string(), "ddeeff".to_string()); + + write_stored_hashes(&path, &hashes).expect("write hashes"); + let loaded = read_stored_hashes(&path).expect("read hashes"); + + assert_eq!(loaded.get("sandcage").map(String::as_str), Some("aabbcc")); + assert_eq!(loaded.get("sandcage-claude").map(String::as_str), Some("ddeeff")); + } + + #[test] + fn read_stored_hashes_returns_empty_when_file_missing() { + let tmp = tempfile::tempdir().expect("create temp dir"); + let path = tmp.path().join("nonexistent"); + + let result = read_stored_hashes(&path).expect("read hashes on missing file"); + assert!(result.is_empty(), "should return empty map when file does not exist"); + } + + #[test] + fn cache_hit_detection_same_hash() { + let dockerfile = "FROM ubuntu:22.04\n"; + let hash = sha256_hex(dockerfile); + + let mut stored: HashMap = HashMap::new(); + stored.insert("sandcage".to_string(), hash.clone()); + + let needs_build = stored.get("sandcage").map_or(true, |h| h != &hash); + assert!(!needs_build, "same hash should be a cache hit (no rebuild needed)"); + } + + #[test] + fn cache_miss_detection_different_hash() { + let dockerfile = "FROM ubuntu:22.04\n"; + let current_hash = sha256_hex(dockerfile); + + let mut stored: HashMap = HashMap::new(); + stored.insert("sandcage".to_string(), "old_stale_hash".to_string()); + + let needs_build = stored.get("sandcage").map_or(true, |h| h != ¤t_hash); + assert!(needs_build, "different hash should be a cache miss (rebuild needed)"); + } + + #[test] + fn cache_miss_detection_missing_entry() { + let dockerfile = "FROM ubuntu:22.04\n"; + let current_hash = sha256_hex(dockerfile); + let stored: HashMap = HashMap::new(); + + let needs_build = stored.get("sandcage").map_or(true, |h| h != ¤t_hash); + assert!(needs_build, "missing entry should be treated as a cache miss"); + } + + #[test] + fn force_flag_bypasses_cache() { + // Even when hash matches, force=true means needs_build is true + let dockerfile = "FROM ubuntu:22.04\n"; + let hash = sha256_hex(dockerfile); + + let mut stored: HashMap = HashMap::new(); + stored.insert("sandcage".to_string(), hash.clone()); + + let force = true; + let needs_build = force || stored.get("sandcage").map_or(true, |h| h != &hash); + assert!(needs_build, "force flag should always trigger a rebuild"); + } + + #[test] + fn write_stored_hashes_creates_parent_dirs() { + let tmp = tempfile::tempdir().expect("create temp dir"); + // Nested path that doesn't exist yet + let path = tmp.path().join("a").join("b").join(".build-hashes"); + + let mut hashes = HashMap::new(); + hashes.insert("sandcage".to_string(), "abc123".to_string()); + + write_stored_hashes(&path, &hashes).expect("should create parent dirs and write"); + assert!(path.exists(), "hash file should have been created"); + } + + #[tokio::test] + async fn require_image_fails_for_nonexistent_image() { + let docker = match require_docker() { + Ok(path) => path, + Err(_) => return, // skip if docker not installed + }; + let result = require_image(&docker, "sandcage-nonexistent-image-abc123").await; + assert!( + matches!(result, Err(DockerError::ImageNotFound { .. })), + "should fail for nonexistent image: {result:?}" + ); + } + + #[test] + fn build_run_args_basic() { + let config = SandcageConfig::default(); + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", false, &[], None, &[]); + assert_eq!(args, vec!["compose", "-f", "/tmp/compose.yml", "run", "--rm", "claude"]); + } + + #[test] + fn build_run_args_with_env() { + let mut env = std::collections::HashMap::new(); + env.insert("FOO".to_string(), "bar".to_string()); + let config = SandcageConfig { + env: Some(env), + ..Default::default() + }; + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", false, &[], None, &[]); + assert!(args.contains(&"-e".to_string())); + assert!(args.contains(&"FOO=bar".to_string())); + let service_pos = args.iter().position(|a| a == "claude").unwrap(); + let env_pos = args.iter().position(|a| a == "FOO=bar").unwrap(); + assert!(env_pos < service_pos, "-e flags must come before the service name"); + } + + #[test] + fn build_run_args_shell_override() { + let config = SandcageConfig::default(); + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", true, &[], None, &[]); + assert!(args.contains(&"--entrypoint".to_string())); + assert!(args.contains(&"/bin/zsh".to_string())); + let ep_pos = args.iter().position(|a| a == "--entrypoint").unwrap(); + let service_pos = args.iter().position(|a| a == "claude").unwrap(); + assert!(ep_pos < service_pos, "--entrypoint must come before the service name"); + } + + #[test] + fn build_run_args_extra_args_after_service() { + let config = SandcageConfig::default(); + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", false, &["--resume".to_string(), "--verbose".to_string()], None, &[]); + let service_pos = args.iter().position(|a| a == "claude").unwrap(); + let resume_pos = args.iter().position(|a| a == "--resume").unwrap(); + let verbose_pos = args.iter().position(|a| a == "--verbose").unwrap(); + assert!(resume_pos > service_pos, "extra args must come after the service name"); + assert!(verbose_pos > resume_pos, "extra args must preserve order"); + } + + #[test] + fn build_run_args_shell_override_with_extra_args() { + let config = SandcageConfig::default(); + let args = build_run_args("codex", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", true, &["--help".to_string()], None, &[]); + assert!(args.contains(&"--entrypoint".to_string())); + assert!(args.contains(&"/bin/zsh".to_string())); + let service_pos = args.iter().position(|a| a == "codex").unwrap(); + let help_pos = args.iter().position(|a| a == "--help").unwrap(); + assert!(help_pos > service_pos); + } + + #[test] + fn build_run_args_shell_override_skips_agent_args() { + let mut agent_args = HashMap::new(); + agent_args.insert("claude".to_string(), vec!["--dangerously-skip-permissions".to_string()]); + let config = SandcageConfig { + agent_args: Some(agent_args), + ..Default::default() + }; + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", true, &[], None, &[]); + assert!(!args.contains(&"--dangerously-skip-permissions".to_string()), + "agent_args must not be passed in shell mode"); + assert!(args.contains(&"--entrypoint".to_string())); + } + + #[tokio::test] + async fn build_compose_env_container_dir_auto_derived() { + let workspace = PathBuf::from("/home/user/projects/my-app"); + let config = SandcageConfig::default(); + let env = build_compose_env(&workspace, &config).await.expect("build_compose_env"); + assert_eq!(env["SANDCAGE_CONTAINER_DIR"], "/workspace/my-app"); + } + + #[tokio::test] + async fn build_compose_env_container_dir_from_config() { + let workspace = PathBuf::from("/home/user/projects/my-app"); + let config = SandcageConfig { + container_workspace: Some("/workspace/custom".to_string()), + ..Default::default() + }; + let env = build_compose_env(&workspace, &config).await.expect("build_compose_env"); + assert_eq!(env["SANDCAGE_CONTAINER_DIR"], "/workspace/custom"); + } + + #[tokio::test] + async fn build_compose_env_container_dir_ignores_relative_override() { + let workspace = PathBuf::from("/home/user/projects/my-app"); + let config = SandcageConfig { + container_workspace: Some("relative/path".to_string()), + ..Default::default() + }; + let env = build_compose_env(&workspace, &config).await.expect("build_compose_env"); + assert_eq!( + env["SANDCAGE_CONTAINER_DIR"], "/workspace/my-app", + "relative paths should be ignored, falling back to auto-derive" + ); + } + + #[test] + fn build_run_args_with_mounts() { + let home = dirs::home_dir().unwrap(); + let home_str = home.to_string_lossy().replace('\\', "/"); + let config = SandcageConfig { + mounts: Some(vec![ + "~/.ssh:/home/agent/.ssh:ro".to_string(), + "~/.gitconfig:/home/agent/.gitconfig:ro".to_string(), + ]), + ..Default::default() + }; + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", false, &[], None, &[]); + + assert!(args.contains(&"-v".to_string()), "should contain -v flag"); + let expected_ssh = format!("{home_str}/.ssh:/home/agent/.ssh:ro"); + let expected_git = format!("{home_str}/.gitconfig:/home/agent/.gitconfig:ro"); + assert!(args.contains(&expected_ssh), "SSH mount should be expanded"); + assert!(args.contains(&expected_git), "gitconfig mount should be expanded"); + + let service_pos = args.iter().position(|a| a == "claude").unwrap(); + let first_v = args.iter().position(|a| a == "-v").unwrap(); + assert!(first_v < service_pos, "-v flags must come before the service name"); + } + + #[test] + fn build_run_args_skips_invalid_mounts() { + let config = SandcageConfig { + mounts: Some(vec![ + "/valid:/mount:ro".to_string(), + "/no-colon-here".to_string(), + ]), + ..Default::default() + }; + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", false, &[], None, &[]); + + let v_count = args.iter().filter(|a| a.as_str() == "-v").count(); + assert_eq!(v_count, 1, "only valid mounts should produce -v flags"); + assert!(args.contains(&"/valid:/mount:ro".to_string())); + assert!(!args.contains(&"/no-colon-here".to_string())); + } + + #[test] + fn build_run_args_mounts_with_env_and_extra_args() { + let home = dirs::home_dir().unwrap(); + let home_str = home.to_string_lossy().replace('\\', "/"); + let mut env = HashMap::new(); + env.insert("FOO".to_string(), "bar".to_string()); + let config = SandcageConfig { + env: Some(env), + mounts: Some(vec!["~/.ssh:/home/agent/.ssh:ro".to_string()]), + ..Default::default() + }; + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", false, &["--resume".to_string()], None, &[]); + + let service_pos = args.iter().position(|a| a == "claude").unwrap(); + let env_pos = args.iter().position(|a| a == "FOO=bar").unwrap(); + let expected_mount = format!("{home_str}/.ssh:/home/agent/.ssh:ro"); + let mount_pos = args.iter().position(|a| *a == expected_mount).unwrap(); + let resume_pos = args.iter().position(|a| a == "--resume").unwrap(); + + assert!(env_pos < service_pos, "env before service"); + assert!(mount_pos < service_pos, "mounts before service"); + assert!(resume_pos > service_pos, "extra args after service"); + } + + #[test] + fn build_run_args_prepends_default_agent_args() { + let mut agent_args = HashMap::new(); + agent_args.insert("claude".to_string(), vec!["--dangerously-skip-permissions".to_string()]); + let config = SandcageConfig { + agent_args: Some(agent_args), + ..Default::default() + }; + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", false, &["--resume".to_string()], None, &[]); + + let service_pos = args.iter().position(|a| a == "claude").unwrap(); + let default_pos = args.iter().position(|a| a == "--dangerously-skip-permissions").unwrap(); + let resume_pos = args.iter().position(|a| a == "--resume").unwrap(); + + assert!(default_pos > service_pos, "default args come after service name"); + assert!(default_pos < resume_pos, "default args come before CLI args"); + } + + #[test] + fn build_run_args_ignores_other_service_defaults() { + let mut agent_args = HashMap::new(); + agent_args.insert("codex".to_string(), vec!["--full-auto".to_string()]); + let config = SandcageConfig { + agent_args: Some(agent_args), + ..Default::default() + }; + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", false, &[], None, &[]); + + assert!(!args.contains(&"--full-auto".to_string()), "codex args should not appear for claude"); + } + + #[test] + fn build_run_args_no_defaults_cli_args_still_work() { + let config = SandcageConfig::default(); + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", false, &["--resume".to_string()], None, &[]); + + let service_pos = args.iter().position(|a| a == "claude").unwrap(); + let resume_pos = args.iter().position(|a| a == "--resume").unwrap(); + assert!(resume_pos > service_pos); + } + + #[test] + fn build_run_args_ssh_mode_volume() { + let config = SandcageConfig { + ssh_mode: Some("volume".to_string()), + ..Default::default() + }; + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", false, &[], None, &[]); + let v_positions: Vec<_> = args.iter().enumerate() + .filter(|(_, a)| *a == "-v") + .map(|(i, _)| i) + .collect(); + let has_volume_mount = v_positions.iter().any(|&i| { + args.get(i + 1).map(|a| a == "sandcage-ssh:/home/agent/.ssh:ro").unwrap_or(false) + }); + assert!(has_volume_mount, "should have sandcage-ssh volume mount, args: {:?}", args); + } + + #[test] + fn build_run_args_ssh_mode_bind() { + let config = SandcageConfig { + ssh_mode: Some("bind".to_string()), + ..Default::default() + }; + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", false, &[], None, &[]); + assert!(args.contains(&"-v".to_string())); + // Find the -v flag and check the mount string contains .ssh + let v_positions: Vec<_> = args + .iter() + .enumerate() + .filter(|(_, a)| *a == "-v") + .map(|(i, _)| i) + .collect(); + let has_ssh_mount = v_positions.iter().any(|&i| { + args.get(i + 1) + .map(|a| a.contains(".ssh") && a.ends_with(":ro")) + .unwrap_or(false) + }); + assert!(has_ssh_mount, "should have .ssh bind mount, args: {:?}", args); + } + + #[test] + fn build_run_args_ssh_mode_none() { + let config = SandcageConfig { + ssh_mode: Some("none".to_string()), + ..Default::default() + }; + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", false, &[], None, &[]); + assert!(!args.iter().any(|a| a.contains("sandcage-ssh"))); + assert!(!args.iter().any(|a| a.contains(".ssh"))); + } + + #[test] + fn build_run_args_ssh_mode_absent() { + let config = SandcageConfig::default(); + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/test", false, &[], None, &[]); + assert!(!args.contains(&"--mount".to_string())); + // May have .ssh in other mounts, but no SSH-specific mount + } + + #[test] + fn build_run_args_does_not_pass_tmpfs_cli_flag() { + let config = SandcageConfig::default(); + let args = build_run_args("claude", "/tmp/compose.yml", &config, Path::new("/tmp/test"), "/workspace/my-app", false, &[], None, &[]); + assert!(!args.contains(&"--tmpfs".to_string()), "tmpfs is in compose YAML, not CLI args"); + } + + #[tokio::test] + async fn build_compose_env_container_dir_root_fallback() { + let workspace = PathBuf::from("/"); + let config = SandcageConfig::default(); + let env = build_compose_env(&workspace, &config).await.expect("build_compose_env"); + assert_eq!(env["SANDCAGE_CONTAINER_DIR"], "/workspace"); + } + + #[test] + fn expand_mount_path_expands_tilde_slash() { + let home = dirs::home_dir().unwrap(); + let home_str = home.to_string_lossy().replace('\\', "/"); + let result = expand_mount_path("~/.ssh:/home/agent/.ssh:ro"); + assert_eq!(result, format!("{home_str}/.ssh:/home/agent/.ssh:ro")); + } + + #[test] + fn expand_mount_path_expands_bare_tilde() { + let home = dirs::home_dir().unwrap(); + let home_str = home.to_string_lossy().replace('\\', "/"); + let result = expand_mount_path("~:/container"); + assert_eq!(result, format!("{home_str}:/container")); + } + + #[test] + fn expand_mount_path_ignores_absolute() { + let result = expand_mount_path("/absolute:/container:ro"); + assert_eq!(result, "/absolute:/container:ro"); + } + + #[test] + fn expand_mount_path_ignores_relative() { + let result = expand_mount_path("./relative:/container"); + assert_eq!(result, "./relative:/container"); + } + + #[test] + fn expand_mount_path_ignores_no_colon() { + let result = expand_mount_path("no-colon"); + assert_eq!(result, "no-colon"); + } + + // ----------------------------------------------------------------------- + // overlay mount tests + // ----------------------------------------------------------------------- + + #[test] + fn collect_overlay_mounts_from_tempdir() { + let dir = tempfile::tempdir().expect("create tempdir"); + let overlay = dir.path().join(".sandcage").join("overlay"); + std::fs::create_dir_all(&overlay).unwrap(); + std::fs::write(overlay.join(".justfile"), "# overlay justfile").unwrap(); + std::fs::write(overlay.join("config.toml"), "# overlay config").unwrap(); + + let mounts = collect_overlay_mounts(dir.path(), "/workspace/test"); + assert_eq!(mounts.len(), 2); + assert!(mounts.iter().any(|m| m.contains(".justfile") && m.contains("/workspace/test/.justfile:ro"))); + assert!(mounts.iter().any(|m| m.contains("config.toml") && m.contains("/workspace/test/config.toml:ro"))); + } + + #[test] + fn collect_overlay_local_after_project() { + let dir = tempfile::tempdir().expect("create tempdir"); + let overlay = dir.path().join(".sandcage").join("overlay"); + let local_overlay = dir.path().join(".sandcage").join("local").join("overlay"); + std::fs::create_dir_all(&overlay).unwrap(); + std::fs::create_dir_all(&local_overlay).unwrap(); + std::fs::write(overlay.join("file.txt"), "project").unwrap(); + std::fs::write(local_overlay.join("file.txt"), "local").unwrap(); + + let mounts = collect_overlay_mounts(dir.path(), "/workspace/test"); + assert_eq!(mounts.len(), 2, "both project and local overlays"); + // Local must come after project (Docker uses last mount) + let project_pos = mounts.iter().position(|m| m.contains("overlay") && !m.contains("local")).unwrap(); + let local_pos = mounts.iter().position(|m| m.contains("local")).unwrap(); + assert!(local_pos > project_pos, "local overlay must come after project overlay"); + } + + #[test] + fn collect_overlay_empty_when_no_dir() { + let dir = tempfile::tempdir().expect("create tempdir"); + let mounts = collect_overlay_mounts(dir.path(), "/workspace/test"); + assert!(mounts.is_empty()); + } + + #[test] + fn overlay_skipped_when_disabled() { + let dir = tempfile::tempdir().expect("create tempdir"); + let overlay = dir.path().join(".sandcage").join("overlay"); + std::fs::create_dir_all(&overlay).unwrap(); + std::fs::write(overlay.join("file.txt"), "test").unwrap(); + + let config = SandcageConfig { + overlay: Some(crate::config::OverlayConfig { enabled: Some(false) }), + ..Default::default() + }; + let args = build_run_args("claude", "/tmp/compose.yml", &config, dir.path(), "/workspace/test", false, &[], None, &[]); + // Should not contain the overlay file mount + assert!(!args.iter().any(|a| a.contains("file.txt"))); + } + + #[test] + fn collect_shadow_mounts_uses_recipes_and_config() { + let mut config = SandcageConfig::default(); + let mut tc = std::collections::HashMap::new(); + tc.insert("rust".to_string(), toml::Value::Boolean(true)); // built-in: target + .cargo/registry + config.toolchains = Some(tc); + + let mounts = collect_shadow_mounts(&config, std::path::Path::new("/proj"), "/workspace/proj"); + // rust recipe contributes target (per-project) and .cargo/registry (shared) + assert!(mounts.iter().any(|m| m.dir == "target")); + assert!(mounts.iter().any(|m| m.dir == ".cargo/registry")); + } + + #[test] + fn spoof_bin_dir_is_under_local() { + let root = std::path::Path::new("/home/u/projects/app"); + assert_eq!( + spoof_bin_dir(root), + std::path::Path::new("/home/u/projects/app/.sandcage/local/spoof/bin") + ); + } + + #[test] + fn collect_spoof_mount_present_only_when_dir_exists() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + assert!(collect_spoof_mount(root).is_none()); + std::fs::create_dir_all(spoof_bin_dir(root)).unwrap(); + let mount = collect_spoof_mount(root).expect("mount present"); + assert!(mount.ends_with(":/usr/local/sandcage/spoof:ro"), "got {mount}"); + assert!(mount.starts_with(&spoof_bin_dir(root).to_string_lossy().to_string())); + } + + #[test] + fn project_volume_prefix_matches_naming() { + use crate::shadow::{project_slug, project_hash, volume_name}; + let root = std::path::Path::new("/home/u/projects/app"); + let prefix = project_volume_prefix(root); + assert_eq!(prefix, format!("sandcage-{}-{}-", project_slug(root), project_hash(root))); + assert!(volume_name(root, "target", false).starts_with(&prefix)); + assert!(!volume_name(root, "target", true).starts_with(&prefix)); // shared excluded + } + + #[test] + fn resolve_security_opts_empty_when_no_profiles() { + let config = SandcageConfig::default(); + let opts = resolve_security_opts(&config, Path::new("/proj")); + assert!(opts.is_empty()); + } + + #[test] + fn resolve_security_opts_includes_seccomp_path() { + let dir = tempfile::tempdir().unwrap(); + let profiles_dir = dir.path().join(".sandcage").join("profiles"); + std::fs::create_dir_all(&profiles_dir).unwrap(); + let seccomp = profiles_dir.join("seccomp.json"); + std::fs::write(&seccomp, r#"{"defaultAction":"SCMP_ACT_ERRNO"}"#).unwrap(); + + let config = SandcageConfig { + profiles: Some(crate::config::ProfilesConfig { + apparmor: None, + seccomp: Some(".sandcage/profiles/seccomp.json".to_string()), + }), + ..Default::default() + }; + let opts = resolve_security_opts(&config, dir.path()); + assert_eq!(opts.len(), 1); + assert!(opts[0].starts_with("seccomp="), "got: {}", opts[0]); + } + + #[test] + fn resolve_security_opts_warns_on_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let config = SandcageConfig { + profiles: Some(crate::config::ProfilesConfig { + apparmor: None, + seccomp: Some(".sandcage/profiles/nonexistent.json".to_string()), + }), + ..Default::default() + }; + let opts = resolve_security_opts(&config, dir.path()); + assert!(opts.is_empty(), "missing file should be skipped"); + } + + // ----------------------------------------------------------------------- + // Home profile tests + // ----------------------------------------------------------------------- + + #[test] + fn resolve_agent_home_defaults_to_default_profile() { + let tmp = tempfile::tempdir().unwrap(); + let home = resolve_agent_home(tmp.path(), None); + assert_eq!(home, tmp.path().join("homes").join("default")); + assert!(home.is_dir(), "default profile dir should be created"); + } + + #[test] + fn resolve_agent_home_named_profile() { + let tmp = tempfile::tempdir().unwrap(); + let home = resolve_agent_home(tmp.path(), Some("work")); + assert_eq!(home, tmp.path().join("homes").join("work")); + assert!(home.is_dir(), "named profile dir should be created"); + } + + #[test] + fn resolve_agent_home_migrates_legacy() { + let tmp = tempfile::tempdir().unwrap(); + let legacy = tmp.path().join("home"); + std::fs::create_dir_all(legacy.join(".claude")).unwrap(); + std::fs::write(legacy.join(".claude").join("creds.json"), "secret").unwrap(); + + let home = resolve_agent_home(tmp.path(), None); + assert_eq!(home, tmp.path().join("homes").join("default")); + assert!(!tmp.path().join("home").exists(), "legacy dir should be moved"); + assert!( + home.join(".claude").join("creds.json").exists(), + "migrated contents should be preserved" + ); + } + + #[test] + fn resolve_agent_home_no_migration_when_homes_exists() { + let tmp = tempfile::tempdir().unwrap(); + let legacy = tmp.path().join("home"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("marker"), "legacy").unwrap(); + + let homes = tmp.path().join("homes").join("default"); + std::fs::create_dir_all(&homes).unwrap(); + std::fs::write(homes.join("marker"), "new").unwrap(); + + let home = resolve_agent_home(tmp.path(), None); + assert_eq!(home, tmp.path().join("homes").join("default")); + assert!(tmp.path().join("home").exists(), "legacy should be left alone"); + let content = std::fs::read_to_string(home.join("marker")).unwrap(); + assert_eq!(content, "new", "new homes dir should not be overwritten"); + } +} diff --git a/crates/sandcage/src/forwarding.rs b/crates/sandcage/src/forwarding.rs new file mode 100644 index 0000000..1429a52 --- /dev/null +++ b/crates/sandcage/src/forwarding.rs @@ -0,0 +1,831 @@ +use std::path::{Path, PathBuf}; + +use crate::config::SandcageConfig; +use crate::spoof::ToolMode; + +pub const CONTAINER_SOCKET_PATH: &str = "/run/sandcage/cmd.sock"; + +pub fn session_dir(sandcage_home: &Path, session_id: &str) -> PathBuf { + sandcage_home.join("run").join(session_id) +} + +pub fn socket_path(sandcage_home: &Path, session_id: &str) -> PathBuf { + session_dir(sandcage_home, session_id).join("cmd.sock") +} + +pub fn audit_log_path(sandcage_home: &Path, session_id: &str) -> PathBuf { + session_dir(sandcage_home, session_id).join("audit.jsonl") +} + +pub fn generate_session_id() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); + let secs = ts.as_secs(); + let nanos = ts.subsec_nanos(); + format!("{secs}-{nanos:09}") +} + +pub fn create_session_dir(sandcage_home: &Path, session_id: &str) -> std::io::Result { + let dir = session_dir(sandcage_home, session_id); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + +pub fn cleanup_session_dir(sandcage_home: &Path, session_id: &str) { + let dir = session_dir(sandcage_home, session_id); + if dir.exists() { + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[derive(Debug, Clone)] +pub struct PathMapping { + pub container_prefix: String, + pub host_prefix: String, +} + +impl PathMapping { + pub fn to_host(&self, container_path: &str) -> String { + if let Some(rest) = container_path.strip_prefix(&self.container_prefix) { + format!("{}{rest}", self.host_prefix) + } else { + container_path.to_string() + } + } + + pub fn to_container(&self, host_path: &str) -> String { + if let Some(rest) = host_path.strip_prefix(&self.host_prefix) { + format!("{}{rest}", self.container_prefix) + } else { + host_path.to_string() + } + } + + pub fn translate_args_to_host(&self, args: &[String]) -> Vec { + args.iter() + .map(|arg| arg.replace(&self.container_prefix, &self.host_prefix)) + .collect() + } + + pub fn translate_output_to_container(&self, text: &str) -> String { + text.replace(&self.host_prefix, &self.container_prefix) + } +} + +use serde::Serialize; + +#[cfg(unix)] +use serde::Deserialize; + +#[derive(Debug, Serialize)] +pub struct AuditEntry { + pub timestamp: String, + pub kind: String, + pub name: String, + pub args: Vec, + pub cwd: String, + pub exit_code: i32, +} + +pub fn append_audit(log_path: &Path, entry: &AuditEntry) -> std::io::Result<()> { + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log_path)?; + let json = serde_json::to_string(entry) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + writeln!(file, "{json}") +} + +pub fn now_iso8601() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); + let secs = d.as_secs(); + let time_secs = secs % 86400; + let hours = time_secs / 3600; + let minutes = (time_secs % 3600) / 60; + let seconds = time_secs % 60; + let (year, month, day) = epoch_days_to_ymd(secs / 86400); + format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z") +} + +fn epoch_days_to_ymd(mut days: u64) -> (u64, u64, u64) { + days += 719468; + let era = days / 146097; + let doe = days - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +pub fn seccomp_uses_notify(profile_path: &Path) -> bool { + std::fs::read_to_string(profile_path) + .map(|content| content.contains("SCMP_ACT_NOTIFY")) + .unwrap_or(false) +} + +pub fn validate_request( + kind: &str, + name: &str, + config: &SandcageConfig, + is_trusted: bool, +) -> Result<(), String> { + if !is_trusted { + return Err("rejected: project is not in trusted_projects — host forwarding disabled".to_string()); + } + + match kind { + "tool" => { + let tools = config.tools.as_ref().ok_or_else(|| { + format!("rejected: tool '{name}' not configured (no [tools] section)") + })?; + let tool_cfg = tools.get(name).ok_or_else(|| { + format!("rejected: tool '{name}' not configured in [tools]") + })?; + let mode = tool_cfg + .mode + .as_deref() + .map(ToolMode::parse) + .unwrap_or(ToolMode::InCage); + if mode != ToolMode::Host { + return Err(format!( + "rejected: tool '{name}' is mode={}, not host", + tool_cfg.mode.as_deref().unwrap_or("in-cage") + )); + } + Ok(()) + } + "task" => { + let tasks = config.tasks.as_ref().ok_or_else(|| { + format!("rejected: task '{name}' not configured (no [tasks] section)") + })?; + let task_cfg = tasks.get(name).ok_or_else(|| { + format!("rejected: task '{name}' not configured in [tasks]") + })?; + let location = task_cfg.location.as_deref().unwrap_or("in-cage"); + if location != "host" { + return Err(format!( + "rejected: task '{name}' is where={location}, not host — \ + it runs in-cage and should not be forwarded" + )); + } + Ok(()) + } + other => Err(format!("rejected: unknown request kind '{other}'")), + } +} + +// --------------------------------------------------------------------------- +// Daemon loop (Unix only) +// --------------------------------------------------------------------------- + +#[cfg(unix)] +#[derive(Debug, Deserialize)] +struct IncomingRequest { + kind: String, + name: String, + #[serde(default)] + args: Vec, + cwd: String, +} + +#[cfg(unix)] +pub async fn serve_one( + socket_path: &Path, + audit_log: &Path, + mapping: &PathMapping, + config: &SandcageConfig, + is_trusted: bool, +) -> Result<(), Box> { + use tokio::net::UnixListener; + + if socket_path.exists() { + std::fs::remove_file(socket_path)?; + } + + let listener = UnixListener::bind(socket_path)?; + + // Set socket permissions to 0o600 + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(socket_path, perms)?; + } + + let (stream, _addr) = listener.accept().await?; + handle_connection(stream, audit_log, mapping, config, is_trusted).await +} + +#[cfg(unix)] +pub async fn serve_loop( + socket_path: &Path, + audit_log: &Path, + mapping: &PathMapping, + config: &SandcageConfig, + is_trusted: bool, +) -> Result<(), Box> { + use tokio::net::UnixListener; + + if socket_path.exists() { + std::fs::remove_file(socket_path)?; + } + + let listener = UnixListener::bind(socket_path)?; + + // Set socket permissions to 0o600 + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(socket_path, perms)?; + } + + loop { + let (stream, _addr) = listener.accept().await?; + let audit_log = audit_log.to_path_buf(); + let mapping = mapping.clone(); + let config = config.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, &audit_log, &mapping, &config, is_trusted).await { + eprintln!("sandcage forwarding: connection error: {e}"); + } + }); + } +} + +#[cfg(unix)] +async fn handle_connection( + stream: tokio::net::UnixStream, + audit_log: &Path, + mapping: &PathMapping, + config: &SandcageConfig, + is_trusted: bool, +) -> Result<(), Box> { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let (reader, mut writer) = tokio::io::split(stream); + let mut buf_reader = BufReader::new(reader); + + let mut line = String::new(); + buf_reader.read_line(&mut line).await?; + let line = line.trim(); + + let req: IncomingRequest = serde_json::from_str(line)?; + + // Validate + if let Err(msg) = validate_request(&req.kind, &req.name, config, is_trusted) { + let err_frame = serde_json::to_string(&serde_json::json!({"stream": "stderr", "data": format!("{msg}\n")}))?; + writer.write_all(err_frame.as_bytes()).await?; + writer.write_all(b"\n").await?; + let exit_frame = serde_json::to_string(&serde_json::json!({"exit": 1}))?; + writer.write_all(exit_frame.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + + let entry = AuditEntry { + timestamp: now_iso8601(), + kind: req.kind, + name: req.name, + args: req.args, + cwd: req.cwd, + exit_code: 1, + }; + let _ = append_audit(audit_log, &entry); + return Ok(()); + } + + // Translate paths + let host_args = mapping.translate_args_to_host(&req.args); + let host_cwd = mapping.to_host(&req.cwd); + + // Execute + let exit_code = execute_forwarded(&req.name, &host_args, &host_cwd, mapping, &mut writer).await?; + + // Exit frame + let exit_frame = serde_json::to_string(&serde_json::json!({"exit": exit_code}))?; + writer.write_all(exit_frame.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + + // Audit + let entry = AuditEntry { + timestamp: now_iso8601(), + kind: req.kind, + name: req.name, + args: req.args, + cwd: req.cwd, + exit_code, + }; + let _ = append_audit(audit_log, &entry); + + Ok(()) +} + +#[cfg(unix)] +async fn execute_forwarded( + name: &str, + args: &[String], + cwd: &str, + mapping: &PathMapping, + writer: &mut W, +) -> Result> { + use tokio::io::AsyncWriteExt; + use tokio::io::{AsyncBufReadExt, BufReader}; + use tokio::process::Command; + use tokio::sync::mpsc; + + let mut child = Command::new(name) + .args(args) + .current_dir(cwd) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + + let (tx, mut rx) = mpsc::channel::<(String, String)>(64); + + let tx_out = tx.clone(); + let stdout_task = tokio::spawn(async move { + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, + Ok(_) => { + let _ = tx_out.send(("stdout".to_string(), line.clone())).await; + } + Err(_) => break, + } + } + }); + + let tx_err = tx; + let stderr_task = tokio::spawn(async move { + let mut reader = BufReader::new(stderr); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, + Ok(_) => { + let _ = tx_err.send(("stderr".to_string(), line.clone())).await; + } + Err(_) => break, + } + } + }); + + // Stream frames as they arrive + // We need to drop the tasks' senders first by awaiting them, but we also + // need to read from rx concurrently. Use a select-style approach. + let mapping_clone = mapping.clone(); + let stream_task = tokio::spawn(async move { + // Collect frames — we cannot write to `writer` from a spawned task + // because it borrows the caller's writer. Instead, collect and return. + let mut frames = Vec::new(); + while let Some((stream_name, data)) = rx.recv().await { + let translated = mapping_clone.translate_output_to_container(&data); + frames.push((stream_name, translated)); + } + frames + }); + + stdout_task.await?; + stderr_task.await?; + + let frames = stream_task.await?; + for (stream_name, data) in frames { + let frame = serde_json::to_string(&serde_json::json!({"stream": stream_name, "data": data}))?; + writer.write_all(frame.as_bytes()).await?; + writer.write_all(b"\n").await?; + } + + let status = child.wait().await?; + Ok(status.code().unwrap_or(1)) +} + +pub fn resolve_task_command( + task_name: &str, + config: &SandcageConfig, +) -> Result<(String, Vec), String> { + resolve_task_command_with_args(task_name, &[] as &[String], config) +} + +pub fn resolve_task_command_with_args>( + task_name: &str, + extra_args: &[S], + config: &SandcageConfig, +) -> Result<(String, Vec), String> { + let tasks = config.tasks.as_ref().ok_or("no [tasks] section")?; + let task_cfg = tasks + .get(task_name) + .ok_or_else(|| format!("task '{task_name}' not found"))?; + let run_cmd = task_cfg + .run + .as_deref() + .ok_or_else(|| format!("task '{task_name}' has no `run` command"))?; + + let mut parts: Vec = run_cmd.split_whitespace().map(String::from).collect(); + if parts.is_empty() { + return Err(format!("task '{task_name}' has an empty `run` command")); + } + + let cmd = parts.remove(0); + parts.extend(extra_args.iter().map(|a| a.as_ref().to_string())); + Ok((cmd, parts)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{SandcageConfig, TaskConfig, ToolConfig}; + use std::collections::HashMap; + + fn config_with_host_tool(name: &str) -> SandcageConfig { + let mut tools = HashMap::new(); + tools.insert( + name.to_string(), + ToolConfig { + mode: Some("host".to_string()), + message: None, + allow: vec![], + deny: vec![], + }, + ); + SandcageConfig { + tools: Some(tools), + ..Default::default() + } + } + + #[test] + fn validate_allows_configured_host_tool() { + let config = config_with_host_tool("docker"); + let result = validate_request("tool", "docker", &config, true); + assert!(result.is_ok()); + } + + #[test] + fn validate_rejects_unconfigured_tool() { + let config = config_with_host_tool("docker"); + let result = validate_request("tool", "git", &config, true); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not configured")); + } + + #[test] + fn validate_rejects_when_untrusted() { + let config = config_with_host_tool("docker"); + let result = validate_request("tool", "docker", &config, false); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("trusted")); + } + + #[test] + fn validate_rejects_non_host_mode_tool() { + let mut tools = HashMap::new(); + tools.insert( + "cargo".to_string(), + ToolConfig { + mode: Some("spoof".to_string()), + message: Some("use just build".to_string()), + allow: vec![], + deny: vec![], + }, + ); + let config = SandcageConfig { + tools: Some(tools), + ..Default::default() + }; + let result = validate_request("tool", "cargo", &config, true); + assert!(result.is_err()); + } + + // Session directory tests + #[test] + fn session_dir_is_under_sandcage_run() { + let home = PathBuf::from("/home/user/.sandcage"); + let dir = session_dir(&home, "abc123"); + assert_eq!(dir, PathBuf::from("/home/user/.sandcage/run/abc123")); + } + + #[test] + fn session_id_is_unique() { + let a = generate_session_id(); + let b = generate_session_id(); + assert_ne!(a, b); + assert!(a.len() > 10); + } + + #[test] + fn socket_path_is_cmd_sock() { + let home = PathBuf::from("/home/user/.sandcage"); + assert_eq!( + socket_path(&home, "abc123"), + PathBuf::from("/home/user/.sandcage/run/abc123/cmd.sock") + ); + } + + #[test] + fn audit_log_path_is_in_session_dir() { + let home = PathBuf::from("/home/user/.sandcage"); + assert_eq!( + audit_log_path(&home, "abc123"), + PathBuf::from("/home/user/.sandcage/run/abc123/audit.jsonl") + ); + } + + // Path translation tests + #[test] + fn translate_container_to_host() { + let m = PathMapping { + container_prefix: "/workspace/app".to_string(), + host_prefix: "/home/user/projects/app".to_string(), + }; + assert_eq!(m.to_host("/workspace/app/src/main.rs"), "/home/user/projects/app/src/main.rs"); + } + + #[test] + fn translate_host_to_container() { + let m = PathMapping { + container_prefix: "/workspace/app".to_string(), + host_prefix: "/home/user/projects/app".to_string(), + }; + assert_eq!(m.to_container("/home/user/projects/app/Cargo.toml"), "/workspace/app/Cargo.toml"); + } + + #[test] + fn no_translation_when_prefix_absent() { + let m = PathMapping { + container_prefix: "/workspace/app".to_string(), + host_prefix: "/home/user/projects/app".to_string(), + }; + assert_eq!(m.to_host("/usr/local/bin/cargo"), "/usr/local/bin/cargo"); + assert_eq!(m.to_container("/etc/passwd"), "/etc/passwd"); + } + + #[test] + fn translate_args_replaces_all_occurrences() { + let m = PathMapping { + container_prefix: "/workspace/app".to_string(), + host_prefix: "/home/user/projects/app".to_string(), + }; + let args = vec![ + "test".to_string(), + "--manifest-path=/workspace/app/Cargo.toml".to_string(), + "/workspace/app/src/lib.rs".to_string(), + ]; + let translated = m.translate_args_to_host(&args); + assert_eq!(translated[0], "test"); + assert_eq!(translated[1], "--manifest-path=/home/user/projects/app/Cargo.toml"); + assert_eq!(translated[2], "/home/user/projects/app/src/lib.rs"); + } + + #[test] + fn translate_output_text_replaces_host_paths() { + let m = PathMapping { + container_prefix: "/workspace/app".to_string(), + host_prefix: "/home/user/projects/app".to_string(), + }; + let output = "error[E0308]: /home/user/projects/app/src/main.rs:42:5\n"; + assert_eq!( + m.translate_output_to_container(output), + "error[E0308]: /workspace/app/src/main.rs:42:5\n" + ); + } + + // Audit logging tests + #[test] + fn audit_entry_serializes() { + let entry = AuditEntry { + timestamp: "2026-06-02T14:00:00Z".to_string(), + kind: "task".to_string(), + name: "build".to_string(), + args: vec!["--release".to_string()], + cwd: "/home/user/projects/app".to_string(), + exit_code: 0, + }; + let json = serde_json::to_string(&entry).unwrap(); + assert!(json.contains("\"name\":\"build\"")); + assert!(json.contains("\"exit_code\":0")); + } + + #[test] + fn append_audit_creates_file_and_appends() { + let dir = tempfile::tempdir().unwrap(); + let log_path = dir.path().join("audit.jsonl"); + + let entry1 = AuditEntry { + timestamp: "2026-06-02T14:00:00Z".to_string(), + kind: "task".to_string(), + name: "build".to_string(), + args: vec![], + cwd: "/workspace".to_string(), + exit_code: 0, + }; + append_audit(&log_path, &entry1).unwrap(); + + let entry2 = AuditEntry { + timestamp: "2026-06-02T14:00:01Z".to_string(), + kind: "tool".to_string(), + name: "docker".to_string(), + args: vec!["ps".to_string()], + cwd: "/workspace".to_string(), + exit_code: 0, + }; + append_audit(&log_path, &entry2).unwrap(); + + let content = std::fs::read_to_string(&log_path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 2); + assert!(lines[0].contains("build")); + assert!(lines[1].contains("docker")); + } + + #[cfg(unix)] + #[tokio::test] + async fn daemon_executes_echo_and_streams_output() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader as TokioBufReader}; + use tokio::net::UnixStream as TokioUnixStream; + + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("cmd.sock"); + let audit = dir.path().join("audit.jsonl"); + + let mapping = PathMapping { + container_prefix: "/workspace".to_string(), + host_prefix: dir.path().to_string_lossy().to_string(), + }; + let config = SandcageConfig::default(); + + let daemon_sock = sock.clone(); + let daemon_audit = audit.clone(); + let daemon_mapping = mapping.clone(); + let handle = tokio::spawn(async move { + serve_one(&daemon_sock, &daemon_audit, &daemon_mapping, &config, true).await + }); + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let mut stream = TokioUnixStream::connect(&sock).await.unwrap(); + let request = r#"{"kind":"task","name":"echo","args":["hello-from-daemon"],"cwd":"/workspace"}"#; + stream.write_all(request.as_bytes()).await.unwrap(); + stream.write_all(b"\n").await.unwrap(); + stream.shutdown().await.unwrap(); + + let reader = TokioBufReader::new(&mut stream); + let mut lines = reader.lines(); + let mut got_exit = false; + let mut output_data = String::new(); + while let Some(line) = lines.next_line().await.unwrap() { + if line.contains("\"exit\"") { + let v: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["exit"].as_i64().unwrap(), 0); + got_exit = true; + break; + } + if line.contains("\"data\"") { + let v: serde_json::Value = serde_json::from_str(&line).unwrap(); + output_data.push_str(v["data"].as_str().unwrap_or("")); + } + } + assert!(got_exit, "should receive exit frame"); + assert!(output_data.contains("hello-from-daemon"), "output should contain 'hello-from-daemon', got: '{output_data}'"); + + handle.await.unwrap().unwrap(); + + let audit_content = std::fs::read_to_string(&audit).unwrap(); + assert!(audit_content.contains("echo")); + } + + #[cfg(unix)] + #[tokio::test] + async fn daemon_rejects_untrusted_request() { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader as TokioBufReader}; + use tokio::net::UnixStream as TokioUnixStream; + + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("cmd.sock"); + let audit = dir.path().join("audit.jsonl"); + + let mapping = PathMapping { + container_prefix: "/workspace".to_string(), + host_prefix: dir.path().to_string_lossy().to_string(), + }; + let config = SandcageConfig::default(); + + let daemon_sock = sock.clone(); + let daemon_audit = audit.clone(); + let daemon_mapping = mapping.clone(); + let handle = tokio::spawn(async move { + serve_one(&daemon_sock, &daemon_audit, &daemon_mapping, &config, false).await + }); + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + let mut stream = TokioUnixStream::connect(&sock).await.unwrap(); + let request = r#"{"kind":"task","name":"echo","args":["hello"],"cwd":"/workspace"}"#; + stream.write_all(request.as_bytes()).await.unwrap(); + stream.write_all(b"\n").await.unwrap(); + stream.shutdown().await.unwrap(); + + let reader = TokioBufReader::new(&mut stream); + let mut lines = reader.lines(); + let mut got_rejection = false; + let mut got_exit = false; + while let Some(line) = lines.next_line().await.unwrap() { + if line.contains("trusted") { + got_rejection = true; + } + if line.contains("\"exit\"") { + let v: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["exit"].as_i64().unwrap(), 1, "rejected requests should exit 1"); + got_exit = true; + break; + } + } + assert!(got_rejection, "should mention trust rejection"); + assert!(got_exit, "should send exit frame"); + + handle.await.unwrap().unwrap(); + } + + fn config_with_task(name: &str, location: &str, run_cmd: &str) -> SandcageConfig { + let mut tasks = HashMap::new(); + tasks.insert( + name.to_string(), + TaskConfig { + location: Some(location.to_string()), + run: Some(run_cmd.to_string()), + }, + ); + SandcageConfig { + tasks: Some(tasks), + ..Default::default() + } + } + + #[test] + fn validate_allows_configured_host_task() { + let config = config_with_task("build", "host", "cargo build --release"); + let result = validate_request("task", "build", &config, true); + assert!(result.is_ok()); + } + + #[test] + fn validate_rejects_unconfigured_task() { + let config = config_with_task("build", "host", "cargo build"); + let result = validate_request("task", "deploy", &config, true); + assert!(result.is_err()); + } + + #[test] + fn validate_rejects_in_cage_task_via_forwarding() { + let config = config_with_task("lint", "in-cage", "clippy"); + let result = validate_request("task", "lint", &config, true); + assert!(result.is_err()); + } + + #[test] + fn resolve_task_command_returns_run_field() { + let config = config_with_task("build", "host", "cargo build --release"); + let (cmd, args) = resolve_task_command("build", &config).unwrap(); + assert_eq!(cmd, "cargo"); + assert_eq!(args, vec!["build", "--release"]); + } + + #[test] + fn resolve_task_command_appends_extra_args() { + let config = config_with_task("test", "host", "cargo test"); + let (cmd, args) = resolve_task_command_with_args("test", &["--lib"], &config).unwrap(); + assert_eq!(cmd, "cargo"); + assert_eq!(args, vec!["test", "--lib"]); + } + + #[test] + fn detects_scmp_act_notify_in_seccomp_profile() { + let dir = tempfile::tempdir().unwrap(); + + let with_notify = dir.path().join("notify.json"); + std::fs::write( + &with_notify, + r#"{"defaultAction":"SCMP_ACT_ERRNO","syscalls":[{"names":["execve"],"action":"SCMP_ACT_NOTIFY"}]}"#, + ).unwrap(); + assert!(seccomp_uses_notify(&with_notify)); + + let without = dir.path().join("basic.json"); + std::fs::write( + &without, + r#"{"defaultAction":"SCMP_ACT_ERRNO","syscalls":[]}"#, + ).unwrap(); + assert!(!seccomp_uses_notify(&without)); + } +} diff --git a/crates/sandcage/src/init.rs b/crates/sandcage/src/init.rs new file mode 100644 index 0000000..32f9bbd --- /dev/null +++ b/crates/sandcage/src/init.rs @@ -0,0 +1,493 @@ +use std::path::{Path, PathBuf}; + +use miette::Diagnostic; +use thiserror::Error; + +const CLAUDE_SETTINGS_TEMPLATE: &str = + include_str!("../../../templates/claude/settings.json"); + +#[derive(Debug, Error, Diagnostic)] +pub enum InitError { + #[error("Failed to create directory {0}: {1}")] + #[diagnostic(code(sandcage::init::create_dir_failed))] + CreateDirFailed(PathBuf, #[source] std::io::Error), + + #[error("Failed to write file {0}: {1}")] + #[diagnostic(code(sandcage::init::write_file_failed))] + WriteFileFailed(PathBuf, #[source] std::io::Error), + + #[error("Cannot determine home directory")] + #[diagnostic(code(sandcage::init::no_home_dir))] + NoHomeDir, + + #[error(".sandcage/config.toml already exists at {0}; remove it manually to re-initialise")] + #[diagnostic(code(sandcage::init::config_already_exists))] + ConfigAlreadyExists(PathBuf), + + #[error("You're in a git worktree. Run `sandcage init` from the main worktree at {0} instead.")] + #[diagnostic(code(sandcage::init::in_worktree))] + InWorktree(PathBuf), +} + +pub type Result = std::result::Result; + +pub fn preseed() -> Result<()> { + let home = dirs::home_dir().ok_or(InitError::NoHomeDir)?; + preseed_with_home(&home) +} + +pub fn preseed_with_home(home: &Path) -> Result<()> { + let sandcage_home = home.join(".sandcage"); + let agent_home = sandcage_home.join("homes").join("default"); + + // 1. ~/.sandcage/homes/default/ — persistent agent home directory + create_dir_all(&agent_home)?; + + // 2. ~/.sandcage/home/.claude/ + let claude_dir = agent_home.join(".claude"); + create_dir_all(&claude_dir)?; + + // 3. ~/.sandcage/home/.codex/ + let codex_dir = agent_home.join(".codex"); + create_dir_all(&codex_dir)?; + + // ~/.sandcage/home/.gemini/ + let gemini_dir = agent_home.join(".gemini"); + create_dir_all(&gemini_dir)?; + + // 4. Seed Claude settings.json from the bundled template + let settings_dest = claude_dir.join("settings.json"); + if !settings_dest.exists() { + std::fs::write(&settings_dest, CLAUDE_SETTINGS_TEMPLATE) + .map_err(|e| InitError::WriteFileFailed(settings_dest.clone(), e))?; + println!("sandcage: seeded {} from template", settings_dest.display()); + } + + // 5. Seed .claude.json so the bind mount targets a file, not a directory + let claude_json = agent_home.join(".claude.json"); + if !claude_json.exists() { + std::fs::write(&claude_json, "{}\n") + .map_err(|e| InitError::WriteFileFailed(claude_json.clone(), e))?; + } + + // 6. Create an empty .justfile if absent + let justfile = agent_home.join(".justfile"); + if !justfile.exists() { + std::fs::write(&justfile, "") + .map_err(|e| InitError::WriteFileFailed(justfile.clone(), e))?; + } + + Ok(()) +} + +fn create_dir_all(path: &Path) -> Result<()> { + std::fs::create_dir_all(path) + .map_err(|e| InitError::CreateDirFailed(path.to_path_buf(), e)) +} + +// --------------------------------------------------------------------------- +// Project scaffolding — `sandcage init` +// --------------------------------------------------------------------------- + +/// Language ecosystems we can detect from marker files. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Ecosystem { + Rust, + Node, + Python, + Go, + Generic, +} + +impl Ecosystem { + fn as_str(self) -> &'static str { + match self { + Ecosystem::Rust => "rust", + Ecosystem::Node => "node", + Ecosystem::Python => "python", + Ecosystem::Go => "go", + Ecosystem::Generic => "generic", + } + } +} + +/// Detect the primary language ecosystem from files present in `dir`. +pub fn detect_ecosystem(dir: &Path) -> Ecosystem { + if dir.join("Cargo.toml").exists() { + Ecosystem::Rust + } else if dir.join("package.json").exists() { + Ecosystem::Node + } else if dir.join("pyproject.toml").exists() || dir.join("requirements.txt").exists() { + Ecosystem::Python + } else if dir.join("go.mod").exists() { + Ecosystem::Go + } else { + Ecosystem::Generic + } +} + +/// Build the TOML content for a `config.toml` config file. +fn build_toml(ecosystem: Ecosystem) -> String { + let toolchain_and_packages = match ecosystem { + Ecosystem::Rust => { + "[toolchains]\nrust = true\n\npackages = [\"ripgrep\", \"fd-find\"]\n" + } + Ecosystem::Node => { + "[toolchains]\nnvm = true\n\npackages = [\"git\", \"curl\"]\n" + } + Ecosystem::Python => { + "[toolchains]\nuv = true\n\npackages = [\"git\", \"curl\"]\n" + } + Ecosystem::Go => { + "# [toolchains]\n# go = true # TBD\n\npackages = [\"git\", \"curl\"]\n" + } + Ecosystem::Generic => { + "packages = [\"git\", \"curl\"]\n" + } + }; + + format!( + "# Sandcage project configuration\n\ + # Docs: https://github.com/user/sandcage\n\ + \n\ + # Detected ecosystem: {ecosystem}\n\ + \n\ + # [env]\n\ + # EXAMPLE_VAR = \"value\"\n\ + \n\ + {toolchain_and_packages}\ + \n\ + # mounts = [\n\ + # \"~/.ssh:/home/agent/.ssh:ro\",\n\ + # \"~/.gitconfig:/home/agent/.gitconfig:ro\",\n\ + # ]\n\ + \n\ + # [agent_args]\n\ + # claude = [\"--dangerously-skip-permissions\"]\n\ + \n\ + # shell = \"zsh\"\n\ + \n\ + # container_workspace = \"/workspace/my-project\"\n", + ecosystem = ecosystem.as_str(), + ) +} + +/// Check if `path` is a linked git worktree (not the main worktree). +/// Returns the main worktree root if so. +fn detect_linked_worktree(path: &Path) -> Option { + let common = std::process::Command::new("git") + .args(["rev-parse", "--git-common-dir"]) + .current_dir(path) + .output() + .ok()?; + if !common.status.success() { + return None; + } + let git_dir = std::process::Command::new("git") + .args(["rev-parse", "--git-dir"]) + .current_dir(path) + .output() + .ok()?; + if !git_dir.status.success() { + return None; + } + let common_path = PathBuf::from(String::from_utf8_lossy(&common.stdout).trim()); + let git_dir_path = PathBuf::from(String::from_utf8_lossy(&git_dir.stdout).trim()); + let common_abs = path.join(&common_path).canonicalize().ok()?; + let git_dir_abs = path.join(&git_dir_path).canonicalize().ok()?; + if common_abs == git_dir_abs { + return None; + } + common_abs.parent().map(|p| p.to_path_buf()) +} + +/// Scaffold a `.sandcage/` directory in `path` with starter configuration +/// based on the detected language ecosystem. Returns an error if the +/// config file already exists. +pub fn scaffold_project(path: &Path) -> Result<()> { + if let Some(main_root) = detect_linked_worktree(path) { + return Err(InitError::InWorktree(main_root)); + } + + let sandcage_dir = path.join(".sandcage"); + let config_path = sandcage_dir.join("config.toml"); + + if config_path.exists() { + return Err(InitError::ConfigAlreadyExists(config_path)); + } + + let ecosystem = detect_ecosystem(path); + let content = build_toml(ecosystem); + + // Create directory structure + create_dir_all(&sandcage_dir)?; + create_dir_all(&sandcage_dir.join("overlay"))?; + let local_dir = sandcage_dir.join("local"); + create_dir_all(&local_dir)?; + create_dir_all(&local_dir.join("overlay"))?; + + // Write config file + std::fs::write(&config_path, &content) + .map_err(|e| InitError::WriteFileFailed(config_path.clone(), e))?; + + // Write .sandcage/.gitignore to exclude local/ + let inner_gitignore = sandcage_dir.join(".gitignore"); + std::fs::write(&inner_gitignore, "local/\nshadow/\n") + .map_err(|e| InitError::WriteFileFailed(inner_gitignore, e))?; + + println!( + "sandcage: initialised {} (detected ecosystem: {})", + config_path.display(), + ecosystem.as_str(), + ); + println!(); + print!("{content}"); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn run(home: &Path) -> Result<()> { + preseed_with_home(home) + } + + #[test] + fn creates_home_dir() { + let home = TempDir::new().expect("create tempdir"); + run(home.path()).expect("preseed"); + assert!( + home.path().join(".sandcage/homes/default").is_dir(), + "home dir should exist" + ); + } + + #[test] + fn creates_agent_config_dirs() { + let home = TempDir::new().expect("create tempdir"); + run(home.path()).expect("preseed"); + + assert!( + home.path().join(".sandcage/homes/default/.claude").is_dir(), + ".claude dir should exist" + ); + assert!( + home.path().join(".sandcage/homes/default/.codex").is_dir(), + ".codex dir should exist" + ); + assert!( + home.path().join(".sandcage/homes/default/.gemini").is_dir(), + ".gemini dir should exist" + ); + } + + #[test] + fn seeds_claude_settings_when_absent() { + let home = TempDir::new().expect("create tempdir"); + run(home.path()).expect("preseed"); + + let settings = home.path().join(".sandcage/homes/default/.claude/settings.json"); + assert!(settings.exists(), "settings.json should be created"); + + let content = std::fs::read_to_string(&settings).expect("read settings"); + assert_eq!( + content.trim(), + CLAUDE_SETTINGS_TEMPLATE.trim(), + "settings.json content should match the template" + ); + } + + #[test] + fn does_not_overwrite_existing_claude_settings() { + let home = TempDir::new().expect("create tempdir"); + // Create the directory and put custom content in settings.json first + let claude_dir = home.path().join(".sandcage/homes/default/.claude"); + std::fs::create_dir_all(&claude_dir).expect("mkdir"); + let settings = claude_dir.join("settings.json"); + std::fs::write(&settings, r#"{"existing": true}"#).expect("write"); + + run(home.path()).expect("preseed"); + + let content = std::fs::read_to_string(&settings).expect("read"); + assert_eq!( + content, r#"{"existing": true}"#, + "existing settings.json must not be overwritten" + ); + } + + #[test] + fn creates_empty_justfile_when_absent() { + let home = TempDir::new().expect("create tempdir"); + run(home.path()).expect("preseed"); + + let justfile = home.path().join(".sandcage/homes/default/.justfile"); + assert!(justfile.exists(), "Justfile should be created"); + assert_eq!( + std::fs::read_to_string(&justfile).expect("read"), + "", + "Justfile should be empty" + ); + } + + #[test] + fn does_not_overwrite_existing_justfile() { + let home = TempDir::new().expect("create tempdir"); + // Seed a Justfile with content first + let sandcage_dir = home.path().join(".sandcage/homes/default"); + std::fs::create_dir_all(&sandcage_dir).expect("mkdir"); + let justfile = sandcage_dir.join(".justfile"); + std::fs::write(&justfile, "default:\n\t@echo hello").expect("write"); + + run(home.path()).expect("preseed"); + + let content = std::fs::read_to_string(&justfile).expect("read"); + assert_eq!( + content, "default:\n\t@echo hello", + "existing Justfile must not be overwritten" + ); + } + + #[test] + fn idempotent_second_run() { + let home = TempDir::new().expect("create tempdir"); + run(home.path()).expect("first preseed"); + run(home.path()).expect("second preseed should also succeed"); + + // All artefacts should still exist after two runs + assert!(home.path().join(".sandcage/homes/default/.claude").is_dir()); + assert!(home.path().join(".sandcage/homes/default/.codex").is_dir()); + assert!(home.path().join(".sandcage/homes/default/.gemini").is_dir()); + assert!(home.path().join(".sandcage/homes/default/.claude/settings.json").exists()); + assert!(home.path().join(".sandcage/homes/default/.justfile").exists()); + } + + // ----------------------------------------------------------------------- + // scaffold_project / ecosystem detection tests + // ----------------------------------------------------------------------- + + fn touch(dir: &Path, name: &str) { + std::fs::write(dir.join(name), "").expect("touch file"); + } + + #[test] + fn detects_rust_from_cargo_toml() { + let dir = TempDir::new().expect("tempdir"); + touch(dir.path(), "Cargo.toml"); + assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Rust); + } + + #[test] + fn detects_node_from_package_json() { + let dir = TempDir::new().expect("tempdir"); + touch(dir.path(), "package.json"); + assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Node); + } + + #[test] + fn detects_python_from_pyproject_toml() { + let dir = TempDir::new().expect("tempdir"); + touch(dir.path(), "pyproject.toml"); + assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Python); + } + + #[test] + fn detects_python_from_requirements_txt() { + let dir = TempDir::new().expect("tempdir"); + touch(dir.path(), "requirements.txt"); + assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Python); + } + + #[test] + fn detects_go_from_go_mod() { + let dir = TempDir::new().expect("tempdir"); + touch(dir.path(), "go.mod"); + assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Go); + } + + #[test] + fn falls_back_to_generic_when_no_markers() { + let dir = TempDir::new().expect("tempdir"); + assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Generic); + } + + #[test] + fn scaffold_creates_config_toml() { + let dir = TempDir::new().expect("tempdir"); + touch(dir.path(), "Cargo.toml"); + + scaffold_project(dir.path()).expect("scaffold"); + + assert!(dir.path().join(".sandcage").join("config.toml").exists()); + } + + #[test] + fn scaffold_refuses_to_overwrite_existing_config() { + let dir = TempDir::new().expect("tempdir"); + let sandcage_dir = dir.path().join(".sandcage"); + std::fs::create_dir_all(&sandcage_dir).expect("mkdir"); + std::fs::write(sandcage_dir.join("config.toml"), "# existing config\n").expect("write"); + + let result = scaffold_project(dir.path()); + assert!( + matches!(result, Err(InitError::ConfigAlreadyExists(_))), + "expected ConfigAlreadyExists, got {result:?}" + ); + } + + #[test] + fn scaffold_generated_toml_is_valid() { + let dir = TempDir::new().expect("tempdir"); + touch(dir.path(), "Cargo.toml"); + + scaffold_project(dir.path()).expect("scaffold"); + + let content = std::fs::read_to_string(dir.path().join(".sandcage").join("config.toml")) + .expect("read config.toml"); + let parsed: toml::Value = + toml::from_str(&content).expect("TOML must be valid"); + let table = parsed.as_table().expect("top-level TOML must be a table"); + assert!( + table.contains_key("toolchains"), + "expected 'toolchains' key in generated TOML" + ); + } + + #[test] + fn scaffold_creates_directory_structure() { + let dir = TempDir::new().expect("tempdir"); + touch(dir.path(), "Cargo.toml"); + + scaffold_project(dir.path()).expect("scaffold"); + + let sandcage = dir.path().join(".sandcage"); + assert!(sandcage.join("overlay").is_dir(), "overlay/ should exist"); + assert!(sandcage.join("local").is_dir(), "local/ should exist"); + assert!(sandcage.join("local").join("overlay").is_dir(), "local/overlay/ should exist"); + } + + #[test] + fn scaffold_creates_inner_gitignore() { + let dir = TempDir::new().expect("tempdir"); + touch(dir.path(), "Cargo.toml"); + + scaffold_project(dir.path()).expect("scaffold"); + + let gitignore = dir.path().join(".sandcage").join(".gitignore"); + assert!(gitignore.exists(), ".sandcage/.gitignore should exist"); + let content = std::fs::read_to_string(&gitignore).expect("read .gitignore"); + assert!(content.contains("local/"), ".gitignore should contain 'local/'"); + } + + #[test] + fn scaffold_gitignore_excludes_shadow() { + let dir = TempDir::new().expect("tempdir"); + touch(dir.path(), "Cargo.toml"); + scaffold_project(dir.path()).expect("scaffold"); + let content = std::fs::read_to_string(dir.path().join(".sandcage").join(".gitignore")).unwrap(); + assert!(content.contains("local/")); + assert!(content.contains("shadow/")); + } +} diff --git a/crates/sandcage/src/lib.rs b/crates/sandcage/src/lib.rs new file mode 100644 index 0000000..6fd75ed --- /dev/null +++ b/crates/sandcage/src/lib.rs @@ -0,0 +1,18 @@ +pub mod backend; +pub mod config; +pub mod config_source; +pub mod docker; +pub mod forwarding; +pub mod init; +pub mod recipe; +pub mod service; +pub mod setup; +pub mod shadow; +pub mod spoof; +pub mod ssh_config; +pub mod tasks; +pub mod workspace; +pub mod worktree; + +#[cfg(feature = "bollard")] +pub mod acp; diff --git a/crates/sandcage/src/main.rs b/crates/sandcage/src/main.rs new file mode 100644 index 0000000..d698f37 --- /dev/null +++ b/crates/sandcage/src/main.rs @@ -0,0 +1,412 @@ +use clap::{Parser, Subcommand}; +use miette::Diagnostic; +use std::path::PathBuf; +use thiserror::Error; + +use sandcage::config; +use sandcage::config_source; +use sandcage::docker; +use sandcage::init; +use sandcage::service; +use sandcage::setup; +use sandcage::workspace; + +/// Sandboxed containers for AI coding agents +#[derive(Parser, Debug)] +#[command(name = "sandcage", version, about, long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Run Claude Code agent in a sandboxed container + Claude { + /// Path to the project directory (defaults to current directory) + #[arg(long, short)] + path: Option, + + /// Drop into a shell instead of launching the agent + #[arg(long)] + shell: bool, + + /// Create a git worktree and mount it instead of the project directory + #[arg(long)] + worktree: bool, + + /// Named home profile for agent auth/config (default: "default") + #[arg(long)] + home: Option, + + /// Arguments forwarded to the agent inside the container + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + agent_args: Vec, + }, + /// Run Codex agent in a sandboxed container + Codex { + /// Path to the project directory (defaults to current directory) + #[arg(long, short)] + path: Option, + + /// Drop into a shell instead of launching the agent + #[arg(long)] + shell: bool, + + /// Create a git worktree and mount it instead of the project directory + #[arg(long)] + worktree: bool, + + /// Named home profile for agent auth/config (default: "default") + #[arg(long)] + home: Option, + + /// Arguments forwarded to the agent inside the container + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + agent_args: Vec, + }, + /// Run Gemini CLI agent in a sandboxed container + Gemini { + /// Path to the project directory (defaults to current directory) + #[arg(long, short)] + path: Option, + + /// Drop into a shell instead of launching the agent + #[arg(long)] + shell: bool, + + /// Create a git worktree and mount it instead of the project directory + #[arg(long)] + worktree: bool, + + /// Named home profile for agent auth/config (default: "default") + #[arg(long)] + home: Option, + + /// Arguments forwarded to the agent inside the container + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + agent_args: Vec, + }, + /// Interactive shell with the same environment + Shell { + /// Path to the project directory (defaults to current directory) + #[arg(long, short)] + path: Option, + + /// Named home profile for agent auth/config (default: "default") + #[arg(long)] + home: Option, + }, + /// Build container images + Build { + /// Force rebuild even if images are up to date + #[arg(long, short)] + force: bool, + + /// Services to build (default: all enabled services) + services: Vec, + }, + /// Initialize a .sandcage/ directory for a project + Init, + /// Remove this project's shadow volumes (named `sandcage--…`). + Clean { + /// Actually delete (default is a dry run). + #[arg(long)] + yes: bool, + }, + /// Guided configuration helpers + Setup { + #[command(subcommand)] + action: SetupAction, + }, + /// Manage sandcage-created worktrees + Worktree { + #[command(subcommand)] + action: WorktreeAction, + }, + /// ACP (Agent Control Protocol) commands + #[cfg(feature = "bollard")] + Acp { + #[command(subcommand)] + action: sandcage::acp::AcpCommands, + }, +} + +#[derive(Subcommand, Debug)] +enum SetupAction { + /// Configure SSH key access for containers + Ssh { + /// Write to global config instead of project config + #[arg(long)] + global: bool, + + /// Skip confirmation prompt + #[arg(long)] + yes: bool, + + /// Re-populate the SSH volume using saved selection + #[arg(long)] + refresh: bool, + + /// Use legacy full bind mount instead of volume copy + #[arg(long)] + bind: bool, + }, +} + +#[derive(Subcommand, Debug)] +enum WorktreeAction { + /// List sandcage-managed worktrees + List, + /// Remove all sandcage-managed worktrees + Clean { + /// Skip confirmation prompt + #[arg(long)] + yes: bool, + }, +} + +#[derive(Debug, Error, Diagnostic)] +enum AppError { + #[error(transparent)] + #[diagnostic(transparent)] + Workspace(#[from] workspace::WorkspaceError), + + #[error(transparent)] + #[diagnostic(transparent)] + Init(#[from] init::InitError), + + #[error(transparent)] + #[diagnostic(transparent)] + Config(#[from] config::ConfigError), + + #[error(transparent)] + #[diagnostic(transparent)] + Docker(#[from] docker::DockerError), + + #[error(transparent)] + #[diagnostic(transparent)] + Setup(#[from] setup::SetupError), + + #[error(transparent)] + #[diagnostic(transparent)] + Worktree(#[from] sandcage::worktree::WorktreeError), +} + +async fn print_runtime_info(config: &sandcage::config::SandcageConfig) { + let backend = match sandcage::backend::default_backend() { + Ok(b) => b, + Err(_) => return, + }; + if backend.check_availability().await.is_err() { + return; // Docker not available — will fail later with a proper error + } + + match backend.runtime_info().await { + Ok(info) => { + use sandcage::backend::runtime::RuntimeMode; + match info.mode { + RuntimeMode::RootlessDocker => { + eprintln!("sandcage: runtime \u{2192} {} (recommended)", info.mode); + } + RuntimeMode::RootfulDocker => { + if config.runtime.as_deref() == Some("rootful") { + eprintln!("sandcage: runtime \u{2192} {}", info.mode); + } else { + eprintln!("sandcage: runtime \u{2192} {} \u{2014} consider rootless for better isolation", info.mode); + } + } + RuntimeMode::Podman => { + eprintln!("sandcage: runtime \u{2192} {}", info.mode); + } + RuntimeMode::Unknown => { + eprintln!("sandcage: runtime \u{2192} {}", info.mode); + } + } + + if !info.cgroup_v2 && info.mode != RuntimeMode::Unknown { + eprintln!("sandcage: note: cgroup v2 not detected — resource limits will not be enforced"); + } + + if let Some(ref runtime_pref) = config.runtime { + match runtime_pref.as_str() { + "rootless" if info.mode != RuntimeMode::RootlessDocker && info.mode != RuntimeMode::Podman => { + eprintln!("sandcage: error: config requires rootless runtime, but connected daemon is {}", info.mode); + std::process::exit(1); + } + _ => {} + } + } + } + Err(_) => {} // Silent — will fail later + } +} + +async fn run_service(service: &str, path: Option, shell_override: bool, worktree: bool, home_override: Option, agent_args: Vec) -> std::result::Result<(), AppError> { + let workspace = workspace::resolve_workspace(path.as_deref())?; + eprintln!("sandcage: workspace \u{2192} {}", workspace.display()); + + let workspace = if worktree { + let sandcage_home = dirs::home_dir() + .map(|h| h.join(".sandcage")) + .unwrap_or_else(|| PathBuf::from(".sandcage")); + let slug = sandcage::worktree::project_slug(&workspace); + let ts = sandcage::worktree::generate_timestamp(); + let wt_path = sandcage::worktree::worktree_dir(&sandcage_home, &slug, &ts); + + eprintln!("sandcage: creating worktree at {}", wt_path.display()); + sandcage::worktree::create_worktree(&workspace, &wt_path, None)?; + eprintln!("sandcage: launching in worktree"); + wt_path + } else { + workspace + }; + + init::preseed()?; + + let home = dirs::home_dir().ok_or(init::InitError::NoHomeDir)?; + let global_config = home.join(".sandcage").join("config.toml"); + + let config_src = config_source::resolve_config_source(&workspace); + let (project_config, local_config) = match &config_src { + Some(src) => { + if !matches!(src.origin, config_source::ConfigOrigin::Workspace) { + eprintln!("sandcage: config → {} ({})", src.root.display(), src.origin); + } + ( + src.root.join(".sandcage").join("config.toml"), + src.root.join(".sandcage").join("local").join("config.local.toml"), + ) + } + None => ( + workspace.join(".sandcage").join("config.toml"), + workspace.join(".sandcage").join("local").join("config.local.toml"), + ), + }; + + // Warn about old YAML config files that have not yet been migrated. + for (old_name, new_name) in [ + ("sandcage.yml", "config.toml"), + ("sandcage.local.yml", "config.local.toml"), + ] { + let dir = config_src.as_ref().map(|s| s.root.as_path()).unwrap_or(&workspace); + let old_path = dir.join(".sandcage").join(old_name); + if old_path.exists() { + eprintln!("sandcage: warning: found old {old_name} — please migrate to {new_name}"); + } + } + + let mut cfg = config::resolve_config( + Some(&global_config), + Some(&project_config), + Some(&local_config), + )?; + + if let Some(home_name) = home_override { + cfg.home = Some(home_name); + } + + print_runtime_info(&cfg).await; + + let registry = service::registry::build_default_registry(&cfg); + + eprintln!("sandcage: service \u{2192} {service}"); + let config_root = config_src.as_ref().map(|s| s.root.as_path()).unwrap_or(&workspace); + docker::run_service(service, &workspace, &cfg, config_root, ®istry, shell_override, &agent_args).await?; + + Ok(()) +} + +#[tokio::main] +async fn main() -> miette::Result<()> { + let cli = Cli::parse(); + + match cli.command { + Commands::Claude { path, shell, worktree, home, agent_args } => { + run_service("claude", path, shell, worktree, home, agent_args).await? + } + Commands::Codex { path, shell, worktree, home, agent_args } => { + run_service("codex", path, shell, worktree, home, agent_args).await? + } + Commands::Gemini { path, shell, worktree, home, agent_args } => { + run_service("gemini", path, shell, worktree, home, agent_args).await? + } + Commands::Shell { path, home } => run_service("shell", path, false, false, home, vec![]).await?, + Commands::Build { force, services } => { + docker::build_images(force, &services).await?; + } + Commands::Init => { + let workspace = workspace::resolve_workspace(None)?; + init::scaffold_project(&workspace)?; + } + Commands::Clean { yes } => { + use miette::IntoDiagnostic; + let workspace = std::env::current_dir().into_diagnostic()?; + docker::clean(&workspace, yes).await.map_err(AppError::Docker)?; + } + Commands::Setup { action } => match action { + SetupAction::Ssh { global, yes, refresh, bind } => { + setup::run_ssh_setup(global, yes, refresh, bind)?; + } + }, + Commands::Worktree { action } => { + let sandcage_home = dirs::home_dir() + .map(|h| h.join(".sandcage")) + .unwrap_or_else(|| PathBuf::from(".sandcage")); + + match action { + WorktreeAction::List => { + let entries = sandcage::worktree::list_worktrees(&sandcage_home); + if entries.is_empty() { + eprintln!("No sandcage worktrees found."); + } else { + for entry in &entries { + println!("{}", entry.path.display()); + } + eprintln!("{} worktree(s)", entries.len()); + } + } + WorktreeAction::Clean { yes } => { + let entries = sandcage::worktree::list_worktrees(&sandcage_home); + if entries.is_empty() { + eprintln!("No worktrees to clean."); + return Ok(()); + } + + eprintln!("Worktrees to remove:"); + for entry in &entries { + eprintln!(" {}", entry.path.display()); + } + + if !yes { + eprint!("Remove {} worktree(s)? [y/N] ", entries.len()); + let mut input = String::new(); + std::io::stdin().read_line(&mut input).ok(); + if !input.trim().eq_ignore_ascii_case("y") { + eprintln!("Aborted."); + return Ok(()); + } + } + + let results = sandcage::worktree::clean_all(&sandcage_home, false); + for (name, result) in &results { + match result { + Ok(()) => eprintln!(" removed: {name}"), + Err(e) => eprintln!(" failed: {name}: {e}"), + } + } + } + } + } + #[cfg(feature = "bollard")] + Commands::Acp { action } => { + let backend = sandcage::backend::default_backend()?; + backend.check_availability().await + .map_err(|e| AppError::Docker(e))?; + sandcage::acp::handle(action, backend.as_ref(), None).await + .map_err(|e| AppError::Docker(e))?; + } + } + + Ok(()) +} diff --git a/crates/sandcage/src/recipe.rs b/crates/sandcage/src/recipe.rs new file mode 100644 index 0000000..7fb40e3 --- /dev/null +++ b/crates/sandcage/src/recipe.rs @@ -0,0 +1,298 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use miette::Diagnostic; +use serde::Deserialize; +use thiserror::Error; + +const RECIPES_TOML: &str = include_str!("../../../images/fragments/recipes.toml"); +const FRAGMENT_UV: &str = include_str!("../../../images/fragments/uv.Dockerfile"); +const FRAGMENT_NVM: &str = include_str!("../../../images/fragments/nvm.Dockerfile"); +const FRAGMENT_RUST: &str = include_str!("../../../images/fragments/rust.Dockerfile"); + +/// Where a toolchain's Dockerfile fragment comes from. +#[derive(Debug, Clone, PartialEq)] +pub enum FragmentSource { + /// Compiled into the binary — built-in recipes (`include_str!`). + Embedded(&'static str), + /// A path on disk, relative to the project — custom recipes. + Path(PathBuf), +} + +/// A fully-resolved toolchain recipe. +#[derive(Debug, Clone, PartialEq)] +pub struct Recipe { + pub name: String, + pub fragment: FragmentSource, + pub shadow: Vec, + pub shared_caches: Vec, +} + +/// The deserializable shape of a recipe in TOML (built-in bundle or custom config). +#[derive(Debug, Clone, Deserialize)] +pub struct RecipeSpec { + pub fragment: String, + #[serde(default)] + pub shadow: Vec, + #[serde(default)] + pub shared_caches: Vec, +} + +/// Map a built-in fragment filename to its embedded content. +fn embedded_fragment(name: &str) -> Option<&'static str> { + match name { + "uv.Dockerfile" => Some(FRAGMENT_UV), + "nvm.Dockerfile" => Some(FRAGMENT_NVM), + "rust.Dockerfile" => Some(FRAGMENT_RUST), + _ => None, + } +} + +/// Parse the bundled recipe TOML into resolved built-in recipes. +/// Panics on a malformed bundle — this is compiled-in data, so a failure is a build bug. +pub fn builtin_recipes() -> HashMap { + let specs: HashMap = + toml::from_str(RECIPES_TOML).expect("bundled recipes.toml must parse"); + + specs + .into_iter() + .map(|(name, spec)| { + let fragment = embedded_fragment(&spec.fragment).unwrap_or_else(|| { + panic!("built-in recipe '{name}' references unknown fragment '{}'", spec.fragment) + }); + let recipe = Recipe { + name: name.clone(), + fragment: FragmentSource::Embedded(fragment), + shadow: spec.shadow, + shared_caches: spec.shared_caches, + }; + (name, recipe) + }) + .collect() +} + +#[derive(Debug, Error, Diagnostic)] +pub enum RecipeError { + #[error("toolchain '{name}' has no built-in recipe")] + #[diagnostic( + code(sandcage::recipe::no_builtin), + help("Provide a [toolchains.{name}] table with a `fragment` path, or remove it.") + )] + NoBuiltin { name: String }, + + #[error("custom toolchain '{name}' has an invalid table: {reason}")] + #[diagnostic(code(sandcage::recipe::invalid_spec))] + InvalidSpec { name: String, reason: String }, + + #[error("fragment for toolchain '{name}' not found at {path}")] + #[diagnostic( + code(sandcage::recipe::fragment_not_found), + help("Check the `fragment` path in [toolchains.{name}]; it is resolved relative to the project root.") + )] + FragmentNotFound { name: String, path: PathBuf }, +} + +/// Resolve `[toolchains]` config into a list of recipes. +/// +/// - `name = true` → built-in recipe (error if none exists). +/// - `name = false` → disabled, skipped. +/// - `[toolchains.name]` table → custom recipe, fully overriding any built-in of the same name. +/// The `fragment` path is resolved relative to `project_root` and must exist. +/// +/// Iteration order is sorted by name so output is deterministic (the input is a HashMap). +pub fn resolve_recipes( + toolchains: &HashMap, + project_root: &Path, +) -> Result, RecipeError> { + let builtins = builtin_recipes(); + let mut names: Vec<&String> = toolchains.keys().collect(); + names.sort(); + + let mut out = Vec::new(); + for name in names { + let value = &toolchains[name]; + match value { + toml::Value::Boolean(false) => continue, + toml::Value::Boolean(true) => { + let recipe = builtins + .get(name) + .cloned() + .ok_or_else(|| RecipeError::NoBuiltin { name: name.clone() })?; + out.push(recipe); + } + toml::Value::Table(_) => { + let spec: RecipeSpec = value.clone().try_into().map_err(|e| { + RecipeError::InvalidSpec { name: name.clone(), reason: e.to_string() } + })?; + let frag_path = project_root.join(&spec.fragment); + if !frag_path.exists() { + return Err(RecipeError::FragmentNotFound { + name: name.clone(), + path: frag_path, + }); + } + out.push(Recipe { + name: name.clone(), + fragment: FragmentSource::Path(frag_path), + shadow: spec.shadow, + shared_caches: spec.shared_caches, + }); + } + // Strings and other scalar truthy forms: treat like `true` for back-compat + // with the previous `match value` logic (String meant "enabled"). + toml::Value::String(_) => { + let recipe = builtins + .get(name) + .cloned() + .ok_or_else(|| RecipeError::NoBuiltin { name: name.clone() })?; + out.push(recipe); + } + _ => continue, + } + } + Ok(out) +} + +impl Recipe { + /// The Dockerfile fragment text for this recipe. + pub fn fragment_text(&self) -> Result { + match &self.fragment { + FragmentSource::Embedded(s) => Ok((*s).to_string()), + FragmentSource::Path(p) => std::fs::read_to_string(p).map_err(|_| { + RecipeError::FragmentNotFound { name: self.name.clone(), path: p.clone() } + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn fragment_source_variants_construct() { + let embedded = FragmentSource::Embedded("FROM scratch"); + let path = FragmentSource::Path(PathBuf::from(".sandcage/fragments/zig.Dockerfile")); + assert_ne!(embedded, path); + } + + #[test] + fn recipe_holds_fields() { + let r = Recipe { + name: "rust".to_string(), + fragment: FragmentSource::Embedded("FROM rust"), + shadow: vec!["target".to_string()], + shared_caches: vec![".cargo/registry".to_string()], + }; + assert_eq!(r.name, "rust"); + assert_eq!(r.shadow, vec!["target".to_string()]); + assert_eq!(r.shared_caches, vec![".cargo/registry".to_string()]); + } + + #[test] + fn builtin_recipes_load_uv_nvm_rust() { + let builtins = builtin_recipes(); + assert!(builtins.contains_key("uv"), "uv built-in must exist"); + assert!(builtins.contains_key("nvm"), "nvm built-in must exist"); + assert!(builtins.contains_key("rust"), "rust built-in must exist"); + + let rust = &builtins["rust"]; + assert_eq!(rust.shadow, vec!["target".to_string()]); + assert_eq!(rust.shared_caches, vec![".cargo/registry".to_string()]); + // Built-ins resolve to embedded fragment content. + match &rust.fragment { + FragmentSource::Embedded(s) => assert!(s.contains("rustup"), "rust fragment should mention rustup"), + other => panic!("built-in fragment must be Embedded, got {other:?}"), + } + } + + use std::collections::HashMap as Map; + + fn toolchains(pairs: &[(&str, toml::Value)]) -> Map { + pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect() + } + + #[test] + fn resolve_enables_builtin_on_true() { + let tc = toolchains(&[("rust", toml::Value::Boolean(true))]); + let recipes = resolve_recipes(&tc, std::path::Path::new("/proj")).expect("resolves"); + assert_eq!(recipes.len(), 1); + assert_eq!(recipes[0].name, "rust"); + assert!(matches!(recipes[0].fragment, FragmentSource::Embedded(_))); + } + + #[test] + fn resolve_skips_disabled_builtin() { + let tc = toolchains(&[("rust", toml::Value::Boolean(false))]); + let recipes = resolve_recipes(&tc, std::path::Path::new("/proj")).expect("resolves"); + assert!(recipes.is_empty(), "rust = false must not produce a recipe"); + } + + #[test] + fn resolve_true_for_unknown_name_errors() { + let tc = toolchains(&[("zig", toml::Value::Boolean(true))]); + let err = resolve_recipes(&tc, std::path::Path::new("/proj")).unwrap_err(); + assert!( + matches!(err, RecipeError::NoBuiltin { ref name } if name == "zig"), + "expected NoBuiltin for zig, got {err:?}" + ); + } + + #[test] + fn resolve_custom_table_overrides_with_existing_fragment() { + let dir = tempfile::tempdir().unwrap(); + let frag = dir.path().join("zig.Dockerfile"); + std::fs::write(&frag, "RUN install zig\n").unwrap(); + + let mut table = toml::value::Table::new(); + table.insert("fragment".into(), toml::Value::String("zig.Dockerfile".into())); + table.insert( + "shadow".into(), + toml::Value::Array(vec![toml::Value::String("zig-cache".into())]), + ); + let tc = toolchains(&[("zig", toml::Value::Table(table))]); + + let recipes = resolve_recipes(&tc, dir.path()).expect("resolves custom"); + assert_eq!(recipes.len(), 1); + assert_eq!(recipes[0].name, "zig"); + assert_eq!(recipes[0].shadow, vec!["zig-cache".to_string()]); + assert_eq!(recipes[0].fragment, FragmentSource::Path(frag)); + } + + #[test] + fn resolve_custom_table_missing_fragment_errors() { + let dir = tempfile::tempdir().unwrap(); + let mut table = toml::value::Table::new(); + table.insert("fragment".into(), toml::Value::String("nope.Dockerfile".into())); + let tc = toolchains(&[("zig", toml::Value::Table(table))]); + + let err = resolve_recipes(&tc, dir.path()).unwrap_err(); + assert!( + matches!(err, RecipeError::FragmentNotFound { .. }), + "expected FragmentNotFound, got {err:?}" + ); + } + + #[test] + fn fragment_text_reads_embedded_and_path() { + let embedded = Recipe { + name: "uv".into(), + fragment: FragmentSource::Embedded("FROM uv-base"), + shadow: vec![], + shared_caches: vec![], + }; + assert_eq!(embedded.fragment_text().unwrap(), "FROM uv-base"); + + let dir = tempfile::tempdir().unwrap(); + let frag = dir.path().join("z.Dockerfile"); + std::fs::write(&frag, "RUN custom\n").unwrap(); + let custom = Recipe { + name: "zig".into(), + fragment: FragmentSource::Path(frag), + shadow: vec![], + shared_caches: vec![], + }; + assert_eq!(custom.fragment_text().unwrap(), "RUN custom\n"); + } +} diff --git a/crates/sandcage/src/service/builtin.rs b/crates/sandcage/src/service/builtin.rs new file mode 100644 index 0000000..0c30020 --- /dev/null +++ b/crates/sandcage/src/service/builtin.rs @@ -0,0 +1,166 @@ +use super::ServiceDef; + +pub const CLAUDE_ENTRYPOINT: &str = r#"#!/bin/sh +set -e +CLAUDE_BIN="$HOME/.local/bin/claude" +if [ ! -x "$CLAUDE_BIN" ]; then + echo "sandcage: installing Claude Code..." >&2 + WORKDIR="$(pwd)" + cd "$HOME" + curl -fsSL https://claude.ai/install.sh | bash >&2 + cd "$WORKDIR" +fi +exec "$CLAUDE_BIN" "$@" +"#; + +pub const CODEX_ENTRYPOINT: &str = r#"#!/bin/sh +set -e +CODEX_BIN="$HOME/.local/bin/codex" +if [ ! -x "$CODEX_BIN" ]; then + echo "sandcage: installing Codex..." >&2 + WORKDIR="$(pwd)" + cd "$HOME" + arch=$(uname -m) + case "$arch" in + x86_64) target="x86_64-unknown-linux-musl" ;; + aarch64) target="aarch64-unknown-linux-musl" ;; + *) echo "sandcage: unsupported arch: $arch" >&2; exit 1 ;; + esac + mkdir -p "$HOME/.local/bin" + curl -fsSL "https://github.com/openai/codex/releases/latest/download/codex-${target}.tar.gz" \ + | tar xz -C "$HOME/.local/bin" + mv "$HOME/.local/bin/codex-${target}" "$CODEX_BIN" + cd "$WORKDIR" +fi +exec "$CODEX_BIN" "$@" +"#; + +pub const GEMINI_ENTRYPOINT: &str = r#"#!/bin/sh +set -e +GEMINI_BIN="$HOME/.local/bin/gemini" +if [ ! -x "$GEMINI_BIN" ]; then + echo "sandcage: installing Gemini CLI..." >&2 + WORKDIR="$(pwd)" + cd "$HOME" + npm install -g @anthropic-ai/gemini-cli >&2 + cd "$WORKDIR" +fi +exec "$GEMINI_BIN" "$@" +"#; + +pub fn claude() -> ServiceDef { + ServiceDef { + name: "claude".to_string(), + description: "Claude Code agent".to_string(), + enabled: true, + entrypoint_name: Some("sandcage-claude-entrypoint".to_string()), + entrypoint_script: Some(CLAUDE_ENTRYPOINT.to_string()), + config_dir: Some(".claude".to_string()), + } +} + +pub fn codex() -> ServiceDef { + ServiceDef { + name: "codex".to_string(), + description: "Codex agent".to_string(), + enabled: true, + entrypoint_name: Some("sandcage-codex-entrypoint".to_string()), + entrypoint_script: Some(CODEX_ENTRYPOINT.to_string()), + config_dir: Some(".codex".to_string()), + } +} + +pub fn gemini() -> ServiceDef { + ServiceDef { + name: "gemini".to_string(), + description: "Gemini CLI agent".to_string(), + enabled: true, + entrypoint_name: Some("sandcage-gemini-entrypoint".to_string()), + entrypoint_script: Some(GEMINI_ENTRYPOINT.to_string()), + config_dir: Some(".gemini".to_string()), + } +} + +pub fn shell() -> ServiceDef { + ServiceDef { + name: "shell".to_string(), + description: "Interactive shell".to_string(), + enabled: true, + entrypoint_name: Some("/bin/zsh".to_string()), + entrypoint_script: None, + config_dir: None, + } +} + +pub fn all() -> Vec { + vec![claude(), codex(), gemini(), shell()] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::Service; + + #[test] + fn all_builtins_are_enabled_by_default() { + for svc in all() { + assert!(svc.enabled(), "{} should be enabled by default", svc.name()); + } + } + + #[test] + fn all_builtins_have_unique_names() { + let services = all(); + let mut names: Vec<&str> = services.iter().map(|s| s.name()).collect(); + let count = names.len(); + names.dedup(); + assert_eq!(names.len(), count, "service names must be unique"); + } + + #[test] + fn agent_services_have_entrypoint_scripts() { + for svc in [claude(), codex(), gemini()] { + assert!( + svc.entrypoint_script().is_some(), + "{} should have an entrypoint script", + svc.name() + ); + assert!( + svc.entrypoint_script().unwrap().starts_with("#!/bin/sh"), + "{} entrypoint should start with shebang", + svc.name() + ); + } + } + + #[test] + fn shell_has_no_entrypoint_script() { + let svc = shell(); + assert!(svc.entrypoint_script().is_none()); + assert_eq!(svc.entrypoint_name(), Some("/bin/zsh")); + } + + #[test] + fn agent_services_have_config_dirs() { + assert_eq!(claude().config_dir(), Some(".claude")); + assert_eq!(codex().config_dir(), Some(".codex")); + assert_eq!(gemini().config_dir(), Some(".gemini")); + } + + #[test] + fn shell_has_no_config_dir() { + assert_eq!(shell().config_dir(), None); + } + + #[test] + fn claude_entrypoint_installs_from_claude_ai() { + assert!(CLAUDE_ENTRYPOINT.contains("claude.ai/install.sh")); + } + + #[test] + fn codex_entrypoint_handles_arch_detection() { + assert!(CODEX_ENTRYPOINT.contains("uname -m")); + assert!(CODEX_ENTRYPOINT.contains("x86_64")); + assert!(CODEX_ENTRYPOINT.contains("aarch64")); + } +} diff --git a/crates/sandcage/src/service/compose.rs b/crates/sandcage/src/service/compose.rs new file mode 100644 index 0000000..3ae99b8 --- /dev/null +++ b/crates/sandcage/src/service/compose.rs @@ -0,0 +1,154 @@ +use super::registry::ServiceRegistry; +use super::ComposeContext; + +pub fn generate_compose(registry: &ServiceRegistry, ctx: &ComposeContext) -> String { + let mut services = serde_yaml::Mapping::new(); + + for svc in registry.enabled() { + let def = svc.compose_service(ctx); + let value = serde_yaml::to_value(&def).expect("ComposeServiceDef must serialize"); + services.insert( + serde_yaml::Value::String(svc.name().to_string()), + value, + ); + } + + let mut doc = serde_yaml::Mapping::new(); + doc.insert( + serde_yaml::Value::String("services".to_string()), + serde_yaml::Value::Mapping(services), + ); + + serde_yaml::to_string(&serde_yaml::Value::Mapping(doc)) + .expect("compose doc must serialize") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::builtin; + use crate::service::ServiceDef; + use std::path::PathBuf; + + fn test_context() -> ComposeContext { + ComposeContext { + uid: "1000".to_string(), + gid: "1000".to_string(), + workspace: PathBuf::from("/home/user/projects/my-app"), + container_dir: "/workspace/my-app".to_string(), + sandcage_home: PathBuf::from("/home/user/.sandcage"), + image: "sandcage".to_string(), + config_root: PathBuf::from("/home/user/projects/my-app"), + forwarding_socket: None, + agent_home: PathBuf::from("/home/user/.sandcage/homes/default"), + } + } + + fn registry_with(services: Vec) -> ServiceRegistry { + let mut reg = ServiceRegistry::new(); + for svc in services { + reg.register(Box::new(svc)); + } + reg + } + + #[test] + fn generates_valid_yaml() { + let reg = registry_with(vec![builtin::claude(), builtin::shell()]); + let yaml = generate_compose(®, &test_context()); + + let parsed: serde_yaml::Value = + serde_yaml::from_str(&yaml).expect("output must be valid YAML"); + assert!(parsed.as_mapping().unwrap().contains_key("services")); + } + + #[test] + fn contains_only_enabled_services() { + let mut codex = builtin::codex(); + codex.enabled = false; + + let reg = registry_with(vec![builtin::claude(), codex, builtin::shell()]); + let yaml = generate_compose(®, &test_context()); + + assert!(yaml.contains("claude:"), "claude should be present"); + assert!(yaml.contains("shell:"), "shell should be present"); + assert!(!yaml.contains("codex:"), "disabled codex should be absent"); + } + + #[test] + fn empty_registry_produces_empty_services() { + let reg = ServiceRegistry::new(); + let yaml = generate_compose(®, &test_context()); + + let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap(); + let services = parsed.as_mapping().unwrap().get("services").unwrap(); + assert!( + services.as_mapping().unwrap().is_empty(), + "services should be empty mapping" + ); + } + + #[test] + fn service_has_correct_image() { + let reg = registry_with(vec![builtin::claude()]); + let yaml = generate_compose(®, &test_context()); + assert!(yaml.contains("sandcage:latest")); + } + + #[test] + fn service_has_correct_entrypoint() { + let reg = registry_with(vec![builtin::claude()]); + let yaml = generate_compose(®, &test_context()); + assert!(yaml.contains("sandcage-claude-entrypoint")); + } + + #[test] + fn service_has_correct_working_dir() { + let reg = registry_with(vec![builtin::claude()]); + let yaml = generate_compose(®, &test_context()); + assert!(yaml.contains("/workspace/my-app")); + } + + #[test] + fn service_has_home_volume() { + let reg = registry_with(vec![builtin::claude()]); + let yaml = generate_compose(®, &test_context()); + assert!(yaml.contains("/home/user/.sandcage/homes/default:/home/agent")); + } + + #[test] + fn all_four_builtins_generate_successfully() { + let reg = registry_with(builtin::all()); + let yaml = generate_compose(®, &test_context()); + + for name in ["claude", "codex", "gemini", "shell"] { + assert!( + yaml.contains(&format!("{name}:")), + "compose should contain {name} service" + ); + } + } + + #[test] + fn backward_compat_claude_codex_shell_match_old_structure() { + let reg = registry_with(vec![ + builtin::claude(), + builtin::codex(), + builtin::shell(), + ]); + let yaml = generate_compose(®, &test_context()); + let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap(); + let services = parsed["services"].as_mapping().unwrap(); + + for name in ["claude", "codex", "shell"] { + let svc = &services[&serde_yaml::Value::String(name.to_string())]; + assert_eq!( + svc["image"].as_str().unwrap(), + "sandcage:latest", + "{name}: image" + ); + assert!(svc["tty"].as_bool().unwrap(), "{name}: tty"); + assert!(svc["stdin_open"].as_bool().unwrap(), "{name}: stdin_open"); + } + } +} diff --git a/crates/sandcage/src/service/mod.rs b/crates/sandcage/src/service/mod.rs new file mode 100644 index 0000000..a71e9dd --- /dev/null +++ b/crates/sandcage/src/service/mod.rs @@ -0,0 +1,266 @@ +pub mod builtin; +pub mod compose; +pub mod registry; + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Compose output types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub struct ComposeContext { + pub uid: String, + pub gid: String, + pub workspace: PathBuf, + pub container_dir: String, + pub sandcage_home: PathBuf, + pub image: String, + /// Directory containing .sandcage/ (may differ from workspace in worktrees) + pub config_root: PathBuf, + /// Unix socket for the forwarding daemon (bind-mounted into container). + pub forwarding_socket: Option, + /// Resolved path to the agent home directory on the host + /// (e.g. `~/.sandcage/homes/work/`). + pub agent_home: PathBuf, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComposeServiceDef { + pub image: String, + pub entrypoint: Vec, + pub working_dir: String, + pub user: String, + pub volumes: Vec, + pub environment: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tmpfs: Vec, + pub tty: bool, + pub stdin_open: bool, +} + +// --------------------------------------------------------------------------- +// Service trait +// --------------------------------------------------------------------------- + +pub trait Service: std::fmt::Debug + Send + Sync { + fn name(&self) -> &str; + fn description(&self) -> &str; + fn enabled(&self) -> bool; + fn set_enabled(&mut self, enabled: bool); + fn entrypoint_name(&self) -> Option<&str>; + fn entrypoint_script(&self) -> Option<&str>; + fn config_dir(&self) -> Option<&str>; + fn compose_service(&self, ctx: &ComposeContext) -> ComposeServiceDef; +} + +// --------------------------------------------------------------------------- +// ServiceDef — standard implementation +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceDef { + pub name: String, + pub description: String, + pub enabled: bool, + pub entrypoint_name: Option, + pub entrypoint_script: Option, + pub config_dir: Option, +} + +impl Service for ServiceDef { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.description + } + + fn enabled(&self) -> bool { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + fn entrypoint_name(&self) -> Option<&str> { + self.entrypoint_name.as_deref() + } + + fn entrypoint_script(&self) -> Option<&str> { + self.entrypoint_script.as_deref() + } + + fn config_dir(&self) -> Option<&str> { + self.config_dir.as_deref() + } + + fn compose_service(&self, ctx: &ComposeContext) -> ComposeServiceDef { + let entrypoint = match self.entrypoint_name.as_deref() { + Some(name) => vec![name.to_string()], + None => vec!["/bin/zsh".to_string()], + }; + + ComposeServiceDef { + image: format!("{}:latest", ctx.image), + entrypoint, + working_dir: ctx.container_dir.clone(), + user: format!("{}:{}", ctx.uid, ctx.gid), + volumes: vec![ + format!("{}:/home/agent", ctx.agent_home.display()), + format!( + "{}:{}", + ctx.workspace.display(), + ctx.container_dir + ), + ], + environment: vec!["HOME=/home/agent".to_string()], + tmpfs: vec![format!("{}/.sandcage", ctx.container_dir)], + tty: true, + stdin_open: true, + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn test_context() -> ComposeContext { + ComposeContext { + uid: "1000".to_string(), + gid: "1000".to_string(), + workspace: PathBuf::from("/home/user/projects/my-app"), + agent_home: PathBuf::from("/home/user/.sandcage/homes/default"), + container_dir: "/workspace/my-app".to_string(), + sandcage_home: PathBuf::from("/home/user/.sandcage"), + image: "sandcage".to_string(), + config_root: PathBuf::from("/home/user/projects/my-app"), + forwarding_socket: None, + } + } + + fn agent_service() -> ServiceDef { + ServiceDef { + name: "test-agent".to_string(), + description: "A test agent".to_string(), + enabled: true, + entrypoint_name: Some("sandcage-test-entrypoint".to_string()), + entrypoint_script: Some("#!/bin/sh\nexec echo hello".to_string()), + config_dir: Some(".test-agent".to_string()), + } + } + + #[test] + fn service_def_trait_accessors() { + let svc = agent_service(); + assert_eq!(svc.name(), "test-agent"); + assert_eq!(svc.description(), "A test agent"); + assert!(svc.enabled()); + assert_eq!(svc.entrypoint_name(), Some("sandcage-test-entrypoint")); + assert!(svc.entrypoint_script().is_some()); + assert_eq!(svc.config_dir(), Some(".test-agent")); + } + + #[test] + fn service_def_set_enabled() { + let mut svc = agent_service(); + assert!(svc.enabled()); + svc.set_enabled(false); + assert!(!svc.enabled()); + } + + #[test] + fn compose_service_has_correct_image() { + let svc = agent_service(); + let ctx = test_context(); + let compose = svc.compose_service(&ctx); + assert_eq!(compose.image, "sandcage:latest"); + } + + #[test] + fn compose_service_uses_entrypoint_name() { + let svc = agent_service(); + let ctx = test_context(); + let compose = svc.compose_service(&ctx); + assert_eq!(compose.entrypoint, vec!["sandcage-test-entrypoint"]); + } + + #[test] + fn compose_service_shell_uses_bin_zsh_when_no_entrypoint() { + let svc = ServiceDef { + entrypoint_name: None, + ..agent_service() + }; + let ctx = test_context(); + let compose = svc.compose_service(&ctx); + assert_eq!(compose.entrypoint, vec!["/bin/zsh"]); + } + + #[test] + fn compose_service_has_correct_user() { + let svc = agent_service(); + let ctx = test_context(); + let compose = svc.compose_service(&ctx); + assert_eq!(compose.user, "1000:1000"); + } + + #[test] + fn compose_service_has_home_and_workspace_volumes() { + let svc = agent_service(); + let ctx = test_context(); + let compose = svc.compose_service(&ctx); + assert_eq!(compose.volumes.len(), 2); + assert!(compose.volumes[0].contains("/homes/default:/home/agent")); + assert!(compose.volumes[1].contains("/workspace/my-app")); + } + + #[test] + fn compose_service_has_tty_and_stdin() { + let svc = agent_service(); + let ctx = test_context(); + let compose = svc.compose_service(&ctx); + assert!(compose.tty); + assert!(compose.stdin_open); + } + + #[test] + fn shell_service_uses_explicit_entrypoint() { + let svc = ServiceDef { + name: "shell".to_string(), + description: "Interactive shell".to_string(), + enabled: true, + entrypoint_name: Some("/bin/zsh".to_string()), + entrypoint_script: None, + config_dir: None, + }; + let ctx = test_context(); + let compose = svc.compose_service(&ctx); + assert_eq!(compose.entrypoint, vec!["/bin/zsh"]); + } + + #[test] + fn compose_service_uses_configured_image() { + let svc = agent_service(); + let mut ctx = test_context(); + ctx.image = "custom-img".to_string(); + let compose = svc.compose_service(&ctx); + assert_eq!(compose.image, "custom-img:latest"); + } + + #[test] + fn compose_service_excludes_sandcage_dir_via_tmpfs() { + let svc = agent_service(); + let ctx = test_context(); + let compose = svc.compose_service(&ctx); + assert_eq!(compose.tmpfs, vec!["/workspace/my-app/.sandcage"]); + } +} diff --git a/crates/sandcage/src/service/registry.rs b/crates/sandcage/src/service/registry.rs new file mode 100644 index 0000000..fcd211a --- /dev/null +++ b/crates/sandcage/src/service/registry.rs @@ -0,0 +1,237 @@ +use indexmap::IndexMap; + +use super::Service; + +pub struct ServiceRegistry { + services: IndexMap>, +} + +impl Default for ServiceRegistry { + fn default() -> Self { + Self::new() + } +} + +impl ServiceRegistry { + pub fn new() -> Self { + Self { + services: IndexMap::new(), + } + } + + pub fn register(&mut self, service: Box) { + self.services.insert(service.name().to_string(), service); + } + + pub fn get(&self, name: &str) -> Option<&dyn Service> { + self.services.get(name).map(|s| s.as_ref()) + } + + pub fn get_mut(&mut self, name: &str) -> Option<&mut Box> { + self.services.get_mut(name) + } + + pub fn enabled(&self) -> impl Iterator { + self.services.values().filter(|s| s.enabled()).map(|s| s.as_ref()) + } + + pub fn all(&self) -> impl Iterator { + self.services.values().map(|s| s.as_ref()) + } + + pub fn names(&self) -> impl Iterator { + self.services.keys().map(|k| k.as_str()) + } + + pub fn enabled_names(&self) -> Vec<&str> { + self.enabled().map(|s| s.name()).collect() + } + + pub fn apply_overrides(&mut self, config: &crate::config::SandcageConfig) { + if let Some(ref overrides) = config.services { + for (name, ovr) in overrides { + if let Some(svc) = self.get_mut(name) + && let Some(enabled) = ovr.enabled + { + svc.set_enabled(enabled); + } + } + } + } +} + +impl std::fmt::Debug for ServiceRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ServiceRegistry") + .field("services", &self.services.keys().collect::>()) + .finish() + } +} + +pub fn build_default_registry(config: &crate::config::SandcageConfig) -> ServiceRegistry { + let mut reg = ServiceRegistry::new(); + for svc in super::builtin::all() { + reg.register(Box::new(svc)); + } + reg.apply_overrides(config); + reg +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::ServiceDef; + + fn make_service(name: &str, enabled: bool) -> Box { + Box::new(ServiceDef { + name: name.to_string(), + description: format!("{name} service"), + enabled, + entrypoint_name: Some(format!("sandcage-{name}-entrypoint")), + entrypoint_script: Some("#!/bin/sh\nexec echo test".to_string()), + config_dir: Some(format!(".{name}")), + }) + } + + #[test] + fn register_and_get() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + assert!(reg.get("claude").is_some()); + assert!(reg.get("nonexistent").is_none()); + } + + #[test] + fn enabled_filters_disabled_services() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + reg.register(make_service("codex", false)); + reg.register(make_service("shell", true)); + + let enabled: Vec<&str> = reg.enabled().map(|s| s.name()).collect(); + assert_eq!(enabled, vec!["claude", "shell"]); + } + + #[test] + fn all_returns_every_service() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + reg.register(make_service("codex", false)); + + let all: Vec<&str> = reg.all().map(|s| s.name()).collect(); + assert_eq!(all, vec!["claude", "codex"]); + } + + #[test] + fn names_returns_all_keys() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + reg.register(make_service("codex", true)); + + let names: Vec<&str> = reg.names().collect(); + assert_eq!(names, vec!["claude", "codex"]); + } + + #[test] + fn preserves_insertion_order() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("shell", true)); + reg.register(make_service("claude", true)); + reg.register(make_service("codex", true)); + + let names: Vec<&str> = reg.names().collect(); + assert_eq!(names, vec!["shell", "claude", "codex"]); + } + + #[test] + fn get_mut_can_disable_service() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + + assert!(reg.get("claude").unwrap().enabled()); + reg.get_mut("claude").unwrap().set_enabled(false); + assert!(!reg.get("claude").unwrap().enabled()); + } + + #[test] + fn enabled_names_convenience() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + reg.register(make_service("codex", false)); + reg.register(make_service("shell", true)); + + assert_eq!(reg.enabled_names(), vec!["claude", "shell"]); + } + + use crate::config::{SandcageConfig, ServiceOverride}; + use std::collections::HashMap; + + #[test] + fn apply_overrides_disables_service() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + reg.register(make_service("codex", true)); + + let mut overrides = HashMap::new(); + overrides.insert("codex".to_string(), ServiceOverride { enabled: Some(false) }); + let config = SandcageConfig { + services: Some(overrides), + ..Default::default() + }; + + reg.apply_overrides(&config); + assert!(reg.get("claude").unwrap().enabled()); + assert!(!reg.get("codex").unwrap().enabled()); + } + + #[test] + fn apply_overrides_ignores_unknown_services() { + let mut reg = ServiceRegistry::new(); + reg.register(make_service("claude", true)); + + let mut overrides = HashMap::new(); + overrides.insert("nonexistent".to_string(), ServiceOverride { enabled: Some(false) }); + let config = SandcageConfig { + services: Some(overrides), + ..Default::default() + }; + + reg.apply_overrides(&config); + assert!(reg.get("claude").unwrap().enabled()); + } + + #[test] + fn build_default_registry_has_all_builtins() { + let config = SandcageConfig::default(); + let reg = build_default_registry(&config); + + for name in ["claude", "codex", "gemini", "shell"] { + assert!( + reg.get(name).is_some(), + "{name} should be in default registry" + ); + assert!( + reg.get(name).unwrap().enabled(), + "{name} should be enabled by default" + ); + } + } + + #[test] + fn build_default_registry_applies_overrides() { + let mut overrides = HashMap::new(); + overrides.insert("codex".to_string(), ServiceOverride { enabled: Some(false) }); + overrides.insert("gemini".to_string(), ServiceOverride { enabled: Some(false) }); + + let config = SandcageConfig { + services: Some(overrides), + ..Default::default() + }; + + let reg = build_default_registry(&config); + assert!(reg.get("claude").unwrap().enabled()); + assert!(!reg.get("codex").unwrap().enabled()); + assert!(!reg.get("gemini").unwrap().enabled()); + assert!(reg.get("shell").unwrap().enabled()); + } +} diff --git a/crates/sandcage/src/setup.rs b/crates/sandcage/src/setup.rs new file mode 100644 index 0000000..1e17d36 --- /dev/null +++ b/crates/sandcage/src/setup.rs @@ -0,0 +1,1358 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use miette::Diagnostic; +use thiserror::Error; + +use figment::providers::Format as _; + +use crate::config::{SandcageConfig, SshKeyEntry}; +use crate::ssh_config::{self, SshHostBlock}; + +#[derive(Debug, Error, Diagnostic)] +pub enum SetupError { + #[error("No SSH directory found at {0}")] + #[diagnostic(code(sandcage::setup::no_ssh_dir))] + NoSshDir(PathBuf), + + #[error("Failed to read directory {0}: {1}")] + #[diagnostic(code(sandcage::setup::read_dir_failed))] + ReadDirFailed(PathBuf, #[source] std::io::Error), + + #[error("No .sandcage/sandcage.yml found. Run `sandcage init` first, use `--global`, or set SANDCAGE_CONFIG_DIR.")] + #[diagnostic( + code(sandcage::setup::no_project_config), + help("In a git worktree, .sandcage/ is loaded from the main worktree root. \ + You can also use a redirect file (sandcage.yml with redirect: key).") + )] + NoProjectConfig, + + #[error("Failed to read config file {0}: {1}")] + #[diagnostic(code(sandcage::setup::config_read_failed))] + ConfigReadFailed(PathBuf, #[source] std::io::Error), + + #[error("Failed to parse config file {0}: {1}")] + #[diagnostic(code(sandcage::setup::config_parse_failed))] + ConfigParseFailed(PathBuf, String), + + #[error("Failed to write config file {0}: {1}")] + #[diagnostic(code(sandcage::setup::config_write_failed))] + ConfigWriteFailed(PathBuf, #[source] std::io::Error), + + #[error("Cannot determine home directory")] + #[diagnostic(code(sandcage::setup::no_home_dir))] + NoHomeDir, + + #[error("Docker command failed: {0}")] + #[diagnostic(code(sandcage::setup::docker_failed))] + DockerFailed(String), +} + +pub type Result = std::result::Result; + +const SSH_MOUNT: &str = "~/.ssh:/home/agent/.ssh:ro"; + +const SSH_CONFIG_FILES: &[&str] = &["config", "known_hosts", "known_hosts.old", "authorized_keys"]; + +#[derive(Debug, Clone, PartialEq)] +pub struct SshScanResult { + pub keys: Vec, + pub config_files: Vec, + pub other_count: usize, +} + +pub fn scan_ssh_dir(ssh_dir: &Path) -> Result { + if !ssh_dir.is_dir() { + return Err(SetupError::NoSshDir(ssh_dir.to_path_buf())); + } + + let entries = std::fs::read_dir(ssh_dir) + .map_err(|e| SetupError::ReadDirFailed(ssh_dir.to_path_buf(), e))?; + + let mut keys = Vec::new(); + let mut config_files = Vec::new(); + let mut other_count: usize = 0; + + for entry in entries { + let entry = entry.map_err(|e| SetupError::ReadDirFailed(ssh_dir.to_path_buf(), e))?; + let file_name = entry.file_name().to_string_lossy().into_owned(); + + if file_name.ends_with(".pub") { + let base = file_name.strip_suffix(".pub").unwrap().to_string(); + keys.push(base); + } else if SSH_CONFIG_FILES.contains(&file_name.as_str()) { + config_files.push(file_name); + } else { + other_count += 1; + } + } + + keys.sort(); + config_files.sort(); + + Ok(SshScanResult { + keys, + config_files, + other_count, + }) +} + +pub fn check_existing_mount(config: &SandcageConfig, mount: &str) -> bool { + config + .mounts + .as_ref() + .is_some_and(|m| m.iter().any(|entry| entry == mount)) +} + +pub fn add_mount_to_yaml(path: &Path, mount: &str) -> Result<()> { + let content = std::fs::read_to_string(path) + .map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))?; + + let mut config: SandcageConfig = if content.trim().is_empty() { + SandcageConfig::default() + } else { + serde_yaml::from_str(&content) + .map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))? + }; + + let mounts = config.mounts.get_or_insert_with(Vec::new); + mounts.push(mount.to_string()); + + let yaml = serde_yaml::to_string(&config) + .map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))?; + + let output = rehydrate_yaml_comments(&content, &yaml); + + std::fs::write(path, output) + .map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?; + + Ok(()) +} + +fn rehydrate_yaml_comments(original: &str, generated: &str) -> String { + use std::collections::HashMap; + + let mut key_comments: Vec<(Option, Vec)> = Vec::new(); + let mut pending: Vec = Vec::new(); + + for line in original.lines() { + let trimmed = line.trim(); + let is_blank = trimmed.is_empty(); + let is_top_comment = + trimmed.starts_with('#') && !line.starts_with(' ') && !line.starts_with('\t'); + let is_top_level_key = !is_blank + && !is_top_comment + && !line.starts_with(' ') + && !line.starts_with('\t') + && !line.starts_with('-') + && line.contains(':'); + + if is_top_level_key { + let key = line.split(':').next().unwrap().trim().to_string(); + key_comments.push((Some(key), std::mem::take(&mut pending))); + } else if is_blank || is_top_comment { + pending.push(line.to_string()); + } + } + if !pending.is_empty() { + key_comments.push((None, pending)); + } + + let comment_map: HashMap<&str, &[String]> = key_comments + .iter() + .filter_map(|(k, v)| k.as_deref().map(|k| (k, v.as_slice()))) + .collect(); + + let preamble = key_comments + .first() + .and_then(|(k, v)| if k.is_some() { Some(v.as_slice()) } else { None }); + + let trailer = key_comments + .last() + .and_then(|(k, v)| if k.is_none() { Some(v.as_slice()) } else { None }); + + let mut result = String::new(); + let mut first_key = true; + + for line in generated.lines() { + let trimmed = line.trim(); + let is_top_level_key = !trimmed.is_empty() + && !line.starts_with(' ') + && !line.starts_with('\t') + && !line.starts_with('-') + && line.contains(':'); + + if is_top_level_key { + let key = line.split(':').next().unwrap().trim(); + if first_key { + if let Some(pre) = preamble { + for c in pre { + result.push_str(c); + result.push('\n'); + } + } + first_key = false; + } else if let Some(comments) = comment_map.get(key) { + for c in *comments { + result.push_str(c); + result.push('\n'); + } + } + } + result.push_str(line); + result.push('\n'); + } + + if let Some(trail) = trailer { + for c in trail { + result.push_str(c); + result.push('\n'); + } + } + + result +} + +pub fn add_mount_to_toml(path: &Path, mount: &str) -> Result<()> { + let content = if path.exists() { + std::fs::read_to_string(path) + .map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))? + } else { + String::new() + }; + + let mut doc: toml_edit::DocumentMut = content + .parse() + .map_err(|e: toml_edit::TomlError| { + SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()) + })?; + + let mounts = doc.entry("mounts").or_insert_with(|| { + toml_edit::Item::Value(toml_edit::Value::Array(toml_edit::Array::new())) + }); + + if let Some(arr) = mounts.as_array_mut() { + arr.push(mount); + } + + std::fs::write(path, doc.to_string()) + .map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?; + + Ok(()) +} + +pub fn extract_git_remote_hosts(git_remote_output: &str) -> Vec { + use std::collections::BTreeSet; + + let mut hosts: BTreeSet = BTreeSet::new(); + + for line in git_remote_output.lines() { + // Each line: "\t (fetch)" or "\t (push)" + let url = match line.split('\t').nth(1) { + Some(u) => u, + None => continue, + }; + // Strip trailing " (fetch)" / " (push)" + let url = url + .trim_end_matches(" (push)") + .trim_end_matches(" (fetch)") + .trim(); + + if url.starts_with("https://") || url.starts_with("http://") { + continue; + } + + if let Some(after_scheme) = url.strip_prefix("ssh://") { + // ssh://[user@]host[:port]/path + // Drop optional user@ + let host_part = match after_scheme.find('@') { + Some(at) => &after_scheme[at + 1..], + None => after_scheme, + }; + // host is everything before the first '/' or ':' + let host = host_part + .split(&['/', ':'][..]) + .next() + .unwrap_or("") + .trim(); + if !host.is_empty() { + hosts.insert(host.to_string()); + } + } else if url.contains('@') && url.contains(':') { + // user@host:path format (e.g. git@github.com:user/repo.git) + let after_at = match url.find('@') { + Some(at) => &url[at + 1..], + None => continue, + }; + let host = match after_at.find(':') { + Some(colon) => &after_at[..colon], + None => continue, + }; + let host = host.trim(); + if !host.is_empty() { + hosts.insert(host.to_string()); + } + } else if url.contains(':') && !url.contains('/') || url.find(':') < url.find('/') { + // SCP-style without user: host:path (e.g. git.g4b.org:user/repo.git) + if let Some(colon) = url.find(':') { + let host = url[..colon].trim(); + if !host.is_empty() && !host.contains(' ') { + hosts.insert(host.to_string()); + } + } + } + } + + hosts.into_iter().collect() +} + +pub fn discover_git_remote_hosts() -> Vec { + let output = std::process::Command::new("git") + .args(["remote", "-v"]) + .output(); + + match output { + Ok(out) if out.status.success() => { + let text = String::from_utf8_lossy(&out.stdout); + extract_git_remote_hosts(&text) + } + _ => Vec::new(), + } +} + +fn display_scan_result(result: &SshScanResult) { + if !result.keys.is_empty() { + eprintln!(" Keys: {}", result.keys.join(", ")); + } + if !result.config_files.is_empty() { + eprintln!(" Config: {}", result.config_files.join(", ")); + } + if result.other_count > 0 { + eprintln!( + " Other: {} other file{}", + result.other_count, + if result.other_count == 1 { "" } else { "s" } + ); + } + if result.keys.is_empty() && result.config_files.is_empty() && result.other_count == 0 { + eprintln!(" (empty)"); + } + eprintln!(); + eprintln!("This will mount ~/.ssh into containers as read-only."); +} + +pub fn has_legacy_ssh_mount(config: &SandcageConfig) -> bool { + config.ssh_mode.is_none() && check_existing_mount(config, SSH_MOUNT) +} + +pub fn write_ssh_config_to_yaml(path: &Path, entries: &[SshDiscoveryEntry]) -> Result<()> { + let content = if path.exists() { + std::fs::read_to_string(path) + .map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))? + } else { + String::new() + }; + + let mut config: SandcageConfig = if content.trim().is_empty() { + SandcageConfig::default() + } else { + serde_yaml::from_str(&content) + .map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))? + }; + + config.ssh_mode = Some("volume".to_string()); + config.ssh_keys = Some( + entries + .iter() + .map(|e| SshKeyEntry { + host: e + .ssh_host_alias + .clone() + .unwrap_or_else(|| e.display_host.clone()), + identity_file: e.identity_file.clone().unwrap_or_default(), + }) + .collect(), + ); + + // Remove old bind mount if present + if let Some(ref mut mounts) = config.mounts { + mounts.retain(|m| m != SSH_MOUNT); + if mounts.is_empty() { + config.mounts = None; + } + } + + let yaml = serde_yaml::to_string(&config) + .map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))?; + + let output = rehydrate_yaml_comments(&content, &yaml); + + std::fs::write(path, output) + .map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?; + + Ok(()) +} + +pub fn write_ssh_config_to_toml(path: &Path, entries: &[SshDiscoveryEntry]) -> Result<()> { + let content = if path.exists() { + std::fs::read_to_string(path) + .map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))? + } else { + String::new() + }; + + let mut doc: toml_edit::DocumentMut = content.parse().map_err(|e: toml_edit::TomlError| { + SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()) + })?; + + doc["ssh_mode"] = toml_edit::value("volume"); + + // Remove old bind mount if present + if let Some(mounts) = doc.get_mut("mounts").and_then(|m| m.as_array_mut()) { + mounts.retain(|v| v.as_str() != Some(SSH_MOUNT)); + if mounts.is_empty() { + doc.remove("mounts"); + } + } + + // Build ssh_keys array of tables + let mut arr = toml_edit::ArrayOfTables::new(); + for entry in entries { + let mut table = toml_edit::Table::new(); + table["host"] = toml_edit::value( + entry + .ssh_host_alias + .clone() + .unwrap_or_else(|| entry.display_host.clone()), + ); + table["identity_file"] = + toml_edit::value(entry.identity_file.clone().unwrap_or_default()); + arr.push(table); + } + doc.insert("ssh_keys", toml_edit::Item::ArrayOfTables(arr)); + + std::fs::write(path, doc.to_string()) + .map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?; + + Ok(()) +} + +pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Result<()> { + if bind { + return run_ssh_setup_bind(global, yes); + } + + let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?; + let ssh_dir = home.join(".ssh"); + + if refresh { + return run_ssh_refresh(global, &ssh_dir); + } + + // Check for existing bind mount (migration path) + let config_path_for_check = if global { + home.join(".sandcage").join("config.toml") + } else { + let cwd = std::env::current_dir() + .map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; + cwd.join(".sandcage").join("sandcage.yml") + }; + + if config_path_for_check.exists() { + let existing_cfg = if global { + let figment = figment::Figment::from( + figment::providers::Serialized::defaults(SandcageConfig::default()), + ) + .merge(figment::providers::Toml::file(&config_path_for_check)); + figment.extract::().unwrap_or_default() + } else { + crate::config::load(&config_path_for_check).unwrap_or_default() + }; + + if has_legacy_ssh_mount(&existing_cfg) { + eprintln!("sandcage: existing SSH bind mount detected in config"); + if !yes { + let migrate = dialoguer::Confirm::new() + .with_prompt("Migrate to volume-based SSH? (bind mount will be removed)") + .default(true) + .interact() + .unwrap_or(false); + if !migrate { + // Set explicit bind mode so we don't ask again + if global { + let mut doc: toml_edit::DocumentMut = + std::fs::read_to_string(&config_path_for_check) + .unwrap_or_default() + .parse() + .unwrap_or_default(); + doc["ssh_mode"] = toml_edit::value("bind"); + std::fs::write(&config_path_for_check, doc.to_string()).map_err( + |e| SetupError::ConfigWriteFailed(config_path_for_check, e), + )?; + } else { + let content = std::fs::read_to_string(&config_path_for_check) + .map_err(|e| { + SetupError::ConfigReadFailed(config_path_for_check.clone(), e) + })?; + let mut cfg: SandcageConfig = + serde_yaml::from_str(&content).unwrap_or_default(); + cfg.ssh_mode = Some("bind".to_string()); + let yaml = serde_yaml::to_string(&cfg).unwrap_or_default(); + let output = rehydrate_yaml_comments(&content, &yaml); + std::fs::write(&config_path_for_check, output).map_err(|e| { + SetupError::ConfigWriteFailed(config_path_for_check, e) + })?; + } + eprintln!("sandcage: keeping bind mount, set ssh_mode: bind"); + return Ok(()); + } + } + // Continue with volume mode setup — the write_ssh_config_to_* functions + // will remove the old mount entry + } + } + + // Discover candidates + let ssh_config_path = ssh_dir.join("config"); + let ssh_blocks = if ssh_config_path.exists() { + let content = std::fs::read_to_string(&ssh_config_path) + .map_err(|e| SetupError::ConfigReadFailed(ssh_config_path.clone(), e))?; + let ssh_dir_clone = ssh_dir.clone(); + let read_include = move |path: &str| { + let include_path = if Path::new(path).is_absolute() { + PathBuf::from(path) + } else { + ssh_dir_clone.join(path) + }; + std::fs::read_to_string(include_path).ok() + }; + ssh_config::parse_ssh_config_with_includes(&content, &read_include) + } else { + Vec::new() + }; + + let git_remote_hosts = discover_git_remote_hosts(); + let entries = build_ssh_discovery(&ssh_blocks, &git_remote_hosts, &ssh_dir); + + if entries.is_empty() { + eprintln!("sandcage: no git SSH hosts found in remotes or ~/.ssh/config"); + return Ok(()); + } + + // Display discoveries + eprintln!("sandcage: scanning git remotes and SSH config...\n"); + for entry in &entries { + let source_label = match entry.source { + SshDiscoverySource::Both => "git remote + ssh config", + SshDiscoverySource::GitRemote => "git remote", + SshDiscoverySource::SshConfig => "ssh config (User git)", + }; + let key_label = entry.identity_file.as_deref().unwrap_or("(default key)"); + let alias_label = entry + .ssh_host_alias + .as_deref() + .filter(|a| *a != entry.display_host) + .map(|a| format!(" (alias: {a})")) + .unwrap_or_default(); + eprintln!( + " {}{alias_label} → {key_label} [{source_label}]", + entry.display_host + ); + } + eprintln!(); + + // Interactive selection + let (selected_entries, include_known_hosts) = if yes { + (entries.clone(), true) + } else { + let items: Vec = entries + .iter() + .map(|e| { + let key = e.identity_file.as_deref().unwrap_or("(default)"); + format!("{} → {key}", e.display_host) + }) + .collect(); + let defaults: Vec = vec![true; items.len()]; + let selected_indices = dialoguer::MultiSelect::new() + .with_prompt("Select SSH keys to copy") + .items(&items) + .defaults(&defaults) + .interact() + .unwrap_or_else(|_| (0..items.len()).collect()); + let selected: Vec = selected_indices + .iter() + .map(|&i| entries[i].clone()) + .collect(); + let include_kh = dialoguer::Confirm::new() + .with_prompt("Include known_hosts? (recommended)") + .default(true) + .interact() + .unwrap_or(true); + (selected, include_kh) + }; + + if selected_entries.is_empty() { + eprintln!("sandcage: no keys selected, nothing to do"); + return Ok(()); + } + + // Determine config target + let (config_path, config_label, is_global) = if global { + (home.join(".sandcage").join("config.toml"), "~/.sandcage/config.toml", true) + } else { + let cwd = std::env::current_dir() + .map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; + (cwd.join(".sandcage").join("local").join("sandcage.local.yml"), ".sandcage/local/sandcage.local.yml", false) + }; + + // Write config + if is_global { + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| SetupError::ConfigWriteFailed(config_path.clone(), e))?; + } + write_ssh_config_to_toml(&config_path, &selected_entries)?; + } else { + write_ssh_config_to_yaml(&config_path, &selected_entries)?; + } + + // Populate volume + eprintln!("sandcage: populating SSH volume..."); + populate_ssh_volume(&selected_entries, &ssh_blocks, include_known_hosts)?; + + eprintln!("sandcage: SSH keys copied to volume 'sandcage-ssh'"); + eprintln!("sandcage: configuration saved to {config_label}"); + + Ok(()) +} + +fn run_ssh_refresh(global: bool, ssh_dir: &Path) -> Result<()> { + let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?; + + let config_path = if global { + home.join(".sandcage").join("config.toml") + } else { + let cwd = std::env::current_dir() + .map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; + cwd.join(".sandcage").join("local").join("sandcage.local.yml") + }; + + if !config_path.exists() { + return Err(SetupError::ConfigReadFailed( + config_path, + std::io::Error::new(std::io::ErrorKind::NotFound, "config file not found"), + )); + } + + let cfg = if global { + let figment = figment::Figment::from( + figment::providers::Serialized::defaults(SandcageConfig::default()), + ) + .merge(figment::providers::Toml::file(&config_path)); + figment + .extract::() + .map_err(|e| SetupError::ConfigParseFailed(config_path.clone(), e.to_string()))? + } else { + crate::config::load(&config_path) + .map_err(|e| SetupError::ConfigParseFailed(config_path.clone(), e.to_string()))? + }; + + let ssh_keys = cfg.ssh_keys.unwrap_or_default(); + if ssh_keys.is_empty() { + eprintln!("sandcage: no ssh_keys in config — run `sandcage setup ssh` first"); + return Ok(()); + } + + // Rebuild entries from saved config + let entries: Vec = ssh_keys + .iter() + .map(|k| SshDiscoveryEntry { + display_host: k.host.clone(), + ssh_host_alias: Some(k.host.clone()), + identity_file: Some(k.identity_file.clone()), + host_block: None, + source: SshDiscoverySource::SshConfig, + }) + .collect(); + + // Re-read SSH config to get full Host blocks for config synthesis + let ssh_config_path = ssh_dir.join("config"); + let ssh_blocks = if ssh_config_path.exists() { + let content = std::fs::read_to_string(&ssh_config_path) + .map_err(|e| SetupError::ConfigReadFailed(ssh_config_path.clone(), e))?; + ssh_config::parse_ssh_config(&content) + } else { + Vec::new() + }; + + // Rebuild entries with host blocks from current SSH config + let entries_with_blocks: Vec = entries + .into_iter() + .map(|mut e| { + e.host_block = ssh_blocks + .iter() + .find(|b| { + b.aliases + .first() + .map(|a| a.as_str()) + == Some(&e.display_host) + }) + .cloned(); + e + }) + .collect(); + + eprintln!("sandcage: refreshing SSH volume..."); + populate_ssh_volume(&entries_with_blocks, &ssh_blocks, true)?; + eprintln!("sandcage: SSH volume refreshed"); + + Ok(()) +} + +fn run_ssh_setup_bind(global: bool, yes: bool) -> Result<()> { + let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?; + let ssh_dir = home.join(".ssh"); + + let result = scan_ssh_dir(&ssh_dir)?; + + eprintln!("sandcage: found SSH directory at ~/.ssh"); + eprintln!(); + display_scan_result(&result); + + let (config_path, config_label) = if global { + ( + home.join(".sandcage").join("config.toml"), + "~/.sandcage/config.toml".to_string(), + ) + } else { + let cwd = + std::env::current_dir().map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; + let project_config = cwd.join(".sandcage").join("sandcage.yml"); + if !project_config.exists() { + return Err(SetupError::NoProjectConfig); + } + (project_config, ".sandcage/sandcage.yml".to_string()) + }; + + // Check if already configured + if config_path.exists() { + let existing = if global { + let content = std::fs::read_to_string(&config_path) + .map_err(|e| SetupError::ConfigReadFailed(config_path.clone(), e))?; + let doc: toml_edit::DocumentMut = content + .parse::() + .map_err(|e: toml_edit::TomlError| { + SetupError::ConfigParseFailed(config_path.clone(), e.to_string()) + })?; + doc.get("mounts") + .and_then(|m: &toml_edit::Item| m.as_array()) + .is_some_and(|arr: &toml_edit::Array| { + arr.iter() + .any(|v: &toml_edit::Value| v.as_str() == Some(SSH_MOUNT)) + }) + } else { + let cfg = crate::config::load(&config_path) + .map_err(|e| SetupError::ConfigParseFailed(config_path.clone(), e.to_string()))?; + check_existing_mount(&cfg, SSH_MOUNT) + }; + + if existing { + eprintln!("sandcage: SSH mount already configured in {config_label}"); + return Ok(()); + } + } + + // Confirm + if !yes { + let confirmed = dialoguer::Confirm::new() + .with_prompt(format!("Add SSH mount to {config_label}?")) + .default(false) + .interact() + .unwrap_or(false); + + if !confirmed { + return Ok(()); + } + } + + // Write + if global { + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| SetupError::ConfigWriteFailed(config_path.clone(), e))?; + } + add_mount_to_toml(&config_path, SSH_MOUNT)?; + } else { + add_mount_to_yaml(&config_path, SSH_MOUNT)?; + } + + eprintln!("sandcage: added SSH mount to {config_label}"); + Ok(()) +} + +// ── Task 7: SSH host discovery ────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq)] +pub enum SshDiscoverySource { + GitRemote, + SshConfig, + Both, +} + +#[derive(Debug, Clone)] +pub struct SshDiscoveryEntry { + pub display_host: String, + pub ssh_host_alias: Option, + pub identity_file: Option, + pub host_block: Option, + pub source: SshDiscoverySource, +} + +/// Find a default SSH private key in `ssh_dir` by checking common names. +fn find_default_key(ssh_dir: &Path) -> Option { + let candidates = ["id_ed25519", "id_ecdsa", "id_rsa"]; + for name in candidates { + if ssh_dir.join(name).exists() { + return Some(format!("~/.ssh/{name}")); + } + } + None +} + +/// Merge SSH host candidates from SSH config blocks and git remote hosts. +/// +/// Returns a deduplicated, sorted list of [`SshDiscoveryEntry`] values. +pub fn build_ssh_discovery( + ssh_blocks: &[SshHostBlock], + git_remote_hosts: &[String], + ssh_dir: &Path, +) -> Vec { + // Keyed by Host alias (not resolved HostName) to preserve distinct blocks + // that point to the same server with different keys. + let mut map: BTreeMap = BTreeMap::new(); + + // 1. Add SSH config blocks where user is git. + for block in ssh_blocks { + if !block.is_git_user() { + continue; + } + let alias = match block.aliases.first() { + Some(a) => a.clone(), + None => continue, + }; + let display_host = block + .hostname() + .unwrap_or(alias.as_str()) + .to_string(); + let identity_file = block.identity_file().map(|s| s.to_string()); + map.entry(alias.clone()).or_insert_with(|| SshDiscoveryEntry { + display_host, + ssh_host_alias: Some(alias), + identity_file, + host_block: Some(block.clone()), + source: SshDiscoverySource::SshConfig, + }); + } + + // 2. Process git remote hosts. + for git_host in git_remote_hosts { + // Check if this hostname matches any existing entry (by alias or display_host). + let existing_key = map.iter() + .find(|(_, e)| e.display_host == *git_host || e.ssh_host_alias.as_deref() == Some(git_host)) + .map(|(k, _)| k.clone()); + + if let Some(key) = existing_key { + map.get_mut(&key).unwrap().source = SshDiscoverySource::Both; + } else { + // Check if any SSH block has this git_host as an alias. + let alias_match = ssh_blocks.iter().find(|b| { + b.is_git_user() && b.aliases.iter().any(|a| a == git_host) + }); + + if let Some(block) = alias_match { + let alias = block.aliases.first().cloned().unwrap_or_default(); + if let Some(existing) = map.get_mut(&alias) { + existing.source = SshDiscoverySource::Both; + } + } else { + // No SSH config match — pure git remote entry. + let identity_file = find_default_key(ssh_dir); + map.insert(git_host.clone(), SshDiscoveryEntry { + display_host: git_host.clone(), + ssh_host_alias: None, + identity_file, + host_block: None, + source: SshDiscoverySource::GitRemote, + }); + } + } + } + + map.into_values().collect() +} + +// ── Task 8: Docker volume population ─────────────────────────────────────── + +/// Copy selected SSH identity files and (optionally) `known_hosts` into the +/// `sandcage-ssh` Docker volume via a temporary container. +pub fn populate_ssh_volume( + entries: &[SshDiscoveryEntry], + _ssh_blocks: &[SshHostBlock], + include_known_hosts: bool, +) -> Result<()> { + let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?; + let ssh_dir = home.join(".ssh"); + + // Build synthesized SSH config from selected entries' host blocks. + let selected_blocks: Vec = entries + .iter() + .filter_map(|e| e.host_block.clone()) + .collect(); + let synthesized_config = ssh_config::synthesize_ssh_config(&selected_blocks); + + // Collect key file names to copy. + let mut key_files: Vec = Vec::new(); + for entry in entries { + if let Some(ref id_file) = entry.identity_file { + let resolved = ssh_config::resolve_identity_file(id_file, &ssh_dir); + if let Some(file_name) = resolved.file_name() { + let name = file_name.to_string_lossy().into_owned(); + if !key_files.contains(&name) { + key_files.push(name.clone()); + } + // Include matching .pub key if it exists. + let pub_name = format!("{name}.pub"); + let pub_path = ssh_dir.join(&pub_name); + if pub_path.exists() && !key_files.contains(&pub_name) { + key_files.push(pub_name); + } + } + } + } + + // Optionally include known_hosts. + if include_known_hosts && ssh_dir.join("known_hosts").exists() { + let kh = "known_hosts".to_string(); + if !key_files.contains(&kh) { + key_files.push(kh); + } + } + + // Normalize path for Docker (Windows: backslash → forward slash). + let ssh_dir_str = ssh_dir.to_string_lossy().replace('\\', "/"); + + // Remove old volume. + let rm_status = std::process::Command::new("docker") + .args(["volume", "rm", "-f", "sandcage-ssh"]) + .status() + .map_err(|e| SetupError::DockerFailed(format!("docker volume rm: {e}")))?; + if !rm_status.success() { + return Err(SetupError::DockerFailed( + format!("docker volume rm exited with {rm_status}"), + )); + } + + // Create volume. + let create_status = std::process::Command::new("docker") + .args(["volume", "create", "sandcage-ssh"]) + .status() + .map_err(|e| SetupError::DockerFailed(format!("docker volume create: {e}")))?; + if !create_status.success() { + return Err(SetupError::DockerFailed( + format!("docker volume create exited with {create_status}"), + )); + } + + // Build in-container shell script. + let mut script = String::new(); + + // Ensure target directory exists. + script.push_str("mkdir -p /home/agent/.ssh && "); + + // Write synthesized config. + // Escape single quotes in the config for sh -c '...' embedding. + let escaped_config = synthesized_config.replace('\'', "'\\''"); + script.push_str(&format!( + "printf '%s' '{escaped_config}' > /home/agent/.ssh/config && chmod 644 /home/agent/.ssh/config" + )); + + // Copy key files. + for name in &key_files { + let is_pub_or_kh = name.ends_with(".pub") || name == "known_hosts"; + let mode = if is_pub_or_kh { "644" } else { "600" }; + script.push_str(&format!( + " && cp /host-ssh/{name} /home/agent/.ssh/{name} && chmod {mode} /home/agent/.ssh/{name}" + )); + } + + // Fix ownership. + script.push_str(" && chown -R agent:agent /home/agent/.ssh"); + + // Run the container. + let run_status = std::process::Command::new("docker") + .args([ + "run", + "--rm", + &format!("-v{ssh_dir_str}:/host-ssh:ro"), + "--mount", + "type=volume,source=sandcage-ssh,target=/home/agent/.ssh", + "--user", + "root", + "sandcage:latest", + "sh", + "-c", + &script, + ]) + .status() + .map_err(|e| SetupError::DockerFailed(format!("docker run: {e}")))?; + + if !run_status.success() { + return Err(SetupError::DockerFailed( + format!("docker run exited with {run_status}"), + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::{NamedTempFile, TempDir}; + + fn touch(dir: &Path, name: &str) { + std::fs::write(dir.join(name), "").expect("touch file"); + } + + #[test] + fn scan_finds_keys_by_pub_extension() { + let dir = TempDir::new().unwrap(); + touch(dir.path(), "id_ed25519"); + touch(dir.path(), "id_ed25519.pub"); + touch(dir.path(), "work_github"); + touch(dir.path(), "work_github.pub"); + + let result = scan_ssh_dir(dir.path()).unwrap(); + assert_eq!(result.keys, vec!["id_ed25519", "work_github"]); + } + + #[test] + fn scan_finds_config_files() { + let dir = TempDir::new().unwrap(); + touch(dir.path(), "config"); + touch(dir.path(), "known_hosts"); + touch(dir.path(), "authorized_keys"); + + let result = scan_ssh_dir(dir.path()).unwrap(); + assert_eq!( + result.config_files, + vec!["authorized_keys", "config", "known_hosts"] + ); + assert_eq!(result.keys, Vec::::new()); + } + + #[test] + fn scan_counts_other_files() { + let dir = TempDir::new().unwrap(); + touch(dir.path(), "id_rsa"); + touch(dir.path(), "id_rsa.pub"); + touch(dir.path(), "config"); + touch(dir.path(), "environment"); + touch(dir.path(), "random_file"); + + let result = scan_ssh_dir(dir.path()).unwrap(); + assert_eq!(result.keys, vec!["id_rsa"]); + assert_eq!(result.config_files, vec!["config"]); + assert_eq!(result.other_count, 3); + } + + #[test] + fn scan_empty_dir() { + let dir = TempDir::new().unwrap(); + let result = scan_ssh_dir(dir.path()).unwrap(); + assert_eq!(result.keys, Vec::::new()); + assert_eq!(result.config_files, Vec::::new()); + assert_eq!(result.other_count, 0); + } + + #[test] + fn scan_nonexistent_dir_errors() { + let result = scan_ssh_dir(Path::new("/nonexistent/ssh/dir")); + assert!(matches!(result, Err(SetupError::NoSshDir(_)))); + } + + // -- check_existing_mount tests -- + + #[test] + fn check_existing_mount_found() { + let config = SandcageConfig { + mounts: Some(vec!["~/.ssh:/home/agent/.ssh:ro".to_string()]), + ..Default::default() + }; + assert!(check_existing_mount(&config, "~/.ssh:/home/agent/.ssh:ro")); + } + + #[test] + fn check_existing_mount_not_found() { + let config = SandcageConfig { + mounts: Some(vec!["/data:/mnt:ro".to_string()]), + ..Default::default() + }; + assert!(!check_existing_mount(&config, "~/.ssh:/home/agent/.ssh:ro")); + } + + #[test] + fn check_existing_mount_no_mounts() { + let config = SandcageConfig::default(); + assert!(!check_existing_mount(&config, "~/.ssh:/home/agent/.ssh:ro")); + } + + // -- add_mount_to_yaml tests -- + + #[test] + fn add_mount_to_yaml_with_existing_mounts() { + let yaml = "env:\n FOO: bar\nmounts:\n - /data:/mnt\n"; + let mut tmp = NamedTempFile::new().unwrap(); + write!(tmp, "{yaml}").unwrap(); + + add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap(); + + let content = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(content.contains("~/.ssh:/home/agent/.ssh:ro")); + assert!(content.contains("/data:/mnt")); + assert!(content.contains("FOO")); + } + + #[test] + fn add_mount_to_yaml_no_existing_mounts() { + let yaml = "env:\n FOO: bar\n"; + let mut tmp = NamedTempFile::new().unwrap(); + write!(tmp, "{yaml}").unwrap(); + + add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap(); + + let content = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(content.contains("~/.ssh:/home/agent/.ssh:ro")); + assert!(content.contains("FOO")); + } + + #[test] + fn add_mount_to_yaml_empty_file() { + let mut tmp = NamedTempFile::new().unwrap(); + write!(tmp, "").unwrap(); + + add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap(); + + let content = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(content.contains("~/.ssh:/home/agent/.ssh:ro")); + } + + #[test] + fn add_mount_to_yaml_preserves_comments() { + let yaml = "# Environment\nenv:\n FOO: bar\n\n# Mount points\nmounts:\n- /data:/mnt\n"; + let mut tmp = NamedTempFile::new().unwrap(); + write!(tmp, "{yaml}").unwrap(); + + add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap(); + + let content = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(content.contains("# Environment"), "preamble comment preserved"); + assert!(content.contains("# Mount points"), "inline comment preserved"); + assert!(content.contains("~/.ssh:/home/agent/.ssh:ro")); + assert!(content.contains("/data:/mnt")); + } + + #[test] + fn add_mount_to_yaml_preserves_trailing_comments() { + let yaml = "mounts:\n- /data:/mnt\n\n# end of file\n"; + let mut tmp = NamedTempFile::new().unwrap(); + write!(tmp, "{yaml}").unwrap(); + + add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap(); + + let content = std::fs::read_to_string(tmp.path()).unwrap(); + assert!(content.contains("# end of file"), "trailing comment preserved"); + } + + #[test] + fn rehydrate_preserves_blank_lines_between_keys() { + let original = "env:\n A: 1\n\nmounts:\n- /data:/mnt\n"; + let generated = "env:\n A: '1'\nmounts:\n- /data:/mnt\n- /new:/new\n"; + let result = rehydrate_yaml_comments(original, generated); + assert!(result.contains("\n\nmounts:"), "blank line before mounts preserved"); + } + + // -- add_mount_to_toml tests -- + + #[test] + fn add_mount_to_toml_new_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("config.toml"); + + add_mount_to_toml(&path, "~/.ssh:/home/agent/.ssh:ro").unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("~/.ssh:/home/agent/.ssh:ro")); + } + + #[test] + fn add_mount_to_toml_existing_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "shell = \"zsh\"\n").unwrap(); + + add_mount_to_toml(&path, "~/.ssh:/home/agent/.ssh:ro").unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("~/.ssh:/home/agent/.ssh:ro")); + assert!(content.contains("zsh")); + } + + #[test] + fn add_mount_to_toml_existing_mounts() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "mounts = [\"/data:/mnt\"]\n").unwrap(); + + add_mount_to_toml(&path, "~/.ssh:/home/agent/.ssh:ro").unwrap(); + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("~/.ssh:/home/agent/.ssh:ro")); + assert!(content.contains("/data:/mnt")); + } + + // -- extract_git_remote_hosts tests -- + + #[test] + fn extract_hostnames_from_git_remote_output() { + let output = "\ +origin\tgit@github.com:user/repo.git (fetch) +origin\tgit@github.com:user/repo.git (push) +upstream\tgit@gitea.example.com:org/repo.git (fetch) +upstream\tgit@gitea.example.com:org/repo.git (push) +https\thttps://gitlab.com/user/repo.git (fetch) +https\thttps://gitlab.com/user/repo.git (push) +"; + let hosts = extract_git_remote_hosts(output); + assert_eq!(hosts, vec!["gitea.example.com", "github.com"]); + } + + #[test] + fn extract_hostnames_ssh_url_with_port() { + let output = "origin\tssh://git@git.corp.com:2222/repo.git (fetch)\n"; + let hosts = extract_git_remote_hosts(output); + assert_eq!(hosts, vec!["git.corp.com"]); + } + + #[test] + fn extract_hostnames_empty_output() { + let hosts = extract_git_remote_hosts(""); + assert!(hosts.is_empty()); + } + + #[test] + fn extract_hostnames_scp_without_user() { + let output = "origin\tgit.g4b.org:g4borg/sandcage.git (fetch)\norigin\tgit.g4b.org:g4borg/sandcage.git (push)\n"; + let hosts = extract_git_remote_hosts(output); + assert_eq!(hosts, vec!["git.g4b.org"]); + } + + // -- Task 7: build_ssh_discovery tests -- + + #[test] + fn discover_ssh_entries_merges_sources() { + use crate::ssh_config::parse_ssh_config; + + let ssh_config_text = "\ +Host github.com + User git + IdentityFile ~/.ssh/id_ed25519 + +Host gitea + User git + HostName gitea.example.com + IdentityFile ~/.ssh/work_gitea + +Host myserver + User admin + HostName server.example.com +"; + let blocks = parse_ssh_config(ssh_config_text); + let git_remote_hosts = vec!["github.com".to_string(), "gitlab.com".to_string()]; + let ssh_dir = Path::new("/nonexistent/.ssh"); // default key won't be found + + let entries = build_ssh_discovery(&blocks, &git_remote_hosts, ssh_dir); + + // github.com: from both (git remote + SSH config User git) + // gitea.example.com: from SSH config only (User git, HostName resolves) + // gitlab.com: from git remote only, no SSH config match + // myserver: NOT included (User admin, not in git remotes) + assert_eq!(entries.len(), 3); + + let github = entries.iter().find(|e| e.display_host == "github.com").unwrap(); + assert_eq!(github.source, SshDiscoverySource::Both); + assert_eq!(github.ssh_host_alias.as_deref(), Some("github.com")); + + let gitea = entries.iter().find(|e| e.display_host == "gitea.example.com").unwrap(); + assert_eq!(gitea.source, SshDiscoverySource::SshConfig); + assert_eq!(gitea.ssh_host_alias.as_deref(), Some("gitea")); + + let gitlab = entries.iter().find(|e| e.display_host == "gitlab.com").unwrap(); + assert_eq!(gitlab.source, SshDiscoverySource::GitRemote); + assert!(gitlab.ssh_host_alias.is_none()); + } + + #[test] + fn discover_preserves_distinct_aliases_for_same_hostname() { + use crate::ssh_config::parse_ssh_config; + let ssh_config_text = "\ +Host sparkle.g4b.org + Hostname git.g4b.org + User git + IdentityFile ~/.ssh/id_rsa_sparkle + +Host git.g4b.org + User git + IdentityFile ~/.ssh/id_rsa +"; + let blocks = parse_ssh_config(ssh_config_text); + let git_remote_hosts = vec!["git.g4b.org".to_string()]; + let ssh_dir = Path::new("/nonexistent/.ssh"); + + let entries = build_ssh_discovery(&blocks, &git_remote_hosts, ssh_dir); + + assert_eq!(entries.len(), 2, "both aliases should be preserved"); + + let sparkle = entries.iter().find(|e| e.ssh_host_alias.as_deref() == Some("sparkle.g4b.org")).unwrap(); + assert_eq!(sparkle.display_host, "git.g4b.org"); + assert_eq!(sparkle.identity_file.as_deref(), Some("~/.ssh/id_rsa_sparkle")); + + let direct = entries.iter().find(|e| e.ssh_host_alias.as_deref() == Some("git.g4b.org")).unwrap(); + assert_eq!(direct.display_host, "git.g4b.org"); + assert_eq!(direct.identity_file.as_deref(), Some("~/.ssh/id_rsa")); + assert_eq!(direct.source, SshDiscoverySource::Both); + } + + // -- has_legacy_ssh_mount tests -- + + #[test] + fn detect_existing_bind_mount_in_config() { + let config = SandcageConfig { + mounts: Some(vec![SSH_MOUNT.to_string()]), + ssh_mode: None, + ..Default::default() + }; + assert!(has_legacy_ssh_mount(&config)); + } + + #[test] + fn no_legacy_mount_when_ssh_mode_set() { + let config = SandcageConfig { + mounts: Some(vec![SSH_MOUNT.to_string()]), + ssh_mode: Some("bind".to_string()), + ..Default::default() + }; + assert!(!has_legacy_ssh_mount(&config)); + } +} diff --git a/crates/sandcage/src/shadow.rs b/crates/sandcage/src/shadow.rs new file mode 100644 index 0000000..b19f49e --- /dev/null +++ b/crates/sandcage/src/shadow.rs @@ -0,0 +1,314 @@ +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use crate::config::ShadowConfig; +use crate::recipe::Recipe; + +#[derive(Debug, Clone, PartialEq)] +pub enum ShadowBackend { + Volume, + Directory, + Tmpfs, +} + +impl ShadowBackend { + fn parse(s: &str) -> ShadowBackend { + match s { + "directory" => ShadowBackend::Directory, + "tmpfs" => ShadowBackend::Tmpfs, + _ => ShadowBackend::Volume, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ShadowMount { + pub dir: String, + pub container_path: String, + pub backend: ShadowBackend, + pub volume_name: Option, + pub host_path: Option, +} + +/// Flatten enabled recipes into `(dir, shared)` shadow pairs. +pub fn recipe_shadow_dirs(recipes: &[Recipe]) -> Vec<(String, bool)> { + let mut out = Vec::new(); + for r in recipes { + for d in &r.shadow { + out.push((d.clone(), false)); + } + for d in &r.shared_caches { + out.push((d.clone(), true)); + } + } + out +} + +/// Resolve effective shadow mounts from explicit `[shadow]` config + recipe dirs. +/// Explicit `[shadow.dirs]` overrides a recipe dir of the same name. Default backend = volume. +/// `project_root` is the config root (where `.sandcage/` lives). +pub fn resolve_shadow_mounts( + shadow: Option<&ShadowConfig>, + recipe_dirs: &[(String, bool)], + project_root: &Path, + container_dir: &str, +) -> Vec { + let default_backend = shadow + .and_then(|s| s.backend.as_deref()) + .map(ShadowBackend::parse) + .unwrap_or(ShadowBackend::Volume); + + // dir → (explicit backend override, shared). BTreeMap = deterministic order. + let mut merged: std::collections::BTreeMap, bool)> = + std::collections::BTreeMap::new(); + for (dir, shared) in recipe_dirs { + merged.insert(dir.clone(), (None, *shared)); + } + if let Some(cfg) = shadow { + if let Some(dirs) = &cfg.dirs { + for (dir, spec) in dirs { + merged.insert(dir.clone(), (spec.backend.as_deref().map(ShadowBackend::parse), spec.shared)); + } + } + } + + merged + .into_iter() + .map(|(dir, (backend_override, shared))| { + let backend = backend_override.unwrap_or_else(|| default_backend.clone()); + let container_path = format!("{}/{}", container_dir.trim_end_matches('/'), dir); + let (vol_name, host_path) = match backend { + ShadowBackend::Volume => (Some(volume_name(project_root, &dir, shared)), None), + ShadowBackend::Directory => ( + None, + Some(project_root.join(".sandcage").join("shadow").join(normalize_dir(&dir))), + ), + ShadowBackend::Tmpfs => (None, None), + }; + ShadowMount { dir, container_path, backend, volume_name: vol_name, host_path } + }) + .collect() +} + +/// Normalize a shadow dir name into Docker's allowed volume-name charset. +/// Leading dots stripped; non-`[a-zA-Z0-9_]` chars become `-`. +pub fn normalize_dir(dir: &str) -> String { + let trimmed = dir.trim_start_matches('.'); + let mut out = String::with_capacity(trimmed.len()); + let mut prev_dash = false; + for ch in trimmed.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' { + out.push(ch); + prev_dash = false; + } else if !prev_dash { + out.push('-'); + prev_dash = true; + } + } + out.trim_matches('-').to_string() +} + +/// Human-legible project slug: basename, lowercased, sanitized. +pub fn project_slug(project_root: &Path) -> String { + let base = project_root + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "project".to_string()); + normalize_dir(&base.to_lowercase()) +} + +/// Short (8 hex) hash of the absolute project path — uniqueness across same-named projects. +pub fn project_hash(project_root: &Path) -> String { + let abs = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf()); + let mut hasher = Sha256::new(); + hasher.update(abs.to_string_lossy().as_bytes()); + let digest = hasher.finalize(); + hex::encode(&digest[..4]) +} + +/// Docker volume name for a shadow dir. +pub fn volume_name(project_root: &Path, dir: &str, shared: bool) -> String { + let d = normalize_dir(dir); + if shared { + format!("sandcage-shared-{d}") + } else { + format!("sandcage-{}-{}-{}", project_slug(project_root), project_hash(project_root), d) + } +} + +/// Bind / `-v` strings for `volume` (named) and `directory` (host bind) mounts. +/// A `name:path` source with no leading `/` is treated by Docker as a named volume +/// (auto-created); a host path is a bind. Tmpfs mounts are excluded. +pub fn shadow_bind_strings(mounts: &[ShadowMount]) -> Vec { + mounts + .iter() + .filter_map(|m| match m.backend { + ShadowBackend::Volume => { + Some(format!("{}:{}", m.volume_name.as_deref().unwrap(), m.container_path)) + } + ShadowBackend::Directory => { + Some(format!("{}:{}:rw", m.host_path.as_ref().unwrap().display(), m.container_path)) + } + ShadowBackend::Tmpfs => None, + }) + .collect() +} + +/// Container paths for `tmpfs`-backend mounts. +pub fn shadow_tmpfs_paths(mounts: &[ShadowMount]) -> Vec { + mounts + .iter() + .filter(|m| matches!(m.backend, ShadowBackend::Tmpfs)) + .map(|m| m.container_path.clone()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + use crate::config::{ShadowConfig, ShadowDir}; + use crate::recipe::{Recipe, FragmentSource}; + use std::collections::HashMap; + + fn shadow_cfg(backend: Option<&str>, dirs: &[(&str, Option<&str>, bool)]) -> ShadowConfig { + let mut map = HashMap::new(); + for (name, b, shared) in dirs { + map.insert(name.to_string(), ShadowDir { backend: b.map(|s| s.to_string()), shared: *shared }); + } + ShadowConfig { backend: backend.map(|s| s.to_string()), dirs: if map.is_empty() { None } else { Some(map) } } + } + + #[test] + fn resolves_default_backend_volume() { + let root = Path::new("/home/u/projects/app"); + let cfg = shadow_cfg(None, &[("target", None, false)]); + let mounts = resolve_shadow_mounts(Some(&cfg), &[], root, "/workspace/app"); + assert_eq!(mounts.len(), 1); + assert_eq!(mounts[0].container_path, "/workspace/app/target"); + assert!(matches!(mounts[0].backend, ShadowBackend::Volume)); + assert_eq!(mounts[0].volume_name.as_deref().unwrap(), volume_name(root, "target", false)); + } + + #[test] + fn resolves_per_dir_directory_backend() { + let root = Path::new("/home/u/projects/app"); + let cfg = shadow_cfg(Some("volume"), &[(".venv", Some("directory"), false)]); + let mounts = resolve_shadow_mounts(Some(&cfg), &[], root, "/workspace/app"); + assert!(matches!(mounts[0].backend, ShadowBackend::Directory)); + assert_eq!( + mounts[0].host_path.as_deref().unwrap(), + Path::new("/home/u/projects/app/.sandcage/shadow/venv") + ); + } + + #[test] + fn resolves_tmpfs_backend() { + let root = Path::new("/home/u/projects/app"); + let cfg = shadow_cfg(Some("tmpfs"), &[("target", None, false)]); + let mounts = resolve_shadow_mounts(Some(&cfg), &[], root, "/workspace/app"); + assert!(matches!(mounts[0].backend, ShadowBackend::Tmpfs)); + assert!(mounts[0].volume_name.is_none() && mounts[0].host_path.is_none()); + } + + #[test] + fn shared_dir_uses_shared_volume_name() { + let root = Path::new("/home/u/projects/app"); + let cfg = shadow_cfg(None, &[(".cargo/registry", None, true)]); + let mounts = resolve_shadow_mounts(Some(&cfg), &[], root, "/workspace/app"); + assert_eq!(mounts[0].volume_name.as_deref().unwrap(), "sandcage-shared-cargo-registry"); + } + + #[test] + fn recipe_dirs_flatten_and_resolve() { + let root = Path::new("/home/u/projects/app"); + let recipes = vec![Recipe { + name: "rust".into(), + fragment: FragmentSource::Embedded("FROM rust"), + shadow: vec!["target".into()], + shared_caches: vec![".cargo/registry".into()], + }]; + let pairs = recipe_shadow_dirs(&recipes); + assert!(pairs.contains(&("target".to_string(), false))); + assert!(pairs.contains(&(".cargo/registry".to_string(), true))); + let mounts = resolve_shadow_mounts(None, &pairs, root, "/workspace/app"); + assert_eq!(mounts.len(), 2); + } + + #[test] + fn explicit_overrides_recipe_dir_backend() { + let root = Path::new("/home/u/projects/app"); + let recipe_dirs = vec![("target".to_string(), false)]; + let cfg = shadow_cfg(None, &[("target", Some("directory"), false)]); + let mounts = resolve_shadow_mounts(Some(&cfg), &recipe_dirs, root, "/workspace/app"); + assert_eq!(mounts.len(), 1, "same dir name de-duplicated"); + assert!(matches!(mounts[0].backend, ShadowBackend::Directory)); + } + + #[test] + fn no_config_no_recipes_yields_empty() { + let mounts = resolve_shadow_mounts(None, &[], Path::new("/x"), "/workspace/x"); + assert!(mounts.is_empty()); + } + + fn sample_mounts() -> Vec { + vec![ + ShadowMount { dir: "target".into(), container_path: "/workspace/app/target".into(), + backend: ShadowBackend::Volume, volume_name: Some("sandcage-app-dead-target".into()), host_path: None }, + ShadowMount { dir: ".venv".into(), container_path: "/workspace/app/.venv".into(), + backend: ShadowBackend::Directory, volume_name: None, + host_path: Some(Path::new("/home/u/projects/app/.sandcage/shadow/venv").to_path_buf()) }, + ShadowMount { dir: "cache".into(), container_path: "/workspace/app/cache".into(), + backend: ShadowBackend::Tmpfs, volume_name: None, host_path: None }, + ] + } + + #[test] + fn bind_strings_cover_volume_and_directory_only() { + let binds = shadow_bind_strings(&sample_mounts()); + assert_eq!(binds.len(), 2); + assert!(binds.contains(&"sandcage-app-dead-target:/workspace/app/target".to_string())); + assert!(binds.contains(&"/home/u/projects/app/.sandcage/shadow/venv:/workspace/app/.venv:rw".to_string())); + } + + #[test] + fn tmpfs_paths_cover_tmpfs_only() { + let paths = shadow_tmpfs_paths(&sample_mounts()); + assert_eq!(paths, vec!["/workspace/app/cache".to_string()]); + } + + #[test] + fn normalize_dir_sanitizes_path_separators_and_dots() { + assert_eq!(normalize_dir(".venv"), "venv"); + assert_eq!(normalize_dir("target"), "target"); + assert_eq!(normalize_dir(".cargo/registry"), "cargo-registry"); + assert_eq!(normalize_dir("node_modules"), "node_modules"); + } + + #[test] + fn project_slug_is_basename_sanitized() { + assert_eq!(project_slug(Path::new("/home/u/projects/My-App")), "my-app"); + assert_eq!(project_slug(Path::new("/home/u/projects/api.service")), "api-service"); + } + + #[test] + fn project_hash_is_stable_and_path_specific() { + let a = project_hash(Path::new("/home/u/projects/api")); + let b = project_hash(Path::new("/home/u/projects/api")); + let c = project_hash(Path::new("/home/u/other/api")); + assert_eq!(a, b); + assert_ne!(a, c); + assert_eq!(a.len(), 8); + } + + #[test] + fn volume_name_per_project_and_shared() { + let root = Path::new("/home/u/projects/my-app"); + let per = volume_name(root, "target", false); + assert!(per.starts_with("sandcage-my-app-"), "got {per}"); + assert!(per.ends_with("-target"), "got {per}"); + assert_eq!(volume_name(root, ".cargo/registry", true), "sandcage-shared-cargo-registry"); + } +} diff --git a/crates/sandcage/src/spoof.rs b/crates/sandcage/src/spoof.rs new file mode 100644 index 0000000..0c62edb --- /dev/null +++ b/crates/sandcage/src/spoof.rs @@ -0,0 +1,170 @@ +use std::collections::HashMap; +use std::path::Path; + +use miette::Diagnostic; +use thiserror::Error; + +use crate::config::ToolConfig; + +/// Container directory baked onto the front of PATH in the base image. +pub const SPOOF_CONTAINER_DIR: &str = "/usr/local/sandcage/spoof"; + +#[derive(Debug, Error, Diagnostic)] +pub enum SpoofError { + #[error("Failed to write spoof script {0}: {1}")] + #[diagnostic(code(sandcage::spoof::write_failed))] + WriteFailed(std::path::PathBuf, #[source] std::io::Error), +} + +/// Generate spoof / host-stub scripts for the given tools into `dir`. +/// Returns the number of scripts written. `in-cage` tools produce nothing. +pub fn generate_spoof_scripts( + tools: &HashMap, + dir: &Path, +) -> Result { + let mut written = 0; + for (name, cfg) in tools { + let mode = cfg.mode.as_deref().map(ToolMode::parse).unwrap_or(ToolMode::InCage); + let body = match mode { + ToolMode::InCage => continue, + ToolMode::Spoof => { + let message = cfg.message.clone().unwrap_or_else(|| { + format!("`{name}` is intercepted by sandcage for this project.") + }); + spoof_script(&message) + } + ToolMode::Host => host_forwarding_script(name), + }; + + let path = dir.join(name); + std::fs::write(&path, body).map_err(|e| SpoofError::WriteFailed(path.clone(), e))?; + set_executable(&path).map_err(|e| SpoofError::WriteFailed(path.clone(), e))?; + written += 1; + } + Ok(written) +} + +#[cfg(unix)] +fn set_executable(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(path, perms) +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> std::io::Result<()> { + Ok(()) // execute bit is meaningless on non-unix hosts; the container is Linux +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolMode { + InCage, + Spoof, + Host, +} + +impl ToolMode { + pub fn parse(s: &str) -> ToolMode { + match s { + "spoof" => ToolMode::Spoof, + "host" => ToolMode::Host, + _ => ToolMode::InCage, + } + } +} + +/// A spoof script: prints `message` to stderr and exits non-zero (never 127). +/// The message is embedded in a single-quoted heredoc so backticks, `$`, and +/// quotes in it stay literal and are never executed by the shell. +pub fn spoof_script(message: &str) -> String { + format!( + "#!/usr/bin/env bash\ncat >&2 <<'SANDCAGE_SPOOF_EOF'\n{message}\nSANDCAGE_SPOOF_EOF\nexit 1\n" + ) +} + +/// A `host`-mode script that forwards execution to the thumper relay. +pub fn host_forwarding_script(tool: &str) -> String { + format!("#!/usr/bin/env bash\nexec thumper {tool} \"$@\"\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ToolConfig; + use std::collections::HashMap; + + fn tool(mode: &str, message: Option<&str>) -> ToolConfig { + ToolConfig { + mode: Some(mode.to_string()), + message: message.map(|s| s.to_string()), + allow: vec![], + deny: vec![], + } + } + + #[test] + fn generate_writes_only_spoof_and_host_scripts() { + let dir = tempfile::tempdir().unwrap(); + let mut tools = HashMap::new(); + tools.insert("cargo".to_string(), tool("spoof", Some("Use `just build`."))); + tools.insert("docker".to_string(), tool("host", None)); + tools.insert("uv".to_string(), tool("in-cage", None)); + + let count = generate_spoof_scripts(&tools, dir.path()).expect("generate"); + assert_eq!(count, 2, "spoof + host produce scripts; in-cage does not"); + + let cargo = dir.path().join("cargo"); + assert!(cargo.exists(), "cargo spoof script written"); + let body = std::fs::read_to_string(&cargo).unwrap(); + assert!(body.contains("Use `just build`.")); + assert!(!dir.path().join("uv").exists(), "in-cage tool has no script"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&cargo).unwrap().permissions().mode(); + assert!(mode & 0o111 != 0, "script should be executable"); + } + } + + #[test] + fn generate_uses_default_message_when_absent() { + let dir = tempfile::tempdir().unwrap(); + let mut tools = HashMap::new(); + tools.insert("npm".to_string(), tool("spoof", None)); + generate_spoof_scripts(&tools, dir.path()).expect("generate"); + let body = std::fs::read_to_string(dir.path().join("npm")).unwrap(); + assert!(body.contains("npm"), "default message mentions the tool name"); + } + + #[test] + fn parse_mode_maps_strings() { + assert_eq!(ToolMode::parse("in-cage"), ToolMode::InCage); + assert_eq!(ToolMode::parse("spoof"), ToolMode::Spoof); + assert_eq!(ToolMode::parse("host"), ToolMode::Host); + // unknown defaults to in-cage (least surprising: tool runs normally) + assert_eq!(ToolMode::parse("wat"), ToolMode::InCage); + } + + #[test] + fn spoof_script_embeds_message_safely_and_is_not_127() { + let msg = "Use `just build` to compile this $PROJECT."; + let script = spoof_script(msg); + assert!(script.starts_with("#!/usr/bin/env bash\n")); + assert!(script.contains(msg), "message embedded verbatim"); + // single-quoted heredoc → backticks and $ stay literal (not executed) + assert!(script.contains("<<'SANDCAGE_SPOOF_EOF'")); + assert!(script.contains("exit 1")); + assert!(!script.contains("exit 127"), "must never imply command-not-found"); + } + + #[test] + fn host_forwarding_script_invokes_thumper() { + let script = host_forwarding_script("docker"); + assert!(script.starts_with("#!/usr/bin/env bash\n")); + assert!(script.contains("thumper"), "should invoke thumper"); + assert!(script.contains("docker"), "should pass the tool name"); + assert!(script.contains("\"$@\""), "should forward all args"); + } +} diff --git a/crates/sandcage/src/ssh_config.rs b/crates/sandcage/src/ssh_config.rs new file mode 100644 index 0000000..9be5f93 --- /dev/null +++ b/crates/sandcage/src/ssh_config.rs @@ -0,0 +1,449 @@ +use std::path::{Path, PathBuf}; + +/// A parsed SSH config `Host` block. +#[derive(Debug, Clone, PartialEq)] +pub struct SshHostBlock { + /// The patterns listed on the `Host` line (e.g. `["github.com", "gh"]`). + pub aliases: Vec, + /// Key-value directives in the order they appear in the config. + pub directives: Vec<(String, String)>, +} + +impl SshHostBlock { + /// Return the value of the first directive matching `key` (case-insensitive). + pub fn get(&self, key: &str) -> Option<&str> { + let key_lower = key.to_lowercase(); + self.directives + .iter() + .find(|(k, _)| k.to_lowercase() == key_lower) + .map(|(_, v)| v.as_str()) + } + + /// Return the `User` directive value, if present. + pub fn user(&self) -> Option<&str> { + self.get("User") + } + + /// Return the `IdentityFile` directive value, if present. + pub fn identity_file(&self) -> Option<&str> { + self.get("IdentityFile") + } + + /// Return the `HostName` directive value, if present. + pub fn hostname(&self) -> Option<&str> { + self.get("HostName") + } + + /// Returns `true` if this block's `User` is `git` (common for GitHub/GitLab host entries). + pub fn is_git_user(&self) -> bool { + self.user().map(|u| u == "git").unwrap_or(false) + } +} + +/// Resolve an SSH `IdentityFile` path to an absolute [`PathBuf`]. +/// +/// - `~/...` paths expand `~` via [`dirs::home_dir`], falling back to `ssh_dir.parent()`. +/// - Relative paths are joined onto `ssh_dir`. +/// - Absolute paths are returned as-is. +pub fn resolve_identity_file(identity_file: &str, ssh_dir: &Path) -> PathBuf { + if let Some(suffix) = identity_file.strip_prefix("~/") { + let home = dirs::home_dir() + .unwrap_or_else(|| ssh_dir.parent().unwrap_or(ssh_dir).to_path_buf()); + home.join(suffix) + } else if Path::new(identity_file).is_absolute() { + PathBuf::from(identity_file) + } else { + ssh_dir.join(identity_file) + } +} + +/// Produce a valid SSH config file string from a slice of [`SshHostBlock`]s. +/// +/// Blocks are separated by blank lines; directives are indented with 4 spaces. +pub fn synthesize_ssh_config(blocks: &[SshHostBlock]) -> String { + let mut out = String::new(); + for (i, block) in blocks.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + out.push_str("Host "); + out.push_str(&block.aliases.join(" ")); + out.push('\n'); + for (key, value) in &block.directives { + out.push_str(" "); + out.push_str(key); + out.push(' '); + out.push_str(value); + out.push('\n'); + } + } + out +} + +/// Parse SSH config text into a list of [`SshHostBlock`]s, supporting `Include` directives. +/// +/// When an `Include ` line is encountered: +/// - The current block is flushed. +/// - `read_include(path)` is called; if it returns `Some(content)`, that content is parsed +/// recursively and its blocks are appended. +/// - Parsing of the main content then continues. +/// +/// Pass `&|_| None` to disable Include handling (identical to [`parse_ssh_config`]). +pub fn parse_ssh_config_with_includes( + content: &str, + read_include: &dyn Fn(&str) -> Option, +) -> Vec { + let mut blocks: Vec = Vec::new(); + // None → no active block yet; Some(true) → Host block; Some(false) → Match block (skip) + let mut current_aliases: Option> = None; + let mut current_directives: Vec<(String, String)> = Vec::new(); + let mut in_host_block = false; + + let flush = |blocks: &mut Vec, + aliases: &mut Option>, + directives: &mut Vec<(String, String)>, + in_host: &mut bool| { + if *in_host { + if let Some(a) = aliases.take() { + blocks.push(SshHostBlock { + aliases: a, + directives: std::mem::take(directives), + }); + } + } else { + aliases.take(); + directives.clear(); + } + *in_host = false; + }; + + for line in content.lines() { + let trimmed = line.trim(); + + // Skip blank lines and comments + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + // Split keyword from the rest on first whitespace or '=' + let (keyword, rest) = split_keyword(trimmed); + + match keyword.to_lowercase().as_str() { + "host" => { + // Flush previous block + flush( + &mut blocks, + &mut current_aliases, + &mut current_directives, + &mut in_host_block, + ); + let aliases: Vec = rest + .split_whitespace() + .map(|s| s.to_string()) + .collect(); + current_aliases = Some(aliases); + in_host_block = true; + } + "match" => { + // Flush previous block, then enter skip mode + flush( + &mut blocks, + &mut current_aliases, + &mut current_directives, + &mut in_host_block, + ); + current_aliases = Some(Vec::new()); // placeholder so flush knows there's a block + in_host_block = false; + } + "include" => { + // Flush the current block before processing the include + flush( + &mut blocks, + &mut current_aliases, + &mut current_directives, + &mut in_host_block, + ); + if let Some(included_content) = read_include(rest.trim()) { + let included_blocks = + parse_ssh_config_with_includes(&included_content, read_include); + blocks.extend(included_blocks); + } + } + _ => { + if in_host_block && current_aliases.is_some() { + current_directives.push((keyword.to_string(), rest.to_string())); + } + // In Match block or before any block → ignore + } + } + } + + // Flush last block + flush( + &mut blocks, + &mut current_aliases, + &mut current_directives, + &mut in_host_block, + ); + + blocks +} + +/// Parse SSH config text into a list of [`SshHostBlock`]s. +/// +/// - Only `Host` blocks are returned; `Match` blocks are skipped entirely. +/// - Comments (`#`) and blank lines are ignored. +/// - Directive lines are split on the first run of whitespace or `=`. +/// - Multiple aliases on a single `Host` line are all captured. +/// - `Include` directives are ignored (use [`parse_ssh_config_with_includes`] to handle them). +pub fn parse_ssh_config(content: &str) -> Vec { + parse_ssh_config_with_includes(content, &|_| None) +} + +/// Split a trimmed SSH config line into (keyword, value). +/// +/// The separator is the first `=` (with optional surrounding whitespace) or the first +/// run of whitespace — whichever comes first. +fn split_keyword(line: &str) -> (String, String) { + // Find position of first '=' or first whitespace character + let eq_pos = line.find('='); + let ws_pos = line.find(|c: char| c.is_ascii_whitespace()); + + let sep_pos = match (eq_pos, ws_pos) { + (Some(e), Some(w)) => Some(e.min(w)), + (Some(e), None) => Some(e), + (None, Some(w)) => Some(w), + (None, None) => None, + }; + + match sep_pos { + None => (line.to_string(), String::new()), + Some(pos) => { + let keyword = line[..pos].to_string(); + // Skip separator characters (whitespace and '=') + let rest = line[pos..].trim_start_matches(|c: char| c == '=' || c.is_ascii_whitespace()); + (keyword, rest.to_string()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_single_host_block() { + let config = "\ +Host github.com + User git + IdentityFile ~/.ssh/id_ed25519 + HostName github.com +"; + let blocks = parse_ssh_config(config); + assert_eq!(blocks.len(), 1); + let b = &blocks[0]; + assert_eq!(b.aliases, vec!["github.com"]); + assert_eq!(b.user(), Some("git")); + assert_eq!(b.identity_file(), Some("~/.ssh/id_ed25519")); + assert_eq!(b.hostname(), Some("github.com")); + } + + #[test] + fn parse_multiple_blocks_filter_git_user() { + let config = "\ +Host work-gitlab + HostName gitlab.mycompany.com + User git + IdentityFile ~/.ssh/id_work + +Host personal + HostName github.com + User git + IdentityFile ~/.ssh/id_personal + +Host bastion + HostName bastion.mycompany.com + User admin + IdentityFile ~/.ssh/id_bastion +"; + let blocks = parse_ssh_config(config); + assert_eq!(blocks.len(), 3); + + let git_blocks: Vec<_> = blocks.iter().filter(|b| b.is_git_user()).collect(); + assert_eq!(git_blocks.len(), 2); + let git_aliases: Vec<&str> = git_blocks.iter().map(|b| b.aliases[0].as_str()).collect(); + assert!(git_aliases.contains(&"work-gitlab")); + assert!(git_aliases.contains(&"personal")); + + let non_git: Vec<_> = blocks.iter().filter(|b| !b.is_git_user()).collect(); + assert_eq!(non_git.len(), 1); + assert_eq!(non_git[0].aliases[0], "bastion"); + } + + #[test] + fn parse_host_with_multiple_aliases() { + let config = "\ +Host github.com gh + User git + IdentityFile ~/.ssh/id_github +"; + let blocks = parse_ssh_config(config); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].aliases, vec!["github.com", "gh"]); + } + + #[test] + fn skip_match_blocks() { + let config = "\ +Host first + User alice + +Match host internal.example.com + ProxyJump bastion + +Host second + User bob +"; + let blocks = parse_ssh_config(config); + assert_eq!(blocks.len(), 2); + assert_eq!(blocks[0].aliases, vec!["first"]); + assert_eq!(blocks[0].user(), Some("alice")); + assert_eq!(blocks[1].aliases, vec!["second"]); + assert_eq!(blocks[1].user(), Some("bob")); + } + + #[test] + fn handle_tabs_and_varied_indentation() { + let config = "Host tabbed\n\tUser git\n\tIdentityFile\t~/.ssh/id_tab\n"; + let blocks = parse_ssh_config(config); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].user(), Some("git")); + assert_eq!(blocks[0].identity_file(), Some("~/.ssh/id_tab")); + } + + #[test] + fn ignore_comments_and_blank_lines() { + let config = "\ +# Global settings comment + +# Host section +Host example.com + # inline comment + User git + + IdentityFile ~/.ssh/id_example +"; + let blocks = parse_ssh_config(config); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].aliases, vec!["example.com"]); + assert_eq!(blocks[0].user(), Some("git")); + assert_eq!(blocks[0].identity_file(), Some("~/.ssh/id_example")); + // The inline comment line should not appear as a directive + assert!(!blocks[0].directives.iter().any(|(k, _)| k.starts_with('#'))); + } + + #[test] + fn empty_config() { + let blocks = parse_ssh_config(""); + assert!(blocks.is_empty()); + } + + #[test] + fn comments_only() { + let config = "\ +# This is a comment +# Another comment + +# Yet another +"; + let blocks = parse_ssh_config(config); + assert!(blocks.is_empty()); + } + + // --- Task 2: identity file resolution --- + + #[test] + fn resolve_identity_file_tilde() { + let ssh_dir = Path::new("/home/user/.ssh"); + let path = resolve_identity_file("~/.ssh/id_ed25519", ssh_dir); + let home = dirs::home_dir().unwrap(); + assert_eq!(path, home.join(".ssh/id_ed25519")); + } + + #[test] + fn resolve_identity_file_relative() { + let path = resolve_identity_file("id_ed25519", Path::new("/home/user/.ssh")); + assert_eq!(path, PathBuf::from("/home/user/.ssh/id_ed25519")); + } + + #[test] + fn resolve_identity_file_absolute() { + let path = resolve_identity_file("/etc/ssh/key", Path::new("/home/user/.ssh")); + assert_eq!(path, PathBuf::from("/etc/ssh/key")); + } + + #[test] + fn synthesize_config_from_blocks() { + let blocks = vec![ + SshHostBlock { + aliases: vec!["github.com".into()], + directives: vec![ + ("User".into(), "git".into()), + ("IdentityFile".into(), "~/.ssh/id_ed25519".into()), + ("HostName".into(), "github.com".into()), + ], + }, + SshHostBlock { + aliases: vec!["gitea".into()], + directives: vec![ + ("User".into(), "git".into()), + ("HostName".into(), "gitea.example.com".into()), + ("IdentityFile".into(), "~/.ssh/work_gitea".into()), + ("Port".into(), "2222".into()), + ], + }, + ]; + let config = synthesize_ssh_config(&blocks); + let expected = "\ +Host github.com + User git + IdentityFile ~/.ssh/id_ed25519 + HostName github.com + +Host gitea + User git + HostName gitea.example.com + IdentityFile ~/.ssh/work_gitea + Port 2222 +"; + assert_eq!(config, expected); + } + + // --- Task 3: Include directive support --- + + #[test] + fn parse_with_include_callback() { + let main_config = "\ +Include extra_config + +Host github.com + User git + IdentityFile ~/.ssh/id_ed25519 +"; + let extra_config = "\ +Host gitea.local + User git + IdentityFile ~/.ssh/gitea_key +"; + let blocks = parse_ssh_config_with_includes(main_config, &|path: &str| { + if path == "extra_config" { + Some(extra_config.to_string()) + } else { + None + } + }); + assert_eq!(blocks.len(), 2); + assert_eq!(blocks[0].aliases, vec!["gitea.local"]); + assert_eq!(blocks[1].aliases, vec!["github.com"]); + } +} diff --git a/crates/sandcage/src/tasks.rs b/crates/sandcage/src/tasks.rs new file mode 100644 index 0000000..fa84cbc --- /dev/null +++ b/crates/sandcage/src/tasks.rs @@ -0,0 +1,97 @@ +pub fn glob_match(pattern: &str, text: &str) -> bool { + let parts: Vec<&str> = pattern.split('*').collect(); + let mut pos = 0; + + for (i, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + match text[pos..].find(part) { + Some(found) => { + if i == 0 && found != 0 && !pattern.starts_with('*') { + return false; + } + pos += found + part.len(); + } + None => return false, + } + } + + if !pattern.ends_with('*') && pos != text.len() { + if let Some(last) = parts.last() { + if !last.is_empty() { + return text.ends_with(last); + } + } + } + + true +} + +pub fn check_patterns(allow: &[String], deny: &[String], args_joined: &str) -> Result<(), String> { + for pattern in deny { + if glob_match(pattern, args_joined) { + return Err(format!("denied by pattern: {pattern}")); + } + } + + if !allow.is_empty() { + let allowed = allow.iter().any(|p| glob_match(p, args_joined)); + if !allowed { + return Err("not matched by any allow pattern".to_string()); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn glob_matches_wildcard_prefix() { + assert!(glob_match("* prune *", "docker system prune -af")); + } + + #[test] + fn glob_matches_wildcard_suffix() { + assert!(glob_match("compose up *", "compose up -d")); + assert!(!glob_match("compose up *", "compose down")); + } + + #[test] + fn glob_matches_exact() { + assert!(glob_match("compose down", "compose down")); + assert!(!glob_match("compose down", "compose up")); + } + + #[test] + fn check_allow_deny_with_empty_lists() { + assert!(check_patterns(&[], &[], "anything goes").is_ok()); + } + + #[test] + fn check_deny_blocks_matching() { + let deny = vec!["* prune *".to_string(), "* rm -f *".to_string()]; + assert!(check_patterns(&[], &deny, "docker system prune -af").is_err()); + assert!(check_patterns(&[], &deny, "docker rm -f abc").is_err()); + assert!(check_patterns(&[], &deny, "docker ps").is_ok()); + } + + #[test] + fn check_allow_is_fail_closed() { + let allow = vec!["compose up *".to_string(), "compose down".to_string()]; + assert!(check_patterns(&allow, &[], "compose up -d").is_ok()); + assert!(check_patterns(&allow, &[], "compose down").is_ok()); + assert!(check_patterns(&allow, &[], "compose rm").is_err()); + } + + #[test] + fn deny_overrides_allow() { + let allow = vec!["compose *".to_string()]; + let deny = vec!["compose rm *".to_string()]; + assert!(check_patterns(&allow, &deny, "compose up -d").is_ok()); + assert!(check_patterns(&allow, &deny, "compose rm -f").is_err()); + } +} diff --git a/crates/sandcage/src/workspace.rs b/crates/sandcage/src/workspace.rs new file mode 100644 index 0000000..9fe0980 --- /dev/null +++ b/crates/sandcage/src/workspace.rs @@ -0,0 +1,114 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use miette::Diagnostic; +use thiserror::Error; + +#[derive(Debug, Error, Diagnostic)] +pub enum WorkspaceError { + #[error("Failed to resolve absolute path for {0}: {1}")] + #[diagnostic(code(sandcage::workspace::canonicalize_failed))] + CanonicalizeFailed(PathBuf, #[source] std::io::Error), +} + +pub type Result = std::result::Result; + +pub fn resolve_workspace(path: Option<&Path>) -> Result { + // Determine starting path + let raw = match path { + Some(p) => p.to_path_buf(), + None => std::env::current_dir() + .map_err(|e| WorkspaceError::CanonicalizeFailed(PathBuf::from("."), e))?, + }; + + // Resolve to absolute, canonical path (follows symlinks, checks existence) + let absolute = raw + .canonicalize() + .map_err(|e| WorkspaceError::CanonicalizeFailed(raw.clone(), e))?; + + // Ask git for the worktree root; silently fall back if not a git repo + let git_root = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(&absolute) + .output(); + + match git_root { + Ok(output) if output.status.success() => { + let root = String::from_utf8_lossy(&output.stdout) + .trim() + .to_string(); + Ok(PathBuf::from(root)) + } + // Not a git repo, or git not installed — return the absolute path as-is + _ => Ok(absolute), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + use tempfile::TempDir; + + /// Create a temporary directory and initialise it as a git repository. + /// Returns the `TempDir` guard (keep it alive for the test) and the path. + fn make_git_repo() -> (TempDir, PathBuf) { + let dir = TempDir::new().expect("create tempdir"); + let path = dir.path().to_path_buf(); + + // git init + let status = Command::new("git") + .args(["init"]) + .current_dir(&path) + .status() + .expect("run git init"); + assert!(status.success(), "git init failed"); + + (dir, path) + } + + #[test] + fn returns_repo_root_when_inside_git_repo() { + let (_guard, repo_root) = make_git_repo(); + + // Create a nested directory inside the repo + let nested = repo_root.join("a").join("b"); + std::fs::create_dir_all(&nested).expect("create nested dirs"); + + // resolve_workspace from the nested dir should return the repo root + let resolved = resolve_workspace(Some(&nested)).expect("resolve_workspace"); + assert_eq!( + resolved.canonicalize().unwrap(), + repo_root.canonicalize().unwrap(), + "expected repo root, got {resolved:?}" + ); + } + + #[test] + fn returns_directory_itself_when_not_in_git_repo() { + // Create a temp dir that is NOT a git repo (no git init) + let dir = TempDir::new().expect("create tempdir"); + + // Make sure there is no ancestor git repo that would pollute the result. + // tempfile creates dirs under the system temp folder, which is typically + // outside any user repo, so this should be safe. + let path = dir.path().to_path_buf(); + let resolved = resolve_workspace(Some(&path)).expect("resolve_workspace"); + + assert_eq!( + resolved.canonicalize().unwrap(), + path.canonicalize().unwrap(), + "expected the directory itself, got {resolved:?}" + ); + } + + #[test] + fn none_path_uses_current_directory() { + // When None is passed the function should not error; it should return + // something (either the cwd or its git root). + let result = resolve_workspace(None); + assert!(result.is_ok(), "resolve_workspace(None) returned error: {result:?}"); + let resolved = result.unwrap(); + assert!(resolved.is_absolute(), "result should be absolute: {resolved:?}"); + } +} diff --git a/crates/sandcage/src/worktree.rs b/crates/sandcage/src/worktree.rs new file mode 100644 index 0000000..49a5ede --- /dev/null +++ b/crates/sandcage/src/worktree.rs @@ -0,0 +1,224 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use miette::Diagnostic; +use thiserror::Error; + +#[derive(Debug, Error, Diagnostic)] +pub enum WorktreeError { + #[error("not a git repository: {0}")] + #[diagnostic(code(sandcage::worktree::not_git))] + NotGitRepo(PathBuf), + + #[error("git worktree add failed: {0}")] + #[diagnostic(code(sandcage::worktree::create_failed))] + CreateFailed(String), + + #[error("git worktree remove failed: {0}")] + #[diagnostic(code(sandcage::worktree::remove_failed))] + RemoveFailed(String), +} + +pub fn worktree_dir(sandcage_home: &Path, project_slug: &str, timestamp: &str) -> PathBuf { + sandcage_home + .join("worktrees") + .join(format!("{project_slug}-{timestamp}")) +} + +pub fn project_slug(project_path: &Path) -> String { + project_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "project".to_string()) +} + +pub fn generate_timestamp() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let d = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + format!("{}", d.as_secs()) +} + +pub fn create_worktree( + repo_root: &Path, + target_path: &Path, + branch: Option<&str>, +) -> Result<(), WorktreeError> { + let check = Command::new("git") + .args(["rev-parse", "--git-dir"]) + .current_dir(repo_root) + .output() + .map_err(|_| WorktreeError::NotGitRepo(repo_root.to_path_buf()))?; + + if !check.status.success() { + return Err(WorktreeError::NotGitRepo(repo_root.to_path_buf())); + } + + let mut cmd = Command::new("git"); + cmd.args(["worktree", "add"]); + + if let Some(b) = branch { + cmd.args([&target_path.to_string_lossy().to_string(), b]); + } else { + cmd.args(["--detach", &target_path.to_string_lossy().to_string()]); + } + + cmd.current_dir(repo_root); + + let output = cmd + .output() + .map_err(|e| WorktreeError::CreateFailed(format!("failed to run git: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::CreateFailed(stderr.to_string())); + } + + Ok(()) +} + +#[derive(Debug)] +pub struct WorktreeEntry { + pub name: String, + pub path: PathBuf, +} + +pub fn list_worktrees(sandcage_home: &Path) -> Vec { + let wt_dir = sandcage_home.join("worktrees"); + if !wt_dir.exists() { + return vec![]; + } + + let mut entries = Vec::new(); + if let Ok(read_dir) = std::fs::read_dir(&wt_dir) { + for entry in read_dir.flatten() { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + let name = entry.file_name().to_string_lossy().to_string(); + entries.push(WorktreeEntry { + name, + path: entry.path(), + }); + } + } + } + entries.sort_by(|a, b| a.name.cmp(&b.name)); + entries +} + +pub fn remove_worktree(path: &Path) -> Result<(), WorktreeError> { + let output = Command::new("git") + .args(["worktree", "remove", "--force", &path.to_string_lossy()]) + .output(); + + match output { + Ok(o) if o.status.success() => return Ok(()), + _ => {} + } + + std::fs::remove_dir_all(path).map_err(|e| { + WorktreeError::RemoveFailed(format!("failed to remove {}: {e}", path.display())) + }) +} + +pub fn clean_all(sandcage_home: &Path, dry_run: bool) -> Vec<(String, Result<(), WorktreeError>)> { + let entries = list_worktrees(sandcage_home); + let mut results = Vec::new(); + + for entry in entries { + if dry_run { + results.push((entry.name, Ok(()))); + } else { + let result = remove_worktree(&entry.path); + results.push((entry.name, result)); + } + } + + results +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn worktree_dir_path_format() { + let home = PathBuf::from("/home/user/.sandcage"); + let dir = worktree_dir(&home, "myapp", "20260602-140000"); + assert_eq!( + dir, + PathBuf::from("/home/user/.sandcage/worktrees/myapp-20260602-140000") + ); + } + + #[test] + fn project_slug_extracts_basename() { + assert_eq!( + project_slug(Path::new("/home/user/projects/my-app")), + "my-app" + ); + } + + #[test] + fn list_worktrees_finds_managed() { + let home = tempfile::tempdir().unwrap(); + let wt_dir = home.path().join("worktrees"); + std::fs::create_dir_all(&wt_dir).unwrap(); + std::fs::create_dir_all(wt_dir.join("myapp-1717300000")).unwrap(); + std::fs::create_dir_all(wt_dir.join("myapp-1717300001")).unwrap(); + + let entries = list_worktrees(home.path()); + assert_eq!(entries.len(), 2); + } + + #[test] + fn list_worktrees_empty_when_no_dir() { + let home = tempfile::tempdir().unwrap(); + let entries = list_worktrees(home.path()); + assert!(entries.is_empty()); + } + + #[test] + fn create_worktree_produces_valid_checkout() { + let dir = tempfile::tempdir().unwrap(); + let repo_root = dir.path().to_path_buf(); + Command::new("git") + .args(["init"]) + .current_dir(&repo_root) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("git init"); + Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(&repo_root) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("git commit"); + + let wt_dir = tempfile::tempdir().unwrap(); + let wt_path = wt_dir.path().join("test-wt"); + + create_worktree(&repo_root, &wt_path, None).unwrap(); + + assert!(wt_path.exists(), "worktree dir should exist"); + let status = Command::new("git") + .args(["status"]) + .current_dir(&wt_path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap(); + assert!(status.success(), "git status should work in worktree"); + + // Cleanup + Command::new("git") + .args(["worktree", "remove", "--force", &wt_path.to_string_lossy()]) + .current_dir(&repo_root) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .ok(); + } +} diff --git a/crates/thumper/Cargo.toml b/crates/thumper/Cargo.toml new file mode 100644 index 0000000..a334607 --- /dev/null +++ b/crates/thumper/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "thumper" +version = "0.1.0" +edition = "2024" +description = "In-container forwarding client for sandcage" +license = "MIT" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/thumper/src/client.rs b/crates/thumper/src/client.rs new file mode 100644 index 0000000..eae5d57 --- /dev/null +++ b/crates/thumper/src/client.rs @@ -0,0 +1,311 @@ +#[cfg(unix)] +mod imp { + use std::io::{self, BufRead, BufReader, Write}; + use std::os::unix::net::UnixStream; + use std::path::Path; + + use crate::protocol::{ForwardRequest, ResponseFrame, StreamKind}; + + #[derive(Debug)] + pub enum ClientError { + Connect(io::Error), + Send(io::Error), + Receive(io::Error), + Parse(serde_json::Error), + UnexpectedEof, + } + + impl std::fmt::Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClientError::Connect(e) => write!(f, "failed to connect to forwarding socket: {e}"), + ClientError::Send(e) => write!(f, "failed to send request: {e}"), + ClientError::Receive(e) => write!(f, "failed to read response: {e}"), + ClientError::Parse(e) => write!(f, "malformed response frame: {e}"), + ClientError::UnexpectedEof => write!(f, "connection closed without exit frame"), + } + } + } + + impl std::error::Error for ClientError {} + + pub fn forward(socket_path: &Path, request: &ForwardRequest) -> Result { + let stream = UnixStream::connect(socket_path).map_err(ClientError::Connect)?; + + // Send request as a single JSON line. + let mut writer = io::BufWriter::new(&stream); + serde_json::to_writer(&mut writer, request).map_err(|e| ClientError::Send(e.into()))?; + writer.write_all(b"\n").map_err(ClientError::Send)?; + writer.flush().map_err(ClientError::Send)?; + + // Read response frames line by line. + let reader = BufReader::new(&stream); + for line_result in reader.lines() { + let line = line_result.map_err(ClientError::Receive)?; + if line.is_empty() { + continue; + } + let frame: ResponseFrame = serde_json::from_str(&line).map_err(ClientError::Parse)?; + match frame { + ResponseFrame::Output { stream: StreamKind::Stdout, data } => { + print!("{data}"); + } + ResponseFrame::Output { stream: StreamKind::Stderr, data } => { + eprint!("{data}"); + } + ResponseFrame::Exit { exit } => { + return Ok(exit); + } + } + } + + Err(ClientError::UnexpectedEof) + } +} + +#[cfg(unix)] +pub use imp::*; + +#[cfg(not(unix))] +mod imp { + use std::path::Path; + + use crate::protocol::ForwardRequest; + + #[derive(Debug)] + pub enum ClientError { + Unsupported, + } + + impl std::fmt::Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "thumper is only supported on Unix platforms") + } + } + + impl std::error::Error for ClientError {} + + pub fn forward(_socket_path: &Path, _request: &ForwardRequest) -> Result { + Err(ClientError::Unsupported) + } +} + +#[cfg(not(unix))] +pub use imp::*; + +#[cfg(test)] +#[cfg(unix)] +mod tests { + use super::*; + use crate::protocol::RequestKind; + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::{UnixListener, UnixStream}; + use std::path::Path; + use std::thread; + + fn mock_server( + socket_path: &Path, + handler: impl FnOnce(UnixStream) + Send + 'static, + ) -> thread::JoinHandle<()> { + let listener = UnixListener::bind(socket_path).unwrap(); + thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + handler(stream); + }) + } + + #[test] + fn sends_request_as_single_json_line() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("cmd.sock"); + + let handle = mock_server(&sock, |stream| { + let mut reader = BufReader::new(&stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + + let parsed: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(parsed["kind"], "task"); + assert_eq!(parsed["name"], "build"); + assert_eq!(parsed["args"][0], "--release"); + assert_eq!(parsed["cwd"], "/workspace/app"); + + // Send exit frame so forward() returns + let mut writer = reader.into_inner(); + writeln!(writer, r#"{{"exit":0}}"#).unwrap(); + }); + + let req = crate::protocol::ForwardRequest { + kind: RequestKind::Task, + name: "build".to_string(), + args: vec!["--release".to_string()], + cwd: "/workspace/app".to_string(), + }; + let exit = forward(&sock, &req).unwrap(); + assert_eq!(exit, 0); + handle.join().unwrap(); + } + + #[test] + fn routes_stdout_and_stderr_separately() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("cmd.sock"); + + let handle = mock_server(&sock, |stream| { + let mut reader = BufReader::new(&stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); // consume request + + let mut writer = reader.into_inner(); + writeln!(writer, r#"{{"stream":"stdout","data":"out1\n"}}"#).unwrap(); + writeln!(writer, r#"{{"stream":"stderr","data":"err1\n"}}"#).unwrap(); + writeln!(writer, r#"{{"stream":"stdout","data":"out2\n"}}"#).unwrap(); + writeln!(writer, r#"{{"exit":0}}"#).unwrap(); + }); + + let req = crate::protocol::ForwardRequest { + kind: RequestKind::Task, + name: "test".to_string(), + args: vec![], + cwd: "/workspace".to_string(), + }; + let exit = forward(&sock, &req).unwrap(); + assert_eq!(exit, 0); + handle.join().unwrap(); + } + + #[test] + fn nonzero_exit_is_relayed() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("cmd.sock"); + + let handle = mock_server(&sock, |stream| { + let mut reader = BufReader::new(&stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + + let mut writer = reader.into_inner(); + writeln!(writer, r#"{{"stream":"stderr","data":"error: build failed\n"}}"#).unwrap(); + writeln!(writer, r#"{{"exit":1}}"#).unwrap(); + }); + + let req = crate::protocol::ForwardRequest { + kind: RequestKind::Task, + name: "build".to_string(), + args: vec![], + cwd: "/workspace".to_string(), + }; + let exit = forward(&sock, &req).unwrap(); + assert_eq!(exit, 1); + handle.join().unwrap(); + } + + #[test] + fn unexpected_eof_when_no_exit_frame() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("cmd.sock"); + + let handle = mock_server(&sock, |stream| { + let mut reader = BufReader::new(&stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + + let mut writer = reader.into_inner(); + writeln!(writer, r#"{{"stream":"stdout","data":"partial"}}"#).unwrap(); + let _ = writer; // close without exit frame + }); + + let req = crate::protocol::ForwardRequest { + kind: RequestKind::Task, + name: "test".to_string(), + args: vec![], + cwd: "/workspace".to_string(), + }; + let err = forward(&sock, &req).unwrap_err(); + assert!( + matches!(err, ClientError::UnexpectedEof), + "expected UnexpectedEof, got {err}" + ); + handle.join().unwrap(); + } + + #[test] + fn full_roundtrip_simulating_cargo_build() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("cmd.sock"); + + let handle = mock_server(&sock, |stream| { + let mut reader = BufReader::new(&stream); + let mut req_line = String::new(); + reader.read_line(&mut req_line).unwrap(); + + // Validate the request + let req: serde_json::Value = serde_json::from_str(&req_line).unwrap(); + assert_eq!(req["kind"], "task"); + assert_eq!(req["name"], "build"); + assert_eq!(req["args"], serde_json::json!(["--release"])); + + // Simulate cargo build output + let writer = reader.into_inner(); + writeln!(&*writer, r#"{{"stream":"stderr","data":" Compiling sandcage v0.1.0\n"}}"#).unwrap(); + writeln!(&*writer, r#"{{"stream":"stderr","data":" Compiling thumper v0.1.0\n"}}"#).unwrap(); + writeln!(&*writer, r#"{{"stream":"stderr","data":" Finished release [optimized]\n"}}"#).unwrap(); + writeln!(&*writer, r#"{{"exit":0}}"#).unwrap(); + }); + + let req = crate::protocol::ForwardRequest { + kind: RequestKind::Task, + name: "build".to_string(), + args: vec!["--release".to_string()], + cwd: "/workspace/app".to_string(), + }; + let exit = forward(&sock, &req).unwrap(); + assert_eq!(exit, 0); + handle.join().unwrap(); + } + + #[test] + fn tool_request_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("cmd.sock"); + + let handle = mock_server(&sock, |stream| { + let mut reader = BufReader::new(&stream); + let mut req_line = String::new(); + reader.read_line(&mut req_line).unwrap(); + + let req: serde_json::Value = serde_json::from_str(&req_line).unwrap(); + assert_eq!(req["kind"], "tool"); + assert_eq!(req["name"], "docker"); + + let writer = reader.into_inner(); + writeln!(&*writer, r#"{{"stream":"stdout","data":"container-id-abc123\n"}}"#).unwrap(); + writeln!(&*writer, r#"{{"exit":0}}"#).unwrap(); + }); + + let req = crate::protocol::ForwardRequest { + kind: RequestKind::Tool, + name: "docker".to_string(), + args: vec!["compose".to_string(), "up".to_string(), "-d".to_string()], + cwd: "/workspace/app".to_string(), + }; + let exit = forward(&sock, &req).unwrap(); + assert_eq!(exit, 0); + handle.join().unwrap(); + } + + #[test] + fn connect_error_on_missing_socket() { + let err = forward( + Path::new("/tmp/sandcage-nonexistent-test.sock"), + &crate::protocol::ForwardRequest { + kind: RequestKind::Task, + name: "x".to_string(), + args: vec![], + cwd: "/workspace".to_string(), + }, + ) + .unwrap_err(); + assert!(matches!(err, ClientError::Connect(_)), "expected Connect, got {err}"); + } +} diff --git a/crates/thumper/src/main.rs b/crates/thumper/src/main.rs new file mode 100644 index 0000000..2c18b6b --- /dev/null +++ b/crates/thumper/src/main.rs @@ -0,0 +1,44 @@ +mod client; +mod protocol; + +use std::path::Path; +use std::process::ExitCode; + +use protocol::{ForwardRequest, RequestKind}; + +const DEFAULT_SOCKET: &str = "/run/sandcage/cmd.sock"; + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + + if args.len() < 2 || args[1] == "--help" || args[1] == "-h" { + eprintln!("usage: thumper [args...]"); + eprintln!(); + eprintln!("Forwards a named task to the sandcage host daemon."); + eprintln!("The task is executed on the host and output is streamed back."); + return ExitCode::from(2); + } + + let socket_path = std::env::var("SANDCAGE_SOCKET") + .map(|s| std::path::PathBuf::from(s)) + .unwrap_or_else(|_| Path::new(DEFAULT_SOCKET).to_path_buf()); + + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "/workspace".to_string()); + + let request = ForwardRequest { + kind: RequestKind::Task, + name: args[1].clone(), + args: args[2..].to_vec(), + cwd, + }; + + match client::forward(&socket_path, &request) { + Ok(code) => ExitCode::from(code as u8), + Err(e) => { + eprintln!("thumper: {e}"); + ExitCode::from(1) + } + } +} diff --git a/crates/thumper/src/protocol.rs b/crates/thumper/src/protocol.rs new file mode 100644 index 0000000..b02c3bd --- /dev/null +++ b/crates/thumper/src/protocol.rs @@ -0,0 +1,113 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +pub struct ForwardRequest { + pub kind: RequestKind, + pub name: String, + pub args: Vec, + pub cwd: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub enum RequestKind { + Task, + Tool, +} + +/// Response frames from the host-side daemon. +/// +/// IMPORTANT: `Exit` MUST come before `Output`. Serde's `untagged` tries variants in +/// declaration order — `{"exit":0}` would match `Output` if `Output` were first. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +#[allow(dead_code)] +pub enum ResponseFrame { + Exit { exit: i32 }, + Output { stream: StreamKind, data: String }, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] +pub enum StreamKind { + Stdout, + Stderr, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_serializes_to_expected_json() { + let req = ForwardRequest { + kind: RequestKind::Task, + name: "build".to_string(), + args: vec!["--release".to_string()], + cwd: "/workspace".to_string(), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["kind"], "task"); + assert_eq!(json["name"], "build"); + assert_eq!(json["args"][0], "--release"); + assert_eq!(json["cwd"], "/workspace"); + } + + #[test] + fn tool_request_kind_serializes() { + let req = ForwardRequest { + kind: RequestKind::Tool, + name: "grep".to_string(), + args: vec![], + cwd: "/".to_string(), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["kind"], "tool"); + } + + #[test] + fn stdout_frame_deserializes() { + let frame: ResponseFrame = + serde_json::from_str(r#"{"stream":"stdout","data":"hello\n"}"#).unwrap(); + match frame { + ResponseFrame::Output { stream: StreamKind::Stdout, data } => { + assert_eq!(data, "hello\n"); + } + other => panic!("unexpected variant: {:?}", other), + } + } + + #[test] + fn stderr_frame_deserializes() { + let frame: ResponseFrame = + serde_json::from_str(r#"{"stream":"stderr","data":"warning: unused var"}"#).unwrap(); + match frame { + ResponseFrame::Output { stream: StreamKind::Stderr, data } => { + assert_eq!(data, "warning: unused var"); + } + other => panic!("unexpected variant: {:?}", other), + } + } + + #[test] + fn exit_frame_deserializes() { + let frame: ResponseFrame = serde_json::from_str(r#"{"exit":42}"#).unwrap(); + match frame { + ResponseFrame::Exit { exit } => assert_eq!(exit, 42), + other => panic!("unexpected variant: {:?}", other), + } + } + + #[test] + fn exit_zero_frame() { + // Regression guard: serde untagged ordering — Exit must come before Output + // so {"exit":0} does not accidentally match the Output variant. + let frame: ResponseFrame = serde_json::from_str(r#"{"exit":0}"#).unwrap(); + match frame { + ResponseFrame::Exit { exit } => assert_eq!(exit, 0), + other => panic!("unexpected variant: {:?}", other), + } + } +} diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..060e446 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,434 @@ +# Command Reference + +Sandcage provides subcommands for running AI coding agents in isolated Docker containers. + +``` +sandcage [OPTIONS] +``` + +--- + +## claude + +Run the Claude Code agent in a sandboxed container. + +``` +sandcage claude [OPTIONS] [-- AGENT_ARGS...] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `-p, --path ` | Path to the project directory (defaults to current directory) | +| `--shell` | Drop into a shell instead of launching the agent | +| `--worktree` | Create a git worktree and mount it instead of the project directory | +| `--home ` | Named home profile for agent auth/config (default: "default") | + +**Trailing arguments:** Any arguments after `--` are forwarded directly to the Claude Code binary inside the container. + +**Examples:** + +```sh +# Run Claude Code on the current directory +sandcage claude + +# Run Claude Code on a specific project +sandcage claude --path /home/user/myproject + +# Drop into a shell in the Claude container environment +sandcage claude --shell + +# Run in a disposable worktree copy +sandcage claude --worktree + +# Run with a specific home profile (e.g. work account) +sandcage claude --home work + +# Forward arguments to the agent +sandcage claude -- --resume +``` + +--- + +## codex + +Run the OpenAI Codex agent in a sandboxed container. + +``` +sandcage codex [OPTIONS] [-- AGENT_ARGS...] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `-p, --path ` | Path to the project directory (defaults to current directory) | +| `--shell` | Drop into a shell instead of launching the agent | +| `--worktree` | Create a git worktree and mount it instead of the project directory | +| `--home ` | Named home profile for agent auth/config (default: "default") | + +**Trailing arguments:** Any arguments after `--` are forwarded directly to the Codex binary inside the container. + +**Examples:** + +```sh +# Run Codex on the current directory +sandcage codex + +# Run Codex in a disposable worktree +sandcage codex --worktree + +# Forward arguments to Codex +sandcage codex -- --model o4-mini +``` + +--- + +## gemini + +Run the Gemini CLI agent in a sandboxed container. + +``` +sandcage gemini [OPTIONS] [-- AGENT_ARGS...] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `-p, --path ` | Path to the project directory (defaults to current directory) | +| `--shell` | Drop into a shell instead of launching the agent | +| `--worktree` | Create a git worktree and mount it instead of the project directory | +| `--home ` | Named home profile for agent auth/config (default: "default") | + +**Trailing arguments:** Any arguments after `--` are forwarded directly to the Gemini CLI binary inside the container. + +**Examples:** + +```sh +# Run Gemini CLI on the current directory +sandcage gemini + +# Run Gemini in a disposable worktree +sandcage gemini --worktree + +# Drop into a shell in the Gemini container +sandcage gemini --shell +``` + +--- + +## shell + +Open an interactive shell (zsh) with the same sandboxed environment used by agents. + +``` +sandcage shell [OPTIONS] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `-p, --path ` | Path to the project directory (defaults to current directory) | +| `--home ` | Named home profile for agent auth/config (default: "default") | + +**Examples:** + +```sh +# Interactive shell in the sandbox for current project +sandcage shell + +# Interactive shell for a specific project +sandcage shell --path /home/user/myproject +``` + +--- + +## build + +Build the container images used by sandcage services. + +``` +sandcage build [OPTIONS] [SERVICES...] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `-f, --force` | Force rebuild even if images are up to date | + +**Positional arguments:** Optionally specify which services to build (e.g. `claude`, `codex`, `gemini`). When omitted, all enabled services are built. + +**Examples:** + +```sh +# Build all enabled service images +sandcage build + +# Force rebuild of all images +sandcage build --force + +# Build only the claude image +sandcage build claude + +# Build claude and gemini images +sandcage build claude gemini +``` + +--- + +## init + +Initialize a `.sandcage/` directory with configuration for a project. + +``` +sandcage init +``` + +Scaffolds a `.sandcage/` directory in the current workspace with `sandcage.yml`, `overlay/`, `local/`, `local/overlay/`, and a `.gitignore` that excludes `local/`. + +Refuses to run from linked git worktrees — run it from the main worktree instead. + +**Examples:** + +```sh +# Initialize sandcage config in current directory +sandcage init +``` + +--- + +## setup ssh + +Configure SSH key access for containers. Copies selected SSH keys into a Docker volume accessible by sandcage containers. + +``` +sandcage setup ssh [OPTIONS] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `--global` | Write to global config (`~/.sandcage/config.toml`) instead of project config | +| `--yes` | Skip confirmation prompt | +| `--refresh` | Re-populate the SSH volume using the previously saved selection | +| `--bind` | Use legacy full bind mount instead of volume copy | + +**Examples:** + +```sh +# Interactive SSH key setup for the current project +sandcage setup ssh + +# Non-interactive setup writing to global config +sandcage setup ssh --global --yes + +# Refresh the SSH volume after adding new keys +sandcage setup ssh --refresh +``` + +--- + +## acp + +Manage and run ACP (Agent Control Protocol) agents. This command group requires the `bollard` feature, which is enabled by default. + +``` +sandcage acp +``` + +--- + +### acp run + +Run a service in ACP relay mode, attaching stdin/stdout/stderr for bidirectional communication with the container. Uses the Bollard Docker API directly instead of docker compose. Designed for structured agent orchestration. + +``` +sandcage acp run [OPTIONS] [-- AGENT_ARGS...] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `-p, --path ` | Path to the project directory (defaults to current directory) | + +**Positional arguments:** `` — the service name to run (e.g. `claude`, `codex`). + +**Trailing arguments:** Any arguments after `--` are forwarded directly to the agent binary inside the container. + +**Examples:** + +```sh +# Run the claude service in ACP relay mode +sandcage acp run claude + +# Run against a specific project directory +sandcage acp run claude --path /home/user/myproject + +# Forward arguments to the agent +sandcage acp run claude -- --resume +``` + +--- + +### acp install + +Install an agent from the ACP registry. Downloads the agent binary for the current platform and caches it at `~/.sandcage/agents///`. + +``` +sandcage acp install [OPTIONS] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `--force` | Reinstall even if the agent is already installed | + +**Positional arguments:** `` — the identifier of the agent to install from the registry. + +**Examples:** + +```sh +# Install an agent from the registry +sandcage acp install my-agent + +# Force reinstall an already-installed agent +sandcage acp install my-agent --force +``` + +--- + +### acp list + +List available agents in the ACP registry. Fetches the agent list from the CDN with a 1-hour local cache. + +``` +sandcage acp list [OPTIONS] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `--refresh` | Bypass the local cache and fetch a fresh list from the registry | + +**Examples:** + +```sh +# List available agents (uses cache if fresh) +sandcage acp list + +# Force a fresh fetch from the registry +sandcage acp list --refresh +``` + +--- + +### acp installed + +List locally installed ACP agents. + +``` +sandcage acp installed +``` + +No options. + +**Examples:** + +```sh +# Show all locally installed ACP agents +sandcage acp installed +``` + +--- + +## clean + +Remove shadow volumes for the current project. By default runs a dry run showing what would be removed. + +``` +sandcage clean [OPTIONS] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `--yes` | Actually delete (default is a dry run) | + +**Examples:** + +```sh +# Preview what would be removed +sandcage clean + +# Remove shadow volumes +sandcage clean --yes +``` + +--- + +## worktree + +Manage sandcage-created git worktrees. Worktrees are created under `~/.sandcage/worktrees/`. + +--- + +### worktree list + +List all sandcage-managed worktrees. + +``` +sandcage worktree list +``` + +**Examples:** + +```sh +sandcage worktree list +``` + +--- + +### worktree clean + +Remove all sandcage-managed worktrees. + +``` +sandcage worktree clean [OPTIONS] +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `--yes` | Skip confirmation prompt | + +**Examples:** + +```sh +# Interactive cleanup +sandcage worktree clean + +# Non-interactive cleanup +sandcage worktree clean --yes +``` + +--- + +## Common Patterns + +**Project path resolution:** All agent commands and `shell` accept `--path` (`-p`) to specify the project directory. When omitted, sandcage resolves the workspace from the current directory. + +**Shell override:** The `--shell` flag on agent commands (`claude`, `codex`, `gemini`) drops you into an interactive shell inside the agent's container without launching the agent itself. Useful for debugging the container environment. + +**Forwarding arguments to agents:** Agent commands accept trailing arguments (after `--`) that are passed through to the underlying agent binary. This allows setting agent-specific flags without sandcage needing to know about them. + +**Available services:** The built-in services are `claude`, `codex`, `gemini`, and `shell`. Each agent service auto-installs its binary on first run if not already present in the container. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..7d5587b --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,577 @@ +# Configuration Reference + +Sandcage uses a layered configuration system. Each layer overrides the previous one: + +1. **Compiled defaults** — all fields unset +2. **Global config** (`~/.sandcage/config.toml`) — user-wide preferences (TOML format) +3. **Project config** (`.sandcage/config.toml`) — per-project settings, checked into version control (TOML format) +4. **Local config** (`.sandcage/local/config.local.toml`) — personal overrides, gitignored via `.sandcage/.gitignore` (TOML format) + +Later layers win. Fields not set in a layer are inherited from the layer below. + +--- + +## Directory Layout + +``` +.sandcage/ +├── config.toml # project config (committed) +├── overlay/ # files bind-mounted read-only over workspace files in the container +├── local/ # gitignored via .sandcage/.gitignore +│ ├── config.local.toml +│ └── overlay/ # local overlay files (also gitignored) +└── .gitignore # contains "local/" +``` + +The `.sandcage/` directory itself is invisible inside the container (shadowed by a tmpfs mount). + +--- + +## Config Source Resolution + +When sandcage looks for the `.sandcage/` directory, it checks these locations in order: + +1. **`SANDCAGE_CONFIG_DIR` env var** — explicit override, used as-is +2. **`.sandcage/` in the workspace** — the most common case +3. **Redirect file** — a `sandcage.toml` at the workspace root containing a `redirect` key pointing to the real `.sandcage/` directory +4. **Main worktree root** — for git worktrees, falls back to the main worktree's `.sandcage/` + +The first match wins. `sandcage init` refuses to run from linked worktrees — run it from the main worktree instead. + +--- + +## Fields + +### env + +Environment variables passed into the container at runtime. + +- **Type:** Map of string keys to string values +- **Valid in:** Global, Project, Local +- **Behavior:** Passed as `-e KEY=VALUE` flags to `docker compose run` + +```toml +# .sandcage/config.toml +[env] +RUST_LOG = "debug" +DATABASE_URL = "postgres://localhost:5432/dev" +``` + +```toml +# ~/.sandcage/config.toml +[env] +EDITOR = "vim" +``` + +--- + +### packages + +System packages to install in the container image (apt packages). + +- **Type:** List of strings +- **Valid in:** Global, Project, Local + +```toml +packages = ["ripgrep", "fd-find", "postgresql-client"] +``` + +--- + +### toolchains + +Language toolchain managers to enable. When enabled, the corresponding Dockerfile fragment from `images/fragments/` is included during `sandcage build`. + +- **Type:** Map of string keys to boolean values +- **Valid in:** Global, Project, Local +- **Supported keys:** `uv`, `nvm`, `rust` + +```toml +[toolchains] +uv = true +nvm = true +rust = true +``` + +--- + +### mounts + +Additional volume mounts passed to the container. Each entry must contain a `:` separator. + +- **Type:** List of strings in Docker mount format (`:[:]`) +- **Valid in:** Global, Project, Local +- **Behavior:** Tilde (`~`) in the source path is expanded to the host home directory. Entries without `:` are skipped with a warning. + +```toml +mounts = [ + "/data/models:/models:ro", + "~/.gitconfig:/home/agent/.gitconfig:ro", + "/tmp/cache:/cache", +] +``` + +--- + +### shell + +Default shell inside the container. + +- **Type:** String +- **Valid in:** Global, Project, Local + +```toml +shell = "zsh" +``` + +--- + +### justfile + +Path to a justfile to make available in the container. + +- **Type:** File path (string) +- **Valid in:** Global, Project, Local + +```toml +justfile = "./project.justfile" +``` + +--- + +### dockerfiles + +Custom Dockerfile overrides for image builds. Keyed by image short name. Values can be a path to a Dockerfile or a directory containing one. + +- **Type:** Map of string keys to file paths +- **Valid in:** Global, Project (project overrides require the project to be in `trusted_projects`) +- **Behavior:** Project-level Dockerfile overrides are ignored unless the project directory is listed in `trusted_projects` in the global config. + +```toml +# .sandcage/config.toml (only applied if project is trusted) +[dockerfiles] +sandcage = "./docker/Dockerfile.custom" +``` + +```toml +# ~/.sandcage/config.toml +[dockerfiles] +sandcage = "/home/user/my-dockerfiles/sandcage/" +``` + +--- + +### trusted_projects + +List of project directories allowed to use Dockerfile overrides. + +- **Type:** List of file paths +- **Valid in:** Global only (a project cannot self-trust) +- **Behavior:** Read exclusively from `~/.sandcage/config.toml`. Projects with `dockerfiles` set are ignored unless their path (or a parent) appears here. + +```toml +# ~/.sandcage/config.toml +trusted_projects = [ + "/home/user/projects/my-trusted-project", + "/home/user/work", +] +``` + +--- + +### container_workspace + +Override the workspace path inside the container. Must be an absolute path (starting with `/`). Relative paths are ignored with a warning. + +- **Type:** String (absolute path) +- **Valid in:** Global, Project, Local +- **Default:** `/workspace/` (derived from the host workspace folder name) + +```toml +container_workspace = "/workspace/my-app" +``` + +--- + +### agent_args + +Default arguments passed to agents on every invocation. Keyed by service name. These are appended after the service name but before any extra CLI arguments. + +- **Type:** Map of service name to list of strings +- **Valid in:** Global, Project, Local +- **Behavior:** Only the args for the invoked service are used. When running with `--shell`, agent_args are skipped entirely. + +```toml +# .sandcage/config.toml +[agent_args] +claude = ["--dangerously-skip-permissions"] +codex = ["--full-auto"] +``` + +```toml +# ~/.sandcage/config.toml +[agent_args] +claude = ["--dangerously-skip-permissions"] +``` + +--- + +### ssh_mode + +Controls how SSH keys are made available inside the container. + +- **Type:** String — one of `"volume"`, `"bind"`, or `"none"` +- **Valid in:** Global, Project, Local +- **Default:** No SSH mount (equivalent to `"none"`) + +| Value | Behavior | +|----------|----------| +| `volume` | Mounts a named Docker volume (`sandcage-ssh`) at `/home/agent/.ssh` (read-only). Populate it with `sandcage setup ssh`. | +| `bind` | Bind-mounts `~/.ssh` from the host at `/home/agent/.ssh` (read-only). | +| `none` | No SSH directory is mounted. | + +```toml +ssh_mode = "volume" +``` + +When using `volume` mode, run `sandcage setup ssh --refresh` after changing keys on the host. + +--- + +### ssh_keys + +Declares which SSH keys to copy into the volume when using `sandcage setup ssh`. Each entry specifies a host and identity file. + +- **Type:** List of objects with `host` (string) and `identity_file` (string) fields +- **Valid in:** Global, Project, Local + +```toml +[[ssh_keys]] +host = "github.com" +identity_file = "~/.ssh/id_ed25519" + +[[ssh_keys]] +host = "gitea.internal" +identity_file = "~/.ssh/work_gitea" +``` + +--- + +### services + +Override the enabled state of built-in services. Keyed by service name. + +- **Type:** Map of service name to object with optional `enabled` (boolean) field +- **Valid in:** Global, Project, Local +- **Behavior:** Disabled services cannot be started with `sandcage ` and are excluded from generated compose files. + +```toml +[services.gemini] +enabled = false + +[services.codex] +enabled = false +``` + +```toml +# ~/.sandcage/config.toml +[services.codex] +enabled = false +``` + +--- + +### default_services + +Controls which services `sandcage build` prepares by default (when no service names are given on the CLI). + +- **Type:** List of strings (service names) +- **Valid in:** Global, Project, Local + +```toml +default_services = ["claude", "shell"] +``` + +--- + +### image + +The Docker image name used for building and running containers. + +- **Type:** String +- **Valid in:** Global, Project, Local +- **Default:** `"sandcage"` + +```toml +# .sandcage/config.toml +image = "my-custom-sandcage" +``` + +```toml +# ~/.sandcage/config.toml +image = "my-custom-sandcage" +``` + +--- + +### overlay.enabled + +Controls whether overlay mounts are active. When enabled, files in `.sandcage/overlay/` (and `.sandcage/local/overlay/`) are bind-mounted read-only over the corresponding workspace files inside the container. + +- **Type:** Boolean +- **Valid in:** Global, Project, Local +- **Default:** `true` + +```toml +[overlay] +enabled = false +``` + +Local overlays (`.sandcage/local/overlay/`) take precedence over project overlays (`.sandcage/overlay/`). + +--- + +### shadow + +Shadow directories redirect build artifact and dependency directories away from the workspace bind-mount into faster, container-local storage. This prevents large directories like `target/`, `.venv/`, or `node_modules/` from thrashing the host filesystem. + +- **Type:** Object with optional `backend` (string) and `dirs` (map) fields +- **Valid in:** Global, Project, Local +- **Default backend:** `"volume"` (named Docker volume) + +Each entry under `[shadow.dirs]` can override the global backend and set `shared = true` to share the volume across containers for the same project. + +| Backend | Behavior | +|-------------|----------| +| `volume` | Named Docker volume (`sandcage--`), persisted across runs | +| `directory` | Host directory under `~/.sandcage/shadow/`, persisted | +| `tmpfs` | In-memory tmpfs mount, lost when the container stops | + +```toml +[shadow] +backend = "volume" + +[shadow.dirs] +".venv" = {} +"target" = { backend = "directory" } +".cargo/registry" = { shared = true } +"node_modules" = { backend = "tmpfs" } +``` + +Use `sandcage clean` to remove shadow volumes for a project. + +--- + +### tools + +Tool mode declarations control how specific CLI tools behave inside the container. Each entry under `[tools.]` configures one tool. + +- **Type:** Map of tool name to object +- **Valid in:** Project, Local +- **Fields:** + - `mode` — `"in-cage"` (default), `"spoof"`, or `"host"` + - `message` — message printed by a spoof script (only for `mode = "spoof"`) + - `allow` — list of glob patterns permitting arguments (only for `mode = "host"`) + - `deny` — list of glob patterns blocking arguments (only for `mode = "host"`) + +| Mode | Behavior | +|-----------|----------| +| `in-cage` | Tool runs normally inside the container (default) | +| `spoof` | Replaced by a script that prints `message` and exits. Prevents agents from running the tool. | +| `host` | Forwarded to the host via the forwarding daemon. Requires `trusted_projects`. | + +**Allow/deny patterns** use simple `*` wildcard glob matching on the full argument string. Deny patterns are checked first and override allow patterns. If any allow patterns are specified, the command must match at least one. + +```toml +[tools.cargo] +mode = "spoof" +message = "Use `just build` to compile this project." + +[tools.docker] +mode = "host" +allow = ["compose up *", "compose down"] +deny = ["* prune *", "* rm -f *"] + +[tools.uv] +mode = "in-cage" +``` + +--- + +### tasks + +Named operations that can run on the host or inside the container. Host tasks are forwarded via the forwarding daemon and thumper; in-cage tasks run directly inside the container. + +- **Type:** Map of task name to object +- **Valid in:** Project, Local +- **Fields:** + - `where` — `"host"` or `"in-cage"` (where the task executes) + - `run` — the command to execute (string, split on whitespace) + +Host tasks (`where = "host"`) require the project to be in `trusted_projects`. A Justfile overlay is auto-generated for host tasks, making them available as `just ` inside the container. + +```toml +[tasks.build] +where = "host" +run = "cargo build --release" + +[tasks.test] +where = "host" +run = "cargo test" + +[tasks.lint] +where = "in-cage" +run = "clippy" +``` + +--- + +### profiles + +Security profiles applied to the container via `--security-opt`. Profile files are loaded from `.sandcage/profiles/`. + +- **Type:** Object with optional `apparmor` (string) and `seccomp` (string) fields +- **Valid in:** Project, Local +- **Behavior:** Paths are relative to the project root. Missing files are skipped with a warning. Applied on both the bollard and compose backends. + +If a seccomp profile contains `SCMP_ACT_NOTIFY`, sandcage detects this as a fermata hook point and logs accordingly. + +```toml +[profiles] +apparmor = ".sandcage/profiles/apparmor.conf" +seccomp = ".sandcage/profiles/seccomp.json" +``` + +### home + +Named home profile for agent auth and configuration. Each profile is a separate directory under `~/.sandcage/homes/`, containing its own agent credentials, settings, and session history. + +- **Type:** String (profile name) +- **Valid in:** Global, Project, Local +- **Default:** `"default"` +- **CLI override:** `--home ` on any agent or shell subcommand +- **Behavior:** Resolves to `~/.sandcage/homes//`, which is mounted as `/home/agent` inside the container. The directory is created automatically on first use. CLI flag takes precedence over config. + +```toml +# Use a specific home profile for this project +home = "work" +``` + +This allows switching between multiple agent identities (e.g., personal and work Claude accounts) without re-authenticating: + +```sh +sandcage claude --home personal # uses ~/.sandcage/homes/personal/ +sandcage claude --home work # uses ~/.sandcage/homes/work/ +sandcage claude # uses ~/.sandcage/homes/default/ +``` + +**Migration:** If a legacy `~/.sandcage/home/` directory exists from before named profiles were introduced, it is automatically moved to `~/.sandcage/homes/default/` on first run. + +--- + +### Reserved Sections + +The following top-level config section is parsed and validated but **not yet active**: + +- **`forward`** — port/socket forwarding rules (the forwarding daemon uses internal configuration; this section is for future user-facing forwarding config) + +Setting this field will not produce errors, but it has no runtime effect yet. + +--- + +## Services + +Sandcage ships with four built-in services, all enabled by default: + +| Service | Description | Config directory | +|----------|-----------------------|------------------| +| `claude` | Claude Code agent | `.claude` | +| `codex` | Codex agent | `.codex` | +| `gemini` | Gemini CLI agent | `.gemini` | +| `shell` | Interactive zsh shell | (none) | + +Agent services are installed on first run into the persistent home directory (`~/.sandcage/homes//`). They auto-update on subsequent runs. + +To disable a service you do not use: + +```toml +[services.codex] +enabled = false + +[services.gemini] +enabled = false +``` + +Disabled services are excluded from Docker compose generation and cannot be invoked. + +--- + +## Examples + +### Minimal project config + +```toml +# .sandcage/config.toml +packages = ["ripgrep"] + +[toolchains] +nvm = true +``` + +### Full-featured project config + +```toml +# .sandcage/config.toml +packages = ["ripgrep", "fd-find", "postgresql-client"] +mounts = ["/data/models:/models:ro"] +shell = "zsh" + +[env] +RUST_LOG = "debug" +NODE_ENV = "development" + +[toolchains] +rust = true +nvm = true + +[agent_args] +claude = ["--dangerously-skip-permissions"] + +[services.gemini] +enabled = false +``` + +### Local overrides (gitignored) + +```toml +# .sandcage/local/config.local.toml +ssh_mode = "volume" +mounts = ["~/.aws:/home/agent/.aws:ro"] + +[env] +SECRET_API_KEY = "sk-local-dev-key" + +[[ssh_keys]] +host = "github.com" +identity_file = "~/.ssh/id_ed25519" +``` + +### Global config + +```toml +# ~/.sandcage/config.toml +shell = "zsh" + +[env] +EDITOR = "vim" + +[agent_args] +claude = ["--dangerously-skip-permissions"] + +[services.codex] +enabled = false + +trusted_projects = [ + "/home/user/work/trusted-project", +] +``` diff --git a/docs/docker-image.md b/docs/docker-image.md new file mode 100644 index 0000000..6a71fd3 --- /dev/null +++ b/docs/docker-image.md @@ -0,0 +1,113 @@ +# Docker Image + +## Base image contents + +The sandcage image is built on `debian:bookworm-slim` and includes: + +- **Shell:** zsh (default), bash +- **VCS:** git, openssh-client +- **Search:** ripgrep, fd-find +- **Utilities:** jq, curl, sudo, ca-certificates +- **Task runner:** just (installed from upstream binary) + +npm is not included in the base image. Node.js is available via the nvm toolchain fragment when `nvm = true` is set in config. + +uv is not in the base image by default. It is added via the uv toolchain fragment when `uv = true` is set in config. + +The image creates an `agent` user (UID 1000) with a home at `/home/agent` and zsh as the default shell. Sudo is disabled (the passwordless sudo line is commented out in the Dockerfile). + +Pre-created directories: `/workspace`, `/home/agent/.claude`, `/home/agent/.codex`, `/home/agent/.gemini`. + +## How agents are installed + +AI coding agents (Claude Code, Codex, Gemini CLI) are **not** baked into the image. They are installed on first run via their respective entrypoint scripts. The agent home directories (`~/.claude`, `~/.codex`, `~/.gemini`) are persisted across runs through volume mounts to `~/.sandcage/` on the host, so installation only happens once. + +Each service has its own entrypoint script installed at `/usr/local/bin/sandcage--entrypoint`. + +## Building images + +Build the sandcage image with: + +``` +sandcage build +``` + +### Force rebuild + +To bypass the cache and rebuild unconditionally: + +``` +sandcage build --force +``` + +When `--force` is used, Docker's `--no-cache` flag is also passed to ensure layers are rebuilt from scratch. + +### Service filter + +Build only images required for specific services: + +``` +sandcage build claude shell +``` + +Unknown service names produce an error listing available services. + +## Cache awareness + +Sandcage uses hash-based rebuild detection to avoid unnecessary image builds. + +On each build, the SHA-256 hash of the Dockerfile content (bundled or custom) is computed and compared against the stored hash in `~/.sandcage/.build-hashes`. If they match, the build is skipped with an "up to date" message. + +After a successful build, the new hash is persisted. The hash file uses a simple `image:hash` line format. + +This means: + +- Editing the Dockerfile (or switching to/from a custom one) triggers a rebuild automatically. +- The `--force` flag bypasses hash comparison entirely. + +## Custom Dockerfiles + +You can override the bundled Dockerfile by specifying a path in configuration. + +### Global config (`~/.sandcage/config.toml`) + +```toml +[dockerfiles] +sandcage = "/path/to/my/Dockerfile" +``` + +### Project config (`.sandcage/config.toml`) + +```toml +[dockerfiles] +sandcage = "./docker/Dockerfile.custom" +``` + +The path can point to either: + +- A **file** -- used as the Dockerfile with a temporary build context. +- A **directory** -- used as the full build context (must contain a `Dockerfile`). + +### trusted_projects requirement + +Project-level Dockerfile overrides are only applied if the project directory is listed in the global `trusted_projects` setting. This prevents untrusted repositories from injecting arbitrary build instructions. + +```toml +# ~/.sandcage/config.toml +trusted_projects = [ + "/home/user/projects/my-trusted-repo", + "/home/user/work", +] +``` + +A project is considered trusted if its canonical path starts with any entry in `trusted_projects` (prefix matching). If a project specifies `dockerfiles` but is not trusted, the override is silently ignored with a warning. + +Global config overrides (`~/.sandcage/config.toml`) are always trusted. + +## Cross-platform notes + +### UID/GID handling + +On **Linux**, sandcage queries the host user's UID and GID via `id -u` / `id -g` and passes them into the container as `SANDCAGE_UID` and `SANDCAGE_GID`. This ensures files created inside the container have correct ownership on bind-mounted host directories. + +On **Windows**, UID/GID passthrough is not meaningful. Sandcage hardcodes both to `1000`, matching the `agent` user built into the image. File ownership on Windows bind mounts is handled by Docker Desktop's filesystem sharing layer. diff --git a/docs/images/tty-decision-tree.svg b/docs/images/tty-decision-tree.svg new file mode 100644 index 0000000..38fd5f0 --- /dev/null +++ b/docs/images/tty-decision-tree.svg @@ -0,0 +1,88 @@ + + + + + + + + + Interactive Mode: Backend Selection + + + + compose_backend = "compose" + in sandcage.toml? + + + + Windows only + + + + MSYSTEM env var set? + (Git Bash / MSYS2) + + + + Console handle valid? + (GetConsoleMode) + + + + Raw mode supported? + (crossterm probe) + + + + Native TTY + + + + Compose fallback + + + + + no + + + + yes + + + + no + + + + yes + + + + yes + + + + no + + + + yes + + + + no + diff --git a/docs/interactive-mode.md b/docs/interactive-mode.md new file mode 100644 index 0000000..41d4769 --- /dev/null +++ b/docs/interactive-mode.md @@ -0,0 +1,91 @@ +# Interactive Mode + +When you run `sandcage shell` or pass `--shell` to an agent command, sandcage opens an +interactive terminal session inside the container. How that session is wired up depends on +your terminal — sandcage picks the best available method automatically. + +## Two backends + +### Native TTY (default) + +Sandcage talks directly to the Docker API, puts your terminal into raw mode, and relays +keystrokes and output between your terminal and the container. This gives you: + +- **Terminal resize tracking** — the container shell adjusts when you resize the window +- **Full TTY fidelity** — colors, cursor movement, and interactive programs (vim, htop, etc.) + work as expected +- **Graceful shutdown** — the container stops cleanly when the session ends + +This is the same mechanism `docker run -it` uses under the hood. + +### Compose fallback + +When native TTY isn't available, sandcage delegates to `docker compose run`, which manages +the terminal connection itself. You get the same interactive shell — the difference is +invisible in normal use. The fallback activates automatically; no configuration needed. + +## Which backend am I using? + +Sandcage prints a diagnostic line to stderr when it makes its choice: + +``` +sandcage: tty probe → crossterm raw mode OK — using bollard TTY +``` + +or + +``` +sandcage: tty probe → MSYS2/MinGW detected (MSYSTEM set) — using compose +``` + +These messages appear once at session start and don't affect your shell. + +## When does the fallback activate? + +![TTY backend decision tree](./images/tty-decision-tree.svg) + +The detection runs through these checks in order: + +| Check | Triggers fallback when… | +|---|---| +| `compose_backend` config | Set to `"compose"` in `sandcage.toml` — forces compose unconditionally | +| MSYSTEM environment variable (Windows) | Present — indicates Git Bash or MSYS2, whose terminal emulator can't handle raw console mode | +| Console handle (Windows) | Invalid — stdin is piped or redirected, not a real terminal | +| Raw mode probe (all platforms) | Fails — terminal doesn't support raw mode | + +If none of these trigger, sandcage uses native TTY. + +## Exiting the session + +In native TTY mode, your keystrokes are relayed directly to the container shell. This means: + +- **Ctrl+D** or typing `exit` ends the session (same as any shell) +- **Ctrl+C** interrupts the current command inside the container — it does *not* kill the + session. This matches `docker run -it` behavior. + +In compose fallback mode, Ctrl+C ends the session immediately. + +## Forcing compose mode + +If you run into rendering issues in an unusual terminal, you can force the compose backend +by adding this to your `sandcage.toml`: + +```toml +compose_backend = "compose" +``` + +This bypasses all detection and always uses `docker compose run` for interactive sessions. + +## Windows terminal compatibility + +| Terminal | Backend used | +|---|---| +| Windows Terminal / PowerShell | Native TTY | +| CMD (Command Prompt) | Native TTY | +| Git Bash (MinTTY) | Compose fallback | +| MSYS2 / MinGW shells | Compose fallback | +| WSL | Native TTY | + +On macOS and Linux, native TTY is used in all standard terminals (iTerm2, Terminal.app, +GNOME Terminal, Alacritty, kitty, etc.). The compose fallback would only activate if stdin +is piped or the terminal is otherwise non-interactive. diff --git a/docs/ssh.md b/docs/ssh.md new file mode 100644 index 0000000..28ad99a --- /dev/null +++ b/docs/ssh.md @@ -0,0 +1,224 @@ +# SSH Key Access + +## Overview + +Containers launched by sandcage often need SSH access to clone private +repositories or push code. Since the agent user inside the container does not +have access to your host SSH keys by default, sandcage provides a setup command +that copies or mounts the relevant keys into the container environment. + +## Modes + +Sandcage supports two SSH modes, configured via the `ssh_mode` field: + +### Volume mode (recommended) + +``` +ssh_mode: volume +``` + +A Docker named volume (`sandcage-ssh`) is created and populated with only the +keys required for your git remotes. The volume is mounted read-only at +`/home/agent/.ssh` inside the container. A synthesized SSH config is written +into the volume so the agent can connect to the correct hosts with the correct +keys. + +Advantages: + +- Only selected keys are copied (not your entire `~/.ssh` directory). +- A minimal SSH config is synthesized from your host config. +- The volume persists across container restarts without re-reading host files. +- Works identically on Linux, macOS, and Windows (Docker Desktop). + +### Bind mode + +``` +ssh_mode: bind +``` + +Your host `~/.ssh` directory is bind-mounted read-only into the container at +`/home/agent/.ssh:ro`. This exposes all files in that directory to the +container. + +Advantages: + +- Zero setup after initial configuration. +- Changes to host keys are immediately visible. + +Disadvantages: + +- Exposes all keys and config, not just the ones needed. +- On some platforms, file permission translation can cause issues. + +### None + +``` +ssh_mode: none +``` + +No SSH mount is added. Use this if you do not need SSH inside containers or +handle key injection through other means. + +## Setup workflow + +Run the interactive setup: + +``` +sandcage setup ssh +``` + +The command: + +1. Scans `~/.ssh/config` (including `Include` directives) for `Host` blocks + with `User git`. +2. Runs `git remote -v` in the current directory to discover SSH remote hosts. +3. Merges the two sources and displays discovered host/key pairs. +4. Prompts you to select which keys to copy. +5. Asks whether to include `known_hosts`. +6. Populates the `sandcage-ssh` Docker volume with the selected keys and a + synthesized SSH config. +7. Writes the configuration to the appropriate config file. + +### Flags + +| Flag | Effect | +|------|--------| +| `--global` | Write to `~/.sandcage/config.toml` instead of project-local config | +| `--yes` | Accept all defaults without prompting | +| `--bind` | Use bind-mount mode instead of volume mode | +| `--refresh` | Re-populate the volume from existing config (no interactive selection) | + +### Examples + +``` +# Interactive setup for current project +sandcage setup ssh + +# Non-interactive, global config +sandcage setup ssh --global --yes + +# Legacy bind-mount mode +sandcage setup ssh --bind + +# Refresh volume after adding a new key to ~/.ssh +sandcage setup ssh --refresh +``` + +## Configuration + +### Project config (`.sandcage/local/sandcage.local.yml` or `.sandcage/sandcage.yml`) + +```yaml +ssh_mode: volume +ssh_keys: + - host: github.com + identity_file: ~/.ssh/id_ed25519 + - host: gitea + identity_file: ~/.ssh/work_gitea +``` + +### Global config (`~/.sandcage/config.toml`) + +```toml +ssh_mode = "volume" + +[[ssh_keys]] +host = "github.com" +identity_file = "~/.ssh/id_ed25519" + +[[ssh_keys]] +host = "gitea" +identity_file = "~/.ssh/work_gitea" +``` + +### Fields + +| Field | Type | Description | +|-------|------|-------------| +| `ssh_mode` | string | One of `volume`, `bind`, or `none` | +| `ssh_keys` | array | List of host/key pairs to include in the volume | +| `ssh_keys[].host` | string | SSH host alias (matches `Host` line in SSH config) | +| `ssh_keys[].identity_file` | string | Path to the private key (supports `~/` expansion) | + +Configuration layers are merged in order: global, project, local. Later layers +override earlier ones. + +## Refreshing keys + +After adding or rotating SSH keys on the host, update the container volume: + +``` +sandcage setup ssh --refresh +``` + +This re-reads the existing `ssh_keys` configuration and the current +`~/.ssh/config`, then repopulates the `sandcage-ssh` Docker volume with fresh +copies. + +For bind mode, no refresh is needed since the host directory is mounted +directly. + +## Security considerations + +- **Volume mode** copies only the selected private keys into a Docker volume. + The volume is mounted read-only into containers. Keys are owned by the + `agent` user with `600` permissions. +- **Bind mode** mounts the entire `~/.ssh` directory read-only. The container + can read all keys and config present in that directory. +- Neither mode exposes keys to other containers or Docker networks. Keys exist + only on the filesystem inside the running container. +- The `sandcage-ssh` volume persists on disk until explicitly removed + (`docker volume rm sandcage-ssh`). Run `--refresh` or delete the volume when + decommissioning keys. + +## Troubleshooting + +### Volume not found + +``` +sandcage: SSH volume not found — run 'sandcage setup ssh --refresh' to populate it +``` + +The `sandcage-ssh` volume does not exist or was removed. Re-run: + +``` +sandcage setup ssh --refresh +``` + +Or, if no config exists yet: + +``` +sandcage setup ssh +``` + +### Permission denied inside container + +If the agent cannot read keys, the volume population step may have failed or +the image does not have an `agent` user at the expected UID. Verify: + +``` +docker run --rm -v sandcage-ssh:/home/agent/.ssh:ro sandcage:latest ls -la /home/agent/.ssh +``` + +Private keys should be `600` and owned by `agent:agent`. + +### No git SSH hosts found + +``` +sandcage: no git SSH hosts found in remotes or ~/.ssh/config +``` + +This means: + +- The current directory has no SSH git remotes (all remotes use HTTPS). +- `~/.ssh/config` has no `Host` blocks with `User git`. + +Add an SSH remote or configure your SSH config, then re-run setup. + +### Migration from legacy bind mount + +If your config contains a raw `~/.ssh:/home/agent/.ssh:ro` entry in `mounts` +without an `ssh_mode` field, sandcage detects this as a legacy configuration +and offers to migrate to volume mode. Accepting the migration removes the old +mount entry and writes `ssh_mode: volume` with discovered keys. Declining sets +`ssh_mode: bind` explicitly to suppress future prompts. diff --git a/justfile b/justfile new file mode 100644 index 0000000..0a7a6bd --- /dev/null +++ b/justfile @@ -0,0 +1,22 @@ +# Justfile for Sandcage + +# Default: list available recipes. +default: + @just --list + +# Run cargo check. +check: + cargo check + +# Run cargo build. +build: + cargo build + +# Run unit tests. +test: + cargo test --workspace + +# Install sandcage and build images. +install: + cargo install --path crates/sandcage + sandcage build --force diff --git a/sandcage_logo.png b/sandcage_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a93884123b22df15400779632341fb3a9caf7957 GIT binary patch literal 401720 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žM-p7ALw|Xmm4(@LoXj(X&A=gyp&I+F1 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

wE_;ZVB+f3O%0Z%EQ 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 u`PAW@2|wJ= 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

*4wV4fs$4HBq09JA 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 literal 0 HcmV?d00001 diff --git a/templates/claude/settings.json b/templates/claude/settings.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/templates/claude/settings.json @@ -0,0 +1 @@ +{} diff --git a/topology.svg b/topology.svg new file mode 100644 index 0000000..3bb385f --- /dev/null +++ b/topology.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + Host Machine + + + + sandcage CLI + sandcage claude + + + + Project Files + ~/projects/my-app/ + (resolved to git root) + + + + Persistent Home + ~/.sandcage/home/ + + + + SSH Keys (selected) + sandcage-ssh volume + + + + + + + Docker + + + + Container + + + + AI Agent (Claude Code / Codex) + runs as entrypoint + + + + /workspace/my-app + project mounted read-write + + + + /home/agent + config, creds, plugins persist + + + + /home/agent/.ssh (from sandcage-ssh volume) + + + + mount + + + persist + + + volume + + + + reads/writes + + + + 1 + + + 2 + + + 3 + + + 1 CLI orchestrates Docker + 2 Project and home are bind-mounted; SSH keys via named volume + 3 Agent works in mounted workspace — changes visible on host + \ No newline at end of file