diff --git a/.changeset/mcp-android-integration.md b/.changeset/mcp-android-integration.md new file mode 100644 index 00000000..8438fcd9 --- /dev/null +++ b/.changeset/mcp-android-integration.md @@ -0,0 +1,14 @@ +--- +"@prover-coder-ai/docker-git": minor +--- + +Add Android MCP integration alongside the existing Playwright MCP support (issue #436). + +Projects can now opt into a nested Android emulator sidecar driven by the +first-party Rust `android-connection` MCP server, mirroring how Playwright MCP works. Enable it +with the new `--mcp-android` / `--no-mcp-android` create flags, the `mcp-android` +subcommand, the interactive create-flow prompt, or the `enableMcpAndroid` field +on the web/API create-project request. When enabled, the generated +`docker-compose.yml` adds a gated `docker-android` emulator service (KVM, +ADB port forwarding, headless CI mode) and the agent MCP config writers register +the Android server so it coexists with Playwright. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 35247746..0312df2a 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -39,6 +39,26 @@ jobs: - name: Build (api) run: bun run --cwd packages/api build + rust-android-connection: + name: Rust Android connection + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - name: Rust version + run: | + rustc --version + cargo --version + - name: Format + working-directory: crates/android-connection + run: cargo fmt --check + - name: Test + working-directory: crates/android-connection + run: cargo test --locked + - name: Clippy + working-directory: crates/android-connection + run: cargo clippy --locked --all-targets -- -D warnings + dist-deps-prune: name: Dist deps prune runs-on: ubuntu-latest diff --git a/README.md b/README.md index cda52047..bca9d692 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ bun run docker-git clone https://github.com/ProverCoderAI/docker-git/issues/122 - `--force` пересоздаёт окружение и удаляет volumes проекта. - `--mcp-playwright` включает Playwright MCP и Chromium sidecar для браузерной автоматизации. +- `--mcp-android` включает first-party Android MCP (`android-connection`) и вложенный sidecar с Android-эмулятором (`docker-android`) для мобильной автоматизации. Автоматический запуск агента: diff --git a/crates/android-connection/.gitignore b/crates/android-connection/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/crates/android-connection/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/crates/android-connection/Cargo.lock b/crates/android-connection/Cargo.lock new file mode 100644 index 00000000..038d1da3 --- /dev/null +++ b/crates/android-connection/Cargo.lock @@ -0,0 +1,249 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "docker-git-android-connection" +version = "0.1.0" +dependencies = [ + "clap", + "serde", + "serde_json", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/android-connection/Cargo.toml b/crates/android-connection/Cargo.toml new file mode 100644 index 00000000..87c88919 --- /dev/null +++ b/crates/android-connection/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "docker-git-android-connection" +version = "0.1.0" +edition = "2021" +description = "First-party Android MCP and lifecycle module for docker-git" +license = "MIT" +repository = "https://github.com/ProverCoderAI/docker-git" + +[lib] +name = "docker_git_android_connection" +path = "src/lib.rs" + +[[bin]] +name = "docker-git-android-connection" +path = "src/main.rs" + +[[bin]] +name = "android-connection" +path = "src/bin/android-connection.rs" + +[dependencies] +clap = { version = "4.5.53", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" diff --git a/crates/android-connection/README.md b/crates/android-connection/README.md new file mode 100644 index 00000000..17b14d76 --- /dev/null +++ b/crates/android-connection/README.md @@ -0,0 +1,10 @@ +# docker-git Android connection + +First-party Android MCP module for docker-git. + +The crate provides two binaries: + +- `android-connection`: MCP stdio server used by Codex, Claude, Gemini, and Grok. +- `docker-git-android-connection`: lifecycle CLI for deterministic Android runtime naming and Docker command construction. + +The core module keeps deterministic naming, endpoint validation, and tool specifications pure. Shell effects are isolated in the binaries and MCP tool handlers. diff --git a/crates/android-connection/src/bin/android-connection.rs b/crates/android-connection/src/bin/android-connection.rs new file mode 100644 index 00000000..8366df39 --- /dev/null +++ b/crates/android-connection/src/bin/android-connection.rs @@ -0,0 +1,54 @@ +use clap::Parser; +use docker_git_android_connection::mcp::{run_stdio, McpState}; +use docker_git_android_connection::{ + android_spec, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, DEFAULT_PROJECT_ID, +}; +use std::io::{self, BufReader}; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser, Debug)] +#[command(version, about = "Android MCP stdio server for docker-git")] +struct Cli { + #[arg(long, default_value = DEFAULT_PROJECT_ID)] + project: String, + #[arg(long, default_value = "docker-git-shared")] + network: String, + #[arg(long, default_value = DEFAULT_ADB_ENDPOINT)] + endpoint: String, + #[arg(long, default_value = DEFAULT_ANDROID_IMAGE)] + image: String, + #[arg(long, default_value = ".")] + workspace: PathBuf, + #[arg(long)] + allow_install: bool, + #[arg(long)] + no_adb_probe: bool, +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::from(1) + } + } +} + +fn run() -> Result<(), Box> { + let cli = Cli::parse(); + let spec = android_spec(&cli.project, &cli.network, &cli.endpoint, &cli.image)?; + let state = McpState { + spec, + workspace: cli.workspace, + adb_probe: !cli.no_adb_probe, + allow_install: cli.allow_install, + }; + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut reader = BufReader::new(stdin.lock()); + let mut writer = stdout.lock(); + run_stdio(&mut reader, &mut writer, state)?; + Ok(()) +} diff --git a/crates/android-connection/src/lib.rs b/crates/android-connection/src/lib.rs new file mode 100644 index 00000000..54c82f6b --- /dev/null +++ b/crates/android-connection/src/lib.rs @@ -0,0 +1,253 @@ +pub mod mcp; + +use serde::Serialize; + +pub const SERVER_NAME: &str = "android-connection"; +pub const DEFAULT_ANDROID_IMAGE: &str = "budtmo/docker-android:emulator_14.0"; +pub const DEFAULT_ADB_ENDPOINT: &str = "android:5555"; +pub const DEFAULT_PROJECT_ID: &str = "docker-git"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EndpointError { + pub value: String, +} + +impl std::fmt::Display for EndpointError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "invalid ADB endpoint {:?}; allowed characters are ASCII letters, digits, '.', '-', '_' and ':'", + self.value + ) + } +} + +impl std::error::Error for EndpointError {} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct AndroidSpec { + pub project_id: String, + pub project_container_name: String, + pub android_container_name: String, + pub android_volume_name: String, + pub docker_network: String, + pub adb_endpoint: String, + pub image: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct McpToolSpec { + pub name: &'static str, + pub description: &'static str, +} + +// CHANGE: normalize externally supplied project ids into Docker-safe names +// WHY: Android sidecar names are pure functions of the project id, so MCP clients and lifecycle CLI agree +// QUOTE(TZ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +// FORMAT THEOREM: forall s: normalize(s) in [a-z0-9-]+ and normalize(s) != "" +// PURITY: CORE +// INVARIANT: output is non-empty, lowercase, and contains only Docker-name-safe characters +// COMPLEXITY: O(n)/O(n) +pub fn normalize_project_id(raw: &str) -> String { + let mut normalized = String::new(); + let mut previous_dash = false; + + for byte in raw.bytes() { + let next = match byte { + b'a'..=b'z' | b'0'..=b'9' => Some(byte as char), + b'A'..=b'Z' => Some(byte.to_ascii_lowercase() as char), + _ => { + if normalized.is_empty() || previous_dash { + None + } else { + Some('-') + } + } + }; + + if let Some(character) = next { + previous_dash = character == '-'; + normalized.push(character); + } + } + + while normalized.ends_with('-') { + normalized.pop(); + } + + if normalized.is_empty() { + DEFAULT_PROJECT_ID.to_string() + } else { + normalized + } +} + +pub fn android_container_name(project_id: &str) -> String { + format!("{}-android", normalize_project_id(project_id)) +} + +pub fn android_volume_name(project_id: &str) -> String { + format!("{}-home-android", normalize_project_id(project_id)) +} + +pub fn is_safe_adb_endpoint(value: &str) -> bool { + !value.is_empty() + && value.len() <= 255 + && value.contains(':') + && value.bytes().all(|byte| { + matches!( + byte, + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'-' | b'_' | b':' + ) + }) +} + +pub fn validate_adb_endpoint(value: &str) -> Result { + if is_safe_adb_endpoint(value) { + Ok(value.to_string()) + } else { + Err(EndpointError { + value: value.to_string(), + }) + } +} + +pub fn android_spec( + project_id: &str, + docker_network: &str, + adb_endpoint: &str, + image: &str, +) -> Result { + let normalized = normalize_project_id(project_id); + Ok(AndroidSpec { + project_id: normalized.clone(), + project_container_name: normalized.clone(), + android_container_name: android_container_name(&normalized), + android_volume_name: android_volume_name(&normalized), + docker_network: docker_network.to_string(), + adb_endpoint: validate_adb_endpoint(adb_endpoint)?, + image: image.to_string(), + }) +} + +pub fn android_tools() -> Vec { + vec![ + McpToolSpec { + name: "android_status", + description: "Return the configured Android runtime and optional ADB status.", + }, + McpToolSpec { + name: "android_devices", + description: "List Android devices visible to adb.", + }, + McpToolSpec { + name: "android_screenshot", + description: "Capture a PNG screenshot into the workspace.", + }, + McpToolSpec { + name: "android_tap", + description: "Tap screen coordinates.", + }, + McpToolSpec { + name: "android_swipe", + description: "Swipe between screen coordinates.", + }, + McpToolSpec { + name: "android_type_text", + description: "Type text into the active Android input field.", + }, + McpToolSpec { + name: "android_press_key", + description: "Send an Android keycode.", + }, + McpToolSpec { + name: "android_launch_app", + description: "Launch an installed Android package.", + }, + McpToolSpec { + name: "android_open_url", + description: "Open a URL through Android intent handling.", + }, + McpToolSpec { + name: "android_logcat", + description: "Read recent logcat output.", + }, + McpToolSpec { + name: "android_install_apk", + description: "Install an APK from the workspace when explicitly enabled.", + }, + ] +} + +pub fn docker_run_args(spec: &AndroidSpec) -> Vec { + vec![ + "run".to_string(), + "--detach".to_string(), + "--name".to_string(), + spec.android_container_name.clone(), + "--privileged".to_string(), + "--network".to_string(), + spec.docker_network.clone(), + "--env".to_string(), + "EMULATOR_HEADLESS=true".to_string(), + "--env".to_string(), + "WEB_VNC=true".to_string(), + "--volume".to_string(), + format!("{}:/root/.android", spec.android_volume_name), + spec.image.clone(), + ] +} + +pub fn docker_stop_args(spec: &AndroidSpec) -> Vec { + vec![ + "rm".to_string(), + "--force".to_string(), + spec.android_container_name.clone(), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_project_id_to_docker_safe_name() { + assert_eq!( + normalize_project_id("Org/Repo:Feature_X"), + "org-repo-feature-x" + ); + assert_eq!(normalize_project_id("///"), DEFAULT_PROJECT_ID); + } + + #[test] + fn rejects_shell_fragments_in_adb_endpoint() { + assert!(validate_adb_endpoint("dg-test-android:5555").is_ok()); + assert!(validate_adb_endpoint("dg-test-android:5555;touch /tmp/pwn").is_err()); + assert!(validate_adb_endpoint("$(whoami):5555").is_err()); + } + + #[test] + fn builds_deterministic_android_spec() { + let spec = android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid spec"); + + assert_eq!(spec.project_container_name, "dg-test"); + assert_eq!(spec.android_container_name, "dg-test-android"); + assert_eq!(spec.android_volume_name, "dg-test-home-android"); + } + + #[test] + fn advertises_android_mcp_tools() { + let names: Vec<&str> = android_tools().into_iter().map(|tool| tool.name).collect(); + assert!(names.contains(&"android_status")); + assert!(names.contains(&"android_tap")); + assert!(names.contains(&"android_install_apk")); + } +} diff --git a/crates/android-connection/src/main.rs b/crates/android-connection/src/main.rs new file mode 100644 index 00000000..2dc0fa43 --- /dev/null +++ b/crates/android-connection/src/main.rs @@ -0,0 +1,104 @@ +use clap::{Args, Parser, Subcommand}; +use docker_git_android_connection::{ + android_spec, docker_run_args, docker_stop_args, DEFAULT_ADB_ENDPOINT, DEFAULT_ANDROID_IMAGE, + DEFAULT_PROJECT_ID, +}; +use serde_json::json; +use std::process::{Command, ExitCode}; + +#[derive(Parser, Debug)] +#[command(version, about = "docker-git Android runtime lifecycle CLI")] +struct Cli { + #[command(subcommand)] + command: LifecycleCommand, +} + +#[derive(Subcommand, Debug)] +enum LifecycleCommand { + Start(LifecycleArgs), + Status(LifecycleArgs), + Stop(LifecycleArgs), +} + +#[derive(Args, Clone, Debug)] +struct LifecycleArgs { + #[arg(long, default_value = DEFAULT_PROJECT_ID)] + project: String, + #[arg(long, default_value = "docker-git-shared")] + network: String, + #[arg(long, default_value = DEFAULT_ADB_ENDPOINT)] + endpoint: String, + #[arg(long, default_value = DEFAULT_ANDROID_IMAGE)] + image: String, + #[arg(long)] + dry_run: bool, +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::from(1) + } + } +} + +fn run() -> Result<(), Box> { + let cli = Cli::parse(); + match cli.command { + LifecycleCommand::Start(args) => start(args), + LifecycleCommand::Status(args) => status(args), + LifecycleCommand::Stop(args) => stop(args), + } +} + +fn start(args: LifecycleArgs) -> Result<(), Box> { + let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; + let docker_args = docker_run_args(&spec); + if args.dry_run { + println!( + "{}", + serde_json::to_string_pretty(&json!({ "docker": docker_args }))? + ); + return Ok(()); + } + + run_docker(&docker_args) +} + +fn status(args: LifecycleArgs) -> Result<(), Box> { + let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; + println!("{}", serde_json::to_string_pretty(&spec)?); + Ok(()) +} + +fn stop(args: LifecycleArgs) -> Result<(), Box> { + let spec = android_spec(&args.project, &args.network, &args.endpoint, &args.image)?; + let docker_args = docker_stop_args(&spec); + if args.dry_run { + println!( + "{}", + serde_json::to_string_pretty(&json!({ "docker": docker_args }))? + ); + return Ok(()); + } + + run_docker(&docker_args) +} + +fn run_docker(args: &[String]) -> Result<(), Box> { + let output = Command::new("docker").args(args).output()?; + if output.status.success() { + print!("{}", String::from_utf8_lossy(&output.stdout)); + return Ok(()); + } + + Err(format!( + "docker failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + ) + .into()) +} diff --git a/crates/android-connection/src/mcp.rs b/crates/android-connection/src/mcp.rs new file mode 100644 index 00000000..d4a6dec3 --- /dev/null +++ b/crates/android-connection/src/mcp.rs @@ -0,0 +1,635 @@ +use crate::{android_tools, AndroidSpec, SERVER_NAME}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::fs; +use std::io::{self, BufRead, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Output}; + +#[derive(Clone, Debug)] +pub struct McpState { + pub spec: AndroidSpec, + pub workspace: PathBuf, + pub adb_probe: bool, + pub allow_install: bool, +} + +#[derive(Debug)] +enum McpToolError { + MissingArgument(&'static str), + InvalidArgument(String), + AdbProbeDisabled, + CommandFailed(String), + Io(String), +} + +impl std::fmt::Display for McpToolError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingArgument(name) => write!(formatter, "missing required argument: {name}"), + Self::InvalidArgument(message) => write!(formatter, "invalid argument: {message}"), + Self::AdbProbeDisabled => write!(formatter, "ADB probing is disabled for this server"), + Self::CommandFailed(message) => write!(formatter, "{message}"), + Self::Io(message) => write!(formatter, "{message}"), + } + } +} + +#[derive(Debug, Deserialize)] +struct JsonRpcRequest { + id: Option, + method: String, + params: Option, +} + +pub fn run_stdio(reader: &mut R, writer: &mut W, state: McpState) -> io::Result<()> +where + R: BufRead, + W: Write, +{ + while let Some(raw) = read_next_message(reader)? { + let response = match serde_json::from_str::(&raw) { + Ok(request) => handle_request(&request, &state), + Err(error) => Some(json_rpc_error( + Value::Null, + -32700, + &format!("invalid JSON-RPC request: {error}"), + )), + }; + + if let Some(value) = response { + write_json_message(writer, &value)?; + } + } + + Ok(()) +} + +fn read_next_message(reader: &mut R) -> io::Result> { + loop { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line)?; + if bytes_read == 0 { + return Ok(None); + } + + let first_line = line.trim_end_matches(['\r', '\n']); + if first_line.is_empty() { + continue; + } + + if let Some(length) = parse_content_length(first_line)? { + read_headers(reader)?; + let mut payload = vec![0_u8; length]; + reader.read_exact(&mut payload)?; + return String::from_utf8(payload) + .map(Some) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)); + } + + return Ok(Some(first_line.to_string())); + } +} + +fn parse_content_length(header: &str) -> io::Result> { + let lowercase = header.to_ascii_lowercase(); + if !lowercase.starts_with("content-length:") { + return Ok(None); + } + + let raw_length = header + .split_once(':') + .map(|(_, value)| value.trim()) + .unwrap_or_default(); + raw_length + .parse::() + .map(Some) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +fn read_headers(reader: &mut R) -> io::Result<()> { + loop { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line)?; + if bytes_read == 0 { + return Ok(()); + } + if line.trim().is_empty() { + return Ok(()); + } + } +} + +fn write_json_message(writer: &mut W, value: &Value) -> io::Result<()> { + let body = serde_json::to_string(value) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body)?; + writer.flush() +} + +fn handle_request(request: &JsonRpcRequest, state: &McpState) -> Option { + if request.id.is_none() && request.method.starts_with("notifications/") { + return None; + } + + let id = request.id.clone().unwrap_or(Value::Null); + let response = match request.method.as_str() { + "initialize" => json!({ + "protocolVersion": "2024-11-05", + "capabilities": { "tools": {} }, + "serverInfo": { + "name": SERVER_NAME, + "version": env!("CARGO_PKG_VERSION") + } + }), + "tools/list" => json!({ "tools": render_tools() }), + "tools/call" => return Some(handle_tools_call(id, request.params.as_ref(), state)), + method => { + return Some(json_rpc_error( + id, + -32601, + &format!("method not found: {method}"), + )) + } + }; + + Some(json!({ + "jsonrpc": "2.0", + "id": id, + "result": response + })) +} + +fn json_rpc_error(id: Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + }) +} + +fn render_tools() -> Value { + Value::Array( + android_tools() + .into_iter() + .map(|tool| { + json!({ + "name": tool.name, + "description": tool.description, + "inputSchema": { + "type": "object", + "additionalProperties": true + } + }) + }) + .collect(), + ) +} + +fn handle_tools_call(id: Value, params: Option<&Value>, state: &McpState) -> Value { + let result = call_tool_from_params(params, state); + let (text, is_error) = match result { + Ok(text) => (text, false), + Err(error) => (error.to_string(), true), + }; + + json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "content": [ + { + "type": "text", + "text": text + } + ], + "isError": is_error + } + }) +} + +fn call_tool_from_params(params: Option<&Value>, state: &McpState) -> Result { + let params = + params.ok_or_else(|| McpToolError::InvalidArgument("missing params".to_string()))?; + let name = params + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| McpToolError::InvalidArgument("missing tool name".to_string()))?; + let arguments = params.get("arguments").unwrap_or(&Value::Null); + + match name { + "android_status" => android_status(state), + "android_devices" => android_devices(state), + "android_screenshot" => android_screenshot(state, arguments), + "android_tap" => android_tap(state, arguments), + "android_swipe" => android_swipe(state, arguments), + "android_type_text" => android_type_text(state, arguments), + "android_press_key" => android_press_key(state, arguments), + "android_launch_app" => android_launch_app(state, arguments), + "android_open_url" => android_open_url(state, arguments), + "android_logcat" => android_logcat(state, arguments), + "android_install_apk" => android_install_apk(state, arguments), + unknown => Err(McpToolError::InvalidArgument(format!( + "unknown Android MCP tool: {unknown}" + ))), + } +} + +fn android_status(state: &McpState) -> Result { + if !state.adb_probe { + return serde_json::to_string_pretty(&json!({ + "server": SERVER_NAME, + "adbProbe": false, + "spec": state.spec + })) + .map_err(|error| McpToolError::Io(error.to_string())); + } + + match run_adb(state, &["devices".to_string()]) { + Ok(output) => Ok(format!( + "Android runtime: {}\nADB endpoint: {}\n\n{}", + state.spec.android_container_name, state.spec.adb_endpoint, output + )), + Err(error) => Ok(format!( + "Android runtime: {}\nADB endpoint: {}\nADB status error: {}", + state.spec.android_container_name, state.spec.adb_endpoint, error + )), + } +} + +fn android_devices(state: &McpState) -> Result { + run_adb(state, &["devices".to_string()]) +} + +fn android_tap(state: &McpState, arguments: &Value) -> Result { + let x = integer_argument(arguments, "x")?; + let y = integer_argument(arguments, "y")?; + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "tap".to_string(), + x.to_string(), + y.to_string(), + ], + ) +} + +fn android_swipe(state: &McpState, arguments: &Value) -> Result { + let start_x = integer_argument(arguments, "startX")?; + let start_y = integer_argument(arguments, "startY")?; + let end_x = integer_argument(arguments, "endX")?; + let end_y = integer_argument(arguments, "endY")?; + let duration_ms = optional_integer_argument(arguments, "durationMs")?.unwrap_or(300); + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "swipe".to_string(), + start_x.to_string(), + start_y.to_string(), + end_x.to_string(), + end_y.to_string(), + duration_ms.to_string(), + ], + ) +} + +fn android_type_text(state: &McpState, arguments: &Value) -> Result { + let text = string_argument(arguments, "text")?.replace(' ', "%s"); + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "text".to_string(), + text, + ], + ) +} + +fn android_press_key(state: &McpState, arguments: &Value) -> Result { + let keycode = string_argument(arguments, "keycode")?; + if !keycode + .bytes() + .all(|byte| matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')) + { + return Err(McpToolError::InvalidArgument( + "keycode may contain only ASCII letters, digits, and '_'".to_string(), + )); + } + run_adb( + state, + &[ + "shell".to_string(), + "input".to_string(), + "keyevent".to_string(), + keycode, + ], + ) +} + +fn android_launch_app(state: &McpState, arguments: &Value) -> Result { + let package_name = string_argument(arguments, "package")?; + let activity = optional_string_argument(arguments, "activity")?; + match activity { + Some(activity) if !activity.is_empty() => run_adb( + state, + &[ + "shell".to_string(), + "am".to_string(), + "start".to_string(), + "-n".to_string(), + format!("{package_name}/{activity}"), + ], + ), + _ => run_adb( + state, + &[ + "shell".to_string(), + "monkey".to_string(), + "-p".to_string(), + package_name, + "-c".to_string(), + "android.intent.category.LAUNCHER".to_string(), + "1".to_string(), + ], + ), + } +} + +fn android_open_url(state: &McpState, arguments: &Value) -> Result { + let url = string_argument(arguments, "url")?; + run_adb( + state, + &[ + "shell".to_string(), + "am".to_string(), + "start".to_string(), + "-a".to_string(), + "android.intent.action.VIEW".to_string(), + "-d".to_string(), + url, + ], + ) +} + +fn android_logcat(state: &McpState, arguments: &Value) -> Result { + let lines = optional_integer_argument(arguments, "lines")? + .unwrap_or(200) + .clamp(1, 1000); + run_adb( + state, + &[ + "logcat".to_string(), + "-d".to_string(), + "-t".to_string(), + lines.to_string(), + ], + ) +} + +fn android_screenshot(state: &McpState, arguments: &Value) -> Result { + let output_path = optional_string_argument(arguments, "path")? + .unwrap_or_else(|| "android-screenshot.png".to_string()); + let target_path = workspace_path(&state.workspace, &output_path)?; + let output = run_adb_raw( + state, + &[ + "exec-out".to_string(), + "screencap".to_string(), + "-p".to_string(), + ], + )?; + if !output.status.success() { + return Err(McpToolError::CommandFailed(command_failure( + "adb screenshot", + output, + ))); + } + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).map_err(|error| McpToolError::Io(error.to_string()))?; + } + fs::write(&target_path, output.stdout).map_err(|error| McpToolError::Io(error.to_string()))?; + Ok(format!("screenshot written to {}", target_path.display())) +} + +fn android_install_apk(state: &McpState, arguments: &Value) -> Result { + if !state.allow_install { + return Err(McpToolError::InvalidArgument( + "APK installation requires --allow-install".to_string(), + )); + } + let apk_path = string_argument(arguments, "path")?; + let target_path = workspace_path(&state.workspace, &apk_path)?; + run_adb( + state, + &["install".to_string(), target_path.display().to_string()], + ) +} + +fn run_adb(state: &McpState, args: &[String]) -> Result { + let output = run_adb_raw(state, args)?; + output_to_text("adb", output) +} + +fn run_adb_raw(state: &McpState, args: &[String]) -> Result { + if !state.adb_probe { + return Err(McpToolError::AdbProbeDisabled); + } + + let connect_output = Command::new("adb") + .arg("connect") + .arg(&state.spec.adb_endpoint) + .output() + .map_err(|error| { + McpToolError::CommandFailed(format!("failed to execute adb connect: {error}")) + })?; + if !connect_output.status.success() { + return Err(McpToolError::CommandFailed(command_failure( + "adb connect", + connect_output, + ))); + } + + Command::new("adb") + .args(args) + .output() + .map_err(|error| McpToolError::CommandFailed(format!("failed to execute adb: {error}"))) +} + +fn output_to_text(label: &str, output: Output) -> Result { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Ok(match (stdout.is_empty(), stderr.is_empty()) { + (true, true) => format!("{label} completed successfully"), + (false, true) => stdout, + (true, false) => stderr, + (false, false) => format!("{stdout}\n{stderr}"), + }); + } + + Err(McpToolError::CommandFailed(command_failure(label, output))) +} + +fn command_failure(label: &str, output: Output) -> String { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + format!( + "{label} failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + stdout.trim(), + stderr.trim() + ) +} + +fn integer_argument(arguments: &Value, name: &'static str) -> Result { + arguments + .get(name) + .and_then(Value::as_i64) + .ok_or(McpToolError::MissingArgument(name)) +} + +fn optional_integer_argument( + arguments: &Value, + name: &'static str, +) -> Result, McpToolError> { + match arguments.get(name) { + Some(value) => value + .as_i64() + .map(Some) + .ok_or_else(|| McpToolError::InvalidArgument(format!("{name} must be an integer"))), + None => Ok(None), + } +} + +fn string_argument(arguments: &Value, name: &'static str) -> Result { + arguments + .get(name) + .and_then(Value::as_str) + .map(ToString::to_string) + .ok_or(McpToolError::MissingArgument(name)) +} + +fn optional_string_argument( + arguments: &Value, + name: &'static str, +) -> Result, McpToolError> { + match arguments.get(name) { + Some(value) => value + .as_str() + .map(|text| Some(text.to_string())) + .ok_or_else(|| McpToolError::InvalidArgument(format!("{name} must be a string"))), + None => Ok(None), + } +} + +fn workspace_path(workspace: &Path, value: &str) -> Result { + let candidate = PathBuf::from(value); + if value.is_empty() + || candidate.is_absolute() + || candidate + .components() + .any(|component| component == Component::ParentDir) + { + return Err(McpToolError::InvalidArgument( + "path must be relative, non-empty, and must not contain '..'".to_string(), + )); + } + + Ok(workspace.join(candidate)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{android_spec, DEFAULT_ANDROID_IMAGE}; + use std::io::Cursor; + + fn test_state() -> McpState { + McpState { + spec: android_spec( + "dg-test", + "docker-git-shared", + "dg-test-android:5555", + DEFAULT_ANDROID_IMAGE, + ) + .expect("valid android spec"), + workspace: PathBuf::from("/workspace"), + adb_probe: false, + allow_install: false, + } + } + + fn frame(value: Value) -> String { + let payload = serde_json::to_string(&value).expect("serializable request"); + format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload) + } + + #[test] + fn serves_initialize_and_tools_list_over_framed_stdio() { + let input = format!( + "{}{}", + frame(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {} + })), + frame(json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + })) + ); + let mut reader = Cursor::new(input.into_bytes()); + let mut output = Vec::new(); + + run_stdio(&mut reader, &mut output, test_state()).expect("stdio server succeeds"); + + let output_text = String::from_utf8(output).expect("valid utf8 output"); + assert!(output_text.contains(SERVER_NAME)); + assert!(output_text.contains("android_status")); + assert!(output_text.contains("android_tap")); + } + + #[test] + fn reports_status_without_adb_when_probe_is_disabled() { + let input = frame(json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "android_status", + "arguments": {} + } + })); + let mut reader = Cursor::new(input.into_bytes()); + let mut output = Vec::new(); + + run_stdio(&mut reader, &mut output, test_state()).expect("status succeeds"); + + let output_text = String::from_utf8(output).expect("valid utf8 output"); + assert!(output_text.contains("\"isError\":false")); + assert!(output_text.contains("adbProbe")); + assert!(output_text.contains("false")); + assert!(output_text.contains("dg-test-android")); + } + + #[test] + fn rejects_workspace_paths_outside_workspace() { + let workspace = PathBuf::from("/workspace"); + + assert!(workspace_path(&workspace, "screenshots/current.png").is_ok()); + assert!(workspace_path(&workspace, "/tmp/outside.png").is_err()); + assert!(workspace_path(&workspace, "../outside.png").is_err()); + assert!(workspace_path(&workspace, "screenshots/../outside.png").is_err()); + } +} diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index e6efd92d..5abcdee4 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -476,6 +476,7 @@ export type CreateProjectRequest = { readonly dockerNetworkMode?: string | undefined readonly dockerSharedNetworkName?: string | undefined readonly enableMcpPlaywright?: boolean | undefined + readonly enableMcpAndroid?: boolean | undefined readonly outDir?: string | undefined readonly gitTokenLabel?: string | undefined readonly skipGithubAuth?: boolean | undefined diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index 6412840b..a91aed32 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -43,6 +43,7 @@ export const CreateProjectRequestSchema = Schema.Struct({ dockerNetworkMode: OptionalString, dockerSharedNetworkName: OptionalString, enableMcpPlaywright: OptionalBoolean, + enableMcpAndroid: OptionalBoolean, outDir: OptionalString, gitTokenLabel: OptionalString, skipGithubAuth: OptionalBoolean, diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index 5f79f94f..173c2626 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -471,6 +471,7 @@ const toCreateRawOptions = (request: CreateProjectRequest): RawOptions => ({ ? {} : { dockerSharedNetworkName: request.dockerSharedNetworkName }), ...(request.enableMcpPlaywright === undefined ? {} : { enableMcpPlaywright: request.enableMcpPlaywright }), + ...(request.enableMcpAndroid === undefined ? {} : { enableMcpAndroid: request.enableMcpAndroid }), ...(request.outDir === undefined ? {} : { outDir: request.outDir }), ...(request.gitTokenLabel === undefined ? {} : { gitTokenLabel: request.gitTokenLabel }), ...(request.skipGithubAuth === undefined ? {} : { skipGithubAuth: request.skipGithubAuth }), diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 2cc86b25..d13ddbb3 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,14 @@ # @prover-coder-ai/docker-git +## 1.3.14 + +### Patch Changes + +- chore: automated version bump + +- Updated dependencies []: + - @prover-coder-ai/docker-git-session-sync@1.0.70 + ## 1.3.13 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 2caac76a..07ed7322 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@prover-coder-ai/docker-git", - "version": "1.3.13", + "version": "1.3.14", "description": "docker-git Bun and Gridland CLI plus browser frontend", "main": "dist/src/docker-git/main.js", "bin": { diff --git a/packages/app/src/docker-git/api-client-create.ts b/packages/app/src/docker-git/api-client-create.ts index 76231dd9..4ed30cce 100644 --- a/packages/app/src/docker-git/api-client-create.ts +++ b/packages/app/src/docker-git/api-client-create.ts @@ -41,6 +41,7 @@ export const buildCreateProjectRequest = ( dockerNetworkMode: config.dockerNetworkMode, dockerSharedNetworkName: config.dockerSharedNetworkName, enableMcpPlaywright: config.enableMcpPlaywright, + enableMcpAndroid: config.enableMcpAndroid ?? false, outDir: command.outDir, gitTokenLabel: config.gitTokenLabel, skipGithubAuth: config.skipGithubAuth, diff --git a/packages/app/src/docker-git/cli/parser-apply.ts b/packages/app/src/docker-git/cli/parser-apply.ts index 3e6ac8b5..7af24f79 100644 --- a/packages/app/src/docker-git/cli/parser-apply.ts +++ b/packages/app/src/docker-git/cli/parser-apply.ts @@ -40,6 +40,7 @@ export const parseApply = ( playwrightCpuLimit, playwrightRamLimit, gpu, - enableMcpPlaywright: raw.enableMcpPlaywright + enableMcpPlaywright: raw.enableMcpPlaywright, + enableMcpAndroid: raw.enableMcpAndroid } }) diff --git a/packages/app/src/docker-git/cli/parser-mcp-android.ts b/packages/app/src/docker-git/cli/parser-mcp-android.ts new file mode 100644 index 00000000..9c24f94d --- /dev/null +++ b/packages/app/src/docker-git/cli/parser-mcp-android.ts @@ -0,0 +1,24 @@ +import { Either } from "effect" + +import { type McpAndroidUpCommand, type ParseError } from "../frontend-lib/core/domain.js" + +import { parseProjectDirWithOptions } from "./parser-shared.js" + +// CHANGE: parse "mcp-android" command for existing docker-git projects +// WHY: allow enabling Android MCP in an already created container/project dir +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +// FORMAT THEOREM: forall argv: parseMcpAndroid(argv) = cmd -> deterministic(cmd) +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: projectDir is never empty +// COMPLEXITY: O(n) where n = |argv| +export const parseMcpAndroid = ( + args: ReadonlyArray +): Either.Either => + Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({ + _tag: "McpAndroidUp", + projectDir, + runUp: raw.up ?? true + })) diff --git a/packages/app/src/docker-git/cli/parser-options.ts b/packages/app/src/docker-git/cli/parser-options.ts index 915e7c09..f1fc8177 100644 --- a/packages/app/src/docker-git/cli/parser-options.ts +++ b/packages/app/src/docker-git/cli/parser-options.ts @@ -110,6 +110,8 @@ const booleanFlagUpdaters: Readonly RawOptio "--force-env": (raw) => ({ ...raw, forceEnv: true }), "--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }), "--no-mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: false }), + "--mcp-android": (raw) => ({ ...raw, enableMcpAndroid: true }), + "--no-mcp-android": (raw) => ({ ...raw, enableMcpAndroid: false }), "--wipe": (raw) => ({ ...raw, wipe: true }), "--no-wipe": (raw) => ({ ...raw, wipe: false }), "--web": (raw) => ({ ...raw, authWeb: true }), diff --git a/packages/app/src/docker-git/cli/parser.ts b/packages/app/src/docker-git/cli/parser.ts index b3fb68a5..e01abde2 100644 --- a/packages/app/src/docker-git/cli/parser.ts +++ b/packages/app/src/docker-git/cli/parser.ts @@ -7,6 +7,7 @@ import { parseAttach } from "./parser-attach.js" import { parseAuth } from "./parser-auth.js" import { parseClone } from "./parser-clone.js" import { buildCreateCommand } from "./parser-create.js" +import { parseMcpAndroid } from "./parser-mcp-android.js" import { parseMcpPlaywright } from "./parser-mcp-playwright.js" import { parseOpen } from "./parser-open.js" import { parseRawOptions } from "./parser-options.js" @@ -93,6 +94,7 @@ export const parseArgs = (args: ReadonlyArray): Either.Either parseSessions(rest)), Match.when("scrap", () => parseScrap(rest)), Match.when("mcp-playwright", () => parseMcpPlaywright(rest)), + Match.when("mcp-android", () => parseMcpAndroid(rest)), Match.when("help", () => Either.right(helpCommand)), Match.when("ps", () => Either.right(statusCommand)), Match.when("status", () => Either.right(statusCommand)), diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index 8de32045..4d7e0477 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -7,6 +7,7 @@ docker-git clone [options] docker-git open [] [options] docker-git apply [] [options] docker-git mcp-playwright [] [options] +docker-git mcp-android [] [options] docker-git attach [] [options] docker-git panes [] [options] docker-git scrap [] [options] @@ -27,6 +28,7 @@ Commands: open Open an existing docker-git project by selector, URL, or path apply Apply docker-git config to an existing project/container (current dir by default) mcp-playwright Enable Playwright MCP + nested Chromium browser for an existing project dir + mcp-android Enable Android MCP (android-connection) + nested Android emulator for an existing project dir attach, tmux Attach to an existing docker-git project workspace with tmux panes, terms List tmux panes for a docker-git project scrap Export/import project scrap (session snapshot + rebuildable deps) @@ -78,6 +80,7 @@ Options: --up | --no-up Run docker compose up after init (default: --up) --ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh) --mcp-playwright | --no-mcp-playwright Enable Rust browser MCP + noVNC/CDP session (default: --no-mcp-playwright) + --mcp-android | --no-mcp-android Enable Android MCP (android-connection) + nested Android emulator sidecar (default: --no-mcp-android) --auto[=claude|codex|gemini|grok] Auto-execute an agent; without value picks by auth, random if multiple are available -d, --daemon browser: run the browser frontend server in the background after build --active apply-all: apply only to currently running containers (skip stopped ones) diff --git a/packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts b/packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts index 152732f1..a5839cf5 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-builders-template.ts @@ -20,6 +20,7 @@ export type BuildTemplateConfigInput = { readonly geminiAuthLabel: string | undefined readonly grokAuthLabel: string | undefined readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean readonly agentMode: AgentMode | undefined readonly agentAuto: boolean /** @@ -96,6 +97,7 @@ export const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateComm dockerNetworkMode: input.dockerNetworkMode, dockerSharedNetworkName: input.dockerSharedNetworkName, enableMcpPlaywright: input.enableMcpPlaywright, + enableMcpAndroid: input.enableMcpAndroid, bunVersion: defaultTemplateConfig.bunVersion, agentMode: input.agentMode, agentAuto: input.agentAuto, diff --git a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts index 5715ce52..421633f9 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-builders.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-builders.ts @@ -201,6 +201,7 @@ type CreateBehavior = { readonly force: boolean readonly forceEnv: boolean readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean } const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ @@ -209,7 +210,8 @@ const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ skipGithubAuth: raw.skipGithubAuth ?? false, force: raw.force ?? false, forceEnv: raw.forceEnv ?? false, - enableMcpPlaywright: raw.enableMcpPlaywright ?? false + enableMcpPlaywright: raw.enableMcpPlaywright ?? false, + enableMcpAndroid: raw.enableMcpAndroid ?? false }) type TokenLabelConfig = { @@ -277,6 +279,7 @@ export const buildCreateCommand = ( ...tokenLabels, skipGithubAuth: behavior.skipGithubAuth, enableMcpPlaywright: behavior.enableMcpPlaywright, + enableMcpAndroid: behavior.enableMcpAndroid, agentMode, agentAuto: isAgentAuto, clonedOnHostname: raw.clonedOnHostname diff --git a/packages/app/src/docker-git/frontend-lib/core/command-options.ts b/packages/app/src/docker-git/frontend-lib/core/command-options.ts index 8dbbf70f..7c52b036 100644 --- a/packages/app/src/docker-git/frontend-lib/core/command-options.ts +++ b/packages/app/src/docker-git/frontend-lib/core/command-options.ts @@ -34,6 +34,7 @@ export interface RawOptions { readonly dockerNetworkMode?: string readonly dockerSharedNetworkName?: string readonly enableMcpPlaywright?: boolean + readonly enableMcpAndroid?: boolean readonly archivePath?: string readonly scrapMode?: string readonly wipe?: boolean diff --git a/packages/app/src/docker-git/frontend-lib/core/domain.ts b/packages/app/src/docker-git/frontend-lib/core/domain.ts index 525e1f28..5a77da7b 100644 --- a/packages/app/src/docker-git/frontend-lib/core/domain.ts +++ b/packages/app/src/docker-git/frontend-lib/core/domain.ts @@ -114,6 +114,7 @@ export interface TemplateConfig { readonly dockerNetworkMode: DockerNetworkMode readonly dockerSharedNetworkName: string readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid?: boolean | undefined readonly bunVersion: string readonly agentMode?: AgentMode | undefined readonly agentAuto?: boolean | undefined @@ -204,6 +205,12 @@ export interface McpPlaywrightUpCommand { readonly runUp: boolean } +export interface McpAndroidUpCommand { + readonly _tag: "McpAndroidUp" + readonly projectDir: string + readonly runUp: boolean +} + export interface ApplyCommand { readonly _tag: "Apply" readonly projectDir: string @@ -219,6 +226,7 @@ export interface ApplyCommand { readonly playwrightRamLimit?: string | undefined readonly gpu?: GpuMode | undefined readonly enableMcpPlaywright?: boolean | undefined + readonly enableMcpAndroid?: boolean | undefined } // CHANGE: add apply-all command to apply docker-git config to every known project; support --active flag @@ -262,6 +270,7 @@ export type Command = | SessionsCommand | ScrapCommand | McpPlaywrightUpCommand + | McpAndroidUpCommand | ApplyCommand | ApplyAllCommand | HelpCommand diff --git a/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts b/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts index b3bc52c1..c7b2f071 100644 --- a/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts +++ b/packages/app/src/docker-git/frontend-lib/core/template-defaults.ts @@ -30,6 +30,7 @@ type DefaultTemplateConfig = Pick< | "dockerNetworkMode" | "dockerSharedNetworkName" | "enableMcpPlaywright" + | "enableMcpAndroid" | "bunVersion" > @@ -75,6 +76,7 @@ export const defaultTemplateConfig = { dockerNetworkMode: defaultDockerNetworkMode, dockerSharedNetworkName: defaultDockerSharedNetworkName, enableMcpPlaywright: false, + enableMcpAndroid: false, bunVersion: "1.3.11" } satisfies DefaultTemplateConfig /* jscpd:ignore-end */ diff --git a/packages/app/src/docker-git/menu-create-command-parse.ts b/packages/app/src/docker-git/menu-create-command-parse.ts index c056bdd5..d3f49977 100644 --- a/packages/app/src/docker-git/menu-create-command-parse.ts +++ b/packages/app/src/docker-git/menu-create-command-parse.ts @@ -113,6 +113,7 @@ const unsupportedCreatePrefixes = new Set([ "gists", "help", "kill-all", + "mcp-android", "mcp-playwright", "menu", "open", @@ -164,6 +165,9 @@ const runUpCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partia const playwrightCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => raw.enableMcpPlaywright === undefined ? {} : { enableMcpPlaywright: command.config.enableMcpPlaywright } +const androidCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.enableMcpAndroid === undefined ? {} : { enableMcpAndroid: command.config.enableMcpAndroid ?? false } + const forceCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => raw.force === undefined ? {} : { force: command.force } @@ -183,6 +187,7 @@ const createInputsFromCommand = ( ...gpuCreateInput(raw, command), ...runUpCreateInput(raw, command), ...playwrightCreateInput(raw, command), + ...androidCreateInput(raw, command), ...forceCreateInput(raw, command), ...forceEnvCreateInput(raw, command) }) diff --git a/packages/app/src/docker-git/menu-create-draft.ts b/packages/app/src/docker-git/menu-create-draft.ts index 478b4065..351caf1c 100644 --- a/packages/app/src/docker-git/menu-create-draft.ts +++ b/packages/app/src/docker-git/menu-create-draft.ts @@ -12,6 +12,7 @@ export const createProjectDraftFromInputs = ( readonly gpu: GpuMode readonly up: boolean readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean readonly force: boolean readonly forceEnv: boolean } => ({ @@ -23,6 +24,7 @@ export const createProjectDraftFromInputs = ( gpu: input.gpu, up: input.runUp, enableMcpPlaywright: input.enableMcpPlaywright, + enableMcpAndroid: input.enableMcpAndroid, force: input.force, forceEnv: input.forceEnv }) diff --git a/packages/app/src/docker-git/menu-create-inputs.ts b/packages/app/src/docker-git/menu-create-inputs.ts index bbeb9dfc..8d92e6c1 100644 --- a/packages/app/src/docker-git/menu-create-inputs.ts +++ b/packages/app/src/docker-git/menu-create-inputs.ts @@ -247,6 +247,7 @@ export const resolveCreateInputs = ( gpu: values.gpu ?? defaultTemplateConfig.gpu, runUp: values.runUp !== false, enableMcpPlaywright: values.enableMcpPlaywright === true, + enableMcpAndroid: values.enableMcpAndroid === true, force: values.force === true, forceEnv: values.forceEnv === true } diff --git a/packages/app/src/docker-git/menu-create-labels.ts b/packages/app/src/docker-git/menu-create-labels.ts index 35459ffc..27a62399 100644 --- a/packages/app/src/docker-git/menu-create-labels.ts +++ b/packages/app/src/docker-git/menu-create-labels.ts @@ -23,6 +23,10 @@ export const renderCreateStepLabel = (step: CreateStep, defaults: CreateInputs): renderExplicitBooleanChoice(defaults.enableMcpPlaywright) }]` ), + Match.when( + "mcpAndroid", + () => `Enable Android MCP (nested Android emulator)? [${renderExplicitBooleanChoice(defaults.enableMcpAndroid)}]` + ), Match.when( "force", () => `Force recreate (overwrite files + wipe volumes)? [${renderExplicitBooleanChoice(defaults.force)}]` @@ -57,6 +61,12 @@ export const renderCreateStepLabelWithBufferPreview = ( ? renderCreateStepLabel(step, defaults) : `Enable Playwright MCP (nested Chromium browser)? [${renderExplicitBooleanChoice(enableMcpPlaywright)}]` }), + Match.when("mcpAndroid", () => { + const enableMcpAndroid = parseExplicitBooleanChoice(buffer) + return enableMcpAndroid === null + ? renderCreateStepLabel(step, defaults) + : `Enable Android MCP (nested Android emulator)? [${renderExplicitBooleanChoice(enableMcpAndroid)}]` + }), Match.when("force", () => { const force = parseExplicitBooleanChoice(buffer) return force === null diff --git a/packages/app/src/docker-git/menu-create-navigation.ts b/packages/app/src/docker-git/menu-create-navigation.ts index 89462acb..f0d7b364 100644 --- a/packages/app/src/docker-git/menu-create-navigation.ts +++ b/packages/app/src/docker-git/menu-create-navigation.ts @@ -224,6 +224,7 @@ export const resolveCreateSettingsChoiceBuffer = ( Match.when("gpu", () => gpuChoiceBuffer(direction)), Match.when("runUp", () => booleanChoiceBuffer(direction)), Match.when("mcpPlaywright", () => booleanChoiceBuffer(direction)), + Match.when("mcpAndroid", () => booleanChoiceBuffer(direction)), Match.when("force", () => booleanChoiceBuffer(direction)), Match.exhaustive ) diff --git a/packages/app/src/docker-git/menu-create-step-apply.ts b/packages/app/src/docker-git/menu-create-step-apply.ts index 66c5dad4..b48baf63 100644 --- a/packages/app/src/docker-git/menu-create-step-apply.ts +++ b/packages/app/src/docker-git/menu-create-step-apply.ts @@ -41,12 +41,13 @@ const applyGpuStep = ( const applyBooleanStep = ( input: ApplyCreateStepInput, - key: "runUp" | "enableMcpPlaywright" | "force" + key: "runUp" | "enableMcpPlaywright" | "enableMcpAndroid" | "force" ): Either.Either>, ParseError> => { const isValue = isYesDefault(input.buffer, input.currentDefaults[key]) return Match.value(key).pipe( Match.when("runUp", () => Either.right({ runUp: isValue })), Match.when("enableMcpPlaywright", () => Either.right({ enableMcpPlaywright: isValue })), + Match.when("enableMcpAndroid", () => Either.right({ enableMcpAndroid: isValue })), Match.when("force", () => Either.right({ force: isValue })), Match.exhaustive ) @@ -64,6 +65,7 @@ const applyCreateStep = ( Match.when("gpu", () => applyGpuStep(input)), Match.when("runUp", () => applyBooleanStep(input, "runUp")), Match.when("mcpPlaywright", () => applyBooleanStep(input, "enableMcpPlaywright")), + Match.when("mcpAndroid", () => applyBooleanStep(input, "enableMcpAndroid")), Match.when("force", () => applyBooleanStep(input, "force")), Match.exhaustive ) diff --git a/packages/app/src/docker-git/menu-create-steps.ts b/packages/app/src/docker-git/menu-create-steps.ts index 73083ce2..63f3d801 100644 --- a/packages/app/src/docker-git/menu-create-steps.ts +++ b/packages/app/src/docker-git/menu-create-steps.ts @@ -19,6 +19,7 @@ const isCreateStepSatisfied = ( Match.when("gpu", () => hasOwn(values, "gpu")), Match.when("runUp", () => hasOwn(values, "runUp")), Match.when("mcpPlaywright", () => hasOwn(values, "enableMcpPlaywright")), + Match.when("mcpAndroid", () => hasOwn(values, "enableMcpAndroid")), Match.when("force", () => hasOwn(values, "force")), Match.exhaustive ) diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index 17c7eff1..84bc9731 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -55,6 +55,7 @@ export type CreateInputs = { readonly gpu: GpuMode readonly runUp: boolean readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean readonly force: boolean readonly forceEnv: boolean } @@ -68,6 +69,7 @@ export type CreateStep = | "gpu" | "runUp" | "mcpPlaywright" + | "mcpAndroid" | "force" export const orderedCreateSteps: ReadonlyArray = [ @@ -77,6 +79,7 @@ export const orderedCreateSteps: ReadonlyArray = [ "gpu", "runUp", "mcpPlaywright", + "mcpAndroid", "force" ] diff --git a/packages/app/src/docker-git/program-unsupported.ts b/packages/app/src/docker-git/program-unsupported.ts index bb33082f..1585f428 100644 --- a/packages/app/src/docker-git/program-unsupported.ts +++ b/packages/app/src/docker-git/program-unsupported.ts @@ -4,6 +4,7 @@ export type UnsupportedOperationalCommandTag = | "ScrapExport" | "ScrapImport" | "McpPlaywrightUp" + | "McpAndroidUp" | "Apply" | "AuthClaudeStatus" | "AuthClaudeLogout" @@ -22,6 +23,10 @@ export const unsupportedOperationalCommands: Record< command: "mcp-playwright", message: "Playwright browser management is disabled in API-only host mode." }, + McpAndroidUp: { + command: "mcp-android", + message: "Android emulator management is disabled in API-only host mode." + }, Apply: { command: "Apply", message: "Command Apply is not available in API-only host mode." diff --git a/packages/app/src/web/api-project-create-body.ts b/packages/app/src/web/api-project-create-body.ts index db0f7019..1a86e48e 100644 --- a/packages/app/src/web/api-project-create-body.ts +++ b/packages/app/src/web/api-project-create-body.ts @@ -44,6 +44,7 @@ export type OptionalProjectResourceFieldsBody = Readonly<{ export type BaseCreateProjectBody = Readonly<{ readonly cpuLimit: CreateProjectDraft["cpuLimit"] readonly enableMcpPlaywright: CreateProjectDraft["enableMcpPlaywright"] + readonly enableMcpAndroid: CreateProjectDraft["enableMcpAndroid"] readonly force: CreateProjectDraft["force"] readonly forceEnv: CreateProjectDraft["forceEnv"] readonly gpu: CreateProjectDraft["gpu"] @@ -94,6 +95,7 @@ export const optionalProjectResourceFields = ( export const baseCreateProjectBody = (draft: CreateProjectDraft): BaseCreateProjectBody => ({ cpuLimit: draft.cpuLimit, enableMcpPlaywright: draft.enableMcpPlaywright, + enableMcpAndroid: draft.enableMcpAndroid, force: draft.force, forceEnv: draft.forceEnv, gpu: draft.gpu, diff --git a/packages/app/src/web/api-types.ts b/packages/app/src/web/api-types.ts index 0d564832..31769ef5 100644 --- a/packages/app/src/web/api-types.ts +++ b/packages/app/src/web/api-types.ts @@ -69,6 +69,7 @@ export type CreateProjectDraft = { readonly ramLimit: string readonly gpu: "none" | "all" readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean readonly force: boolean readonly forceEnv: boolean readonly up: boolean diff --git a/packages/app/tests/docker-git/actions-project-create.test.ts b/packages/app/tests/docker-git/actions-project-create.test.ts index 39ac709f..278d5abf 100644 --- a/packages/app/tests/docker-git/actions-project-create.test.ts +++ b/packages/app/tests/docker-git/actions-project-create.test.ts @@ -26,6 +26,7 @@ vi.mock("../../src/web/project-events.js", () => ({ const inputConfig = { cpuLimit: "75%", enableMcpPlaywright: true, + enableMcpAndroid: false, force: false, forceEnv: false, gpu: "none", diff --git a/packages/app/tests/docker-git/api-create-project.test.ts b/packages/app/tests/docker-git/api-create-project.test.ts index 21b944c4..1d2d358e 100644 --- a/packages/app/tests/docker-git/api-create-project.test.ts +++ b/packages/app/tests/docker-git/api-create-project.test.ts @@ -8,6 +8,7 @@ import type { CreateProjectRequestDraft } from "../../src/web/api-project-create const projectDraft = { cpuLimit: "80%", enableMcpPlaywright: true, + enableMcpAndroid: false, force: false, forceEnv: false, gpu: "none", @@ -61,6 +62,7 @@ describe("api create project request body", () => { async: true, cpuLimit: "80%", enableMcpPlaywright: true, + enableMcpAndroid: false, force: false, forceEnv: false, gpu: "none", diff --git a/packages/app/tests/docker-git/app-ready-create-fixture.ts b/packages/app/tests/docker-git/app-ready-create-fixture.ts index 85fbdf5b..80e9d1bf 100644 --- a/packages/app/tests/docker-git/app-ready-create-fixture.ts +++ b/packages/app/tests/docker-git/app-ready-create-fixture.ts @@ -40,6 +40,7 @@ export const validGithubStatus: GithubAuthStatus = { const defaultQuickCreateInputs = { cpuLimit: "", enableMcpPlaywright: false, + enableMcpAndroid: false, force: false, forceEnv: false, gpu: "none", diff --git a/packages/app/tests/docker-git/app-ready-create-settings.test.ts b/packages/app/tests/docker-git/app-ready-create-settings.test.ts index ae2c4b45..47ae7ea6 100644 --- a/packages/app/tests/docker-git/app-ready-create-settings.test.ts +++ b/packages/app/tests/docker-git/app-ready-create-settings.test.ts @@ -124,7 +124,7 @@ describe("app-ready-create settings", () => { const enteredView = requireCreateViewValue(enterResult.setCreateViewSpy.mock.calls[0]?.[0]) expect(enterResult.handled).toBe(true) - expect(enteredView.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(enteredView.step).toBe(resolveCreateDisplaySteps().indexOf("mcpAndroid")) expect(enteredView.values.enableMcpPlaywright).toBe(true) expect(enteredView.buffer).toBe("") }) @@ -135,7 +135,7 @@ describe("app-ready-create settings", () => { const nextView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) expect(isHandled).toBe(true) - expect(nextView.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(nextView.step).toBe(resolveCreateDisplaySteps().indexOf("mcpAndroid")) expect(nextView.values.enableMcpPlaywright).toBeUndefined() expect(nextView.buffer).toBe("") }) diff --git a/packages/app/tests/docker-git/app-ready-create.test.ts b/packages/app/tests/docker-git/app-ready-create.test.ts index 3de81b62..4e655099 100644 --- a/packages/app/tests/docker-git/app-ready-create.test.ts +++ b/packages/app/tests/docker-git/app-ready-create.test.ts @@ -55,7 +55,8 @@ describe("app-ready-create", () => { "ramLimit", "gpu", "runUp", - "mcpPlaywright" + "mcpPlaywright", + "mcpAndroid" ]) expect(context.setMessage).toHaveBeenCalledWith(null) }) diff --git a/packages/app/tests/docker-git/menu-create-display-settings.test.ts b/packages/app/tests/docker-git/menu-create-display-settings.test.ts index 1aebb66f..f4445a70 100644 --- a/packages/app/tests/docker-git/menu-create-display-settings.test.ts +++ b/packages/app/tests/docker-git/menu-create-display-settings.test.ts @@ -34,10 +34,11 @@ describe("menu-create-shared display settings", () => { const cwd = process.cwd() const isDisplaySettingStep = (step: CreateStep): step is Exclude => step !== "repoUrl" const displaySettingSteps = resolveCreateDisplaySteps().filter(isDisplaySettingStep) - const discreteDisplaySteps: ReadonlyArray<"gpu" | "runUp" | "mcpPlaywright" | "force"> = [ + const discreteDisplaySteps: ReadonlyArray<"gpu" | "runUp" | "mcpPlaywright" | "mcpAndroid" | "force"> = [ "gpu", "runUp", "mcpPlaywright", + "mcpAndroid", "force" ] const validBufferByStep: Record = { @@ -45,6 +46,7 @@ describe("menu-create-shared display settings", () => { force: "y", gpu: "all", mcpPlaywright: "y", + mcpAndroid: "y", outDir: "/home/dev/.docker-git/org/repo-preview", ramLimit: "8g", repoRef: "main", @@ -69,6 +71,7 @@ describe("menu-create-shared display settings", () => { "gpu", "runUp", "mcpPlaywright", + "mcpAndroid", "force" ]) }) @@ -90,7 +93,7 @@ describe("menu-create-shared display settings", () => { { ...mcpPlaywrightView, buffer: "y" } ))) - expect(next.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(next.step).toBe(resolveCreateDisplaySteps().indexOf("mcpAndroid")) expect(next.buffer).toBe("") expect(next.values.enableMcpPlaywright).toBe(true) }) @@ -123,7 +126,7 @@ describe("menu-create-shared display settings", () => { const down = moveCreateDisplaySettingsStep(applied, "down") const up = moveCreateDisplaySettingsStep(applied, "up") - expect(down?.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(down?.step).toBe(resolveCreateDisplaySteps().indexOf("mcpAndroid")) expect(up?.step).toBe(resolveCreateDisplaySteps().indexOf("runUp")) expect(down?.buffer).toBe("") expect(up?.values.enableMcpPlaywright).toBe(true) diff --git a/packages/app/tests/docker-git/menu-create-shared-properties.test.ts b/packages/app/tests/docker-git/menu-create-shared-properties.test.ts index 7fe2b3ae..fea34597 100644 --- a/packages/app/tests/docker-git/menu-create-shared-properties.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared-properties.test.ts @@ -14,6 +14,7 @@ const settingsStepArbitrary: fc.Arbitrary = fc.constantFrom( "gpu", "runUp", "mcpPlaywright", + "mcpAndroid", "force" ) @@ -22,12 +23,13 @@ const stepBufferByStep: Readonly> = { force: "y", gpu: "all", mcpPlaywright: "n", + mcpAndroid: "n", ramLimit: "4g", runUp: "y" } const satisfiedCreateSettingsArbitrary = fc.uniqueArray(settingsStepArbitrary, { - maxLength: 6 + maxLength: 7 }) /** @@ -60,6 +62,7 @@ const createSatisfiedStepValue = (step: CreateSettingStep): Partial => ({ gpu: "none" })), Match.when("runUp", (): Partial => ({ runUp: true })), Match.when("mcpPlaywright", (): Partial => ({ enableMcpPlaywright: false })), + Match.when("mcpAndroid", (): Partial => ({ enableMcpAndroid: false })), Match.when("force", (): Partial => ({ force: false })), Match.exhaustive ) diff --git a/packages/app/tests/docker-git/menu-create-shared.test.ts b/packages/app/tests/docker-git/menu-create-shared.test.ts index 63177803..a41a475d 100644 --- a/packages/app/tests/docker-git/menu-create-shared.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared.test.ts @@ -58,6 +58,7 @@ describe("menu-create-shared", () => { "gpu", "runUp", "mcpPlaywright", + "mcpAndroid", "force" ]) }) @@ -87,7 +88,8 @@ describe("menu-create-shared", () => { "repoUrl", "cpuLimit", "ramLimit", - "gpu" + "gpu", + "mcpAndroid" ]) }) @@ -110,7 +112,7 @@ describe("menu-create-shared", () => { const inputs = expectCreateCompleteInputs(advanceCreateFlow( cwd, createInitialFlowView( - `${featureCreateRepoUrl} --cpu 25% --ram 4g --gpu all --no-up --mcp-playwright --force` + `${featureCreateRepoUrl} --cpu 25% --ram 4g --gpu all --no-up --mcp-playwright --mcp-android --force` ) )) @@ -120,6 +122,7 @@ describe("menu-create-shared", () => { expect(inputs.gpu).toBe("all") expect(inputs.runUp).toBe(false) expect(inputs.enableMcpPlaywright).toBe(true) + expect(inputs.enableMcpAndroid).toBe(true) expect(inputs.force).toBe(true) }) @@ -304,6 +307,7 @@ describe("menu-create-shared", () => { const generatedSettingsArbitrary = fc.record({ cpuLimit: fc.constantFrom("", "25%", "50%"), enableMcpPlaywright: fc.boolean(), + enableMcpAndroid: fc.boolean(), force: fc.boolean(), gpu: fc.constantFrom("none", "all"), ramLimit: fc.constantFrom("", "2g", "4g"), diff --git a/packages/container/src/core/domain.ts b/packages/container/src/core/domain.ts index 41d9fda3..7466e2fe 100644 --- a/packages/container/src/core/domain.ts +++ b/packages/container/src/core/domain.ts @@ -74,6 +74,7 @@ export interface TemplateConfig { readonly dockerNetworkMode: DockerNetworkMode readonly dockerSharedNetworkName: string readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid?: boolean | undefined readonly bunVersion: string readonly agentMode?: AgentMode | undefined readonly agentAuto?: boolean | undefined diff --git a/packages/container/src/core/template-defaults.ts b/packages/container/src/core/template-defaults.ts index b17cbd85..98682b27 100644 --- a/packages/container/src/core/template-defaults.ts +++ b/packages/container/src/core/template-defaults.ts @@ -29,6 +29,7 @@ type DefaultTemplateConfig = Pick< | "dockerNetworkMode" | "dockerSharedNetworkName" | "enableMcpPlaywright" + | "enableMcpAndroid" | "bunVersion" > @@ -74,5 +75,6 @@ export const defaultTemplateConfig = { dockerNetworkMode: defaultDockerNetworkMode, dockerSharedNetworkName: defaultDockerSharedNetworkName, enableMcpPlaywright: false, + enableMcpAndroid: false, bunVersion: "1.3.11" } satisfies DefaultTemplateConfig diff --git a/packages/container/src/core/templates-entrypoint.ts b/packages/container/src/core/templates-entrypoint.ts index 96fdd684..7d74625f 100644 --- a/packages/container/src/core/templates-entrypoint.ts +++ b/packages/container/src/core/templates-entrypoint.ts @@ -17,6 +17,7 @@ import { renderEntrypointCodexHome, renderEntrypointCodexResumeHint, renderEntrypointCodexSharedAuth, + renderEntrypointMcpAndroid, renderEntrypointMcpPlaywright, renderEntrypointProjectCodexSkillsSync } from "./templates-entrypoint/codex.js" @@ -60,6 +61,7 @@ export const renderEntrypoint = (config: TemplateConfig): string => renderEntrypointDockerSocket(config), renderEntrypointRustBrowserConnection(), renderEntrypointMcpPlaywright(config), + renderEntrypointMcpAndroid(config), renderEntrypointGitConfig(config), renderEntrypointClaudeConfig(config), renderEntrypointGeminiConfig(config), diff --git a/packages/container/src/core/templates-entrypoint/base.ts b/packages/container/src/core/templates-entrypoint/base.ts index 0ad47619..5c6be2a1 100644 --- a/packages/container/src/core/templates-entrypoint/base.ts +++ b/packages/container/src/core/templates-entrypoint/base.ts @@ -41,6 +41,7 @@ AGENT_MODE="\${AGENT_MODE:-}" AGENT_AUTO="\${AGENT_AUTO:-}" MCP_PLAYWRIGHT_ENABLE="\${MCP_PLAYWRIGHT_ENABLE:-${config.enableMcpPlaywright ? "1" : "0"}}" MCP_PLAYWRIGHT_ISOLATED="\${MCP_PLAYWRIGHT_ISOLATED:-0}" +MCP_ANDROID_ENABLE="\${MCP_ANDROID_ENABLE:-${config.enableMcpAndroid === true ? "1" : "0"}}" SSH_ENV_PATH="/home/${config.sshUser}/.ssh/environment" diff --git a/packages/container/src/core/templates-entrypoint/claude.ts b/packages/container/src/core/templates-entrypoint/claude.ts index e86f0167..599fd24a 100644 --- a/packages/container/src/core/templates-entrypoint/claude.ts +++ b/packages/container/src/core/templates-entrypoint/claude.ts @@ -251,6 +251,65 @@ NODE docker_git_sync_claude_playwright_mcp chown 1000:1000 "$CLAUDE_SETTINGS_FILE" 2>/dev/null || true` +const renderClaudeMcpAndroidConfig = (): string => + String.raw`# Claude Code: keep Android MCP config in sync with container settings +CLAUDE_SETTINGS_FILE="${"$"}{CLAUDE_HOME_JSON:-$CLAUDE_CONFIG_DIR/.claude.json}" +docker_git_sync_claude_android_mcp() { + local android_project="${"$"}{DOCKER_GIT_ANDROID_PROJECT:-${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}}" + [[ -n "$android_project" ]] || android_project="$(hostname)" + local android_network="${"$"}{DOCKER_GIT_ANDROID_NETWORK:-container:$android_project}" + local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" + [[ -n "$adb_endpoint" ]] || adb_endpoint="$android_project-android:5555" + CLAUDE_SETTINGS_FILE="$CLAUDE_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_PROJECT="$android_project" DOCKER_GIT_ANDROID_NETWORK="$android_network" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" TARGET_DIR="${"$"}{TARGET_DIR:-}" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") + +const settingsPath = process.env.CLAUDE_SETTINGS_FILE +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +const enableAndroid = process.env.MCP_ANDROID_ENABLE === "1" +const androidProject = process.env.DOCKER_GIT_ANDROID_PROJECT || "" +const androidNetwork = process.env.DOCKER_GIT_ANDROID_NETWORK || (androidProject.length > 0 ? "container:" + androidProject : "") +const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" +const workspace = process.env.TARGET_DIR || "" +const androidArgs = androidProject.length > 0 && adbEndpoint.length > 0 ? ["--project", androidProject, "--network", androidNetwork, "--endpoint", adbEndpoint] : [] +if (workspace.length > 0) androidArgs.push("--workspace", workspace) +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) + +let settings = {} +try { + const raw = fs.readFileSync(settingsPath, "utf8") + const parsed = JSON.parse(raw) + settings = isRecord(parsed) ? parsed : {} +} catch { settings = {} } + +const currentServers = isRecord(settings.mcpServers) ? settings.mcpServers : {} +const nextServers = { ...currentServers } +if (enableAndroid) { + nextServers.android = { type: "stdio", command: "android-connection", args: androidArgs, env: {} } +} else { + delete nextServers.android +} + +const nextSettings = { ...settings } +if (Object.keys(nextServers).length > 0) { + nextSettings.mcpServers = nextServers +} else { + delete nextSettings.mcpServers +} + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) { + process.exit(0) +} + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_claude_android_mcp +chown 1000:1000 "$CLAUDE_SETTINGS_FILE" 2>/dev/null || true` + const renderClaudeProfileSetup = (): string => String.raw`CLAUDE_PROFILE="/etc/profile.d/claude-config.sh" printf "export CLAUDE_AUTH_LABEL=%q\n" "$CLAUDE_AUTH_LABEL" > "$CLAUDE_PROFILE" @@ -277,6 +336,7 @@ export const renderEntrypointClaudeConfig = (config: TemplateConfig): string => renderClaudeCliInstall(), renderClaudePermissionSettingsConfig(), renderClaudeMcpPlaywrightConfig(), + renderClaudeMcpAndroidConfig(), renderClaudeGlobalPromptSetup(config), renderClaudeWrapperSetup(), renderClaudeProfileSetup() diff --git a/packages/container/src/core/templates-entrypoint/codex.ts b/packages/container/src/core/templates-entrypoint/codex.ts index a43bd715..564ade11 100644 --- a/packages/container/src/core/templates-entrypoint/codex.ts +++ b/packages/container/src/core/templates-entrypoint/codex.ts @@ -132,6 +132,94 @@ export const renderEntrypointMcpPlaywright = (config: TemplateConfig): string => .replaceAll("__CODEX_HOME__", () => config.codexHome) .replaceAll("__SERVICE_NAME__", () => config.serviceName) +// CHANGE: configure the first-party Android MCP server for Codex, mirroring the Playwright block +// WHY: issue-436 asks to wire mcp-android "the same way" Playwright MCP works; Codex reads its +// MCP servers from config.toml, so we add/remove an [mcp_servers.android] entry to match the build +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +const entrypointMcpAndroidTemplate = String.raw`# Optional: configure Android MCP for Codex (Rust android-connection) +CODEX_CONFIG_FILE="__CODEX_HOME__/config.toml" +DOCKER_GIT_ANDROID_PROJECT="${"$"}{DOCKER_GIT_ANDROID_PROJECT:-${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}}" +if [[ -z "$DOCKER_GIT_ANDROID_PROJECT" ]]; then + DOCKER_GIT_ANDROID_PROJECT="$(hostname)" +fi +DOCKER_GIT_ANDROID_NETWORK="${"$"}{DOCKER_GIT_ANDROID_NETWORK:-container:$DOCKER_GIT_ANDROID_PROJECT}" +DOCKER_GIT_ANDROID_ADB_ENDPOINT="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" +if [[ -z "$DOCKER_GIT_ANDROID_ADB_ENDPOINT" ]]; then + DOCKER_GIT_ANDROID_ADB_ENDPOINT="$DOCKER_GIT_ANDROID_PROJECT-android:5555" +fi + +# Keep config.toml consistent with the container build. +# If Android MCP is disabled for this container, remove the block so Codex +# doesn't try (and fail) to spawn android-connection. +if [[ "$MCP_ANDROID_ENABLE" != "1" ]]; then + if [[ -f "$CODEX_CONFIG_FILE" ]] && grep -q "^\[mcp_servers\.android" "$CODEX_CONFIG_FILE" 2>/dev/null; then + awk ' + BEGIN { skip=0 } + /^# docker-git: Android MCP/ { next } + /^\[mcp_servers[.]android([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } + skip==0 { print } + ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" + mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" + fi +else + if [[ ! -f "$CODEX_CONFIG_FILE" ]]; then + mkdir -p "$(dirname "$CODEX_CONFIG_FILE")" || true + cat <<'EOF' > "$CODEX_CONFIG_FILE" +# docker-git codex config +model = "gpt-5.5" +model_reasoning_effort = "xhigh" +plan_mode_reasoning_effort = "xhigh" +personality = "pragmatic" + +approval_policy = "never" +sandbox_mode = "danger-full-access" +web_search = "live" + +[features] +shell_snapshot = true +multi_agent = true +apps = true +shell_tool = true + +[profiles.longcontx] +model = "gpt-5.5" +model_context_window = 1050000 +model_auto_compact_token_limit = 945000 +model_reasoning_effort = "xhigh" +plan_mode_reasoning_effort = "xhigh" +EOF + chown 1000:1000 "$CODEX_CONFIG_FILE" || true + fi + + # Replace the docker-git Android MCP block to allow upgrades via --force without manual edits. + if grep -q "^\[mcp_servers\.android" "$CODEX_CONFIG_FILE" 2>/dev/null; then + awk ' + BEGIN { skip=0 } + /^# docker-git: Android MCP/ { next } + /^\[mcp_servers[.]android([.]|\])/ { skip=1; next } + skip==1 && /^\[/ { skip=0 } + skip==0 { print } + ' "$CODEX_CONFIG_FILE" > "$CODEX_CONFIG_FILE.tmp" + mv "$CODEX_CONFIG_FILE.tmp" "$CODEX_CONFIG_FILE" + fi + + cat <> "$CODEX_CONFIG_FILE" + +# docker-git: Android MCP (rust android-connection) +[mcp_servers.android] +command = "android-connection" +args = ["--project", "$DOCKER_GIT_ANDROID_PROJECT", "--network", "$DOCKER_GIT_ANDROID_NETWORK", "--endpoint", "$DOCKER_GIT_ANDROID_ADB_ENDPOINT", "--workspace", "$TARGET_DIR"] +EOF +fi` + +export const renderEntrypointMcpAndroid = (config: TemplateConfig): string => + entrypointMcpAndroidTemplate + .replaceAll("__CODEX_HOME__", () => config.codexHome) + .replaceAll("__SERVICE_NAME__", () => config.serviceName) + const entrypointProjectCodexSkillsSyncTemplate = String .raw`# Mirror project-owned Codex skill trees into CODEX_HOME without overwriting global skills. docker_git_sync_project_codex_skills() { diff --git a/packages/container/src/core/templates-entrypoint/gemini-android-mcp.ts b/packages/container/src/core/templates-entrypoint/gemini-android-mcp.ts new file mode 100644 index 00000000..f1a92e2c --- /dev/null +++ b/packages/container/src/core/templates-entrypoint/gemini-android-mcp.ts @@ -0,0 +1,57 @@ +// CHANGE: extract the Gemini Android MCP config sync into its own module +// WHY: issue-436 wires mcp-android "the same way" Playwright MCP works; keeping the +// render helper in a dedicated file keeps gemini.ts under the max-lines lint budget +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +export const renderGeminiMcpAndroidConfig = (): string => + String.raw`# Gemini CLI: keep Android MCP config in sync with container settings +docker_git_sync_gemini_android_mcp() { + local android_project="${"$"}{DOCKER_GIT_ANDROID_PROJECT:-${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}}" + if [[ -z "$android_project" ]]; then + android_project="$(hostname)" + fi + local android_network="${"$"}{DOCKER_GIT_ANDROID_NETWORK:-container:$android_project}" + local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" + if [[ -z "$adb_endpoint" ]]; then + adb_endpoint="$android_project-android:5555" + fi + GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_PROJECT="$android_project" DOCKER_GIT_ANDROID_NETWORK="$android_network" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" TARGET_DIR="${"$"}{TARGET_DIR:-}" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") +const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + if (isRecord(parsed)) settings = parsed +} catch {} + +const androidProject = process.env.DOCKER_GIT_ANDROID_PROJECT || "" +const androidNetwork = process.env.DOCKER_GIT_ANDROID_NETWORK || (androidProject.length > 0 ? "container:" + androidProject : "") +const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" +const workspace = process.env.TARGET_DIR || "" +const androidArgs = androidProject.length > 0 && adbEndpoint.length > 0 + ? ["--project", androidProject, "--network", androidNetwork, "--endpoint", adbEndpoint] + : [] +if (workspace.length > 0) androidArgs.push("--workspace", workspace) +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_ANDROID_ENABLE === "1") { + nextServers.android = { command: "android-connection", args: androidArgs, trust: true } +} else { + delete nextServers.android +} + +const nextSettings = { ...settings } +Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_gemini_android_mcp` diff --git a/packages/container/src/core/templates-entrypoint/gemini-playwright-mcp.ts b/packages/container/src/core/templates-entrypoint/gemini-playwright-mcp.ts new file mode 100644 index 00000000..c4dea291 --- /dev/null +++ b/packages/container/src/core/templates-entrypoint/gemini-playwright-mcp.ts @@ -0,0 +1,42 @@ +// CHANGE: house the Gemini Playwright MCP config sync in its own module +// WHY: gemini.ts also wires the Android MCP sidecar (issue-436); moving both optional MCP +// config helpers into sibling modules keeps gemini.ts under the max-lines lint budget +// REF: issue-436 +export const renderGeminiMcpPlaywrightConfig = (): string => + String.raw`# Gemini CLI: keep Playwright MCP config in sync with container settings +docker_git_sync_gemini_playwright_mcp() { + local browser_project="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"; [[ -n "$browser_project" ]] || browser_project="$(hostname)" + local browser_network="container:$browser_project" + GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") +const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + if (isRecord(parsed)) settings = parsed +} catch {} + +const browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" +const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", process.env.DOCKER_GIT_BROWSER_NETWORK || "container:" + browserProject] : [] +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { + nextServers.playwright = { command: "browser-connection", args: browserArgs, trust: true } +} else { + delete nextServers.playwright +} + +const nextSettings = { ...settings } +Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_gemini_playwright_mcp` diff --git a/packages/container/src/core/templates-entrypoint/gemini.ts b/packages/container/src/core/templates-entrypoint/gemini.ts index e8d35570..48599300 100644 --- a/packages/container/src/core/templates-entrypoint/gemini.ts +++ b/packages/container/src/core/templates-entrypoint/gemini.ts @@ -1,4 +1,6 @@ import type { TemplateConfig } from "../domain.js" +import { renderGeminiMcpAndroidConfig } from "./gemini-android-mcp.js" +import { renderGeminiMcpPlaywrightConfig } from "./gemini-playwright-mcp.js" // CHANGE: add Gemini CLI entrypoint configuration // WHY: enable Gemini CLI in Docker with automated auth, trust settings and MCP @@ -199,44 +201,6 @@ if [[ -d /etc/sudoers.d ]]; then chmod 0440 /etc/sudoers.d/gemini-agent fi` -const renderGeminiMcpPlaywrightConfig = (): string => - String.raw`# Gemini CLI: keep Playwright MCP config in sync with container settings -docker_git_sync_gemini_playwright_mcp() { - local browser_project="${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}"; [[ -n "$browser_project" ]] || browser_project="$(hostname)" - local browser_network="container:$browser_project" - GEMINI_CONFIG_SETTINGS_FILE="$GEMINI_CONFIG_SETTINGS_FILE" MCP_PLAYWRIGHT_ENABLE="${"$"}{MCP_PLAYWRIGHT_ENABLE:-0}" DOCKER_GIT_BROWSER_PROJECT="$browser_project" DOCKER_GIT_BROWSER_NETWORK="$browser_network" node - <<'NODE' -const fs = require("node:fs") -const path = require("node:path") -const settingsPath = process.env.GEMINI_CONFIG_SETTINGS_FILE -const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) -if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) - -let settings = {} -try { - const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) - if (isRecord(parsed)) settings = parsed -} catch {} - -const browserProject = process.env.DOCKER_GIT_BROWSER_PROJECT || "" -const browserArgs = browserProject.length > 0 ? ["--project", browserProject, "--network", process.env.DOCKER_GIT_BROWSER_NETWORK || "container:" + browserProject] : [] -const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } -if (process.env.MCP_PLAYWRIGHT_ENABLE === "1") { - nextServers.playwright = { command: "browser-connection", args: browserArgs, trust: true } -} else { - delete nextServers.playwright -} - -const nextSettings = { ...settings } -Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers - -if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) - -fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) -fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) -NODE -} - -docker_git_sync_gemini_playwright_mcp` const renderGeminiProfileSetup = (config: TemplateConfig): string => String.raw`GEMINI_PROFILE="/etc/profile.d/gemini-config.sh" @@ -336,6 +300,7 @@ export const renderEntrypointGeminiConfig = (config: TemplateConfig): string => renderGeminiAuthConfig(config), renderGeminiPermissionSettingsConfig(config), renderGeminiMcpPlaywrightConfig(), + renderGeminiMcpAndroidConfig(), renderGeminiSudoConfig(config), renderGeminiProfileSetup(config), entrypointGeminiNoticeTemplate diff --git a/packages/container/src/core/templates-entrypoint/grok-android-mcp.ts b/packages/container/src/core/templates-entrypoint/grok-android-mcp.ts new file mode 100644 index 00000000..84c529a5 --- /dev/null +++ b/packages/container/src/core/templates-entrypoint/grok-android-mcp.ts @@ -0,0 +1,57 @@ +// CHANGE: extract the Grok Android MCP config sync into its own module +// WHY: issue-436 wires mcp-android "the same way" Playwright MCP works; keeping the +// render helper in a dedicated file keeps grok.ts under the max-lines lint budget +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +export const renderGrokMcpAndroidConfig = (): string => + String.raw`# Grok CLI: keep Android MCP config in sync with container settings +docker_git_sync_grok_android_mcp() { + local android_project="${"$"}{DOCKER_GIT_ANDROID_PROJECT:-${"$"}{DOCKER_GIT_PROJECT_CONTAINER_NAME:-}}" + if [[ -z "$android_project" ]]; then + android_project="$(hostname)" + fi + local android_network="${"$"}{DOCKER_GIT_ANDROID_NETWORK:-container:$android_project}" + local adb_endpoint="${"$"}{DOCKER_GIT_ANDROID_ADB_ENDPOINT:-}" + if [[ -z "$adb_endpoint" ]]; then + adb_endpoint="$android_project-android:5555" + fi + GROK_CONFIG_SETTINGS_FILE="$GROK_CONFIG_SETTINGS_FILE" MCP_ANDROID_ENABLE="${"$"}{MCP_ANDROID_ENABLE:-0}" DOCKER_GIT_ANDROID_PROJECT="$android_project" DOCKER_GIT_ANDROID_NETWORK="$android_network" DOCKER_GIT_ANDROID_ADB_ENDPOINT="$adb_endpoint" TARGET_DIR="${"$"}{TARGET_DIR:-}" node - <<'NODE' +const fs = require("node:fs") +const path = require("node:path") +const settingsPath = process.env.GROK_CONFIG_SETTINGS_FILE +const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) +if (typeof settingsPath !== "string" || settingsPath.length === 0) process.exit(0) + +let settings = {} +try { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) + if (isRecord(parsed)) settings = parsed +} catch {} + +const androidProject = process.env.DOCKER_GIT_ANDROID_PROJECT || "" +const androidNetwork = process.env.DOCKER_GIT_ANDROID_NETWORK || (androidProject.length > 0 ? "container:" + androidProject : "") +const adbEndpoint = process.env.DOCKER_GIT_ANDROID_ADB_ENDPOINT || "" +const workspace = process.env.TARGET_DIR || "" +const androidArgs = androidProject.length > 0 && adbEndpoint.length > 0 + ? ["--project", androidProject, "--network", androidNetwork, "--endpoint", adbEndpoint] + : [] +if (workspace.length > 0) androidArgs.push("--workspace", workspace) +const nextServers = { ...(isRecord(settings.mcpServers) ? settings.mcpServers : {}) } +if (process.env.MCP_ANDROID_ENABLE === "1") { + nextServers.android = { command: "android-connection", args: androidArgs, trust: true } +} else { + delete nextServers.android +} + +const nextSettings = { ...settings } +Object.keys(nextServers).length > 0 ? nextSettings.mcpServers = nextServers : delete nextSettings.mcpServers + +if (JSON.stringify(settings) === JSON.stringify(nextSettings)) process.exit(0) + +fs.mkdirSync(path.dirname(settingsPath), { recursive: true }) +fs.writeFileSync(settingsPath, JSON.stringify(nextSettings, null, 2) + "\n", { mode: 0o600 }) +NODE +} + +docker_git_sync_grok_android_mcp` diff --git a/packages/container/src/core/templates-entrypoint/grok.ts b/packages/container/src/core/templates-entrypoint/grok.ts index 86e80bcf..3e5296fd 100644 --- a/packages/container/src/core/templates-entrypoint/grok.ts +++ b/packages/container/src/core/templates-entrypoint/grok.ts @@ -1,4 +1,5 @@ import type { TemplateConfig } from "../domain.js" +import { renderGrokMcpAndroidConfig } from "./grok-android-mcp.js" // CHANGE: add Grok CLI entrypoint configuration // WHY: issue #304 requires Grok auth, Playwright MCP and unrestricted agent permissions @@ -344,6 +345,7 @@ export const renderEntrypointGrokConfig = (config: TemplateConfig): string => renderGrokAuthConfig(config), renderGrokPermissionSettingsConfig(config), renderGrokMcpPlaywrightConfig(), + renderGrokMcpAndroidConfig(), renderGrokSudoConfig(config), renderGrokProfileSetup(config), renderEntrypointGrokNotice(config) diff --git a/packages/container/src/core/templates/docker-compose-android.ts b/packages/container/src/core/templates/docker-compose-android.ts new file mode 100644 index 00000000..f0e03d56 --- /dev/null +++ b/packages/container/src/core/templates/docker-compose-android.ts @@ -0,0 +1,65 @@ +import { resolveComposeNetworkName, type TemplateConfig } from "../domain.js" + +// CHANGE: render an Android emulator sidecar service for the first-party Android MCP wiring +// WHY: issue-436 asks to connect mcp-android "the same way" Playwright MCP works, exposing +// a docker-android emulator as a service reachable over ADB for android-connection. +// Extracted into its own module so docker-compose.ts stays under the max-lines budget. +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: https://github.com/budtmo/docker-android +// PURITY: CORE +// INVARIANT: only emitted when config.enableMcpAndroid === true; image/ports are env-overridable +export type AndroidFragments = { + readonly maybeAndroidEnv: string + readonly maybeAndroidService: string + readonly maybeAndroidVolume: string +} + +const defaultAndroidEmulatorImage = "budtmo/docker-android:emulator_14.0" + +export const buildAndroidFragments = ( + config: TemplateConfig, + resourceLimitsBlock: string +): AndroidFragments => { + if (config.enableMcpAndroid !== true) { + return { + maybeAndroidEnv: "", + maybeAndroidService: "", + maybeAndroidVolume: "" + } + } + + const androidContainerName = `${config.containerName}-android` + const androidVolumeName = `${config.volumeName}-android` + const androidImageRef = `\${DOCKER_GIT_ANDROID_EMULATOR_IMAGE:-${defaultAndroidEmulatorImage}}` + const networkName = resolveComposeNetworkName(config) + + const maybeAndroidEnv = + ` MCP_ANDROID_ENABLE: "1"\n DOCKER_GIT_ANDROID_PROJECT: "${config.containerName}"\n DOCKER_GIT_ANDROID_CONTAINER_NAME: "${androidContainerName}"\n DOCKER_GIT_ANDROID_ADB_ENDPOINT: "\${DOCKER_GIT_ANDROID_ADB_ENDPOINT:-${androidContainerName}:5555}"\n DOCKER_GIT_ANDROID_EMULATOR_IMAGE: "${androidImageRef}"\n` + + const maybeAndroidService = ` + ${config.serviceName}-android: + image: "${androidImageRef}" + container_name: ${androidContainerName} + privileged: true + environment: + EMULATOR_DEVICE: "\${DOCKER_GIT_ANDROID_DEVICE:-Samsung Galaxy S10}" + WEB_VNC: "\${DOCKER_GIT_ANDROID_WEB_VNC:-true}" + EMULATOR_HEADLESS: "\${DOCKER_GIT_ANDROID_HEADLESS:-true}" + devices: + - /dev/kvm + ports: + - "\${DOCKER_GIT_ANDROID_ADB_BIND_HOST:-127.0.0.1}:\${DOCKER_GIT_ANDROID_ADB_PORT:-5555}:5555" + - "\${DOCKER_GIT_ANDROID_NOVNC_BIND_HOST:-127.0.0.1}:\${DOCKER_GIT_ANDROID_NOVNC_PORT:-6080}:6080" +${resourceLimitsBlock} volumes: + - ${androidVolumeName}:/root/.android + networks: + - ${networkName} +` + + return { + maybeAndroidEnv, + maybeAndroidService, + maybeAndroidVolume: ` ${androidVolumeName}:` + } +} diff --git a/packages/container/src/core/templates/docker-compose-playwright.ts b/packages/container/src/core/templates/docker-compose-playwright.ts new file mode 100644 index 00000000..abe6bc78 --- /dev/null +++ b/packages/container/src/core/templates/docker-compose-playwright.ts @@ -0,0 +1,61 @@ +import type { TemplateConfig } from "../domain.js" +import type { ResolvedComposeResourceLimits } from "../resource-limits.js" +import type { DockerComposeRenderOptions } from "./docker-compose.js" + +// CHANGE: house the Playwright MCP sidecar fragment builder in its own module +// WHY: docker-compose.ts hosts the optional Android sidecar (issue-436) too; moving both +// optional-sidecar builders into sibling modules keeps docker-compose.ts under the +// max-lines lint budget while grouping the parallel Playwright/Android wiring together +// REF: issue-436 +export type PlaywrightFragments = { + readonly maybeDependsOn: string + readonly maybeDockerSocketMount: string + readonly maybePlaywrightEnv: string + readonly maybeBrowserVolume: string +} + +const renderBrowserLimitEnv = ( + key: string, + value: number | string | undefined +): string => ` ${key}: "\${${key}:-${value ?? ""}}"\n` + +const renderOptionalDockerSocketMount = ( + shouldEnableLocalDockerSocket: boolean +): string => + shouldEnableLocalDockerSocket + ? ` - /var/run/docker.sock:/var/run/docker.sock` + : "" + +export const buildPlaywrightFragments = ( + config: TemplateConfig, + resourceLimits: ResolvedComposeResourceLimits | undefined, + options: DockerComposeRenderOptions +): PlaywrightFragments => { + if (!config.enableMcpPlaywright) { + return { + maybeDependsOn: "", + maybeDockerSocketMount: "", + maybePlaywrightEnv: "", + maybeBrowserVolume: "" + } + } + + const browserContainerName = `${config.containerName}-browser` + const browserVolumeName = `${config.volumeName}-browser` + const browserImageName = `${browserContainerName}:docker-git-browser` + + return { + maybeDependsOn: "", + maybeDockerSocketMount: renderOptionalDockerSocketMount( + options.enableLocalDockerSocket + ), + maybePlaywrightEnv: + ` MCP_PLAYWRIGHT_ENABLE: "1"\n DOCKER_GIT_PROJECT_CONTAINER_NAME: "${config.containerName}"\n DOCKER_GIT_BROWSER_CONTAINER_NAME: "${browserContainerName}"\n DOCKER_GIT_BROWSER_IMAGE_NAME: "${browserImageName}"\n DOCKER_GIT_BROWSER_VOLUME_NAME: "${browserVolumeName}"\n${ + renderBrowserLimitEnv( + "DOCKER_GIT_BROWSER_CPU_LIMIT", + resourceLimits?.cpuLimit + ) + }${renderBrowserLimitEnv("DOCKER_GIT_BROWSER_RAM_LIMIT", resourceLimits?.ramLimit)}`, + maybeBrowserVolume: ` ${browserVolumeName}:` + } +} diff --git a/packages/container/src/core/templates/docker-compose.ts b/packages/container/src/core/templates/docker-compose.ts index a0b25547..7fa1f340 100644 --- a/packages/container/src/core/templates/docker-compose.ts +++ b/packages/container/src/core/templates/docker-compose.ts @@ -7,6 +7,8 @@ import { type TemplateConfig } from "../domain.js" import type { ResolvedComposeResourceLimits } from "../resource-limits.js" +import { buildAndroidFragments } from "./docker-compose-android.js" +import { buildPlaywrightFragments } from "./docker-compose-playwright.js" type ComposeFragments = { readonly networkMode: TemplateConfig["dockerNetworkMode"] @@ -23,18 +25,13 @@ type ComposeFragments = { readonly maybeDockerSocketMount: string readonly maybePlaywrightEnv: string readonly maybeBrowserVolume: string + readonly maybeAndroidEnv: string + readonly maybeAndroidService: string + readonly maybeAndroidVolume: string readonly maybeBootstrapMounts: string readonly forkRepoUrl: string } -type PlaywrightFragments = Pick< - ComposeFragments, - | "maybeDependsOn" - | "maybeDockerSocketMount" - | "maybePlaywrightEnv" - | "maybeBrowserVolume" -> - type AuthEnvFragments = Pick< ComposeFragments, | "maybeGitTokenLabelEnv" @@ -112,13 +109,6 @@ const renderBootstrapMounts = (): string => ` - ${bootstrapVolumeKey}:/opt/ const renderYamlSingleQuoted = (value: string): string => `'${value.replaceAll("'", "''")}'` -const renderOptionalDockerSocketMount = ( - shouldEnableLocalDockerSocket: boolean -): string => - shouldEnableLocalDockerSocket - ? ` - /var/run/docker.sock:/var/run/docker.sock` - : "" - const renderEnvFiles = (config: TemplateConfig): string => ` env_file:\n - ${renderYamlSingleQuoted(config.envGlobalPath)}\n - ${ renderYamlSingleQuoted( @@ -151,45 +141,6 @@ const buildAgentEnvFragments = (config: TemplateConfig): AgentEnvFragments => ({ maybeAgentAutoEnv: renderAgentAutoEnv(config.agentAuto) }) -const renderBrowserLimitEnv = ( - key: string, - value: number | string | undefined -): string => ` ${key}: "\${${key}:-${value ?? ""}}"\n` - -const buildPlaywrightFragments = ( - config: TemplateConfig, - resourceLimits: ResolvedComposeResourceLimits | undefined, - options: DockerComposeRenderOptions -): PlaywrightFragments => { - if (!config.enableMcpPlaywright) { - return { - maybeDependsOn: "", - maybeDockerSocketMount: "", - maybePlaywrightEnv: "", - maybeBrowserVolume: "" - } - } - - const browserContainerName = `${config.containerName}-browser` - const browserVolumeName = `${config.volumeName}-browser` - const browserImageName = `${browserContainerName}:docker-git-browser` - - return { - maybeDependsOn: "", - maybeDockerSocketMount: renderOptionalDockerSocketMount( - options.enableLocalDockerSocket - ), - maybePlaywrightEnv: - ` MCP_PLAYWRIGHT_ENABLE: "1"\n DOCKER_GIT_PROJECT_CONTAINER_NAME: "${config.containerName}"\n DOCKER_GIT_BROWSER_CONTAINER_NAME: "${browserContainerName}"\n DOCKER_GIT_BROWSER_IMAGE_NAME: "${browserImageName}"\n DOCKER_GIT_BROWSER_VOLUME_NAME: "${browserVolumeName}"\n${ - renderBrowserLimitEnv( - "DOCKER_GIT_BROWSER_CPU_LIMIT", - resourceLimits?.cpuLimit - ) - }${renderBrowserLimitEnv("DOCKER_GIT_BROWSER_RAM_LIMIT", resourceLimits?.ramLimit)}`, - maybeBrowserVolume: ` ${browserVolumeName}:` - } -} - const isResolvedComposeResourceLimits = ( value: ResolvedComposeResourceLimits | ComposeResourceLimits ): value is ResolvedComposeResourceLimits => "cpuLimit" in value && "ramLimit" in value && "swapLimit" in value @@ -225,6 +176,10 @@ const buildComposeFragments = ( resourceLimits.playwright, options ) + const android = buildAndroidFragments( + config, + renderResourceLimits(resourceLimits.playwright) + ) return { networkMode, @@ -236,6 +191,9 @@ const buildComposeFragments = ( maybeDockerSocketMount: playwright.maybeDockerSocketMount, maybePlaywrightEnv: playwright.maybePlaywrightEnv, maybeBrowserVolume: playwright.maybeBrowserVolume, + maybeAndroidEnv: android.maybeAndroidEnv, + maybeAndroidService: android.maybeAndroidService, + maybeAndroidVolume: android.maybeAndroidVolume, maybeBootstrapMounts: renderBootstrapMounts(), forkRepoUrl } @@ -269,7 +227,7 @@ ${fragments.maybeGrokAuthLabelEnv}${fragments.maybeAgentModeEnv}${fragments.mayb DOCKER_GIT_PROJECT_DOCKER_HOST: "\${DOCKER_GIT_PROJECT_DOCKER_HOST:-}" TARGET_DIR: "${config.targetDir}" CODEX_HOME: "${config.codexHome}" -${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} # bootstrap auth/env arrives through docker_git_bootstrap +${fragments.maybePlaywrightEnv}${fragments.maybeAndroidEnv}${fragments.maybeDependsOn} # bootstrap auth/env arrives through docker_git_bootstrap ports: - "\${DOCKER_GIT_PROJECT_SSH_BIND_HOST:-127.0.0.1}:${config.sshPort}:22" ${renderResourceLimits(resourceLimits.main)} volumes: @@ -286,7 +244,7 @@ ${fragments.maybeDockerSocketMount} - 1.1.1.1 networks: - ${fragments.networkName} -` +${fragments.maybeAndroidService}` const renderComposeNetworks = ( networkMode: TemplateConfig["dockerNetworkMode"], @@ -302,7 +260,8 @@ const renderComposeNetworks = ( const renderComposeVolumes = ( config: TemplateConfig, - maybeBrowserVolume: string + maybeBrowserVolume: string, + maybeAndroidVolume: string ): string => [ "volumes:", @@ -315,7 +274,8 @@ const renderComposeVolumes = ( ` ${sharedCodexVolumeKey}:`, " external: true", ` name: ${dockerGitSharedCodexVolumeName}`, - maybeBrowserVolume + maybeBrowserVolume, + maybeAndroidVolume ] .filter((entry) => entry.length > 0) .join("\n") @@ -331,6 +291,10 @@ export const renderDockerCompose = ( `name: ${resolveComposeProjectName(config)}`, renderComposeServices(config, fragments, limits), renderComposeNetworks(fragments.networkMode, fragments.networkName), - renderComposeVolumes(config, fragments.maybeBrowserVolume) + renderComposeVolumes( + config, + fragments.maybeBrowserVolume, + fragments.maybeAndroidVolume + ) ].join("\n\n") } diff --git a/packages/container/src/core/templates/dockerfile.ts b/packages/container/src/core/templates/dockerfile.ts index 14b756f2..03f18da9 100644 --- a/packages/container/src/core/templates/dockerfile.ts +++ b/packages/container/src/core/templates/dockerfile.ts @@ -91,6 +91,25 @@ const renderDockerfilePlaywrightRuntime = (config: TemplateConfig): string => # Old browser-vnc + cdp-guard duplication removed per #347` : "" +// CHANGE: install the first-party Android MCP module when Android MCP is enabled +// WHY: issue-436 requires a separately proven module instead of an unpinned runtime npx server +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +// PURITY: CORE (pure template renderer) +const renderDockerfileAndroidRuntime = (config: TemplateConfig): string => + config.enableMcpAndroid === true + ? `# Android MCP runtime: ADB client + first-party Rust android-connection module. +COPY .docker-git-tools/android-connection /opt/docker-git/tools/android-connection +RUN apt-get update \ + && apt-get install -y --no-install-recommends android-tools-adb \ + && rm -rf /var/lib/apt/lists/* \ + && adb --version \ + && cargo install --path /opt/docker-git/tools/android-connection --locked --bins --root /usr/local \ + && /usr/local/bin/docker-git-android-connection --version \ + && /usr/local/bin/android-connection --version` + : "" + /** * Renders /etc/profile.d/bun.sh with a runtime-relative PATH extension. * @@ -241,6 +260,7 @@ export const renderDockerfile = (config: TemplateConfig): string => renderDockerfileNode(), renderDockerfileBun(config), renderDockerfilePlaywrightRuntime(config), + renderDockerfileAndroidRuntime(config), renderDockerfileRtk(), renderDockerfileOpenCode(), renderDockerfileGitleaks(), diff --git a/packages/container/tests/core/templates.test.ts b/packages/container/tests/core/templates.test.ts index b3feec3b..dbd419a3 100644 --- a/packages/container/tests/core/templates.test.ts +++ b/packages/container/tests/core/templates.test.ts @@ -1130,6 +1130,83 @@ describe("renderDockerCompose", () => { expect(compose).toContain('DOCKER_GIT_BROWSER_RAM_LIMIT: "${DOCKER_GIT_BROWSER_RAM_LIMIT:-2g}"') }) + it("renders the Android emulator sidecar service when Android MCP is enabled", () => { + const compose = renderDockerCompose( + makeTemplateConfig({ + enableMcpAndroid: true, + gpu: "none", + }), + { + cpuLimit: 1.5, + ramLimit: "2g", + swapLimit: "4g" + } + ) + + expect(compose).toContain('MCP_ANDROID_ENABLE: "1"') + expect(compose).toContain('DOCKER_GIT_ANDROID_PROJECT: "dg-test"') + expect(compose).toContain('DOCKER_GIT_ANDROID_CONTAINER_NAME: "dg-test-android"') + expect(compose).toContain( + 'DOCKER_GIT_ANDROID_ADB_ENDPOINT: "${DOCKER_GIT_ANDROID_ADB_ENDPOINT:-dg-test-android:5555}"' + ) + expect(compose).toContain( + 'DOCKER_GIT_ANDROID_EMULATOR_IMAGE: "${DOCKER_GIT_ANDROID_EMULATOR_IMAGE:-budtmo/docker-android:emulator_14.0}"' + ) + // emulator runs as a real compose service (unlike the externally-managed browser container) + expect(compose).toContain("\n dg-test-android:\n") + expect(compose).toContain(" - /dev/kvm") + expect(compose).toContain( + '- "${DOCKER_GIT_ANDROID_ADB_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_ANDROID_ADB_PORT:-5555}:5555"' + ) + expect(compose).toContain(" dg-test-home-android:") + // the sidecar reuses the Playwright sidecar resource budget + expect(compose).toContain(" cpus: 1.5\n") + }) + + it("omits all Android emulator wiring when Android MCP is disabled", () => { + const compose = renderDockerCompose(makeTemplateConfig({ enableMcpAndroid: false })) + + expect(compose).not.toContain("MCP_ANDROID_ENABLE") + expect(compose).not.toContain("\n dg-test-android:\n") + expect(compose).not.toContain("dg-test-home-android:") + expect(compose).not.toContain("/dev/kvm") + }) + + it("installs the first-party Android connection module only when Android MCP is enabled", () => { + const enabled = renderDockerfile(makeTemplateConfig({ enableMcpAndroid: true })) + const disabled = renderDockerfile(makeTemplateConfig({ enableMcpAndroid: false })) + + expect(enabled).toContain("COPY .docker-git-tools/android-connection") + expect(enabled).toContain("android-tools-adb") + expect(enabled).toContain("cargo install --path /opt/docker-git/tools/android-connection") + expect(enabled).toContain("/usr/local/bin/docker-git-android-connection --version") + expect(enabled).toContain("/usr/local/bin/android-connection --version") + expect(disabled).not.toContain(".docker-git-tools/android-connection") + expect(disabled).not.toContain("android-tools-adb") + }) + + it("configures the Android MCP server for every agent and defaults the enable flag", () => { + const entrypoint = renderEntrypoint(makeTemplateConfig({ enableMcpAndroid: true })) + + expect(entrypoint).toContain('MCP_ANDROID_ENABLE="${MCP_ANDROID_ENABLE:-1}"') + // Codex (TOML) + expect(entrypoint).toContain("[mcp_servers.android]") + expect(entrypoint).toContain('command = "android-connection"') + expect(entrypoint).toContain('"--endpoint", "$DOCKER_GIT_ANDROID_ADB_ENDPOINT"') + expect(entrypoint).not.toContain("@mobilenext/mobile-mcp") + // Claude / Gemini / Grok (JSON sync helpers) + expect(entrypoint).toContain("docker_git_sync_claude_android_mcp") + expect(entrypoint).toContain("docker_git_sync_gemini_android_mcp") + expect(entrypoint).toContain("docker_git_sync_grok_android_mcp") + expect(entrypoint).toContain('command: "android-connection"') + }) + + it("defaults MCP_ANDROID_ENABLE to 0 when Android MCP is disabled", () => { + const entrypoint = renderEntrypoint(makeTemplateConfig({ enableMcpAndroid: false })) + + expect(entrypoint).toContain('MCP_ANDROID_ENABLE="${MCP_ANDROID_ENABLE:-0}"') + }) + it("renders explicit anonymous GitHub clone override for public repos", () => { const compose = renderDockerCompose( makeTemplateConfig({ diff --git a/packages/docker-git-session-sync/CHANGELOG.md b/packages/docker-git-session-sync/CHANGELOG.md index 424da984..0b49da7b 100644 --- a/packages/docker-git-session-sync/CHANGELOG.md +++ b/packages/docker-git-session-sync/CHANGELOG.md @@ -1,5 +1,11 @@ # @prover-coder-ai/docker-git-session-sync +## 1.0.70 + +### Patch Changes + +- chore: automated version bump + ## 1.0.69 ### Patch Changes diff --git a/packages/docker-git-session-sync/package.json b/packages/docker-git-session-sync/package.json index 44aebcee..f8caac85 100644 --- a/packages/docker-git-session-sync/package.json +++ b/packages/docker-git-session-sync/package.json @@ -1,6 +1,6 @@ { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.69", + "version": "1.0.70", "description": "Standalone docker-git AI agent session synchronization tool", "main": "dist/docker-git-session-sync.js", "bin": { diff --git a/packages/lib/src/core/command-builders-template.ts b/packages/lib/src/core/command-builders-template.ts index d0bcce72..9e7b1f8d 100644 --- a/packages/lib/src/core/command-builders-template.ts +++ b/packages/lib/src/core/command-builders-template.ts @@ -19,6 +19,7 @@ export type BuildTemplateConfigInput = { readonly geminiAuthLabel: string | undefined readonly grokAuthLabel: string | undefined readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean readonly agentMode: AgentMode | undefined readonly agentAuto: boolean /** @@ -95,6 +96,7 @@ export const buildTemplateConfig = (input: BuildTemplateConfigInput): CreateComm dockerNetworkMode: input.dockerNetworkMode, dockerSharedNetworkName: input.dockerSharedNetworkName, enableMcpPlaywright: input.enableMcpPlaywright, + enableMcpAndroid: input.enableMcpAndroid, bunVersion: defaultTemplateConfig.bunVersion, agentMode: input.agentMode, agentAuto: input.agentAuto, diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 99c5a058..07515123 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -200,6 +200,7 @@ type CreateBehavior = { readonly force: boolean readonly forceEnv: boolean readonly enableMcpPlaywright: boolean + readonly enableMcpAndroid: boolean } const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ @@ -208,7 +209,8 @@ const resolveCreateBehavior = (raw: RawOptions): CreateBehavior => ({ skipGithubAuth: raw.skipGithubAuth ?? false, force: raw.force ?? false, forceEnv: raw.forceEnv ?? false, - enableMcpPlaywright: raw.enableMcpPlaywright ?? false + enableMcpPlaywright: raw.enableMcpPlaywright ?? false, + enableMcpAndroid: raw.enableMcpAndroid ?? false }) type TokenLabelConfig = { @@ -276,6 +278,7 @@ export const buildCreateCommand = ( ...tokenLabels, skipGithubAuth: behavior.skipGithubAuth, enableMcpPlaywright: behavior.enableMcpPlaywright, + enableMcpAndroid: behavior.enableMcpAndroid, agentMode, agentAuto: isAgentAuto, clonedOnHostname: raw.clonedOnHostname diff --git a/packages/lib/src/core/command-options.ts b/packages/lib/src/core/command-options.ts index 036e43df..742a5623 100644 --- a/packages/lib/src/core/command-options.ts +++ b/packages/lib/src/core/command-options.ts @@ -33,6 +33,7 @@ export interface RawOptions { readonly dockerNetworkMode?: string readonly dockerSharedNetworkName?: string readonly enableMcpPlaywright?: boolean + readonly enableMcpAndroid?: boolean readonly archivePath?: string readonly scrapMode?: string readonly wipe?: boolean diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 0a76dd79..de9b9bcf 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -144,6 +144,12 @@ export interface McpPlaywrightUpCommand { readonly runUp: boolean } +export interface McpAndroidUpCommand { + readonly _tag: "McpAndroidUp" + readonly projectDir: string + readonly runUp: boolean +} + export interface ApplyCommand { readonly _tag: "Apply" readonly projectDir: string @@ -159,6 +165,7 @@ export interface ApplyCommand { readonly playwrightRamLimit?: string | undefined readonly gpu?: GpuMode | undefined readonly enableMcpPlaywright?: boolean | undefined + readonly enableMcpAndroid?: boolean | undefined } // CHANGE: add apply-all command to apply docker-git config to every known project; support --active flag @@ -201,6 +208,7 @@ export type Command = | SessionsCommand | ScrapCommand | McpPlaywrightUpCommand + | McpAndroidUpCommand | ApplyCommand | ApplyAllCommand | HelpCommand diff --git a/packages/lib/src/shell/android-connection-source.ts b/packages/lib/src/shell/android-connection-source.ts new file mode 100644 index 00000000..063f5fca --- /dev/null +++ b/packages/lib/src/shell/android-connection-source.ts @@ -0,0 +1,149 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import { resolveWorkspaceRoot } from "./workspace-root.js" + +const androidConnectionToolRelativePath = ".docker-git-tools/android-connection" + +const ensureParentDir = ( + path: Path.Path, + fs: FileSystem.FileSystem, + filePath: string +) => fs.makeDirectory(path.dirname(filePath), { recursive: true }) + +const resolveFileUrlPath = (fileUrl: string): string => { + const url = new URL(fileUrl) + return url.protocol === "file:" ? decodeURIComponent(url.pathname) : fileUrl +} + +const shouldSkipAndroidConnectionEntry = (entry: string): boolean => entry === "target" || entry === ".git" + +const copyTextFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourcePath: string, + targetPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const contents = yield* _(fs.readFileString(sourcePath)) + yield* _(ensureParentDir(path, fs, targetPath)) + yield* _(fs.writeFileString(targetPath, contents)) + }) + +const copyTextDirectoryEntry = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourcePath: string, + targetPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const info = yield* _(fs.stat(sourcePath)) + if (info.type === "Directory") { + yield* _(copyTextDirectory(fs, path, sourcePath, targetPath)) + return + } + if (info.type === "File") { + yield* _(copyTextFile(fs, path, sourcePath, targetPath)) + } + }) + +const copyTextDirectory = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourcePath: string, + targetPath: string +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(fs.makeDirectory(targetPath, { recursive: true })) + const entries = yield* _(fs.readDirectory(sourcePath)) + for (const entry of entries) { + if (shouldSkipAndroidConnectionEntry(entry)) { + continue + } + yield* _( + copyTextDirectoryEntry( + fs, + path, + path.join(sourcePath, entry), + path.join(targetPath, entry) + ) + ) + } + }) + +const androidConnectionSourceCandidates = ( + path: Path.Path, + workspaceRoot: string +): ReadonlyArray => [ + path.join(workspaceRoot, "crates", "android-connection"), + path.join( + path.dirname(resolveFileUrlPath(import.meta.url)), + "..", + "..", + "..", + "..", + "crates", + "android-connection" + ) +] + +const firstExistingDirectory = ( + fs: FileSystem.FileSystem, + candidates: ReadonlyArray +): Effect.Effect => + Effect.gen(function*(_) { + for (const candidate of candidates) { + const isExists = yield* _(fs.exists(candidate)) + if (!isExists) { + continue + } + const info = yield* _(fs.stat(candidate)) + if (info.type === "Directory") { + return candidate + } + } + return null + }) + +// CHANGE: provision the first-party Android MCP Rust source into the Docker build context +// WHY: the generated Dockerfile installs android-connection with cargo install --path --locked +// QUOTE(ТЗ): "Сперва нужно отдельно реализовать сам модуль и доказать что он работает" +// REF: issue-436 +// SOURCE: n/a +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: enabled Android MCP builds from the same audited crate source as local tests +// COMPLEXITY: O(n) where n = |android_connection_source_files| +export const provisionDockerGitAndroidConnectionSource = ( + fs: FileSystem.FileSystem, + path: Path.Path, + baseDir: string +): Effect.Effect => + Effect.gen(function*(_) { + const workspaceRoot = yield* _(resolveWorkspaceRoot(process.cwd())) + const sourcePath = yield* _( + firstExistingDirectory( + fs, + androidConnectionSourceCandidates(path, workspaceRoot) + ) + ) + if (sourcePath === null) { + yield* _( + Effect.dieMessage( + "android-connection source not found; expected crates/android-connection in the docker-git workspace" + ) + ) + return + } + + yield* _( + copyTextDirectory( + fs, + path, + sourcePath, + path.join(baseDir, androidConnectionToolRelativePath) + ) + ) + }) diff --git a/packages/lib/src/shell/config.ts b/packages/lib/src/shell/config.ts index d165a0a9..b0b45e1e 100644 --- a/packages/lib/src/shell/config.ts +++ b/packages/lib/src/shell/config.ts @@ -85,6 +85,9 @@ const TemplateConfigInputSchema = Schema.Struct({ enableMcpPlaywright: Schema.optionalWith(Schema.Boolean, { default: () => defaultTemplateConfig.enableMcpPlaywright }), + enableMcpAndroid: Schema.optionalWith(Schema.Boolean, { + default: () => defaultTemplateConfig.enableMcpAndroid + }), bunVersion: Schema.optional(Schema.String), pnpmVersion: Schema.optional(Schema.String), clonedOnHostname: Schema.optional(HostnameSchema) diff --git a/packages/lib/src/shell/errors.ts b/packages/lib/src/shell/errors.ts index 5928f0e8..612216c5 100644 --- a/packages/lib/src/shell/errors.ts +++ b/packages/lib/src/shell/errors.ts @@ -39,6 +39,8 @@ export type DockerIdentityConflictKind = | "serviceName" | "volumeName" | "browserVolumeName" + | "androidContainerName" + | "androidVolumeName" | "bootstrapVolumeName" export type DockerIdentityConflict = { diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts index 8036ef28..98c475fd 100644 --- a/packages/lib/src/shell/files.ts +++ b/packages/lib/src/shell/files.ts @@ -11,6 +11,7 @@ import { withDefaultResourceLimitIntent } from "../core/resource-limits.js" import { type FileSpec, planFiles } from "../core/templates.js" +import { provisionDockerGitAndroidConnectionSource } from "./android-connection-source.js" import { resolveDockerEnvValue } from "./docker-auth.js" import { FileExistsError } from "./errors.js" import { resolveBaseDir } from "./paths.js" @@ -214,6 +215,20 @@ const provisionDockerGitSessionSyncTool = ( ) }) +const provisionDockerGitBuildContext = ( + fs: FileSystem.FileSystem, + path: Path.Path, + baseDir: string, + config: TemplateConfig +): Effect.Effect => + Effect.gen(function*(_) { + yield* _(provisionDockerGitScripts(fs, path, baseDir)) + yield* _(provisionDockerGitSessionSyncTool(fs, path, baseDir)) + if (config.enableMcpAndroid === true) { + yield* _(provisionDockerGitAndroidConnectionSource(fs, path, baseDir)) + } + }) + // CHANGE: write generated docker-git files to disk // WHY: isolate all filesystem effects in a thin shell // QUOTE(ТЗ): "создавать докер образы" @@ -267,11 +282,7 @@ export const writeProjectFiles = ( } } - // CHANGE: provision docker-git scripts into project build context - // WHY: Dockerfile COPY scripts/ requires scripts to be in the build context - // REF: issue-176 - yield* _(provisionDockerGitScripts(fs, path, baseDir)) - yield* _(provisionDockerGitSessionSyncTool(fs, path, baseDir)) + yield* _(provisionDockerGitBuildContext(fs, path, baseDir, normalizedConfig)) return created }) diff --git a/packages/lib/src/usecases/actions/create-project-conflicts.ts b/packages/lib/src/usecases/actions/create-project-conflicts.ts index 21365037..1016e3a9 100644 --- a/packages/lib/src/usecases/actions/create-project-conflicts.ts +++ b/packages/lib/src/usecases/actions/create-project-conflicts.ts @@ -16,7 +16,7 @@ type CreateProjectRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor. type DockerIdentityOwner = Pick< TemplateConfig, - "containerName" | "serviceName" | "volumeName" | "enableMcpPlaywright" + "containerName" | "serviceName" | "volumeName" | "enableMcpPlaywright" | "enableMcpAndroid" > type DockerIdentityNamespace = "container" | "composeProject" | "volume" @@ -52,14 +52,30 @@ const resolveBrowserVolumeClaims = ( ? [{ namespace: "volume", kind: "browserVolumeName", name: `${config.volumeName}-browser` }] : [] +const resolveAndroidContainerClaims = ( + config: DockerIdentityOwner +): ReadonlyArray => + config.enableMcpAndroid + ? [{ namespace: "container", kind: "androidContainerName", name: `${config.containerName}-android` }] + : [] + +const resolveAndroidVolumeClaims = ( + config: DockerIdentityOwner +): ReadonlyArray => + config.enableMcpAndroid + ? [{ namespace: "volume", kind: "androidVolumeName", name: `${config.volumeName}-android` }] + : [] + const resolveDockerIdentityClaims = ( config: DockerIdentityOwner ): ReadonlyArray => [ { namespace: "container", kind: "containerName", name: config.containerName }, ...resolveBrowserContainerClaims(config), + ...resolveAndroidContainerClaims(config), { namespace: "composeProject", kind: "serviceName", name: resolveComposeProjectName(config) }, { namespace: "volume", kind: "volumeName", name: config.volumeName }, ...resolveBrowserVolumeClaims(config), + ...resolveAndroidVolumeClaims(config), { namespace: "volume", kind: "bootstrapVolumeName", name: resolveProjectBootstrapVolumeName(config) } ] diff --git a/packages/lib/src/usecases/apply-overrides.ts b/packages/lib/src/usecases/apply-overrides.ts index bf10b913..86194525 100644 --- a/packages/lib/src/usecases/apply-overrides.ts +++ b/packages/lib/src/usecases/apply-overrides.ts @@ -12,7 +12,8 @@ const applyOverrideKeys = [ "playwrightCpuLimit", "playwrightRamLimit", "gpu", - "enableMcpPlaywright" + "enableMcpPlaywright", + "enableMcpAndroid" ] satisfies ReadonlyArray export const hasApplyOverrides = (command: ApplyCommand): boolean => @@ -58,6 +59,9 @@ const applyResourceOverrides = (template: TemplateConfig, command: ApplyCommand) if (command.enableMcpPlaywright !== undefined) { next = { ...next, enableMcpPlaywright: command.enableMcpPlaywright } } + if (command.enableMcpAndroid !== undefined) { + next = { ...next, enableMcpAndroid: command.enableMcpAndroid } + } return next } diff --git a/packages/lib/src/usecases/errors.ts b/packages/lib/src/usecases/errors.ts index 111e0f90..172e37e6 100644 --- a/packages/lib/src/usecases/errors.ts +++ b/packages/lib/src/usecases/errors.ts @@ -126,6 +126,8 @@ const formatDockerIdentityConflictKind = ( serviceName: "compose project name", volumeName: "volume name", browserVolumeName: "browser volume name", + androidContainerName: "android container name", + androidVolumeName: "android volume name", bootstrapVolumeName: "bootstrap volume name" })[kind] diff --git a/packages/lib/src/usecases/mcp-android.ts b/packages/lib/src/usecases/mcp-android.ts new file mode 100644 index 00000000..96b106de --- /dev/null +++ b/packages/lib/src/usecases/mcp-android.ts @@ -0,0 +1,90 @@ +import type { CommandExecutor } from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import type { FileSystem } from "@effect/platform/FileSystem" +import type { Path } from "@effect/platform/Path" +import { Effect } from "effect" + +import type { McpAndroidUpCommand, TemplateConfig } from "../core/domain.js" +import { readProjectConfig } from "../shell/config.js" +import { ensureDockerDaemonAccess } from "../shell/docker.js" +import type { + ConfigDecodeError, + ConfigNotFoundError, + DockerAccessError, + DockerCommandError, + FileExistsError, + PortProbeError +} from "../shell/errors.js" +import { writeProjectFiles } from "../shell/files.js" +import { ensureCodexConfigFile } from "./auth-sync.js" +import { runDockerComposeUpWithPortCheck } from "./projects-up.js" + +type McpAndroidFilesError = ConfigNotFoundError | ConfigDecodeError | FileExistsError | PlatformError +type McpAndroidFilesEnv = FileSystem | Path + +const enableInTemplate = (template: TemplateConfig): TemplateConfig => ({ + ...template, + enableMcpAndroid: true +}) + +// CHANGE: enable Android MCP in an existing docker-git project directory (files only) +// WHY: allow adding the Android emulator sidecar + android-connection MCP config without wiping env or volumes +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +// FORMAT THEOREM: forall p: enable(p) -> template(p).enableMcpAndroid = true +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: does not rewrite .orch/env/project.env (only managed templates + docker-git.json) +// COMPLEXITY: O(n) where n = |managed_files| +export const enableMcpAndroidProjectFiles = ( + projectDir: string +): Effect.Effect => + Effect.gen(function*(_) { + const config = yield* _(readProjectConfig(projectDir)) + const wasAlreadyEnabled = config.template.enableMcpAndroid + const updated = wasAlreadyEnabled ? config.template : enableInTemplate(config.template) + + yield* _( + wasAlreadyEnabled + ? Effect.log("Android MCP is already enabled for this project.") + : Effect.log("Enabling Android MCP for this project (templates only)...") + ) + + yield* _(writeProjectFiles(projectDir, updated, true)) + yield* _(ensureCodexConfigFile(projectDir, updated.codexAuthPath)) + + return updated + }) + +export type McpAndroidUpError = + | McpAndroidFilesError + | DockerAccessError + | DockerCommandError + | PortProbeError + +type McpAndroidUpEnv = McpAndroidFilesEnv | CommandExecutor + +// CHANGE: enable Android MCP in an existing project dir and bring docker compose up +// WHY: upgrade already created containers to support Android automation without forcing full recreation flows +// QUOTE(ТЗ): "Подключить mcp-android так же как работает MCP PLAYRIGHT" +// REF: issue-436 +// SOURCE: n/a +// FORMAT THEOREM: forall p: up(p) -> running(p-android) OR docker_error +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: volumes are preserved (no docker compose down -v) +// COMPLEXITY: O(command) +export const mcpAndroidUp = ( + command: McpAndroidUpCommand +): Effect.Effect => + Effect.gen(function*(_) { + const updated = yield* _(enableMcpAndroidProjectFiles(command.projectDir)) + + if (!command.runUp) { + return updated + } + + yield* _(ensureDockerDaemonAccess(command.projectDir)) + return yield* _(runDockerComposeUpWithPortCheck(command.projectDir)) + }) diff --git a/packages/lib/tests/usecases/apply.test.ts b/packages/lib/tests/usecases/apply.test.ts index 3c39f51a..11cdf177 100644 --- a/packages/lib/tests/usecases/apply.test.ts +++ b/packages/lib/tests/usecases/apply.test.ts @@ -227,6 +227,7 @@ describe("applyProjectFiles", () => { cpuLimit: "2", ramLimit: "4g", enableMcpPlaywright: true, + enableMcpAndroid: true, gpu: "none", }) ) @@ -236,6 +237,7 @@ describe("applyProjectFiles", () => { expect(appliedTemplate.cpuLimit).toBe("2") expect(appliedTemplate.ramLimit).toBe("4g") expect(appliedTemplate.enableMcpPlaywright).toBe(true) + expect(appliedTemplate.enableMcpAndroid).toBe(true) const composeAfter = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml"))) expect(composeAfter).toContain('GITHUB_AUTH_LABEL: "AGIEN_MAIN"') @@ -247,10 +249,17 @@ describe("applyProjectFiles", () => { expect(composeAfter).toContain('memswap_limit: "8192m"') expect(composeAfter).toContain('MCP_PLAYWRIGHT_ENABLE: "1"') expect(composeAfter).toContain("dg-test-browser") + expect(composeAfter).toContain('MCP_ANDROID_ENABLE: "1"') + expect(composeAfter).toContain('DOCKER_GIT_ANDROID_PROJECT: "dg-test"') + expect(composeAfter).toContain("dg-test-android") + + const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile"))) + expect(dockerfileAfter).toContain("cargo install --path /opt/docker-git/tools/android-connection") const configAfter = yield* _(fs.readFileString(path.join(outDir, "docker-git.json"))) expect(configAfter).toContain('"cpuLimit": "2"') expect(configAfter).toContain('"ramLimit": "4g"') + expect(configAfter).toContain('"enableMcpAndroid": true') }) ).pipe(Effect.provide(NodeContext.layer))) diff --git a/packages/lib/tests/usecases/mcp-android.test.ts b/packages/lib/tests/usecases/mcp-android.test.ts new file mode 100644 index 00000000..b40fd6a3 --- /dev/null +++ b/packages/lib/tests/usecases/mcp-android.test.ts @@ -0,0 +1,146 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import type { TemplateConfig } from "../../src/core/domain.js" +import { enableMcpAndroidProjectFiles } from "../../src/usecases/mcp-android.js" +import { prepareProjectFiles } from "../../src/usecases/actions/prepare-files.js" + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-mcp-android-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const makeGlobalConfig = (root: string, path: Path.Path): TemplateConfig => ({ + containerName: "dg-test", + serviceName: "dg-test", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + skipGithubAuth: false, + targetDir: "/home/dev/org/repo", + volumeName: "dg-test-home", + dockerGitPath: path.join(root, ".docker-git"), + authorizedKeysPath: path.join(root, "authorized_keys"), + envGlobalPath: path.join(root, ".orch/env/global.env"), + envProjectPath: path.join(root, ".orch/env/project.env"), + codexAuthPath: path.join(root, ".orch/auth/codex"), + codexSharedAuthPath: path.join(root, ".orch/auth/codex-shared"), + codexHome: "/home/dev/.codex", + dockerNetworkMode: "shared", + dockerSharedNetworkName: "docker-git-shared", + enableMcpPlaywright: false, + enableMcpAndroid: false, + gpu: "none", + bunVersion: "1.3.11" +}) + +const makeProjectConfig = ( + outDir: string, + enableMcpAndroid: boolean, + path: Path.Path +): TemplateConfig => ({ + containerName: "dg-test", + serviceName: "dg-test", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + skipGithubAuth: false, + targetDir: "/home/dev/org/repo", + volumeName: "dg-test-home", + dockerGitPath: path.join(outDir, ".docker-git"), + authorizedKeysPath: path.join(outDir, "authorized_keys"), + envGlobalPath: path.join(outDir, ".orch/env/global.env"), + envProjectPath: path.join(outDir, ".orch/env/project.env"), + codexAuthPath: path.join(outDir, ".orch/auth/codex"), + codexSharedAuthPath: path.join(outDir, ".orch/auth/codex-shared"), + codexHome: "/home/dev/.codex", + dockerNetworkMode: "shared", + dockerSharedNetworkName: "docker-git-shared", + enableMcpPlaywright: false, + enableMcpAndroid, + bunVersion: "1.3.11" +}) + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null + +const readEnableMcpAndroidFlag = (value: unknown): boolean | undefined => { + if (!isRecord(value)) { + return undefined + } + + const template = value.template + if (!isRecord(template)) { + return undefined + } + + const flag = template.enableMcpAndroid + return typeof flag === "boolean" ? flag : undefined +} + +describe("enableMcpAndroidProjectFiles", () => { + it.effect("enables Android MCP for an existing project without rewriting env files", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const outDir = path.join(root, "project") + const globalConfig = makeGlobalConfig(root, path) + const withoutMcp = makeProjectConfig(outDir, false, path) + + yield* _( + prepareProjectFiles(outDir, root, globalConfig, withoutMcp, { + force: false, + forceEnv: false + }) + ) + + const envProjectPath = path.join(outDir, ".orch/env/project.env") + yield* _(fs.writeFileString(envProjectPath, "# custom env\nCUSTOM_KEY=1\n")) + + yield* _(enableMcpAndroidProjectFiles(outDir)) + + const envAfter = yield* _(fs.readFileString(envProjectPath)) + expect(envAfter).toContain("CUSTOM_KEY=1") + + const composeAfter = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml"))) + expect(composeAfter).toContain("dg-test-android") + expect(composeAfter).toContain('MCP_ANDROID_ENABLE: "1"') + expect(composeAfter).toContain('DOCKER_GIT_ANDROID_PROJECT: "dg-test"') + expect(composeAfter).toContain('DOCKER_GIT_ANDROID_CONTAINER_NAME: "dg-test-android"') + expect(composeAfter).toContain("/dev/kvm") + + const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile"))) + expect(dockerfileAfter).toContain("android-tools-adb") + expect(dockerfileAfter).toContain("cargo install --path /opt/docker-git/tools/android-connection") + expect(dockerfileAfter).toContain("/usr/local/bin/android-connection --version") + + const androidConnectionCargoToml = path.join( + outDir, + ".docker-git-tools", + "android-connection", + "Cargo.toml" + ) + expect(yield* _(fs.exists(androidConnectionCargoToml))).toBe(true) + + const configAfterText = yield* _(fs.readFileString(path.join(outDir, "docker-git.json"))) + const configAfter = yield* _(Effect.sync((): unknown => JSON.parse(configAfterText))) + expect(readEnableMcpAndroidFlag(configAfter)).toBe(true) + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/packages/openapi/openapi.json b/packages/openapi/openapi.json index 1e797e52..5d9d6c4d 100644 --- a/packages/openapi/openapi.json +++ b/packages/openapi/openapi.json @@ -1081,6 +1081,9 @@ "enableMcpPlaywright": { "type": "boolean" }, + "enableMcpAndroid": { + "type": "boolean" + }, "outDir": { "type": "string" }, diff --git a/packages/openapi/src/openapi-paths.ts b/packages/openapi/src/openapi-paths.ts index 21e1bdd6..c38bc92f 100644 --- a/packages/openapi/src/openapi-paths.ts +++ b/packages/openapi/src/openapi-paths.ts @@ -1188,6 +1188,7 @@ export interface operations { dockerNetworkMode?: string; dockerSharedNetworkName?: string; enableMcpPlaywright?: boolean; + enableMcpAndroid?: boolean; outDir?: string; gitTokenLabel?: string; skipGithubAuth?: boolean;