Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ SandVault (`sv`) manages a limited user account to sandbox shell commands and AI
</br>
</br>

- **AI ready** - Includes Claude Code, OpenAI Codex, OpenCode, Google Gemini, pi
- **AI ready** - Includes Claude Code, OpenAI Codex, OpenCode, Google Gemini, pi, Muse Code
- **Web and iOS automation** - sandbox access to Chrome / Lightpanda and iOS Simulator
- **Fast context switching** - No VM overhead; instant user switching
- **Passwordless** - switch accounts without a prompt (after setup)
Expand Down Expand Up @@ -94,6 +94,10 @@ Install via git:
# shortcut: sv p
sv pi

# Run Muse Code in the sandbox
# shortcut: sv m
sv muse

# Run command shell in the sandbox
# shortcut: sv s
sv shell
Expand Down Expand Up @@ -182,6 +186,7 @@ By default, SandVault installs AI tools via Homebrew on the host side. With `--n
- **OpenCode** — installed via `curl -fsSL https://opencode.ai/install | bash`
- **Gemini** — installed via `npm install -g @google/gemini-cli`
- **pi** — installed via `npm install -g @earendil-works/pi-coding-agent`
- **Muse Code** — installed via `curl -fsSL https://dev.meta.ai/install.sh | bash`

Tools are installed on first run and reused on subsequent runs.

Expand All @@ -195,6 +200,7 @@ sv -N codex
sv -N opencode
sv -N gemini
sv -N pi
sv -N muse
```

To make native install the default, set `SANDVAULT_ARGS`:
Expand Down Expand Up @@ -258,7 +264,7 @@ Explicit command-line arguments are appended after `SANDVAULT_ARGS`, so they are

## `agentsview` Integration

[`agentsview`](https://github.com/badlogic/agentsview) is a dashboard for AI coding agents (Claude Code, Codex, OpenCode, Gemini, pi). It shows session history, search, and cost tracking. If you have agentsview installed on the host, `sv-agentsview-setup` mirrors sandbox session data so that it appears next to your host-side sessions.
[`agentsview`](https://github.com/badlogic/agentsview) is a dashboard for AI coding agents (Claude Code, Codex, OpenCode, Gemini, pi). It shows session history, search, and cost tracking. If you have agentsview installed on the host, `sv-agentsview-setup` mirrors sandbox session data so that it appears next to your host-side sessions. Muse Code is not included: agentsview has no parser for its session format yet.

```bash
# Detect agentsview, prompt to opt in, and configure
Expand Down Expand Up @@ -408,6 +414,11 @@ Next time you run sandvault, your files will be copied to the sandvault user hom

SandVault supports a headless browser for automation from within the sandbox. The browser runs on the host side and the sandbox connects to it via the Chrome DevTools Protocol (CDP) over localhost. Two backends are supported: **Chrome** (default) and **Lightpanda**.

> **Muse Code:** `sv --browser muse` starts the browser, but muse is not told the
> endpoint exists — it is the one agent with no way to inject a system prompt. The
> `SV_BROWSER_ENDPOINT` variable below is still set in its environment, so mention
> it in your prompt or the project's `AGENTS.md` to make muse aware of it.

### Usage

```bash
Expand Down Expand Up @@ -447,6 +458,11 @@ See also [`./tests/browser/*.js`](./tests/browser) for examples of using Playwri

SandVault can expose the iOS Simulator to sandboxed AI agents for iOS app testing. The simulator runs on the host (it is a GUI app and cannot run inside the sandbox), and an HTTP bridge on localhost translates sandbox-side requests into `xcrun simctl` and [`iosef`](https://github.com/riwsky/iosef) invocations.

> **Muse Code:** `sv --ios muse` boots the simulator, but muse is not told the
> bridge exists — it is the one agent with no way to inject a system prompt. The
> `SV_IOS_SIMULATOR_ENDPOINT` variable below is still set in its environment, so
> mention it in your prompt or the project's `AGENTS.md`.

### Usage

```bash
Expand Down Expand Up @@ -528,6 +544,7 @@ After exploring Docker containers, Podman, sandbox-exec, and virtualization, I n
- Runs OpenCode with `OPENCODE_PERMISSION='{"*":"allow"}'`
- Runs Google Gemini with `--yolo`
- Runs pi with `--approve` to trust project-local files (pi needs no permission bypass)
- Runs Muse Code with `--yolo`, which disables both its approval prompts and its own sandbox — sandvault is already the sandbox, and muse's nested one defaults to proxy-only network and workspace-only writes
- Automates Chrome for web testing (via Chrome DevTools Protocol)
- Automated iOS Simulator for app testing (via `xcrun simctl`, and `iosef`)
- Maintains a clean separation between trusted and untrusted code
Expand Down
47 changes: 47 additions & 0 deletions guest/home/bin/muse
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/bin/bash
set -Eeuo pipefail
trap 'echo >&2 "${BASH_SOURCE[0]}: line $LINENO: $BASH_COMMAND: exitcode $?"' ERR

unset MUSE
if [[ "${SV_NATIVE_INSTALL:-}" == "true" ]]; then
if [[ ! -x "$HOME/.local/bin/muse" ]]; then
echo >&2 "Installing Muse Code natively..."
# MUSE_INSTALL_DIR is the installer's default, set explicitly so the
# binary lands where the line below looks for it regardless of the
# installer's future defaults. MUSE_NO_MODIFY_PATH stops the installer
# appending "export PATH=..." to .zshrc/.zprofile: sandvault owns those
# files and rewrites them from guest/home on every build, so the edit is
# both lost and noise. ~/.local/bin is already on PATH via .zprofile.
curl -fsSL https://dev.meta.ai/install.sh \
| MUSE_INSTALL_DIR="$HOME/.local/bin" MUSE_NO_MODIFY_PATH=1 bash
fi
MUSE="$HOME/.local/bin/muse"
elif command -v brew &>/dev/null ; then
HOMEBREW_PREFIX="${HOMEBREW_PREFIX:-"$(brew --prefix)"}"
if [[ -x "$HOMEBREW_PREFIX/bin/muse" ]]; then
# Homebrew install (the "muse-code" cask ships a "muse" binary)
MUSE="$HOMEBREW_PREFIX/bin/muse"
fi
fi

if [[ -z "${MUSE:-}" || ! -x "$MUSE" ]]; then
echo >&2 "ERROR: muse is not installed"
exit 1
fi

# No sv-tool-prompts.sh source: muse 1.0.2 has no way to inject a session
# prompt -- no --append-system-prompt, no instructions-file option, no
# extra-context directory. It reads instructions only from rules files
# (AGENTS.md and friends), which would mean writing into the user's repo or
# another agent's config and outliving the session. So `sv -b/-i muse` set
# SV_BROWSER_ENDPOINT / SV_IOS_SIMULATOR_ENDPOINT but never announce them;
# the README says so too. Revisit if muse gains a system-prompt flag.

# --yolo is muse's own "approval and sandbox off" switch, the direct analogue of
# codex's --dangerously-bypass-approvals-and-sandbox: muse ships a Seatbelt
# sandbox and staged approval prompts, both ON by default. Sandvault is already
# the sandbox, so muse's nested one adds no protection while defaulting to
# proxy-only network and workspace-only writes. --yolo also trusts the workspace
# for the run, which is what loads its project-local skills and rules.
echo >&2 "$USER: running muse --yolo"
exec "$MUSE" --yolo "$@"
10 changes: 10 additions & 0 deletions helpers/agentsview-paths.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@

AGENTSVIEW_AGENTS=(claude codex opencode gemini pi)

# muse (Muse Code) is deliberately absent. agentsview 0.42.0 has no muse parser
# and no muse config key -- its nearest entry, commandcode_project_dirs
# (~/.commandcode/projects), is a different tool; muse keeps sessions under
# ~/.local/share/muse/sessions. Mirroring that directory would copy files
# agentsview cannot read. Add muse here (SUBDIR/DEFAULT/TOMLKEY, the
# VALID_KEYS and DEFAULT_SUBPATHS tables in agentsview-config.py, and the
# fixtures in scripts/test-agentsview-resync.sh) once agentsview ships one.
# Existing opt-in users then pick it up the documented way: delete
# $SV_PRIVATE_DIR/setup/agentsview-export.state and re-run sv-agentsview-setup.

# Sandbox-side path under /Users/sandvault-$USER/
AGENTSVIEW_SUBDIR_claude=".claude/projects"
AGENTSVIEW_SUBDIR_codex=".codex/sessions"
Expand Down
132 changes: 131 additions & 1 deletion scripts/tests
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,136 @@ EOF
}
queue_test test_pi_wrapper_no_command_v_loop

# muse listing, alongside the opencode and pi help tests.
test_help_lists_muse() {
local output
output=$(sv_cmd --help 2>&1)
if [[ "$output" == *"m, muse"* ]]; then
pass "--help lists muse"
else
fail "--help lists muse" "muse command line" "$output"
fi
}
queue_test test_help_lists_muse

# Muse ships its own Seatbelt sandbox and staged approval prompts, both ON
# by default; --yolo turns both off and trusts the workspace, the direct
# analogue of codex's --dangerously-bypass-approvals-and-sandbox. Anchor it
# to the exec line, like the pi test: --yolo also appears in a comment and
# the startup banner, so a whole-file grep would stay green after it was
# dropped from the exec -- exactly the drift this guards.
test_muse_wrapper_uses_yolo() {
local wrapper="$SCRIPT_DIR/../guest/home/bin/muse"
if [[ ! -x "$wrapper" ]]; then
fail "muse wrapper uses --yolo" "executable wrapper" "$wrapper"
return
fi
if grep -Eq '^exec .*--yolo' "$wrapper"; then
pass "muse wrapper uses --yolo"
else
fail "muse wrapper uses --yolo" \
"--yolo on the exec line" "$wrapper"
fi
}
queue_test test_muse_wrapper_uses_yolo

# Guard the dispatch mapping, as for pi: a typo in (or removal of) the
# m|muse) case arm would pass the wrapper static tests but break 'sv muse'.
test_sv_dispatches_muse() {
if grep -Eq '^[[:space:]]+m[|]muse\)' "$SV_BASE" \
&& grep -A2 -E '^[[:space:]]+m[|]muse\)' "$SV_BASE" | grep -q 'COMMAND=muse'; then
pass "sv dispatches m|muse -> COMMAND=muse"
else
fail "sv dispatches m|muse -> COMMAND=muse" \
"an m|muse) case arm setting COMMAND=muse" \
"missing or broken dispatch arm in $SV_BASE"
fi
}
queue_test test_sv_dispatches_muse

# Same error class as the pi formula test: muse installs from the
# homebrew/cask "muse-code" token, whose name differs from its "muse"
# binary. Passing only the token would look for a "muse-code" binary that
# does not exist, reinstalling on every run.
test_sv_installs_muse_via_brew_cask() {
if grep -F -- 'ensure_brew_tool "muse-code" "muse"' "$SV_BASE" &>/dev/null; then
pass "sv installs muse via the muse-code cask"
else
fail "sv installs muse via the muse-code cask" \
'ensure_brew_tool "muse-code" "muse"' \
"missing or broken install arm in $SV_BASE"
fi
}
queue_test test_sv_installs_muse_via_brew_cask

# The other install path. Muse is not an npm package: its installer drops a
# self-updating launcher into MUSE_INSTALL_DIR and would append PATH lines
# to the shell rc files that sandvault owns and rewrites from guest/home on
# every build, so MUSE_NO_MODIFY_PATH must stay set. Both surface only at
# runtime under 'sv -N muse', which the suite never exercises.
test_muse_wrapper_native_install() {
local wrapper="$SCRIPT_DIR/../guest/home/bin/muse"
if [[ ! -x "$wrapper" ]]; then
fail "muse wrapper native-installs from dev.meta.ai" \
"executable wrapper" "$wrapper"
return
fi
local code
code=$(grep -vE '^[[:space:]]*#' "$wrapper" | sed 's/[[:space:]]#.*$//')
if ! grep -F -- 'https://dev.meta.ai/install.sh' <<< "$code" &>/dev/null; then
fail "muse wrapper native-installs from dev.meta.ai" \
'curl -fsSL https://dev.meta.ai/install.sh' "$wrapper"
elif ! grep -F -- 'MUSE_INSTALL_DIR="$HOME/.local/bin"' <<< "$code" &>/dev/null; then
# Not redundant with the installer's default. This pins the install
# dir to the one path the wrapper then execs, so an upstream default
# change cannot separate them. Were they to diverge, the
# [[ ! -x "$HOME/.local/bin/muse" ]] guard would never become false:
# every 'sv -N muse' would re-download ~230MB, re-run the installer,
# and still exit "ERROR: muse is not installed".
fail "muse wrapper native-installs from dev.meta.ai" \
'MUSE_INSTALL_DIR="$HOME/.local/bin" pinned to the exec path' \
"$wrapper"
elif ! grep -F -- 'MUSE_NO_MODIFY_PATH=1' <<< "$code" &>/dev/null; then
fail "muse wrapper native-installs from dev.meta.ai" \
"MUSE_NO_MODIFY_PATH=1 so the installer leaves .zshrc alone" \
"$wrapper"
else
pass "muse wrapper native-installs from dev.meta.ai"
fi
}
queue_test test_muse_wrapper_native_install

# muse is deliberately NOT in AGENTSVIEW_AGENTS: agentsview 0.42.0 has no
# muse parser or config key, so a mirror would copy files it cannot read.
# Assert the omission is still deliberate rather than half-done -- a muse
# entry with no matching key in agentsview-config.py's VALID_KEYS would
# abort sv-agentsview-setup for every agent, not just muse.
test_agentsview_omits_muse() {
local paths="$SCRIPT_DIR/../helpers/agentsview-paths.sh"
if [[ ! -r "$paths" ]]; then
fail "agentsview omits muse" "readable agentsview-paths.sh" "$paths"
return
fi
# shellcheck source=/dev/null
source "$paths"
local found=false agent
for agent in "${AGENTSVIEW_AGENTS[@]}"; do
[[ "$agent" == "muse" ]] && found=true
done
if [[ "$found" == "true" ]]; then
fail "agentsview omits muse" \
"muse absent from AGENTSVIEW_AGENTS until agentsview supports it" \
"muse is listed; add its key to VALID_KEYS/DEFAULT_SUBPATHS in agentsview-config.py and the scripts/test-agentsview-resync.sh fixtures too"
elif ! grep -q 'muse' "$paths"; then
fail "agentsview omits muse" \
"a comment recording why muse is omitted" \
"no muse note in $paths -- the omission looks accidental"
else
pass "agentsview omits muse"
fi
}
queue_test test_agentsview_omits_muse

# Unknown option
test_unknown_option() {
local output
Expand Down Expand Up @@ -935,7 +1065,7 @@ EOF
fi
local failed=false
local link
for link in claude codex opencode gemini pi; do
for link in claude codex opencode gemini pi muse; do
if [[ -L "$brew_prefix/bin/$link" ]]; then
local link_perms
link_perms=$(/usr/bin/stat -f "%Lp" "$brew_prefix/bin/$link")
Expand Down
15 changes: 13 additions & 2 deletions sv
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,12 @@ install_deps () {
pi)
ensure_brew_tool "pi-coding-agent" "pi"
;;
muse)
# The "muse-code" cask ships a "muse" binary; names differ, so
# both arguments are required. Casks are quarantined on
# download, which ensure_brew_tool's warm-up step clears.
ensure_brew_tool "muse-code" "muse"
;;
*)
# No tool installation needed for other commands
;;
Expand Down Expand Up @@ -1006,11 +1012,12 @@ show_help() {
echo " o, opencode [PATH] Open OpenCode in sandvault"
echo " g, gemini [PATH] Open Google Gemini in sandvault"
echo " p, pi [PATH] Open pi in sandvault"
echo " m, muse [PATH] Open Muse Code in sandvault"
echo " s, shell [PATH] Open shell in sandvault"
echo " b, build Build sandvault"
echo " u, uninstall Remove sandvault; keep shared files"
echo ""
echo "Arguments after -- are passed to the command (claude, codex, opencode, gemini, pi, shell)"
echo "Arguments after -- are passed to the command (claude, codex, opencode, gemini, pi, muse, shell)"
echo ""
echo "Environment:"
echo " SANDVAULT_ARGS Default arguments (prepended to command line)"
Expand Down Expand Up @@ -1142,6 +1149,10 @@ case "${1:-}" in
COMMAND=pi
INITIAL_DIR="${2:-}"
;;
m|muse)
COMMAND=muse
INITIAL_DIR="${2:-}"
;;
s|shell)
COMMAND=
INITIAL_DIR="${2:-}"
Expand Down Expand Up @@ -1828,7 +1839,7 @@ if [[ "$FIX_PERMISSIONS" == "true" ]]; then
# Fix homebrew symlinks for any installed tools
# shellcheck disable=SC2310 # brew_shellenv intentionally used in condition
if brew_shellenv 2>/dev/null; then
for tool_cli in claude codex opencode gemini pi; do
for tool_cli in claude codex opencode gemini pi muse; do
brew_link="$(brew --prefix)/bin/$tool_cli"
if [[ -L "$brew_link" ]]; then
link_perms=$(/usr/bin/stat -f "%Lp" "$brew_link")
Expand Down