lib/openagents/plugins/manifest.ex

58e6347eeb72 · 14 KB

defmodule OpenAgents.Plugins.Manifest do
  @moduledoc """
  Typed manifest validation for plugin registries.

  A plugin manifest is the unit the registry indexes. It carries identity,
  a typed interface, declared capabilities, discovery text, and reserved
  economy fields. This module validates the wire shape without running the
  artifact and returns a field-keyed error for any malformed value.
  """

  alias __MODULE__.ValidationError

  defmodule ValidationError do
    @moduledoc "A typed validation refusal naming the field that failed."
    defstruct [:field, :reason]

    @type t :: %__MODULE__{
            field: String.t(),
            reason: atom()
          }
  end

  @manifest_keys ~w(manifest_version name version author description artifact
                    abi interface capabilities surfaces price_msats license)

  @required_keys ~w(manifest_version name version author description artifact
                    abi interface capabilities price_msats license)

  @name_pattern ~r/\A[a-z][a-z0-9_]{0,63}\z/
  @semver_pattern ~r/\A[0-9]+\.[0-9]+\.[0-9]+(?:[-+.A-Za-z0-9]+)?\z/
  @digest_pattern ~r/\Asha256:[0-9a-f]{64}\z/

  @schema_types MapSet.new(~w(object array string integer number boolean null))

  @doc "Validate a decoded manifest map and return the normalized manifest or a field-keyed error."
  @spec validate(map()) :: {:ok, map()} | {:error, ValidationError.t()}
  def validate(%{} = manifest) do
    with :ok <- required_keys(manifest, @required_keys),
         :ok <- exact_keys(manifest, @manifest_keys),
         :ok <- validate_identity(manifest),
         :ok <- validate_artifact(manifest["artifact"]),
         :ok <- validate_abi(manifest["abi"]),
         :ok <- validate_interface(manifest["interface"]),
         :ok <- validate_capabilities(manifest["capabilities"]),
         :ok <- validate_surfaces(manifest["surfaces"]),
         :ok <- validate_reserved(manifest) do
      {:ok, normalize(manifest)}
    end
  end

  def validate(_manifest), do: error("root", :not_a_map)

  defp required_keys(manifest, required) do
    keys = manifest |> Map.keys() |> Enum.map(&to_string/1) |> MapSet.new()

    case Enum.find(required, &(!MapSet.member?(keys, &1))) do
      nil -> :ok
      key -> error(key, :missing)
    end
  end

  defp exact_keys(manifest, allowed) do
    keys = Map.keys(manifest) |> Enum.map(&to_string/1) |> Enum.sort()
    allowed = Enum.sort(allowed)

    case keys -- allowed do
      [] -> :ok
      [extra | _] -> error(extra, :unexpected_field)
    end
  end

  defp exact_nested_keys(map, allowed, prefix) do
    keys = Map.keys(map) |> Enum.map(&to_string/1) |> Enum.sort()
    allowed = Enum.sort(allowed)

    case keys -- allowed do
      [] -> :ok
      [extra | _] -> error("#{prefix}.#{extra}", :unexpected_field)
    end
  end

  defp validate_identity(manifest) do
    with :ok <- require_integer(manifest, "manifest_version"),
         :ok <- require_string(manifest, "name", &valid_name?/1),
         :ok <- require_string(manifest, "version", &valid_version?/1),
         :ok <- require_string(manifest, "author", &non_empty?/1),
         :ok <- require_string(manifest, "description", &non_empty?/1) do
      :ok
    end
  end

  defp valid_name?(name), do: Regex.match?(@name_pattern, name)
  defp valid_version?(version), do: Regex.match?(@semver_pattern, version)
  defp non_empty?(value), do: is_binary(value) and String.trim(value) != ""

  defp validate_artifact(%{} = artifact) do
    with :ok <- exact_nested_keys(artifact, ~w(path digest), "artifact"),
         :ok <- require_string_value(artifact["path"], "artifact.path", &non_empty?/1),
         :ok <- require_string_value(artifact["digest"], "artifact.digest", &valid_digest?/1) do
      :ok
    end
  end

  defp validate_artifact(nil), do: error("artifact", :missing)
  defp validate_artifact(_), do: error("artifact", :not_a_map)

  defp valid_digest?(digest), do: Regex.match?(@digest_pattern, digest)

  defp validate_abi(%{} = abi) do
    with :ok <- exact_nested_keys(abi, ~w(kind entry alloc), "abi"),
         :ok <- require_string_value(abi["kind"], "abi.kind", &non_empty?/1),
         :ok <- require_string_value(abi["entry"], "abi.entry", &non_empty?/1),
         :ok <- require_string_value(abi["alloc"], "abi.alloc", &non_empty?/1) do
      :ok
    end
  end

  defp validate_abi(nil), do: error("abi", :missing)
  defp validate_abi(_), do: error("abi", :not_a_map)

  defp validate_interface(%{} = interface) do
    with :ok <- exact_nested_keys(interface, ~w(input output), "interface"),
         :ok <- validate_schema(interface["input"], "interface.input"),
         :ok <- validate_schema(interface["output"], "interface.output") do
      :ok
    end
  end

  defp validate_interface(nil), do: error("interface", :missing)
  defp validate_interface(_), do: error("interface", :not_a_map)

  defp validate_schema(nil, field), do: error(field, :missing)

  defp validate_schema(%{} = schema, field) do
    with :ok <- require_type(schema["type"], "#{field}.type"),
         :ok <- validate_schema_body(schema, field) do
      :ok
    end
  end

  defp validate_schema(_, field), do: error(field, :not_a_schema)

  defp require_type(nil, field), do: error(field, :missing)

  defp require_type(type, field) when is_binary(type) do
    if MapSet.member?(@schema_types, type),
      do: :ok,
      else: error(field, :invalid_type)
  end

  defp require_type(types, field) when is_list(types) and types != [] do
    Enum.reduce_while(types, :ok, fn type, :ok ->
      if is_binary(type) and MapSet.member?(@schema_types, type) do
        {:cont, :ok}
      else
        {:halt, error(field, :invalid_type)}
      end
    end)
  end

  defp require_type(_, field), do: error(field, :invalid_type)

  defp validate_schema_body(schema, field) do
    case schema["type"] do
      "object" -> validate_object_schema(schema, field)
      "array" -> validate_array_schema(schema, field)
      _ -> validate_simple_schema(schema, field)
    end
  end

  defp validate_object_schema(schema, field) do
    with :ok <-
           exact_nested_keys(
             schema,
             ~w(type properties required additionalProperties description),
             field
           ),
         :ok <- validate_schema_properties(schema["properties"], "#{field}.properties"),
         :ok <- validate_required_strings(schema["required"], "#{field}.required"),
         :ok <-
           validate_additional_properties(
             schema["additionalProperties"],
             "#{field}.additionalProperties"
           ) do
      :ok
    end
  end

  defp validate_schema_properties(nil, _field), do: :ok

  defp validate_schema_properties(%{} = props, field) do
    Enum.reduce_while(props, :ok, fn {name, sub_schema}, :ok ->
      key = to_string(name)

      case validate_schema(sub_schema, "#{field}.#{key}") do
        :ok -> {:cont, :ok}
        {:error, _} = err -> {:halt, err}
      end
    end)
  end

  defp validate_schema_properties(_, field), do: error(field, :not_a_map)

  defp validate_required_strings(nil, _field), do: :ok

  defp validate_required_strings(required, field) when is_list(required) do
    Enum.reduce_while(required, :ok, fn name, :ok ->
      case require_string_value(name, field, &non_empty?/1) do
        :ok -> {:cont, :ok}
        {:error, _} = err -> {:halt, err}
      end
    end)
  end

  defp validate_required_strings(_, field), do: error(field, :not_a_list)

  defp validate_additional_properties(nil, _field), do: :ok
  defp validate_additional_properties(value, _field) when is_boolean(value), do: :ok
  defp validate_additional_properties(_, field), do: error(field, :invalid_boolean)

  defp validate_array_schema(schema, field) do
    with :ok <- exact_nested_keys(schema, ~w(type items description), field),
         :ok <- validate_schema(schema["items"], "#{field}.items") do
      :ok
    end
  end

  defp validate_simple_schema(schema, field) do
    exact_nested_keys(schema, ~w(type description), field)
  end

  defp validate_capabilities(%{} = caps) do
    with :ok <-
           exact_nested_keys(caps, ~w(mounts hosts timeout_ms memory_max_mib), "capabilities"),
         :ok <- require_list(caps["mounts"], "capabilities.mounts"),
         :ok <- validate_mounts(caps["mounts"]),
         :ok <- require_list(caps["hosts"], "capabilities.hosts"),
         :ok <- validate_hosts(caps["hosts"]),
         :ok <- require_positive_integer(caps["timeout_ms"], "capabilities.timeout_ms"),
         :ok <- require_positive_integer(caps["memory_max_mib"], "capabilities.memory_max_mib") do
      :ok
    end
  end

  defp validate_capabilities(nil), do: error("capabilities", :missing)
  defp validate_capabilities(_), do: error("capabilities", :not_a_map)

  defp validate_mounts(mounts) when is_list(mounts) do
    Enum.reduce_while(Enum.with_index(mounts), :ok, fn {mount, idx}, :ok ->
      case validate_mount(mount, "capabilities.mounts.#{idx}") do
        :ok -> {:cont, :ok}
        {:error, _} = err -> {:halt, err}
      end
    end)
  end

  defp validate_mount(%{} = mount, field) do
    with :ok <- exact_nested_keys(mount, ~w(path readonly), field),
         :ok <- require_string_value(mount["path"], "#{field}.path", &non_empty?/1),
         :ok <- require_literal_true(mount["readonly"], "#{field}.readonly") do
      :ok
    end
  end

  defp validate_mount(_, field), do: error(field, :not_a_map)

  defp validate_hosts(hosts) when is_list(hosts) do
    Enum.reduce_while(Enum.with_index(hosts), :ok, fn {host, idx}, :ok ->
      case require_string_value(host, "capabilities.hosts.#{idx}", &non_empty?/1) do
        :ok -> {:cont, :ok}
        {:error, _} = err -> {:halt, err}
      end
    end)
  end

  defp validate_surfaces(nil), do: :ok

  defp validate_surfaces(surfaces) when is_list(surfaces) do
    Enum.reduce_while(Enum.with_index(surfaces), :ok, fn {surface, idx}, :ok ->
      case validate_surface(surface, "surfaces.#{idx}") do
        :ok -> {:cont, :ok}
        {:error, _} = err -> {:halt, err}
      end
    end)
  end

  defp validate_surfaces(_), do: error("surfaces", :not_a_list)

  defp validate_surface(%{} = surface, field) do
    with :ok <-
           exact_nested_keys(surface, ~w(kind name description slash_commands tools), field),
         :ok <- require_string_value(surface["name"], "#{field}.name", &non_empty?/1),
         :ok <-
           require_string_value(surface["description"], "#{field}.description", &non_empty?/1),
         :ok <- validate_slash_commands(surface["slash_commands"], "#{field}.slash_commands"),
         :ok <- validate_tools(surface["tools"], "#{field}.tools") do
      :ok
    end
  end

  defp validate_surface(_, field), do: error(field, :not_a_map)

  defp validate_slash_commands(nil, _field), do: :ok

  defp validate_slash_commands(commands, field) when is_list(commands) do
    Enum.reduce_while(Enum.with_index(commands), :ok, fn {cmd, idx}, :ok ->
      case validate_slash_command(cmd, "#{field}.#{idx}") do
        :ok -> {:cont, :ok}
        {:error, _} = err -> {:halt, err}
      end
    end)
  end

  defp validate_slash_commands(_, field), do: error(field, :not_a_list)

  defp validate_slash_command(%{} = cmd, field) do
    with :ok <- exact_nested_keys(cmd, ~w(command description), field),
         :ok <- require_string_value(cmd["command"], "#{field}.command", &non_empty?/1),
         :ok <- require_string_value(cmd["description"], "#{field}.description", &non_empty?/1) do
      :ok
    end
  end

  defp validate_slash_command(_, field), do: error(field, :not_a_map)

  defp validate_tools(nil, _field), do: :ok

  defp validate_tools(tools, field) when is_list(tools) do
    Enum.reduce_while(Enum.with_index(tools), :ok, fn {tool, idx}, :ok ->
      case validate_tool(tool, "#{field}.#{idx}") do
        :ok -> {:cont, :ok}
        {:error, _} = err -> {:halt, err}
      end
    end)
  end

  defp validate_tools(_, field), do: error(field, :not_a_list)

  defp validate_tool(%{} = tool, field) do
    with :ok <- exact_nested_keys(tool, ~w(name description), field),
         :ok <- require_string_value(tool["name"], "#{field}.name", &non_empty?/1),
         :ok <- require_string_value(tool["description"], "#{field}.description", &non_empty?/1) do
      :ok
    end
  end

  defp validate_tool(_, field), do: error(field, :not_a_map)

  defp validate_reserved(manifest) do
    with :ok <- validate_price(manifest["price_msats"]),
         :ok <- validate_license(manifest["license"]) do
      :ok
    end
  end

  defp validate_price(nil), do: :ok
  defp validate_price(price) when is_integer(price) and price >= 0, do: :ok
  defp validate_price(_), do: error("price_msats", :invalid_price_msats)

  defp validate_license(nil), do: :ok
  defp validate_license(license) when is_binary(license), do: :ok
  defp validate_license(_), do: error("license", :invalid_license)

  defp require_integer(manifest, key) do
    case manifest[key] do
      value when is_integer(value) and value > 0 -> :ok
      nil -> error(key, :missing)
      _ -> error(key, :invalid_integer)
    end
  end

  defp require_string(manifest, key, pred) do
    case manifest[key] do
      nil ->
        error(key, :missing)

      value when is_binary(value) ->
        if pred.(value), do: :ok, else: error(key, :invalid_string)

      _ ->
        error(key, :invalid_string)
    end
  end

  defp require_string_value(nil, key, _pred), do: error(key, :missing)

  defp require_string_value(value, key, pred) when is_binary(value) do
    if pred.(value), do: :ok, else: error(key, :invalid_string)
  end

  defp require_string_value(_, key, _pred), do: error(key, :invalid_string)

  defp require_list(nil, key), do: error(key, :missing)
  defp require_list(value, _key) when is_list(value), do: :ok
  defp require_list(_, key), do: error(key, :not_a_list)

  defp require_literal_true(nil, field), do: error(field, :missing)
  defp require_literal_true(true, _field), do: :ok
  defp require_literal_true(_, field), do: error(field, :invalid_readonly)

  defp require_positive_integer(nil, key), do: error(key, :missing)

  defp require_positive_integer(value, _key) when is_integer(value) and value > 0,
    do: :ok

  defp require_positive_integer(_, key), do: error(key, :invalid_positive_integer)

  defp error(field, reason) do
    {:error, %ValidationError{field: to_string(field), reason: reason}}
  end

  defp normalize(manifest) do
    Map.new(manifest, fn {key, value} -> {to_string(key), normalize_value(value)} end)
  end

  defp normalize_value(%{} = map),
    do: Map.new(map, fn {k, v} -> {to_string(k), normalize_value(v)} end)

  defp normalize_value(list) when is_list(list), do: Enum.map(list, &normalize_value/1)
  defp normalize_value(value), do: value
end