From 41fe589dfe7b479f85e013c2fe531fd3a740e1ec Mon Sep 17 00:00:00 2001 From: Ali-Akber Saifee Date: Wed, 9 Sep 2026 10:16:52 -0700 Subject: [PATCH 1/6] Install Grok Build in the guest image Add the official installer, put grok and agent on the guest PATH, and require the binary in the golden image. Marketplace baking comes later. --- scripts/guest/grok.sh | 54 +++++++++++++++++++++ scripts/guest/guest-config.sh | 41 +++++++++++----- src/guest.rs | 37 ++++++++++++++- src/lima.rs | 89 ++++++++++++++++++++++++++++------- src/setup.rs | 30 ++++++++++-- 5 files changed, 218 insertions(+), 33 deletions(-) create mode 100644 scripts/guest/grok.sh diff --git a/scripts/guest/grok.sh b/scripts/guest/grok.sh new file mode 100644 index 0000000..2560ce4 --- /dev/null +++ b/scripts/guest/grok.sh @@ -0,0 +1,54 @@ +set -euo pipefail + +# GUEST_USER is exported by the orchestrator (setup.rs / lima.rs). +: "${GUEST_USER:?GUEST_USER must be set by the orchestrator}" + +# Skip if a profile already provided a grok binary (e.g. a test stub). +# Profile post_install scripts run before this script. +# Using if/else (not early `exit 0`) because this file is concatenated +# with the other guest installers; an unconditional exit would skip them. +if [ -x "/home/${GUEST_USER}/.grok/bin/grok" ]; then + echo ' [guest] Grok Build CLI already installed, skipping.' +else + echo ' [guest] Installing Grok Build CLI...' + + # Download the installer to a file first. The `curl | bash` pattern causes + # the installer's prompts to inherit curl's pipe as stdin, hanging in + # non-interactive contexts (cloud-init, chroot). Running from a file with + # stdin from /dev/null avoids this. + INSTALLER=$(mktemp) + chmod 644 "$INSTALLER" + + # Retry with exponential backoff — transient network errors are common + # during cloud-init (DNS not ready, CDN hiccups, etc.). + MAX_RETRIES=4 + RETRY_DELAY=5 + for attempt in $(seq 1 "$MAX_RETRIES"); do + if curl -fsSL -o "$INSTALLER" https://x.ai/cli/install.sh 2>/tmp/grok-curl-err; then + break + fi + CURL_EXIT=$? + CURL_ERR=$(cat /tmp/grok-curl-err 2>/dev/null || true) + if [ "$attempt" -eq "$MAX_RETRIES" ]; then + echo " [guest] ERROR: Failed to download Grok Build installer" \ + "after $MAX_RETRIES attempts." >&2 + echo " [guest] curl exit code: $CURL_EXIT" >&2 + echo " [guest] curl error: ${CURL_ERR:-none}" >&2 + rm -f "$INSTALLER" + exit 1 + fi + echo " [guest] Download failed (attempt $attempt/$MAX_RETRIES," \ + "curl exit $CURL_EXIT), retrying in ${RETRY_DELAY}s..." + sleep "$RETRY_DELAY" + RETRY_DELAY=$((RETRY_DELAY * 2)) + done + + su - "${GUEST_USER}" -c "bash '$INSTALLER'" &2 + exit 1 + fi +fi diff --git a/scripts/guest/guest-config.sh b/scripts/guest/guest-config.sh index 1028dde..1bd9129 100644 --- a/scripts/guest/guest-config.sh +++ b/scripts/guest/guest-config.sh @@ -83,9 +83,10 @@ fi echo "${GUEST_USER} ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/${GUEST_USER}" chmod 440 "/etc/sudoers.d/${GUEST_USER}" -# Ensure home directory exists with correct ownership +# The image ships home skel files as root. This is the guest user's +# home, so they must own its contents. mkdir -p "${GUEST_HOME}" -chown "${GUEST_USER}:${GUEST_USER}" "${GUEST_HOME}" +chown -R "${GUEST_USER}:${GUEST_USER}" "${GUEST_HOME}" chmod 755 "${GUEST_HOME}" # Create .local tree — the Claude Code installer expects to write here @@ -100,30 +101,37 @@ chown -R "${GUEST_USER}:${GUEST_USER}" "${GUEST_HOME}/.ssh" chmod 700 "${GUEST_HOME}/.ssh" chmod 600 "${GUEST_HOME}/.ssh/authorized_keys" -echo " [guest] Adding ${GUEST_USER} ~/.local/bin to /etc/environment PATH..." +echo " [guest] Adding ${GUEST_USER} ~/.grok/bin and ~/.local/bin to /etc/environment PATH..." # pam_env reads /etc/environment for every SSH session — login, non-login, # and non-interactive (`ssh host cmd`) alike — so this is the one layer that -# reaches `coop claude` (a remote command), its Bash-tool subshells, and VS -# Code remote sessions. The .profile/.bashrc appends did not: .profile is -# login-only and the .bashrc line sat below Ubuntu's non-interactive guard. -# pam_env does no variable expansion, so the home path is baked in literally. +# reaches `coop claude` / `coop grok` (a remote command), their Bash-tool +# subshells, and VS Code remote sessions. The .profile/.bashrc appends did +# not: .profile is login-only and the .bashrc line sat below Ubuntu's +# non-interactive guard. pam_env does no variable expansion, so the home +# path is baked in literally. # # /etc/environment is system-wide, so this prepends the guest user's writable -# ~/.local/bin to PATH for every account, including root. That's safe here: +# bin dirs to PATH for every account, including root. That's safe here: # sudo keeps Ubuntu's default secure_path (we set no override), so it ignores -# ~/.local/bin, and the guest is a single-user dev VM where that user already +# those dirs, and the guest is a single-user dev VM where that user already # has passwordless root — there is no privilege boundary to cross. -if ! grep -q "^PATH=\"${GUEST_HOME}/.local/bin:" /etc/environment 2>/dev/null; then - if grep -q '^PATH="' /etc/environment 2>/dev/null; then - sed -i "s|^PATH=\"|PATH=\"${GUEST_HOME}/.local/bin:|" /etc/environment +if ! grep -q "^PATH=\"${GUEST_HOME}/.grok/bin:" /etc/environment 2>/dev/null; then + if grep -q "^PATH=\"${GUEST_HOME}/.local/bin:" /etc/environment 2>/dev/null; then + sed -i "s|^PATH=\"${GUEST_HOME}/.local/bin:|PATH=\"${GUEST_HOME}/.grok/bin:${GUEST_HOME}/.local/bin:|" /etc/environment + elif grep -q '^PATH="' /etc/environment 2>/dev/null; then + sed -i "s|^PATH=\"|PATH=\"${GUEST_HOME}/.grok/bin:${GUEST_HOME}/.local/bin:|" /etc/environment else - echo "PATH=\"${GUEST_HOME}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games\"" >> /etc/environment + echo "PATH=\"${GUEST_HOME}/.grok/bin:${GUEST_HOME}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games\"" >> /etc/environment fi fi echo ' [guest] Symlinking claude into system PATH...' ln -sf "${GUEST_HOME}/.local/bin/claude" /usr/local/bin/claude +echo ' [guest] Symlinking grok into system PATH...' +ln -sf "${GUEST_HOME}/.grok/bin/grok" /usr/local/bin/grok +ln -sf "${GUEST_HOME}/.grok/bin/agent" /usr/local/bin/agent + echo ' [guest] Installing claude-yolo shortcut...' cat > /usr/local/bin/claude-yolo <<'YOLOEOF' #!/bin/bash @@ -131,6 +139,13 @@ exec claude --dangerously-skip-permissions "$@" YOLOEOF chmod 755 /usr/local/bin/claude-yolo +echo ' [guest] Installing grok-yolo shortcut...' +cat > /usr/local/bin/grok-yolo <<'YOLOEOF' +#!/bin/bash +exec grok --always-approve --trust --cwd /workspace "$@" +YOLOEOF +chmod 755 /usr/local/bin/grok-yolo + echo ' [guest] Installing codex-yolo shortcut...' cat > /usr/local/bin/codex-yolo <<'YOLOEOF' #!/bin/bash diff --git a/src/guest.rs b/src/guest.rs index 757e540..5974849 100644 --- a/src/guest.rs +++ b/src/guest.rs @@ -113,6 +113,14 @@ impl GuestUser { pub fn claude_bin(&self) -> GuestPath { GuestPath::new(format!("/home/{}/.local/bin/claude", self.0)) } + + /// Where the Grok Build installer places the per-user binary. + /// The official installer writes to `~/.grok/bin/grok` and a same-file + /// `agent` link; coop calls this path directly so launch does not depend + /// on PATH resolution. + pub fn grok_bin(&self) -> GuestPath { + GuestPath::new(format!("/home/{}/.grok/bin/grok", self.0)) + } } /// Where the Codex CLI installer places the system-wide binary. @@ -175,11 +183,12 @@ impl From for String { /// build shell commands or inspect the chroot get path semantics for /// free (and the `/usr/bin/docker`/`/usr/bin/gh` entries can't be /// mistaken for host paths). -pub fn required_guest_binaries(user: &GuestUser) -> [GuestPath; 9] { +pub fn required_guest_binaries(user: &GuestUser) -> [GuestPath; 10] { [ GuestPath::new("/usr/bin/docker"), GuestPath::new("/usr/bin/gh"), user.claude_bin(), + user.grok_bin(), codex_bin(), codex_code_mode_host_bin(), codex_account_bin(), @@ -235,6 +244,7 @@ pub const SCRIPT_DOCKER_REPO: &str = include_str!("../scripts/guest/docker-repo. pub const SCRIPT_CLAUDE_CODE: &str = include_str!("../scripts/guest/claude-code.sh"); pub const SCRIPT_CODEX: &str = include_str!("../scripts/guest/codex.sh"); pub const SCRIPT_CODEX_ACCOUNT: &str = include_str!("../scripts/guest/codex-account.sh"); +pub const SCRIPT_GROK: &str = include_str!("../scripts/guest/grok.sh"); /// Packages installed into every golden image. /// @@ -767,6 +777,26 @@ mod tests { assert_eq!(plugins, vec!["p1@a".to_string(), "p2@b".to_string()]); } + #[test] + fn grok_script_downloads_installer_to_file() { + assert!( + SCRIPT_GROK.contains("https://x.ai/cli/install.sh"), + "Grok installer should download the official install script", + ); + assert!( + SCRIPT_GROK.contains("su - \"${GUEST_USER}\""), + "Grok installer should run as the guest user", + ); + assert!( + SCRIPT_GROK.contains("/.grok/bin/grok"), + "Grok installer should verify ~/.grok/bin/grok", + ); + assert!( + !SCRIPT_GROK.contains("trap "), + "Grok installer must not replace the concatenated script's EXIT trap" + ); + } + #[test] fn all_builtins_resolve() { let custom = HashMap::new(); @@ -950,6 +980,11 @@ mod tests { .any(|b| b.to_string() == "/usr/local/bin/codex-account"), "guest image should include the Codex account-auth wrapper", ); + assert!( + bins.iter() + .any(|b| b.to_string() == "/home/ubuntu/.grok/bin/grok"), + "guest image should include Grok Build", + ); // The wrapper is written unconditionally by the provision script, so // verifying it alone cannot catch the packages failing to install. for tool in [ diff --git a/src/lima.rs b/src/lima.rs index a731aa9..1ed5cfb 100644 --- a/src/lima.rs +++ b/src/lima.rs @@ -12,7 +12,7 @@ use crate::config::{CoopConfig, GiB, ImageName, Instance, InstanceName, MiB}; use crate::devcontainer_oci::{ResolvedFeature, installed_features}; use crate::guest::{ BASE_PACKAGES, DOCKER_PACKAGES, GH_PACKAGES, GuestUser, ProfileDef, SCRIPT_CLAUDE_CODE, - SCRIPT_CODEX, SCRIPT_CODEX_ACCOUNT, SCRIPT_DOCKER_REPO, SCRIPT_GH_REPO, + SCRIPT_CODEX, SCRIPT_CODEX_ACCOUNT, SCRIPT_DOCKER_REPO, SCRIPT_GH_REPO, SCRIPT_GROK, }; use crate::remote_command::RemoteCommand; use crate::setup::{SetupOptions, TEMPLATE_VERSION, TemplateConfig, utc_timestamp}; @@ -1496,6 +1496,10 @@ fn compose_provision_script( s.push_str(SCRIPT_CODEX_ACCOUNT); s.push('\n'); + // Grok Build (official installer, runs as the guest user) + s.push_str(SCRIPT_GROK); + s.push('\n'); + // Test hook: inject a provision failure to exercise error detection. // Only activates when COOP_TEST_INJECT_PROVISION_FAILURE is set. if std::env::var("COOP_TEST_INJECT_PROVISION_FAILURE").is_ok() { @@ -1539,8 +1543,9 @@ echo "{user} ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/{user}" chmod 440 "/etc/sudoers.d/{user}" echo " [guest] Setting up home and SSH for {user} user..." +# Image skel files arrive as root; this is the guest user's home. mkdir -p "{home}" -chown "{user}:{user}" "{home}" +chown -R "{user}:{user}" "{home}" chmod 755 "{home}" install -d -o "{user}" -g "{user}" "{home}/.local" install -d -o "{user}" -g "{user}" "{home}/.local/bin" @@ -1551,30 +1556,37 @@ chown -R "{user}:{user}" "{home}/.ssh" chmod 700 "{home}/.ssh" chmod 600 "{home}/.ssh/authorized_keys" -echo " [guest] Adding {user} ~/.local/bin to /etc/environment PATH..." +echo " [guest] Adding {user} ~/.grok/bin and ~/.local/bin to /etc/environment PATH..." # pam_env reads /etc/environment for every SSH session — login, non-login, # and non-interactive (`ssh host cmd`) alike — so this is the one layer that -# reaches `coop claude` (a remote command), its Bash-tool subshells, and VS -# Code remote sessions. The .profile/.bashrc appends did not: .profile is -# login-only and the .bashrc line sat below Ubuntu's non-interactive guard. -# pam_env does no variable expansion, so the home path is baked in literally. +# reaches `coop claude` / `coop grok` (a remote command), their Bash-tool +# subshells, and VS Code remote sessions. The .profile/.bashrc appends did +# not: .profile is login-only and the .bashrc line sat below Ubuntu's +# non-interactive guard. pam_env does no variable expansion, so the home +# path is baked in literally. # # /etc/environment is system-wide, so this prepends the guest user's writable -# ~/.local/bin to PATH for every account, including root. That's safe here: +# bin dirs to PATH for every account, including root. That's safe here: # sudo keeps Ubuntu's default secure_path (we set no override), so it ignores -# ~/.local/bin, and the guest is a single-user dev VM where that user already +# those dirs, and the guest is a single-user dev VM where that user already # has passwordless root — there is no privilege boundary to cross. -if ! grep -q '^PATH="{home}/.local/bin:' /etc/environment 2>/dev/null; then - if grep -q '^PATH="' /etc/environment 2>/dev/null; then - sed -i 's|^PATH="|PATH="{home}/.local/bin:|' /etc/environment +if ! grep -q '^PATH="{home}/.grok/bin:' /etc/environment 2>/dev/null; then + if grep -q '^PATH="{home}/.local/bin:' /etc/environment 2>/dev/null; then + sed -i 's|^PATH="{home}/.local/bin:|PATH="{home}/.grok/bin:{home}/.local/bin:|' /etc/environment + elif grep -q '^PATH="' /etc/environment 2>/dev/null; then + sed -i 's|^PATH="|PATH="{home}/.grok/bin:{home}/.local/bin:|' /etc/environment else - echo 'PATH="{home}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games"' >> /etc/environment + echo 'PATH="{home}/.grok/bin:{home}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games"' >> /etc/environment fi fi echo ' [guest] Symlinking claude into system PATH...' ln -sf "{home}/.local/bin/claude" /usr/local/bin/claude +echo ' [guest] Symlinking grok into system PATH...' +ln -sf "{home}/.grok/bin/grok" /usr/local/bin/grok +ln -sf "{home}/.grok/bin/agent" /usr/local/bin/agent + echo ' [guest] Installing claude-yolo shortcut...' cat > /usr/local/bin/claude-yolo <<'YOLOEOF' #!/bin/bash @@ -1582,6 +1594,13 @@ exec claude --dangerously-skip-permissions "$@" YOLOEOF chmod 755 /usr/local/bin/claude-yolo +echo ' [guest] Installing grok-yolo shortcut...' +cat > /usr/local/bin/grok-yolo <<'YOLOEOF' +#!/bin/bash +exec grok --always-approve --trust --cwd /workspace "$@" +YOLOEOF +chmod 755 /usr/local/bin/grok-yolo + echo ' [guest] Installing codex-yolo shortcut...' cat > /usr/local/bin/codex-yolo <<'YOLOEOF' #!/bin/bash @@ -1982,9 +2001,10 @@ mod tests { // PATH is set in /etc/environment (pam_env applies it to every SSH // session), with the guest home interpolated as a literal path. assert!( - script - .contains("sed -i 's|^PATH=\"|PATH=\"/home/ubuntu/.local/bin:|' /etc/environment"), - "should prepend ~/.local/bin to /etc/environment PATH", + script.contains( + "sed -i 's|^PATH=\"|PATH=\"/home/ubuntu/.grok/bin:/home/ubuntu/.local/bin:|' /etc/environment" + ), + "should prepend ~/.grok/bin and ~/.local/bin to /etc/environment PATH", ); // The old PATH appends to .profile/.bashrc are gone (see issue #248). assert!( @@ -1994,6 +2014,24 @@ mod tests { ); } + #[test] + fn provision_script_chowns_guest_home_recursively() { + // Image skel files arrive as root; the guest must own their home. + let script = compose_provision_script( + "ssh-ed25519 AAAA test@test", + &[], + &[], + &GuestUser::default(), + ); + assert!( + script + .lines() + .any(|line| line.trim() == r#"chown -R "ubuntu:ubuntu" "/home/ubuntu""#), + "guest home must be chowned recursively so image skel files \ + are writable by the guest user:\n{script}" + ); + } + #[test] fn provision_script_post_install_without_trailing_newline() { let profiles = vec![profile("test", &["curl"], None, Some("echo done"))]; @@ -2080,6 +2118,25 @@ mod tests { ); } + #[test] + fn provision_script_installs_grok() { + let script = compose_provision_script( + "ssh-ed25519 AAAA test@test", + &[], + &[], + &GuestUser::default(), + ); + + assert!( + script.contains("Installing Grok Build CLI"), + "Lima provision script should install Grok Build CLI", + ); + assert!( + script.contains("https://x.ai/cli/install.sh"), + "Lima provision script should use the official Grok installer", + ); + } + // ── inject_mounts ─────────────────────────────────────────── #[test] diff --git a/src/setup.rs b/src/setup.rs index 0454cd9..2cc3442 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -13,7 +13,8 @@ use crate::config::{CoopConfig, ImageName, Instance, InstanceName}; use crate::devcontainer_oci::{InstalledFeature, ResolvedFeature, installed_features}; use crate::guest::{ BASE_PACKAGES, DOCKER_PACKAGES, GH_PACKAGES, GuestUser, ProfileDef, SCRIPT_CLAUDE_CODE, - SCRIPT_CODEX, SCRIPT_CODEX_ACCOUNT, SCRIPT_DOCKER_REPO, SCRIPT_GH_REPO, resolve_profiles, + SCRIPT_CODEX, SCRIPT_CODEX_ACCOUNT, SCRIPT_DOCKER_REPO, SCRIPT_GH_REPO, SCRIPT_GROK, + resolve_profiles, }; use crate::sha256_hash::Sha256Hash; @@ -690,7 +691,7 @@ fn build_template( " 3. Create a {} GiB ext4 template image", cfg.vm.template_size_gib ); - eprintln!(" 4. Install Docker, Claude Code, Codex, and profile packages"); + eprintln!(" 4. Install Docker, Claude Code, Codex, Grok Build, and profile packages"); eprintln!(" Image: {image}"); eprintln!(" Output: {}", cfg.template_path_for(image).display()); eprintln!(); @@ -889,6 +890,8 @@ fn compose_recipe( // Codex installs as a package with stable entrypoints under /usr/local/bin. s.push_str(SCRIPT_CODEX); s.push_str(SCRIPT_CODEX_ACCOUNT); + // Grok Build installs under ~/.grok/bin for the guest user. + s.push_str(SCRIPT_GROK); s } @@ -1446,7 +1449,7 @@ fn install_guest_packages( guest_user: &GuestUser, builder_timeout: Option, ) -> Result<()> { - eprintln!(" Installing guest packages (Docker, Claude Code, Codex)..."); + eprintln!(" Installing guest packages (Docker, Claude Code, Codex, Grok Build)..."); eprintln!(" This requires sudo and may take several minutes."); let template_str = image_path.display().to_string(); @@ -1789,6 +1792,14 @@ mod tests { "codex-yolo should route through the account wrapper so keyring \ mode works from an in-guest shell", ); + assert!( + script.contains("Installing Grok Build CLI"), + "base recipe should install Grok Build CLI", + ); + assert!( + script.contains("https://x.ai/cli/install.sh"), + "base recipe should use the official Grok installer", + ); no_consecutive_concat(&script); } @@ -1802,6 +1813,19 @@ mod tests { ); } + #[test] + fn compose_recipe_chowns_guest_home_recursively() { + // Image skel files arrive as root; the guest must own their home. + let script = compose_recipe(&[], &[], &[], &GuestUser::default()); + assert!( + script.lines().any(|line| { + line.trim() == r#"chown -R "${GUEST_USER}:${GUEST_USER}" "${GUEST_HOME}""# + }), + "guest home must be chowned recursively so squashfs skel files \ + are writable by the guest user:\n{script}" + ); + } + #[test] fn template_config_loads_legacy_json_without_guest_user_field() { // Pre-PR images on disk have no `guest_user` field; the serde From f0ff6542224dc98fca0b4af023141d1abd341219 Mon Sep 17 00:00:00 2001 From: Ali-Akber Saifee Date: Wed, 9 Sep 2026 10:16:53 -0700 Subject: [PATCH 2/6] Add [grok] configuration Parse api_key, env_forward, config_dir, marketplaces, plugins, and Model Context Protocol servers the same way [claude] and [codex] work. --- config.example.toml | 18 +++++++ src/config.rs | 129 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/config.example.toml b/config.example.toml index 6f49a82..76a078a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -111,6 +111,24 @@ # model = "gpt-oss:120b" # auth_token = "sk-..." # Optional; permissive servers ignore it. +# [grok] +# config_dir = "~/.grok" # AGENTS.md, auth.json, config.toml, rules/, skills/, commands/, plugins/ (false to disable) +# env_forward = ["CUSTOM_TOKEN"] # Extra env vars to forward to guest +# marketplaces = ["owner/grok-plugins"] # owner/repo, git URL, or local path +# plugins = ["my-skill@grok-plugins"] # plugin to install (`grok plugin install --trust`) +# +# Secrets: `api_key` accepts the same "cmd:" prefix as `claude.api_key`. +# api_key = "cmd:op read op://Private/xAI/credential" +# api_key = "xai-..." +# Host `~/.grok/auth.json` is copied into the guest (same as Codex). A +# copied session token takes precedence over `XAI_API_KEY`. If there is +# no host file, sign in with `coop grok -- login --device-auth`. + +# [grok.mcp_servers.my-server] +# command = "npx" +# args = ["-y", "@example/mcp-server"] +# env = { API_KEY = "MY_HOST_ENV_VAR" } + # [proxy] # Host-side credential-injecting proxy # # (issue #411). Opt-in: when set, coop runs # # a `coop-proxy` process on the host and the diff --git a/src/config.rs b/src/config.rs index 95191a9..bd47572 100644 --- a/src/config.rs +++ b/src/config.rs @@ -722,6 +722,10 @@ pub struct CoopConfig { #[serde(default)] pub codex: CodexConfig, + /// Grok Build config forwarding settings + #[serde(default)] + pub grok: GrokConfig, + /// Host-side credential-injecting proxy (issue #411). Opt-in: when an /// upstream is configured, the real credential stays on the host and the /// guest is pointed at a local proxy instead of receiving the key. @@ -1527,6 +1531,33 @@ pub struct CodexConfig { pub local_model: Option, } +#[derive(Debug, Serialize, Deserialize)] +pub struct GrokConfig { + /// xAI API key (forwarded via `SendEnv`, never written to disk) + pub api_key: Option>, + + /// Additional env var names to forward from host to guest via SSH + #[serde(default)] + pub env_forward: Vec, + + /// Plugin marketplace sources (URL, path, or GitHub repo) + #[serde(default)] + pub marketplaces: Vec, + + /// Plugins to install from marketplaces + #[serde(default)] + pub plugins: Vec, + + /// MCP servers to merge into the guest `~/.grok/config.toml` + #[serde(default)] + pub mcp_servers: HashMap, + + /// Source directory for Grok Build files (AGENTS.md, auth.json, config.toml, + /// rules/, skills/, commands/, plugins/) + #[serde(default)] + pub config_dir: ConfigDir, +} + /// Codex cloud authentication mode. /// /// `ApiKey` preserves the historical behavior: coop forwards @@ -1973,6 +2004,7 @@ impl CoopConfig { fn expand_user_paths(&mut self) { expand_marketplaces(&mut self.claude.marketplaces); expand_marketplaces(&mut self.codex.marketplaces); + expand_marketplaces(&mut self.grok.marketplaces); for profile in self.profiles.values_mut() { expand_marketplaces(&mut profile.marketplaces); } @@ -2069,6 +2101,15 @@ impl CoopConfig { )); } + if let ConfigDir::Custom(ref path) = self.grok.config_dir + && !path.is_dir() + { + errors.push(format!( + "grok.config_dir '{}' does not exist or is not a directory", + path.display() + )); + } + if self.codex.auth.uses_chatgpt_account() && self.proxy.openai.is_some() { errors.push( "codex.auth = \"chatgpt\" conflicts with [proxy.openai]; \ @@ -2084,6 +2125,7 @@ impl CoopConfig { &mut errors, ); check_local_marketplaces("codex.marketplaces", &self.codex.marketplaces, &mut errors); + check_local_marketplaces("grok.marketplaces", &self.grok.marketplaces, &mut errors); // `[claude.local_model]` / `[codex.local_model]` invariants // (http(s) scheme, present host, non-empty model) are enforced by @@ -2368,6 +2410,7 @@ impl Default for CoopConfig { setup: SetupConfig::default(), claude: ClaudeConfig::default(), codex: CodexConfig::default(), + grok: GrokConfig::default(), proxy: ProxyConfig::default(), guest_env: BTreeMap::new(), profiles: HashMap::new(), @@ -2429,6 +2472,19 @@ impl Default for CodexConfig { } } +impl Default for GrokConfig { + fn default() -> Self { + Self { + api_key: std::env::var("XAI_API_KEY").ok().map(Secret::new), + env_forward: Vec::new(), + marketplaces: Vec::new(), + plugins: Vec::new(), + mcp_servers: HashMap::new(), + config_dir: ConfigDir::Default, + } + } +} + // ── Image info ──────────────────────────────────────────────── /// Metadata about a named golden image. @@ -3537,6 +3593,47 @@ mod tests { assert!(serde_json::from_str::(json).is_err()); } + #[test] + fn grok_config_all_fields() { + let json = r#"{ + "api_key": "xai-test", + "env_forward": ["MYORG_KEY"], + "marketplaces": ["https://github.com/example/grok-plugins"], + "plugins": ["my-skill@grok-plugins"], + "mcp_servers": { + "sentry": { + "type": "http", + "url": "https://mcp.sentry.dev/mcp" + } + } + }"#; + let cfg: GrokConfig = serde_json::from_str(json).unwrap(); + assert_eq!( + cfg.api_key.as_ref().map(|s| s.expose().as_str()), + Some("xai-test") + ); + assert_eq!(cfg.env_forward, vec![EnvVarName::new("MYORG_KEY").unwrap()]); + assert_eq!( + cfg.marketplaces, + vec!["https://github.com/example/grok-plugins".to_string()] + ); + assert_eq!(cfg.plugins, vec!["my-skill@grok-plugins".to_string()]); + assert_eq!(cfg.mcp_servers.len(), 1); + assert!(cfg.mcp_servers.contains_key("sentry")); + } + + #[test] + fn grok_config_all_defaults() { + let json = "{}"; + let cfg: GrokConfig = serde_json::from_str(json).unwrap(); + assert!(cfg.api_key.is_none()); + assert!(cfg.env_forward.is_empty()); + assert!(cfg.marketplaces.is_empty()); + assert!(cfg.plugins.is_empty()); + assert!(cfg.mcp_servers.is_empty()); + assert_eq!(cfg.config_dir, ConfigDir::Default); + } + // ── LocalModel ─────────────────────────────────────────── #[test] @@ -5442,6 +5539,16 @@ skip = ["not-a-slug"] ); } + #[test] + fn grok_config_dir_deserializes_custom_path() { + let json = r#"{"grok": {"config_dir": "/custom/path"}}"#; + let cfg: CoopConfig = serde_json::from_str(json).unwrap(); + assert_eq!( + cfg.grok.config_dir, + ConfigDir::Custom(ConfigPath::new("/custom/path")) + ); + } + #[test] fn config_dir_deserializes_disabled() { let json = r#"{"claude": {"config_dir": false}}"#; @@ -5518,6 +5625,17 @@ skip = ["not-a-slug"] ); } + #[test] + fn validate_rejects_nonexistent_grok_config_dir() { + let mut cfg = CoopConfig::default(); + cfg.grok.config_dir = ConfigDir::Custom(ConfigPath::new("/nonexistent/config")); + let err = cfg.validate().unwrap_err(); + assert!( + err.to_string().contains("grok.config_dir"), + "expected grok config_dir error, got: {err}" + ); + } + #[test] fn validate_passes_with_disabled_config_dir() { let mut cfg = CoopConfig::default(); @@ -5942,6 +6060,17 @@ skip = ["not-a-slug"] ); } + #[test] + fn grok_config_api_key_debug_redacts() { + let json = r#"{"api_key": "xai-real-secret"}"#; + let cfg: GrokConfig = serde_json::from_str(json).unwrap(); + let debug = format!("{cfg:?}"); + assert!( + !debug.contains("xai-real-secret"), + "GrokConfig Debug leaked api_key: {debug}" + ); + } + #[test] fn pat_entry_token_debug_redacts() { let entry = PatEntry { From 76fbd5daec1347eb8fa981e4f56efa8f600ba0e7 Mon Sep 17 00:00:00 2001 From: Ali-Akber Saifee Date: Wed, 9 Sep 2026 12:12:56 -0700 Subject: [PATCH 3/6] Launch and bootstrap Grok Build in the guest Add coop grok, forward XAI_API_KEY, and copy host allowlisted files without following directory symlinks. Write managed settings and folder trust, drop the host [plugins] table, set a copied auth.json to 0600, and install configured plugins. coop agent update --grok runs grok update. --- config.example.toml | 4 +- src/backend.rs | 744 +++++++++++++++++++++++++++++++++++++- src/commands/agent.rs | 186 +++++++--- src/commands/lifecycle.rs | 89 +++++ src/commands/mod.rs | 5 +- src/config.rs | 19 +- src/guest.rs | 25 ++ src/lib.rs | 78 +++- src/lima.rs | 26 +- src/setup.rs | 8 + tests/integration.sh | 131 ++++++- 11 files changed, 1229 insertions(+), 86 deletions(-) diff --git a/config.example.toml b/config.example.toml index 76a078a..9837d69 100644 --- a/config.example.toml +++ b/config.example.toml @@ -112,10 +112,10 @@ # auth_token = "sk-..." # Optional; permissive servers ignore it. # [grok] -# config_dir = "~/.grok" # AGENTS.md, auth.json, config.toml, rules/, skills/, commands/, plugins/ (false to disable) +# config_dir = "~/.grok" # AGENTS.md, auth.json, config.toml, lsp.json, rules/, skills/, commands/, plugins/, hooks/, agents/, workflows/ (false to disable) # env_forward = ["CUSTOM_TOKEN"] # Extra env vars to forward to guest # marketplaces = ["owner/grok-plugins"] # owner/repo, git URL, or local path -# plugins = ["my-skill@grok-plugins"] # plugin to install (`grok plugin install --trust`) +# plugins = ["my-skill"] # plugin name (`grok plugin install --trust`) # # Secrets: `api_key` accepts the same "cmd:" prefix as `claude.api_key`. # api_key = "cmd:op read op://Private/xAI/credential" diff --git a/src/backend.rs b/src/backend.rs index 253e76c..d3169ae 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -4,7 +4,7 @@ use std::fs; use std::num::{NonZeroU8, NonZeroU16}; use std::path::{Path, PathBuf}; use std::process::Command; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; use indexmap::IndexMap; @@ -49,7 +49,7 @@ pub enum LogMode { /// mutation of the process-global environment. /// /// The whole struct is secret-bearing by construction (entries are -/// `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GITHUB_TOKEN`, plus any +/// `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `XAI_API_KEY`, `GITHUB_TOKEN`, plus any /// user-configured `env_forward` values), so `Debug` redacts every /// value. Variable *names* are preserved because they are useful in /// diagnostics and are not themselves secret. @@ -1376,6 +1376,7 @@ pub fn prepare_env_forwarding( ) -> Result { let claude = &cfg.claude; let codex = &cfg.codex; + let grok = &cfg.grok; let codex_account_auth = codex.auth.uses_chatgpt_account(); let suppress_openai_key = suppress_openai_key || codex_account_auth; // Only ever read under `suppress_openai_key`, so there is no "not @@ -1418,6 +1419,16 @@ pub fn prepare_env_forwarding( env.set("OPENAI_API_KEY", key); } + // XAI_API_KEY: prefer config, fall back to process env. Never written + // to guest disk — forwarded via SSH SendEnv on every session. + if let Some(key) = &grok.api_key { + let resolved = crate::config::resolve_cmd_value(key.expose()) + .context("Failed to resolve grok.api_key")?; + env.set("XAI_API_KEY", resolved); + } else if let Ok(key) = std::env::var("XAI_API_KEY") { + env.set("XAI_API_KEY", key); + } + // GITHUB_TOKEN: resolve via configured strategy if let Some(token) = resolve_github_token(cfg.github.as_ref(), repo)? { env.set("GITHUB_TOKEN", token); @@ -1444,8 +1455,17 @@ pub fn prepare_env_forwarding( } }; - // User-specified env_forward vars from process environment - for name in claude.env_forward.iter().chain(codex.env_forward.iter()) { + // User-specified env_forward vars from process environment, plus + // host names referenced by Grok stdio MCP `env` mappings (those + // become `${NAME}` in the guest config and must exist there). + let grok_mcp_env_names = grok.stdio_env_host_names(); + for name in claude + .env_forward + .iter() + .chain(codex.env_forward.iter()) + .chain(grok.env_forward.iter()) + .chain(grok_mcp_env_names.iter()) + { if suppressed.contains(&name.as_str()) { let reason = suppression_reason(name.as_str()); tracing::warn!("{reason}: ignoring env_forward entry '{name}'"); @@ -1513,6 +1533,7 @@ pub fn bootstrap_agents( bootstrap_claude(session, cfg, inst, mode, guest_host)?; bootstrap_codex(session, cfg, inst, mode, guest_host)?; + bootstrap_grok(session, cfg, inst, mode)?; Ok(()) } @@ -1759,6 +1780,60 @@ fn bootstrap_codex( result } +/// Bootstrap Grok Build in the guest declaratively. +/// +/// Copies allowlisted user content, writes managed permission settings and +/// workspace folder trust, merges configured MCP servers into +/// `~/.grok/config.toml`, and (on first boot) installs marketplaces/plugins +/// not already baked into the golden image. +fn bootstrap_grok( + session: &SshSession, + cfg: &CoopConfig, + inst: &crate::config::Instance, + mode: BootMode, +) -> Result<()> { + let grok = &cfg.grok; + let grok_bin = persisted_guest_user(cfg, &inst.image).grok_bin(); + + if let BootMode::FirstBoot = mode { + let needs_grok_cli = !grok.marketplaces.is_empty() + || !grok.plugins.is_empty() + || !grok.mcp_servers.is_empty(); + + if needs_grok_cli + && !session + .target + .exec_ok(RemoteCommand::new().literal("test -x ").arg(&grok_bin)) + { + bail!( + "Grok Build CLI is not installed in the guest.\n\ + The golden image may have been built before the \ + installer was added, or the install failed silently.\n\ + Run `coop setup --rebuild` to rebuild the image." + ); + } + } + + copy_grok_config(&session.target, &grok.config_dir)?; + write_managed_grok_config(&session.target, &grok.mcp_servers)?; + write_workspace_folder_trust(&session.target)?; + + if let BootMode::FirstBoot = mode { + let (missing_marketplaces, missing_plugins) = compute_grok_plugin_delta(cfg, &inst.image); + + if !missing_marketplaces.is_empty() { + install_grok_marketplaces(session, &grok_bin, &missing_marketplaces)?; + } + + if !missing_plugins.is_empty() { + install_grok_plugins(session, &grok_bin, &missing_plugins)?; + } + } + + tracing::info!("Grok Build bootstrap complete"); + Ok(()) +} + fn codex_missing_guest_cli_message() -> &'static str { "Codex CLI is not installed in the guest.\n\ The golden image may have been built before Codex support \ @@ -1929,6 +2004,21 @@ fn compute_codex_plugin_delta(cfg: &CoopConfig, image: &ImageName) -> (Vec (Vec, Vec) { + let (baked_m, baked_p) = crate::setup::TemplateConfig::load_for(cfg, image) + .ok() + .map(|tc| (tc.grok_marketplaces, tc.grok_plugins)) + .unwrap_or_default(); + plugin_delta( + &cfg.grok.marketplaces, + &cfg.grok.plugins, + &baked_m, + &baked_p, + ) +} + /// Resolve a GitHub token for the guest given the configured auth strategy /// and the resolved target repo (when known). /// @@ -2026,6 +2116,194 @@ fn copy_claude_config(target: &SshTarget, config_dir: &ConfigDir) -> Result<()> copy_staged_to_guest(target, &staged, ".claude", "Claude") } +fn copy_grok_config(target: &SshTarget, config_dir: &ConfigDir) -> Result<()> { + let Some(source_dir) = resolve_config_source_dir(config_dir, ".grok", "grok.config_dir") else { + return Ok(()); + }; + + let staged = stage_selected_files(&source_dir, GROK_ALLOWED_FILES, GROK_ALLOWED_DIRS) + .context("Failed to stage Grok Build config files")?; + copy_staged_to_guest(target, &staged, ".grok", "Grok Build")?; + restrict_guest_grok_auth(target) +} + +/// Owner-only mode for a copied host `~/.grok/auth.json`. `scp` without `-p` +/// creates the guest file with the remote umask (typically 0644). +fn restrict_guest_grok_auth(target: &SshTarget) -> Result<()> { + if !target.exec_ok(RemoteCommand::new().literal("test -f ~/.grok/auth.json")) { + return Ok(()); + } + target + .exec(RemoteCommand::new().literal("chmod 0600 ~/.grok/auth.json")) + .context("Failed to restrict guest ~/.grok/auth.json to owner-only") +} + +const GROK_ALLOWED_FILES: &[&str] = &["AGENTS.md", "auth.json", "config.toml", "lsp.json"]; +const GROK_ALLOWED_DIRS: &[&str] = &[ + "rules", + "skills", + "commands", + "plugins", + "hooks", + "agents", + "workflows", +]; + +/// Merge coop-owned keys into the guest `~/.grok/config.toml`. +/// +/// `ui.permission_mode` is always set to always-approve so a bare `grok` +/// from `coop shell` matches `coop grok`. Configured MCP servers replace +/// the `mcp_servers` table. Host `[plugins]` is dropped (those names +/// resolve through `installed-plugins/`, which is not copied). Every +/// other key is preserved. A missing file is treated as empty; a read +/// or parse failure is an error. +fn write_managed_grok_config( + target: &SshTarget, + mcp_servers: &std::collections::HashMap, +) -> Result<()> { + target.exec(RemoteCommand::new().literal("mkdir -p ~/.grok"))?; + + let existing = target + .capture("cat ~/.grok/config.toml 2>/dev/null || true") + .context("Failed to read guest ~/.grok/config.toml")?; + let merged = merge_managed_grok_config(&existing, mcp_servers)?; + + target + .exec_with_stdin( + RemoteCommand::new().literal( + "t=\"$(mktemp ~/.grok/config.toml.XXXXXX)\" && \ + cat > \"$t\" && mv \"$t\" ~/.grok/config.toml", + ), + merged.into_bytes(), + ) + .context("Failed to write managed ~/.grok/config.toml")?; + Ok(()) +} + +fn merge_managed_grok_config( + existing: &str, + mcp_servers: &std::collections::HashMap, +) -> Result { + let mut root = if existing.trim().is_empty() { + toml::Table::new() + } else { + existing + .parse::() + .context("existing ~/.grok/config.toml is not valid TOML")? + }; + + let ui = root + .entry("ui") + .or_insert_with(|| toml::Value::Table(toml::Table::new())); + let ui_table = ui + .as_table_mut() + .context("`ui` in ~/.grok/config.toml is not a table")?; + ui_table.insert( + "permission_mode".to_string(), + toml::Value::String("always-approve".to_string()), + ); + + if root.remove("plugins").is_some() { + tracing::warn!( + "Dropping [plugins] from guest ~/.grok/config.toml; \ + those names resolve through installed-plugins/, which is not copied. \ + Put marketplace plugins in [grok] plugins" + ); + } + + if !mcp_servers.is_empty() { + let resolved = resolve_mcp_header_secrets("Grok Build MCP server", mcp_servers)?; + if root.contains_key("mcp_servers") { + tracing::warn!( + "Replacing existing [mcp_servers] in ~/.grok/config.toml with servers from coop config" + ); + } + root.insert("mcp_servers".to_string(), grok_mcp_servers_toml(&resolved)?); + } + + toml::to_string(&root).context("Failed to serialize managed ~/.grok/config.toml") +} + +/// Grok expands MCP `env` values as `${NAME}` from the guest process +/// environment. coop's `McpServerDef` stores the host variable *name*, +/// so rewrite those values before they land in guest `config.toml`. +fn grok_mcp_servers_toml( + servers: &std::collections::HashMap, +) -> Result { + let mut value = + toml::Value::try_from(servers).context("Failed to serialize Grok Build MCP servers")?; + let Some(table) = value.as_table_mut() else { + return Ok(value); + }; + let server_names: Vec = table.keys().cloned().collect(); + for server_name in server_names { + let Some(server) = table.get_mut(&server_name) else { + continue; + }; + let Some(env) = server.get_mut("env").and_then(toml::Value::as_table_mut) else { + continue; + }; + let env_keys: Vec = env.keys().cloned().collect(); + for env_key in env_keys { + let Some(val) = env.get(&env_key).and_then(toml::Value::as_str) else { + continue; + }; + let expanded = format!("${{{val}}}"); + env.insert(env_key, toml::Value::String(expanded)); + } + } + Ok(value) +} + +/// Record `/workspace` as a trusted folder so project `.grok/` hooks, +/// MCP servers, and permission rules load without a first-run prompt. +fn write_workspace_folder_trust(target: &SshTarget) -> Result<()> { + target.exec(RemoteCommand::new().literal("mkdir -p ~/.grok"))?; + + let existing = target + .capture("cat ~/.grok/trusted_folders.toml 2>/dev/null || true") + .context("Failed to read guest ~/.grok/trusted_folders.toml")?; + let merged = merge_workspace_folder_trust(&existing)?; + + target + .exec_with_stdin( + RemoteCommand::new().literal( + "t=\"$(mktemp ~/.grok/trusted_folders.toml.XXXXXX)\" && \ + cat > \"$t\" && mv \"$t\" ~/.grok/trusted_folders.toml", + ), + merged.into_bytes(), + ) + .context("Failed to write ~/.grok/trusted_folders.toml")?; + Ok(()) +} + +fn merge_workspace_folder_trust(existing: &str) -> Result { + let mut root = if existing.trim().is_empty() { + toml::Table::new() + } else { + existing + .parse::() + .context("existing ~/.grok/trusted_folders.toml is not valid TOML")? + }; + + let folders = root + .entry("folders") + .or_insert_with(|| toml::Value::Table(toml::Table::new())); + let folders_table = folders + .as_table_mut() + .context("`folders` in ~/.grok/trusted_folders.toml is not a table")?; + let mut workspace = toml::Table::new(); + workspace.insert("trusted".to_string(), toml::Value::Boolean(true)); + let decided_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or(0); + workspace.insert("decided_at".to_string(), toml::Value::Integer(decided_at)); + folders_table.insert("/workspace".to_string(), toml::Value::Table(workspace)); + + toml::to_string(&root).context("Failed to serialize ~/.grok/trusted_folders.toml") +} + /// Copy every entry staged in `staged` into the guest's `~//`, /// creating the directory first. Files go via `scp_to`, subdirectories via /// `scp_to_recursive`. An empty staging dir is a no-op (debug-logged). @@ -2056,6 +2334,12 @@ fn copy_staged_to_guest( let path = entry.path(); let local = HostPath::new(&path); if path.is_dir() { + // A previous boot may have copied read-only files (git packs). + // scp cannot overwrite those; replace the dest directory first. + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + let dest = format!("~/{guest_subdir}/{name}"); + target.exec(RemoteCommand::new().literal("rm -rf -- ").arg(&dest))?; + } target .scp_to_recursive(&local, &guest_dir) .with_context(|| format!("Failed to copy {} to guest", path.display()))?; @@ -2644,7 +2928,16 @@ fn stage_selected_files_into( for dir_name in dirs { let src = source_dir.join(dir_name); - if src.is_dir() { + let Ok(meta) = std::fs::symlink_metadata(&src) else { + continue; + }; + if meta.file_type().is_symlink() { + tracing::warn!( + "Skipping symlink {dir_name}/ (directory links are not copied into the guest)" + ); + continue; + } + if meta.is_dir() { copy_dir_recursive(&src, &staging_dir.join(dir_name)) .with_context(|| format!("Failed to stage {dir_name}/"))?; tracing::debug!("Staged {dir_name}/"); @@ -2872,17 +3165,25 @@ fn codex_bootstrap_needed( fn resolve_codex_mcp_servers( mcp_servers: &std::collections::HashMap, +) -> Result> { + resolve_mcp_header_secrets("Codex MCP server", mcp_servers) +} + +fn resolve_mcp_header_secrets( + label: &str, + mcp_servers: &std::collections::HashMap, ) -> Result> { let mut resolved = std::collections::HashMap::with_capacity(mcp_servers.len()); for (name, def) in mcp_servers { let mut cloned = def.clone(); - cloned.resolve_header_secrets("Codex MCP server", name)?; + cloned.resolve_header_secrets(label, name)?; resolved.insert(name.clone(), cloned); } Ok(resolved) } -/// Recursively copy a directory tree. +/// Recursively copy a directory tree. Directory symlinks are skipped so a +/// host checkout linked into `plugins/` cannot be followed into the guest. fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { std::fs::create_dir_all(dst).with_context(|| format!("Failed to create {}", dst.display()))?; for entry in @@ -2891,9 +3192,28 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { let entry = entry.context("Failed to read directory entry")?; let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); - if src_path.is_dir() { + let meta = std::fs::symlink_metadata(&src_path) + .with_context(|| format!("Failed to stat {}", src_path.display()))?; + if meta.file_type().is_symlink() && std::fs::metadata(&src_path).is_ok_and(|m| m.is_dir()) { + tracing::warn!("Skipping directory symlink {}", src_path.display()); + continue; + } + if meta.is_dir() { copy_dir_recursive(&src_path, &dst_path)?; } else { + if dst_path.exists() { + let mut perms = std::fs::metadata(&dst_path) + .with_context(|| format!("Failed to stat {}", dst_path.display()))? + .permissions(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + perms.set_mode(perms.mode() | 0o200); + } + std::fs::set_permissions(&dst_path, perms).with_context(|| { + format!("Failed to make {} owner-writable", dst_path.display()) + })?; + } std::fs::copy(&src_path, &dst_path).with_context(|| { format!( "Failed to copy {} -> {}", @@ -2915,9 +3235,9 @@ const GUEST_MARKETPLACE_DIR: &str = "~/.coop/marketplaces"; /// shorthand is passed through unchanged. `made_dir` tracks whether the /// guest marketplace dir has been created yet so it is only `mkdir -p`'d /// once per install pass. Shared by the Claude and Codex marketplace -/// installers; `tool` (`"claude"` / `"codex"`) namespaces the copy dir so two -/// local marketplaces with the same directory basename — one per agent — do -/// not overwrite each other in the guest. +/// installers; `tool` (`"claude"` / `"codex"` / `"grok"`) namespaces the +/// copy dir so two local marketplaces with the same directory basename — +/// one per agent — do not overwrite each other in the guest. fn stage_marketplace_source( session: &SshSession, tool: &str, @@ -3046,6 +3366,53 @@ pub(crate) fn install_codex_plugins( Ok(()) } +/// Register Grok Build marketplaces via `grok plugin marketplace add`. +/// +/// Local directories are copied into the guest first, mirroring +/// [`install_marketplaces`]. +pub(crate) fn install_grok_marketplaces( + session: &SshSession, + grok_bin: &GuestPath, + marketplaces: &[String], +) -> Result<()> { + let mut made_dir = false; + for source in marketplaces { + let guest_source = stage_marketplace_source(session, "grok", source, &mut made_dir)?; + tracing::info!("Adding Grok Build marketplace: {guest_source}"); + let cmd = RemoteCommand::new() + .arg(grok_bin) + .literal(" plugin marketplace add ") + .arg(&guest_source); + session + .exec(cmd) + .with_context(|| format!("Failed to add Grok Build marketplace '{source}'"))?; + } + Ok(()) +} + +/// Install Grok Build plugins via `grok plugin install --trust`. +/// +/// `--trust` is required: without it Grok prints a warning and stops, +/// which would fail first-boot bootstrap. +pub(crate) fn install_grok_plugins( + session: &SshSession, + grok_bin: &GuestPath, + plugins: &[String], +) -> Result<()> { + for plugin in plugins { + tracing::info!("Installing Grok Build plugin: {plugin}"); + let cmd = RemoteCommand::new() + .arg(grok_bin) + .literal(" plugin install ") + .arg(plugin) + .literal(" --trust"); + session + .exec(cmd) + .with_context(|| format!("Failed to install Grok Build plugin '{plugin}'"))?; + } + Ok(()) +} + fn register_mcp_servers( session: &SshSession, claude_bin: &GuestPath, @@ -3282,6 +3649,7 @@ fn gh_auth_token() -> Option { #[expect(clippy::unwrap_used, reason = "tests")] mod tests { use super::*; + use std::os::unix::fs::PermissionsExt; const SAMPLE_OUTPUT: &str = "\ 0.12 0.08 0.03 1/42 1234 @@ -3753,6 +4121,92 @@ Filesystem 1M-blocks Used Available Use% Mounted on assert!(!staging.path().join("projects").exists()); } + #[test] + fn stage_grok_files_copies_auth_json() { + let src = tempfile::TempDir::new().unwrap(); + std::fs::write(src.path().join("auth.json"), "{\"access_token\":\"test\"}").unwrap(); + std::fs::write(src.path().join("AGENTS.md"), "rules").unwrap(); + std::fs::write(src.path().join("config.toml"), "permission_mode = \"ask\"").unwrap(); + std::fs::create_dir(src.path().join("plugins")).unwrap(); + std::fs::write(src.path().join("plugins/SKILL.md"), "plugin").unwrap(); + std::fs::create_dir(src.path().join("installed-plugins")).unwrap(); + std::fs::write(src.path().join("installed-plugins/registry.json"), "{}").unwrap(); + + let staging = + stage_selected_files(src.path(), GROK_ALLOWED_FILES, GROK_ALLOWED_DIRS).unwrap(); + assert_eq!( + std::fs::read_to_string(staging.path().join("auth.json")).unwrap(), + "{\"access_token\":\"test\"}" + ); + assert!(staging.path().join("AGENTS.md").is_file()); + assert_eq!( + std::fs::read_to_string(staging.path().join("config.toml")).unwrap(), + "permission_mode = \"ask\"" + ); + assert!(staging.path().join("plugins/SKILL.md").is_file()); + assert!( + !staging.path().join("installed-plugins").exists(), + "installed-plugins is a host-path registry and must not be copied" + ); + } + + #[test] + fn stage_grok_files_copies_hooks_agents_workflows_and_lsp() { + let src = tempfile::TempDir::new().unwrap(); + std::fs::create_dir(src.path().join("hooks")).unwrap(); + std::fs::write(src.path().join("hooks/session-start.json"), "{}").unwrap(); + std::fs::create_dir(src.path().join("agents")).unwrap(); + std::fs::write(src.path().join("agents/review.md"), "# review").unwrap(); + std::fs::create_dir(src.path().join("workflows")).unwrap(); + std::fs::write(src.path().join("workflows/desk.rhai"), "let meta = #{}").unwrap(); + std::fs::write(src.path().join("lsp.json"), "{\"servers\":{}}").unwrap(); + + let staging = + stage_selected_files(src.path(), GROK_ALLOWED_FILES, GROK_ALLOWED_DIRS).unwrap(); + assert!(staging.path().join("hooks/session-start.json").is_file()); + assert!(staging.path().join("agents/review.md").is_file()); + assert!(staging.path().join("workflows/desk.rhai").is_file()); + assert_eq!( + std::fs::read_to_string(staging.path().join("lsp.json")).unwrap(), + "{\"servers\":{}}" + ); + } + + #[test] + fn stage_grok_files_skips_directory_symlink() { + let src = tempfile::TempDir::new().unwrap(); + let checkout = tempfile::TempDir::new().unwrap(); + std::fs::write(checkout.path().join("secret.txt"), "host-checkout").unwrap(); + + let plugins = src.path().join("plugins"); + std::fs::create_dir(&plugins).unwrap(); + std::os::unix::fs::symlink(checkout.path(), plugins.join("grok-nest")).unwrap(); + std::fs::write(plugins.join("SKILL.md"), "portable").unwrap(); + + let staging = + stage_selected_files(src.path(), GROK_ALLOWED_FILES, GROK_ALLOWED_DIRS).unwrap(); + assert!(staging.path().join("plugins/SKILL.md").is_file()); + assert!( + !staging.path().join("plugins/grok-nest").exists(), + "directory symlinks must not be followed into a host checkout" + ); + } + + #[test] + fn stage_grok_files_skips_symlinked_plugins_dir() { + let src = tempfile::TempDir::new().unwrap(); + let checkout = tempfile::TempDir::new().unwrap(); + std::fs::write(checkout.path().join("SKILL.md"), "from-link").unwrap(); + std::os::unix::fs::symlink(checkout.path(), src.path().join("plugins")).unwrap(); + + let staging = + stage_selected_files(src.path(), GROK_ALLOWED_FILES, GROK_ALLOWED_DIRS).unwrap(); + assert!( + !staging.path().join("plugins").exists(), + "a symlinked plugins/ directory must not be copied" + ); + } + #[test] fn stage_allowed_files_empty_source() { let src = tempfile::TempDir::new().unwrap(); @@ -4591,6 +5045,138 @@ url = "https://example.com/m" assert_eq!(missing_p, vec!["p-new@m".to_string()]); } + #[test] + fn compute_grok_plugin_delta_returns_unbaked_entries() { + let tmp = tempfile::TempDir::new().unwrap(); + let mut cfg = CoopConfig { + data_dir: crate::config::ConfigPath::new(tmp.path()), + ..CoopConfig::default() + }; + cfg.grok.marketplaces = vec!["m-baked".into(), "m-new".into()]; + cfg.grok.plugins = vec!["p-baked@m".into(), "p-new@m".into()]; + let image = ImageName::new("default").unwrap(); + + std::fs::create_dir_all(cfg.image_dir(&image)).unwrap(); + let json = r#"{ + "version": 1, + "created": "2026-01-01T00:00:00Z", + "install_script_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "profiles": [], + "extra_packages": [], + "post_install_hash": null, + "grok_marketplaces": ["m-baked"], + "grok_plugins": ["p-baked@m"] + }"#; + std::fs::write(cfg.template_config_path_for(&image), json).unwrap(); + + let (missing_m, missing_p) = compute_grok_plugin_delta(&cfg, &image); + assert_eq!(missing_m, vec!["m-new".to_string()]); + assert_eq!(missing_p, vec!["p-new@m".to_string()]); + } + + #[test] + fn merge_managed_grok_config_sets_permission_mode_and_preserves_other_keys() { + let existing = "[ui]\nvim_mode = true\n[models]\ndefault = \"grok-4.6\"\n"; + let merged = + merge_managed_grok_config(existing, &std::collections::HashMap::new()).unwrap(); + let table: toml::Table = merged.parse().unwrap(); + let ui = table["ui"].as_table().unwrap(); + assert_eq!(ui["permission_mode"].as_str(), Some("always-approve")); + assert_eq!(ui["vim_mode"].as_bool(), Some(true)); + assert_eq!( + table["models"].as_table().unwrap()["default"].as_str(), + Some("grok-4.6") + ); + } + + #[test] + fn merge_managed_grok_config_drops_plugins_table() { + let existing = "[plugins]\nenabled = [\"nest\", \"fdm-print\"]\n\ + [ui]\nvim_mode = true\n"; + let merged = + merge_managed_grok_config(existing, &std::collections::HashMap::new()).unwrap(); + let table: toml::Table = merged.parse().unwrap(); + assert!( + !table.contains_key("plugins"), + "[plugins] names resolve through installed-plugins/ and must not be copied" + ); + let ui = table["ui"].as_table().unwrap(); + assert_eq!(ui["vim_mode"].as_bool(), Some(true)); + assert_eq!(ui["permission_mode"].as_str(), Some("always-approve")); + } + + #[test] + fn merge_managed_grok_config_expands_stdio_env_as_grok_substitution() { + let mut env = std::collections::BTreeMap::new(); + env.insert( + crate::guest_env_state::EnvVarName::new("API_KEY").unwrap(), + crate::guest_env_state::EnvVarName::new("MY_HOST_TOKEN").unwrap(), + ); + let mut servers = std::collections::HashMap::new(); + servers.insert( + "playwright".into(), + McpServerDef::Stdio { + command: "npx".into(), + args: vec!["-y".into(), "@playwright/mcp".into()], + env, + }, + ); + let merged = merge_managed_grok_config("", &servers).unwrap(); + let table: toml::Table = merged.parse().unwrap(); + let server = table["mcp_servers"]["playwright"].as_table().unwrap(); + assert_eq!( + server["env"].as_table().unwrap()["API_KEY"].as_str(), + Some("${MY_HOST_TOKEN}"), + "Grok expands MCP env values as ${{NAME}} from the guest process" + ); + } + + #[test] + fn merge_managed_grok_config_rejects_invalid_toml() { + let empty = std::collections::HashMap::new(); + assert!(merge_managed_grok_config("not toml", &empty).is_err()); + assert!( + merge_managed_grok_config("[ui]\npermission_mode = [", &empty).is_err(), + "invalid TOML must be rejected, not replaced with managed defaults", + ); + assert!( + merge_managed_grok_config("ui = \"nope\"\n", &empty).is_err(), + "a non-table `ui` value must be rejected, not silently clobbered", + ); + } + + #[test] + fn merge_workspace_folder_trust_adds_workspace() { + let merged = merge_workspace_folder_trust("").unwrap(); + let table: toml::Table = merged.parse().unwrap(); + let folders = table["folders"].as_table().unwrap(); + let workspace = folders["/workspace"].as_table().unwrap(); + assert_eq!(workspace["trusted"].as_bool(), Some(true)); + assert!( + workspace["decided_at"].as_integer().is_some_and(|t| t > 0), + "Grok's trust store records decided_at as a unix timestamp" + ); + } + + #[test] + fn merge_workspace_folder_trust_preserves_other_folders() { + let existing = "[folders.\"/tmp\"]\ntrusted = true\n"; + let merged = merge_workspace_folder_trust(existing).unwrap(); + let table: toml::Table = merged.parse().unwrap(); + let folders = table["folders"].as_table().unwrap(); + assert_eq!(folders["/tmp"]["trusted"].as_bool(), Some(true)); + assert_eq!(folders["/workspace"]["trusted"].as_bool(), Some(true)); + } + + #[test] + fn merge_workspace_folder_trust_rejects_invalid_toml() { + assert!(merge_workspace_folder_trust("not toml").is_err()); + assert!( + merge_workspace_folder_trust("folders = \"nope\"\n").is_err(), + "a non-table `folders` value must be rejected, not silently clobbered", + ); + } + #[test] fn codex_missing_guest_cli_message_mentions_skip_and_rebuild_paths() { let msg = codex_missing_guest_cli_message(); @@ -4629,6 +5215,47 @@ url = "https://example.com/m" ); } + #[test] + fn copy_dir_recursive_overwrites_readonly_file() { + let src1 = tempfile::TempDir::new().unwrap(); + std::fs::write(src1.path().join("pack"), b"v1").unwrap(); + let mut perms = std::fs::metadata(src1.path().join("pack")) + .unwrap() + .permissions(); + perms.set_mode(0o444); + std::fs::set_permissions(src1.path().join("pack"), perms).unwrap(); + + let dst = tempfile::TempDir::new().unwrap(); + let target = dst.path().join("out"); + copy_dir_recursive(src1.path(), &target).unwrap(); + + let src2 = tempfile::TempDir::new().unwrap(); + std::fs::write(src2.path().join("pack"), b"v2").unwrap(); + copy_dir_recursive(src2.path(), &target).unwrap(); + assert_eq!(std::fs::read(target.join("pack")).unwrap(), b"v2"); + } + + #[test] + fn copy_dir_recursive_skips_directory_symlink() { + let src = tempfile::TempDir::new().unwrap(); + let outside = tempfile::TempDir::new().unwrap(); + std::fs::write(outside.path().join("secret.txt"), "host-checkout").unwrap(); + std::fs::write(src.path().join("keep.txt"), "ok").unwrap(); + std::os::unix::fs::symlink(outside.path(), src.path().join("linked")).unwrap(); + + let dst = tempfile::TempDir::new().unwrap(); + let target = dst.path().join("out"); + copy_dir_recursive(src.path(), &target).unwrap(); + assert_eq!( + std::fs::read_to_string(target.join("keep.txt")).unwrap(), + "ok" + ); + assert!( + !target.join("linked").exists(), + "directory symlinks must not be followed" + ); + } + #[test] fn is_github_https_url_matches_canonical_https() { assert!(is_github_https_url("https://github.com/owner/repo")); @@ -4922,6 +5549,97 @@ url = "https://example.com/m" ); } + #[test] + fn grok_api_key_is_forwarded_as_xai_api_key() { + let mut cfg = CoopConfig::default(); + cfg.grok.api_key = Some(crate::config::Secret::new("xai-realkey".to_string())); + cfg.github = None; + + let env = prepare_env_forwarding(&cfg, None, false, false).unwrap(); + assert!( + env.contains("XAI_API_KEY"), + "configured grok.api_key must be forwarded as XAI_API_KEY" + ); + } + + #[test] + fn grok_forwards_process_xai_api_key_when_config_omits_it() { + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + let prior = std::env::var("XAI_API_KEY").ok(); + // SAFETY: this is the only test that mutates XAI_API_KEY, it holds + // ENV_LOCK while doing so, and it restores the prior value before + // returning. Tests that construct CoopConfig::default() may copy a + // process-inherited XAI_API_KEY into grok.api_key; they then either + // overwrite that field or do not assert on it. + unsafe { std::env::set_var("XAI_API_KEY", "xai-from-host-env") }; + + let mut cfg = CoopConfig::default(); + cfg.grok.api_key = None; + cfg.github = None; + + let env = prepare_env_forwarding(&cfg, None, false, false).unwrap(); + + unsafe { + match &prior { + Some(v) => std::env::set_var("XAI_API_KEY", v), + None => std::env::remove_var("XAI_API_KEY"), + } + } + + assert_eq!( + env.as_envs().get("XAI_API_KEY").map(String::as_str), + Some("xai-from-host-env"), + "process XAI_API_KEY must be forwarded when grok.api_key is unset" + ); + } + + #[test] + fn grok_stdio_mcp_env_host_names_are_forwarded() { + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + let prior = std::env::var("MY_HOST_TOKEN").ok(); + unsafe { std::env::set_var("MY_HOST_TOKEN", "token-from-host") }; + + let mut env = std::collections::BTreeMap::new(); + env.insert( + crate::guest_env_state::EnvVarName::new("API_KEY").unwrap(), + crate::guest_env_state::EnvVarName::new("MY_HOST_TOKEN").unwrap(), + ); + let mut cfg = CoopConfig::default(); + cfg.grok.api_key = None; + cfg.github = None; + cfg.grok.mcp_servers.insert( + "playwright".into(), + McpServerDef::Stdio { + command: "npx".into(), + args: vec![], + env, + }, + ); + + let forwarded = prepare_env_forwarding(&cfg, None, false, false).unwrap(); + + unsafe { + match &prior { + Some(v) => std::env::set_var("MY_HOST_TOKEN", v), + None => std::env::remove_var("MY_HOST_TOKEN"), + } + } + + assert_eq!( + forwarded.as_envs().get("MY_HOST_TOKEN").map(String::as_str), + Some("token-from-host"), + "Grok MCP env mappings must forward the referenced host variable" + ); + } + // ── ensure_codex_remote_auth_consistent ───────────────── fn auth_check_cfg(auth: CodexAuthMode, openai: bool, anthropic: bool) -> CoopConfig { @@ -5045,7 +5763,7 @@ url = "https://example.com/m" /// Build a `CoopConfig` whose env-resolving inputs are all empty /// except `guest_env`. Defaults read `ANTHROPIC_API_KEY` / - /// `OPENAI_API_KEY` from the process environment, which would make + /// `OPENAI_API_KEY` / `XAI_API_KEY` from the process environment, which would make /// these tests flaky; clearing them keeps the assertions about /// `guest_env` precise. fn cfg_with_guest_env(entries: &[(&str, &str)]) -> CoopConfig { @@ -5054,6 +5772,8 @@ url = "https://example.com/m" cfg.claude.env_forward = Vec::new(); cfg.codex.api_key = None; cfg.codex.env_forward = Vec::new(); + cfg.grok.api_key = None; + cfg.grok.env_forward = Vec::new(); cfg.github = None; for (k, v) in entries { cfg.guest_env.insert( diff --git a/src/commands/agent.rs b/src/commands/agent.rs index f9f7dbc..d91ed07 100644 --- a/src/commands/agent.rs +++ b/src/commands/agent.rs @@ -1,12 +1,12 @@ //! `coop agent update` — refresh the coding-agent binaries inside a running VM. //! -//! Both agents are installed "latest at build time" during `coop setup`, so +//! The three agents are installed "latest at build time" during `coop setup`, so //! they go stale in long-running VMs and in VMs created from an old image. //! This command updates them in place against a *running* instance, without //! rebuilding the golden image (`coop setup --rebuild` remains the path for //! refreshing the image itself). //! -//! The two agents differ in how they update, and the difference is encoded in +//! The agents differ in how they update, and the difference is encoded in //! [`UpdateStrategy`] so no caller can run the wrong one: //! //! - **Codex** is a root-owned package exposed through `/usr/local/bin` and has @@ -17,6 +17,9 @@ //! auto-updates in the background. `coop agent update --claude` just runs //! `claude update` synchronously as the guest user — a convenience, not a //! fix. +//! - **Grok Build** lives in the guest user's `~/.grok/bin` and already +//! auto-updates in the background. `coop agent update --grok` runs +//! `grok update` synchronously as the guest user. use std::io::Write as _; @@ -47,6 +50,7 @@ const CODEX_REPO: &str = "openai/codex"; enum Agent { Claude, Codex, + Grok, } impl Agent { @@ -55,15 +59,16 @@ impl Agent { match self { Self::Claude => "Claude Code", Self::Codex => "Codex", + Self::Grok => "Grok Build", } } /// How this agent's binary is refreshed inside the guest. The /// root-vs-user asymmetry lives here so a caller can't run Claude's - /// self-update as root or Codex's reinstall without sudo. + /// or Grok's self-update as root or Codex's reinstall without sudo. fn strategy(self) -> UpdateStrategy { match self { - Self::Claude => UpdateStrategy::SelfUpdate, + Self::Claude | Self::Grok => UpdateStrategy::SelfUpdate, Self::Codex => UpdateStrategy::ReinstallAsRoot { script: guest::SCRIPT_CODEX, }, @@ -80,33 +85,35 @@ enum UpdateStrategy { SelfUpdate, } -/// Which agents a single `coop agent update` invocation targets. No variant -/// can represent "update nothing", so [`agents`](Self::agents) is always -/// non-empty. +/// Which agents a single `coop agent update` invocation targets. No +/// construction can represent "update nothing", so [`agents`](Self::agents) +/// is always non-empty. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum AgentSelection { - Claude, - Codex, - Both, +pub(crate) struct AgentSelection { + agents: &'static [Agent], } impl AgentSelection { - /// Map the two CLI booleans to a selection. Selection is additive: no - /// flag, or both flags, means both agents. - pub(crate) fn from_flags(claude: bool, codex: bool) -> Self { - match (claude, codex) { - (true, false) => Self::Claude, - (false, true) => Self::Codex, - _ => Self::Both, + /// Map the CLI booleans to a selection. Selection is additive: no + /// flag, or every flag, means every agent. + pub(crate) fn from_flags(claude: bool, codex: bool, grok: bool) -> Self { + Self { + agents: match (claude, codex, grok) { + (false, false, false) | (true, true, true) => { + &[Agent::Claude, Agent::Codex, Agent::Grok] + } + (true, false, false) => &[Agent::Claude], + (false, true, false) => &[Agent::Codex], + (false, false, true) => &[Agent::Grok], + (true, true, false) => &[Agent::Claude, Agent::Codex], + (true, false, true) => &[Agent::Claude, Agent::Grok], + (false, true, true) => &[Agent::Codex, Agent::Grok], + }, } } fn agents(self) -> &'static [Agent] { - match self { - Self::Claude => &[Agent::Claude], - Self::Codex => &[Agent::Codex], - Self::Both => &[Agent::Claude, Agent::Codex], - } + self.agents } } @@ -167,8 +174,8 @@ enum UpdateOutcome { enum CheckStatus { UpToDate, UpdateAvailable, - /// Claude Code updates itself in the background — coop does not track a - /// "latest" for it. + /// Claude Code and Grok Build update themselves in the background — + /// coop does not track a "latest" for them. AutoUpdates, /// Installed or latest version could not be determined. Unknown, @@ -220,14 +227,19 @@ pub(crate) fn cmd_agent_update( /// Comma/and-joined agent names for the confirmation prompt. fn selection_phrase(selection: AgentSelection) -> String { let labels: Vec<&str> = selection.agents().iter().map(|a| a.display()).collect(); - labels.join(" and ") + match labels.as_slice() { + [] => unreachable!("AgentSelection is never empty"), + [one] => (*one).to_string(), + [a, b] => format!("{a} and {b}"), + [rest @ .., last] => format!("{}, and {last}", rest.join(", ")), + } } // ── Update path ─────────────────────────────────────────────── /// Update every selected agent, printing each result. Continues past a /// per-agent failure and returns an error only after all have run, so a -/// `Both` update reports both outcomes even when one fails. +/// multi-agent update reports every outcome even when one fails. fn run_updates(session: &SshSession, selection: AgentSelection) -> Result<()> { let out = &mut std::io::stdout(); let mut failed = false; @@ -235,10 +247,11 @@ fn run_updates(session: &SshSession, selection: AgentSelection) -> Result<()> { match update_one(session, agent) { Ok(outcome) => { writeln!(out, "{}", outcome_line(agent, &outcome))?; - if agent == Agent::Claude { + if matches!(agent, Agent::Claude | Agent::Grok) { writeln!( out, - " note: Claude Code also auto-updates in the background." + " note: {} also auto-updates in the background.", + agent.display() )?; } } @@ -322,7 +335,7 @@ fn run_check(session: &SshSession, selection: AgentSelection) -> Result<()> { fn check_row(session: &SshSession, agent: Agent) -> CheckRow { let installed = capture_version(session, agent); let latest = match agent { - Agent::Claude => None, + Agent::Claude | Agent::Grok => None, Agent::Codex => codex_latest(), }; let status = check_status(agent, installed.as_ref(), latest.as_ref()); @@ -353,7 +366,7 @@ fn check_status( latest: Option<&AgentVersion>, ) -> CheckStatus { match agent { - Agent::Claude => CheckStatus::AutoUpdates, + Agent::Claude | Agent::Grok => CheckStatus::AutoUpdates, Agent::Codex => match (installed, latest) { (Some(i), Some(l)) if i < l => CheckStatus::UpdateAvailable, (Some(_), Some(_)) => CheckStatus::UpToDate, @@ -415,12 +428,13 @@ fn check_line(row: &CheckRow) -> String { // ── Guest binary resolution + version capture (IO) ──────────── -/// Absolute guest path of an agent's binary. Claude lives under the guest -/// user's home; Codex is system-wide. +/// Absolute guest path of an agent's binary. Claude and Grok Build live +/// under the guest user's home; Codex is system-wide. fn agent_binary(session: &SshSession, agent: Agent) -> Result { Ok(match agent { Agent::Claude => guest::GuestUser::new(session.target.user.as_ref())?.claude_bin(), Agent::Codex => guest::codex_bin(), + Agent::Grok => guest::GuestUser::new(session.target.user.as_ref())?.grok_bin(), }) } @@ -445,40 +459,80 @@ mod tests { // ── selection ────────────────────────────────────────────── #[test] - fn from_flags_maps_all_four_combinations() { + fn from_flags_maps_every_combination() { assert_eq!( - AgentSelection::from_flags(true, false), - AgentSelection::Claude + AgentSelection::from_flags(true, false, false).agents(), + &[Agent::Claude] ); assert_eq!( - AgentSelection::from_flags(false, true), - AgentSelection::Codex + AgentSelection::from_flags(false, true, false).agents(), + &[Agent::Codex] ); assert_eq!( - AgentSelection::from_flags(false, false), - AgentSelection::Both + AgentSelection::from_flags(false, false, true).agents(), + &[Agent::Grok] + ); + assert_eq!( + AgentSelection::from_flags(false, false, false).agents(), + &[Agent::Claude, Agent::Codex, Agent::Grok] + ); + assert_eq!( + AgentSelection::from_flags(true, true, true).agents(), + &[Agent::Claude, Agent::Codex, Agent::Grok] + ); + assert_eq!( + AgentSelection::from_flags(true, true, false).agents(), + &[Agent::Claude, Agent::Codex] + ); + assert_eq!( + AgentSelection::from_flags(true, false, true).agents(), + &[Agent::Claude, Agent::Grok] + ); + assert_eq!( + AgentSelection::from_flags(false, true, true).agents(), + &[Agent::Codex, Agent::Grok] ); - assert_eq!(AgentSelection::from_flags(true, true), AgentSelection::Both); } #[test] - fn agents_is_never_empty_and_both_lists_two() { - assert_eq!(AgentSelection::Claude.agents(), &[Agent::Claude]); - assert_eq!(AgentSelection::Codex.agents(), &[Agent::Codex]); + fn agents_is_never_empty_and_pairs_list_two() { + assert!( + !AgentSelection::from_flags(false, false, false) + .agents() + .is_empty() + ); assert_eq!( - AgentSelection::Both.agents(), - &[Agent::Claude, Agent::Codex] + AgentSelection::from_flags(true, false, false).agents(), + &[Agent::Claude] + ); + assert_eq!( + AgentSelection::from_flags(false, true, false).agents(), + &[Agent::Codex] + ); + assert_eq!( + AgentSelection::from_flags(true, true, false).agents().len(), + 2 ); } #[test] fn selection_phrase_joins_with_and() { - assert_eq!(selection_phrase(AgentSelection::Claude), "Claude Code"); - assert_eq!(selection_phrase(AgentSelection::Codex), "Codex"); assert_eq!( - selection_phrase(AgentSelection::Both), + selection_phrase(AgentSelection::from_flags(true, false, false)), + "Claude Code" + ); + assert_eq!( + selection_phrase(AgentSelection::from_flags(false, true, false)), + "Codex" + ); + assert_eq!( + selection_phrase(AgentSelection::from_flags(true, true, false)), "Claude Code and Codex" ); + assert_eq!( + selection_phrase(AgentSelection::from_flags(false, false, false)), + "Claude Code, Codex, and Grok Build" + ); } // ── version parsing ──────────────────────────────────────── @@ -530,6 +584,14 @@ mod tests { ); } + #[test] + fn grok_reports_auto_updates() { + assert_eq!( + check_status(Agent::Grok, Some(&ver("1.0.24")), None), + CheckStatus::AutoUpdates + ); + } + #[test] fn codex_update_available_when_installed_is_older() { assert_eq!( @@ -605,6 +667,20 @@ mod tests { assert!(line.contains("auto-updates in background"), "{line}"); } + #[test] + fn check_line_auto_updates_names_grok_build() { + let row = CheckRow { + agent: Agent::Grok, + installed: Some(ver("1.0.24")), + latest: None, + status: CheckStatus::AutoUpdates, + }; + let line = check_line(&row); + assert!(line.contains("Grok Build"), "{line}"); + assert!(line.contains("1.0.24"), "{line}"); + assert!(line.contains("auto-updates in background"), "{line}"); + } + #[test] fn check_line_unknown_shows_placeholder() { let row = CheckRow { @@ -633,8 +709,18 @@ mod tests { latest: Some(ver("0.5.0")), status: CheckStatus::UpdateAvailable, }, + CheckRow { + agent: Agent::Grok, + installed: Some(ver("1.0.24")), + latest: None, + status: CheckStatus::AutoUpdates, + }, ]; - assert_eq!(check_report(&rows).len(), 2); + let lines = check_report(&rows); + assert_eq!(lines.len(), 3); + assert!(lines[0].contains("Claude Code"), "{}", lines[0]); + assert!(lines[1].contains("Codex"), "{}", lines[1]); + assert!(lines[2].contains("Grok Build"), "{}", lines[2]); } // ── outcome lines ────────────────────────────────────────── diff --git a/src/commands/lifecycle.rs b/src/commands/lifecycle.rs index a489219..197c880 100644 --- a/src/commands/lifecycle.rs +++ b/src/commands/lifecycle.rs @@ -1528,6 +1528,47 @@ pub(crate) fn codex_launch_args(ask: bool, mut args: Vec) -> Vec args } +/// Grok Build flags for running unrestricted (always-approve + folder trust). +const GROK_ALWAYS_APPROVE: &str = "--always-approve"; +const GROK_TRUST: &str = "--trust"; +const GROK_PERMISSION_MODE: &str = "--permission-mode"; +const GROK_PERMISSION_ASK: &str = "default"; +const GROK_AUTH_SUBCOMMANDS: &[&str] = &["login", "logout"]; + +/// Prepend Grok Build's always-approve and folder-trust flags, and pin +/// `--cwd /workspace`, unless the user opted into prompts or is running an +/// auth subcommand. +/// +/// The VM is the isolation boundary, so Grok's own permission prompts add +/// no protection. `--trust` records `/workspace` as trusted so project +/// `.grok/` hooks and MCP servers load without a first-run question. +/// Guest `~/.grok/config.toml` also sets `ui.permission_mode = +/// "always-approve"`, so `--ask` must pass `--permission-mode default` +/// (Grok's ask mode). Omitting `--always-approve` alone leaves the +/// config default in force. `login` / `logout` never start a session, so +/// always-approve is dropped there (folder trust is still harmless and +/// kept). +pub(crate) fn grok_launch_args(ask: bool, mut args: Vec) -> Vec { + let is_auth_subcommand = args + .first() + .is_some_and(|arg| GROK_AUTH_SUBCOMMANDS.contains(&arg.as_str())); + let has_cwd = args + .iter() + .any(|arg| arg == "--cwd" || arg.starts_with("--cwd=")); + if !has_cwd && !is_auth_subcommand { + args.insert(0, "/workspace".to_string()); + args.insert(0, "--cwd".to_string()); + } + args.insert(0, GROK_TRUST.to_string()); + if ask { + args.insert(0, GROK_PERMISSION_ASK.to_string()); + args.insert(0, GROK_PERMISSION_MODE.to_string()); + } else if !is_auth_subcommand { + args.insert(0, GROK_ALWAYS_APPROVE.to_string()); + } + args +} + pub(crate) fn cmd_exec( be: &backend::PlatformBackend, cfg: &config::CoopConfig, @@ -2710,6 +2751,54 @@ mod tests { assert_eq!(args, vec!["--dangerously-bypass-approvals-and-sandbox"]); } + #[test] + fn grok_launch_args_always_approves_and_trusts_by_default() { + let args = super::grok_launch_args(false, vec!["--model".into(), "grok-4.6".into()]); + assert_eq!( + args, + vec![ + "--always-approve", + "--trust", + "--cwd", + "/workspace", + "--model", + "grok-4.6" + ] + ); + } + + #[test] + fn grok_launch_args_ask_overrides_guest_permission_mode() { + let args = super::grok_launch_args(true, vec!["--model".into(), "grok-4.6".into()]); + assert_eq!( + args, + vec![ + "--permission-mode", + "default", + "--trust", + "--cwd", + "/workspace", + "--model", + "grok-4.6" + ] + ); + } + + #[test] + fn grok_launch_args_login_and_logout_keep_trust() { + for subcommand in ["login", "logout"] { + let args = + super::grok_launch_args(false, vec![subcommand.into(), "--device-auth".into()]); + assert_eq!(args, vec!["--trust", subcommand, "--device-auth"]); + } + } + + #[test] + fn grok_launch_args_respects_user_cwd() { + let args = super::grok_launch_args(false, vec!["--cwd".into(), "/tmp".into()]); + assert_eq!(args, vec!["--always-approve", "--trust", "--cwd", "/tmp"]); + } + #[test] fn profile_image_target_sorts_and_deduplicates_profiles() { let profiles = vec![ diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 88bb1fd..531b3d2 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -26,8 +26,9 @@ pub(crate) use lifecycle::{ ProfileImageTarget, ProjectTransport, ReprovisionOpts, ResizeOpts, RestoreMode, RestoreOpts, StartOpts, UpDevcontainerOpts, UpOpts, UpRuntimeOpts, apply_runtime_guest_env, apply_vm_overrides, cmd_commit, cmd_destroy, cmd_exec, cmd_list, cmd_resize, cmd_restore, - cmd_shell, cmd_start, cmd_status, cmd_stop, cmd_up, codex_launch_args, open_ssh_session, - preflight_start_target, prepare_session_from_target, prepend_binary, resolve_running, + cmd_shell, cmd_start, cmd_status, cmd_stop, cmd_up, codex_launch_args, grok_launch_args, + open_ssh_session, preflight_start_target, prepare_session_from_target, prepend_binary, + resolve_running, }; pub(crate) use model::cmd_model; pub(crate) use profiles::{cmd_images, cmd_profiles}; diff --git a/src/config.rs b/src/config.rs index bd47572..9c43312 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1553,11 +1553,28 @@ pub struct GrokConfig { pub mcp_servers: HashMap, /// Source directory for Grok Build files (AGENTS.md, auth.json, config.toml, - /// rules/, skills/, commands/, plugins/) + /// lsp.json, rules/, skills/, commands/, plugins/, hooks/, agents/, + /// workflows/) #[serde(default)] pub config_dir: ConfigDir, } +impl GrokConfig { + /// Host environment variable names referenced by stdio MCP `env` + /// mappings. Grok expands those as `${NAME}` in the guest config, so + /// the names must be forwarded into the guest. + pub(crate) fn stdio_env_host_names(&self) -> Vec { + self.mcp_servers + .values() + .filter_map(|def| match def { + McpServerDef::Stdio { env, .. } => Some(env.values().cloned()), + _ => None, + }) + .flatten() + .collect() + } +} + /// Codex cloud authentication mode. /// /// `ApiKey` preserves the historical behavior: coop forwards diff --git a/src/guest.rs b/src/guest.rs index 5974849..19c8ea3 100644 --- a/src/guest.rs +++ b/src/guest.rs @@ -494,6 +494,21 @@ pub fn collect_codex_baked_lists(cfg: &CoopConfig) -> (Vec, Vec) (marketplaces, plugins) } +/// Collect Grok Build marketplace and plugin lists from global config. +/// Results are sorted and deduplicated. Profiles contribute nothing here: +/// profile plugin lists are Claude-only. +pub fn collect_grok_baked_lists(cfg: &CoopConfig) -> (Vec, Vec) { + let mut marketplaces = cfg.grok.marketplaces.clone(); + let mut plugins = cfg.grok.plugins.clone(); + + marketplaces.sort_unstable(); + marketplaces.dedup(); + plugins.sort_unstable(); + plugins.dedup(); + + (marketplaces, plugins) +} + #[cfg(test)] #[expect(clippy::panic, reason = "tests use panic for assertion failures")] #[expect(clippy::unwrap_used, reason = "tests use unwrap for brevity")] @@ -777,6 +792,16 @@ mod tests { assert_eq!(plugins, vec!["p1@a".to_string(), "p2@b".to_string()]); } + #[test] + fn collect_grok_baked_lists_sorts_and_dedups() { + let mut cfg = CoopConfig::default(); + cfg.grok.marketplaces = vec!["b".into(), "a".into(), "a".into()]; + cfg.grok.plugins = vec!["p2@b".into(), "p1@a".into(), "p2@b".into()]; + let (marketplaces, plugins) = collect_grok_baked_lists(&cfg); + assert_eq!(marketplaces, vec!["a".to_string(), "b".to_string()]); + assert_eq!(plugins, vec!["p1@a".to_string(), "p2@b".to_string()]); + } + #[test] fn grok_script_downloads_installer_to_file() { assert!( diff --git a/src/lib.rs b/src/lib.rs index 2a3e2be..703c46c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -75,14 +75,14 @@ use commands::{ apply_vm_overrides, cmd_agent_update, cmd_commit, cmd_destroy, cmd_devcontainer, cmd_devcontainer_check, cmd_exec, cmd_github, cmd_images, cmd_init, cmd_list, cmd_model, cmd_profiles, cmd_proxy, cmd_quickstart, cmd_resize, cmd_restore, cmd_shell, cmd_start, - cmd_status, cmd_stop, cmd_uninstall, cmd_up, cmd_validate, codex_launch_args, open_ssh_session, - preflight_start_target, prepend_binary, resolve_devcontainer, resolve_devcontainer_collect, - resolve_running, + cmd_status, cmd_stop, cmd_uninstall, cmd_up, cmd_validate, codex_launch_args, grok_launch_args, + open_ssh_session, preflight_start_target, prepend_binary, resolve_devcontainer, + resolve_devcontainer_collect, resolve_running, }; #[derive(Parser)] #[command(name = "coop", version = env!("COOP_VERSION_STR"))] -#[command(about = "Isolated VM environment for running Claude Code and Codex")] +#[command(about = "Isolated VM environment for running Claude Code, Codex, and Grok Build")] pub(crate) struct Cli { /// Path to coop config file #[arg(long, default_value_os_t = config::CoopConfig::default_path())] @@ -137,7 +137,7 @@ enum Commands { /// Instance disk size in GiB (only used when creating a new instance) #[arg(long, value_parser = config::GiB::parse_cli)] disk: Option, - /// Skip injecting Claude Code and Codex credentials/config into the VM + /// Skip injecting Claude Code, Codex, and Grok Build credentials/config into the VM #[arg(long, alias = "no-claude")] no_agents: bool, /// Use github = "off" for this invocation and skip the GitHub PAT prompt @@ -290,7 +290,7 @@ enum Commands { /// Project directory used to select an associated stopped instance #[arg(long)] workspace: Option, - /// Skip injecting Claude Code and Codex credentials/config into the VM + /// Skip injecting Claude Code, Codex, and Grok Build credentials/config into the VM #[arg(long, alias = "no-claude")] no_agents: bool, /// Use github = "off" for this invocation and skip the GitHub PAT prompt @@ -392,6 +392,21 @@ enum Commands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, + /// Launch Grok Build inside the VM (always-approve by default) + Grok { + /// Instance name (required if multiple instances exist) + #[arg( + value_parser = config::InstanceName::new, + add = ArgValueCandidates::new(completions::instance_candidates), + )] + name: Option, + /// Prompt for permissions instead of skipping them + #[arg(long)] + ask: bool, + /// Extra arguments passed to `grok` + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, /// Gracefully stop the VM Stop { /// Instance name (required if multiple instances exist) @@ -633,7 +648,7 @@ enum Commands { /// Skip the --reprovision confirmation prompt (required off a TTY) #[arg(short = 'y', long, requires = "reprovision")] yes: bool, - /// Skip injecting Claude Code and Codex credentials/config into the VM + /// Skip injecting Claude Code, Codex, and Grok Build credentials/config into the VM #[arg(long, alias = "no-claude", requires = "reprovision")] no_agents: bool, /// Suppress the interactive prompt to set up a scoped GitHub PAT @@ -724,8 +739,8 @@ extra line in your shell rc: enum AgentAction { /// Update coding agent(s) to the latest version inside the VM. /// - /// With no agent flag, both Claude Code and Codex are updated. The VM - /// must be running. + /// With no agent flag, Claude Code, Codex, and Grok Build are updated. + /// The VM must be running. Update { /// Instance name (required if multiple instances exist) #[arg( @@ -733,12 +748,15 @@ enum AgentAction { add = ArgValueCandidates::new(completions::instance_candidates), )] name: Option, - /// Update Claude Code (default: update both agents) + /// Update Claude Code (default: update every agent) #[arg(long)] claude: bool, - /// Update Codex (default: update both agents) + /// Update Codex (default: update every agent) #[arg(long)] codex: bool, + /// Update Grok Build (default: update every agent) + #[arg(long)] + grok: bool, /// Only report installed vs. latest versions — change nothing #[arg(long)] check: bool, @@ -1367,6 +1385,12 @@ pub fn run() -> Result<()> { }; ssh::run_interactive(&sess, &prepend_binary(codex_bin.as_ref(), args)) } + Commands::Grok { name, ask, args } => { + let sess = open_ssh_session(&be, &cfg, name.as_ref())?; + let args = grok_launch_args(ask, args); + let grok_bin = guest::GuestUser::new(sess.target.user.as_ref())?.grok_bin(); + ssh::run_interactive(&sess, &prepend_binary(grok_bin.as_ref(), args)) + } Commands::Stop { name } => { let inst = cfg.resolve_instance(name.as_ref())?; cmd_stop(&be, &cfg, &inst) @@ -1383,6 +1407,7 @@ pub fn run() -> Result<()> { name, claude, codex, + grok, check, yes, }, @@ -1391,7 +1416,7 @@ pub fn run() -> Result<()> { &cfg, name.as_ref(), &AgentUpdateOpts { - selection: commands::AgentSelection::from_flags(claude, codex), + selection: commands::AgentSelection::from_flags(claude, codex, grok), check, yes, }, @@ -1754,6 +1779,7 @@ token = "test-pat" name, claude, codex, + grok, check, yes, }, @@ -1767,6 +1793,7 @@ token = "test-pat" ); assert!(!claude); assert!(codex); + assert!(!grok); assert!(check); assert!(!yes); } @@ -1780,6 +1807,7 @@ token = "test-pat" name, claude, codex, + grok, check, yes, }, @@ -1788,7 +1816,7 @@ token = "test-pat" panic!("expected Agent::Update variant"); }; assert!(name.is_none()); - assert!(!claude && !codex && !check && !yes); + assert!(!claude && !codex && !grok && !check && !yes); } #[test] @@ -2084,6 +2112,30 @@ token = "test-pat" assert_eq!(args, vec!["--model", "gpt-5"]); } + #[test] + fn grok_name_and_trailing_args_parse() { + let cli = parse(&["grok", "myvm", "--", "--model", "grok-4.6"]); + let super::Commands::Grok { name, ask, args } = cli.command else { + panic!("expected Grok variant"); + }; + assert_eq!( + name.as_ref().map(super::config::InstanceName::as_str), + Some("myvm") + ); + assert!(!ask, "ask defaults to false (always-approve)"); + assert_eq!(args, vec!["--model", "grok-4.6"]); + } + + #[test] + fn grok_ask_flag_parses() { + let cli = parse(&["grok", "myvm", "--ask", "--", "--model", "grok-4.6"]); + let super::Commands::Grok { ask, args, .. } = cli.command else { + panic!("expected Grok variant"); + }; + assert!(ask, "--ask restores permission prompts"); + assert_eq!(args, vec!["--model", "grok-4.6"]); + } + #[test] fn start_no_agents_flag_parses() { let cli = parse(&["start", "--no-agents"]); diff --git a/src/lima.rs b/src/lima.rs index 1ed5cfb..f4b29f6 100644 --- a/src/lima.rs +++ b/src/lima.rs @@ -724,10 +724,13 @@ fn needs_rebuild( let (wanted_m, wanted_p) = crate::guest::collect_baked_lists(cfg, profiles); let (wanted_cm, wanted_cp) = crate::guest::collect_codex_baked_lists(cfg); + let (wanted_gm, wanted_gp) = crate::guest::collect_grok_baked_lists(cfg); existing.marketplaces != wanted_m || existing.plugins != wanted_p || existing.codex_marketplaces != wanted_cm || existing.codex_plugins != wanted_cp + || existing.grok_marketplaces != wanted_gm + || existing.grok_plugins != wanted_gp } fn build_golden_image( @@ -848,6 +851,8 @@ fn build_golden_image( plugins: baked.plugins, codex_marketplaces: baked.codex_marketplaces, codex_plugins: baked.codex_plugins, + grok_marketplaces: baked.grok_marketplaces, + grok_plugins: baked.grok_plugins, guest_user: guest_user.clone(), oci_features: installed_features(oci_features), }; @@ -992,6 +997,8 @@ struct BakedLists { plugins: Vec, codex_marketplaces: Vec, codex_plugins: Vec, + grok_marketplaces: Vec, + grok_plugins: Vec, } impl BakedLists { @@ -1000,12 +1007,14 @@ impl BakedLists { && self.plugins.is_empty() && self.codex_marketplaces.is_empty() && self.codex_plugins.is_empty() + && self.grok_marketplaces.is_empty() + && self.grok_plugins.is_empty() } } -/// Install Claude and Codex marketplaces and plugins in the builder VM via -/// SSH. Returns the lists that were installed (for recording in -/// `TemplateConfig`). +/// Install Claude, Codex, and Grok Build marketplaces and plugins in the +/// builder VM via SSH. Returns the lists that were installed (for +/// recording in `TemplateConfig`). fn install_builder_plugins( cfg: &CoopConfig, profiles: &[ProfileDef], @@ -1013,11 +1022,14 @@ fn install_builder_plugins( ) -> Result { let (marketplaces, plugins) = crate::guest::collect_baked_lists(cfg, profiles); let (codex_marketplaces, codex_plugins) = crate::guest::collect_codex_baked_lists(cfg); + let (grok_marketplaces, grok_plugins) = crate::guest::collect_grok_baked_lists(cfg); let baked = BakedLists { marketplaces, plugins, codex_marketplaces, codex_plugins, + grok_marketplaces, + grok_plugins, }; if baked.is_empty() { @@ -1055,6 +1067,14 @@ fn install_builder_plugins( crate::backend::install_codex_plugins(&session, &codex_bin, &baked.codex_plugins)?; } + let grok_bin = guest_user.grok_bin(); + if !baked.grok_marketplaces.is_empty() { + crate::backend::install_grok_marketplaces(&session, &grok_bin, &baked.grok_marketplaces)?; + } + if !baked.grok_plugins.is_empty() { + crate::backend::install_grok_plugins(&session, &grok_bin, &baked.grok_plugins)?; + } + Ok(baked) } diff --git a/src/setup.rs b/src/setup.rs index 2cc3442..b399c35 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -65,6 +65,10 @@ pub struct TemplateConfig { #[serde(default)] pub codex_plugins: Vec, #[serde(default)] + pub grok_marketplaces: Vec, + #[serde(default)] + pub grok_plugins: Vec, + #[serde(default)] pub guest_user: GuestUser, #[serde(default)] pub oci_features: Vec, @@ -587,6 +591,8 @@ fn build_or_check_template(cfg: &CoopConfig, opts: &SetupOptions) -> Result<()> plugins: Vec::new(), codex_marketplaces: Vec::new(), codex_plugins: Vec::new(), + grok_marketplaces: Vec::new(), + grok_plugins: Vec::new(), guest_user: opts.guest_user.clone(), oci_features: installed_features(&opts.oci_features), }; @@ -1845,6 +1851,8 @@ mod tests { // must default to empty rather than failing to deserialize. assert!(tc.codex_marketplaces.is_empty()); assert!(tc.codex_plugins.is_empty()); + assert!(tc.grok_marketplaces.is_empty()); + assert!(tc.grok_plugins.is_empty()); } #[test] diff --git a/tests/integration.sh b/tests/integration.sh index db3c1b7..c55e173 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -1034,6 +1034,128 @@ test_claude_bin_path() { fi } +test_grok_bin_path() { + echo "" + echo "=== Phase: grok binary path ===" + + if guest_exec test -x /home/ubuntu/.grok/bin/grok; then + pass "grok binary exists at GROK_BIN path" + else + skip "grok binary at GROK_BIN path" "not installed in this image" + return + fi + + if coop_exec /home/ubuntu/.grok/bin/grok --version >/dev/null; then + pass "grok binary invocable via full path" + else + skip "grok --version" "binary exists but --version returned non-zero" + fi + + local link_target + if link_target=$(guest_exec readlink /usr/local/bin/grok); then + if [[ "$link_target" == "/home/ubuntu/.grok/bin/grok" ]]; then + pass "grok symlink in /usr/local/bin" + else + fail "grok symlink in /usr/local/bin" "points to: $link_target" + fi + else + fail "grok symlink in /usr/local/bin" "not found" + fi + + local guest_path + if guest_path=$(guest_exec printenv PATH); then + if [[ ":$guest_path:" == *":/home/ubuntu/.grok/bin:"* ]]; then + pass "~/.grok/bin on PATH in non-interactive session" + else + fail "~/.grok/bin on PATH in non-interactive session" "PATH=$guest_path" + fi + else + fail "~/.grok/bin on PATH in non-interactive session" "printenv PATH failed; stderr: $(guest_stderr)" + fi + + if guest_exec test -x /usr/local/bin/grok-yolo; then + pass "grok-yolo shortcut exists" + else + fail "grok-yolo shortcut exists" "stderr: $(guest_stderr)" + fi + + local yolo_content + if yolo_content=$(guest_exec cat /usr/local/bin/grok-yolo); then + if echo "$yolo_content" | grep -q "always-approve"; then + pass "grok-yolo includes --always-approve" + else + fail "grok-yolo includes --always-approve" "content: $yolo_content" + fi + else + fail "grok-yolo includes --always-approve" "cat failed" + fi +} + +test_grok_settings_merge() { + echo "" + echo "=== Phase: grok settings merge across restart ===" + + # Host ~/.grok/config.toml is recopied every boot, then managed keys + # are merged into that copy. Seed a wrong permission_mode and a host- + # style [plugins] table so restart proves the merge. trusted_folders.toml + # is not copied from the host, so a guest /tmp entry must survive. + local seed='mkdir -p ~/.grok && printf "%s\n" ' + seed+='"[ui]" "vim_mode = true" "permission_mode = \"default\"" "" ' + seed+='"[plugins]" "sentinel = true" > ~/.grok/config.toml && ' + seed+='printf "%s\n" "[folders.\"/tmp\"]" "trusted = true" ' + seed+='> ~/.grok/trusted_folders.toml' + if coop_exec sh -c "$seed"; then + pass "seed grok config and trust files" + else + fail "seed grok config and trust files" "stderr: $(guest_stderr)" + return + fi + + coop stop "$INSTANCE" || true + if coop start "$INSTANCE"; then + pass "restart for grok settings merge exits 0" + else + fail "restart for grok settings merge exits 0" "stderr: $HARNESS_ERR" + return + fi + + local merged + if ! merged=$(coop_exec sh -c 'cat ~/.grok/config.toml'); then + fail "read merged config.toml after restart" "stderr: $(guest_stderr)" + return + fi + + if echo "$merged" | grep -q 'always-approve'; then + pass "managed grok permission_mode reapplied after restart" + else + fail "managed grok permission_mode reapplied after restart" "$merged" + fi + + if echo "$merged" | grep -q sentinel; then + fail "host [plugins] table dropped after restart" "$merged" + else + pass "host [plugins] table dropped after restart" + fi + + local trust + if ! trust=$(coop_exec sh -c 'cat ~/.grok/trusted_folders.toml'); then + fail "read trusted_folders.toml after restart" "stderr: $(guest_stderr)" + return + fi + + if echo "$trust" | grep -q '/workspace'; then + pass "/workspace recorded in trusted_folders.toml" + else + fail "/workspace recorded in trusted_folders.toml" "$trust" + fi + + if echo "$trust" | grep -q '/tmp'; then + pass "existing trusted folder survives restart" + else + fail "existing trusted folder survives restart" "$trust" + fi +} + test_claude_settings_merge() { echo "" echo "=== Phase: claude settings merge across restart ===" @@ -1509,10 +1631,11 @@ test_agent_update() { if coop agent update "$INSTANCE" --check; then if echo "$HARNESS_OUT" | grep -q "Claude Code" \ - && echo "$HARNESS_OUT" | grep -q "Codex"; then - pass "agent update --check reports both agents" + && echo "$HARNESS_OUT" | grep -q "Codex" \ + && echo "$HARNESS_OUT" | grep -q "Grok Build"; then + pass "agent update --check reports all agents" else - fail "agent update --check reports both agents" "out: $HARNESS_OUT" + fail "agent update --check reports all agents" "out: $HARNESS_OUT" fi else fail "agent update --check exits 0" "exit: $? stderr: $HARNESS_ERR" @@ -6695,6 +6818,8 @@ main() { test_editor test_exec test_claude_bin_path + test_grok_bin_path + test_grok_settings_merge test_claude_settings_merge test_claude_onboarding_seed test_codex_bin_path From cd74b0f71024951fdd8aa7bdb99dbd43c9b2da36 Mon Sep 17 00:00:00 2001 From: Ali-Akber Saifee Date: Wed, 9 Sep 2026 12:12:56 -0700 Subject: [PATCH 4/6] Document Grok Build as a guest agent Add the integration guide and update the command, config, trust-model, and getting-started docs so the third agent is listed with Claude and Codex. --- AGENTS.md | 4 +- CHANGELOG.md | 19 ++++ README.md | 7 +- SECURITY.md | 4 +- docs/ARCHITECTURE.md | 4 +- docs/backends.md | 4 +- docs/commands.md | 68 +++++++++--- docs/configuration.md | 17 ++- docs/getting-started.md | 40 ++++++-- docs/grok-integration.md | 200 ++++++++++++++++++++++++++++++++++++ docs/images-and-profiles.md | 12 ++- docs/index.md | 3 +- docs/trust-model.md | 14 ++- 13 files changed, 352 insertions(+), 44 deletions(-) create mode 100644 docs/grok-integration.md diff --git a/AGENTS.md b/AGENTS.md index 80f864f..e4091bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # coop — agent and contributor guide -Isolated VM environment for running Codex and Claude Code — Firecracker on -Linux, Lima on macOS. +Isolated VM environment for running Codex, Claude Code, and Grok Build — +Firecracker on Linux, Lima on macOS. ## Agent entrypoint diff --git a/CHANGELOG.md b/CHANGELOG.md index e8ac6c2..c9179d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## Unreleased +### New features + +- **Grok Build** — `coop grok` launches Grok Build inside the guest with + `--always-approve`, `--trust`, and `--cwd /workspace`. The golden image + installs `~/.grok/bin/grok` (and an `agent` link) and a `grok-yolo` + shortcut. `[grok]` forwards `XAI_API_KEY`, copies an allowlist from + `config_dir` (`AGENTS.md`, `auth.json`, `config.toml`, `lsp.json`, `rules/`, + `skills/`, `commands/`, `plugins/`, `hooks/`, `agents/`, `workflows/`; + directory symlinks skipped), drops the host `[plugins]` table, merges Model Context Protocol + servers and `permission_mode` into the guest `~/.grok/config.toml`, + records `/workspace` as a trusted folder, and installs configured + marketplaces/plugins on first boot. A copied host `auth.json` is set to + owner-only (`0600`) and signs the guest in; otherwise use `coop grok -- + login --device-auth`. + `coop agent update --grok` runs `grok update`. Existing images need + `coop setup --rebuild`; existing VMs also need + `coop restore --image --reprovision` (or destroy/recreate) + to pick up the new binary. + ## v0.6.0 ### Upgrading from v0.5.4 diff --git a/README.md b/README.md index 01c104b..b05b8dc 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # coop -Isolated VM environments for running Claude Code and Codex. +Isolated VM environments for running Claude Code, Codex, and Grok Build. > **Pronunciation:** "coop" (/kuːp/) — one syllable, rhymes with "loop", like the thing you keep chickens in. Not "co-op". -coop is a Rust CLI that manages disposable virtual machines where Claude Code and Codex have full tool access: Docker, git, compilers, package managers, all without risk to your host machine. Each VM is isolated, reproducible, and cheap to create and destroy. +coop is a Rust CLI that manages disposable virtual machines where Claude Code, Codex, and Grok Build have full tool access: Docker, git, compilers, package managers, all without risk to your host machine. Each VM is isolated, reproducible, and cheap to create and destroy. ## Setup @@ -47,6 +47,8 @@ coop up coop claude # or coop codex +# or +coop grok ``` ## Documentation @@ -59,6 +61,7 @@ coop codex - [Workspace sync](docs/workspaces.md) - [Claude Code integration](docs/claude-integration.md) - [Codex integration](docs/codex-integration.md) +- [Grok Build integration](docs/grok-integration.md) - [Editor integration](docs/editor.md) - [Multi-instance](docs/multi-instance.md) - [Platform backends](docs/backends.md) diff --git a/SECURITY.md b/SECURITY.md index 902f8cd..eca701f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,7 +38,7 @@ attestation. ## Scope coop provisions isolated virtual machines — Firecracker microVMs on Linux, Lima -VMs on macOS — to run coding agents such as Claude Code and Codex. **The +VMs on macOS — to run coding agents such as Claude Code, Codex, and Grok Build. **The security boundary is the VM.** coop's job is to stand that boundary up and hand work to it without weakening it. @@ -67,6 +67,6 @@ Out of scope: [`docs/platform-notes.md`](docs/platform-notes.md) and [`docs/trust-model.md`](docs/trust-model.md) for details. - Vulnerabilities in the software coop runs or orchestrates rather than ships — - the guest agents (Claude Code, Codex), Docker, the guest OS, Firecracker, and + the guest agents (Claude Code, Codex, Grok Build), Docker, the guest OS, Firecracker, and Lima. Report those to their respective projects. - Behavior that requires an attacker who already controls the host coop runs on. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4ca6132..1a0e999 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,7 +1,7 @@ # Architecture `coop` is a Rust CLI that orchestrates isolated VM environments for running AI -coding agents (Claude Code, Codex). It manages the full VM lifecycle — setup, +coding agents (Claude Code, Codex, Grok Build). It manages the full VM lifecycle — setup, start, shell, stop, destroy, status, logs — behind two platform backends: - **Linux** — Firecracker microVMs on KVM. @@ -87,7 +87,7 @@ backends.) Everything above the trait is **backend-shared**: the entire "shared guest operations" surface in `backend.rs` (env/secret forwarding, agent bootstrap, -Claude/Codex config injection, git-repo cloning), plus `workspace.rs`, +Claude/Codex/Grok config injection, git-repo cloning), plus `workspace.rs`, `ssh.rs`, `config.rs`, and the `commands/` handlers. When you touch shared code, it must hold for **both** backends. Known intentional divergences: diff --git a/docs/backends.md b/docs/backends.md index db55355..040da4f 100644 --- a/docs/backends.md +++ b/docs/backends.md @@ -24,7 +24,7 @@ Setup verifies that `limactl --version` is reachable. If it is not, setup fails 1. Generates an ed25519 SSH key pair, stored in the coop data directory. 2. Creates a temporary builder VM from an Ubuntu 24.04 cloud image. The Lima YAML template includes a cloud-init provision script. -3. The provision script installs all packages (Docker, GitHub CLI, Claude Code, Codex, and any profile packages), creates the `ubuntu` user with SSH access, and enables services. +3. The provision script installs all packages (Docker, GitHub CLI, Claude Code, Codex, Grok Build, and any profile packages), creates the `ubuntu` user with SSH access, and enables services. 4. After provisioning completes, cleans cloud-init state so it re-runs on cloned instances. 5. Stops the builder VM and extracts its disk as the golden image. 6. Generates a fast-start Lima template that references the golden image directly. No cloud-init provisioning runs on instance start. @@ -68,7 +68,7 @@ The Firecracker backend runs [Firecracker microVMs](https://firecracker-microvm. 1. **Firecracker binary**: Downloaded from the latest GitHub release and stored in the data directory. The jailer binary is extracted alongside it. 2. **Guest kernel**: Fetched from Firecracker's CI S3 bucket. This is a minimal `vmlinux` image matching the Firecracker release version. -3. **Template rootfs**: Built by downloading the Firecracker CI squashfs rootfs (Ubuntu-based), unpacking it, creating an ext4 image at the configured template size, and running an install script inside a chroot. The script installs Docker, GitHub CLI, Claude Code, Codex, and profile packages. It configures the `ubuntu` user with SSH keys and sets up systemd-networkd. +3. **Template rootfs**: Built by downloading the Firecracker CI squashfs rootfs (Ubuntu-based), unpacking it, creating an ext4 image at the configured template size, and running an install script inside a chroot. The script installs Docker, GitHub CLI, Claude Code, Codex, Grok Build, and profile packages. It configures the `ubuntu` user with SSH keys and sets up systemd-networkd. All three steps are idempotent. If the artifact already exists and is up to date, setup skips it. diff --git a/docs/commands.md b/docs/commands.md index c86d21c..b6f08fb 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,6 +1,6 @@ # Command Reference -coop creates isolated VM environments for running Claude Code and Codex. It runs Firecracker microVMs on Linux and Lima VMs on macOS, selecting the backend automatically based on platform. +coop creates isolated VM environments for running Claude Code, Codex, and Grok Build. It runs Firecracker microVMs on Linux and Lima VMs on macOS, selecting the backend automatically based on platform. ## Global Flags @@ -58,7 +58,7 @@ Use `--git-repo ` instead of `DIR` to clone a remote repository into | `--vcpus ` | Number of vCPUs when creating a new instance | | `--mem ` | Memory in MiB when creating a new instance | | `--disk ` | Instance disk size when creating a new instance | -| `--no-agents` | Skip injecting Claude Code and Codex credentials/config into the VM | +| `--no-agents` | Skip injecting Claude Code, Codex, and Grok Build credentials/config into the VM | | `--no-github` | Use `github = "off"` for this invocation and suppress the PAT setup prompt. See [scope and limitations](configuration.md#github-auth). | | `--image ` | Named image to use when creating a new instance (default: `default`) | | `--profile ` | Build or reuse a profile-derived image when creating a new instance, named from the sorted profiles (for example `node-python`) | @@ -245,7 +245,7 @@ instances, pass the instance name. |------|-------------| | `NAME` | Stopped instance name (optional only when exactly one stopped instance exists) | | `--workspace ` | Restart the stopped instance associated with this project path | -| `--no-agents` | Skip injecting Claude Code and Codex credentials/config into the VM | +| `--no-agents` | Skip injecting Claude Code, Codex, and Grok Build credentials/config into the VM | | `--no-github` | Use `github = "off"` for this invocation and suppress the PAT setup prompt. See [scope and limitations](configuration.md#github-auth). | | `--forward-port ` | Forward a guest port to the host (`GUEST[:HOST]`, repeatable). Lives for the lifetime of the VM; torn down on `coop stop`. | | `--no-prompt` | Suppress the interactive prompt to set up a scoped GitHub PAT when one is missing for the resolved repo (see [`coop github setup-pat`](#github)). | @@ -359,6 +359,37 @@ coop codex my-project -- --model gpt-5 coop codex my-project -- login --device-auth ``` +### `grok` + +Launch Grok Build inside the VM. By default coop passes `--always-approve`, +`--trust`, and `--cwd /workspace`. The VM is the isolation boundary, so Grok +Build's own permission prompts add no protection. `--trust` records +`/workspace` as a trusted folder so project `.grok/` hooks and Model Context +Protocol servers load without a first-run question. Use `--ask` to restore +permission prompts for that session (coop passes `--permission-mode default`, +which overrides the guest `ui.permission_mode = "always-approve"`). The +`login` and `logout` subcommands are launched without `--always-approve`. +Host `~/.grok/auth.json` is copied into the guest on boot when `config_dir` +is enabled and set to owner-only (`0600`). If there is no host file, sign +in with `coop grok -- login --device-auth`. + +``` +coop grok [NAME] [FLAGS] [ARGS...] +``` + +| Flag | Description | +|------|-------------| +| `NAME` | Instance name (required if multiple instances exist) | +| `--ask` | Prompt for permissions instead of skipping them | +| `ARGS...` | Extra arguments passed through to `grok` | + +``` +coop grok +coop grok my-project --ask +coop grok my-project -- --model grok-4.6 +coop grok my-project -- login --device-auth +``` + ### `exec` Run a command in the VM and print its output. No PTY is allocated and stdin is not forwarded; use `shell` for interactive work. @@ -468,14 +499,14 @@ $ coop status my-project --json ### `agent update` -Update the coding agents (Claude Code and Codex) installed inside a running VM -to their latest versions, without rebuilding the golden image. Both agents are -installed "latest at build time" during `coop setup`, so they can go stale in -long-running VMs and in new VMs created from an old image. To refresh the image -itself instead, rebuild it with `coop setup --rebuild`. +Update the coding agents (Claude Code, Codex, and Grok Build) installed inside +a running VM to their latest versions, without rebuilding the golden image. The +agents are installed "latest at build time" during `coop setup`, so they can go +stale in long-running VMs and in new VMs created from an old image. To refresh +the image itself instead, rebuild it with `coop setup --rebuild`. ``` -coop agent update [NAME] [--claude] [--codex] [--check] [-y] +coop agent update [NAME] [--claude] [--codex] [--grok] [--check] [-y] ``` | Argument / Flag | Description | @@ -483,19 +514,20 @@ coop agent update [NAME] [--claude] [--codex] [--check] [-y] | `NAME` | Instance name (required if multiple instances exist) | | `--claude` | Update Claude Code | | `--codex` | Update Codex | +| `--grok` | Update Grok Build | | `--check` | Only report installed vs. latest versions — change nothing | | `-y`, `--yes` | Skip the confirmation prompt | -With no agent flag, both agents are updated; passing both `--claude` and -`--codex` is the same as passing neither. The VM must be running. +With no agent flag, every agent is updated; passing every flag is the same as +passing none. The VM must be running. Codex has no background updater, so `coop agent update --codex` re-runs coop's own installer inside the guest as root. It installs the complete upstream package, verifies its published checksums, and switches the CLI and code-mode host through the same current-release link. -Claude Code already auto-updates in the background; -`coop agent update --claude` runs `claude update` now, synchronously — a -convenience rather than a fix. +Claude Code and Grok Build already auto-update in the background; +`coop agent update --claude` / `--grok` run `claude update` / `grok update` +now, synchronously — a convenience rather than a fix. `--check` reports each agent's installed version and, for Codex, the latest release on GitHub, changing nothing: @@ -504,12 +536,14 @@ release on GitHub, changing nothing: $ coop agent update my-project --check Claude Code 1.2.3 up to date (auto-updates in background) Codex 0.4.1 → 0.5.0 update available — run: coop agent update --codex +Grok Build 1.0.24 up to date (auto-updates in background) ``` ``` -coop agent update # both agents, resolved instance -coop agent update my-project # both agents, instance "my-project" +coop agent update # every agent, resolved instance +coop agent update my-project # every agent, instance "my-project" coop agent update --codex # Codex only +coop agent update --grok # Grok Build only coop agent update --check # report versions, change nothing ``` @@ -806,7 +840,7 @@ coop restore [NAME] [--image ] [--reprovision] [-y] [--no-agents] [--no-pr | `--image ` | Image to restore from. Required on its own; with `--reprovision` it defaults to the image the instance already records | | `--reprovision` | Provision the new disk as a first boot and leave the instance running (see below) | | `-y`, `--yes` | Skip the `--reprovision` confirmation prompt (required when stdin is not a TTY). Requires `--reprovision` | -| `--no-agents` | Skip injecting Claude Code and Codex credentials/config into the VM. Requires `--reprovision` | +| `--no-agents` | Skip injecting Claude Code, Codex, and Grok Build credentials/config into the VM. Requires `--reprovision` | | `--no-prompt` | Suppress the interactive prompt to set up a scoped GitHub PAT. Requires `--reprovision` | Unlike `destroy` + `up --image`, `restore` keeps the same instance identity (name, index, IP) instead of allocating a new one. The disk is reset to the image's size, so restoring an image built before a `coop resize` returns the instance to the smaller size. diff --git a/docs/configuration.md b/docs/configuration.md index 59af349..8bd39a3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,7 +4,7 @@ coop reads configuration from `~/.coop/config.toml` by default. Pass `--config < If the file does not exist, coop falls back to built-in defaults. A valid minimal config is an empty file. -A leading `~` is expanded to the home directory in every path-valued field (`data_dir`, `firecracker_bin`, `vm.kernel_path`, `claude.config_dir`, `codex.config_dir`, and the `claude.marketplaces` / `codex.marketplaces` / `profiles..marketplaces` lists). The shell does not expand `~` inside config-file values, so coop does it when loading the file. +A leading `~` is expanded to the home directory in every path-valued field (`data_dir`, `firecracker_bin`, `vm.kernel_path`, `claude.config_dir`, `codex.config_dir`, `grok.config_dir`, and the `claude.marketplaces` / `codex.marketplaces` / `grok.marketplaces` / `profiles..marketplaces` lists). The shell does not expand `~` inside config-file values, so coop does it when loading the file. Run `coop validate` to surface errors and warnings before anything touches a VM. @@ -311,6 +311,21 @@ Codex configuration injected into the guest VM at start time. Every field is opt coop preserves any other settings already present in the staged `config.toml`, but the `mcp_servers` table is owned by coop when `codex.mcp_servers` is configured. With `auth = "chatgpt"`, coop also writes `cli_auth_credentials_store = "keyring"` so Codex caches account credentials in the guest OS credential store instead of `auth.json`. +## `grok` section + +Grok Build configuration injected into the guest VM at start time. Every field is optional. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `api_key` | string | unset (reads `$XAI_API_KEY` from environment) | xAI API key. Forwarded to the guest via SSH `SendEnv`. Never written to disk inside the VM. | +| `config_dir` | string (path) or `false` | `~/.grok` | Source directory for Grok Build files. Copies an allowlist of entries (`AGENTS.md`, `auth.json`, `config.toml`, `lsp.json`, `rules/`, `skills/`, `commands/`, `plugins/`, `hooks/`, `agents/`, `workflows/`) from this directory to `~/.grok/` in the guest on start. Directory symlinks under those entries are skipped. `installed-plugins/` is a host-absolute registry and stays on the host. Set to `false` to disable. Supports `~` expansion. | +| `env_forward` | array of strings | `[]` | Extra environment variable names to forward from host to guest via SSH `SendEnv`. `XAI_API_KEY` and `GITHUB_TOKEN` are forwarded automatically when set; list additional variables here. | +| `marketplaces` | array of strings | `[]` | Grok Build plugin marketplace sources. Each entry is a `owner/repo`[`@ref`] shorthand, a git URL, or an absolute local directory path. Local directories are copied into the guest before registration. Baked into the golden image and delta-installed on first boot. | +| `plugins` | array of strings | `[]` | Grok Build plugins to install from registered marketplaces (`grok plugin install --trust`). | +| `mcp_servers` | table | `{}` | MCP servers to merge into the guest `~/.grok/config.toml`. Keys are server names; values are server definitions. See [MCP servers](#mcp-servers). | + +coop also writes `ui.permission_mode = "always-approve"` into the guest `~/.grok/config.toml`, drops the host `[plugins]` table (those names resolve through `installed-plugins/`), and records `/workspace` in `~/.grok/trusted_folders.toml`. Other keys in `config.toml` are preserved. A copied host `auth.json` is set to owner-only (`0600`) on the guest and signs the guest in; otherwise use `coop grok -- login --device-auth`. + ## Local-model routing `[claude.local_model]` and `[codex.local_model]` declare a host-side model diff --git a/docs/getting-started.md b/docs/getting-started.md index e16aa1d..45da7de 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,6 +1,6 @@ # Getting Started -coop runs Claude Code and Codex inside isolated virtual machines. On Linux, it spins up Firecracker microVMs backed by KVM. On macOS, it uses Lima with Apple's Virtualization.framework. Each VM gets its own filesystem, network stack, and Docker daemon. Agent CLIs never touch your host. +coop runs Claude Code, Codex, and Grok Build inside isolated virtual machines. On Linux, it spins up Firecracker microVMs backed by KVM. On macOS, it uses Lima with Apple's Virtualization.framework. Each VM gets its own filesystem, network stack, and Docker daemon. Agent CLIs never touch your host. ## Prerequisites @@ -93,7 +93,7 @@ All VM artifacts (kernel, rootfs images, instance disks) live under `~/.coop/`. The guest runs as an unprivileged user (`ubuntu`, uid 1000, by default) with `~/.local/bin` on `PATH` for every session. Override the username at setup with `coop setup --guest-user `; see [Guest user](configuration.md#guest-user) for details. -### Claude Code and Codex integration +### Agent integration Forward your API keys and GitHub credentials into the guest: @@ -110,6 +110,9 @@ config_dir = "~/.claude" [codex] auth = "api_key" config_dir = "~/.codex" + +[grok] +config_dir = "~/.grok" ``` The `github` field controls how coop resolves a GitHub token for the guest: @@ -121,10 +124,10 @@ The `github` field controls how coop resolves a GitHub token for the guest: GitHub auth is off by default. Set `github = "auto"` (or run `coop github setup-pat --repo owner/name` for a scoped PAT) to enable it. `coop up` offers to run the PAT wizard inline the first time you bring up a project backed by a GitHub repo without auth configured. -coop picks up `ANTHROPIC_API_KEY` and, in the default Codex API-key mode, -`OPENAI_API_KEY` from your environment automatically. Setting them explicitly -under `claude.api_key` or `codex.api_key` also works, but environment variables -are preferred. +coop picks up `ANTHROPIC_API_KEY`, `XAI_API_KEY`, and, in the default Codex +API-key mode, `OPENAI_API_KEY` from your environment automatically. Setting +them explicitly under `claude.api_key`, `codex.api_key`, or `grok.api_key` +also works, but environment variables are preferred. For Codex account or workspace access without OpenAI API billing, set `[codex] auth = "chatgpt"` and rebuild any old image with `coop setup @@ -225,6 +228,7 @@ After the environment is running, connect to it: coop shell coop claude coop codex +coop grok ``` ### 3. Restart a stopped instance @@ -257,7 +261,7 @@ coop up ~/code/my-project --profile python,node coop up --git-repo https://github.com/trailofbits/coop.git ``` -Skip Claude Code and Codex credential/config injection: +Skip Claude Code, Codex, and Grok Build credential/config injection: ``` coop start my-project --no-agents @@ -301,6 +305,27 @@ Pass extra arguments through to `codex`: coop codex -- --model gpt-5 ``` +**Launch Grok Build inside the VM:** + +``` +coop grok +``` + +coop launches `grok --always-approve --trust --cwd /workspace`. For permission +prompts, pass `--ask` (coop passes `--permission-mode default`). A host +`~/.grok/auth.json` is copied into the guest on boot. If you have not signed +in on the host, use device-code auth (there is no browser in the guest): + +``` +coop grok -- login --device-auth +``` + +Pass extra arguments through to `grok`: + +``` +coop grok -- --model grok-4.6 +``` + **Open a shell in the VM:** ``` @@ -416,6 +441,7 @@ coop images --delete python-dev - [Workspace sync](workspaces.md) - [Claude Code integration](claude-integration.md) - [Codex integration](codex-integration.md) +- [Grok Build integration](grok-integration.md) - [Editor integration](editor.md) - [Running multiple instances](multi-instance.md) - [Platform backends](backends.md) diff --git a/docs/grok-integration.md b/docs/grok-integration.md new file mode 100644 index 0000000..87a489d --- /dev/null +++ b/docs/grok-integration.md @@ -0,0 +1,200 @@ +# Grok Build Integration + +coop installs Grok Build into every guest image and gives you a dedicated +`coop grok` launcher. This guide covers the command, the configuration that +controls what gets injected into the guest, and the bootstrap sequence that +runs when a VM starts. + +## Launching Grok Build + +```bash +coop grok [instance-name] [-- extra-args...] +``` + +This SSHes into the guest and runs the `grok` CLI with `--always-approve`, +`--trust`, and `--cwd /workspace`. The VM is the isolation boundary, so Grok +Build's own permission prompts are redundant. `--trust` records `/workspace` +as a trusted folder so project `.grok/` hooks, Model Context Protocol servers, +and permission rules load without a first-run question. + +To restore permission prompts for a single session, pass `--ask`. coop then +passes `--permission-mode default`, which overrides the guest +`ui.permission_mode = "always-approve"` (folder trust and the working +directory stay): + +```bash +coop grok --ask +``` + +Trailing arguments go straight through to the `grok` CLI: + +```bash +coop grok -- --model grok-4.6 +``` + +`login` and `logout` run without `--always-approve`. If the host has +`~/.grok/auth.json` (from `grok login` on the host), coop copies it into the +guest on boot — same as Codex — and sets it to owner-only (`0600`). A +copied session token takes precedence over `XAI_API_KEY`. If there is no +host file, sign in from the guest with device-code auth (there is no +browser in the VM): + +```bash +coop grok -- login --device-auth +``` + +## Configuration + +Grok-related settings live under the `[grok]` section in `config.toml`, except +`github` which is a top-level field: + +```toml +github = "auto" + +[grok] +api_key = "xai-..." +env_forward = ["MYORG_KEY"] +config_dir = "~/.grok" + +[grok.mcp_servers.playwright] +command = "npx" +args = ["-y", "@playwright/mcp@latest"] +``` + +Every field is optional. An empty `[grok]` section (or omitting it entirely) +still installs the CLI in the image and still writes managed guest settings +on boot. + +### API key forwarding + +coop forwards `XAI_API_KEY` to the guest via SSH `SendEnv` on every session: +`coop grok`, `coop shell`, and `coop exec` alike. The key is never written to +disk inside the guest. + +Resolution order: + +1. `grok.api_key` in `config.toml` +2. `XAI_API_KEY` environment variable on the host + +If neither is set, the guest starts without an API key unless host +`auth.json` was copied (see [Config directory](#config-directory)). + +A grok.com session token in `~/.grok/auth.json` takes precedence over the +forwarded API key. That file comes from the host copy on boot, or from +`coop grok -- login --device-auth` inside the guest. + +### Config directory + +`config_dir` specifies a host directory from which coop copies an allowlist +of entries (`AGENTS.md`, `auth.json`, `config.toml`, `lsp.json`, `rules/`, +`skills/`, `commands/`, `plugins/`, `hooks/`, `agents/`, `workflows/`) into +`~/.grok/` in the guest. + +`config.toml` is the merge base: coop then forces +`ui.permission_mode = "always-approve"` and, when `[grok.mcp_servers]` is +set, replaces the `mcp_servers` table. The host `[plugins]` table is +dropped: those names resolve through `installed-plugins/`, which is not +copied. UI, model, and HTTP Model Context Protocol entries travel. +Host-absolute paths (`auth_provider_command`, local marketplace `path =`) +will not resolve in the guest. + +`plugins/` is the user-scoped *source* tree (markdown, scripts). It is +auto-trusted. Directory symlinks inside it (or `plugins/` itself as a +link) are skipped so a host checkout cannot be followed into the guest. +`installed-plugins/` and `registry.json` are **not** copied: they record +absolute host paths and local checkouts, so they are not portable from +macOS to a Linux guest. Marketplace plugins belong in `[grok] plugins` so +the guest installs them itself. + +A copied `auth.json` is set to owner-only (`0600`) on the guest. + +```toml +[grok] +config_dir = "~/.grok" +``` + +The default is `~/.grok`. Set to `false` to disable config file copying +entirely. Project files under `/workspace` (`AGENTS.md`, `.grok/`) are +already in the workspace and do not need to be copied. + +### Environment variable forwarding + +`env_forward` lists additional environment variable names to forward from +the host to the guest via SSH `SendEnv`. These are forwarded on every SSH +session, not just during bootstrap. + +`XAI_API_KEY` and `GITHUB_TOKEN` are handled through their own mechanisms +and do not need to appear here. + +### MCP server registration + +`mcp_servers` maps server names to their definitions. coop merges these +definitions into the guest `~/.grok/config.toml` under `mcp_servers`. +Stdio `env` values are host variable names in coop config; they are written +as `${NAME}` so Grok expands them from the guest environment, and those +host names are forwarded automatically. + +Definitions use the same schema as Claude and Codex integration. + +### Plugin marketplaces + +`marketplaces` and `plugins` declare Grok Build plugin marketplaces and the +plugins to install from them: + +```toml +[grok] +marketplaces = ["owner/grok-plugins"] +plugins = ["my-skill"] +``` + +Each marketplace source is registered with `grok plugin marketplace add` and +each plugin installed with `grok plugin install --trust` (Grok takes +a plugin name after the marketplace is added, or a git URL / `owner/repo` +source). A source that is an absolute local directory is copied into the +guest first. + +These are baked into the golden image during `coop setup` (on the Lima/macOS +backend) and recorded in the image's template config. On a VM's first boot +coop installs only the delta not already baked in; on the Firecracker/Linux +backend, where nothing is baked, the full set installs on first boot. + +## Bootstrap sequence + +When `coop up` creates/restarts a project VM or `coop start` restarts a +stopped VM (without `--no-agents`), coop executes the following steps after +the VM boots and SSH becomes available: + +1. **User content**: Copy the allowlisted entries from `config_dir` to + `~/.grok/` in the guest, including `auth.json`, `config.toml`, and + `plugins/` when present. +2. **Managed settings**: Merge `ui.permission_mode = "always-approve"` into + the guest `~/.grok/config.toml`, drop the host `[plugins]` table, and + merge configured MCP servers. Other keys in that file are preserved. +3. **Folder trust**: Record `/workspace` in `~/.grok/trusted_folders.toml`. +4. **Marketplaces & plugins** (first boot only): Install the configured + `marketplaces`/`plugins` not already baked into the golden image. + +On restart, the same config files are refreshed so host-side updates are +reflected in the guest; marketplaces and plugins are not reinstalled. + +### Skipping bootstrap + +```bash +coop up . --no-agents +coop start --no-agents +``` + +This skips the guest bootstrap sequence entirely. The VM still includes the +Grok Build CLI because it is baked into the image during `coop setup`. + +## Updating Grok Build + +Grok Build auto-updates in the background by default. To force an update +immediately: + +```bash +coop agent update --grok +``` + +This runs `grok update` synchronously inside the guest as the guest user. +See [`agent update`](commands.md#agent-update). diff --git a/docs/images-and-profiles.md b/docs/images-and-profiles.md index b0b38c4..506244a 100644 --- a/docs/images-and-profiles.md +++ b/docs/images-and-profiles.md @@ -8,7 +8,7 @@ A template is a fully provisioned ext4 root filesystem. The build process: 1. Creates an ext4 disk image (default 8 GiB, configurable with `--template-size`) 2. Provisions a base Ubuntu system (a downloaded Firecracker CI squashfs on Firecracker, Ubuntu 24.04 cloud image on Lima) -3. Installs base packages, Docker, GitHub CLI, Claude Code, and Codex +3. Installs base packages, Docker, GitHub CLI, Claude Code, Codex, and Grok Build 4. Applies requested profiles and extra packages 5. Runs post-install scripts if provided @@ -38,9 +38,15 @@ configs, so gating them would let a later `auth = "chatgpt"` edit meet an image that cannot serve it. When that mode is not configured the wrapper simply execs Codex, so it costs nothing at run time. -Both agents are installed at whatever version was current when the template was built, and that version is not part of the staleness hash — a plain `coop setup` does not refresh them. There are two ways to get newer agents: +**Grok Build CLI:** installed via the official installer +(`https://x.ai/cli/install.sh`) during the template build. The binary lives at +`~/.grok/bin/grok` with a same-file `agent` link; `/usr/local/bin/grok` and +`/usr/local/bin/agent` point there. The image also installs a `grok-yolo` +shortcut. -- **A live instance:** `coop agent update [--claude] [--codex]` updates the binaries inside a running VM in place (see [`agent update`](commands.md#agent-update)). Claude Code also auto-updates itself in the background; Codex does not, so it is the one that typically needs this. +The agents are installed at whatever version was current when the template was built, and that version is not part of the staleness hash — a plain `coop setup` does not refresh them. There are two ways to get newer agents: + +- **A live instance:** `coop agent update [--claude] [--codex] [--grok]` updates the binaries inside a running VM in place (see [`agent update`](commands.md#agent-update)). Claude Code and Grok Build also auto-update themselves in the background; Codex does not, so it is the one that typically needs this. - **The golden image:** `coop setup --rebuild` rebuilds the template from a fresh base, so every new instance ships the latest agents. ## Built-in profiles diff --git a/docs/index.md b/docs/index.md index 5b6acfc..90b1bcb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,7 +28,8 @@ short navigational entrypoint; durable detail lives here. [`devcontainer.md`](devcontainer.md), [`editor.md`](editor.md), [`shell-completion.md`](shell-completion.md). - [`claude-integration.md`](claude-integration.md), - [`codex-integration.md`](codex-integration.md) — agent integration. + [`codex-integration.md`](codex-integration.md), + [`grok-integration.md`](grok-integration.md) — agent integration. - [`credential-proxy.md`](credential-proxy.md) — the opt-in `[proxy]` credential-injecting proxy (issue #411): keeps the raw API key out of the guest. diff --git a/docs/trust-model.md b/docs/trust-model.md index 9a74d61..24fc974 100644 --- a/docs/trust-model.md +++ b/docs/trust-model.md @@ -15,14 +15,17 @@ they don't introduce one. **coop's isolation boundary is the guest VM itself** — a Firecracker microVM on Linux, a Lima VM (Apple Virtualization.framework) on macOS. The point of the -tool is to run AI coding agents (Claude Code, Codex) with broad autonomy -*inside* that boundary, so the guest is deliberately permissive: +tool is to run AI coding agents (Claude Code, Codex, Grok Build) with broad +autonomy *inside* that boundary, so the guest is deliberately permissive: - The guest user has passwordless `sudo` (`NOPASSWD:ALL`). - Claude runs with a managed `~/.claude/settings.json` carrying `defaultMode: bypassPermissions`; the `codex`/`claude` launchers pass `--dangerously-bypass-approvals-and-sandbox` / `--dangerously-skip-permissions` - unless the user passes `--ask`. + unless the user passes `--ask`. Grok Build is launched with + `--always-approve` and a managed `ui.permission_mode = "always-approve"`; + `--ask` passes `--permission-mode default` so the guest config does not + keep always-approve for that session. This is intentional and correct: there is **no privilege boundary inside the guest to protect** — the whole VM is the blast radius. The security model is @@ -77,7 +80,7 @@ user launched it. ## Secrets and how they cross into the guest coop relays several secrets from the host into the guest: `ANTHROPIC_API_KEY`, -`OPENAI_API_KEY`, `GITHUB_TOKEN`/PAT, `CLAUDE_CODE_OAUTH_TOKEN`, arbitrary +`OPENAI_API_KEY`, `XAI_API_KEY`, `GITHUB_TOKEN`/PAT, `CLAUDE_CODE_OAUTH_TOKEN`, arbitrary user `env_forward` entries, and the VM SSH key. The invariants: - **Never on argv.** Secrets ride SSH `SendEnv` (env channel) or process env @@ -104,7 +107,8 @@ user `env_forward` entries, and the VM SSH key. The invariants: - **Secret files stay `0600`, dirs `0700`.** File-backend PATs live at `/github-pat/.txt` (`secret_store.rs:store_file`); all managed writes go through `fs_util::atomic_write_with_mode` / `atomic_write_ssh`, - which never relax permissions. + which never relax permissions. A host `~/.grok/auth.json` copied into the + guest is `chmod 0600` after `scp` (`backend.rs:restrict_guest_grok_auth`). - **Secrets stay out of logs.** `Cmd::redacted_arg` redacts argv in traces; `EnvForward`/`Secret` custom `Debug` impls keep values out of debug output. Do not log a resolved secret. From d470f82aed6256d600c179f25490a3e27f2d5077 Mon Sep 17 00:00:00 2001 From: Ali-Akber Saifee Date: Thu, 10 Sep 2026 20:33:37 -0700 Subject: [PATCH 5/6] Keep host Grok trees from breaking guest start A developer ~/.grok/skills can hold gigabytes of git lore and venvs. Copying that into every VM start filled the Lima disk and left read-only packs that a second scp could not overwrite. The restart remove quoted ~ so it never expanded. Skip hidden and bare-git directories, leave ~/ unquoted in the guest remove, and isolate the integration suite from host ~/.grok. --- config.example.toml | 2 +- docs/configuration.md | 2 +- docs/grok-integration.md | 2 + src/backend.rs | 99 ++++++++++++++++++++++++++++++++++++++-- tests/integration.sh | 64 ++++++++++++++++++++++---- 5 files changed, 153 insertions(+), 16 deletions(-) diff --git a/config.example.toml b/config.example.toml index 9837d69..7adfe4e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -112,7 +112,7 @@ # auth_token = "sk-..." # Optional; permissive servers ignore it. # [grok] -# config_dir = "~/.grok" # AGENTS.md, auth.json, config.toml, lsp.json, rules/, skills/, commands/, plugins/, hooks/, agents/, workflows/ (false to disable) +# config_dir = "~/.grok" # AGENTS.md, auth.json, config.toml, lsp.json, rules/, skills/, commands/, plugins/, hooks/, agents/, workflows/; skips hidden dirs and *.git (false to disable) # env_forward = ["CUSTOM_TOKEN"] # Extra env vars to forward to guest # marketplaces = ["owner/grok-plugins"] # owner/repo, git URL, or local path # plugins = ["my-skill"] # plugin name (`grok plugin install --trust`) diff --git a/docs/configuration.md b/docs/configuration.md index 8bd39a3..36dfc31 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -318,7 +318,7 @@ Grok Build configuration injected into the guest VM at start time. Every field i | Field | Type | Default | Description | |-------|------|---------|-------------| | `api_key` | string | unset (reads `$XAI_API_KEY` from environment) | xAI API key. Forwarded to the guest via SSH `SendEnv`. Never written to disk inside the VM. | -| `config_dir` | string (path) or `false` | `~/.grok` | Source directory for Grok Build files. Copies an allowlist of entries (`AGENTS.md`, `auth.json`, `config.toml`, `lsp.json`, `rules/`, `skills/`, `commands/`, `plugins/`, `hooks/`, `agents/`, `workflows/`) from this directory to `~/.grok/` in the guest on start. Directory symlinks under those entries are skipped. `installed-plugins/` is a host-absolute registry and stays on the host. Set to `false` to disable. Supports `~` expansion. | +| `config_dir` | string (path) or `false` | `~/.grok` | Source directory for Grok Build files. Copies an allowlist of entries (`AGENTS.md`, `auth.json`, `config.toml`, `lsp.json`, `rules/`, `skills/`, `commands/`, `plugins/`, `hooks/`, `agents/`, `workflows/`) from this directory to `~/.grok/` in the guest on start. Directory symlinks, hidden directories (`.git`, `.venv`, caches), and bare git repos (`*.git`) under those entries are skipped. `installed-plugins/` is a host-absolute registry and stays on the host. Set to `false` to disable. Supports `~` expansion. | | `env_forward` | array of strings | `[]` | Extra environment variable names to forward from host to guest via SSH `SendEnv`. `XAI_API_KEY` and `GITHUB_TOKEN` are forwarded automatically when set; list additional variables here. | | `marketplaces` | array of strings | `[]` | Grok Build plugin marketplace sources. Each entry is a `owner/repo`[`@ref`] shorthand, a git URL, or an absolute local directory path. Local directories are copied into the guest before registration. Baked into the golden image and delta-installed on first boot. | | `plugins` | array of strings | `[]` | Grok Build plugins to install from registered marketplaces (`grok plugin install --trust`). | diff --git a/docs/grok-integration.md b/docs/grok-integration.md index 87a489d..b330515 100644 --- a/docs/grok-integration.md +++ b/docs/grok-integration.md @@ -101,6 +101,8 @@ will not resolve in the guest. `plugins/` is the user-scoped *source* tree (markdown, scripts). It is auto-trusted. Directory symlinks inside it (or `plugins/` itself as a link) are skipped so a host checkout cannot be followed into the guest. +Hidden directories (`.git`, `.venv`, caches) and bare git repos (`*.git`) +inside a copied tree stay on the host. `installed-plugins/` and `registry.json` are **not** copied: they record absolute host paths and local checkouts, so they are not portable from macOS to a Linux guest. Marketplace plugins belong in `[grok] plugins` so diff --git a/src/backend.rs b/src/backend.rs index d3169ae..8d4d802 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -2334,11 +2334,10 @@ fn copy_staged_to_guest( let path = entry.path(); let local = HostPath::new(&path); if path.is_dir() { - // A previous boot may have copied read-only files (git packs). - // scp cannot overwrite those; replace the dest directory first. + // A previous boot may have copied read-only files. scp cannot + // overwrite those; replace the dest directory first. if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - let dest = format!("~/{guest_subdir}/{name}"); - target.exec(RemoteCommand::new().literal("rm -rf -- ").arg(&dest))?; + target.exec(remove_guest_staged_dir(guest_subdir, name))?; } target .scp_to_recursive(&local, &guest_dir) @@ -2354,6 +2353,18 @@ fn copy_staged_to_guest( Ok(()) } +/// Guest-side `rm -rf` of one previously copied allowlist directory. +/// +/// `~/` stays in a literal so the guest shell expands the home directory. +/// `.arg(name)` quotes only the directory basename. Passing the whole +/// `~/.grok/skills` path through `.arg` quotes the tilde and the remove +/// becomes a no-op (`rm -rf -- '~/.grok/skills'`). +fn remove_guest_staged_dir(guest_subdir: &str, name: &str) -> RemoteCommand { + RemoteCommand::new() + .literal(format!("rm -rf -- ~/{guest_subdir}/")) + .arg(name) +} + /// JSON body of the managed `~/.claude/settings.json` written to every guest. /// /// `skipDangerousModePermissionPrompt: true` pre-accepts bypass mode so that @@ -3182,8 +3193,22 @@ fn resolve_mcp_header_secrets( Ok(resolved) } +/// Hidden directories (`.git`, `.venv`, caches) and bare git repos +/// (`lkml-19.git`) are host-machine state, not guest config. +fn is_host_only_dir(name: &std::ffi::OsStr) -> bool { + let Some(n) = name.to_str() else { + return false; + }; + n.starts_with('.') + || std::path::Path::new(n) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("git")) +} + /// Recursively copy a directory tree. Directory symlinks are skipped so a /// host checkout linked into `plugins/` cannot be followed into the guest. +/// Hidden directories and bare git repos are skipped so a host skill tree +/// cannot drag venvs or lore object stores into the guest. fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { std::fs::create_dir_all(dst).with_context(|| format!("Failed to create {}", dst.display()))?; for entry in @@ -3199,6 +3224,10 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { continue; } if meta.is_dir() { + if is_host_only_dir(&entry.file_name()) { + tracing::debug!("Skipping host-only directory {}", src_path.display()); + continue; + } copy_dir_recursive(&src_path, &dst_path)?; } else { if dst_path.exists() { @@ -4207,6 +4236,41 @@ Filesystem 1M-blocks Used Available Use% Mounted on ); } + #[test] + fn stage_grok_files_skips_host_only_dirs_under_skills() { + let src = tempfile::TempDir::new().unwrap(); + let skill = src.path().join("skills/review"); + std::fs::create_dir_all(skill.join(".git/objects")).unwrap(); + std::fs::write(skill.join(".git/HEAD"), "ref").unwrap(); + std::fs::create_dir_all(skill.join("data/lore/lkml-19.git/objects")).unwrap(); + std::fs::write(skill.join("data/lore/lkml-19.git/HEAD"), "ref").unwrap(); + std::fs::create_dir_all(skill.join(".venv/bin")).unwrap(); + std::fs::write(skill.join(".venv/bin/python"), "py").unwrap(); + std::fs::write(skill.join("SKILL.md"), "skill").unwrap(); + + let staging = + stage_selected_files(src.path(), GROK_ALLOWED_FILES, GROK_ALLOWED_DIRS).unwrap(); + assert_eq!( + std::fs::read_to_string(staging.path().join("skills/review/SKILL.md")).unwrap(), + "skill" + ); + assert!( + !staging.path().join("skills/review/.git").exists(), + ".git must not be staged" + ); + assert!( + !staging + .path() + .join("skills/review/data/lore/lkml-19.git") + .exists(), + "bare git repos must not be staged" + ); + assert!( + !staging.path().join("skills/review/.venv").exists(), + ".venv must not be staged" + ); + } + #[test] fn stage_allowed_files_empty_source() { let src = tempfile::TempDir::new().unwrap(); @@ -5215,6 +5279,33 @@ url = "https://example.com/m" ); } + #[test] + fn remove_guest_staged_dir_keeps_tilde_unquoted() { + let cmd = remove_guest_staged_dir(".grok", "skills"); + assert_eq!(cmd.into_string(), "rm -rf -- ~/.grok/'skills'"); + } + + #[test] + fn copy_dir_recursive_skips_hidden_and_git_dirs() { + let src = tempfile::TempDir::new().unwrap(); + std::fs::write(src.path().join("keep.txt"), "ok").unwrap(); + std::fs::create_dir_all(src.path().join(".venv/bin")).unwrap(); + std::fs::write(src.path().join(".venv/bin/python"), "py").unwrap(); + std::fs::create_dir_all(src.path().join("lore/lkml-19.git/objects")).unwrap(); + std::fs::write(src.path().join("lore/lkml-19.git/HEAD"), "ref").unwrap(); + + let dst = tempfile::TempDir::new().unwrap(); + let target = dst.path().join("out"); + copy_dir_recursive(src.path(), &target).unwrap(); + assert_eq!( + std::fs::read_to_string(target.join("keep.txt")).unwrap(), + "ok" + ); + assert!(!target.join(".venv").exists()); + assert!(!target.join("lore/lkml-19.git").exists()); + assert!(target.join("lore").is_dir()); + } + #[test] fn copy_dir_recursive_overwrites_readonly_file() { let src1 = tempfile::TempDir::new().unwrap(); diff --git a/tests/integration.sh b/tests/integration.sh index c55e173..d699fa8 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -31,6 +31,7 @@ BINARY="${TEST_BINARY:-}" PROFILES="${TEST_PROFILES:-python,node}" INSTANCE="${TEST_INSTANCE:-test-$$}" FULL="${TEST_FULL:-0}" +SUITE_CONFIG="" # Track all instances we create for cleanup STARTED_INSTANCES=() @@ -114,7 +115,21 @@ HARNESS_ERR="" coop() { local rc=0 - HARNESS_OUT=$("$BINARY" "$@" 2>"$tmpdir/stderr") || rc=$? + local args=("$@") + if [[ -n "${SUITE_CONFIG:-}" ]]; then + local has_config=0 + local a + for a in "${args[@]}"; do + if [[ "$a" == "--config" || "$a" == --config=* ]]; then + has_config=1 + break + fi + done + if [[ "$has_config" -eq 0 ]]; then + args=(--config "$SUITE_CONFIG" "${args[@]}") + fi + fi + HARNESS_OUT=$("$BINARY" "${args[@]}" 2>"$tmpdir/stderr") || rc=$? HARNESS_ERR=$(cat "$tmpdir/stderr") return $rc } @@ -1095,14 +1110,26 @@ test_grok_settings_merge() { echo "" echo "=== Phase: grok settings merge across restart ===" - # Host ~/.grok/config.toml is recopied every boot, then managed keys - # are merged into that copy. Seed a wrong permission_mode and a host- - # style [plugins] table so restart proves the merge. trusted_folders.toml - # is not copied from the host, so a guest /tmp entry must survive. + # Use a fixture host dir, not the developer's ~/.grok. The default + # suite config disables that copy so a multi-gigabyte skills tree + # cannot stall or fill the guest. This phase still proves recopy + + # merge: host config.toml is the base, managed keys are forced, and + # trusted_folders.toml (not copied) keeps a guest /tmp entry. + local grok_src="$tmpdir/grok-host-config" + mkdir -p "$grok_src" + printf '%s\n' \ + '[ui]' 'vim_mode = true' 'permission_mode = "default"' '' \ + '[plugins]' 'sentinel = true' \ + > "$grok_src/config.toml" + + local cfg_file="$tmpdir/grok-merge-coop.toml" + cat > "$cfg_file" < "$SUITE_CONFIG" <<'EOF' +[grok] +config_dir = false +EOF + verify_binary # Pre-VM tests From 02d24637cb5b58728682b32dd03001def6e752c2 Mon Sep 17 00:00:00 2001 From: Ali-Akber Saifee Date: Thu, 10 Sep 2026 20:33:43 -0700 Subject: [PATCH 6/6] Keep Lima disk yaml in sync on resize Growing a disk with truncate left lima.yaml at the old size, so the next start looked like a shrink and Lima refused to boot. Update disk: with the grow, and revert the yaml if truncate fails. --- docs/backends.md | 4 ++-- src/lima.rs | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/docs/backends.md b/docs/backends.md index 040da4f..e13de23 100644 --- a/docs/backends.md +++ b/docs/backends.md @@ -43,7 +43,7 @@ The Lima template configures: ### Resize (disk, memory, vCPUs) -Resizing a stopped instance's disk truncates the Lima disk to the new size. Cloud-init's `growpart` module expands the partition and filesystem on next boot. Shrinking is not supported. +Resizing a stopped instance's disk truncates the Lima disk to the new size and updates the instance `lima.yaml` `disk:` field to match, so the next start sees the grown size. Cloud-init's `growpart` module expands the partition and filesystem on next boot. Shrinking is not supported. Memory and vCPU changes rewrite the `cpus`/`memory` fields in the instance's `lima.yaml`, which Lima re-reads on `limactl start`. The edit is written atomically, then coop starts the instance to validate and apply the new spec — if `limactl` rejects it (e.g. a spec larger than the host), the previous `lima.yaml` is restored. Without `--start` the instance is stopped again after the validating boot. The `lima.yaml` is authoritative: the global `[vm]` `cpus`/`memory` settings only seed *new* instances. @@ -159,7 +159,7 @@ Both backends support the same CLI commands and guest capabilities: | `coop status` | Queries `limactl list --json` | Reads PID file, queries guest via SSH | | `coop logs` | Reads Lima's `serial.log` | Reads Firecracker log file | | `coop shell` | SSH to localhost on Lima-assigned port | SSH to guest IP on configured port | -| `coop resize` | Disk: truncates Lima disk. Mem/vCPU: edits `lima.yaml`, validated via start | Disk: truncates + resize2fs on rootfs. Mem/vCPU: edits per-instance JSON | +| `coop resize` | Disk: truncates Lima disk and updates `lima.yaml` `disk:`. Mem/vCPU: edits `lima.yaml`, validated via start | Disk: truncates + resize2fs on rootfs. Mem/vCPU: edits per-instance JSON | | Resource monitoring | SSH query to guest | SSH query to guest | | Docker in guest | Works (full kernel) | Works (with iptables-legacy workaround) | | `--mount` host mounts | Live virtiofs (changes visible immediately) | One-time rsync sync (use `push`/`pull` to re-sync) | diff --git a/src/lima.rs b/src/lima.rs index f4b29f6..1d20123 100644 --- a/src/lima.rs +++ b/src/lima.rs @@ -275,8 +275,9 @@ pub fn destroy(inst: &Instance) -> Result<()> { /// Resize the disk of a stopped Lima instance. /// -/// Truncates the disk to the new size. Cloud-init's `growpart` -/// will expand the partition and filesystem on next boot. +/// Truncates the disk to the new size and updates `lima.yaml` `disk:` +/// to match, so the next start sees the grown size. Cloud-init's +/// `growpart` expands the partition and filesystem on next boot. pub fn resize_disk(_cfg: &CoopConfig, inst: &Instance, new_size: crate::config::GiB) -> Result<()> { let disk = disk_path(inst)?; @@ -310,6 +311,18 @@ pub fn resize_disk(_cfg: &CoopConfig, inst: &Instance, new_size: crate::config:: "Resizing instance '{}' from {current_gib} to {new_gib} GiB", inst.name, ); + + // Lima re-reads `disk:` from lima.yaml on start. Growing the file + // without updating that field makes the next start look like a + // shrink, which Lima rejects. + let yaml_path = lima_home()?.join(lima_name(inst)).join("lima.yaml"); + let original = fs::read_to_string(&yaml_path) + .with_context(|| format!("Failed to read {}", yaml_path.display()))?; + let disk_value = format!("\"{}GiB\"", new_size.as_u32()); + let edited = set_yaml_scalar(&original, "disk", &disk_value) + .with_context(|| format!("No top-level 'disk' key in {}", yaml_path.display()))?; + crate::fs_util::atomic_write_with_mode(&yaml_path, &edited, 0o644)?; + let status = Command::new("truncate") .arg("-s") .arg(format!("{new_size}G")) @@ -318,6 +331,12 @@ pub fn resize_disk(_cfg: &CoopConfig, inst: &Instance, new_size: crate::config:: .context("Failed to run truncate")?; if !status.success() { + if let Err(restore) = crate::fs_util::atomic_write_with_mode(&yaml_path, &original, 0o644) { + tracing::error!( + "Failed to restore {} after a failed truncate: {restore}", + yaml_path.display() + ); + } bail!("truncate failed for {}", disk.display()); } @@ -1914,6 +1933,20 @@ mod tests { ); } + #[test] + fn set_yaml_scalar_replaces_top_level_disk() { + let yaml = "cpus: 2\ndisk: \"8GiB\"\nmemory: \"4GiB\"\n"; + let edited = set_yaml_scalar(yaml, "disk", "\"9GiB\"").unwrap(); + assert!( + edited.contains("disk: \"9GiB\"\n"), + "disk not updated: {edited}" + ); + assert!( + edited.contains("cpus: 2\n") && edited.contains("memory: \"4GiB\"\n"), + "unrelated keys changed: {edited}" + ); + } + #[test] fn set_yaml_scalar_returns_none_for_missing_key() { assert!(set_yaml_scalar("cpus: 2\n", "memory", "\"4GiB\"").is_none());