diff --git a/documents/docs/mcp/action.md b/documents/docs/mcp/action.md index 6d0b1adc1..475742c30 100644 --- a/documents/docs/mcp/action.md +++ b/documents/docs/mcp/action.md @@ -99,7 +99,7 @@ UFO² provides several built-in action servers for different automation scenario | Server | Platform | Description | Documentation | |--------|----------|-------------|---------------| -| **[CommandLineExecutor](servers/command_line_executor.md)** | Windows | Execute shell commands and launch applications | [Full Details →](servers/command_line_executor.md) | +| **[CommandLineExecutor](servers/command_line_executor.md)** | Windows | Launch applications via direct execution (no shell) | [Full Details →](servers/command_line_executor.md) | | **[BashExecutor](servers/bash_executor.md)** | Linux | Execute Linux commands via HTTP server | [Full Details →](servers/bash_executor.md) | ### Office Automation Servers (COM API) diff --git a/documents/docs/mcp/local_servers.md b/documents/docs/mcp/local_servers.md index 5ef27020f..5dea415ac 100644 --- a/documents/docs/mcp/local_servers.md +++ b/documents/docs/mcp/local_servers.md @@ -13,7 +13,7 @@ UFO² includes several built-in local MCP servers organized by functionality. Th | **UICollector** | Data Collection | Windows UI observation | **[→ Full Docs](servers/ui_collector.md)** | | **HostUIExecutor** | Action | Desktop-level UI automation | **[→ Full Docs](servers/host_ui_executor.md)** | | **AppUIExecutor** | Action | Application-level UI automation | **[→ Full Docs](servers/app_ui_executor.md)** | -| **CommandLineExecutor** | Action | Shell command execution | **[→ Full Docs](servers/command_line_executor.md)** | +| **CommandLineExecutor** | Action | Application launching (no shell) | **[→ Full Docs](servers/command_line_executor.md)** | | **WordCOMExecutor** | Action | Microsoft Word COM API | **[→ Full Docs](servers/word_com_executor.md)** | | **ExcelCOMExecutor** | Action | Microsoft Excel COM API | **[→ Full Docs](servers/excel_com_executor.md)** | | **PowerPointCOMExecutor** | Action | Microsoft PowerPoint COM API | **[→ Full Docs](servers/ppt_com_executor.md)** | @@ -58,10 +58,10 @@ UFO² includes several built-in local MCP servers organized by functionality. Th ### CommandLineExecutor -**Type**: Action (LLM-selectable, shell execution) +**Type**: Action (LLM-selectable, application launching) **Platform**: Cross-platform **Agent**: HostAgent, AppAgent -**Tool**: `run_shell` - Execute shell commands +**Tool**: `run_shell` - Launch applications (executes with `shell=False` to prevent shell injection) **[→ See complete CommandLineExecutor documentation](servers/command_line_executor.md)** for security guidelines and examples. diff --git a/documents/docs/mcp/servers/command_line_executor.md b/documents/docs/mcp/servers/command_line_executor.md index f31e2ff04..29514cab7 100644 --- a/documents/docs/mcp/servers/command_line_executor.md +++ b/documents/docs/mcp/servers/command_line_executor.md @@ -46,21 +46,21 @@ await computer.run_actions([ ) ]) -# Launch application with arguments +# Launch PowerPoint with a file await computer.run_actions([ MCPToolCall( tool_key="action::run_shell", tool_name="run_shell", - parameters={"bash_command": "python script.py --arg value"} + parameters={"bash_command": "powerpnt \"Desktop\\test.pptx\""} ) ]) -# Create directory (Windows) +# Launch File Explorer await computer.run_actions([ MCPToolCall( tool_key="action::run_shell", tool_name="run_shell", - parameters={"bash_command": "mkdir C:\\temp\\newfolder"} + parameters={"bash_command": "explorer.exe"} ) ]) ``` @@ -81,19 +81,22 @@ ToolError("Failed to launch application: {error_details}") #### Implementation Details -- Uses `subprocess.Popen` with `shell=True` +- Commands are parsed into an argument list via `shlex.split()` +- Uses `subprocess.Popen` with `shell=False` to prevent shell injection +- Shell metacharacters (`|`, `&`, `;`, `` ` ``, `$()`, etc.) are **not** interpreted +- Shell built-in commands (e.g., `start`, `dir`, `cd`) are **not** available — only executable binaries can be launched - Waits 5 seconds after launch for application to start - Non-blocking: Returns immediately after launch -!!!danger "Security Warning" - **Arbitrary command execution risk!** Always validate commands before execution. +!!!info "Security Note" + Commands are executed **without a shell** (`shell=False`). This means: - Dangerous examples: - - `rm -rf /` (Linux) - - `del /F /S /Q C:\*` (Windows) - - `shutdown /s /t 0` + - Shell injection via metacharacters is not possible + - Only direct executable binaries can be invoked + - Shell built-ins (`start`, `dir`, `cd`, `copy`, etc.) will **not** work + - Command chaining (`&&`, `||`, `|`, `;`) has no effect - **Best Practice**: Implement command whitelist or validation. + **Best Practice**: Use an allow-list to restrict which executables may be launched. ## Configuration @@ -119,17 +122,22 @@ AppAgent: ### 1. Validate Commands +Since `run_shell` executes commands with `shell=False`, shell injection is already mitigated. However, it is still recommended to restrict which executables can be launched: + ```python def safe_run_shell(command: str): - """Whitelist-based command validation""" + """Allow-list-based command validation""" + import shlex allowed_commands = [ - "notepad.exe", - "calc.exe", - "mspaint.exe", - "code", # VS Code + "notepad.exe", "notepad", + "calc.exe", "calc", + "mspaint.exe", "mspaint", + "code", "code.exe", + "explorer", "explorer.exe", ] - cmd_base = command.split()[0] + tokens = shlex.split(command) + cmd_base = tokens[0].lower() if cmd_base not in allowed_commands: raise ValueError(f"Command not allowed: {cmd_base}") @@ -235,61 +243,45 @@ await computer.run_actions([ ) ]) -# Launch browser with URL +# Launch browser await computer.run_actions([ MCPToolCall( tool_key="action::run_shell", - parameters={"bash_command": "start https://www.example.com"} + parameters={"bash_command": "msedge.exe https://www.example.com"} ) ]) ``` -### 2. File Operations +### 2. Open Files with Applications ```python -# Create directory +# Open a document in Word await computer.run_actions([ MCPToolCall( tool_key="action::run_shell", - parameters={"bash_command": "mkdir C:\\temp\\workspace"} + parameters={"bash_command": "winword.exe report.docx"} ) ]) -# Copy file +# Open a spreadsheet in Excel await computer.run_actions([ MCPToolCall( tool_key="action::run_shell", - parameters={"bash_command": "copy source.txt dest.txt"} + parameters={"bash_command": "excel.exe data.xlsx"} ) ]) ``` -### 3. Script Execution - -```python -# Run Python script -await computer.run_actions([ - MCPToolCall( - tool_key="action::run_shell", - parameters={"bash_command": "python automation_script.py --mode batch"} - ) -]) - -# Run PowerShell script -await computer.run_actions([ - MCPToolCall( - tool_key="action::run_shell", - parameters={"bash_command": "powershell -File script.ps1"} - ) -]) -``` +!!!note + Shell built-in commands like `start`, `copy`, `mkdir`, and `dir` are **not available** because commands run without a shell. Only direct executable binaries (`.exe`) can be invoked. ## Limitations - **No output capture**: Command output (stdout/stderr) is not returned - **No exit code**: Cannot determine if command succeeded - **Async execution**: No way to know when command completes -- **Security risk**: Arbitrary command execution +- **No shell built-ins**: Commands like `start`, `dir`, `copy`, `cd` are not available (runs with `shell=False`) +- **No shell features**: Piping (`|`), redirection (`>`), chaining (`&&`) are not supported **Tip:** For Linux systems with output capture and better control, use **BashExecutor** server instead. diff --git a/galaxy/galaxy.py b/galaxy/galaxy.py index d8dbc1ab4..34ca47706 100644 --- a/galaxy/galaxy.py +++ b/galaxy/galaxy.py @@ -370,7 +370,7 @@ def find_free_port(start_port=8000, max_attempts=10): # Configure and run uvicorn server config = uvicorn.Config( app, - host="0.0.0.0", + host="127.0.0.1", port=port, log_level="info", access_log=False, diff --git a/galaxy/webui/dependencies.py b/galaxy/webui/dependencies.py index 731da9821..9fcaa7d0d 100644 --- a/galaxy/webui/dependencies.py +++ b/galaxy/webui/dependencies.py @@ -9,8 +9,11 @@ """ import logging +import secrets from typing import TYPE_CHECKING, Optional +from fastapi import Header, HTTPException + from galaxy.webui.websocket_observer import WebSocketObserver if TYPE_CHECKING: @@ -35,6 +38,9 @@ def __init__(self) -> None: """Initialize the application state with default values.""" self.logger: logging.Logger = logging.getLogger(__name__) + # API key for authenticating HTTP and WebSocket requests + self._api_key: Optional[str] = None + # WebSocket observer for broadcasting events to clients self._websocket_observer: Optional[WebSocketObserver] = None @@ -45,6 +51,16 @@ def __init__(self) -> None: # Counter for generating unique task names in Web UI mode self._request_counter: int = 0 + @property + def api_key(self) -> Optional[str]: + """Get the API key.""" + return self._api_key + + @api_key.setter + def api_key(self, key: str) -> None: + """Set the API key.""" + self._api_key = key + @property def websocket_observer(self) -> Optional[WebSocketObserver]: """ @@ -145,3 +161,12 @@ def get_app_state() -> AppState: :return: Application state instance """ return app_state + + +async def verify_api_key( + x_api_key: str = Header(..., alias="X-API-Key"), +) -> None: + """FastAPI dependency that validates the X-API-Key header.""" + key = app_state.api_key + if not key or not secrets.compare_digest(x_api_key, key): + raise HTTPException(status_code=401, detail="Invalid API key") diff --git a/galaxy/webui/frontend/src/services/websocket.ts b/galaxy/webui/frontend/src/services/websocket.ts index b58416038..3bae129ed 100644 --- a/galaxy/webui/frontend/src/services/websocket.ts +++ b/galaxy/webui/frontend/src/services/websocket.ts @@ -48,7 +48,8 @@ export class WebSocketClient { if (!url) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; - this.url = `${protocol}//${host}/ws`; + const apiKey = (window as any).__GALAXY_API_KEY__ || ''; + this.url = `${protocol}//${host}/ws?token=${encodeURIComponent(apiKey)}`; } else { this.url = url; } diff --git a/galaxy/webui/routers/devices.py b/galaxy/webui/routers/devices.py index 965ad1e04..a1ad25190 100644 --- a/galaxy/webui/routers/devices.py +++ b/galaxy/webui/routers/devices.py @@ -10,9 +10,9 @@ import logging from typing import Dict, Any -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException -from galaxy.webui.dependencies import get_app_state +from galaxy.webui.dependencies import get_app_state, verify_api_key from galaxy.webui.models.requests import DeviceAddRequest from galaxy.webui.models.responses import DeviceAddResponse from galaxy.webui.services import ConfigService, DeviceService @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) -@router.post("/devices", response_model=DeviceAddResponse) +@router.post("/devices", response_model=DeviceAddResponse, dependencies=[Depends(verify_api_key)]) async def add_device(device: DeviceAddRequest) -> Dict[str, Any]: """ Add a new device to the Galaxy configuration. diff --git a/galaxy/webui/routers/health.py b/galaxy/webui/routers/health.py index 15ec19175..154f4db6c 100644 --- a/galaxy/webui/routers/health.py +++ b/galaxy/webui/routers/health.py @@ -9,15 +9,15 @@ from typing import Dict, Any -from fastapi import APIRouter +from fastapi import APIRouter, Depends -from galaxy.webui.dependencies import get_app_state +from galaxy.webui.dependencies import get_app_state, verify_api_key from galaxy.webui.models.responses import HealthResponse router = APIRouter(tags=["health"]) -@router.get("/health", response_model=HealthResponse) +@router.get("/health", response_model=HealthResponse, dependencies=[Depends(verify_api_key)]) async def health_check() -> Dict[str, Any]: """ Health check endpoint. diff --git a/galaxy/webui/routers/websocket.py b/galaxy/webui/routers/websocket.py index 11376b2dd..76c1f14dd 100644 --- a/galaxy/webui/routers/websocket.py +++ b/galaxy/webui/routers/websocket.py @@ -9,8 +9,10 @@ """ import logging +import secrets -from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect +from starlette.websockets import WebSocketState from galaxy.webui.dependencies import get_app_state from galaxy.webui.handlers import WebSocketMessageHandler @@ -18,26 +20,53 @@ router = APIRouter(tags=["websocket"]) logger = logging.getLogger(__name__) +WS_1008_POLICY_VIOLATION = 1008 + @router.websocket("/ws") -async def websocket_endpoint(websocket: WebSocket) -> None: +async def websocket_endpoint( + websocket: WebSocket, + token: str = Query(default=None), +) -> None: """ WebSocket endpoint for real-time event streaming. + Requires a valid ``token`` query parameter that matches the server API key. + This endpoint establishes a persistent connection with clients to: - Send welcome messages and initial state (device snapshots) - Receive and process client messages (requests, commands) - Broadcast Galaxy events to all connected clients in real-time The connection lifecycle: - 1. Accept the WebSocket connection - 2. Register with the WebSocket observer for event broadcasting - 3. Send welcome message and initial device snapshot - 4. Process incoming messages until disconnection - 5. Cleanup and remove from observer on disconnect + 1. Validate the token query parameter + 2. Accept the WebSocket connection + 3. Register with the WebSocket observer for event broadcasting + 4. Send welcome message and initial device snapshot + 5. Process incoming messages until disconnection + 6. Cleanup and remove from observer on disconnect :param websocket: The WebSocket connection from the client + :param token: API key passed as a query parameter """ + # Validate token before accepting the connection + app_state = get_app_state() + expected_key = app_state.api_key + if ( + not expected_key + or not token + or not secrets.compare_digest(token, expected_key) + ): + await websocket.close( + code=WS_1008_POLICY_VIOLATION, + reason="Invalid or missing token", + ) + logger.warning( + "WebSocket connection rejected (invalid token) from %s", + websocket.client, + ) + return + await websocket.accept() logger.info(f"WebSocket connection established from {websocket.client}") diff --git a/galaxy/webui/server.py b/galaxy/webui/server.py index edfb0f341..968f9a63e 100644 --- a/galaxy/webui/server.py +++ b/galaxy/webui/server.py @@ -15,9 +15,10 @@ """ import logging +import secrets from contextlib import asynccontextmanager from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -53,6 +54,13 @@ async def lifespan(app: FastAPI): # Get application state app_state = get_app_state() + # Generate API key if not already set (e.g. via start_server) + if not app_state.api_key: + app_state.api_key = secrets.token_urlsafe(32) + + logger.info("🔑 Galaxy WebUI API key: %s", app_state.api_key) + print(f"🔑 Galaxy WebUI API key: {app_state.api_key}") + # Create and register WebSocket observer with event bus websocket_observer = WebSocketObserver() app_state.websocket_observer = websocket_observer @@ -82,10 +90,13 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) -# Add CORS middleware to allow cross-origin requests +# Add CORS middleware – restrict to local origins only app.add_middleware( CORSMiddleware, - allow_origins=["*"], # In production, specify exact origins + allow_origins=[ + "http://localhost:8000", + "http://127.0.0.1:8000", + ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -131,8 +142,18 @@ async def root() -> HTMLResponse: frontend_index: Path = Path(__file__).parent / "frontend" / "dist" / "index.html" if frontend_index.exists(): with open(frontend_index, "r", encoding="utf-8") as f: - return HTMLResponse( - content=f.read(), + content = f.read() + + # Inject API key so the frontend can authenticate WS and HTTP requests + app_state = get_app_state() + api_key = app_state.api_key or "" + api_key_script = ( + f'' + ) + content = content.replace("", f"{api_key_script}", 1) + + return HTMLResponse( + content=content, status_code=200, headers={ "Cache-Control": "no-cache, no-store, must-revalidate", @@ -173,15 +194,24 @@ def set_galaxy_client(client: "GalaxyClient") -> None: app_state.galaxy_client = client -def start_server(host: str = "0.0.0.0", port: int = 8000) -> None: +def start_server( + host: str = "127.0.0.1", + port: int = 8000, + api_key: Optional[str] = None, +) -> None: """ Start the Galaxy Web UI server. - :param host: Host address to bind to (default: "0.0.0.0") + :param host: Host address to bind to (default: "127.0.0.1") :param port: Port number to listen on (default: 8000) + :param api_key: API key for authenticating requests. Auto-generated if not provided. """ import uvicorn + # Set API key before starting the server so lifespan picks it up + app_state = get_app_state() + app_state.api_key = api_key or secrets.token_urlsafe(32) + logger: logging.Logger = logging.getLogger(__name__) logger.info(f"Starting Galaxy Web UI server on {host}:{port}") diff --git a/tests/galaxy/webui/test_websocket_server.py b/tests/galaxy/webui/test_websocket_server.py index 5c65c30d0..14471621a 100644 --- a/tests/galaxy/webui/test_websocket_server.py +++ b/tests/galaxy/webui/test_websocket_server.py @@ -9,6 +9,17 @@ from fastapi.websockets import WebSocket from galaxy.webui.server import app, set_galaxy_client +from galaxy.webui.dependencies import get_app_state + +TEST_API_KEY = "test-api-key-for-unit-tests" + + +@pytest.fixture(autouse=True) +def _set_test_api_key(): + """Ensure all tests run with a known API key.""" + state = get_app_state() + state.api_key = TEST_API_KEY + yield @pytest.fixture @@ -19,7 +30,7 @@ def test_client(): def test_health_endpoint(test_client): """Test the health check endpoint.""" - response = test_client.get("/health") + response = test_client.get("/health", headers={"X-API-Key": TEST_API_KEY}) assert response.status_code == 200 data = response.json() assert "status" in data @@ -41,7 +52,7 @@ def test_root_endpoint(test_client): async def test_websocket_connection(): """Test WebSocket connection establishment.""" with TestClient(app) as client: - with client.websocket_connect("/ws") as websocket: + with client.websocket_connect(f"/ws?token={TEST_API_KEY}") as websocket: # Should receive welcome message data = websocket.receive_json() assert data["type"] == "welcome" @@ -52,7 +63,7 @@ async def test_websocket_connection(): async def test_websocket_ping_pong(): """Test WebSocket ping/pong mechanism.""" with TestClient(app) as client: - with client.websocket_connect("/ws") as websocket: + with client.websocket_connect(f"/ws?token={TEST_API_KEY}") as websocket: # Receive welcome message websocket.receive_json() @@ -69,7 +80,7 @@ async def test_websocket_ping_pong(): async def test_websocket_request_without_client(): """Test sending request when Galaxy client is not set.""" with TestClient(app) as client: - with client.websocket_connect("/ws") as websocket: + with client.websocket_connect(f"/ws?token={TEST_API_KEY}") as websocket: # Receive welcome message websocket.receive_json() @@ -94,7 +105,7 @@ async def test_websocket_request_with_client(): try: with TestClient(app) as client: - with client.websocket_connect("/ws") as websocket: + with client.websocket_connect(f"/ws?token={TEST_API_KEY}") as websocket: # Receive welcome message websocket.receive_json() @@ -118,7 +129,7 @@ async def test_websocket_request_with_client(): async def test_websocket_reset(): """Test reset message handling.""" with TestClient(app) as client: - with client.websocket_connect("/ws") as websocket: + with client.websocket_connect(f"/ws?token={TEST_API_KEY}") as websocket: # Receive welcome message websocket.receive_json() @@ -135,7 +146,7 @@ async def test_websocket_reset(): async def test_websocket_unknown_message(): """Test handling of unknown message types.""" with TestClient(app) as client: - with client.websocket_connect("/ws") as websocket: + with client.websocket_connect(f"/ws?token={TEST_API_KEY}") as websocket: # Receive welcome message websocket.receive_json() diff --git a/ufo/client/mcp/local_servers/cli_mcp_server.py b/ufo/client/mcp/local_servers/cli_mcp_server.py index ad78a61a8..def3a761d 100644 --- a/ufo/client/mcp/local_servers/cli_mcp_server.py +++ b/ufo/client/mcp/local_servers/cli_mcp_server.py @@ -8,6 +8,7 @@ - Application launching via command execution """ +import shlex import subprocess import time @@ -44,8 +45,8 @@ def run_shell( raise ToolError("Bash command cannot be empty.") try: - # Create an AppPuppeteer instance to launch the application - subprocess.Popen(bash_command, shell=True) + # Launch the application without shell=True to prevent injection + subprocess.Popen(shlex.split(bash_command)) time.sleep(5) # Wait for the application to launch except Exception as e: raise ToolError(f"Failed to launch application: {str(e)}") diff --git a/ufo/client/mcp/local_servers/pdf_reader_mcp_server.py b/ufo/client/mcp/local_servers/pdf_reader_mcp_server.py index 44dbdaffe..2de7c0682 100644 --- a/ufo/client/mcp/local_servers/pdf_reader_mcp_server.py +++ b/ufo/client/mcp/local_servers/pdf_reader_mcp_server.py @@ -60,7 +60,7 @@ def _extract_text_from_pdf(pdf_path: str, simulate_human: bool = True) -> str: print(f"🔍 Opening PDF file: {os.path.basename(pdf_path)}") try: # 尝试用默认程序打开PDF(通常是Adobe Reader或浏览器) - pdf_process = subprocess.Popen(["start", "", pdf_path], shell=True) + pdf_process = subprocess.Popen(["cmd", "/c", "start", "", pdf_path], shell=False) # 模拟人工查看时间:随机等待2-5秒 wait_time = random.uniform(2.0, 5.0) diff --git a/ufo/prompts/examples/visual/host_agent_example.yaml b/ufo/prompts/examples/visual/host_agent_example.yaml index 27723203c..d401233bf 100644 --- a/ufo/prompts/examples/visual/host_agent_example.yaml +++ b/ufo/prompts/examples/visual/host_agent_example.yaml @@ -236,9 +236,9 @@ example9: plan: [] function: run_shell arguments: - bash_command: start powerpnt "Desktop\test.pptx" + bash_command: powerpnt "Desktop\test.pptx" comment: |- - Since the PowerPoint application is not visible in the screenshot, I will use the bash command to open the PowerPoint application directly. Then modify the title to 'Apple is the best tech Company' in the test.pptx file. + Since the PowerPoint application is not visible in the screenshot, I will launch the PowerPoint application directly with the file path. Then modify the title to 'Apple is the best tech Company' in the test.pptx file. questions: [] result: |- User request in CONTINUE state. Plan: use run_shell to launch PowerPoint with Desktop\test.pptx and change title to 'Apple is the best tech Company'. Execution outcome not yet confirmed; editability of title unknown. Remaining: (1) execute & verify file open; (2) locate title placeholder (control or coordinate); (3) replace text; (4) optionally save; (5) report completion. No file modification artifact yet. @@ -260,10 +260,10 @@ example10: plan: [] function: run_shell arguments: - bash_command: start explorer + bash_command: explorer comment: |- - Since the file explorer application is not visible in the screenshot, I will use the bash command to open the file explorer application directly. Then find the label.txt file in the file explorer. + Since the file explorer application is not visible in the screenshot, I will launch the file explorer application directly. Then find the label.txt file in the file explorer. questions: [] result: |- - User request in CONTINUE state. Plan: launch File Explorer via run_shell (start explorer) to locate label.txt. Explorer window not yet confirmed open; no navigation or search performed. Remaining: (1) confirm window active; (2) choose path (e.g., user folder/Desktop or global search); (3) enter 'label.txt' in search; (4) locate file and optionally return full path; (5) perform follow-up (open/read) if required. No evidence of file discovery yet. + User request in CONTINUE state. Plan: launch File Explorer via run_shell (explorer) to locate label.txt. Explorer window not yet confirmed open; no navigation or search performed. Remaining: (1) confirm window active; (2) choose path (e.g., user folder/Desktop or global search); (3) enter 'label.txt' in search; (4) locate file and optionally return full path; (5) perform follow-up (open/read) if required. No evidence of file discovery yet.