diff --git a/src/oci-code-mcp-server/Containerfile b/src/oci-code-mcp-server/Containerfile new file mode 100644 index 00000000..c5606da5 --- /dev/null +++ b/src/oci-code-mcp-server/Containerfile @@ -0,0 +1,26 @@ +# Copyright (c) 2026, Oracle and/or its affiliates. +# Licensed under the Universal Permissive License v1.0 as shown at +# https://oss.oracle.com/licenses/upl. + +FROM ghcr.io/oracle/oraclelinux:9-slim@sha256:1950389c3dd619841813520b4e69d7f3112c4c10f713fcb90680703d184c33ad + +RUN microdnf install epel-release && \ + microdnf install python3.13 python3.13-pip \ + && rm -rf /var/cache/dnf/* \ + && useradd -m -d /app oracle + +WORKDIR /app +COPY --chown=oracle:oracle . /app + +RUN pip3.13 install --no-cache-dir uv && \ + uv --no-cache sync --locked --all-extras + +USER oracle + +ENV ORACLE_MCP_HOST="" +ENV ORACLE_MCP_PORT="" +ENV OCI_CODE_FIRECRACKER_RUNNER_CMD="" +ENV OCI_CODE_SNAPSHOT_NAME="oci-python-sdk-default" +ENV OCI_CODE_ALLOWED_EGRESS="*.oraclecloud.com" + +ENTRYPOINT ["uv", "run", "oracle.oci-code-mcp-server"] diff --git a/src/oci-code-mcp-server/LICENSE.txt b/src/oci-code-mcp-server/LICENSE.txt new file mode 100644 index 00000000..46c0c79d --- /dev/null +++ b/src/oci-code-mcp-server/LICENSE.txt @@ -0,0 +1,35 @@ +Copyright (c) 2025 Oracle and/or its affiliates. + +The Universal Permissive License (UPL), Version 1.0 + +Subject to the condition set forth below, permission is hereby granted to any +person obtaining a copy of this software, associated documentation and/or data +(collectively the "Software"), free of charge and under any and all copyright +rights in the Software, and any and all patent rights owned or freely +licensable by each licensor hereunder covering either (i) the unmodified +Software as contributed to or provided by such licensor, or (ii) the Larger +Works (as defined below), to deal in both + +(a) the Software, and +(b) any piece of software and/or hardware listed in the lrgrwrks.txt file if +one is included with the Software (each a "Larger Work" to which the Software +is contributed by such licensors), + +without restriction, including without limitation the rights to copy, create +derivative works of, display, perform, and distribute the Software and make, +use, sell, offer for sale, import, export, have made, and have sold the +Software and the Larger Work(s), and to sublicense the foregoing rights on +either these or other terms. + +This license is subject to the following condition: +The above copyright notice and either this complete permission notice or at +a minimum a reference to the UPL must be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/oci-code-mcp-server/README.md b/src/oci-code-mcp-server/README.md new file mode 100644 index 00000000..7c32bb01 --- /dev/null +++ b/src/oci-code-mcp-server/README.md @@ -0,0 +1,258 @@ +# OCI Code MCP Server + +## Overview + +`oracle.oci-code-mcp-server` is a host-side MCP server for running OCI Python SDK snippets inside an ephemeral Firecracker microVM instead of executing arbitrary Python in-process. + +The host server: + +- accepts Python code over MCP +- validates it with AST guardrails as defense in depth +- serializes only the minimum OCI auth material needed for the guest +- hands execution to a Firecracker runner command +- expects the runner to resume a snapshot, enforce strict limits, allow only OCI egress, and destroy the VM after the request +- returns only the JSON-serializable result plus minimal sandbox metadata + +This package intentionally separates the MCP interface from the Linux-only Firecracker wiring. The repo does not yet include the actual Firecracker launcher, jailer setup, TAP networking, or nftables rules; those are expected to be provided by the external runner command configured for this server. +For macOS local development, the repo does include a Lima-based nested Firecracker path that provisions those pieces inside a Linux guest. + +## Security model + +The safety boundary is the microVM, not the Python AST filter. + +- The AST policy blocks obviously dangerous imports and reflective escape hatches. +- The guest runner strips dangerous builtins as another layer. +- The real enforcement point is the Firecracker runtime plus host network policy. + +If the external runner is not configured, the server refuses execution rather than falling back to in-process Python. + +## Running the server + +### STDIO transport mode + +```sh +OCI_CODE_FIRECRACKER_RUNNER_CMD=/usr/local/bin/oci-code-firecracker-runner uvx oracle.oci-code-mcp-server +``` + +### HTTP streaming transport mode + +```sh +ORACLE_MCP_HOST= ORACLE_MCP_PORT= OCI_CODE_FIRECRACKER_RUNNER_CMD=/usr/local/bin/oci-code-firecracker-runner uvx oracle.oci-code-mcp-server +``` + +For repo-local development, this package now ships a helper wrapper at `scripts/oci-code-firecracker-runner`. A typical local setup looks like: + +```sh +export OCI_CODE_FIRECRACKER_RUNNER_CMD="/path/to/repo/src/oci-code-mcp-server/scripts/oci-code-firecracker-runner" +``` + +## macOS Local Dev With Lima Nested Virtualization + +The `lima` backend is the recommended local-dev path on macOS. It uses Lima to boot or reuse a Linux guest with nested virtualization enabled, stages the manifest and project into that guest, and then runs a guest-side helper that will: + +- provision a guest-local Firecracker runtime inside the Lima VM +- invoke a real nested Firecracker microVM inside that guest for every request + +This is the practical macOS path for testing "Linux guest runs Firecracker" without depending on a separately signed host helper with Apple virtualization entitlements. + +Prerequisites on macOS: + +- Lima installed and `limactl` available on the host +- a Lima instance started with `--vm-type=vz --nested-virt` +- a Linux guest that exposes `/dev/kvm` when nested virtualization is working +- enough host permissions to let the Lima guest create TAP devices and access `/dev/kvm` + +Typical instance bring-up: + +```sh +limactl start --vm-type=vz --nested-virt --name firecracker-dev template:default +limactl shell firecracker-dev -- sh -lc 'test -c /dev/kvm && echo kvm-present || echo no-kvm' +``` + +## Required environment + +| Variable | Description | +| --- | --- | +| `OCI_CODE_FIRECRACKER_RUNNER_CMD` | Required. External command that resumes a Firecracker snapshot, runs the guest bootstrap, and writes a JSON result payload. | +| `OCI_CODE_RUNNER_BACKEND` | Optional. `delegate` for a real Linux-side Firecracker orchestrator, `lima` for macOS Lima-based nested-virt bring-up, or `emulator` for unsafe host-local testing. Default: `delegate`. | +| `OCI_CODE_FIRECRACKER_DELEGATE_CMD` | Required when `OCI_CODE_RUNNER_BACKEND=delegate`. The real orchestrator command invoked by the wrapper. | +| `OCI_CODE_LIMACTL_BIN` | Optional `limactl` binary path. Default: `limactl`. | +| `OCI_CODE_LIMA_INSTANCE` | Optional Lima instance name. Default: `firecracker-dev`. | +| `OCI_CODE_LIMA_START_TEMPLATE` | Optional Lima template used if the instance does not already exist. Default: `template:default`. | +| `OCI_CODE_LIMA_VM_TYPE` | Optional Lima VM type used during first boot. Default: `vz`. | +| `OCI_CODE_LIMA_START_TIMEOUT` | Optional `limactl start --timeout` value. Default: `10m`. | +| `OCI_CODE_LIMA_ENABLE_NESTED_VIRT` | Optional. When true, add `--nested-virt` when creating a new instance. Default: `true`. | +| `OCI_CODE_LIMA_COPY_BACKEND` | Optional Lima copy backend: `auto`, `scp`, or `rsync`. Default: `auto`. | +| `OCI_CODE_LIMA_GUEST_ROOT` | Optional base directory inside the Lima guest where staged requests are copied. Default: `/tmp/oci-code-mcp`. | +| `OCI_CODE_LIMA_GUEST_FIRECRACKER_CMD` | Optional guest-local command that launches the Firecracker microVM and consumes `--manifest ...`. Default inside the Lima guest: `~/.local/bin/oci-code-lima-firecracker-orchestrator`. | +| `OCI_CODE_LIMA_GUEST_VENV_ROOT` | Optional guest-side venv cache root used by `oci-code-lima-guest-runner.sh`. | +| `OCI_CODE_LIMA_GUEST_OCI_PIP_SPEC` | Optional OCI SDK pip spec installed in the Lima guest venv. Default: `oci==2.160.0`. | +| `OCI_CODE_LIMA_KEEP_GUEST_BUNDLE` | Optional. When true, keep the staged request bundle in the Lima guest after execution for debugging. Default: `false`. | +| `OCI_CODE_LIMA_FIRECRACKER_FORCE_REBUILD` | Optional. When true, rebuild the inner Firecracker rootfs instead of reusing matching cached guest assets. The setup script also accepts `--force`. | +| `OCI_CODE_SNAPSHOT_NAME` | Optional. Snapshot label to request from the runner. Default: `oci-python-sdk-default`. | +| `OCI_CODE_ALLOWED_EGRESS` | Optional comma-separated domain suffix allowlist passed to the runner. Default: `*.oraclecloud.com`. | +| `OCI_CONFIG_FILE` | Optional OCI config path used to source auth material on the host. | +| `OCI_CONFIG_PROFILE` | Optional default OCI profile if the tool call does not specify one. | + +## Tool + +| Tool Name | Description | +| --- | --- | +| `execute_oci_python` | Execute restricted Python inside an ephemeral Firecracker-backed OCI sandbox. | + +### `execute_oci_python` + +The code contract is intentionally small: + +- define `main(input_data)` and return a JSON-serializable value, or +- assign a top-level `result` + +The guest runtime also injects these helpers: + +- `INPUT`: the `input_data` object passed to the tool +- `load_oci_config()`: load the guest OCI config file +- `build_oci_signer()`: build a signer from the injected auth material +- `create_oci_client(SomeSdkClient)`: instantiate an OCI SDK client with the guest config and signer + +Example: + +```python +from oci.identity import IdentityClient + + +def main(input_data): + client = create_oci_client(IdentityClient) + response = client.list_regions() + return [region.name for region in response.data] +``` + +Example MCP payload: + +```json +{ + "code": "from oci.identity import IdentityClient\n\ndef main(input_data):\n client = create_oci_client(IdentityClient)\n response = client.list_regions()\n return [region.name for region in response.data]\n", + "input_data": {}, + "timeout_seconds": 30, + "memory_limit_mib": 512 +} +``` + +Example response shape: + +```json +{ + "request_id": "95d13e4f6c234636a77424eb5bd45543", + "result": ["us-ashburn-1", "us-phoenix-1"], + "sandbox": { + "executor": "firecracker-command", + "snapshot": "oci-python-sdk-default", + "resumed_from_snapshot": true, + "vm_id": "oci-code-95d13e4f6c234636a77424eb5bd45543", + "execution_time_ms": 812 + } +} +``` + +## Firecracker runner contract + +The configured runner command is invoked with: + +```text + --manifest /tmp/.../request.json +``` + +This package now includes a stable wrapper command named `oci-code-firecracker-runner`. It supports these modes: + +- `delegate`: production shape. Validate the manifest and forward it to `OCI_CODE_FIRECRACKER_DELEGATE_CMD --manifest ...`. +- `lima`: macOS local-dev path. Start or reuse a Lima guest, stage the manifest and project into Linux, and run a guest-side helper that launches a nested Firecracker microVM inside that VM. +- `emulator`: unsafe local mode. Execute the guest runner directly on the host for testing only. + +Recommended usage: + +```sh +export OCI_CODE_FIRECRACKER_RUNNER_CMD="/usr/local/bin/oci-code-firecracker-runner" +export OCI_CODE_RUNNER_BACKEND="delegate" +export OCI_CODE_FIRECRACKER_DELEGATE_CMD="/usr/local/bin/real-firecracker-orchestrator" +``` + +Unsafe host-local bring-up only: + +```sh +export OCI_CODE_FIRECRACKER_RUNNER_CMD="/path/to/repo/src/oci-code-mcp-server/scripts/oci-code-firecracker-runner" +export OCI_CODE_RUNNER_BACKEND="emulator" +``` + +Lima-backed local development on macOS: + +```sh +limactl start --vm-type=vz --nested-virt --name firecracker-dev template:default + +export OCI_CODE_FIRECRACKER_RUNNER_CMD="$PWD/scripts/oci-code-firecracker-runner" +export OCI_CODE_RUNNER_BACKEND="lima" +export OCI_CODE_LIMA_INSTANCE="firecracker-dev" +``` + +The repo ships these local-dev helpers: + +- `scripts/oci-code-lima-guest-runner.sh`: guest-side helper copied into the Lima VM for every request +- `scripts/oci-code-lima-setup-firecracker.sh`: host-side installer that provisions Firecracker, its kernel/rootfs, and the guest-local orchestrator inside the Lima VM +- `scripts/oci-code-lima-firecracker-smoke-test.sh`: host-side smoke test that exercises the nested Firecracker path end-to-end + +### Smoke test the `lima` backend + +Real Firecracker path inside the Lima guest: + +```sh +cd /path/to/repo/src/oci-code-mcp-server +./scripts/oci-code-lima-setup-firecracker.sh +./scripts/oci-code-lima-firecracker-smoke-test.sh +``` + +The setup step is incremental. If the guest already has matching Firecracker assets, rerunning it reuses them. Use `./scripts/oci-code-lima-setup-firecracker.sh --force` to rebuild the inner rootfs explicitly. + +For a real OCI SDK request after the echo smoke test passes: + +```sh +./scripts/oci-code-lima-firecracker-smoke-test.sh --oci-regions +``` + +The Lima helper runs the package runner in `delegate` mode inside Linux. The reproducible local-dev path in this repo installs that delegate as `~/.local/bin/oci-code-lima-firecracker-orchestrator` inside the Lima guest, and the guest helper uses that path automatically unless you override `OCI_CODE_LIMA_GUEST_FIRECRACKER_CMD`. + +The manifest contains: + +- request id +- snapshot name +- code +- JSON input payload +- timeout and memory limits +- requested OCI-only egress policy +- serialized OCI auth bundle +- result file path + +The runner is expected to: + +1. resume or launch a microVM from the requested snapshot +2. inject the manifest into the guest +3. run `python -m oracle.oci_code_mcp_server.guest_runner --manifest ` +4. enforce timeout, memory, and OCI-only egress +5. destroy the VM after the request +6. write a JSON result payload to the host-visible result path + +When using the included wrapper in `delegate` mode, the low-level Firecracker orchestration still lives behind `OCI_CODE_FIRECRACKER_DELEGATE_CMD`. That lets the MCP server and manifest contract stay stable while the Linux-specific implementation evolves independently. + +## Limitations + +- The production security story depends on Linux Firecracker infrastructure that is not bundled in this repo yet. +- The `lima` backend depends on a Lima guest that actually exposes `/dev/kvm`; a running Lima VM alone is not enough. +- The `lima` backend assumes the guest can create TAP devices, enable IPv4 forwarding, and access `/dev/kvm`; those operations may still require local host approval depending on your Lima setup. +- The included `emulator` backend is intentionally unsafe and should only be used for local testing. +- The egress policy is passed symbolically as domain suffixes; the external runner must translate that into concrete network controls. +- The AST filter is intentionally conservative and may reject some metaprogramming-heavy snippets. + +## Third-Party APIs + +Developers choosing to distribute a binary implementation of this project are responsible for obtaining and providing all required licenses and copyright notices for the third-party code used in order to ensure compliance with their respective open source licenses. + +## Disclaimer + +Users are responsible for their local environment, Firecracker runner hardening, OCI credential safety, and network policy enforcement. diff --git a/src/oci-code-mcp-server/oracle/__init__.py b/src/oci-code-mcp-server/oracle/__init__.py new file mode 100644 index 00000000..b8f69b97 --- /dev/null +++ b/src/oci-code-mcp-server/oracle/__init__.py @@ -0,0 +1,5 @@ +""" +Copyright (c) 2026, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. +""" diff --git a/src/oci-code-mcp-server/oracle/oci_code_mcp_server/__init__.py b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/__init__.py new file mode 100644 index 00000000..b245bf7d --- /dev/null +++ b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/__init__.py @@ -0,0 +1,8 @@ +""" +Copyright (c) 2026, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. +""" + +__project__ = "oracle.oci-code-mcp-server" +__version__ = "0.1.0" diff --git a/src/oci-code-mcp-server/oracle/oci_code_mcp_server/executor.py b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/executor.py new file mode 100644 index 00000000..ac66e5cb --- /dev/null +++ b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/executor.py @@ -0,0 +1,253 @@ +""" +Copyright (c) 2026, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. +""" + +from __future__ import annotations + +import json +import os +import shlex +import subprocess +import tempfile +import time +import uuid +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +import oci + +from . import __project__, __version__ + +DEFAULT_SNAPSHOT_NAME = "oci-python-sdk-default" +DEFAULT_ALLOWED_EGRESS = ("*.oraclecloud.com",) + + +class SandboxExecutionError(RuntimeError): + """Raised when the external sandbox runner cannot complete a request.""" + + +@dataclass(slots=True) +class ExecutionLimits: + timeout_seconds: int = 30 + memory_limit_mib: int = 512 + vcpu_count: int = 1 + max_result_bytes: int = 262_144 + + +@dataclass(slots=True) +class ExecutionRequest: + request_id: str + code: str + input_data: dict[str, Any] | None + profile_name: str + snapshot_name: str + auth_bundle: dict[str, Any] + allowed_egress: list[str] + limits: ExecutionLimits + + +@dataclass(slots=True) +class ExecutionResult: + request_id: str + result: Any + snapshot_name: str + resumed_from_snapshot: bool + vm_id: str | None + execution_time_ms: int + executor_name: str + + def to_response(self) -> dict[str, Any]: + return { + "request_id": self.request_id, + "result": self.result, + "sandbox": { + "executor": self.executor_name, + "snapshot": self.snapshot_name, + "resumed_from_snapshot": self.resumed_from_snapshot, + "vm_id": self.vm_id, + "execution_time_ms": self.execution_time_ms, + }, + } + + +def _user_agent() -> str: + user_agent_name = __project__.split("oracle.", 1)[1].split("-server", 1)[0] + return f"{user_agent_name}/{__version__}" + + +def _default_profile_name(profile_name: str | None) -> str: + return profile_name or os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE) + + +def _config_file_location() -> str: + return os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION) + + +def collect_auth_bundle(profile_name: str | None = None) -> dict[str, Any]: + resolved_profile = _default_profile_name(profile_name) + config = oci.config.from_file( + file_location=_config_file_location(), + profile_name=resolved_profile, + ) + config["additional_user_agent"] = _user_agent() + + filtered_config = { + key: value + for key, value in config.items() + if key in {"additional_user_agent", "fingerprint", "pass_phrase", "region", "tenancy", "user"} + and value not in (None, "") + } + + key_pem = Path(os.path.expanduser(config["key_file"])).read_text() + + security_token = None + token_file = os.path.expanduser(config.get("security_token_file", "") or "") + if token_file and os.path.exists(token_file): + security_token = Path(token_file).read_text() + + return { + "profile_name": resolved_profile, + "config": filtered_config, + "key_pem": key_pem, + "security_token": security_token, + } + + +def default_allowed_egress() -> list[str]: + configured = os.getenv("OCI_CODE_ALLOWED_EGRESS", "") + if not configured.strip(): + return list(DEFAULT_ALLOWED_EGRESS) + return [item.strip() for item in configured.split(",") if item.strip()] + + +def build_execution_request( + *, + code: str, + input_data: dict[str, Any] | None, + profile_name: str | None, + snapshot_name: str | None, + timeout_seconds: int, + memory_limit_mib: int, +) -> ExecutionRequest: + resolved_profile = _default_profile_name(profile_name) + resolved_snapshot = snapshot_name or os.getenv("OCI_CODE_SNAPSHOT_NAME", DEFAULT_SNAPSHOT_NAME) + return ExecutionRequest( + request_id=uuid.uuid4().hex, + code=code, + input_data=input_data, + profile_name=resolved_profile, + snapshot_name=resolved_snapshot, + auth_bundle=collect_auth_bundle(resolved_profile), + allowed_egress=default_allowed_egress(), + limits=ExecutionLimits( + timeout_seconds=timeout_seconds, + memory_limit_mib=memory_limit_mib, + ), + ) + + +class FirecrackerCommandExecutor: + """Host-side adapter that delegates microVM work to an external runner command.""" + + executor_name = "firecracker-command" + + def __init__( + self, + runner_command: str | None = None, + *, + host_timeout_buffer_seconds: int = 5, + ) -> None: + self.runner_command = runner_command or os.getenv("OCI_CODE_FIRECRACKER_RUNNER_CMD", "").strip() + self.host_timeout_buffer_seconds = host_timeout_buffer_seconds + + def _manifest(self, request: ExecutionRequest, result_path: Path) -> dict[str, Any]: + return { + "schema_version": 1, + "request_id": request.request_id, + "snapshot_name": request.snapshot_name, + "resume_snapshot": True, + "destroy_after_request": True, + "allowed_egress": request.allowed_egress, + "limits": asdict(request.limits), + "auth": request.auth_bundle, + "code": request.code, + "input": request.input_data, + "result_path": str(result_path), + "vm_id": f"oci-code-{request.request_id}", + "guest_entrypoint": "python -m oracle.oci_code_mcp_server.guest_runner --manifest ", + } + + def execute(self, request: ExecutionRequest) -> ExecutionResult: + if not self.runner_command: + raise SandboxExecutionError( + "OCI_CODE_FIRECRACKER_RUNNER_CMD is not configured; refusing to execute code without a sandbox" + ) + + command = shlex.split(self.runner_command) + if not command: + raise SandboxExecutionError("OCI_CODE_FIRECRACKER_RUNNER_CMD is empty") + + with tempfile.TemporaryDirectory(prefix="oci-code-host-") as temp_dir: + temp_path = Path(temp_dir) + manifest_path = temp_path / "request.json" + result_path = temp_path / "result.json" + manifest = self._manifest(request, result_path) + manifest_path.write_text(json.dumps(manifest)) + + started_at = time.monotonic() + try: + completed = subprocess.run( + [*command, "--manifest", str(manifest_path)], + capture_output=True, + text=True, + check=False, + shell=False, + timeout=request.limits.timeout_seconds + self.host_timeout_buffer_seconds, + ) + except subprocess.TimeoutExpired as exc: + raise SandboxExecutionError("Firecracker runner exceeded the host timeout buffer") from exc + + payload: dict[str, Any] | None = None + if result_path.exists() or completed.stdout.strip(): + payload = _load_result_payload(result_path, completed.stdout) + + if completed.returncode != 0: + if payload and not payload.get("ok"): + error = payload.get("error", {}) + error_type = error.get("type", "ExecutionError") + error_message = error.get("message", "unknown sandbox error") + raise SandboxExecutionError(f"{error_type}: {error_message}") + stderr = (completed.stderr or "").strip() + raise SandboxExecutionError( + f"Firecracker runner failed with exit code {completed.returncode}: {stderr or 'no stderr'}" + ) + + if payload is None: + raise SandboxExecutionError("Firecracker runner finished without producing a result payload") + duration_ms = int((time.monotonic() - started_at) * 1000) + if not payload.get("ok"): + error = payload.get("error", {}) + error_type = error.get("type", "ExecutionError") + error_message = error.get("message", "unknown sandbox error") + raise SandboxExecutionError(f"{error_type}: {error_message}") + + return ExecutionResult( + request_id=request.request_id, + result=payload.get("result"), + snapshot_name=request.snapshot_name, + resumed_from_snapshot=bool(payload.get("resumed_from_snapshot", True)), + vm_id=payload.get("vm_id"), + execution_time_ms=duration_ms, + executor_name=self.executor_name, + ) + + +def _load_result_payload(result_path: Path, stdout: str) -> dict[str, Any]: + if result_path.exists(): + return json.loads(result_path.read_text()) + if stdout.strip(): + return json.loads(stdout) + raise SandboxExecutionError("Firecracker runner finished without producing a result payload") diff --git a/src/oci-code-mcp-server/oracle/oci_code_mcp_server/guest_runner.py b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/guest_runner.py new file mode 100644 index 00000000..53c72d28 --- /dev/null +++ b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/guest_runner.py @@ -0,0 +1,206 @@ +""" +Copyright (c) 2026, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. +""" + +from __future__ import annotations + +import argparse +import contextlib +import inspect +import io +import json +import os +import tempfile +import traceback +from pathlib import Path +from typing import Any, Sequence + +import oci + +from .policy import CodePolicyError, make_restricted_builtins, validate_user_code + + +def _serialize_value(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + return {str(key): _serialize_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set, frozenset)): + return [_serialize_value(item) for item in value] + if isinstance(value, Path): + return str(value) + + try: + converted = oci.util.to_dict(value) + except Exception: + converted = value + + if converted is not value: + return _serialize_value(converted) + + try: + json.dumps(value) + return value + except TypeError: + return str(value) + + +def load_oci_config() -> dict[str, Any]: + return oci.config.from_file( + file_location=os.getenv("OCI_CONFIG_FILE", oci.config.DEFAULT_LOCATION), + profile_name=os.getenv("OCI_CONFIG_PROFILE", oci.config.DEFAULT_PROFILE), + ) + + +def build_oci_signer(config: dict[str, Any] | None = None) -> Any: + cfg = config or load_oci_config() + private_key = oci.signer.load_private_key_from_file(cfg["key_file"]) + token_file = os.path.expanduser(cfg.get("security_token_file", "") or "") + + if token_file and os.path.exists(token_file): + token = Path(token_file).read_text() + return oci.auth.signers.SecurityTokenSigner(token, private_key) + + return oci.signer.Signer( + tenancy=cfg["tenancy"], + user=cfg["user"], + fingerprint=cfg["fingerprint"], + private_key_file_location=cfg["key_file"], + pass_phrase=cfg.get("pass_phrase"), + ) + + +def create_oci_client(client_class: type[Any], config: dict[str, Any] | None = None) -> Any: + cfg = config or load_oci_config() + signer = build_oci_signer(cfg) + return client_class(cfg, signer=signer) + + +def _install_auth_bundle(auth_bundle: dict[str, Any], workdir: Path) -> None: + profile_name = auth_bundle.get("profile_name", oci.config.DEFAULT_PROFILE) + config_values = dict(auth_bundle.get("config", {})) + key_file = workdir / "oci_api_key.pem" + key_file.write_text(auth_bundle["key_pem"]) + config_values["key_file"] = str(key_file) + + token = auth_bundle.get("security_token") + if token: + token_file = workdir / "security_token" + token_file.write_text(token) + config_values["security_token_file"] = str(token_file) + + config_path = workdir / "config" + lines = [f"[{profile_name}]"] + for key, value in sorted(config_values.items()): + lines.append(f"{key}={value}") + config_path.write_text("\n".join(lines) + "\n") + + os.environ["OCI_CONFIG_FILE"] = str(config_path) + os.environ["OCI_CONFIG_PROFILE"] = profile_name + + +def _invoke_main(entrypoint: Any, input_data: dict[str, Any] | None) -> Any: + if inspect.iscoroutinefunction(entrypoint): + raise CodePolicyError("async main is not supported") + + signature = inspect.signature(entrypoint) + positional_params = [ + parameter + for parameter in signature.parameters.values() + if parameter.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + required_params = [ + parameter for parameter in positional_params if parameter.default is inspect.Parameter.empty + ] + + if len(positional_params) == 0: + return entrypoint() + if len(required_params) <= 1: + return entrypoint(input_data) + + raise CodePolicyError("main must accept zero or one positional argument") + + +def execute_user_code(code: str, input_data: dict[str, Any] | None) -> dict[str, Any]: + validate_user_code(code) + + namespace = { + "__builtins__": make_restricted_builtins(), + "__name__": "__main__", + "INPUT": input_data, + "build_oci_signer": build_oci_signer, + "create_oci_client": create_oci_client, + "load_oci_config": load_oci_config, + } + + stdout_buffer = io.StringIO() + with contextlib.redirect_stdout(stdout_buffer): + compiled = compile(code, "", "exec") + exec(compiled, namespace, namespace) + if callable(namespace.get("main")): + raw_result = _invoke_main(namespace["main"], input_data) + else: + raw_result = namespace.get("result") + + return { + "result": _serialize_value(raw_result), + "stdout": stdout_buffer.getvalue(), + } + + +def run_manifest(manifest: dict[str, Any]) -> dict[str, Any]: + request_id = manifest["request_id"] + + try: + with tempfile.TemporaryDirectory(prefix="oci-code-guest-") as temp_dir: + workdir = Path(temp_dir) + auth_bundle = manifest.get("auth") + if auth_bundle: + _install_auth_bundle(auth_bundle, workdir) + + execution = execute_user_code(manifest["code"], manifest.get("input")) + + return { + "ok": True, + "request_id": request_id, + "result": execution["result"], + "guest_stdout": execution["stdout"][-4_096:], + "vm_id": manifest.get("vm_id"), + "resumed_from_snapshot": bool(manifest.get("resume_snapshot", True)), + } + except Exception as exc: + return { + "ok": False, + "request_id": request_id, + "error": { + "type": type(exc).__name__, + "message": str(exc), + }, + "traceback": traceback.format_exc(limit=8), + "vm_id": manifest.get("vm_id"), + "resumed_from_snapshot": bool(manifest.get("resume_snapshot", True)), + } + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Execute OCI code inside the guest sandbox") + parser.add_argument("--manifest", required=True, help="Path to the JSON execution manifest") + args = parser.parse_args(argv) + + manifest_path = Path(args.manifest) + manifest = json.loads(manifest_path.read_text()) + result = run_manifest(manifest) + + result_path = manifest.get("result_path") + if result_path: + Path(result_path).write_text(json.dumps(result)) + else: + print(json.dumps(result)) + + return 0 if result.get("ok") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/oci-code-mcp-server/oracle/oci_code_mcp_server/policy.py b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/policy.py new file mode 100644 index 00000000..85123811 --- /dev/null +++ b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/policy.py @@ -0,0 +1,180 @@ +""" +Copyright (c) 2026, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. +""" + +from __future__ import annotations + +import ast +import builtins +from typing import Any, Final + +MAX_CODE_BYTES: Final[int] = 64_000 + +SAFE_IMPORT_ROOTS: Final[set[str]] = { + "base64", + "collections", + "dataclasses", + "datetime", + "decimal", + "enum", + "fractions", + "functools", + "itertools", + "json", + "math", + "oci", + "re", + "statistics", + "time", + "typing", + "uuid", +} + +BANNED_CALLS: Final[set[str]] = { + "__import__", + "breakpoint", + "compile", + "delattr", + "eval", + "exec", + "getattr", + "globals", + "help", + "input", + "locals", + "open", + "setattr", + "vars", +} + +BANNED_NAME_REFERENCES: Final[set[str]] = { + "__import__", + "builtins", + "compile", + "ctypes", + "delattr", + "eval", + "exec", + "getattr", + "globals", + "help", + "importlib", + "input", + "locals", + "open", + "os", + "pathlib", + "resource", + "setattr", + "shutil", + "socket", + "subprocess", + "sys", + "vars", +} + +BANNED_ATTR_NAMES: Final[set[str]] = { + "__bases__", + "__class__", + "__code__", + "__dict__", + "__globals__", + "__mro__", + "__subclasses__", +} + + +class CodePolicyError(ValueError): + """Raised when a snippet violates the defense-in-depth policy.""" + + +class CodePolicyVisitor(ast.NodeVisitor): + """Apply a narrow AST policy before code reaches the sandbox.""" + + def __init__(self) -> None: + self.violations: list[str] = [] + + def _add_violation(self, node: ast.AST, message: str) -> None: + line = getattr(node, "lineno", "?") + self.violations.append(f"line {line}: {message}") + + def visit_Import(self, node: ast.Import) -> Any: + for alias in node.names: + module_root = alias.name.split(".", 1)[0] + if module_root not in SAFE_IMPORT_ROOTS: + self._add_violation(node, f"Import '{alias.name}' is not allowed") + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> Any: + if node.level: + self._add_violation(node, "Relative imports are not allowed") + module_root = (node.module or "").split(".", 1)[0] + if module_root not in SAFE_IMPORT_ROOTS: + self._add_violation(node, f"Import '{node.module}' is not allowed") + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> Any: + if isinstance(node.func, ast.Name) and node.func.id in BANNED_CALLS: + self._add_violation(node, f"Call to '{node.func.id}' is not allowed") + if isinstance(node.func, ast.Attribute) and node.func.attr in BANNED_ATTR_NAMES: + self._add_violation(node, f"Reflective access to '{node.func.attr}' is not allowed") + self.generic_visit(node) + + def visit_Attribute(self, node: ast.Attribute) -> Any: + if node.attr.startswith("__") or node.attr in BANNED_ATTR_NAMES: + self._add_violation(node, f"Attribute '{node.attr}' is not allowed") + self.generic_visit(node) + + def visit_Name(self, node: ast.Name) -> Any: + if isinstance(node.ctx, ast.Load) and node.id in BANNED_NAME_REFERENCES: + self._add_violation(node, f"Name '{node.id}' is not allowed") + self.generic_visit(node) + + +def _has_supported_entrypoint(tree: ast.Module) -> bool: + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "main": + return True + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "result": + return True + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + if node.target.id == "result": + return True + return False + + +def validate_user_code(code: str) -> ast.Module: + if not isinstance(code, str): + raise CodePolicyError("code must be a string") + if not code.strip(): + raise CodePolicyError("code must not be empty") + if len(code.encode("utf-8")) > MAX_CODE_BYTES: + raise CodePolicyError(f"code exceeds the maximum size of {MAX_CODE_BYTES} bytes") + + try: + tree = ast.parse(code, mode="exec") + except SyntaxError as exc: + raise CodePolicyError(f"Invalid Python syntax: {exc.msg} (line {exc.lineno})") from exc + + visitor = CodePolicyVisitor() + visitor.visit(tree) + if visitor.violations: + raise CodePolicyError("; ".join(visitor.violations)) + + if not _has_supported_entrypoint(tree): + raise CodePolicyError("code must define main(input_data) or assign a top-level result") + + return tree + + +def make_restricted_builtins() -> dict[str, Any]: + removed = (BANNED_CALLS - {"__import__"}) | {"exit", "license", "quit"} + return { + name: value + for name, value in builtins.__dict__.items() + if name not in removed + } diff --git a/src/oci-code-mcp-server/oracle/oci_code_mcp_server/runner.py b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/runner.py new file mode 100644 index 00000000..446ac6a9 --- /dev/null +++ b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/runner.py @@ -0,0 +1,530 @@ +""" +Copyright (c) 2026, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Sequence + +from .guest_runner import run_manifest + + +class ManifestValidationError(ValueError): + """Raised when the host manifest is malformed.""" + + +class RunnerConfigurationError(RuntimeError): + """Raised when the runner backend is not configured correctly.""" + + +@dataclass(slots=True) +class RunnerManifest: + schema_version: int + request_id: str + snapshot_name: str + allowed_egress: list[str] + limits: dict[str, Any] + code: str + input_data: Any + auth: dict[str, Any] | None + result_path: str | None + vm_id: str | None + resume_snapshot: bool + destroy_after_request: bool + guest_entrypoint: str | None + + +def _read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text()) + except FileNotFoundError as exc: + raise ManifestValidationError(f"Manifest file does not exist: {path}") from exc + except json.JSONDecodeError as exc: + raise ManifestValidationError(f"Manifest file is not valid JSON: {exc}") from exc + + if not isinstance(payload, dict): + raise ManifestValidationError("Manifest root must be a JSON object") + return payload + + +def _require_type(payload: dict[str, Any], key: str, expected_type: type | tuple[type, ...]) -> Any: + if key not in payload: + raise ManifestValidationError(f"Manifest is missing required field '{key}'") + value = payload[key] + if not isinstance(value, expected_type): + expected_name = ( + ", ".join(item.__name__ for item in expected_type) + if isinstance(expected_type, tuple) + else expected_type.__name__ + ) + raise ManifestValidationError(f"Manifest field '{key}' must be of type {expected_name}") + return value + + +def load_manifest(path: str | Path) -> RunnerManifest: + payload = _read_json(Path(path)) + schema_version = _require_type(payload, "schema_version", int) + request_id = _require_type(payload, "request_id", str) + snapshot_name = _require_type(payload, "snapshot_name", str) + allowed_egress = _require_type(payload, "allowed_egress", list) + limits = _require_type(payload, "limits", dict) + code = _require_type(payload, "code", str) + + input_data = payload.get("input") + auth = payload.get("auth") + result_path = payload.get("result_path") + vm_id = payload.get("vm_id") + resume_snapshot = bool(payload.get("resume_snapshot", True)) + destroy_after_request = bool(payload.get("destroy_after_request", True)) + guest_entrypoint = payload.get("guest_entrypoint") + + if schema_version != 1: + raise ManifestValidationError(f"Unsupported manifest schema version: {schema_version}") + if not request_id.strip(): + raise ManifestValidationError("Manifest field 'request_id' must not be empty") + if not all(isinstance(item, str) and item.strip() for item in allowed_egress): + raise ManifestValidationError("Manifest field 'allowed_egress' must contain non-empty strings") + if not isinstance(result_path, (str, type(None))): + raise ManifestValidationError("Manifest field 'result_path' must be a string when provided") + if auth is not None and not isinstance(auth, dict): + raise ManifestValidationError("Manifest field 'auth' must be an object when provided") + + return RunnerManifest( + schema_version=schema_version, + request_id=request_id, + snapshot_name=snapshot_name, + allowed_egress=allowed_egress, + limits=limits, + code=code, + input_data=input_data, + auth=auth, + result_path=result_path, + vm_id=vm_id, + resume_snapshot=resume_snapshot, + destroy_after_request=destroy_after_request, + guest_entrypoint=guest_entrypoint, + ) + + +def backend_name(cli_backend: str | None = None) -> str: + return (cli_backend or os.getenv("OCI_CODE_RUNNER_BACKEND", "delegate")).strip().lower() + + +def _run_subprocess(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + capture_output=True, + text=True, + check=False, + shell=False, + ) + + +def _delegate_command() -> list[str]: + command = os.getenv("OCI_CODE_FIRECRACKER_DELEGATE_CMD", "").strip() + if not command: + raise RunnerConfigurationError( + "OCI_CODE_FIRECRACKER_DELEGATE_CMD is required when OCI_CODE_RUNNER_BACKEND=delegate" + ) + parts = shlex.split(command) + if not parts: + raise RunnerConfigurationError("OCI_CODE_FIRECRACKER_DELEGATE_CMD is empty") + return parts + + +def _payload_to_manifest_dict(manifest: RunnerManifest) -> dict[str, Any]: + payload = { + "schema_version": manifest.schema_version, + "request_id": manifest.request_id, + "snapshot_name": manifest.snapshot_name, + "allowed_egress": manifest.allowed_egress, + "limits": manifest.limits, + "code": manifest.code, + "input": manifest.input_data, + "resume_snapshot": manifest.resume_snapshot, + "destroy_after_request": manifest.destroy_after_request, + } + if manifest.auth is not None: + payload["auth"] = manifest.auth + if manifest.result_path is not None: + payload["result_path"] = manifest.result_path + if manifest.vm_id is not None: + payload["vm_id"] = manifest.vm_id + if manifest.guest_entrypoint is not None: + payload["guest_entrypoint"] = manifest.guest_entrypoint + return payload + + +def execute_emulator_backend(manifest: RunnerManifest) -> dict[str, Any]: + return run_manifest(_payload_to_manifest_dict(manifest)) + + +def execute_delegate_backend(manifest_path: Path) -> tuple[int, str, str]: + command = _delegate_command() + completed = _run_subprocess([*command, "--manifest", str(manifest_path)]) + return completed.returncode, completed.stdout, completed.stderr + + +def _limactl_binary() -> str: + return os.getenv("OCI_CODE_LIMACTL_BIN", "limactl").strip() or "limactl" + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _scripts_dir() -> Path: + return _repo_root() / "scripts" + + +def _lima_instance_name() -> str: + return os.getenv("OCI_CODE_LIMA_INSTANCE", "firecracker-dev").strip() or "firecracker-dev" + + +def _lima_template() -> str: + return os.getenv("OCI_CODE_LIMA_START_TEMPLATE", "template:default").strip() or "template:default" + + +def _lima_vm_type() -> str: + return os.getenv("OCI_CODE_LIMA_VM_TYPE", "vz").strip() or "vz" + + +def _lima_start_timeout() -> str: + return os.getenv("OCI_CODE_LIMA_START_TIMEOUT", "10m").strip() or "10m" + + +def _lima_copy_backend() -> str: + return os.getenv("OCI_CODE_LIMA_COPY_BACKEND", "auto").strip() or "auto" + + +def _lima_guest_root(request_id: str) -> str: + root = os.getenv("OCI_CODE_LIMA_GUEST_ROOT", "/tmp/oci-code-mcp").strip() or "/tmp/oci-code-mcp" + return f"{root.rstrip('/')}/{request_id}" + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _lima_instance_dir() -> Path: + lima_home = Path(os.getenv("LIMA_HOME", str(Path.home() / ".lima"))) + return lima_home / _lima_instance_name() + + +def _shell_assignment(name: str, value: str) -> str: + return f"{name}={shlex.quote(value)}" + + +def _rewrite_manifest_for_guest(manifest_path: Path, guest_result_path: str) -> dict[str, Any]: + payload = _read_json(manifest_path) + payload["result_path"] = guest_result_path + return payload + + +def _build_lima_stage_bundle(manifest_path: Path, manifest: RunnerManifest) -> tuple[tempfile.TemporaryDirectory[str], Path, str]: + temp_dir = tempfile.TemporaryDirectory(prefix="oci-code-lima-") + host_bundle_root = Path(temp_dir.name) / manifest.request_id + host_stage_dir = host_bundle_root / "stage" + host_project_dir = host_bundle_root / "project" + host_scripts_dir = host_project_dir / "scripts" + guest_root = _lima_guest_root(manifest.request_id) + guest_result_path = f"{guest_root}/stage/result.json" + + host_stage_dir.mkdir(parents=True) + host_project_dir.mkdir(parents=True) + host_scripts_dir.mkdir(parents=True) + + rewritten_manifest = _rewrite_manifest_for_guest(manifest_path, guest_result_path) + (host_stage_dir / "request.json").write_text(json.dumps(rewritten_manifest, sort_keys=True, indent=2)) + + shutil.copytree(_repo_root() / "oracle", host_project_dir / "oracle", dirs_exist_ok=True) + shutil.copy2(_scripts_dir() / "oci-code-lima-guest-runner.sh", host_scripts_dir / "oci-code-lima-guest-runner.sh") + + return temp_dir, host_bundle_root, guest_root + + +def _ensure_lima_instance_running() -> tuple[int, str, str]: + limactl = _limactl_binary() + instance = _lima_instance_name() + timeout = _lima_start_timeout() + + if _lima_instance_dir().exists(): + completed = _run_subprocess([limactl, "start", "-y", "--timeout", timeout, instance]) + return completed.returncode, completed.stdout, completed.stderr + + command = [ + limactl, + "start", + "-y", + "--timeout", + timeout, + "--vm-type", + _lima_vm_type(), + "--name", + instance, + ] + if _bool_env("OCI_CODE_LIMA_ENABLE_NESTED_VIRT", True): + command.append("--nested-virt") + command.append(_lima_template()) + completed = _run_subprocess(command) + return completed.returncode, completed.stdout, completed.stderr + + +def _lima_kvm_preflight_command(instance: str) -> list[str]: + shell_command = ( + "test -c /dev/kvm || " + "{ echo '/dev/kvm is not available inside the Lima guest; nested virtualization is not active.' >&2; exit 42; }" + ) + return [_limactl_binary(), "shell", "--start", instance, "sh", "-lc", shell_command] + + +def _lima_guest_shell_command(guest_root: str) -> str: + exports: list[str] = [] + + optional_names = ( + "OCI_CODE_LIMA_GUEST_FIRECRACKER_CMD", + "OCI_CODE_LIMA_GUEST_VENV_ROOT", + "OCI_CODE_LIMA_GUEST_OCI_PIP_SPEC", + ) + for name in optional_names: + value = os.getenv(name, "").strip() + if value: + exports.append(_shell_assignment(name, value)) + + guest_script = f"{guest_root}/project/scripts/oci-code-lima-guest-runner.sh" + return " && ".join( + [ + *(f"export {assignment}" for assignment in exports), + f"bash {shlex.quote(guest_script)} {shlex.quote(guest_root)}", + ] + ) + + +def execute_lima_backend(manifest_path: Path, manifest: RunnerManifest) -> tuple[int, str, str]: + limactl = _limactl_binary() + instance = _lima_instance_name() + stdout_chunks: list[str] = [] + stderr_chunks: list[str] = [] + + def record_output(stdout: str, stderr: str) -> None: + if stdout.strip(): + stdout_chunks.append(stdout.strip()) + if stderr.strip(): + stderr_chunks.append(stderr.strip()) + + start_code, start_stdout, start_stderr = _ensure_lima_instance_running() + record_output(start_stdout, start_stderr) + if start_code != 0: + return start_code, "\n".join(stdout_chunks), "\n".join(stderr_chunks) + + kvm_check = _run_subprocess(_lima_kvm_preflight_command(instance)) + record_output(kvm_check.stdout, kvm_check.stderr) + if kvm_check.returncode != 0: + return kvm_check.returncode, "\n".join(stdout_chunks), "\n".join(stderr_chunks) + + temp_dir, host_bundle_root, guest_root = _build_lima_stage_bundle(manifest_path, manifest) + guest_parent = str(Path(guest_root).parent) + guest_result_path = f"{guest_root}/stage/result.json" + + try: + prepare_guest = _run_subprocess( + [ + limactl, + "shell", + "--start", + instance, + "sh", + "-lc", + f"rm -rf {shlex.quote(guest_root)} && mkdir -p {shlex.quote(guest_parent)}", + ] + ) + record_output(prepare_guest.stdout, prepare_guest.stderr) + if prepare_guest.returncode != 0: + return prepare_guest.returncode, "\n".join(stdout_chunks), "\n".join(stderr_chunks) + + copy_in = _run_subprocess( + [ + limactl, + "copy", + "--backend", + _lima_copy_backend(), + "-r", + str(host_bundle_root), + f"{instance}:{guest_parent}/", + ] + ) + record_output(copy_in.stdout, copy_in.stderr) + if copy_in.returncode != 0: + return copy_in.returncode, "\n".join(stdout_chunks), "\n".join(stderr_chunks) + + guest_run = _run_subprocess( + [ + limactl, + "shell", + "--start", + instance, + "sh", + "-lc", + _lima_guest_shell_command(guest_root), + ] + ) + record_output(guest_run.stdout, guest_run.stderr) + + local_result_path: Path + capture_stdout_from_result = False + if manifest.result_path: + local_result_path = Path(manifest.result_path) + else: + local_result_path = Path(temp_dir.name) / "result.json" + capture_stdout_from_result = True + + copy_out = _run_subprocess( + [ + limactl, + "copy", + "--backend", + _lima_copy_backend(), + f"{instance}:{guest_result_path}", + str(local_result_path), + ] + ) + record_output(copy_out.stdout, copy_out.stderr) + + if capture_stdout_from_result and local_result_path.exists(): + stdout_chunks.append(local_result_path.read_text()) + + if guest_run.returncode != 0: + return guest_run.returncode, "\n".join(stdout_chunks), "\n".join(stderr_chunks) + if copy_out.returncode != 0: + return copy_out.returncode, "\n".join(stdout_chunks), "\n".join(stderr_chunks) + + return 0, "\n".join(stdout_chunks), "\n".join(stderr_chunks) + finally: + if not _bool_env("OCI_CODE_LIMA_KEEP_GUEST_BUNDLE", False): + cleanup = _run_subprocess( + [ + limactl, + "shell", + "--start", + instance, + "sh", + "-lc", + f"rm -rf {shlex.quote(guest_root)}", + ] + ) + record_output(cleanup.stdout, cleanup.stderr) + temp_dir.cleanup() + + +def _failure_payload(request_id: str, error_type: str, message: str) -> dict[str, Any]: + return { + "ok": False, + "request_id": request_id, + "error": { + "type": error_type, + "message": message, + }, + } + + +def emit_result(manifest: RunnerManifest, payload: dict[str, Any]) -> None: + serialized = json.dumps(payload) + if manifest.result_path: + Path(manifest.result_path).write_text(serialized) + else: + print(serialized) + + +def _finalize_proxy_backend_result( + manifest: RunnerManifest, returncode: int, stdout: str, stderr: str, *, backend_name: str +) -> int: + if returncode == 0: + if not manifest.result_path and stdout.strip(): + sys.stdout.write(stdout) + return 0 + + if manifest.result_path and Path(manifest.result_path).exists(): + return returncode + + payload = _failure_payload( + manifest.request_id, + f"{backend_name.title()}ExecutionError", + stderr.strip() or stdout.strip() or f"{backend_name} backend exited with status {returncode}", + ) + emit_result(manifest, payload) + return returncode + + +def run_runner(manifest_path: Path, *, cli_backend: str | None = None) -> int: + manifest = load_manifest(manifest_path) + backend = backend_name(cli_backend) + + if backend == "emulator": + payload = execute_emulator_backend(manifest) + emit_result(manifest, payload) + return 0 if payload.get("ok") else 1 + + if backend == "lima": + returncode, stdout, stderr = execute_lima_backend(manifest_path, manifest) + return _finalize_proxy_backend_result( + manifest, returncode, stdout, stderr, backend_name="lima" + ) + + if backend == "delegate": + returncode, stdout, stderr = execute_delegate_backend(manifest_path) + return _finalize_proxy_backend_result( + manifest, returncode, stdout, stderr, backend_name="delegate" + ) + + raise RunnerConfigurationError( + f"Unsupported OCI_CODE_RUNNER_BACKEND '{backend}'. Expected 'delegate', 'emulator', or 'lima'." + ) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Manifest-driven OCI code Firecracker runner wrapper") + parser.add_argument("--manifest", required=True, help="Path to the JSON manifest created by the MCP host") + parser.add_argument( + "--backend", + choices=("delegate", "emulator", "lima"), + default=None, + help="Optional backend override. Defaults to OCI_CODE_RUNNER_BACKEND or 'delegate'.", + ) + args = parser.parse_args(argv) + + try: + return run_runner(Path(args.manifest), cli_backend=args.backend) + except (ManifestValidationError, RunnerConfigurationError) as exc: + request_id = "unknown-request" + try: + raw_payload = _read_json(Path(args.manifest)) + request_id = str(raw_payload.get("request_id", request_id)) + result_path = raw_payload.get("result_path") + if isinstance(result_path, str) and result_path: + Path(result_path).write_text( + json.dumps(_failure_payload(request_id, type(exc).__name__, str(exc))) + ) + else: + print(json.dumps(_failure_payload(request_id, type(exc).__name__, str(exc)))) + except Exception: + print(json.dumps(_failure_payload(request_id, type(exc).__name__, str(exc)))) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/oci-code-mcp-server/oracle/oci_code_mcp_server/server.py b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/server.py new file mode 100644 index 00000000..f7245fd3 --- /dev/null +++ b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/server.py @@ -0,0 +1,118 @@ +""" +Copyright (c) 2026, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. +""" + +from __future__ import annotations + +import os +from logging import Logger +from typing import Any + +from fastmcp import FastMCP +from fastmcp.exceptions import ToolError +from pydantic import Field + +from . import __project__ +from .executor import FirecrackerCommandExecutor, SandboxExecutionError, build_execution_request +from .policy import CodePolicyError, validate_user_code + +logger = Logger(__name__, level="INFO") + +mcp = FastMCP( + name=__project__, + instructions=""" + This server executes OCI Python SDK snippets inside an ephemeral Firecracker-backed sandbox. + Use execute_oci_python for code that must call the OCI Python SDK. + The code must define main(input_data) or assign a top-level result. + The host never executes arbitrary user code in-process. + """, +) + + +@mcp.resource("resource://oci-code-execution-contract") +def code_execution_contract() -> str: + return """ +OCI code execution contract: +- Define main(input_data) and return a JSON-serializable value, or assign a top-level result. +- INPUT is injected as a convenience alias for input_data. +- Guest helpers: load_oci_config(), build_oci_signer(), create_oci_client(SomeClient) +- Only a narrow stdlib import allowlist is accepted; os/subprocess/socket/importlib and reflective dunder access are blocked. +- The runner must execute inside a Firecracker microVM and destroy the VM after the request. +""".strip() + + +def get_executor() -> FirecrackerCommandExecutor: + return FirecrackerCommandExecutor() + + +@mcp.tool( + description="Execute restricted Python against the OCI Python SDK inside an ephemeral Firecracker sandbox" +) +def execute_oci_python( + code: str = Field( + ..., + description=( + "Python source code. Define main(input_data) or assign a top-level result. " + "The guest also provides INPUT, load_oci_config(), build_oci_signer(), " + "and create_oci_client(SomeClient)." + ), + ), + input_data: dict[str, Any] | None = Field( + None, + description="Optional JSON object passed to main(input_data) and exposed as INPUT.", + ), + timeout_seconds: int = Field( + 30, + ge=1, + le=120, + description="Maximum guest runtime in seconds.", + ), + memory_limit_mib: int = Field( + 512, + ge=128, + le=8192, + description="Maximum guest memory in MiB.", + ), + snapshot_name: str | None = Field( + None, + description="Optional Firecracker snapshot label. Defaults to OCI_CODE_SNAPSHOT_NAME.", + ), + profile_name: str | None = Field( + None, + description="Optional OCI config profile to serialize into the guest.", + ), +) -> dict[str, Any]: + try: + validate_user_code(code) + request = build_execution_request( + code=code, + input_data=input_data, + profile_name=profile_name, + snapshot_name=snapshot_name, + timeout_seconds=timeout_seconds, + memory_limit_mib=memory_limit_mib, + ) + result = get_executor().execute(request) + return result.to_response() + except CodePolicyError as exc: + logger.error("Code policy rejected execution: %s", exc) + raise ToolError(f"Code policy rejected execution: {exc}") from exc + except SandboxExecutionError as exc: + logger.error("Sandbox execution failed: %s", exc) + raise ToolError(f"Sandbox execution failed: {exc}") from exc + + +def main() -> None: + host = os.getenv("ORACLE_MCP_HOST") + port = os.getenv("ORACLE_MCP_PORT") + + if host and port: + mcp.run(transport="http", host=host, port=int(port)) + else: + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/src/oci-code-mcp-server/oracle/oci_code_mcp_server/tests/test_runner.py b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/tests/test_runner.py new file mode 100644 index 00000000..bca7b8ac --- /dev/null +++ b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/tests/test_runner.py @@ -0,0 +1,526 @@ +""" +Copyright (c) 2026, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from oracle.oci_code_mcp_server.runner import ( + ManifestValidationError, + RunnerManifest, + RunnerConfigurationError, + _bool_env, + _delegate_command, + _ensure_lima_instance_running, + _finalize_proxy_backend_result, + _limactl_binary, + _lima_copy_backend, + _lima_guest_root, + _lima_guest_shell_command, + _lima_instance_name, + _lima_start_timeout, + _lima_template, + _lima_vm_type, + _payload_to_manifest_dict, + _read_json, + _require_type, + _shell_assignment, + backend_name, + emit_result, + execute_delegate_backend, + execute_lima_backend, + load_manifest, + main, + run_runner, +) + + +def _manifest_payload(result_path: str | None = None) -> dict: + return { + "schema_version": 1, + "request_id": "req-test", + "snapshot_name": "snapshot-a", + "resume_snapshot": True, + "destroy_after_request": True, + "allowed_egress": ["*.oraclecloud.com"], + "limits": { + "timeout_seconds": 30, + "memory_limit_mib": 512, + "vcpu_count": 1, + "max_result_bytes": 262144, + }, + "auth": { + "profile_name": "DEFAULT", + "config": {}, + "key_pem": "PRIVATE KEY", + "security_token": None, + }, + "code": "result = {'ok': True}", + "input": {"value": 1}, + "result_path": result_path, + "vm_id": "vm-1", + "guest_entrypoint": "python -m oracle.oci_code_mcp_server.guest_runner --manifest ", + } + + +def _completed(returncode: int = 0, stdout: str = "", stderr: str = "") -> SimpleNamespace: + return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr) + + +class TestLoadManifest: + def test_load_manifest_validates_required_fields(self, tmp_path: Path): + manifest_path = tmp_path / "request.json" + manifest_path.write_text(json.dumps({"schema_version": 1})) + + with pytest.raises(ManifestValidationError): + load_manifest(manifest_path) + + def test_load_manifest_rejects_bad_shapes_and_helper_errors(self, tmp_path: Path): + missing_path = tmp_path / "missing.json" + with pytest.raises(ManifestValidationError, match="does not exist"): + _read_json(missing_path) + + invalid_json = tmp_path / "invalid.json" + invalid_json.write_text("{") + with pytest.raises(ManifestValidationError, match="not valid JSON"): + _read_json(invalid_json) + + invalid_root = tmp_path / "invalid-root.json" + invalid_root.write_text("[]") + with pytest.raises(ManifestValidationError, match="root must be a JSON object"): + _read_json(invalid_root) + + with pytest.raises(ManifestValidationError, match="must be of type str"): + _require_type({"request_id": 1}, "request_id", str) + + payload = _manifest_payload() + payload["schema_version"] = 2 + manifest_path = tmp_path / "request.json" + manifest_path.write_text(json.dumps(payload)) + with pytest.raises(ManifestValidationError, match="Unsupported manifest schema version"): + load_manifest(manifest_path) + + payload["schema_version"] = 1 + payload["request_id"] = " " + manifest_path.write_text(json.dumps(payload)) + with pytest.raises(ManifestValidationError, match="must not be empty"): + load_manifest(manifest_path) + + payload["request_id"] = "req-test" + payload["allowed_egress"] = [""] + manifest_path.write_text(json.dumps(payload)) + with pytest.raises(ManifestValidationError, match="allowed_egress"): + load_manifest(manifest_path) + + payload["allowed_egress"] = ["*.oraclecloud.com"] + payload["result_path"] = 123 + manifest_path.write_text(json.dumps(payload)) + with pytest.raises(ManifestValidationError, match="result_path"): + load_manifest(manifest_path) + + payload["result_path"] = None + payload["auth"] = "nope" + manifest_path.write_text(json.dumps(payload)) + with pytest.raises(ManifestValidationError, match="auth"): + load_manifest(manifest_path) + + def test_helper_accessors_and_payload_helpers(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("OCI_CODE_RUNNER_BACKEND", raising=False) + assert backend_name() == "delegate" + assert backend_name(" LiMa ") == "lima" + + monkeypatch.setenv("OCI_CODE_LIMACTL_BIN", " ") + monkeypatch.setenv("OCI_CODE_LIMA_INSTANCE", " ") + monkeypatch.setenv("OCI_CODE_LIMA_START_TEMPLATE", " ") + monkeypatch.setenv("OCI_CODE_LIMA_VM_TYPE", " ") + monkeypatch.setenv("OCI_CODE_LIMA_START_TIMEOUT", " ") + monkeypatch.setenv("OCI_CODE_LIMA_COPY_BACKEND", " ") + monkeypatch.setenv("OCI_CODE_LIMA_GUEST_ROOT", " ") + assert _limactl_binary() == "limactl" + assert _lima_instance_name() == "firecracker-dev" + assert _lima_template() == "template:default" + assert _lima_vm_type() == "vz" + assert _lima_start_timeout() == "10m" + assert _lima_copy_backend() == "auto" + assert _lima_guest_root("req-1") == "/tmp/oci-code-mcp/req-1" + + assert _bool_env("MISSING_BOOL", False) is False + monkeypatch.setenv("TEST_BOOL", "on") + assert _bool_env("TEST_BOOL", False) is True + monkeypatch.setenv("TEST_BOOL", "off") + assert _bool_env("TEST_BOOL", True) is False + assert _shell_assignment("NAME", "value with spaces") == "NAME='value with spaces'" + + minimal = RunnerManifest( + schema_version=1, + request_id="req-1", + snapshot_name="snap", + allowed_egress=["*.oraclecloud.com"], + limits={"timeout_seconds": 30}, + code="result = 1", + input_data={}, + auth=None, + result_path=None, + vm_id=None, + resume_snapshot=True, + destroy_after_request=True, + guest_entrypoint=None, + ) + assert _payload_to_manifest_dict(minimal) == { + "schema_version": 1, + "request_id": "req-1", + "snapshot_name": "snap", + "allowed_egress": ["*.oraclecloud.com"], + "limits": {"timeout_seconds": 30}, + "code": "result = 1", + "input": {}, + "resume_snapshot": True, + "destroy_after_request": True, + } + + +class TestRunnerBackends: + def test_emulator_backend_writes_result_file(self, tmp_path: Path): + result_path = tmp_path / "result.json" + manifest_path = tmp_path / "request.json" + manifest_path.write_text(json.dumps(_manifest_payload(str(result_path)))) + + exit_code = run_runner(manifest_path, cli_backend="emulator") + + assert exit_code == 0 + payload = json.loads(result_path.read_text()) + assert payload["ok"] is True + assert payload["result"] == {"ok": True} + + @patch("oracle.oci_code_mcp_server.runner.subprocess.run") + def test_delegate_backend_invokes_delegate_command(self, mock_run, tmp_path: Path): + manifest_path = tmp_path / "request.json" + manifest_path.write_text(json.dumps(_manifest_payload())) + mock_run.return_value.returncode = 0 + mock_run.return_value.stdout = "" + mock_run.return_value.stderr = "" + + with patch.dict( + "os.environ", + { + "OCI_CODE_FIRECRACKER_DELEGATE_CMD": "/usr/local/bin/real-runner --flag", + }, + clear=False, + ): + execute_delegate_backend(manifest_path) + + mock_run.assert_called_once() + called_args = mock_run.call_args.args[0] + assert called_args[:2] == ["/usr/local/bin/real-runner", "--flag"] + assert called_args[-2:] == ["--manifest", str(manifest_path)] + + @patch("oracle.oci_code_mcp_server.runner._run_subprocess") + @patch("oracle.oci_code_mcp_server.runner._lima_instance_dir") + def test_lima_backend_invokes_limactl_flow(self, mock_instance_dir, mock_run, tmp_path: Path): + manifest_path = tmp_path / "request.json" + manifest_path.write_text(json.dumps(_manifest_payload())) + manifest = load_manifest(manifest_path) + mock_instance_dir.return_value = tmp_path / "missing-instance" + + def completed(returncode: int = 0, stdout: str = "", stderr: str = ""): + class Result: + def __init__(self): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + return Result() + + mock_run.side_effect = [ + completed(), # limactl start + completed(), # kvm preflight + completed(), # guest prep + completed(), # copy in + completed(), # guest run + completed(), # copy out + completed(), # cleanup + ] + + with patch.dict("os.environ", {"OCI_CODE_LIMA_INSTANCE": "firecracker-dev"}, clear=False): + returncode, _, _ = execute_lima_backend(manifest_path, manifest) + + assert returncode == 0 + calls = [call.args[0] for call in mock_run.call_args_list] + assert calls[0][:8] == [ + "limactl", + "start", + "-y", + "--timeout", + "10m", + "--vm-type", + "vz", + "--name", + ] + assert calls[0][-1] == "template:default" + assert "--nested-virt" in calls[0] + assert calls[1][:4] == ["limactl", "shell", "--start", "firecracker-dev"] + assert "/dev/kvm" in calls[1][-1] + assert calls[2][:4] == ["limactl", "shell", "--start", "firecracker-dev"] + assert calls[3][:4] == ["limactl", "copy", "--backend", "auto"] + assert calls[4][:4] == ["limactl", "shell", "--start", "firecracker-dev"] + assert calls[5][:4] == ["limactl", "copy", "--backend", "auto"] + + @patch("oracle.oci_code_mcp_server.runner._run_subprocess") + @patch("oracle.oci_code_mcp_server.runner._lima_instance_dir") + def test_lima_backend_preserves_backend_authored_failure_payload(self, mock_instance_dir, mock_run, tmp_path: Path): + result_path = tmp_path / "result.json" + manifest_path = tmp_path / "request.json" + manifest_path.write_text(json.dumps(_manifest_payload(str(result_path)))) + mock_instance_dir.return_value = tmp_path / "existing-instance" + mock_instance_dir.return_value.mkdir() + + def fake_run(command: list[str]): + class Result: + def __init__(self, returncode: int, stdout: str = "", stderr: str = ""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + if command[:2] == ["limactl", "start"]: + return Result(0) + if command[:2] == ["limactl", "shell"] and "/dev/kvm" in command[-1]: + return Result(0) + if command[:2] == ["limactl", "shell"] and "rm -rf" in command[-1] and "mkdir -p" in command[-1]: + return Result(0) + if command[:2] == ["limactl", "copy"] and command[-1].endswith("/"): + return Result(0) + if command[:2] == ["limactl", "shell"] and "oci-code-lima-guest-runner.sh" in command[-1]: + return Result(1, stderr="guest firecracker delegate failed") + if command[:2] == ["limactl", "copy"] and command[-1] == str(result_path): + result_path.write_text( + json.dumps( + { + "ok": False, + "request_id": "req-test", + "error": {"type": "RunnerError", "message": "firecracker launch failed"}, + } + ) + ) + return Result(0) + if command[:2] == ["limactl", "shell"] and command[-1].startswith("rm -rf"): + return Result(0) + raise AssertionError(f"Unexpected command: {command}") + + with patch.dict("os.environ", {"OCI_CODE_LIMA_INSTANCE": "firecracker-dev"}, clear=False): + mock_run.side_effect = fake_run + exit_code = run_runner(manifest_path, cli_backend="lima") + + assert exit_code == 1 + payload = json.loads(result_path.read_text()) + assert payload["error"]["message"] == "firecracker launch failed" + + def test_delegate_and_lima_helper_paths(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys): + monkeypatch.delenv("OCI_CODE_FIRECRACKER_DELEGATE_CMD", raising=False) + with pytest.raises(RunnerConfigurationError, match="required"): + _delegate_command() + + with ( + patch("oracle.oci_code_mcp_server.runner.shlex.split", return_value=[]), + patch.dict("os.environ", {"OCI_CODE_FIRECRACKER_DELEGATE_CMD": "runner"}, clear=False), + ): + with pytest.raises(RunnerConfigurationError, match="is empty"): + _delegate_command() + + existing_instance = tmp_path / "firecracker-dev" + existing_instance.mkdir() + with ( + patch("oracle.oci_code_mcp_server.runner._lima_instance_dir", return_value=existing_instance), + patch("oracle.oci_code_mcp_server.runner._run_subprocess", return_value=_completed(0, "started", "")) as mock_run, + ): + assert _ensure_lima_instance_running() == (0, "started", "") + assert mock_run.call_args.args[0] == ["limactl", "start", "-y", "--timeout", "10m", "firecracker-dev"] + + missing_instance = tmp_path / "missing" + with ( + patch("oracle.oci_code_mcp_server.runner._lima_instance_dir", return_value=missing_instance), + patch("oracle.oci_code_mcp_server.runner._run_subprocess", return_value=_completed()) as mock_run, + patch.dict( + "os.environ", + { + "OCI_CODE_LIMA_ENABLE_NESTED_VIRT": "false", + "OCI_CODE_LIMA_VM_TYPE": "qemu", + "OCI_CODE_LIMA_START_TEMPLATE": "template://custom", + }, + clear=False, + ), + ): + _ensure_lima_instance_running() + command = mock_run.call_args.args[0] + assert "--nested-virt" not in command + assert command[-1] == "template://custom" + + with patch.dict( + "os.environ", + { + "OCI_CODE_LIMA_GUEST_FIRECRACKER_CMD": "/usr/local/bin/run-fc --flag", + "OCI_CODE_LIMA_GUEST_VENV_ROOT": "/opt/venvs", + "OCI_CODE_LIMA_GUEST_OCI_PIP_SPEC": "oci==2.0.0", + }, + clear=False, + ): + shell_command = _lima_guest_shell_command("/guest/root") + assert "OCI_CODE_LIMA_GUEST_FIRECRACKER_CMD='/usr/local/bin/run-fc --flag'" in shell_command + assert "OCI_CODE_LIMA_GUEST_VENV_ROOT=/opt/venvs" in shell_command + assert "OCI_CODE_LIMA_GUEST_OCI_PIP_SPEC=oci==2.0.0" in shell_command + assert shell_command.endswith("bash /guest/root/project/scripts/oci-code-lima-guest-runner.sh /guest/root") + + manifest = RunnerManifest( + schema_version=1, + request_id="req-stdout", + snapshot_name="snap", + allowed_egress=["*.oraclecloud.com"], + limits={"timeout_seconds": 30}, + code="result = 1", + input_data={}, + auth=None, + result_path=None, + vm_id=None, + resume_snapshot=True, + destroy_after_request=True, + guest_entrypoint=None, + ) + assert _finalize_proxy_backend_result(manifest, 0, '{"ok": true}', "", backend_name="lima") == 0 + assert capsys.readouterr().out == '{"ok": true}' + + assert _finalize_proxy_backend_result(manifest, 7, "stdout detail", "", backend_name="delegate") == 7 + error_payload = json.loads(capsys.readouterr().out) + assert error_payload["error"]["type"] == "DelegateExecutionError" + assert error_payload["error"]["message"] == "stdout detail" + + def test_emit_result_and_run_runner_paths(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + manifest_path = tmp_path / "request.json" + manifest_path.write_text(json.dumps(_manifest_payload())) + + manifest = RunnerManifest( + schema_version=1, + request_id="req-print", + snapshot_name="snap", + allowed_egress=["*.oraclecloud.com"], + limits={"timeout_seconds": 30}, + code="result = 1", + input_data={}, + auth=None, + result_path=None, + vm_id=None, + resume_snapshot=True, + destroy_after_request=True, + guest_entrypoint=None, + ) + emit_result(manifest, {"ok": False, "request_id": "req-print"}) + assert json.loads(capsys.readouterr().out)["request_id"] == "req-print" + + with patch("oracle.oci_code_mcp_server.runner.execute_delegate_backend", return_value=(0, "", "")): + assert run_runner(manifest_path, cli_backend="delegate") == 0 + + with patch("oracle.oci_code_mcp_server.runner.backend_name", return_value="mystery"): + with pytest.raises(RunnerConfigurationError, match="Unsupported OCI_CODE_RUNNER_BACKEND"): + run_runner(manifest_path, cli_backend=None) + + def test_lima_backend_covers_failure_and_copy_out_paths(self, tmp_path: Path): + manifest_path = tmp_path / "request.json" + manifest_path.write_text(json.dumps(_manifest_payload())) + manifest = load_manifest(manifest_path) + + class TempDir: + def __init__(self, name: str): + self.name = name + self.cleaned = False + + def cleanup(self) -> None: + self.cleaned = True + + with patch("oracle.oci_code_mcp_server.runner._ensure_lima_instance_running", return_value=(9, "start-out", "start-err")): + returncode, stdout, stderr = execute_lima_backend(manifest_path, manifest) + assert (returncode, stdout, stderr) == (9, "start-out", "start-err") + + with ( + patch("oracle.oci_code_mcp_server.runner._ensure_lima_instance_running", return_value=(0, "", "")), + patch("oracle.oci_code_mcp_server.runner._run_subprocess", return_value=_completed(42, "", "no kvm")), + ): + returncode, stdout, stderr = execute_lima_backend(manifest_path, manifest) + assert (returncode, stdout, stderr) == (42, "", "no kvm") + + temp_dir = TempDir(str(tmp_path / "bundle")) + (tmp_path / "bundle").mkdir() + with ( + patch("oracle.oci_code_mcp_server.runner._ensure_lima_instance_running", return_value=(0, "", "")), + patch("oracle.oci_code_mcp_server.runner._build_lima_stage_bundle", return_value=(temp_dir, tmp_path / "bundle-root", "/guest/root")), + patch( + "oracle.oci_code_mcp_server.runner._run_subprocess", + side_effect=[_completed(0), _completed(1, "prep-out", "prep-err"), _completed(0)], + ), + ): + returncode, stdout, stderr = execute_lima_backend(manifest_path, manifest) + assert returncode == 1 + assert "prep-out" in stdout + assert "prep-err" in stderr + assert temp_dir.cleaned is True + + stdout_manifest_path = tmp_path / "stdout-request.json" + stdout_manifest_path.write_text(json.dumps(_manifest_payload(None))) + stdout_manifest = load_manifest(stdout_manifest_path) + temp_dir = TempDir(str(tmp_path / "stdout-bundle")) + (tmp_path / "stdout-bundle").mkdir() + staged_result = Path(temp_dir.name) / "result.json" + + def fake_run(command: list[str]): + if command[:2] == ["limactl", "shell"] and "/dev/kvm" in command[-1]: + return _completed(0) + if command[:2] == ["limactl", "shell"] and "mkdir -p" in command[-1]: + return _completed(0) + if command[:2] == ["limactl", "copy"] and command[-1].endswith("/"): + return _completed(0) + if command[:2] == ["limactl", "shell"] and "oci-code-lima-guest-runner.sh" in command[-1]: + return _completed(0) + if command[:2] == ["limactl", "copy"] and command[-1] == str(staged_result): + staged_result.write_text('{"ok": true}') + return _completed(5, "copy-out", "copy failed") + if command[:2] == ["limactl", "shell"] and command[-1].startswith("rm -rf"): + return _completed(0) + raise AssertionError(f"Unexpected command: {command}") + + with ( + patch("oracle.oci_code_mcp_server.runner._ensure_lima_instance_running", return_value=(0, "", "")), + patch("oracle.oci_code_mcp_server.runner._build_lima_stage_bundle", return_value=(temp_dir, tmp_path / "bundle-root", "/guest/root")), + patch("oracle.oci_code_mcp_server.runner._run_subprocess", side_effect=fake_run), + ): + returncode, stdout, stderr = execute_lima_backend(stdout_manifest_path, stdout_manifest) + assert returncode == 5 + assert '{"ok": true}' in stdout + assert "copy failed" in stderr + + def test_main_writes_error_payload_for_bad_manifest(self, tmp_path: Path): + result_path = tmp_path / "result.json" + manifest_path = tmp_path / "request.json" + payload = _manifest_payload(str(result_path)) + del payload["allowed_egress"] + manifest_path.write_text(json.dumps(payload)) + + exit_code = main(["--manifest", str(manifest_path), "--backend", "emulator"]) + + assert exit_code == 1 + error_payload = json.loads(result_path.read_text()) + assert error_payload["ok"] is False + assert error_payload["error"]["type"] == "ManifestValidationError" + + def test_main_prints_error_when_manifest_cannot_be_read(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + manifest_path = tmp_path / "missing.json" + + exit_code = main(["--manifest", str(manifest_path), "--backend", "emulator"]) + + assert exit_code == 1 + payload = json.loads(capsys.readouterr().out) + assert payload["error"]["type"] == "ManifestValidationError" diff --git a/src/oci-code-mcp-server/oracle/oci_code_mcp_server/tests/test_runtime.py b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/tests/test_runtime.py new file mode 100644 index 00000000..619ff0b3 --- /dev/null +++ b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/tests/test_runtime.py @@ -0,0 +1,390 @@ +""" +Copyright (c) 2026, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from oracle.oci_code_mcp_server.executor import ( + DEFAULT_ALLOWED_EGRESS, + DEFAULT_SNAPSHOT_NAME, + ExecutionLimits, + ExecutionRequest, + FirecrackerCommandExecutor, + SandboxExecutionError, + _config_file_location, + _default_profile_name, + _load_result_payload, + _user_agent, + build_execution_request, + collect_auth_bundle, + default_allowed_egress, +) +from oracle.oci_code_mcp_server.guest_runner import ( + _install_auth_bundle, + _invoke_main, + _serialize_value, + build_oci_signer, + create_oci_client, + load_oci_config, + main as guest_main, + run_manifest, +) +from oracle.oci_code_mcp_server.policy import ( + CodePolicyError, + MAX_CODE_BYTES, + make_restricted_builtins, + validate_user_code, +) +from oracle.oci_code_mcp_server.server import code_execution_contract, get_executor + + +def _request() -> ExecutionRequest: + return ExecutionRequest( + request_id="req-test", + code="result = 1", + input_data={"value": 1}, + profile_name="DEFAULT", + snapshot_name=DEFAULT_SNAPSHOT_NAME, + auth_bundle={"profile_name": "DEFAULT", "config": {}, "key_pem": "KEY", "security_token": None}, + allowed_egress=["*.oraclecloud.com"], + limits=ExecutionLimits(timeout_seconds=30, memory_limit_mib=512, vcpu_count=1, max_result_bytes=262144), + ) + + +class TestPolicyCoverage: + def test_validate_user_code_rejects_non_string_empty_and_oversized(self): + with pytest.raises(CodePolicyError, match="code must be a string"): + validate_user_code(123) # type: ignore[arg-type] + with pytest.raises(CodePolicyError, match="code must not be empty"): + validate_user_code(" ") + with pytest.raises(CodePolicyError, match=str(MAX_CODE_BYTES)): + validate_user_code("x" * (MAX_CODE_BYTES + 1)) + + def test_validate_user_code_rejects_syntax_and_missing_entrypoint(self): + with pytest.raises(CodePolicyError, match="Invalid Python syntax"): + validate_user_code("def broken(:\n pass\n") + with pytest.raises(CodePolicyError, match="must define main"): + validate_user_code("value = 1\n") + + def test_validate_user_code_rejects_relative_imports_and_banned_access(self): + with pytest.raises(CodePolicyError, match="Relative imports are not allowed"): + validate_user_code("from .x import y\nresult = 1\n") + with pytest.raises(CodePolicyError, match="Call to 'open' is not allowed"): + validate_user_code("def main(input_data):\n return open('x')\n") + with pytest.raises(CodePolicyError, match="Attribute '__class__' is not allowed"): + validate_user_code("def main(input_data):\n return input_data.__class__\n") + with pytest.raises(CodePolicyError, match="Name 'sys' is not allowed"): + validate_user_code("def main(input_data):\n return sys.version\n") + + def test_validate_user_code_allows_supported_result_entrypoints(self): + tree = validate_user_code("result: int = 1\n") + assert tree.body + tree = validate_user_code("from oci.identity import IdentityClient\n\ndef main():\n return IdentityClient\n") + assert tree.body + + def test_make_restricted_builtins_removes_quit_and_open(self): + safe = make_restricted_builtins() + assert "quit" not in safe + assert "open" not in safe + assert "sum" in safe + + +class TestGuestRunnerCoverage: + def test_serialize_value_handles_paths_sets_and_fallbacks(self, tmp_path: Path): + assert _serialize_value(tmp_path) == str(tmp_path) + assert sorted(_serialize_value({"items": {3, 1}})["items"]) == [1, 3] + + with patch("oracle.oci_code_mcp_server.guest_runner.oci.util.to_dict", return_value={"x": 1}): + assert _serialize_value(object()) == {"x": 1} + + class Unserializable: + def __str__(self) -> str: + return "custom" + + with patch("oracle.oci_code_mcp_server.guest_runner.oci.util.to_dict", side_effect=RuntimeError("nope")): + assert _serialize_value(Unserializable()) == "custom" + + def test_load_oci_config_and_build_oci_signer_with_and_without_token(self, tmp_path: Path): + key_file = tmp_path / "key.pem" + key_file.write_text("KEY") + token_file = tmp_path / "token" + token_file.write_text("TOKEN") + config = { + "key_file": str(key_file), + "security_token_file": str(token_file), + "tenancy": "tenancy", + "user": "user", + "fingerprint": "fp", + } + + with patch("oracle.oci_code_mcp_server.guest_runner.oci.config.from_file", return_value=config) as from_file: + loaded = load_oci_config() + assert loaded == config + from_file.assert_called_once() + + with ( + patch("oracle.oci_code_mcp_server.guest_runner.oci.signer.load_private_key_from_file", return_value="PRIVATE"), + patch("oracle.oci_code_mcp_server.guest_runner.oci.auth.signers.SecurityTokenSigner", return_value="TOKEN_SIGNER") as token_signer, + ): + assert build_oci_signer(config) == "TOKEN_SIGNER" + token_signer.assert_called_once_with("TOKEN", "PRIVATE") + + config_no_token = dict(config) + config_no_token["security_token_file"] = str(tmp_path / "missing-token") + with ( + patch("oracle.oci_code_mcp_server.guest_runner.oci.signer.load_private_key_from_file", return_value="PRIVATE"), + patch("oracle.oci_code_mcp_server.guest_runner.oci.signer.Signer", return_value="SIGNER") as signer_cls, + ): + assert build_oci_signer(config_no_token) == "SIGNER" + signer_cls.assert_called_once() + + def test_create_oci_client_and_install_auth_bundle(self, tmp_path: Path): + class DummyClient: + def __init__(self, config, signer=None): + self.config = config + self.signer = signer + + with patch("oracle.oci_code_mcp_server.guest_runner.build_oci_signer", return_value="SIGNER"): + client = create_oci_client(DummyClient, {"region": "us-ashburn-1"}) + assert client.config == {"region": "us-ashburn-1"} + assert client.signer == "SIGNER" + + auth_bundle = { + "profile_name": "ALT", + "config": {"tenancy": "ocid1.tenancy", "user": "ocid1.user", "fingerprint": "fp", "region": "us-phoenix-1"}, + "key_pem": "KEY", + "security_token": "TOKEN", + } + _install_auth_bundle(auth_bundle, tmp_path) + assert Path(tmp_path / "oci_api_key.pem").read_text() == "KEY" + assert Path(tmp_path / "security_token").read_text() == "TOKEN" + config_text = Path(tmp_path / "config").read_text() + assert "[ALT]" in config_text + assert "security_token_file=" in config_text + + def test_invoke_main_validates_signatures(self): + assert _invoke_main(lambda: "ok", {"x": 1}) == "ok" + assert _invoke_main(lambda input_data=None: input_data["x"], {"x": 2}) == 2 + + async def async_main(): + return "nope" + + def too_many(a, b): + return a, b + + with pytest.raises(CodePolicyError, match="async main"): + _invoke_main(async_main, {}) + with pytest.raises(CodePolicyError, match="zero or one positional"): + _invoke_main(too_many, {}) + + def test_run_manifest_success_and_guest_main(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + payload = run_manifest( + { + "request_id": "req-success", + "code": "print('hello')\nresult = {'value': INPUT['value'] + 2}\n", + "input": {"value": 3}, + "vm_id": "vm-9", + "resume_snapshot": False, + } + ) + assert payload["ok"] is True + assert payload["result"] == {"value": 5} + assert payload["guest_stdout"].strip() == "hello" + assert payload["vm_id"] == "vm-9" + assert payload["resumed_from_snapshot"] is False + + result_path = tmp_path / "result.json" + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text( + json.dumps( + { + "request_id": "req-cli", + "code": "result = 9\n", + "input": {}, + "result_path": str(result_path), + } + ) + ) + assert guest_main(["--manifest", str(manifest_path)]) == 0 + assert json.loads(result_path.read_text())["result"] == 9 + + stdout_manifest = tmp_path / "stdout-manifest.json" + stdout_manifest.write_text(json.dumps({"request_id": "req-cli-stdout", "code": "result = 7\n", "input": {}})) + assert guest_main(["--manifest", str(stdout_manifest)]) == 0 + assert json.loads(capsys.readouterr().out)["result"] == 7 + + +class TestExecutorCoverage: + def test_helpers_for_profiles_egress_and_user_agent(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("OCI_CONFIG_PROFILE", raising=False) + monkeypatch.delenv("OCI_CODE_ALLOWED_EGRESS", raising=False) + monkeypatch.delenv("OCI_CONFIG_FILE", raising=False) + assert _default_profile_name(None) == "DEFAULT" + assert _default_profile_name("ALT") == "ALT" + assert default_allowed_egress() == list(DEFAULT_ALLOWED_EGRESS) + assert _config_file_location().endswith("config") + assert _user_agent() + + monkeypatch.setenv("OCI_CODE_ALLOWED_EGRESS", " foo.oraclecloud.com, bar.oraclecloud.com ") + assert default_allowed_egress() == ["foo.oraclecloud.com", "bar.oraclecloud.com"] + + def test_collect_auth_bundle_and_build_execution_request(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + key_file = tmp_path / "key.pem" + key_file.write_text("KEY") + token_file = tmp_path / "token" + token_file.write_text("TOKEN") + fake_config = { + "fingerprint": "fp", + "key_file": str(key_file), + "pass_phrase": "pw", + "region": "us-ashburn-1", + "security_token_file": str(token_file), + "tenancy": "tenancy", + "user": "user", + "extra": "ignored", + } + + with patch("oracle.oci_code_mcp_server.executor.oci.config.from_file", return_value=fake_config): + bundle = collect_auth_bundle("ALT") + assert bundle["profile_name"] == "ALT" + assert bundle["key_pem"] == "KEY" + assert bundle["security_token"] == "TOKEN" + assert "extra" not in bundle["config"] + assert bundle["config"]["additional_user_agent"] == _user_agent() + + monkeypatch.setenv("OCI_CODE_SNAPSHOT_NAME", "snapshot-x") + monkeypatch.setenv("OCI_CODE_ALLOWED_EGRESS", "*.oraclecloud.com,iad.oraclecloud.com") + with ( + patch("oracle.oci_code_mcp_server.executor.collect_auth_bundle", return_value={"token": "bundle"}), + patch("oracle.oci_code_mcp_server.executor.uuid.uuid4") as uuid4, + ): + uuid4.return_value.hex = "abc123" + request = build_execution_request( + code="result = 1", + input_data={"x": 1}, + profile_name=None, + snapshot_name=None, + timeout_seconds=11, + memory_limit_mib=222, + ) + assert request.request_id == "abc123" + assert request.snapshot_name == "snapshot-x" + assert request.allowed_egress == ["*.oraclecloud.com", "iad.oraclecloud.com"] + assert request.auth_bundle == {"token": "bundle"} + assert request.limits.timeout_seconds == 11 + assert request.limits.memory_limit_mib == 222 + + def test_executor_manifest_and_result_loading(self, tmp_path: Path): + executor = FirecrackerCommandExecutor("/bin/echo") + manifest = executor._manifest(_request(), tmp_path / "result.json") + assert manifest["guest_entrypoint"].startswith("python -m") + assert manifest["vm_id"].startswith("oci-code-") + + assert _load_result_payload(tmp_path / "missing.json", '{"ok": true, "result": 5}')["result"] == 5 + with pytest.raises(SandboxExecutionError, match="without producing a result payload"): + _load_result_payload(tmp_path / "missing.json", "") + + def test_executor_success_and_error_paths(self, tmp_path: Path): + request = _request() + result_path = tmp_path / "result.json" + + def run_success(*args, **kwargs): + result_path.write_text(json.dumps({"ok": True, "result": {"done": True}, "vm_id": "vm-ok"})) + return MagicMock(returncode=0, stdout="", stderr="") + + executor = FirecrackerCommandExecutor("/bin/runner", host_timeout_buffer_seconds=2) + with ( + patch("oracle.oci_code_mcp_server.executor.tempfile.TemporaryDirectory") as tempdir, + patch("oracle.oci_code_mcp_server.executor.subprocess.run", side_effect=run_success), + patch("oracle.oci_code_mcp_server.executor.time.monotonic", side_effect=[10.0, 10.25]), + ): + tempdir.return_value.__enter__.return_value = str(tmp_path) + tempdir.return_value.__exit__.return_value = False + result = executor.execute(request) + assert result.result == {"done": True} + assert result.execution_time_ms == 250 + + executor = FirecrackerCommandExecutor("/bin/runner") + with ( + patch("oracle.oci_code_mcp_server.executor.tempfile.TemporaryDirectory") as tempdir, + patch( + "oracle.oci_code_mcp_server.executor.subprocess.run", + return_value=MagicMock(returncode=1, stdout="", stderr="boom"), + ), + ): + tempdir.return_value.__enter__.return_value = str(tmp_path) + tempdir.return_value.__exit__.return_value = False + if result_path.exists(): + result_path.unlink() + with pytest.raises(SandboxExecutionError, match="exit code 1"): + executor.execute(request) + + result_path.write_text(json.dumps({"ok": False, "error": {"type": "GuestError", "message": "bad"}})) + with ( + patch("oracle.oci_code_mcp_server.executor.tempfile.TemporaryDirectory") as tempdir, + patch( + "oracle.oci_code_mcp_server.executor.subprocess.run", + return_value=MagicMock(returncode=1, stdout="", stderr="boom"), + ), + ): + tempdir.return_value.__enter__.return_value = str(tmp_path) + tempdir.return_value.__exit__.return_value = False + with pytest.raises(SandboxExecutionError, match="GuestError: bad"): + executor.execute(request) + + result_path.write_text(json.dumps({"ok": False, "error": {"type": "GuestError", "message": "still bad"}})) + with ( + patch("oracle.oci_code_mcp_server.executor.tempfile.TemporaryDirectory") as tempdir, + patch( + "oracle.oci_code_mcp_server.executor.subprocess.run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ), + ): + tempdir.return_value.__enter__.return_value = str(tmp_path) + tempdir.return_value.__exit__.return_value = False + with pytest.raises(SandboxExecutionError, match="GuestError: still bad"): + executor.execute(request) + + if result_path.exists(): + result_path.unlink() + with ( + patch("oracle.oci_code_mcp_server.executor.tempfile.TemporaryDirectory") as tempdir, + patch( + "oracle.oci_code_mcp_server.executor.subprocess.run", + return_value=MagicMock(returncode=0, stdout="", stderr=""), + ), + ): + tempdir.return_value.__enter__.return_value = str(tmp_path) + tempdir.return_value.__exit__.return_value = False + with pytest.raises(SandboxExecutionError, match="without producing a result payload"): + executor.execute(request) + + def test_executor_translates_subprocess_timeout(self, tmp_path: Path): + request = _request() + executor = FirecrackerCommandExecutor("/bin/runner") + with ( + patch("oracle.oci_code_mcp_server.executor.tempfile.TemporaryDirectory") as tempdir, + patch( + "oracle.oci_code_mcp_server.executor.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd=["runner"], timeout=1), + ), + ): + tempdir.return_value.__enter__.return_value = str(tmp_path) + tempdir.return_value.__exit__.return_value = False + with pytest.raises(SandboxExecutionError, match="host timeout buffer"): + executor.execute(request) + + +class TestServerHelpers: + def test_contract_and_executor_factory(self): + assert "OCI code execution contract" in code_execution_contract.fn() + assert isinstance(get_executor(), FirecrackerCommandExecutor) diff --git a/src/oci-code-mcp-server/oracle/oci_code_mcp_server/tests/test_server.py b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/tests/test_server.py new file mode 100644 index 00000000..c3686174 --- /dev/null +++ b/src/oci-code-mcp-server/oracle/oci_code_mcp_server/tests/test_server.py @@ -0,0 +1,198 @@ +""" +Copyright (c) 2026, Oracle and/or its affiliates. +Licensed under the Universal Permissive License v1.0 as shown at +https://oss.oracle.com/licenses/upl. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest +from fastmcp import Client +from fastmcp.exceptions import ToolError + +from oracle.oci_code_mcp_server.executor import ( + ExecutionLimits, + ExecutionRequest, + ExecutionResult, + FirecrackerCommandExecutor, + SandboxExecutionError, + _load_result_payload, +) +from oracle.oci_code_mcp_server.guest_runner import execute_user_code, run_manifest +from oracle.oci_code_mcp_server.policy import CodePolicyError, make_restricted_builtins, validate_user_code +from oracle.oci_code_mcp_server.server import main, mcp + + +class FakeExecutor: + def execute(self, request: ExecutionRequest) -> ExecutionResult: + assert request.snapshot_name == "snapshot-a" + return ExecutionResult( + request_id=request.request_id, + result={"regions": ["us-ashburn-1"]}, + snapshot_name=request.snapshot_name, + resumed_from_snapshot=True, + vm_id="vm-123", + execution_time_ms=17, + executor_name="firecracker-command", + ) + + +class TestPolicy: + def test_rejects_disallowed_import(self): + with pytest.raises(CodePolicyError): + validate_user_code("import os\n\nresult = 1\n") + + def test_restricted_builtins_strip_eval(self): + safe_builtins = make_restricted_builtins() + assert "eval" not in safe_builtins + assert "len" in safe_builtins + + +class TestGuestRunner: + def test_execute_user_code_uses_main(self): + result = execute_user_code( + """ +def main(input_data): + return {"value": input_data["value"] + 1} +""", + {"value": 4}, + ) + + assert result["result"] == {"value": 5} + + def test_run_manifest_returns_error_payload(self): + payload = run_manifest( + { + "request_id": "req-1", + "code": "import os\n\nresult = 1\n", + "input": {}, + } + ) + + assert payload["ok"] is False + assert payload["error"]["type"] == "CodePolicyError" + + +class TestExecutorHelpers: + def test_load_result_payload_prefers_file(self, tmp_path: Path): + result_path = tmp_path / "result.json" + result_path.write_text(json.dumps({"ok": True, "result": {"x": 1}})) + + payload = _load_result_payload(result_path, "") + + assert payload["result"] == {"x": 1} + + def test_execute_raises_when_runner_missing(self): + executor = FirecrackerCommandExecutor(runner_command="") + request = ExecutionRequest( + request_id="req-2", + code="result = 1", + input_data=None, + profile_name="DEFAULT", + snapshot_name="oci-python-sdk-default", + auth_bundle={}, + allowed_egress=["*.oraclecloud.com"], + limits=ExecutionLimits(), + ) + + with pytest.raises(SandboxExecutionError): + executor.execute(request) + + +class TestServerTools: + @pytest.mark.asyncio + async def test_execute_oci_python_success(self): + request = ExecutionRequest( + request_id="req-3", + code="def main(input_data):\n return input_data\n", + input_data={"hello": "world"}, + profile_name="DEFAULT", + snapshot_name="snapshot-a", + auth_bundle={}, + allowed_egress=["*.oraclecloud.com"], + limits=ExecutionLimits(timeout_seconds=20, memory_limit_mib=256), + ) + + with ( + patch("oracle.oci_code_mcp_server.server.build_execution_request", return_value=request), + patch("oracle.oci_code_mcp_server.server.get_executor", return_value=FakeExecutor()), + ): + async with Client(mcp) as client: + result = ( + await client.call_tool( + "execute_oci_python", + { + "code": "def main(input_data):\n return input_data\n", + "input_data": {"hello": "world"}, + "snapshot_name": "snapshot-a", + }, + ) + ).data + + assert result["result"] == {"regions": ["us-ashburn-1"]} + assert result["sandbox"]["vm_id"] == "vm-123" + + @pytest.mark.asyncio + async def test_execute_oci_python_rejects_policy_violation(self): + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool( + "execute_oci_python", + { + "code": "import os\n\nresult = 1\n", + }, + ) + + @pytest.mark.asyncio + async def test_execute_oci_python_surfaces_sandbox_error(self): + request = ExecutionRequest( + request_id="req-4", + code="result = 1", + input_data=None, + profile_name="DEFAULT", + snapshot_name="snapshot-a", + auth_bundle={}, + allowed_egress=["*.oraclecloud.com"], + limits=ExecutionLimits(), + ) + + failing_executor = type( + "FailingExecutor", + (), + {"execute": lambda self, request: (_ for _ in ()).throw(SandboxExecutionError("boom"))}, + )() + + with ( + patch("oracle.oci_code_mcp_server.server.build_execution_request", return_value=request), + patch("oracle.oci_code_mcp_server.server.get_executor", return_value=failing_executor), + ): + async with Client(mcp) as client: + with pytest.raises(ToolError): + await client.call_tool("execute_oci_python", {"code": "result = 1"}) + + +class TestMain: + @patch("oracle.oci_code_mcp_server.server.mcp.run") + def test_main_with_host_and_port(self, mock_mcp_run): + with patch.dict( + "os.environ", + { + "ORACLE_MCP_HOST": "127.0.0.1", + "ORACLE_MCP_PORT": "9000", + }, + clear=False, + ): + main() + + mock_mcp_run.assert_called_once_with(transport="http", host="127.0.0.1", port=9000) + + @patch("oracle.oci_code_mcp_server.server.mcp.run") + def test_main_without_host_and_port(self, mock_mcp_run): + with patch.dict("os.environ", {}, clear=True): + main() + + mock_mcp_run.assert_called_once_with() diff --git a/src/oci-code-mcp-server/pyproject.toml b/src/oci-code-mcp-server/pyproject.toml new file mode 100644 index 00000000..25ea3aab --- /dev/null +++ b/src/oci-code-mcp-server/pyproject.toml @@ -0,0 +1,66 @@ +[project] +name = "oracle.oci-code-mcp-server" +version = "0.1.0" +description = "OCI Python code execution MCP server backed by an ephemeral Firecracker sandbox" +readme = "README.md" +requires-python = ">=3.13" +license = "UPL-1.0" +license-files = ["LICENSE.txt"] +authors = [ + {name = "Oracle MCP", email = "237432095+oracle-mcp@users.noreply.github.com"}, +] +dependencies = [ + "fastmcp==2.14.2", + "oci==2.160.0", +] + +classifiers = [ + "License :: OSI Approved :: Universal Permissive License (UPL)", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3.13", +] + +[project.scripts] +"oracle.oci-code-mcp-server" = "oracle.oci_code_mcp_server.server:main" +"oci-code-firecracker-runner" = "oracle.oci_code_mcp_server.runner:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["oracle"] + +[tool.hatch.build.targets.sdist] +include = [ + "/LICENSE.txt", + "/README.md", + "/pyproject.toml", + "/scripts", + "/oracle", + "/uv.lock", +] + +[dependency-groups] +dev = [ + "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", + "pytest-cov>=7.0.0", +] + +[tool.coverage.run] +omit = [ + "**/__init__.py", + "**/tests/*", + "dist/*", + ".venv/*", +] + +[tool.coverage.report] +omit = [ + "**/__init__.py", + "**/tests/*", +] +precision = 2 +fail_under = 90 diff --git a/src/oci-code-mcp-server/scripts/oci-code-firecracker-microvm-init.sh b/src/oci-code-mcp-server/scripts/oci-code-firecracker-microvm-init.sh new file mode 100755 index 00000000..3a5768e4 --- /dev/null +++ b/src/oci-code-mcp-server/scripts/oci-code-firecracker-microvm-init.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +DATA_MOUNT="/mnt/oci-code-data" +RESULT_PATH="${DATA_MOUNT}/stage/result.json" +MANIFEST_PATH="${DATA_MOUNT}/stage/request.json" + +log() { + echo "[oci-code-init] $*" > /dev/console +} + +cmdline_value() { + local key="$1" + local value + value="$(tr ' ' '\n' /proc/sysrq-trigger || true + exit "${1:-0}" +} + +mount -t proc proc /proc || true +mount -t sysfs sysfs /sys || true +mount -t devtmpfs devtmpfs /dev || true +mount -t tmpfs tmpfs /run || true +mount -t tmpfs tmpfs /tmp || true + +mkdir -p "${DATA_MOUNT}" +if ! mount /dev/vdb "${DATA_MOUNT}"; then + log "failed to mount /dev/vdb" + write_failure_payload "Failed to mount the staged request disk" + cleanup_and_exit 1 +fi + +GUEST_IP="$(cmdline_value oci.guest_ip)" +HOST_IP="$(cmdline_value oci.host_ip)" +DNS_IP="$(cmdline_value oci.dns)" +IFACE="$(first_non_loopback_iface || true)" + +if [ -n "${IFACE}" ] && [ -n "${GUEST_IP}" ] && [ -n "${HOST_IP}" ]; then + ip link set "${IFACE}" up || true + ip addr flush dev "${IFACE}" || true + ip addr add "${GUEST_IP}/30" dev "${IFACE}" || true + ip route replace default via "${HOST_IP}" dev "${IFACE}" || true +fi + +if [ -n "${DNS_IP}" ]; then + printf 'nameserver %s\n' "${DNS_IP}" > /etc/resolv.conf +fi + +if [ ! -f "${MANIFEST_PATH}" ]; then + log "manifest missing at ${MANIFEST_PATH}" + write_failure_payload "Manifest is missing from the staged request disk" + cleanup_and_exit 1 +fi + +if [ ! -d "${DATA_MOUNT}/project/oracle/oci_code_mcp_server" ]; then + log "project checkout missing on staged request disk" + write_failure_payload "Project checkout is missing from the staged request disk" + cleanup_and_exit 1 +fi + +export PYTHONDONTWRITEBYTECODE=1 +export PYTHONPATH="${DATA_MOUNT}/project${PYTHONPATH:+:${PYTHONPATH}}" +export TMPDIR="${DATA_MOUNT}/tmp" +mkdir -p "${TMPDIR}" +PYTHON_BIN="/opt/oci-code-venv/bin/python" +[ -x "${PYTHON_BIN}" ] || PYTHON_BIN="$(command -v python3)" + +if ! "${PYTHON_BIN}" -m oracle.oci_code_mcp_server.guest_runner --manifest "${MANIFEST_PATH}"; then + log "guest runner exited non-zero" +fi + +[ -f "${RESULT_PATH}" ] || write_failure_payload "Guest runner completed without writing a result payload" +sync || true +cleanup_and_exit 0 diff --git a/src/oci-code-mcp-server/scripts/oci-code-firecracker-runner b/src/oci-code-mcp-server/scripts/oci-code-firecracker-runner new file mode 100755 index 00000000..df357d33 --- /dev/null +++ b/src/oci-code-mcp-server/scripts/oci-code-firecracker-runner @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +exec uv run --project "${PROJECT_DIR}" oci-code-firecracker-runner "$@" diff --git a/src/oci-code-mcp-server/scripts/oci-code-lima-firecracker-orchestrator.sh b/src/oci-code-mcp-server/scripts/oci-code-lima-firecracker-orchestrator.sh new file mode 100755 index 00000000..98dacf79 --- /dev/null +++ b/src/oci-code-mcp-server/scripts/oci-code-lima-firecracker-orchestrator.sh @@ -0,0 +1,300 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: oci-code-lima-firecracker-orchestrator.sh --manifest /path/to/request.json + +Run a nested Firecracker microVM inside the current Lima guest. The script: +- rewrites the staged manifest for the nested microVM +- builds a request data disk containing stage/ and project/ +- creates a tap/NAT network for OCI egress +- launches Firecracker with a prepared kernel and rootfs template +- copies the result payload back to the original manifest.result_path +EOF +} + +MANIFEST_PATH="" +while [ "$#" -gt 0 ]; do + case "$1" in + --manifest) + MANIFEST_PATH="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +[ -n "${MANIFEST_PATH}" ] || { + usage >&2 + exit 1 +} +[ -f "${MANIFEST_PATH}" ] || { + echo "Manifest not found: ${MANIFEST_PATH}" >&2 + exit 1 +} + +require_tool() { + command -v "$1" >/dev/null 2>&1 || { + echo "Required tool not found: $1" >&2 + exit 1 + } +} + +require_tool python3 +require_tool jq +require_tool sudo +require_tool truncate +require_tool mkfs.ext4 +require_tool ip +require_tool iptables +require_tool mount +require_tool umount +require_tool setfacl +require_tool timeout + +FIRECRACKER_BIN="${OCI_CODE_LIMA_FIRECRACKER_BIN:-${HOME}/.local/bin/firecracker}" +KERNEL_PATH="${OCI_CODE_LIMA_FIRECRACKER_KERNEL_PATH:-${HOME}/.local/share/oci-code-firecracker/vmlinux}" +ROOTFS_TEMPLATE="${OCI_CODE_LIMA_FIRECRACKER_ROOTFS_TEMPLATE:-${HOME}/.local/share/oci-code-firecracker/rootfs.ext4}" +RUN_ROOT="${OCI_CODE_LIMA_FIRECRACKER_RUN_ROOT:-/tmp/oci-code-firecracker}" +DNS_SERVER="${OCI_CODE_LIMA_FIRECRACKER_DNS:-1.1.1.1}" +KEEP_WORKDIR="${OCI_CODE_LIMA_FIRECRACKER_KEEP_WORKDIR:-false}" + +[ -x "${FIRECRACKER_BIN}" ] || { + echo "Firecracker binary not found or not executable: ${FIRECRACKER_BIN}" >&2 + exit 1 +} +[ -f "${KERNEL_PATH}" ] || { + echo "Kernel image not found: ${KERNEL_PATH}" >&2 + exit 1 +} +[ -f "${ROOTFS_TEMPLATE}" ] || { + echo "Rootfs template not found: ${ROOTFS_TEMPLATE}" >&2 + exit 1 +} + +REQUEST_ID="$(python3 - "${MANIFEST_PATH}" <<'PY' +import json +import sys +from pathlib import Path + +manifest = json.loads(Path(sys.argv[1]).read_text()) +print(manifest["request_id"]) +PY +)" +HOST_RESULT_PATH="$(python3 - "${MANIFEST_PATH}" <<'PY' +import json +import sys +from pathlib import Path + +manifest = json.loads(Path(sys.argv[1]).read_text()) +print(manifest.get("result_path", "")) +PY +)" +TIMEOUT_SECONDS="$(python3 - "${MANIFEST_PATH}" <<'PY' +import json +import sys +from pathlib import Path + +manifest = json.loads(Path(sys.argv[1]).read_text()) +print(manifest.get("limits", {}).get("timeout_seconds", 30)) +PY +)" +MEMORY_MIB="$(python3 - "${MANIFEST_PATH}" <<'PY' +import json +import sys +from pathlib import Path + +manifest = json.loads(Path(sys.argv[1]).read_text()) +print(max(manifest.get("limits", {}).get("memory_limit_mib", 512), 256)) +PY +)" +VCPU_COUNT="$(python3 - "${MANIFEST_PATH}" <<'PY' +import json +import sys +from pathlib import Path + +manifest = json.loads(Path(sys.argv[1]).read_text()) +print(max(manifest.get("limits", {}).get("vcpu_count", 1), 1)) +PY +)" + +REQUEST_ROOT="$(cd "$(dirname "${MANIFEST_PATH}")/.." && pwd)" +PROJECT_SOURCE="${REQUEST_ROOT}/project" + +[ -d "${PROJECT_SOURCE}/oracle/oci_code_mcp_server" ] || { + echo "Expected staged project checkout at ${PROJECT_SOURCE}" >&2 + exit 1 +} + +WORKDIR="${RUN_ROOT}/${REQUEST_ID}" +BUNDLE_ROOT="${WORKDIR}/bundle" +DATA_IMAGE="${WORKDIR}/data.ext4" +ROOTFS_IMAGE="${WORKDIR}/rootfs.ext4" +FIRECRACKER_CONFIG="${WORKDIR}/firecracker.json" +API_SOCKET="${WORKDIR}/firecracker.sock" +FC_STDERR="${WORKDIR}/firecracker.stderr.log" +FC_STDOUT="${WORKDIR}/firecracker.stdout.log" +MOUNT_DIR="${WORKDIR}/mount" + +mkdir -p "${WORKDIR}" "${BUNDLE_ROOT}/stage" "${BUNDLE_ROOT}/project" "${MOUNT_DIR}" + +cleanup() { + set +e + if mountpoint -q "${MOUNT_DIR}"; then + sudo umount "${MOUNT_DIR}" >/dev/null 2>&1 || true + fi + if [ -n "${TAP_DEV:-}" ]; then + sudo iptables -D FORWARD -i "${TAP_DEV}" -j ACCEPT >/dev/null 2>&1 || true + sudo iptables -D FORWARD -o "${TAP_DEV}" -m state --state RELATED,ESTABLISHED -j ACCEPT >/dev/null 2>&1 || true + sudo iptables -t nat -D POSTROUTING -s "${GUEST_IP}/32" -o "${HOST_IFACE}" -j MASQUERADE >/dev/null 2>&1 || true + sudo ip link del "${TAP_DEV}" >/dev/null 2>&1 || true + fi + if [ "${KEEP_WORKDIR}" != "true" ]; then + rm -rf "${WORKDIR}" + fi +} +trap cleanup EXIT + +write_failure_payload() { + local message="$1" + python3 - "${MANIFEST_PATH}" "${HOST_RESULT_PATH}" "${message}" <<'PY' +import json +import sys +from pathlib import Path + +manifest = json.loads(Path(sys.argv[1]).read_text()) +result_path = Path(sys.argv[2]) +message = sys.argv[3] + +payload = { + "ok": False, + "request_id": manifest.get("request_id", "unknown-request"), + "error": {"type": "FirecrackerDelegateError", "message": message}, + "vm_id": manifest.get("vm_id"), + "resumed_from_snapshot": bool(manifest.get("resume_snapshot", True)), +} +result_path.write_text(json.dumps(payload)) +PY +} + +python3 - "${MANIFEST_PATH}" "${BUNDLE_ROOT}/stage/request.json" <<'PY' +import json +import sys +from pathlib import Path + +manifest = json.loads(Path(sys.argv[1]).read_text()) +manifest["result_path"] = "/mnt/oci-code-data/stage/result.json" +Path(sys.argv[2]).write_text(json.dumps(manifest, indent=2, sort_keys=True)) +PY + +cp -a "${PROJECT_SOURCE}/." "${BUNDLE_ROOT}/project/" +mkdir -p "${BUNDLE_ROOT}/tmp" + +DATA_SIZE_MB="$(( $(du -sm "${BUNDLE_ROOT}" | awk '{print $1}') + 256 ))" +[ "${DATA_SIZE_MB}" -ge 512 ] || DATA_SIZE_MB=512 +truncate -s "${DATA_SIZE_MB}M" "${DATA_IMAGE}" +mkfs.ext4 -q -F -d "${BUNDLE_ROOT}" "${DATA_IMAGE}" + +if ! cp --reflink=auto "${ROOTFS_TEMPLATE}" "${ROOTFS_IMAGE}" 2>/dev/null; then + cp "${ROOTFS_TEMPLATE}" "${ROOTFS_IMAGE}" +fi + +sudo setfacl -m "u:${USER}:rw" /dev/kvm >/dev/null 2>&1 || true +[ -r /dev/kvm ] && [ -w /dev/kvm ] || { + write_failure_payload "Current user cannot access /dev/kvm inside the Lima guest" + exit 1 +} + +NET_OCTET="$(( (16#${REQUEST_ID:0:2} % 200) + 20 ))" +HOST_IP="172.30.${NET_OCTET}.1" +GUEST_IP="172.30.${NET_OCTET}.2" +HOST_IFACE="$(ip -j route list default | jq -r '.[0].dev')" +TAP_DEV="fc${REQUEST_ID:0:8}" +MAC_ADDR="06:00:${REQUEST_ID:0:2}:${REQUEST_ID:2:2}:${REQUEST_ID:4:2}:${REQUEST_ID:6:2}" + +sudo ip link del "${TAP_DEV}" >/dev/null 2>&1 || true +sudo ip tuntap add dev "${TAP_DEV}" mode tap user "${USER}" +sudo ip addr add "${HOST_IP}/30" dev "${TAP_DEV}" +sudo ip link set dev "${TAP_DEV}" up +sudo sysctl -w net.ipv4.ip_forward=1 >/dev/null +sudo iptables -I FORWARD 1 -i "${TAP_DEV}" -j ACCEPT +sudo iptables -I FORWARD 1 -o "${TAP_DEV}" -m state --state RELATED,ESTABLISHED -j ACCEPT +sudo iptables -t nat -A POSTROUTING -s "${GUEST_IP}/32" -o "${HOST_IFACE}" -j MASQUERADE + +KERNEL_ARGS="console=ttyS0 reboot=k panic=1 pci=off nomodules random.trust_cpu=on root=/dev/vda rw init=/oci-init oci.guest_ip=${GUEST_IP} oci.host_ip=${HOST_IP} oci.dns=${DNS_SERVER}" +if [ "$(uname -m)" = "aarch64" ]; then + KERNEL_ARGS="keep_bootcon ${KERNEL_ARGS}" +fi + +cat >"${FIRECRACKER_CONFIG}" <"${FC_STDOUT}" 2>"${FC_STDERR}" +FC_EXIT=$? +set -e + +mountpoint -q "${MOUNT_DIR}" && sudo umount "${MOUNT_DIR}" >/dev/null 2>&1 || true +sudo mount -o loop,ro,noload "${DATA_IMAGE}" "${MOUNT_DIR}" + +if [ -f "${MOUNT_DIR}/stage/result.json" ] && [ -n "${HOST_RESULT_PATH}" ]; then + sudo cp "${MOUNT_DIR}/stage/result.json" "${HOST_RESULT_PATH}" +fi +sudo umount "${MOUNT_DIR}" >/dev/null + +if [ -n "${HOST_RESULT_PATH}" ] && [ -f "${HOST_RESULT_PATH}" ]; then + exit 0 +fi + +if [ "${FC_EXIT}" -eq 124 ]; then + write_failure_payload "Nested Firecracker microVM timed out" +elif [ "${FC_EXIT}" -ne 0 ]; then + write_failure_payload "Nested Firecracker exited with status ${FC_EXIT}. See ${FC_STDERR}" +else + write_failure_payload "Nested Firecracker exited without producing a result payload" +fi + +exit 1 diff --git a/src/oci-code-mcp-server/scripts/oci-code-lima-firecracker-smoke-test.sh b/src/oci-code-mcp-server/scripts/oci-code-lima-firecracker-smoke-test.sh new file mode 100755 index 00000000..633ca20f --- /dev/null +++ b/src/oci-code-mcp-server/scripts/oci-code-lima-firecracker-smoke-test.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +INSTANCE="${OCI_CODE_LIMA_INSTANCE:-firecracker-dev}" +SMOKE_MODE="echo" + +usage() { + cat <<'EOF' +Usage: oci-code-lima-firecracker-smoke-test.sh [--oci-regions] + +Run a host-side smoke test that exercises: +host MCP server -> Lima guest -> nested Firecracker microVM -> guest OCI Python runner + +Options: + --oci-regions Run a real OCI SDK request (list regions) instead of the default echo test +EOF +} + +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + usage + exit 0 +fi + +if [ "${1:-}" = "--oci-regions" ]; then + SMOKE_MODE="oci-regions" + shift +fi + +[ "$#" -eq 0 ] || { + echo "Unexpected arguments: $*" >&2 + usage >&2 + exit 1 +} + +export OCI_CODE_FIRECRACKER_RUNNER_CMD="${OCI_CODE_FIRECRACKER_RUNNER_CMD:-${SCRIPT_DIR}/oci-code-firecracker-runner}" +export OCI_CODE_RUNNER_BACKEND="lima" +export OCI_CODE_LIMA_INSTANCE="${INSTANCE}" +export OCI_CODE_SMOKE_TEST_MODE="${SMOKE_MODE}" + +uv run --project "${PROJECT_DIR}" python - <<'PY' +import asyncio +import os + +from fastmcp import Client + +from oracle.oci_code_mcp_server.server import mcp + +MODE = os.environ.get("OCI_CODE_SMOKE_TEST_MODE", "echo") +if MODE == "oci-regions": + CODE = """from oci.identity import IdentityClient + +def main(input_data): + client = create_oci_client(IdentityClient) + response = client.list_regions() + return [region.name for region in response.data] +""" + INPUT = {} +else: + CODE = """def main(input_data): + return {"echo": input_data["value"] + 1} +""" + INPUT = {"value": 41} + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "execute_oci_python", + { + "code": CODE, + "input_data": INPUT, + }, + ) + print(result.data) + + +asyncio.run(main()) +PY diff --git a/src/oci-code-mcp-server/scripts/oci-code-lima-guest-runner.sh b/src/oci-code-mcp-server/scripts/oci-code-lima-guest-runner.sh new file mode 100755 index 00000000..a70a9c28 --- /dev/null +++ b/src/oci-code-mcp-server/scripts/oci-code-lima-guest-runner.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -euo pipefail + +REQUEST_ROOT="${1:?usage: oci-code-lima-guest-runner.sh }" +OCI_PIP_SPEC="${OCI_CODE_LIMA_GUEST_OCI_PIP_SPEC:-oci==2.160.0}" + +STAGE_DIR="${REQUEST_ROOT}/stage" +PROJECT_PATH="${REQUEST_ROOT}/project" +MANIFEST_PATH="${STAGE_DIR}/request.json" +RESULT_PATH="${STAGE_DIR}/result.json" +VENV_ROOT="${OCI_CODE_LIMA_GUEST_VENV_ROOT:-/var/tmp/oci-code-mcp/venvs}" +SPEC_SLUG="$(printf '%s' "${OCI_PIP_SPEC}" | tr '/:=' '___')" +VENV_PATH="${VENV_ROOT}/${SPEC_SLUG}" + +python_minor_version() { + python3 - <<'PY' +import sys +print(f"{sys.version_info.major}.{sys.version_info.minor}") +PY +} + +ensure_python_present() { + if command -v python3 >/dev/null 2>&1 && python3 -m pip --version >/dev/null 2>&1; then + return 0 + fi + + if command -v apt-get >/dev/null 2>&1; then + sudo DEBIAN_FRONTEND=noninteractive apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y python3 python3-pip python3-venv + return 0 + fi + + if command -v dnf >/dev/null 2>&1; then + sudo dnf install -y python3 python3-pip + return 0 + fi + + if command -v yum >/dev/null 2>&1; then + sudo yum install -y python3 python3-pip + return 0 + fi + + echo "Unable to install python3/python3-pip automatically inside the Lima guest" >&2 + exit 1 +} + +ensure_venv_support() { + if python3 - <<'PY' >/dev/null 2>&1 +import ensurepip +print(ensurepip.version()) +PY + then + return 0 + fi + + if command -v apt-get >/dev/null 2>&1; then + PYTHON_MM="$(python_minor_version)" + sudo DEBIAN_FRONTEND=noninteractive apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "python${PYTHON_MM}-venv" python3-venv >/dev/null || \ + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "python${PYTHON_MM}-venv" >/dev/null || \ + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y python3-venv >/dev/null + return 0 + fi + + echo "python3 is present but ensurepip/venv support is unavailable inside the Lima guest" >&2 + exit 1 +} + +venv_python_usable() { + [ -x "${VENV_PATH}/bin/python" ] || return 1 + "${VENV_PATH}/bin/python" -c 'import sys; print(sys.executable)' >/dev/null 2>&1 || return 1 + "${VENV_PATH}/bin/python" -m pip --version >/dev/null 2>&1 +} + +oci_installed_in_venv() { + "${VENV_PATH}/bin/python" - "${OCI_PIP_SPEC}" <<'PY' >/dev/null 2>&1 +import importlib.metadata +import sys + +expected = sys.argv[1] +try: + installed = importlib.metadata.version("oci") +except importlib.metadata.PackageNotFoundError: + raise SystemExit(1) +raise SystemExit(0 if installed == expected else 1) +PY +} + +ensure_venv_with_oci() { + mkdir -p "${VENV_ROOT}" + + if ! venv_python_usable; then + rm -rf "${VENV_PATH}" + python3 -m venv "${VENV_PATH}" + fi + + if oci_installed_in_venv; then + return 0 + fi + + PIP_DISABLE_PIP_VERSION_CHECK=1 "${VENV_PATH}/bin/python" -m pip install --upgrade pip >/dev/null + PIP_DISABLE_PIP_VERSION_CHECK=1 "${VENV_PATH}/bin/python" -m pip install "${OCI_PIP_SPEC}" +} + +ensure_manifest_present() { + [ -f "${MANIFEST_PATH}" ] || { + echo "Expected manifest at ${MANIFEST_PATH}" >&2 + exit 1 + } +} + +ensure_project_present() { + [ -d "${PROJECT_PATH}/oracle/oci_code_mcp_server" ] || { + echo "Expected staged project checkout at ${PROJECT_PATH}" >&2 + exit 1 + } +} + +run_firecracker_backend() { + local firecracker_cmd + firecracker_cmd="${OCI_CODE_LIMA_GUEST_FIRECRACKER_CMD:-${HOME}/.local/bin/oci-code-lima-firecracker-orchestrator}" + + [ -c /dev/kvm ] || { + echo "/dev/kvm is not available inside the Lima guest" >&2 + exit 1 + } + + [ -x "${firecracker_cmd}" ] || { + echo "Nested Firecracker delegate not found or not executable: ${firecracker_cmd}" >&2 + exit 1 + } + + export PYTHONPATH="${PROJECT_PATH}${PYTHONPATH:+:${PYTHONPATH}}" + export OCI_CODE_FIRECRACKER_DELEGATE_CMD="${firecracker_cmd}" + "${VENV_PATH}/bin/python" -m oracle.oci_code_mcp_server.runner --backend delegate --manifest "${MANIFEST_PATH}" +} + +ensure_python_present +ensure_venv_support +ensure_manifest_present +ensure_project_present +ensure_venv_with_oci +run_firecracker_backend + +[ -f "${RESULT_PATH}" ] || { + echo "Guest runner completed without writing ${RESULT_PATH}" >&2 + exit 1 +} diff --git a/src/oci-code-mcp-server/scripts/oci-code-lima-install-firecracker-guest.sh b/src/oci-code-mcp-server/scripts/oci-code-lima-install-firecracker-guest.sh new file mode 100755 index 00000000..a629c1c9 --- /dev/null +++ b/src/oci-code-mcp-server/scripts/oci-code-lima-install-firecracker-guest.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ARCH="$(uname -m)" + +INSTALL_ROOT="${OCI_CODE_LIMA_FIRECRACKER_HOME:-${HOME}/.local/share/oci-code-firecracker}" +BIN_DIR="${OCI_CODE_LIMA_FIRECRACKER_BIN_DIR:-${HOME}/.local/bin}" +CACHE_DIR="${INSTALL_ROOT}/downloads" +BUILD_DIR="${INSTALL_ROOT}/build" +ROOTFS_DIR="${BUILD_DIR}/rootfs" +ROOTFS_IMAGE="${INSTALL_ROOT}/rootfs.ext4" +KERNEL_IMAGE="${INSTALL_ROOT}/vmlinux" +METADATA_PATH="${INSTALL_ROOT}/build-metadata.json" +OCI_PIP_SPEC="${OCI_CODE_LIMA_GUEST_OCI_PIP_SPEC:-oci==2.160.0}" +DNS_SERVER="${OCI_CODE_LIMA_FIRECRACKER_DNS:-1.1.1.1}" +ROOTFS_VENV_DIR="/opt/oci-code-venv" +FORCE_REBUILD="${OCI_CODE_LIMA_FIRECRACKER_FORCE_REBUILD:-false}" + +usage() { + cat <<'EOF' +Usage: oci-code-lima-install-firecracker-guest.sh [--force] + +Install or refresh the nested Firecracker runtime inside the current Lima guest. + +Options: + --force Rebuild the inner Firecracker rootfs even if cached assets already match +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --force) + FORCE_REBUILD="true" + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +mkdir -p "${INSTALL_ROOT}" "${BIN_DIR}" "${CACHE_DIR}" "${BUILD_DIR}" + +require_tool() { + command -v "$1" >/dev/null 2>&1 || { + echo "Required tool not found: $1" >&2 + exit 1 + } +} + +require_tool curl +require_tool python3 +require_tool sha256sum +require_tool sudo +require_tool tar + +print_summary() { + local status_line="$1" + + cat <)(firecracker-ci/${ci_version}/${ARCH}/vmlinux-[0-9]+\.[0-9]+\.[0-9]{1,3})(?=)" \ + | sort -V | tail -1 +)" +latest_rootfs_key="$( + curl -fsSL "https://s3.amazonaws.com/spec.ccfc.min/?prefix=firecracker-ci/${ci_version}/${ARCH}/ubuntu-&list-type=2" \ + | grep -oP "(?<=)(firecracker-ci/${ci_version}/${ARCH}/ubuntu-[0-9]+\.[0-9]+\.squashfs)(?=)" \ + | sort -V | tail -1 +)" + +[ -n "${latest_kernel_key}" ] || { + echo "Unable to resolve a Firecracker CI kernel for ${ci_version}/${ARCH}" >&2 + exit 1 +} +[ -n "${latest_rootfs_key}" ] || { + echo "Unable to resolve a Firecracker CI Ubuntu rootfs for ${ci_version}/${ARCH}" >&2 + exit 1 +} + +current_init_sha="$(sha256sum "${SCRIPT_DIR}/oci-code-firecracker-microvm-init.sh" | awk '{print $1}')" + +if [ "${FORCE_REBUILD}" != "true" ] && metadata_matches "${current_init_sha}" "${latest_kernel_key}" "${latest_rootfs_key}"; then + install -m 0755 "${SCRIPT_DIR}/oci-code-lima-firecracker-orchestrator.sh" "${BIN_DIR}/oci-code-lima-firecracker-orchestrator" + sudo setfacl -m "u:${USER}:rw" /dev/kvm >/dev/null 2>&1 || true + print_summary "Reusing existing nested Firecracker assets inside the Lima guest." + exit 0 +fi + +if command -v apt-get >/dev/null 2>&1; then + sudo DEBIAN_FRONTEND=noninteractive apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \ + acl \ + ca-certificates \ + curl \ + e2fsprogs \ + iproute2 \ + iptables \ + jq \ + python3 \ + python3-pip \ + python3-venv \ + rsync \ + squashfs-tools \ + util-linux +fi + +if [ ! -f "${firecracker_tarball}" ]; then + curl -fL "${release_url}/download/${latest_version}/firecracker-${latest_version}-${ARCH}.tgz" -o "${firecracker_tarball}" +fi +rm -rf "${release_dir}" +tar -xzf "${firecracker_tarball}" -C "${CACHE_DIR}" + +install -m 0755 "${release_dir}/firecracker-${latest_version}-${ARCH}" "${BIN_DIR}/firecracker" +if [ -f "${release_dir}/jailer-${latest_version}-${ARCH}" ]; then + install -m 0755 "${release_dir}/jailer-${latest_version}-${ARCH}" "${BIN_DIR}/jailer" +fi + +kernel_download="${CACHE_DIR}/$(basename "${latest_kernel_key}")" +rootfs_squashfs="${CACHE_DIR}/$(basename "${latest_rootfs_key}")" + +[ -f "${kernel_download}" ] || curl -fL "https://s3.amazonaws.com/spec.ccfc.min/${latest_kernel_key}" -o "${kernel_download}" +[ -f "${rootfs_squashfs}" ] || curl -fL "https://s3.amazonaws.com/spec.ccfc.min/${latest_rootfs_key}" -o "${rootfs_squashfs}" + +sudo rm -rf "${ROOTFS_DIR}" +unsquashfs -f -d "${ROOTFS_DIR}" "${rootfs_squashfs}" >/dev/null + +sudo cp "${SCRIPT_DIR}/oci-code-firecracker-microvm-init.sh" "${ROOTFS_DIR}/oci-init" +sudo chmod 0755 "${ROOTFS_DIR}/oci-init" +sudo mkdir -p "${ROOTFS_DIR}/tmp" +sudo chmod 1777 "${ROOTFS_DIR}/tmp" +sudo mkdir -p "${ROOTFS_DIR}/var/cache/apt/archives/partial" "${ROOTFS_DIR}/var/lib/apt/lists/partial" +sudo mkdir -p "${ROOTFS_DIR}/var/log/apt" +printf 'nameserver %s\n' "${DNS_SERVER}" | sudo tee "${ROOTFS_DIR}/etc/resolv.conf" >/dev/null + +cleanup_chroot_mounts() { + set +e + for dir in dev sys proc; do + if mountpoint -q "${ROOTFS_DIR}/${dir}"; then + sudo umount "${ROOTFS_DIR}/${dir}" >/dev/null 2>&1 || true + fi + done +} +trap cleanup_chroot_mounts EXIT + +sudo mount --bind /dev "${ROOTFS_DIR}/dev" +sudo mount --bind /sys "${ROOTFS_DIR}/sys" +sudo mount --bind /proc "${ROOTFS_DIR}/proc" + +sudo chroot "${ROOTFS_DIR}" /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get update +sudo chroot "${ROOTFS_DIR}" /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get install -y \ + ca-certificates \ + iproute2 \ + python3 \ + python3-venv +sudo chroot "${ROOTFS_DIR}" rm -rf "${ROOTFS_VENV_DIR}" +sudo chroot "${ROOTFS_DIR}" python3 -m venv "${ROOTFS_VENV_DIR}" +sudo chroot "${ROOTFS_DIR}" "${ROOTFS_VENV_DIR}/bin/python" -m pip install --upgrade pip +sudo chroot "${ROOTFS_DIR}" "${ROOTFS_VENV_DIR}/bin/python" -m pip install "${OCI_PIP_SPEC}" +sudo chroot "${ROOTFS_DIR}" /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get clean + +cleanup_chroot_mounts +trap - EXIT + +sudo chown -R root:root "${ROOTFS_DIR}" + +rootfs_size_mb="$(( $(sudo du -sm "${ROOTFS_DIR}" | awk '{print $1}') + 1024 ))" +[ "${rootfs_size_mb}" -ge 1536 ] || rootfs_size_mb=1536 +truncate -s "${rootfs_size_mb}M" "${ROOTFS_IMAGE}" +sudo mkfs.ext4 -q -F -d "${ROOTFS_DIR}" "${ROOTFS_IMAGE}" + +install -m 0644 "${kernel_download}" "${KERNEL_IMAGE}" +install -m 0755 "${SCRIPT_DIR}/oci-code-lima-firecracker-orchestrator.sh" "${BIN_DIR}/oci-code-lima-firecracker-orchestrator" +write_metadata "${current_init_sha}" "${latest_kernel_key}" "${latest_rootfs_key}" + +sudo setfacl -m "u:${USER}:rw" /dev/kvm >/dev/null 2>&1 || true + +print_summary "Installed nested Firecracker assets inside the Lima guest." diff --git a/src/oci-code-mcp-server/scripts/oci-code-lima-setup-firecracker.sh b/src/oci-code-mcp-server/scripts/oci-code-lima-setup-firecracker.sh new file mode 100755 index 00000000..afe4efee --- /dev/null +++ b/src/oci-code-mcp-server/scripts/oci-code-lima-setup-firecracker.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTANCE="${OCI_CODE_LIMA_INSTANCE:-firecracker-dev}" +LIMACTL_BIN="${OCI_CODE_LIMACTL_BIN:-limactl}" +GUEST_TMP_ROOT="${OCI_CODE_LIMA_SETUP_TMP_ROOT:-/tmp/oci-code-firecracker-setup}" +GUEST_TMP_DIR="${GUEST_TMP_ROOT}/$(date +%s)" +FORCE_REBUILD=false + +usage() { + cat <<'EOF' +Usage: oci-code-lima-setup-firecracker.sh [--force] + +Prepare the current Lima guest to run a nested Firecracker microVM by: +- ensuring the Lima instance is running with nested virtualization +- copying the guest install/orchestrator scripts into the VM +- installing Firecracker, its kernel/rootfs assets, and the guest-local delegate command + +Environment: + OCI_CODE_LIMA_INSTANCE Lima instance name (default: firecracker-dev) + OCI_CODE_LIMACTL_BIN limactl binary path (default: limactl) + OCI_CODE_LIMA_GUEST_OCI_PIP_SPEC + OCI_CODE_LIMA_FIRECRACKER_VERSION + OCI_CODE_LIMA_FIRECRACKER_CI_VERSION + OCI_CODE_LIMA_FIRECRACKER_DNS + OCI_CODE_LIMA_FIRECRACKER_HOME + OCI_CODE_LIMA_FIRECRACKER_BIN_DIR +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --force) + FORCE_REBUILD=true + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +require_tool() { + command -v "$1" >/dev/null 2>&1 || { + echo "Required tool not found: $1" >&2 + exit 1 + } +} + +append_guest_export() { + local name="$1" + local value="${!name:-}" + [ -n "${value}" ] || return 0 + GUEST_EXPORTS="${GUEST_EXPORTS}export ${name}=$(printf '%q' "${value}") && " +} + +require_tool "${LIMACTL_BIN}" + +"${LIMACTL_BIN}" start -y --timeout 10m "${INSTANCE}" >/dev/null + +"${LIMACTL_BIN}" shell "${INSTANCE}" -- sh -lc "rm -rf '${GUEST_TMP_DIR}' && mkdir -p '${GUEST_TMP_DIR}'" +"${LIMACTL_BIN}" copy --backend auto \ + "${SCRIPT_DIR}/oci-code-lima-install-firecracker-guest.sh" \ + "${SCRIPT_DIR}/oci-code-lima-firecracker-orchestrator.sh" \ + "${SCRIPT_DIR}/oci-code-firecracker-microvm-init.sh" \ + "${INSTANCE}:${GUEST_TMP_DIR}/" + +GUEST_EXPORTS="" +append_guest_export "OCI_CODE_LIMA_GUEST_OCI_PIP_SPEC" +append_guest_export "OCI_CODE_LIMA_FIRECRACKER_VERSION" +append_guest_export "OCI_CODE_LIMA_FIRECRACKER_CI_VERSION" +append_guest_export "OCI_CODE_LIMA_FIRECRACKER_DNS" +append_guest_export "OCI_CODE_LIMA_FIRECRACKER_HOME" +append_guest_export "OCI_CODE_LIMA_FIRECRACKER_BIN_DIR" +if [ "${FORCE_REBUILD}" = "true" ]; then + GUEST_EXPORTS="${GUEST_EXPORTS}export OCI_CODE_LIMA_FIRECRACKER_FORCE_REBUILD=true && " +fi +INSTALL_ARGS="" +if [ "${FORCE_REBUILD}" = "true" ]; then + INSTALL_ARGS=" --force" +fi + +"${LIMACTL_BIN}" shell "${INSTANCE}" -- sh -lc \ + "${GUEST_EXPORTS}chmod +x '${GUEST_TMP_DIR}/oci-code-lima-install-firecracker-guest.sh' '${GUEST_TMP_DIR}/oci-code-lima-firecracker-orchestrator.sh' '${GUEST_TMP_DIR}/oci-code-firecracker-microvm-init.sh' && '${GUEST_TMP_DIR}/oci-code-lima-install-firecracker-guest.sh'${INSTALL_ARGS}" + +cat <