commit 8262b5f39d021507c52c44afc9f6d85995372a34 Author: Gabor KΓΆrber Date: Mon Sep 21 14:31:12 2026 +0200 πŸ›°οΈ export from upstream (18069bd) 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 0000000..a938841 Binary files /dev/null and b/sandcage_logo.png differ 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