diff --git a/lib/ourocode/plugin/user_level/artifact_watcher.ex b/lib/ourocode/plugin/user_level/artifact_watcher.ex new file mode 100644 index 0000000..0ac951a --- /dev/null +++ b/lib/ourocode/plugin/user_level/artifact_watcher.ex @@ -0,0 +1,114 @@ +defmodule Ourocode.Plugin.UserLevel.ArtifactWatcher do + @moduledoc """ + Scans for artifact paths declared by a UserLevel plugin command after the + plugin run completes. + + Only the globs published by the plugin's own + `CommandCapability.expected_artifacts` list are considered. `ourocode` + never hardcodes plugin-internal storage paths. + + This module is pure and has no GenServer / process state. It is invoked + by `Ourocode.Runtime.UserLevelPluginInvocation` right after the + external command runner returns. + """ + + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + @type artifact :: %{ + required(:kind) => :seed | :handoff | :report | :log | :other, + required(:path) => String.t(), + required(:glob) => String.t(), + optional(:size) => non_neg_integer(), + optional(:digest) => String.t(), + optional(:generated_at) => DateTime.t() + } + + @doc """ + Returns the matched artifact list for the given command capability and cwd. + + When `:lstat?` is `true` (default), each matched path gets size, digest, and + generated_at fields populated from the local file system. Pass `:lstat?: + false` to keep the scan filesystem-free (useful in tests where the file + doesn't need to exist). + """ + @spec scan(CommandCapability.t(), Path.t(), keyword()) :: [artifact()] + def scan(%CommandCapability{expected_artifacts: globs}, cwd, opts \\ []) + when is_binary(cwd) and is_list(globs) do + lstat? = Keyword.get(opts, :lstat?, true) + + globs + |> Enum.flat_map(fn glob -> expand_glob(glob, cwd, lstat?) end) + |> Enum.uniq_by(& &1.path) + end + + defp expand_glob(glob, cwd, lstat?) do + full_glob = Path.expand(glob, cwd) + + full_glob + |> Path.wildcard(match_dot: false) + |> Enum.map(fn path -> build_artifact(path, glob, lstat?) end) + end + + defp build_artifact(path, glob, lstat?) do + base = %{ + kind: classify(path), + path: path, + glob: glob + } + + if lstat? do + add_lstat(base, path) + else + base + end + end + + defp add_lstat(artifact, path) do + case File.stat(path, time: :posix) do + {:ok, %File.Stat{size: size, mtime: mtime}} -> + artifact + |> Map.put(:size, size) + |> Map.put(:generated_at, posix_to_datetime(mtime)) + |> maybe_digest(path) + + {:error, _reason} -> + artifact + end + end + + defp posix_to_datetime(seconds) when is_integer(seconds) do + DateTime.from_unix!(seconds) + end + + defp maybe_digest(artifact, path) do + # Only digest small text artifacts (Seed/handoff are markdown; size cap is + # to avoid hashing arbitrary plugin-emitted blobs). + case artifact do + %{size: size} when is_integer(size) and size <= 1_048_576 -> + case File.read(path) do + {:ok, content} -> + digest = :crypto.hash(:sha256, content) |> Base.encode16(case: :lower) + Map.put(artifact, :digest, "sha256:" <> digest) + + {:error, _reason} -> + artifact + end + + _other -> + artifact + end + end + + defp classify(path) do + basename = path |> Path.basename() |> String.downcase() + + cond do + basename == "seed.md" -> :seed + basename == "handoff.md" -> :handoff + basename in ["report.md", "evidence.json"] -> :report + String.ends_with?(basename, ".log") -> :log + String.ends_with?(basename, ".jsonl") and String.contains?(basename, "audit") -> :log + true -> :other + end + end +end diff --git a/lib/ourocode/plugin/user_level/capability.ex b/lib/ourocode/plugin/user_level/capability.ex new file mode 100644 index 0000000..f157e82 --- /dev/null +++ b/lib/ourocode/plugin/user_level/capability.ex @@ -0,0 +1,124 @@ +defmodule Ourocode.Plugin.UserLevel.Capability do + @moduledoc """ + Normalized identity and command surface for one installed Ouroboros + UserLevel plugin. + + `ourocode` only consumes this struct. Ouroboros remains the source of truth + for installation, trust, and execution; this module describes what was + discovered so the runtime can route, preflight, and render UserLevel plugin + commands without guessing. + """ + + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + @enforce_keys [:plugin_id, :source] + defstruct plugin_id: nil, + plugin_name: nil, + source: nil, + version: nil, + install_scope: :unknown, + trust_scope: [], + manifest_digest: nil, + commands: [], + discovered_at: nil, + resolution_origin: %{} + + @type install_scope :: :user | :workspace | :unknown + @type trust_scope :: String.t() + @type source :: :ouroboros_cli | :ouroboros_mcp | :fixture + + @type t :: %__MODULE__{ + plugin_id: String.t(), + plugin_name: String.t() | nil, + source: source(), + version: String.t() | nil, + install_scope: install_scope(), + trust_scope: [trust_scope()], + manifest_digest: String.t() | nil, + commands: [CommandCapability.t()], + discovered_at: DateTime.t() | nil, + resolution_origin: map() + } + + @valid_sources [:ouroboros_cli, :ouroboros_mcp, :fixture] + @valid_scopes [:user, :workspace, :unknown] + + @doc """ + Builds a `Capability` from a normalized descriptor produced by a discovery + adapter. + + Required fields: + * `plugin_id` (non-empty string) + * `source` (atom in `#{inspect(@valid_sources)}`) + + Invalid command descriptors are dropped silently so a single bad command + does not lose the whole plugin. Top-level shape violations return + `{:error, :invalid_capability_attrs}`. + """ + @spec new(map()) :: {:ok, t()} | {:error, :invalid_capability_attrs} + def new(%{plugin_id: id, source: source} = attrs) + when is_binary(id) and id != "" and source in @valid_sources do + commands = + attrs + |> Map.get(:commands, []) + |> List.wrap() + |> Enum.flat_map(fn descriptor -> + case CommandCapability.new(descriptor) do + {:ok, command} -> [command] + {:error, _reason} -> [] + end + end) + + install_scope = Map.get(attrs, :install_scope, :unknown) + install_scope = if install_scope in @valid_scopes, do: install_scope, else: :unknown + + {:ok, + %__MODULE__{ + plugin_id: id, + plugin_name: Map.get(attrs, :plugin_name) || id, + source: source, + version: Map.get(attrs, :version), + install_scope: install_scope, + trust_scope: normalize_scopes(Map.get(attrs, :trust_scope, [])), + manifest_digest: Map.get(attrs, :manifest_digest), + commands: commands, + discovered_at: Map.get(attrs, :discovered_at) || DateTime.utc_now(), + resolution_origin: Map.get(attrs, :resolution_origin, %{}) + }} + end + + def new(_attrs), do: {:error, :invalid_capability_attrs} + + @doc """ + Canonical identity tuple used for cache equality and identity stability. + + Two capabilities with the same `{plugin_id, version, manifest_digest}` are + considered the same artifact even across re-discoveries. + """ + @spec identity(t()) :: {String.t(), String.t() | nil, String.t() | nil} + def identity(%__MODULE__{plugin_id: id, version: version, manifest_digest: digest}) do + {id, version, digest} + end + + @doc """ + Finds a command on the capability by canonical name or alias. + + Returns `nil` when neither matches; callers should treat that as + `:unknown` rather than guessing. + """ + @spec find_command(t(), String.t()) :: CommandCapability.t() | nil + def find_command(%__MODULE__{commands: commands}, token) when is_binary(token) do + Enum.find(commands, fn cmd -> + cmd.name == token or token in cmd.aliases + end) + end + + defp normalize_scopes(scopes) do + scopes + |> List.wrap() + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end +end diff --git a/lib/ourocode/plugin/user_level/capability/command.ex b/lib/ourocode/plugin/user_level/capability/command.ex new file mode 100644 index 0000000..28aef96 --- /dev/null +++ b/lib/ourocode/plugin/user_level/capability/command.ex @@ -0,0 +1,129 @@ +defmodule Ourocode.Plugin.UserLevel.Capability.Command do + @moduledoc """ + Per-command capability metadata declared by an installed UserLevel plugin. + + This struct is read-only and never owns trust state, execution, or storage + paths. Trust and execution live in Ouroboros; storage paths are derived from + `expected_artifacts` glob declarations the plugin itself publishes. + """ + + @enforce_keys [:name] + defstruct name: nil, + aliases: [], + summary: nil, + args: [], + risk_class: :unknown, + expected_artifacts: [], + continuation_hint: :none + + @type risk_class :: :read_only | :handoff_producing | :destructive | :unknown + @type continuation_hint :: :none | :suggest_run | :auto_run_when_requested + @type arg :: %{ + required(:name) => String.t(), + required(:required?) => boolean(), + required(:repeatable?) => boolean(), + required(:description) => String.t() + } + + @type t :: %__MODULE__{ + name: String.t(), + aliases: [String.t()], + summary: String.t() | nil, + args: [arg()], + risk_class: risk_class(), + expected_artifacts: [String.t()], + continuation_hint: continuation_hint() + } + + @doc """ + Builds a `Command` capability from a normalized descriptor. + + Returns `{:error, :invalid_command_attrs}` when `name` is missing or blank + so the registry can drop the descriptor without aborting discovery of the + surrounding plugin. + """ + @spec new(map()) :: {:ok, t()} | {:error, :invalid_command_attrs} + def new(%{name: name} = attrs) when is_binary(name) and name != "" do + {:ok, + %__MODULE__{ + name: name, + aliases: normalize_aliases(Map.get(attrs, :aliases, [])), + summary: normalize_summary(Map.get(attrs, :summary)), + args: normalize_args(Map.get(attrs, :args, [])), + risk_class: normalize_risk(Map.get(attrs, :risk_class, :unknown)), + expected_artifacts: normalize_artifacts(Map.get(attrs, :expected_artifacts, [])), + continuation_hint: normalize_continuation(Map.get(attrs, :continuation_hint, :none)) + }} + end + + def new(_attrs), do: {:error, :invalid_command_attrs} + + defp normalize_aliases(aliases) do + aliases + |> List.wrap() + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp normalize_summary(nil), do: nil + defp normalize_summary(value) when is_binary(value), do: value + defp normalize_summary(_other), do: nil + + defp normalize_args(args) do + args + |> List.wrap() + |> Enum.map(&normalize_arg/1) + |> Enum.reject(&(&1.name == "")) + end + + defp normalize_arg(%{name: name} = attrs) when is_binary(name) do + %{ + name: name, + required?: truthy?(Map.get(attrs, :required?, Map.get(attrs, :required, false))), + repeatable?: truthy?(Map.get(attrs, :repeatable?, Map.get(attrs, :repeatable, false))), + description: to_description(Map.get(attrs, :description, "")) + } + end + + defp normalize_arg(name) when is_binary(name) do + %{name: name, required?: false, repeatable?: false, description: ""} + end + + defp normalize_arg(_other), + do: %{name: "", required?: false, repeatable?: false, description: ""} + + defp normalize_artifacts(artifacts) do + artifacts + |> List.wrap() + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp normalize_risk(value) + when value in [:read_only, :handoff_producing, :destructive, :unknown], + do: value + + defp normalize_risk("read_only"), do: :read_only + defp normalize_risk("handoff_producing"), do: :handoff_producing + defp normalize_risk("destructive"), do: :destructive + defp normalize_risk(_other), do: :unknown + + defp normalize_continuation(value) + when value in [:none, :suggest_run, :auto_run_when_requested], + do: value + + defp normalize_continuation("none"), do: :none + defp normalize_continuation("suggest_run"), do: :suggest_run + defp normalize_continuation("auto_run_when_requested"), do: :auto_run_when_requested + defp normalize_continuation(_other), do: :none + + defp to_description(value) when is_binary(value), do: value + defp to_description(_other), do: "" + + defp truthy?(true), do: true + defp truthy?("true"), do: true + defp truthy?(_other), do: false +end diff --git a/lib/ourocode/plugin/user_level/continuation.ex b/lib/ourocode/plugin/user_level/continuation.ex new file mode 100644 index 0000000..472ace0 --- /dev/null +++ b/lib/ourocode/plugin/user_level/continuation.ex @@ -0,0 +1,136 @@ +defmodule Ourocode.Plugin.UserLevel.Continuation do + @moduledoc """ + Decides whether a UserLevel plugin run should be followed up with an + Ouroboros workflow step (`ooo run seed_path=...`), and whether to auto-run + that follow-up or merely suggest it. + + Policy is intentionally conservative: + + * `:read_only` commands → no continuation. + * `:handoff_producing` commands with a detected seed artifact → suggest + a continuation. Auto-run only when the original prompt contains an + explicit opt-in phrase such as "then run the generated handoff" (en) + or "이어서 실행" (ko). + * `:destructive` commands → never auto-continue. The continuation card + may be suggested but always requires explicit approval. + + Auto-run intent detection is a small allow-list of substrings. Free-form + natural-language detection is out of scope. + """ + + alias Ourocode.Plugin.UserLevel.ArtifactWatcher + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + alias Ourocode.Plugin.UserLevel.PreflightResult + + @type decision :: %{ + required(:action) => :none | :suggest | :auto_run, + required(:seed_path) => String.t() | nil, + required(:command_template) => String.t() | nil, + required(:reason) => atom() + } + + # Explicit opt-in phrases. Keep this list short and review-friendly. + @auto_run_intents [ + "then run the generated handoff", + "then run the seed", + "and run the seed", + "이어서 실행", + "이후 실행" + ] + + @doc """ + Decides the continuation action for a finished run. + + Inputs: + + * `preflight` — the original PreflightResult (provides risk class + + continuation policy + the user's task_input). + * `artifacts` — list of artifacts produced by the run (typically the + output of `ArtifactWatcher.scan/3`). + """ + @spec decide(PreflightResult.t(), [ArtifactWatcher.artifact()]) :: decision() + def decide(%PreflightResult{} = preflight, artifacts) when is_list(artifacts) do + seed = Enum.find(artifacts, &(&1.kind == :seed)) + + case continuation_action(preflight, seed) do + :auto_run -> + %{ + action: :auto_run, + seed_path: seed && seed.path, + command_template: command_template(seed), + reason: :auto_run_requested + } + + :suggest -> + %{ + action: :suggest, + seed_path: seed && seed.path, + command_template: command_template(seed), + reason: suggest_reason(preflight) + } + + :none -> + %{ + action: :none, + seed_path: nil, + command_template: nil, + reason: none_reason(preflight, seed) + } + end + end + + defp continuation_action(%PreflightResult{kind: :unique_match} = preflight, seed) do + cond do + preflight.risk_class == :read_only -> + :none + + preflight.risk_class == :destructive -> + if seed, do: :suggest, else: :none + + seed == nil -> + :none + + auto_run_requested?(preflight.task_input) and + allows_auto_run?(preflight.command) -> + :auto_run + + true -> + :suggest + end + end + + defp continuation_action(_preflight, _seed), do: :none + + defp allows_auto_run?(%CommandCapability{continuation_hint: hint}) do + hint in [:auto_run_when_requested, :suggest_run] + end + + defp allows_auto_run?(_command), do: false + + @doc """ + Returns `true` when the user's input contains an explicit opt-in phrase + for auto-running the generated continuation. Public so callers can preview + the intent without re-running the decision. + """ + @spec auto_run_requested?(String.t()) :: boolean() + def auto_run_requested?(task_input) when is_binary(task_input) do + normalized = String.downcase(task_input) + Enum.any?(@auto_run_intents, &String.contains?(normalized, &1)) + end + + def auto_run_requested?(_other), do: false + + defp command_template(nil), do: nil + defp command_template(%{path: path}), do: "ooo run seed_path=#{path}" + + defp suggest_reason(%PreflightResult{risk_class: :destructive}), + do: :destructive_requires_explicit_approval + + defp suggest_reason(_preflight), do: :user_confirmation_required + + defp none_reason(%PreflightResult{risk_class: :read_only}, _seed), do: :read_only_command + + defp none_reason(_preflight, nil), do: :no_continuation_artifact + + defp none_reason(_preflight, _seed), do: :no_continuation_policy +end diff --git a/lib/ourocode/plugin/user_level/decision_journal.ex b/lib/ourocode/plugin/user_level/decision_journal.ex new file mode 100644 index 0000000..933e7e4 --- /dev/null +++ b/lib/ourocode/plugin/user_level/decision_journal.ex @@ -0,0 +1,132 @@ +defmodule Ourocode.Plugin.UserLevel.DecisionJournal do + @moduledoc """ + Appends one structured event per UserLevel plugin decision phase into the + existing `Ourocode.Journal.Writer`. + + Four phases are recorded so the audit trail can answer "why did ourocode + pick this plugin, did it run, what did it produce, did the user continue": + + * `:user_level_preflight` — the PreflightResult that drove dispatch. + * `:user_level_dispatch` — the invocation envelope (argv, status, + blocked reason or execution status). + * `:user_level_artifact` — one event per produced artifact (so the + audit can reconstruct which files attached to which task). + * `:user_level_continuation` — the continuation decision (none / + suggest / auto_run, with seed path and reason). + + Events are written via `Journal.Writer.append/2` so they share the same + durable format and sequencing rules as the rest of the runtime journal. + The module is a thin shape-builder; it never decides what to log on its + own. + """ + + alias Ourocode.Journal.Writer + alias Ourocode.Plugin.UserLevel.ArtifactWatcher + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.PreflightResult + alias Ourocode.Plugin.UserLevel.PreflightView + + @type writer :: (map() -> :ok | {:error, term()}) + + @doc """ + Logs a preflight event. + + `journal` may be a `Path.t()` (passed straight to `Journal.Writer.append/2`) + or a 1-arity function for tests (`fn event -> :ok end`). + """ + @spec log_preflight(any(), String.t(), PreflightResult.t()) :: :ok | {:error, term()} + def log_preflight(journal, task_request_id, %PreflightResult{} = preflight) do + append(journal, base_event(:user_level_preflight, task_request_id, %{ + preflight: PreflightView.project(preflight) + })) + end + + @doc """ + Logs a dispatch envelope event. + """ + @spec log_dispatch(any(), String.t(), map()) :: :ok | {:error, term()} + def log_dispatch(journal, task_request_id, invocation) when is_map(invocation) do + append(journal, base_event(:user_level_dispatch, task_request_id, %{ + status: Map.get(invocation, :status), + command: Map.get(invocation, :command), + argv: Map.get(invocation, :argv), + blocked_reason: Map.get(invocation, :blocked_reason), + execution_status: get_in(invocation, [:execution, :status]), + plugin_id: get_in(invocation, [:preflight, Access.key(:plugin), Access.key(:plugin_id)]) + })) + end + + @doc """ + Logs one event per produced artifact. + + No-op when the artifact list is empty. + """ + @spec log_artifacts(any(), String.t(), [ArtifactWatcher.artifact()]) :: + :ok | {:error, term()} + def log_artifacts(_journal, _task_request_id, []), do: :ok + + def log_artifacts(journal, task_request_id, artifacts) when is_list(artifacts) do + Enum.reduce_while(artifacts, :ok, fn artifact, _acc -> + event = base_event(:user_level_artifact, task_request_id, %{ + artifact_kind: Map.get(artifact, :kind), + path: Map.get(artifact, :path), + glob: Map.get(artifact, :glob), + size: Map.get(artifact, :size), + digest: Map.get(artifact, :digest), + generated_at: maybe_iso(Map.get(artifact, :generated_at)) + }) + + case append(journal, event) do + :ok -> {:cont, :ok} + {:error, _reason} = err -> {:halt, err} + end + end) + end + + @doc """ + Logs the continuation decision (none / suggest / auto_run). + """ + @spec log_continuation(any(), String.t(), map()) :: :ok | {:error, term()} + def log_continuation(journal, task_request_id, decision) when is_map(decision) do + append(journal, base_event(:user_level_continuation, task_request_id, %{ + action: Map.get(decision, :action), + seed_path: Map.get(decision, :seed_path), + command_template: Map.get(decision, :command_template), + reason: Map.get(decision, :reason) + })) + end + + defp base_event(type, task_request_id, payload) do + %{ + "event_type" => Atom.to_string(type), + "task_request_id" => to_string(task_request_id), + "recorded_at_ms" => System.system_time(:millisecond), + "payload" => stringify(payload) + } + end + + defp append(journal, event) when is_function(journal, 1), do: journal.(event) + + defp append(journal, event) when is_binary(journal) do + Writer.append(journal, event) + end + + defp append(_journal, _event), do: {:error, :invalid_journal_target} + + defp maybe_iso(nil), do: nil + defp maybe_iso(%DateTime{} = dt), do: DateTime.to_iso8601(dt) + defp maybe_iso(other), do: other + + defp stringify(map) when is_map(map) do + Map.new(map, fn {k, v} -> {to_string(k), stringify_value(v)} end) + end + + defp stringify_value(value) when is_map(value), do: stringify(value) + defp stringify_value(value) when is_list(value), do: Enum.map(value, &stringify_value/1) + defp stringify_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt) + defp stringify_value(%Capability{} = cap), do: stringify(Map.from_struct(cap)) + defp stringify_value(value) when is_atom(value) and not is_boolean(value) and not is_nil(value), + do: Atom.to_string(value) + + defp stringify_value(value), do: value +end diff --git a/lib/ourocode/plugin/user_level/discovery.ex b/lib/ourocode/plugin/user_level/discovery.ex new file mode 100644 index 0000000..79b2de8 --- /dev/null +++ b/lib/ourocode/plugin/user_level/discovery.ex @@ -0,0 +1,50 @@ +defmodule Ourocode.Plugin.UserLevel.Discovery do + @moduledoc """ + Behaviour for discovering installed Ouroboros UserLevel plugins. + + Discovery adapters are read-only: they ask Ouroboros which plugins are + installed and return one descriptor per plugin. They must never install, + trust, escalate, or execute plugin code. Caching, freshness, and identity + stability live in `Ourocode.Plugin.UserLevel.Registry`, not in the adapter. + + The behaviour is transport-neutral: a CLI adapter and an MCP adapter can + both satisfy it, and the registry treats them interchangeably. + """ + + alias Ourocode.Plugin.UserLevel.Capability + + @type raw_descriptor :: map() + @type discovery_options :: keyword() | map() + @type discovery_result :: + {:ok, [raw_descriptor()]} + | {:error, atom() | {atom(), term()}} + + @callback discover(discovery_options()) :: discovery_result() + + @doc """ + Runs an adapter and normalizes raw descriptors into `Capability` structs. + + Per-descriptor validation failures are reported separately so the registry + can keep the good capabilities while logging the bad ones. Adapter-level + failures bubble up unchanged. + """ + @spec run(module(), discovery_options()) :: + {:ok, [Capability.t()], [{:invalid_descriptor, term()}]} + | {:error, term()} + def run(adapter, opts \\ []) when is_atom(adapter) do + with {:ok, descriptors} <- adapter.discover(opts) do + {capabilities, errors} = + Enum.reduce(descriptors, {[], []}, fn descriptor, {ok_acc, err_acc} -> + case Capability.new(descriptor) do + {:ok, capability} -> + {[capability | ok_acc], err_acc} + + {:error, reason} -> + {ok_acc, [{:invalid_descriptor, {reason, descriptor}} | err_acc]} + end + end) + + {:ok, Enum.reverse(capabilities), Enum.reverse(errors)} + end + end +end diff --git a/lib/ourocode/plugin/user_level/discovery/ouroboros_cli.ex b/lib/ourocode/plugin/user_level/discovery/ouroboros_cli.ex new file mode 100644 index 0000000..c79d61f --- /dev/null +++ b/lib/ourocode/plugin/user_level/discovery/ouroboros_cli.ex @@ -0,0 +1,170 @@ +defmodule Ourocode.Plugin.UserLevel.Discovery.OuroborosCLI do + @moduledoc """ + Discovers installed UserLevel plugins by invoking + `ouroboros plugin list --json`. + + This adapter is the first-class discovery surface until a dedicated MCP + plugin-list tool exists. It is read-only: it never installs, trusts, or + executes plugin code. + + Tests inject a stub runner via the `:command_runner` option so that no + external process is spawned. The runner contract is + `runner.(command, args, opts) :: {:ok, %{status: integer, stdout: binary, + stderr: binary}} | {:error, term()}`. + """ + + @behaviour Ourocode.Plugin.UserLevel.Discovery + + alias Ourocode.Json + + @default_command "ouroboros" + @default_args ["plugin", "list", "--json"] + @default_timeout_ms 5_000 + + @impl true + @spec discover(keyword() | map()) :: {:ok, [map()]} | {:error, term()} + def discover(opts \\ []) do + opts = if is_map(opts), do: opts, else: Map.new(opts) + command = Map.get(opts, :command, @default_command) + args = Map.get(opts, :args, @default_args) + runner = Map.get(opts, :command_runner, &default_runner/3) + timeout_ms = Map.get(opts, :timeout_ms, @default_timeout_ms) + + case runner.(command, args, %{timeout_ms: timeout_ms}) do + {:ok, %{status: 0, stdout: stdout}} -> + parse(stdout) + + {:ok, %{status: status} = result} when status != 0 -> + {:error, + {:ouroboros_cli_failed, + %{exit_status: status, stderr: Map.get(result, :stderr, "")}}} + + {:ok, other} -> + {:error, {:ouroboros_cli_unexpected_result, other}} + + {:error, reason} -> + {:error, {:ouroboros_cli_unavailable, reason}} + end + end + + @doc """ + Parses an `ouroboros plugin list --json` payload into the descriptor shape + expected by `Ourocode.Plugin.UserLevel.Capability.new/1`. + + Public so tests can validate the parser without going through the runner + indirection. + """ + @spec parse(binary()) :: {:ok, [map()]} | {:error, term()} + def parse(stdout) when is_binary(stdout) do + case Json.decode(stdout) do + {:ok, %{"plugins" => plugins}} when is_list(plugins) -> + {:ok, Enum.map(plugins, &normalize_plugin/1)} + + {:ok, plugins} when is_list(plugins) -> + {:ok, Enum.map(plugins, &normalize_plugin/1)} + + {:ok, _other} -> + {:error, :ouroboros_cli_unexpected_shape} + + {:error, reason} -> + {:error, {:ouroboros_cli_invalid_json, reason}} + end + end + + defp normalize_plugin(plugin) when is_map(plugin) do + %{ + plugin_id: read(plugin, ["id", "plugin_id", "name"]), + plugin_name: read(plugin, ["name", "display_name", "id"]), + source: :ouroboros_cli, + version: read(plugin, ["version"]), + install_scope: normalize_scope(read(plugin, ["install_scope", "scope"])), + trust_scope: + plugin + |> read(["trust_scope", "trust_scopes"], []) + |> List.wrap() + |> Enum.filter(&is_binary/1), + manifest_digest: read(plugin, ["manifest_digest", "digest"]), + commands: + plugin + |> read(["commands"], []) + |> List.wrap() + |> Enum.map(&normalize_command/1), + resolution_origin: %{ + adapter: __MODULE__, + call: %{command: @default_command, args: @default_args} + } + } + end + + defp normalize_plugin(_other), do: %{plugin_id: nil, source: :ouroboros_cli} + + defp normalize_command(cmd) when is_map(cmd) do + %{ + name: read(cmd, ["name", "command"]) || "", + aliases: cmd |> read(["aliases"], []) |> List.wrap() |> Enum.filter(&is_binary/1), + summary: read(cmd, ["summary", "description"]), + args: + cmd + |> read(["args", "arguments"], []) + |> List.wrap() + |> Enum.map(&normalize_arg/1), + risk_class: read(cmd, ["risk_class", "risk"]), + expected_artifacts: + cmd + |> read(["expected_artifacts", "artifacts"], []) + |> List.wrap() + |> Enum.filter(&is_binary/1), + continuation_hint: read(cmd, ["continuation_hint", "continuation"]) + } + end + + defp normalize_command(_other), do: %{name: ""} + + defp normalize_arg(arg) when is_map(arg) do + %{ + name: read(arg, ["name", "arg"]) || "", + required?: truthy?(read(arg, ["required", "required?"])), + repeatable?: truthy?(read(arg, ["repeatable", "repeatable?"])), + description: to_string_safe(read(arg, ["description", "summary"])) + } + end + + defp normalize_arg(arg) when is_binary(arg), + do: %{name: arg, required?: false, repeatable?: false, description: ""} + + defp normalize_arg(_other), + do: %{name: "", required?: false, repeatable?: false, description: ""} + + defp normalize_scope("user"), do: :user + defp normalize_scope("workspace"), do: :workspace + defp normalize_scope("project"), do: :workspace + defp normalize_scope(_other), do: :unknown + + defp read(map, keys, default \\ nil) when is_map(map) and is_list(keys) do + Enum.find_value(keys, default, fn key -> + case Map.get(map, key) do + nil -> nil + "" -> nil + value -> value + end + end) + end + + defp to_string_safe(nil), do: "" + defp to_string_safe(value) when is_binary(value), do: value + defp to_string_safe(value), do: to_string(value) + + defp truthy?(true), do: true + defp truthy?("true"), do: true + defp truthy?(_other), do: false + + defp default_runner(command, args, _opts) when is_binary(command) and is_list(args) do + case System.cmd(command, args, stderr_to_stdout: false) do + {output, 0} -> {:ok, %{status: 0, stdout: output, stderr: ""}} + {output, status} -> {:ok, %{status: status, stdout: "", stderr: output}} + end + rescue + error in [ErlangError, File.Error, System.EnvError] -> + {:error, {:command_runner_raised, Exception.message(error)}} + end +end diff --git a/lib/ourocode/plugin/user_level/entry.ex b/lib/ourocode/plugin/user_level/entry.ex new file mode 100644 index 0000000..ced9d55 --- /dev/null +++ b/lib/ourocode/plugin/user_level/entry.ex @@ -0,0 +1,74 @@ +defmodule Ourocode.Plugin.UserLevel.Entry do + @moduledoc """ + Entry point that the runtime calls to decide whether an incoming + `TaskRequest` should be routed to the UserLevel plugin adapter. + + This is the small router refinement step that keeps + `Ourocode.Runtime.Router` itself transport- and registry-agnostic: + + * Router does coarse classification (`ooo`/`ouroboros` → ouroboros + workflow + adapter_route). + * `Entry.refine/2` looks at the resolved capability list and, if the + task input targets a known UserLevel plugin, swaps the routing + decision to `:user_level_plugin` and attaches the plugin_id. + + The runtime call site does: + + task_request + |> Entry.refine(capabilities) + |> Dispatcher.dispatch(adapters: adapters, context: context) + + No execution happens here; this is decision data only. + """ + + alias Ourocode.Plugin.UserLevel.Resolver + alias Ourocode.TaskRequest + + @doc """ + Returns a TaskRequest whose routing_decision is rewritten to + `:user_level_plugin` when the input targets a known plugin; otherwise + returns the original TaskRequest unchanged. + + The refined routing_decision carries: + + * `kind` and `execution_route` set to `:user_level_plugin` + * `runtime_source: :ouroboros` + * `transport: :auto` + * `plugin_id` — the matched plugin id + * `reason: :user_level_plugin_resolved` + """ + @spec refine(TaskRequest.t(), [Ourocode.Plugin.UserLevel.Capability.t()]) :: TaskRequest.t() + def refine(%TaskRequest{task_input: input} = task_request, capabilities) + when is_list(capabilities) do + if Resolver.applies_to?(input, capabilities) do + plugin_id = plugin_id_from_input(input) + + routing_decision = %{ + kind: :user_level_plugin, + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :user_level_plugin_resolved, + plugin_id: plugin_id + } + + %{task_request | routing_decision: routing_decision} + else + task_request + end + end + + def refine(task_request, _capabilities), do: task_request + + defp plugin_id_from_input(input) do + input + |> String.trim() + |> String.split(~r/\s+/u, trim: true) + |> case do + [_prefix, plugin_token | _rest] -> String.downcase(plugin_token) + _other -> nil + end + end +end diff --git a/lib/ourocode/plugin/user_level/preflight_result.ex b/lib/ourocode/plugin/user_level/preflight_result.ex new file mode 100644 index 0000000..0864f82 --- /dev/null +++ b/lib/ourocode/plugin/user_level/preflight_result.ex @@ -0,0 +1,72 @@ +defmodule Ourocode.Plugin.UserLevel.PreflightResult do + @moduledoc """ + Read-only resolution result for an `ooo ...`-shaped + prompt. + + A `PreflightResult` records *what would happen* if dispatch proceeded: + which UserLevel plugin and command were matched, which arguments were + parsed, what trust state applies, what artifacts the command is expected + to produce, and which continuation policy governs follow-up workflows. + + The result itself never executes anything. The dispatch adapter consumes + the result; the TUI renders it; the journal records it. + """ + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + @type kind :: + :unique_match + | :ambiguous + | :unknown + | :not_applicable + + @type trust_state :: :allowed | :missing | :unknown + @type continuation_policy :: :none | :suggest | :auto_when_requested + @type confidence :: :exact | :alias | :none + + @type match_explanation :: %{ + required(:matched_by) => :canonical | :alias | nil, + required(:confidence) => confidence(), + optional(:reason) => atom() + } + + @type t :: %__MODULE__{ + kind: kind(), + task_input: String.t(), + plugin: Capability.t() | nil, + command: CommandCapability.t() | nil, + args: [String.t()], + trust_state: trust_state(), + remediation: String.t() | nil, + risk_class: CommandCapability.risk_class(), + expected_artifacts: [String.t()], + continuation_policy: continuation_policy(), + candidates: [Capability.t()], + match_explanation: match_explanation(), + reason: atom() | nil + } + + defstruct kind: :unknown, + task_input: "", + plugin: nil, + command: nil, + args: [], + trust_state: :unknown, + remediation: nil, + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: nil + + @doc """ + Convenience constructor used by the resolver to ensure default fields stay + consistent across kinds. + """ + @spec new(keyword()) :: t() + def new(fields) when is_list(fields) do + struct(__MODULE__, fields) + end +end diff --git a/lib/ourocode/plugin/user_level/preflight_view.ex b/lib/ourocode/plugin/user_level/preflight_view.ex new file mode 100644 index 0000000..7bba07d --- /dev/null +++ b/lib/ourocode/plugin/user_level/preflight_view.ex @@ -0,0 +1,76 @@ +defmodule Ourocode.Plugin.UserLevel.PreflightView do + @moduledoc """ + JSON-safe projection of a `Ourocode.Plugin.UserLevel.PreflightResult` for + TUIs, dashboards, and decision journals. + + The projection deliberately mirrors the field shape used by + `Ourocode.Command.CapabilityPreflight.Projection` so any UI that already + renders the slash-command preflight can render UserLevel plugin + preflight without a separate code path. + + No execution, no trust mutation, no plugin-internal paths. + """ + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + alias Ourocode.Plugin.UserLevel.PreflightResult + + @doc """ + Projects a `PreflightResult` into a JSON-safe map. + """ + @spec project(PreflightResult.t()) :: map() + def project(%PreflightResult{} = result) do + %{ + kind: result.kind, + task_input: result.task_input, + reason: result.reason, + plugin: plugin_view(result.plugin), + command: command_view(result.command), + args: result.args, + trust: %{ + state: result.trust_state, + remediation: result.remediation + }, + side_effects: %{ + execution: execution_class(result.kind, result.command), + discovery: :read_only, + risk_class: result.risk_class, + expected_artifacts: result.expected_artifacts, + continuation_policy: result.continuation_policy + }, + candidates: Enum.map(result.candidates, &plugin_view/1), + match_explanation: result.match_explanation + } + end + + defp plugin_view(nil), do: nil + + defp plugin_view(%Capability{} = capability) do + %{ + plugin_id: capability.plugin_id, + plugin_name: capability.plugin_name, + source: capability.source, + version: capability.version, + install_scope: capability.install_scope, + trust_scope: capability.trust_scope, + manifest_digest: capability.manifest_digest + } + end + + defp command_view(nil), do: nil + + defp command_view(%CommandCapability{} = command) do + %{ + name: command.name, + aliases: command.aliases, + summary: command.summary, + args: command.args, + risk_class: command.risk_class, + expected_artifacts: command.expected_artifacts, + continuation_hint: command.continuation_hint + } + end + + defp execution_class(:unique_match, %CommandCapability{}), do: :pending_approval + defp execution_class(_kind, _command), do: :blocked +end diff --git a/lib/ourocode/plugin/user_level/registry.ex b/lib/ourocode/plugin/user_level/registry.ex new file mode 100644 index 0000000..af4ac6a --- /dev/null +++ b/lib/ourocode/plugin/user_level/registry.ex @@ -0,0 +1,189 @@ +defmodule Ourocode.Plugin.UserLevel.Registry do + @moduledoc """ + In-memory cache of installed Ouroboros UserLevel plugin capabilities. + + The registry is a small Agent that keeps the most recent discovery + snapshot. Lookups are O(N) over a tiny N; the registry exists for + freshness and identity stability, not for high-throughput access. + + Freshness: + * `list/2` returns the cached snapshot, refreshing when it is older than + `:max_age_ms` (default 60 s). + * `refresh/2` is explicit (used by a `/plugins refresh` slash command + and by the plugin config watcher signal handler). + * Discovery failures degrade the snapshot to `:degraded` while keeping + the last good capability list. Boot is never blocked. + + Identity stability: + * Capabilities are deduplicated by + `Ourocode.Plugin.UserLevel.Capability.identity/1`. A re-discovery + without manifest changes returns the same struct instance, so + downstream caches (preflight, panel, journal) do not churn. + """ + + use Agent + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Discovery + alias Ourocode.Plugin.UserLevel.Discovery.OuroborosCLI + + @default_ttl_ms 60_000 + @default_adapter OuroborosCLI + + @type status :: :ready | :degraded | :empty + @type snapshot :: %{ + required(:status) => status(), + required(:capabilities) => [Capability.t()], + required(:errors) => [term()], + required(:refreshed_at) => DateTime.t() | nil, + required(:adapter) => module() + } + + @doc """ + Starts the registry agent. + + Options: + * `:name` — registered process name (defaults to `__MODULE__`). + * `:adapter` — discovery adapter module (defaults to `OuroborosCLI`). + * `:adapter_options` — passed verbatim to `adapter.discover/1`. + * `:eager?` — when `true`, runs an initial discovery synchronously. + Defaults to `false` so boot stays fast and offline-safe. + """ + @spec start_link(keyword()) :: Agent.on_start() + def start_link(opts \\ []) do + name = Keyword.get(opts, :name, __MODULE__) + adapter = Keyword.get(opts, :adapter, @default_adapter) + adapter_options = Keyword.get(opts, :adapter_options, []) + eager? = Keyword.get(opts, :eager?, false) + + initial = %{ + status: :empty, + capabilities: [], + errors: [], + refreshed_at: nil, + adapter: adapter, + adapter_options: adapter_options + } + + case Agent.start_link(fn -> initial end, name: name) do + {:ok, pid} -> + if eager?, do: _ = refresh(name) + {:ok, pid} + + other -> + other + end + end + + @doc """ + Returns the current snapshot, refreshing when older than `:max_age_ms`. + + Options: + * `:max_age_ms` — TTL for the cached snapshot (defaults to + `#{@default_ttl_ms}` ms). Pass `nil` to never auto-refresh — the + cached snapshot is returned unchanged even when it is still empty, + so callers can inspect the initial state without triggering + discovery. To force discovery on first read, pass `max_age_ms: 0` + or call `refresh/2` explicitly. + """ + @spec list(GenServer.server(), keyword()) :: snapshot() + def list(server \\ __MODULE__, opts \\ []) do + max_age_ms = Keyword.get(opts, :max_age_ms, @default_ttl_ms) + snapshot = Agent.get(server, & &1) + + if stale?(snapshot, max_age_ms) do + refresh(server) + else + project(snapshot) + end + end + + @doc """ + Forces re-discovery through the configured adapter and updates the cache. + + Discovery failures preserve the previous capability list and surface as a + `:degraded` snapshot with the error attached. Successful runs reset the + error list and update `refreshed_at`. + """ + @spec refresh(GenServer.server(), keyword()) :: snapshot() + def refresh(server \\ __MODULE__, opts \\ []) do + Agent.get_and_update(server, fn current -> + adapter = Keyword.get(opts, :adapter, current.adapter) + adapter_options = Keyword.get(opts, :adapter_options, current.adapter_options) + + next = run_discovery(current, adapter, adapter_options) + + new_state = + current + |> Map.put(:adapter, adapter) + |> Map.put(:adapter_options, adapter_options) + |> Map.merge(next) + + {project(new_state), new_state} + end) + end + + @doc """ + Looks up a capability by plugin id from the cached snapshot. + + Does not trigger discovery. Callers that need freshness should call + `list/2` first. + """ + @spec fetch(GenServer.server(), String.t()) :: {:ok, Capability.t()} | :error + def fetch(server \\ __MODULE__, plugin_id) when is_binary(plugin_id) do + snapshot = Agent.get(server, & &1) + + case Enum.find(snapshot.capabilities, &(&1.plugin_id == plugin_id)) do + nil -> :error + capability -> {:ok, capability} + end + end + + # `max_age_ms: nil` takes precedence over an empty cache so callers can + # inspect the initial state without triggering discovery. + defp stale?(_snapshot, nil), do: false + defp stale?(%{refreshed_at: nil}, _max_age_ms), do: true + + defp stale?(%{refreshed_at: refreshed_at}, max_age_ms) do + DateTime.diff(DateTime.utc_now(), refreshed_at, :millisecond) > max_age_ms + end + + defp run_discovery(current, adapter, adapter_options) do + case Discovery.run(adapter, adapter_options) do + {:ok, capabilities, descriptor_errors} -> + merged = preserve_identity(current.capabilities, capabilities) + + %{ + status: status_for(merged, descriptor_errors), + capabilities: merged, + errors: descriptor_errors, + refreshed_at: DateTime.utc_now(), + adapter: adapter + } + + {:error, reason} -> + %{ + status: :degraded, + capabilities: current.capabilities, + errors: [{:discovery_failed, reason} | current.errors], + refreshed_at: DateTime.utc_now(), + adapter: adapter + } + end + end + + defp status_for([], []), do: :empty + defp status_for(_capabilities, _errors), do: :ready + + defp preserve_identity(previous, fresh) do + index = Map.new(previous, fn cap -> {Capability.identity(cap), cap} end) + + Enum.map(fresh, fn cap -> + Map.get(index, Capability.identity(cap), cap) + end) + end + + defp project(snapshot) do + Map.take(snapshot, [:status, :capabilities, :errors, :refreshed_at, :adapter]) + end +end diff --git a/lib/ourocode/plugin/user_level/registry_entry.ex b/lib/ourocode/plugin/user_level/registry_entry.ex new file mode 100644 index 0000000..77f7f0d --- /dev/null +++ b/lib/ourocode/plugin/user_level/registry_entry.ex @@ -0,0 +1,128 @@ +defmodule Ourocode.Plugin.UserLevel.RegistryEntry do + @moduledoc """ + Projects `Ourocode.Plugin.UserLevel.Capability` into the normalized + command_entry shape consumed by `Ourocode.Command.Registry`. + + This bridge lets the existing slash command surface and + `Ourocode.Command.CapabilityPreflight` see UserLevel plugins as first-class + registry entries without duplicating projection logic. The metadata shape + intentionally mirrors `Ourocode.Command.Registry.PluginSurfaceEntry` so + `CapabilityPreflight.Trust` and `CapabilityPreflight.Projection` work + unchanged. + + Trust defaults are conservative: the registry assumes `requires_explicit_approval` + unless the discovered capability declares trust scopes. Granting trust + remains an Ouroboros responsibility; `ourocode` only surfaces what was + reported. + """ + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + @doc """ + Returns a list of registry-shaped maps suitable for + `Ourocode.Command.Registry.merge_normalized_entries/2`. + + One entry per command capability is produced. + """ + @spec entries([Capability.t()] | Capability.t()) :: [map()] + def entries(capabilities) when is_list(capabilities) do + Enum.flat_map(capabilities, &entries/1) + end + + def entries(%Capability{} = capability) do + Enum.map(capability.commands, &entry(capability, &1)) + end + + defp entry(%Capability{} = capability, %CommandCapability{} = command) do + slash = "/" <> capability.plugin_id <> " " <> command.name + + aliases = + Enum.map(command.aliases, fn alias_name -> + "/" <> capability.plugin_id <> " " <> alias_name + end) + + args = + Enum.map(command.args, fn arg -> + %{ + name: Map.get(arg, :name, ""), + required?: Map.get(arg, :required?, false), + description: Map.get(arg, :description, "") + } + end) + + %{ + id: "user_level_plugin:#{capability.plugin_id}:#{command.name}", + name: "#{capability.plugin_id} #{command.name}", + slash: slash, + source: :plugin, + source_id: capability.plugin_id, + source_attribution: source_attribution(capability), + type: :slash_command, + category: :plugins, + summary: command.summary || "", + aliases: aliases, + args: args, + availability: :available, + runnable?: true, + run_spec: %{ + kind: :user_level_plugin_command, + plugin_id: capability.plugin_id, + command: command.name, + risk_class: command.risk_class, + expected_artifacts: command.expected_artifacts, + continuation_hint: command.continuation_hint + }, + metadata: %{ + plugin_id: capability.plugin_id, + plugin_source: capability.source, + plugin_surface: :user_level, + command_namespace: capability.plugin_id, + namespace_owner: :ouroboros, + trust_policy: trust_policy(capability), + trust_evaluation: trust_evaluation(capability), + trust_policy_state: nil, + expected_outputs: command.expected_artifacts, + risk_class: command.risk_class, + capability_version: capability.version, + manifest_digest: capability.manifest_digest + } + } + end + + defp source_attribution(%Capability{} = capability) do + %{ + source: :plugin, + source_id: capability.plugin_id, + plugin_id: capability.plugin_id, + plugin_source: capability.source, + plugin_surface: :user_level, + command_namespace: capability.plugin_id, + namespace_owner: :ouroboros, + capability_version: capability.version, + manifest_digest: capability.manifest_digest + } + end + + defp trust_policy(%Capability{trust_scope: scopes}) when scopes != [] do + %{ + "tier" => "user_level", + "requires_explicit_approval" => false, + "trust_scopes" => scopes + } + end + + defp trust_policy(_capability) do + %{ + "tier" => "user_level", + "requires_explicit_approval" => true + } + end + + defp trust_evaluation(%Capability{trust_scope: scopes}) do + %{ + "trusted" => scopes != [], + "trust_scopes" => scopes + } + end +end diff --git a/lib/ourocode/plugin/user_level/resolver.ex b/lib/ourocode/plugin/user_level/resolver.ex new file mode 100644 index 0000000..ead1b5f --- /dev/null +++ b/lib/ourocode/plugin/user_level/resolver.ex @@ -0,0 +1,263 @@ +defmodule Ourocode.Plugin.UserLevel.Resolver do + @moduledoc """ + Pure resolver from `ooo [args ...]`-shaped input to a + `Ourocode.Plugin.UserLevel.PreflightResult`. + + The resolver is intentionally narrow: + + * Direct command form only — the first token must be `ooo` or + `ouroboros`. Free-form natural language is deferred until the exact + path is stable. + * Exact match only on plugin id and command name/alias. Fuzzy matching + is explicitly out of scope; ambiguity surfaces as `:ambiguous` with + candidate plugins rather than as a guess. + * No execution, no trust mutation. The resolver only describes what a + dispatch step *would* do. + + Trust mapping: + + * A capability whose `trust_scope` is non-empty is considered + `:allowed`. The Ouroboros plugin list is the source of truth — if + Ouroboros declares scopes, the user has granted them. + * A capability whose `trust_scope` is empty surfaces as `:missing` + with a remediation string suggesting `ouroboros plugin trust ...`. + Granting trust remains an Ouroboros responsibility. + """ + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + alias Ourocode.Plugin.UserLevel.PreflightResult + + @ooo_prefixes ["ooo", "ouroboros"] + + @doc """ + Resolves `task_input` against the given capability list. + + Returns a `PreflightResult`. The resolver never raises and never mutates + capabilities; callers can pass a registry snapshot directly. + """ + @spec resolve(String.t(), [Capability.t()]) :: PreflightResult.t() + def resolve(task_input, capabilities) when is_binary(task_input) and is_list(capabilities) do + trimmed = String.trim(task_input) + + case tokenize(trimmed) do + [prefix, plugin_token | rest] -> + if ooo_prefix?(prefix) do + resolve_plugin(trimmed, plugin_token, rest, capabilities) + else + not_applicable(trimmed) + end + + [prefix] -> + if ooo_prefix?(prefix) do + unknown(trimmed, :missing_plugin_token) + else + not_applicable(trimmed) + end + + [] -> + not_applicable(trimmed) + end + end + + def resolve(_task_input, _capabilities), do: not_applicable("") + + defp resolve_plugin(input, plugin_token, rest, capabilities) do + normalized = String.downcase(plugin_token) + + case Enum.filter(capabilities, &(String.downcase(&1.plugin_id) == normalized)) do + [] -> + unknown(input, :unknown_plugin) + + [capability] -> + resolve_command(input, capability, rest) + + [_ | _] = matches -> + ambiguous(input, matches) + end + end + + defp resolve_command(input, capability, []) do + %PreflightResult{ + kind: :unknown, + task_input: input, + plugin: capability, + command: nil, + args: [], + trust_state: trust_state(capability), + remediation: remediation_for(capability), + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: :missing_command_token + } + end + + defp resolve_command(input, capability, [command_token | args]) do + normalized = String.downcase(command_token) + + case find_command_ci(capability, normalized) do + nil -> + %PreflightResult{ + kind: :unknown, + task_input: input, + plugin: capability, + command: nil, + args: args, + trust_state: trust_state(capability), + remediation: remediation_for(capability), + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: :unknown_command + } + + %CommandCapability{} = command -> + unique_match(input, capability, command, normalized, args) + end + end + + defp find_command_ci(%Capability{commands: commands}, normalized_token) do + Enum.find(commands, fn cmd -> + String.downcase(cmd.name) == normalized_token or + normalized_token in Enum.map(cmd.aliases, &String.downcase/1) + end) + end + + defp unique_match(input, capability, command, normalized_token, args) do + confidence = + cond do + String.downcase(command.name) == normalized_token -> :exact + normalized_token in Enum.map(command.aliases, &String.downcase/1) -> :alias + true -> :none + end + + matched_by = + cond do + String.downcase(command.name) == normalized_token -> :canonical + normalized_token in Enum.map(command.aliases, &String.downcase/1) -> :alias + true -> nil + end + + %PreflightResult{ + kind: :unique_match, + task_input: input, + plugin: capability, + command: command, + args: args, + trust_state: trust_state(capability), + remediation: remediation_for(capability), + risk_class: command.risk_class, + expected_artifacts: command.expected_artifacts, + continuation_policy: continuation_policy_for(command), + candidates: [], + match_explanation: %{matched_by: matched_by, confidence: confidence}, + reason: nil + } + end + + defp ambiguous(input, candidates) do + %PreflightResult{ + kind: :ambiguous, + task_input: input, + plugin: nil, + command: nil, + args: [], + trust_state: :unknown, + remediation: nil, + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: candidates, + match_explanation: %{matched_by: nil, confidence: :none}, + reason: :duplicate_plugin_ids + } + end + + defp unknown(input, reason) do + %PreflightResult{ + kind: :unknown, + task_input: input, + plugin: nil, + command: nil, + args: [], + trust_state: :unknown, + remediation: nil, + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: reason + } + end + + defp not_applicable(input) do + %PreflightResult{ + kind: :not_applicable, + task_input: input, + plugin: nil, + command: nil, + args: [], + trust_state: :unknown, + remediation: nil, + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: :not_user_level_plugin_input + } + end + + @doc """ + Convenience predicate for routing layers: returns `true` when `task_input` + syntactically targets a UserLevel plugin known to `capabilities`. + + Does not evaluate trust or command validity; that is the job of `resolve/2`. + Callers can use this to decide whether to swap the routing decision before + invoking the dispatcher. + """ + @spec applies_to?(String.t(), [Capability.t()]) :: boolean() + def applies_to?(task_input, capabilities) + when is_binary(task_input) and is_list(capabilities) do + case tokenize(String.trim(task_input)) do + [prefix, plugin_token | _rest] -> + if ooo_prefix?(prefix) do + normalized = String.downcase(plugin_token) + Enum.any?(capabilities, &(String.downcase(&1.plugin_id) == normalized)) + else + false + end + + _other -> + false + end + end + + def applies_to?(_task_input, _capabilities), do: false + + defp tokenize(""), do: [] + defp tokenize(input), do: String.split(input, ~r/\s+/u, trim: true) + + defp ooo_prefix?(prefix), do: String.downcase(prefix) in @ooo_prefixes + + defp trust_state(%Capability{trust_scope: scopes}) when scopes != [], do: :allowed + defp trust_state(%Capability{}), do: :missing + + defp remediation_for(%Capability{trust_scope: scopes, plugin_id: id}) when scopes == [] do + "ouroboros plugin trust #{id} --scope " + end + + defp remediation_for(_capability), do: nil + + defp continuation_policy_for(%CommandCapability{continuation_hint: :auto_run_when_requested}), + do: :auto_when_requested + + defp continuation_policy_for(%CommandCapability{continuation_hint: :suggest_run}), do: :suggest + defp continuation_policy_for(%CommandCapability{}), do: :none +end diff --git a/lib/ourocode/runtime/dispatcher/route_resolution.ex b/lib/ourocode/runtime/dispatcher/route_resolution.ex index d7f5793..0b2c816 100644 --- a/lib/ourocode/runtime/dispatcher/route_resolution.ex +++ b/lib/ourocode/runtime/dispatcher/route_resolution.ex @@ -3,7 +3,7 @@ defmodule Ourocode.Runtime.Dispatcher.RouteResolution do alias Ourocode.TaskRequest - @supported_routes [:runtime, :ouroboros_workflow, :mcp_flow] + @supported_routes [:runtime, :ouroboros_workflow, :mcp_flow, :user_level_plugin] @supported_runtime_sources [:auto, :codex, :opencode, :claude_code, :ouroboros, :mcp] @supported_transports [:auto, :stdio, :streamable_http, :sse] @supported_adapter_routes [:interview, :seed, :run, :evolve, :ralph, :workflow] @@ -87,6 +87,15 @@ defmodule Ourocode.Runtime.Dispatcher.RouteResolution do ] end + def adapter_keys(%{execution_route: :user_level_plugin, plugin_id: plugin_id}) + when is_binary(plugin_id) and plugin_id != "" do + [{:user_level_plugin, plugin_id}, :user_level_plugin] + end + + def adapter_keys(%{execution_route: :user_level_plugin}) do + [:user_level_plugin] + end + def adapter_keys(%{execution_route: route, runtime_source: runtime_source}) do adapter_keys(route, runtime_source) end diff --git a/lib/ourocode/runtime/user_level_plugin_invocation.ex b/lib/ourocode/runtime/user_level_plugin_invocation.ex new file mode 100644 index 0000000..99ff6f2 --- /dev/null +++ b/lib/ourocode/runtime/user_level_plugin_invocation.ex @@ -0,0 +1,248 @@ +defmodule Ourocode.Runtime.UserLevelPluginInvocation do + @moduledoc """ + Runtime adapter for `:user_level_plugin` execution routes. + + Takes a `TaskRequest` whose routing decision references an installed + Ouroboros UserLevel plugin, resolves the request against a capability + list, and dispatches the matched command through the guarded external + command runner installed by `Ourocode.Runtime.Dispatcher`. + + Safety rules (the entire point of this module): + + * The adapter never executes anything when the preflight result is not + `:unique_match`. Ambiguous, unknown, or not-applicable results return + a structured error. + * The adapter never executes when `trust_state` is anything other than + `:allowed`. The structured error includes the remediation string so + the UI can render it verbatim. + * The adapter never executes commands whose declared `risk_class` is + `:destructive` unless the dispatch context explicitly carries + `destructive_action_approved?: true`. Future trust UX can flip this + flag; until then destructive actions are blocked closed. + * Arguments are passed through as an argv list (never assembled into a + shell string). The Dispatcher's `guarded_external_command_runner` + enforces the forbidden-command rules regardless. + + Context inputs (all optional unless noted): + + * `:capabilities` (required) — list of `Ourocode.Plugin.UserLevel.Capability` + structs. The adapter resolves against this snapshot rather than + reaching into a live registry, so unit tests can pass fixture data. + * `:external_command_runner` (set by Dispatcher) — guarded runner the + adapter must use. + * `:cwd` — working directory passed to the runner. + * `:env` — environment overlay for the runner. + * `:command` — override the executable name (defaults to `"ouroboros"`). + * `:destructive_action_approved?` — explicit approval flag for + destructive risk_class commands. + + Result envelope: + + %{ + type: :user_level_plugin_invocation, + status: :invoked | :blocked, + task_request_id: String.t(), + preflight: PreflightResult.t(), + argv: [String.t()], + command: String.t(), + execution: %{...} | nil, + blocked_reason: atom() | nil + } + """ + + @behaviour Ourocode.Runtime.Adapter + + alias Ourocode.Plugin.UserLevel.ArtifactWatcher + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Continuation + alias Ourocode.Plugin.UserLevel.DecisionJournal + alias Ourocode.Plugin.UserLevel.PreflightResult + alias Ourocode.Plugin.UserLevel.Resolver + alias Ourocode.TaskRequest + + @default_command "ouroboros" + + @type invocation :: %{ + required(:type) => :user_level_plugin_invocation, + required(:status) => :invoked | :blocked, + required(:task_request_id) => String.t(), + required(:preflight) => PreflightResult.t(), + required(:argv) => [String.t()], + required(:command) => String.t(), + optional(:execution) => map(), + optional(:blocked_reason) => atom(), + optional(:artifacts) => [ArtifactWatcher.artifact()], + optional(:continuation) => map() + } + + @impl true + @spec execute(TaskRequest.t(), map()) :: {:ok, invocation()} | {:error, term()} + def execute(%TaskRequest{} = task_request, context) when is_map(context) do + with {:ok, capabilities} <- fetch_capabilities(context), + %PreflightResult{} = preflight <- + Resolver.resolve(task_request.task_input, capabilities) do + preflight + |> evaluate(context) + |> finalize(task_request, preflight, context) + end + end + + def execute(_task_request, _context), do: {:error, :invalid_task_request} + + defp fetch_capabilities(context) do + case Map.get(context, :capabilities) do + capabilities when is_list(capabilities) -> + if Enum.all?(capabilities, &match?(%Capability{}, &1)) do + {:ok, capabilities} + else + {:error, :invalid_capabilities_in_context} + end + + nil -> + {:error, :capabilities_required_in_context} + + _other -> + {:error, :invalid_capabilities_in_context} + end + end + + defp evaluate(%PreflightResult{kind: :unique_match} = preflight, context) do + cond do + preflight.trust_state != :allowed -> + {:blocked, :trust_missing} + + preflight.risk_class == :destructive and + Map.get(context, :destructive_action_approved?) != true -> + {:blocked, :destructive_action_requires_approval} + + true -> + :allowed + end + end + + defp evaluate(%PreflightResult{kind: :ambiguous}, _context), do: {:blocked, :ambiguous_match} + defp evaluate(%PreflightResult{kind: :unknown}, _context), do: {:blocked, :unknown_plugin_or_command} + + defp evaluate(%PreflightResult{kind: :not_applicable}, _context), + do: {:blocked, :not_user_level_plugin_input} + + defp finalize({:blocked, reason}, task_request, preflight, context) do + envelope = %{ + type: :user_level_plugin_invocation, + status: :blocked, + task_request_id: to_string(task_request.id), + preflight: preflight, + argv: argv_for(preflight, context), + command: command_for(context), + blocked_reason: reason + } + + record_journal(:blocked, envelope, preflight, [], nil, context) + {:ok, envelope} + end + + defp finalize(:allowed, task_request, preflight, context) do + argv = argv_for(preflight, context) + command = command_for(context) + runner = Map.get(context, :external_command_runner) + + cond do + runner == nil -> + {:error, :external_command_runner_not_configured} + + not is_function(runner, 3) -> + {:error, :invalid_external_command_runner} + + true -> + case runner.(command, argv, runner_opts(context)) do + {:ok, execution} -> + envelope = %{ + type: :user_level_plugin_invocation, + status: :invoked, + task_request_id: to_string(task_request.id), + preflight: preflight, + argv: argv, + command: command, + execution: execution + } + + {artifacts, continuation} = post_execution(preflight, context) + + envelope = + envelope + |> maybe_put(:artifacts, artifacts) + |> maybe_put(:continuation, continuation) + + record_journal(:invoked, envelope, preflight, artifacts, continuation, context) + {:ok, envelope} + + {:error, reason} -> + envelope = %{ + type: :user_level_plugin_invocation, + status: :blocked, + task_request_id: to_string(task_request.id), + preflight: preflight, + argv: argv, + command: command, + blocked_reason: {:external_command_failed, reason} + } + + record_journal(:blocked, envelope, preflight, [], nil, context) + {:ok, envelope} + end + end + end + + defp post_execution(%PreflightResult{kind: :unique_match, command: command} = preflight, context) + when not is_nil(command) do + cwd = Map.get(context, :cwd) || File.cwd!() + + artifacts = + if command.expected_artifacts == [] do + [] + else + ArtifactWatcher.scan(command, cwd, + lstat?: Map.get(context, :artifact_lstat?, true) + ) + end + + continuation = Continuation.decide(preflight, artifacts) + {artifacts, continuation} + end + + defp post_execution(_preflight, _context), do: {[], nil} + + defp maybe_put(envelope, _key, []), do: envelope + defp maybe_put(envelope, _key, nil), do: envelope + defp maybe_put(envelope, key, value), do: Map.put(envelope, key, value) + + defp record_journal(_phase, envelope, preflight, artifacts, continuation, context) do + case Map.get(context, :decision_journal) do + nil -> + :ok + + target -> + task_id = Map.get(envelope, :task_request_id, "") + _ = DecisionJournal.log_preflight(target, task_id, preflight) + _ = DecisionJournal.log_dispatch(target, task_id, envelope) + _ = DecisionJournal.log_artifacts(target, task_id, artifacts) + + if continuation, do: _ = DecisionJournal.log_continuation(target, task_id, continuation) + :ok + end + end + + defp argv_for(%PreflightResult{kind: :unique_match, plugin: plugin, command: command, args: args}, _context) do + [plugin.plugin_id, command.name | args] + end + + defp argv_for(_preflight, _context), do: [] + + defp command_for(context), do: Map.get(context, :command, @default_command) + + defp runner_opts(context) do + context + |> Map.take([:cwd, :env, :timeout_ms]) + |> Map.new() + end +end diff --git a/lib/ourocode/task_request.ex b/lib/ourocode/task_request.ex index 99a1641..1a20e72 100644 --- a/lib/ourocode/task_request.ex +++ b/lib/ourocode/task_request.ex @@ -22,15 +22,17 @@ defmodule Ourocode.TaskRequest do ] @type routing_decision :: %{ - required(:kind) => :runtime | :ouroboros_workflow | :mcp_flow, - required(:execution_route) => :runtime | :ouroboros_workflow | :mcp_flow, + required(:kind) => :runtime | :ouroboros_workflow | :mcp_flow | :user_level_plugin, + required(:execution_route) => + :runtime | :ouroboros_workflow | :mcp_flow | :user_level_plugin, required(:runtime_source) => :auto | :codex | :opencode | :claude_code | :ouroboros | :mcp, required(:transport) => :auto | :stdio | :streamable_http | :sse, required(:requires_command_syntax?) => false, required(:advanced_shortcut?) => boolean(), required(:reason) => atom(), - optional(:adapter_route) => atom() + optional(:adapter_route) => atom(), + optional(:plugin_id) => String.t() } @type t :: %__MODULE__{ diff --git a/test/fixtures/user_level_plugins/superpowers.json b/test/fixtures/user_level_plugins/superpowers.json new file mode 100644 index 0000000..a190ddf --- /dev/null +++ b/test/fixtures/user_level_plugins/superpowers.json @@ -0,0 +1,62 @@ +{ + "plugins": [ + { + "id": "superpowers", + "name": "superpowers", + "version": "0.4.2", + "install_scope": "user", + "trust_scope": ["filesystem:read", "filesystem:write"], + "manifest_digest": "sha256:abc123def456", + "commands": [ + { + "name": "list", + "aliases": ["ls"], + "summary": "List installed Superpowers skills.", + "args": [], + "risk_class": "read_only", + "expected_artifacts": [], + "continuation_hint": "none" + }, + { + "name": "inspect", + "aliases": [], + "summary": "Print the manifest of one Superpowers skill.", + "args": [ + {"name": "skill", "required": true, "description": "Skill name."} + ], + "risk_class": "read_only", + "expected_artifacts": [], + "continuation_hint": "none" + }, + { + "name": "test-driven-development", + "aliases": ["tdd"], + "summary": "Generate a TDD handoff and seed for the given goal.", + "args": [ + {"name": "goal", "required": true, "description": "User goal."} + ], + "risk_class": "handoff_producing", + "expected_artifacts": [ + ".omx/superpowers/runs/*/seed.md", + ".omx/superpowers/runs/*/handoff.md" + ], + "continuation_hint": "suggest_run" + }, + { + "name": "systematic-debugging", + "aliases": ["debug"], + "summary": "Generate a debugging handoff and seed for the given goal.", + "args": [ + {"name": "goal", "required": true, "description": "User goal."} + ], + "risk_class": "handoff_producing", + "expected_artifacts": [ + ".omx/superpowers/runs/*/seed.md", + ".omx/superpowers/runs/*/handoff.md" + ], + "continuation_hint": "suggest_run" + } + ] + } + ] +} diff --git a/test/ourocode/plugin/user_level/artifact_watcher_test.exs b/test/ourocode/plugin/user_level/artifact_watcher_test.exs new file mode 100644 index 0000000..9f1c3d3 --- /dev/null +++ b/test/ourocode/plugin/user_level/artifact_watcher_test.exs @@ -0,0 +1,107 @@ +defmodule Ourocode.Plugin.UserLevel.ArtifactWatcherTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.ArtifactWatcher + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + setup do + tmp = Path.join(System.tmp_dir!(), "ourocode_artifact_watcher_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + %{cwd: tmp} + end + + defp write!(path, content) do + path |> Path.dirname() |> File.mkdir_p!() + File.write!(path, content) + end + + test "matches a seed.md under the declared glob", %{cwd: cwd} do + {:ok, command} = + CommandCapability.new(%{ + name: "tdd", + risk_class: "handoff_producing", + expected_artifacts: [".omx/superpowers/runs/*/seed.md"] + }) + + seed = Path.join([cwd, ".omx", "superpowers", "runs", "abc123", "seed.md"]) + write!(seed, "# seed\n") + + [artifact] = ArtifactWatcher.scan(command, cwd) + + assert artifact.kind == :seed + assert artifact.path == seed + assert artifact.glob == ".omx/superpowers/runs/*/seed.md" + assert artifact.size > 0 + assert "sha256:" <> _ = artifact.digest + assert %DateTime{} = artifact.generated_at + end + + test "classifies handoff and report files", %{cwd: cwd} do + {:ok, command} = + CommandCapability.new(%{ + name: "tdd", + risk_class: "handoff_producing", + expected_artifacts: [".omx/runs/*/*"] + }) + + handoff = Path.join([cwd, ".omx", "runs", "x", "handoff.md"]) + report = Path.join([cwd, ".omx", "runs", "x", "report.md"]) + log = Path.join([cwd, ".omx", "runs", "x", "audit.jsonl"]) + other = Path.join([cwd, ".omx", "runs", "x", "extra.bin"]) + + Enum.each([handoff, report, log, other], &write!(&1, "data")) + + artifacts = ArtifactWatcher.scan(command, cwd) + by_path = Map.new(artifacts, &{&1.path, &1.kind}) + + assert by_path[handoff] == :handoff + assert by_path[report] == :report + assert by_path[log] == :log + assert by_path[other] == :other + end + + test "deduplicates artifacts that match multiple globs", %{cwd: cwd} do + {:ok, command} = + CommandCapability.new(%{ + name: "tdd", + risk_class: "handoff_producing", + expected_artifacts: [ + ".omx/runs/*/seed.md", + ".omx/runs/**/*.md" + ] + }) + + seed = Path.join([cwd, ".omx", "runs", "x", "seed.md"]) + write!(seed, "# seed\n") + + artifacts = ArtifactWatcher.scan(command, cwd) + assert length(artifacts) == 1 + end + + test "returns empty list when nothing matches", %{cwd: cwd} do + {:ok, command} = + CommandCapability.new(%{ + name: "tdd", + risk_class: "handoff_producing", + expected_artifacts: [".omx/nothing/*.md"] + }) + + assert ArtifactWatcher.scan(command, cwd) == [] + end + + test "lstat?: false skips file metadata", %{cwd: cwd} do + {:ok, command} = + CommandCapability.new(%{ + name: "tdd", + expected_artifacts: [".omx/x/*"] + }) + + write!(Path.join([cwd, ".omx", "x", "seed.md"]), "x") + + [artifact] = ArtifactWatcher.scan(command, cwd, lstat?: false) + refute Map.has_key?(artifact, :size) + refute Map.has_key?(artifact, :digest) + refute Map.has_key?(artifact, :generated_at) + end +end diff --git a/test/ourocode/plugin/user_level/capability_test.exs b/test/ourocode/plugin/user_level/capability_test.exs new file mode 100644 index 0000000..aeb3289 --- /dev/null +++ b/test/ourocode/plugin/user_level/capability_test.exs @@ -0,0 +1,138 @@ +defmodule Ourocode.Plugin.UserLevel.CapabilityTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + describe "new/1" do + test "builds a capability from a minimal valid descriptor" do + assert {:ok, %Capability{} = capability} = + Capability.new(%{plugin_id: "superpowers", source: :ouroboros_cli}) + + assert capability.plugin_id == "superpowers" + assert capability.plugin_name == "superpowers" + assert capability.source == :ouroboros_cli + assert capability.commands == [] + assert capability.trust_scope == [] + assert capability.install_scope == :unknown + assert %DateTime{} = capability.discovered_at + end + + test "normalizes commands and drops invalid ones" do + assert {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :ouroboros_cli, + commands: [ + %{name: "list"}, + %{name: ""}, + %{name: "tdd", aliases: ["test-driven-development"]} + ] + }) + + assert [%CommandCapability{name: "list"}, %CommandCapability{name: "tdd"} = tdd] = + capability.commands + + assert tdd.aliases == ["test-driven-development"] + end + + test "rejects missing plugin_id" do + assert {:error, :invalid_capability_attrs} = + Capability.new(%{source: :ouroboros_cli}) + end + + test "rejects unknown source" do + assert {:error, :invalid_capability_attrs} = + Capability.new(%{plugin_id: "x", source: :random}) + end + + test "uses caller-provided discovered_at when present" do + stamp = ~U[2026-01-01 00:00:00Z] + + assert {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + discovered_at: stamp + }) + + assert capability.discovered_at == stamp + end + + test "trust_scope drops blanks and dedupes" do + assert {:ok, capability} = + Capability.new(%{ + plugin_id: "x", + source: :ouroboros_cli, + trust_scope: ["filesystem:read", "", "filesystem:read", "filesystem:write"] + }) + + assert capability.trust_scope == ["filesystem:read", "filesystem:write"] + end + end + + describe "identity/1" do + test "is stable when plugin_id, version, and manifest_digest match" do + attrs = %{ + plugin_id: "superpowers", + source: :ouroboros_cli, + version: "0.4.2", + manifest_digest: "sha256:abc" + } + + {:ok, a} = Capability.new(attrs) + {:ok, b} = Capability.new(attrs) + + assert Capability.identity(a) == Capability.identity(b) + assert Capability.identity(a) == {"superpowers", "0.4.2", "sha256:abc"} + end + + test "differs when manifest digest changes" do + {:ok, a} = + Capability.new(%{ + plugin_id: "superpowers", + source: :ouroboros_cli, + manifest_digest: "sha256:old" + }) + + {:ok, b} = + Capability.new(%{ + plugin_id: "superpowers", + source: :ouroboros_cli, + manifest_digest: "sha256:new" + }) + + refute Capability.identity(a) == Capability.identity(b) + end + end + + describe "find_command/2" do + setup do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + commands: [ + %{name: "list"}, + %{name: "test-driven-development", aliases: ["tdd"]} + ] + }) + + %{capability: capability} + end + + test "matches canonical name", %{capability: capability} do + assert %CommandCapability{name: "test-driven-development"} = + Capability.find_command(capability, "test-driven-development") + end + + test "matches alias", %{capability: capability} do + assert %CommandCapability{name: "test-driven-development"} = + Capability.find_command(capability, "tdd") + end + + test "returns nil for unknown command", %{capability: capability} do + assert nil == Capability.find_command(capability, "nope") + end + end +end diff --git a/test/ourocode/plugin/user_level/continuation_test.exs b/test/ourocode/plugin/user_level/continuation_test.exs new file mode 100644 index 0000000..bcaa617 --- /dev/null +++ b/test/ourocode/plugin/user_level/continuation_test.exs @@ -0,0 +1,132 @@ +defmodule Ourocode.Plugin.UserLevel.ContinuationTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + alias Ourocode.Plugin.UserLevel.Continuation + alias Ourocode.Plugin.UserLevel.PreflightResult + + defp preflight(opts \\ []) do + risk = Keyword.get(opts, :risk_class, :handoff_producing) + hint = Keyword.get(opts, :continuation_hint, :suggest_run) + + {:ok, command} = + CommandCapability.new(%{ + name: "tdd", + risk_class: Atom.to_string(risk), + continuation_hint: Atom.to_string(hint), + expected_artifacts: [".omx/runs/*/seed.md"] + }) + + %PreflightResult{ + kind: :unique_match, + task_input: Keyword.get(opts, :task_input, "ooo superpowers tdd --goal x"), + command: command, + risk_class: risk, + expected_artifacts: command.expected_artifacts, + continuation_policy: :suggest + } + end + + defp seed_artifact(path \\ "/tmp/runs/x/seed.md") do + %{kind: :seed, path: path, glob: ".omx/runs/*/seed.md"} + end + + describe "decide/2 — auto-run intent" do + test "auto-runs when prompt asks 'then run the generated handoff'" do + result = + Continuation.decide( + preflight(task_input: "ooo superpowers tdd --goal x then run the generated handoff"), + [seed_artifact()] + ) + + assert result.action == :auto_run + assert result.seed_path == "/tmp/runs/x/seed.md" + assert result.command_template == "ooo run seed_path=/tmp/runs/x/seed.md" + assert result.reason == :auto_run_requested + end + + test "auto-runs for Korean opt-in phrase" do + result = + Continuation.decide( + preflight(task_input: "ooo superpowers tdd --goal x 이어서 실행"), + [seed_artifact()] + ) + + assert result.action == :auto_run + end + + test "does not auto-run without an explicit opt-in phrase" do + result = + Continuation.decide(preflight(), [seed_artifact()]) + + assert result.action == :suggest + assert result.command_template == "ooo run seed_path=/tmp/runs/x/seed.md" + assert result.reason == :user_confirmation_required + end + end + + describe "decide/2 — risk class gating" do + test "read_only commands never continue" do + result = + Continuation.decide( + preflight( + risk_class: :read_only, + task_input: "ooo superpowers list then run the generated handoff" + ), + [seed_artifact()] + ) + + assert result.action == :none + assert result.reason == :read_only_command + end + + test "destructive commands never auto-run, even when opt-in is present" do + result = + Continuation.decide( + preflight( + risk_class: :destructive, + task_input: "ooo danger wipe then run the generated handoff" + ), + [seed_artifact()] + ) + + assert result.action == :suggest + assert result.reason == :destructive_requires_explicit_approval + end + end + + describe "decide/2 — no continuation artifact" do + test "no seed artifact returns :none with :no_continuation_artifact reason" do + result = Continuation.decide(preflight(), []) + assert result.action == :none + assert result.reason == :no_continuation_artifact + end + + test "only handoff (no seed) still returns :none" do + handoff = %{kind: :handoff, path: "/tmp/handoff.md", glob: ".omx/runs/*/handoff.md"} + result = Continuation.decide(preflight(), [handoff]) + assert result.action == :none + end + end + + describe "decide/2 — non-unique_match" do + test "ambiguous preflight returns :none" do + result = Continuation.decide(%PreflightResult{kind: :ambiguous}, [seed_artifact()]) + assert result.action == :none + end + end + + describe "auto_run_requested?/1" do + test "true for English opt-in" do + assert Continuation.auto_run_requested?("anything then run the seed please") + end + + test "true for Korean opt-in" do + assert Continuation.auto_run_requested?("실험 후 이후 실행") + end + + test "false for unrelated text" do + refute Continuation.auto_run_requested?("just run the plugin") + end + end +end diff --git a/test/ourocode/plugin/user_level/decision_journal_test.exs b/test/ourocode/plugin/user_level/decision_journal_test.exs new file mode 100644 index 0000000..9284a65 --- /dev/null +++ b/test/ourocode/plugin/user_level/decision_journal_test.exs @@ -0,0 +1,113 @@ +defmodule Ourocode.Plugin.UserLevel.DecisionJournalTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.DecisionJournal + alias Ourocode.Plugin.UserLevel.PreflightResult + + defp collect_events do + pid = self() + {pid, fn event -> send(pid, {:journal_event, event}); :ok end} + end + + defp preflight do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + trust_scope: ["filesystem:read"], + commands: [%{name: "list"}] + }) + + %PreflightResult{ + kind: :unique_match, + task_input: "ooo superpowers list", + plugin: capability, + command: hd(capability.commands), + args: [], + trust_state: :allowed, + risk_class: :read_only + } + end + + test "log_preflight/3 emits a user_level_preflight event with projected payload" do + {_pid, writer} = collect_events() + + assert :ok = DecisionJournal.log_preflight(writer, "task-1", preflight()) + + assert_received {:journal_event, event} + assert event["event_type"] == "user_level_preflight" + assert event["task_request_id"] == "task-1" + assert is_integer(event["recorded_at_ms"]) + assert %{"preflight" => %{"kind" => "unique_match"}} = event["payload"] + end + + test "log_dispatch/3 includes status, command, argv, plugin_id" do + {_pid, writer} = collect_events() + + envelope = %{ + status: :invoked, + command: "ouroboros", + argv: ["superpowers", "list"], + execution: %{status: 0}, + preflight: preflight() + } + + assert :ok = DecisionJournal.log_dispatch(writer, "task-1", envelope) + + assert_received {:journal_event, event} + assert event["event_type"] == "user_level_dispatch" + assert event["payload"]["status"] == "invoked" + assert event["payload"]["command"] == "ouroboros" + assert event["payload"]["argv"] == ["superpowers", "list"] + assert event["payload"]["execution_status"] == 0 + end + + test "log_artifacts/3 emits one event per artifact" do + {_pid, writer} = collect_events() + + artifacts = [ + %{kind: :seed, path: "/tmp/seed.md", glob: ".omx/*/seed.md", size: 12}, + %{kind: :handoff, path: "/tmp/handoff.md", glob: ".omx/*/handoff.md"} + ] + + assert :ok = DecisionJournal.log_artifacts(writer, "task-1", artifacts) + + assert_received {:journal_event, %{"event_type" => "user_level_artifact"} = first} + assert_received {:journal_event, %{"event_type" => "user_level_artifact"} = second} + refute_received {:journal_event, _} + + paths = Enum.map([first, second], & &1["payload"]["path"]) |> Enum.sort() + assert paths == ["/tmp/handoff.md", "/tmp/seed.md"] + end + + test "log_artifacts/3 with empty list is a no-op" do + {_pid, writer} = collect_events() + assert :ok = DecisionJournal.log_artifacts(writer, "task-1", []) + refute_received {:journal_event, _} + end + + test "log_continuation/3 captures action + seed_path + reason" do + {_pid, writer} = collect_events() + + decision = %{ + action: :suggest, + seed_path: "/tmp/seed.md", + command_template: "ooo run seed_path=/tmp/seed.md", + reason: :user_confirmation_required + } + + assert :ok = DecisionJournal.log_continuation(writer, "task-1", decision) + + assert_received {:journal_event, event} + assert event["event_type"] == "user_level_continuation" + assert event["payload"]["action"] == "suggest" + assert event["payload"]["seed_path"] == "/tmp/seed.md" + assert event["payload"]["reason"] == "user_confirmation_required" + end + + test "invalid journal target returns structured error" do + assert {:error, :invalid_journal_target} = + DecisionJournal.log_preflight(:not_callable, "task", preflight()) + end +end diff --git a/test/ourocode/plugin/user_level/discovery/ouroboros_cli_test.exs b/test/ourocode/plugin/user_level/discovery/ouroboros_cli_test.exs new file mode 100644 index 0000000..135770c --- /dev/null +++ b/test/ourocode/plugin/user_level/discovery/ouroboros_cli_test.exs @@ -0,0 +1,71 @@ +defmodule Ourocode.Plugin.UserLevel.Discovery.OuroborosCLITest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Discovery.OuroborosCLI + + @fixture_path Path.join([__DIR__, "..", "..", "..", "..", "fixtures", "user_level_plugins", "superpowers.json"]) + + test "parses the superpowers fixture into four commands" do + json = File.read!(@fixture_path) + + assert {:ok, [plugin]} = OuroborosCLI.parse(json) + + assert plugin.plugin_id == "superpowers" + assert plugin.version == "0.4.2" + assert plugin.install_scope == :user + assert plugin.trust_scope == ["filesystem:read", "filesystem:write"] + assert plugin.manifest_digest == "sha256:abc123def456" + + names = Enum.map(plugin.commands, & &1.name) + assert names == ["list", "inspect", "test-driven-development", "systematic-debugging"] + + tdd = Enum.find(plugin.commands, &(&1.name == "test-driven-development")) + assert tdd.aliases == ["tdd"] + assert tdd.risk_class == "handoff_producing" + + assert tdd.expected_artifacts == [ + ".omx/superpowers/runs/*/seed.md", + ".omx/superpowers/runs/*/handoff.md" + ] + end + + test "treats a bare JSON array (no plugins wrapper) the same way" do + json = ~s([{"id":"x","name":"x","commands":[]}]) + assert {:ok, [plugin]} = OuroborosCLI.parse(json) + assert plugin.plugin_id == "x" + end + + test "rejects malformed JSON" do + assert {:error, {:ouroboros_cli_invalid_json, _}} = + OuroborosCLI.parse("not-json") + end + + test "rejects unexpected JSON shape" do + assert {:error, :ouroboros_cli_unexpected_shape} = + OuroborosCLI.parse(~s({"unexpected": true})) + end + + test "runner failure surfaces as command_failed" do + runner = fn _cmd, _args, _opts -> + {:ok, %{status: 1, stdout: "", stderr: "ouroboros: not found"}} + end + + assert {:error, {:ouroboros_cli_failed, %{exit_status: 1, stderr: "ouroboros: not found"}}} = + OuroborosCLI.discover(command_runner: runner) + end + + test "runner error surfaces as unavailable" do + runner = fn _cmd, _args, _opts -> {:error, :enoent} end + + assert {:error, {:ouroboros_cli_unavailable, :enoent}} = + OuroborosCLI.discover(command_runner: runner) + end + + test "happy path runner returns parsed descriptors" do + json = File.read!(@fixture_path) + runner = fn _cmd, _args, _opts -> {:ok, %{status: 0, stdout: json, stderr: ""}} end + + assert {:ok, [plugin]} = OuroborosCLI.discover(command_runner: runner) + assert plugin.plugin_id == "superpowers" + end +end diff --git a/test/ourocode/plugin/user_level/discovery_test.exs b/test/ourocode/plugin/user_level/discovery_test.exs new file mode 100644 index 0000000..dd10427 --- /dev/null +++ b/test/ourocode/plugin/user_level/discovery_test.exs @@ -0,0 +1,47 @@ +defmodule Ourocode.Plugin.UserLevel.DiscoveryTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Discovery + + defmodule StubAdapter do + @behaviour Ourocode.Plugin.UserLevel.Discovery + + @impl true + def discover(opts) do + Map.new(opts) |> Map.get(:stub_result, {:ok, []}) + end + end + + test "normalizes valid descriptors into Capability structs" do + descriptors = [ + %{plugin_id: "superpowers", source: :ouroboros_cli, commands: [%{name: "list"}]}, + %{plugin_id: "other", source: :ouroboros_cli} + ] + + assert {:ok, [a, b], []} = + Discovery.run(StubAdapter, stub_result: {:ok, descriptors}) + + assert %Capability{plugin_id: "superpowers", commands: [_command]} = a + assert %Capability{plugin_id: "other", commands: []} = b + end + + test "reports invalid descriptors without losing valid ones" do + descriptors = [ + %{plugin_id: "good", source: :ouroboros_cli}, + %{source: :ouroboros_cli}, + %{plugin_id: "another_good", source: :ouroboros_cli} + ] + + assert {:ok, capabilities, errors} = + Discovery.run(StubAdapter, stub_result: {:ok, descriptors}) + + assert Enum.map(capabilities, & &1.plugin_id) == ["good", "another_good"] + assert [{:invalid_descriptor, {:invalid_capability_attrs, %{source: :ouroboros_cli}}}] = errors + end + + test "propagates adapter errors unchanged" do + assert {:error, :boom} == + Discovery.run(StubAdapter, stub_result: {:error, :boom}) + end +end diff --git a/test/ourocode/plugin/user_level/entry_test.exs b/test/ourocode/plugin/user_level/entry_test.exs new file mode 100644 index 0000000..20b5b11 --- /dev/null +++ b/test/ourocode/plugin/user_level/entry_test.exs @@ -0,0 +1,74 @@ +defmodule Ourocode.Plugin.UserLevel.EntryTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Entry + alias Ourocode.TaskRequest + + defp original_routing_decision do + %{ + kind: :ouroboros_workflow, + execution_route: :ouroboros_workflow, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :explicit_ouroboros_shortcut, + adapter_route: :workflow + } + end + + defp task(input) do + %TaskRequest{ + id: "tr-#{System.unique_integer([:positive])}", + source: :cli, + task_input: input, + submitted_at_ms: 0, + routing_decision: original_routing_decision() + } + end + + defp superpowers do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + trust_scope: ["filesystem:read"], + commands: [%{name: "list"}] + }) + + capability + end + + test "rewrites routing_decision to :user_level_plugin when input targets a known plugin" do + refined = Entry.refine(task("ooo superpowers list"), [superpowers()]) + + assert refined.routing_decision == %{ + kind: :user_level_plugin, + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :user_level_plugin_resolved, + plugin_id: "superpowers" + } + end + + test "leaves routing_decision unchanged when input is not ooo-shaped" do + task_request = task("interview goal") + refined = Entry.refine(task_request, [superpowers()]) + assert refined.routing_decision == task_request.routing_decision + end + + test "leaves routing_decision unchanged when plugin is unknown" do + task_request = task("ooo unknown_plugin list") + refined = Entry.refine(task_request, [superpowers()]) + assert refined.routing_decision == task_request.routing_decision + end + + test "handles non-list capabilities by returning the task unchanged" do + task_request = task("ooo superpowers list") + assert ^task_request = Entry.refine(task_request, nil) + end +end diff --git a/test/ourocode/plugin/user_level/preflight_result_test.exs b/test/ourocode/plugin/user_level/preflight_result_test.exs new file mode 100644 index 0000000..421989b --- /dev/null +++ b/test/ourocode/plugin/user_level/preflight_result_test.exs @@ -0,0 +1,25 @@ +defmodule Ourocode.Plugin.UserLevel.PreflightResultTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.PreflightResult + + test "new/1 fills defaults for unspecified fields" do + result = PreflightResult.new(kind: :unknown, task_input: "x") + + assert %PreflightResult{ + kind: :unknown, + task_input: "x", + plugin: nil, + command: nil, + args: [], + trust_state: :unknown, + remediation: nil, + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: nil + } = result + end +end diff --git a/test/ourocode/plugin/user_level/preflight_view_test.exs b/test/ourocode/plugin/user_level/preflight_view_test.exs new file mode 100644 index 0000000..4eb87b3 --- /dev/null +++ b/test/ourocode/plugin/user_level/preflight_view_test.exs @@ -0,0 +1,94 @@ +defmodule Ourocode.Plugin.UserLevel.PreflightViewTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.PreflightResult + alias Ourocode.Plugin.UserLevel.PreflightView + alias Ourocode.Plugin.UserLevel.Resolver + + defp superpowers do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + version: "0.4.2", + trust_scope: ["filesystem:read"], + commands: [ + %{ + name: "test-driven-development", + aliases: ["tdd"], + risk_class: "handoff_producing", + expected_artifacts: [".omx/superpowers/runs/*/seed.md"], + continuation_hint: "suggest_run" + }, + %{name: "list", risk_class: "read_only"} + ] + }) + + capability + end + + test "projects a unique_match into a JSON-safe map with trust + side effects" do + result = Resolver.resolve("ooo superpowers tdd --goal x", [superpowers()]) + + view = PreflightView.project(result) + + assert view.kind == :unique_match + assert view.plugin.plugin_id == "superpowers" + assert view.command.name == "test-driven-development" + assert view.args == ["--goal", "x"] + assert view.trust.state == :allowed + assert view.trust.remediation == nil + assert view.side_effects.execution == :pending_approval + assert view.side_effects.discovery == :read_only + assert view.side_effects.risk_class == :handoff_producing + + assert view.side_effects.expected_artifacts == [ + ".omx/superpowers/runs/*/seed.md" + ] + + assert view.side_effects.continuation_policy == :suggest + assert view.candidates == [] + assert view.match_explanation == %{matched_by: :alias, confidence: :alias} + end + + test "projects trust missing into remediation" do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + trust_scope: [], + commands: [%{name: "list", risk_class: "read_only"}] + }) + + result = Resolver.resolve("ooo superpowers list", [capability]) + view = PreflightView.project(result) + + assert view.trust.state == :missing + assert view.trust.remediation =~ "ouroboros plugin trust" + assert view.side_effects.execution == :pending_approval + end + + test "projects ambiguous candidates" do + a = superpowers() + {:ok, b} = Capability.new(%{plugin_id: "superpowers", source: :fixture}) + + result = Resolver.resolve("ooo superpowers list", [a, b]) + view = PreflightView.project(result) + + assert view.kind == :ambiguous + assert length(view.candidates) == 2 + assert view.side_effects.execution == :blocked + end + + test "projects not_applicable inputs with execution :blocked" do + result = %PreflightResult{ + kind: :not_applicable, + task_input: "hello" + } + + view = PreflightView.project(result) + assert view.kind == :not_applicable + assert view.side_effects.execution == :blocked + end +end diff --git a/test/ourocode/plugin/user_level/registry_entry_test.exs b/test/ourocode/plugin/user_level/registry_entry_test.exs new file mode 100644 index 0000000..56703e6 --- /dev/null +++ b/test/ourocode/plugin/user_level/registry_entry_test.exs @@ -0,0 +1,98 @@ +defmodule Ourocode.Plugin.UserLevel.RegistryEntryTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.RegistryEntry + + defp capability(opts \\ []) do + {:ok, capability} = + Capability.new(%{ + plugin_id: Keyword.get(opts, :plugin_id, "superpowers"), + source: :ouroboros_cli, + version: Keyword.get(opts, :version, "0.4.2"), + manifest_digest: Keyword.get(opts, :manifest_digest, "sha256:abc"), + trust_scope: Keyword.get(opts, :trust_scope, []), + commands: [ + %{ + name: "test-driven-development", + aliases: ["tdd"], + summary: "TDD handoff.", + args: [%{name: "goal", required: true, description: "User goal."}], + risk_class: "handoff_producing", + expected_artifacts: [".omx/superpowers/runs/*/seed.md"], + continuation_hint: "suggest_run" + } + ] + }) + + capability + end + + test "projects one entry per command capability" do + [entry] = RegistryEntry.entries(capability()) + + assert entry.id == "user_level_plugin:superpowers:test-driven-development" + assert entry.name == "superpowers test-driven-development" + assert entry.slash == "/superpowers test-driven-development" + assert entry.aliases == ["/superpowers tdd"] + assert entry.source == :plugin + assert entry.source_id == "superpowers" + assert entry.category == :plugins + assert entry.summary == "TDD handoff." + assert entry.availability == :available + assert entry.runnable? == true + + assert entry.args == [%{name: "goal", required?: true, description: "User goal."}] + + assert entry.run_spec.kind == :user_level_plugin_command + assert entry.run_spec.plugin_id == "superpowers" + assert entry.run_spec.command == "test-driven-development" + assert entry.run_spec.risk_class == :handoff_producing + assert entry.run_spec.continuation_hint == :suggest_run + + assert entry.metadata.plugin_id == "superpowers" + assert entry.metadata.plugin_surface == :user_level + assert entry.metadata.namespace_owner == :ouroboros + assert entry.metadata.capability_version == "0.4.2" + assert entry.metadata.manifest_digest == "sha256:abc" + end + + test "trust metadata defaults to requires_explicit_approval when no scopes are present" do + [entry] = RegistryEntry.entries(capability(trust_scope: [])) + + assert entry.metadata.trust_policy == %{ + "tier" => "user_level", + "requires_explicit_approval" => true + } + + assert entry.metadata.trust_evaluation == %{ + "trusted" => false, + "trust_scopes" => [] + } + end + + test "trust metadata reflects discovered trust scopes" do + scopes = ["filesystem:read", "filesystem:write"] + [entry] = RegistryEntry.entries(capability(trust_scope: scopes)) + + assert entry.metadata.trust_policy == %{ + "tier" => "user_level", + "requires_explicit_approval" => false, + "trust_scopes" => scopes + } + + assert entry.metadata.trust_evaluation == %{ + "trusted" => true, + "trust_scopes" => scopes + } + end + + test "entries/1 flattens a list of capabilities" do + one = capability(plugin_id: "a", manifest_digest: "sha256:a") + two = capability(plugin_id: "b", manifest_digest: "sha256:b") + + entries = RegistryEntry.entries([one, two]) + assert length(entries) == 2 + assert Enum.map(entries, & &1.source_id) == ["a", "b"] + end +end diff --git a/test/ourocode/plugin/user_level/registry_test.exs b/test/ourocode/plugin/user_level/registry_test.exs new file mode 100644 index 0000000..f4c3363 --- /dev/null +++ b/test/ourocode/plugin/user_level/registry_test.exs @@ -0,0 +1,117 @@ +defmodule Ourocode.Plugin.UserLevel.RegistryTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Registry + + defmodule StubAdapter do + @behaviour Ourocode.Plugin.UserLevel.Discovery + + @impl true + def discover(opts) do + Map.new(opts) |> Map.get(:stub_result, {:ok, []}) + end + end + + setup do + name = :"user_level_registry_#{System.unique_integer([:positive])}" + {:ok, _pid} = Registry.start_link(name: name, adapter: StubAdapter) + %{registry: name} + end + + test "starts in :empty status with no capabilities", %{registry: registry} do + snapshot = Registry.list(registry, max_age_ms: nil) + assert snapshot.status == :empty + assert snapshot.capabilities == [] + assert snapshot.refreshed_at == nil + end + + test "refresh/2 with successful discovery returns :ready snapshot", %{registry: registry} do + descriptors = [%{plugin_id: "superpowers", source: :fixture, version: "1.0.0"}] + + snapshot = + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + assert snapshot.status == :ready + assert [%Capability{plugin_id: "superpowers"}] = snapshot.capabilities + assert %DateTime{} = snapshot.refreshed_at + assert snapshot.errors == [] + end + + test "refresh/2 surface adapter errors as :degraded snapshot", %{registry: registry} do + snapshot = + Registry.refresh(registry, adapter_options: [stub_result: {:error, :boom}]) + + assert snapshot.status == :degraded + assert snapshot.capabilities == [] + assert [{:discovery_failed, :boom}] = snapshot.errors + assert %DateTime{} = snapshot.refreshed_at + end + + test "refresh/2 preserves last good capabilities on subsequent failure", %{registry: registry} do + descriptors = [%{plugin_id: "superpowers", source: :fixture}] + + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + after_failure = + Registry.refresh(registry, adapter_options: [stub_result: {:error, :network}]) + + assert after_failure.status == :degraded + assert [%Capability{plugin_id: "superpowers"}] = after_failure.capabilities + assert [{:discovery_failed, :network} | _] = after_failure.errors + end + + test "identity stability: same struct instance is returned across refreshes", %{ + registry: registry + } do + descriptors = [ + %{ + plugin_id: "superpowers", + source: :fixture, + version: "0.4.2", + manifest_digest: "sha256:abc" + } + ] + + %{capabilities: [first]} = + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + %{capabilities: [second]} = + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + # Same identity -> reused struct + assert first == second + assert Capability.identity(first) == Capability.identity(second) + end + + test "list/2 with TTL=0 triggers a refresh using the cached adapter options", + %{registry: registry} do + descriptors = [%{plugin_id: "x", source: :fixture}] + + # Seed the adapter options once via an explicit refresh. + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + # max_age_ms: 0 forces stale; list/2 must re-run discovery with the + # adapter options it was configured with. + snapshot = Registry.list(registry, max_age_ms: 0) + assert [%Capability{plugin_id: "x"}] = snapshot.capabilities + + # max_age_ms: nil returns the cached snapshot unchanged. + cached = Registry.list(registry, max_age_ms: nil) + assert cached.refreshed_at == snapshot.refreshed_at + end + + test "fetch/2 returns capability by plugin_id from cached snapshot", %{registry: registry} do + descriptors = [ + %{plugin_id: "superpowers", source: :fixture}, + %{plugin_id: "other", source: :fixture} + ] + + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + assert {:ok, %Capability{plugin_id: "superpowers"}} = + Registry.fetch(registry, "superpowers") + + assert :error == Registry.fetch(registry, "unknown") + end +end diff --git a/test/ourocode/plugin/user_level/resolver_test.exs b/test/ourocode/plugin/user_level/resolver_test.exs new file mode 100644 index 0000000..0ca0052 --- /dev/null +++ b/test/ourocode/plugin/user_level/resolver_test.exs @@ -0,0 +1,216 @@ +defmodule Ourocode.Plugin.UserLevel.ResolverTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.PreflightResult + alias Ourocode.Plugin.UserLevel.Resolver + + defp superpowers(opts \\ []) do + {:ok, cap} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + version: "0.4.2", + manifest_digest: "sha256:abc", + trust_scope: Keyword.get(opts, :trust_scope, ["filesystem:read", "filesystem:write"]), + commands: [ + %{name: "list", aliases: ["ls"], risk_class: "read_only"}, + %{ + name: "test-driven-development", + aliases: ["tdd"], + risk_class: "handoff_producing", + expected_artifacts: [".omx/superpowers/runs/*/seed.md"], + continuation_hint: "suggest_run" + } + ] + }) + + cap + end + + describe "resolve/2 — unique match" do + test "exact canonical command match returns :unique_match with confidence :exact" do + result = + Resolver.resolve( + "ooo superpowers test-driven-development --goal retry", + [superpowers()] + ) + + assert %PreflightResult{ + kind: :unique_match, + plugin: %Capability{plugin_id: "superpowers"}, + trust_state: :allowed, + risk_class: :handoff_producing, + expected_artifacts: [".omx/superpowers/runs/*/seed.md"], + continuation_policy: :suggest, + match_explanation: %{matched_by: :canonical, confidence: :exact} + } = result + + assert result.command.name == "test-driven-development" + assert result.args == ["--goal", "retry"] + assert result.reason == nil + end + + test "alias matches with confidence :alias" do + result = Resolver.resolve("ooo superpowers tdd --goal x", [superpowers()]) + + assert %PreflightResult{ + kind: :unique_match, + match_explanation: %{matched_by: :alias, confidence: :alias} + } = result + + assert result.command.name == "test-driven-development" + end + + test "treats `ouroboros` prefix identically to `ooo`" do + result = + Resolver.resolve("ouroboros superpowers tdd --goal x", [superpowers()]) + + assert %PreflightResult{kind: :unique_match} = result + end + + test "preserves argument casing verbatim" do + result = + Resolver.resolve( + "ooo superpowers tdd --Goal MixedCase --Verbose", + [superpowers()] + ) + + assert %PreflightResult{kind: :unique_match, args: args} = result + assert "--Goal" in args + assert "MixedCase" in args + assert "--Verbose" in args + end + + test "matches plugin and command case-insensitively even when typed in mixed case" do + result = Resolver.resolve("OOO Superpowers TDD --goal x", [superpowers()]) + + assert %PreflightResult{ + kind: :unique_match, + match_explanation: %{matched_by: :alias} + } = result + + assert result.command.name == "test-driven-development" + end + + test "preserves shell-injection-like arg tokens as argv (no shell parsing)" do + result = + Resolver.resolve( + ~s(ooo superpowers tdd --goal "; rm -rf /"), + [superpowers()] + ) + + assert result.kind == :unique_match + # tokenization is whitespace-only; quoting is not honored. The point is + # that no shell expansion happens here. + assert "--goal" in result.args + refute Enum.any?(result.args, &String.contains?(&1, "$(")) + end + end + + describe "resolve/2 — trust missing" do + test "capability without trust_scope returns :allowed=false and remediation" do + result = + Resolver.resolve( + "ooo superpowers list", + [superpowers(trust_scope: [])] + ) + + assert %PreflightResult{ + kind: :unique_match, + trust_state: :missing, + remediation: "ouroboros plugin trust superpowers --scope " + } = result + end + end + + describe "resolve/2 — unknown" do + test "unknown plugin returns :unknown with :unknown_plugin reason" do + result = Resolver.resolve("ooo unknownplug tdd", [superpowers()]) + + assert %PreflightResult{ + kind: :unknown, + reason: :unknown_plugin, + plugin: nil, + command: nil + } = result + end + + test "known plugin with unknown command returns :unknown with :unknown_command reason" do + result = Resolver.resolve("ooo superpowers nope", [superpowers()]) + + assert %PreflightResult{ + kind: :unknown, + reason: :unknown_command, + plugin: %Capability{plugin_id: "superpowers"}, + command: nil + } = result + end + + test "missing command token returns :missing_command_token" do + result = Resolver.resolve("ooo superpowers", [superpowers()]) + + assert %PreflightResult{ + kind: :unknown, + reason: :missing_command_token, + plugin: %Capability{plugin_id: "superpowers"} + } = result + end + + test "missing plugin token returns :missing_plugin_token" do + result = Resolver.resolve("ooo", [superpowers()]) + + assert %PreflightResult{kind: :unknown, reason: :missing_plugin_token} = result + end + end + + describe "resolve/2 — ambiguous" do + test "duplicate plugin_ids return :ambiguous with candidates" do + cap_a = superpowers() + {:ok, cap_b} = Capability.new(%{plugin_id: "superpowers", source: :fixture}) + + result = Resolver.resolve("ooo superpowers list", [cap_a, cap_b]) + + assert %PreflightResult{ + kind: :ambiguous, + reason: :duplicate_plugin_ids + } = result + + assert length(result.candidates) == 2 + end + end + + describe "resolve/2 — not applicable" do + test "non-ooo input returns :not_applicable" do + result = Resolver.resolve("interview some goal", [superpowers()]) + + assert %PreflightResult{ + kind: :not_applicable, + reason: :not_user_level_plugin_input + } = result + end + + test "blank input returns :not_applicable" do + result = Resolver.resolve(" ", [superpowers()]) + assert result.kind == :not_applicable + end + end + + describe "applies_to?/2" do + test "true when ooo + known plugin id" do + assert Resolver.applies_to?("ooo superpowers list", [superpowers()]) + end + + test "false when ooo + unknown plugin id" do + refute Resolver.applies_to?("ooo other list", [superpowers()]) + end + + test "false for non-ooo input" do + refute Resolver.applies_to?("interview x", [superpowers()]) + end + + test "false for blank input" do + refute Resolver.applies_to?("", [superpowers()]) + end + end +end diff --git a/test/ourocode/runtime/dispatcher/route_resolution_user_level_test.exs b/test/ourocode/runtime/dispatcher/route_resolution_user_level_test.exs new file mode 100644 index 0000000..ab8dd1e --- /dev/null +++ b/test/ourocode/runtime/dispatcher/route_resolution_user_level_test.exs @@ -0,0 +1,63 @@ +defmodule Ourocode.Runtime.Dispatcher.RouteResolutionUserLevelTest do + use ExUnit.Case, async: true + + alias Ourocode.Runtime.Dispatcher.RouteResolution + + describe ":user_level_plugin route validation" do + test "validate_decision/1 accepts a well-formed user_level_plugin decision" do + decision = %{ + kind: :user_level_plugin, + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :user_level_plugin_resolved, + plugin_id: "superpowers" + } + + assert :ok == RouteResolution.validate_decision(decision) + end + + test "validate_decision/1 rejects a user_level_plugin decision that carries an adapter_route" do + decision = %{ + kind: :user_level_plugin, + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :user_level_plugin_resolved, + plugin_id: "superpowers", + adapter_route: :workflow + } + + assert {:error, {:unexpected_adapter_route, :workflow}} = + RouteResolution.validate_decision(decision) + end + end + + describe ":user_level_plugin adapter_keys" do + test "scopes by plugin_id and falls back to generic key" do + decision = %{ + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + plugin_id: "superpowers" + } + + assert [{:user_level_plugin, "superpowers"}, :user_level_plugin] = + RouteResolution.adapter_keys(decision) + end + + test "falls back to :user_level_plugin when plugin_id is missing" do + decision = %{ + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto + } + + assert [:user_level_plugin] = RouteResolution.adapter_keys(decision) + end + end +end diff --git a/test/ourocode/runtime/user_level_plugin_invocation_post_execution_test.exs b/test/ourocode/runtime/user_level_plugin_invocation_post_execution_test.exs new file mode 100644 index 0000000..fa2bf87 --- /dev/null +++ b/test/ourocode/runtime/user_level_plugin_invocation_post_execution_test.exs @@ -0,0 +1,155 @@ +defmodule Ourocode.Runtime.UserLevelPluginInvocationPostExecutionTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Runtime.UserLevelPluginInvocation + alias Ourocode.TaskRequest + + setup do + tmp = Path.join(System.tmp_dir!(), "ourocode_invocation_post_#{System.unique_integer([:positive])}") + File.mkdir_p!(tmp) + on_exit(fn -> File.rm_rf!(tmp) end) + %{cwd: tmp} + end + + defp superpowers_capability do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + trust_scope: ["filesystem:read", "filesystem:write"], + commands: [ + %{ + name: "tdd", + risk_class: "handoff_producing", + expected_artifacts: [".omx/superpowers/runs/*/seed.md"], + continuation_hint: "suggest_run" + } + ] + }) + + capability + end + + defp task(input) do + %TaskRequest{ + id: "tr-#{System.unique_integer([:positive])}", + source: :cli, + task_input: input, + submitted_at_ms: 0, + routing_decision: %{ + kind: :user_level_plugin, + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :user_level_plugin_resolved, + plugin_id: "superpowers" + } + } + end + + defp write_seed(cwd) do + seed = Path.join([cwd, ".omx", "superpowers", "runs", "abc", "seed.md"]) + seed |> Path.dirname() |> File.mkdir_p!() + File.write!(seed, "# seed\n") + seed + end + + test "attaches discovered artifacts and a :suggest continuation to the envelope", + %{cwd: cwd} do + seed = write_seed(cwd) + + runner = fn _cmd, _argv, _opts -> + {:ok, %{status: 0, stdout: "", stderr: ""}} + end + + assert {:ok, envelope} = + UserLevelPluginInvocation.execute(task("ooo superpowers tdd --goal x"), %{ + capabilities: [superpowers_capability()], + external_command_runner: runner, + cwd: cwd + }) + + assert envelope.status == :invoked + + assert [%{kind: :seed, path: ^seed}] = envelope.artifacts + assert envelope.continuation.action == :suggest + assert envelope.continuation.seed_path == seed + + assert envelope.continuation.command_template == + "ooo run seed_path=#{seed}" + end + + test "auto_run continuation when prompt opts in explicitly", %{cwd: cwd} do + write_seed(cwd) + runner = fn _cmd, _argv, _opts -> {:ok, %{status: 0, stdout: "", stderr: ""}} end + + {:ok, envelope} = + UserLevelPluginInvocation.execute( + task("ooo superpowers tdd --goal x then run the generated handoff"), + %{ + capabilities: [superpowers_capability()], + external_command_runner: runner, + cwd: cwd + } + ) + + assert envelope.continuation.action == :auto_run + end + + test "decision journal callback receives preflight + dispatch + artifact + continuation events", + %{cwd: cwd} do + write_seed(cwd) + runner = fn _cmd, _argv, _opts -> {:ok, %{status: 0, stdout: "", stderr: ""}} end + + pid = self() + journal = fn event -> send(pid, {:journal, event["event_type"]}); :ok end + + {:ok, _envelope} = + UserLevelPluginInvocation.execute(task("ooo superpowers tdd --goal x"), %{ + capabilities: [superpowers_capability()], + external_command_runner: runner, + cwd: cwd, + decision_journal: journal + }) + + assert_received {:journal, "user_level_preflight"} + assert_received {:journal, "user_level_dispatch"} + assert_received {:journal, "user_level_artifact"} + assert_received {:journal, "user_level_continuation"} + end + + test "blocked dispatch still records preflight and dispatch journal events", + %{cwd: cwd} do + runner = fn _cmd, _argv, _opts -> flunk("must not run when trust missing") end + + {:ok, untrusted} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + trust_scope: [], + commands: [%{name: "tdd", risk_class: "handoff_producing"}] + }) + + pid = self() + journal = fn event -> send(pid, {:journal, event["event_type"]}); :ok end + + {:ok, envelope} = + UserLevelPluginInvocation.execute(task("ooo superpowers tdd --goal x"), %{ + capabilities: [untrusted], + external_command_runner: runner, + cwd: cwd, + decision_journal: journal + }) + + assert envelope.status == :blocked + assert envelope.blocked_reason == :trust_missing + + assert_received {:journal, "user_level_preflight"} + assert_received {:journal, "user_level_dispatch"} + refute_received {:journal, "user_level_artifact"} + refute_received {:journal, "user_level_continuation"} + end +end diff --git a/test/ourocode/runtime/user_level_plugin_invocation_test.exs b/test/ourocode/runtime/user_level_plugin_invocation_test.exs new file mode 100644 index 0000000..0a8e999 --- /dev/null +++ b/test/ourocode/runtime/user_level_plugin_invocation_test.exs @@ -0,0 +1,235 @@ +defmodule Ourocode.Runtime.UserLevelPluginInvocationTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Discovery.OuroborosCLI + alias Ourocode.Plugin.UserLevel.PreflightResult + alias Ourocode.Runtime.UserLevelPluginInvocation + alias Ourocode.TaskRequest + + @fixture_path Path.join([__DIR__, "..", "..", "fixtures", "user_level_plugins", "superpowers.json"]) + + defp superpowers_capability!(opts \\ []) do + json = File.read!(@fixture_path) + {:ok, [raw]} = OuroborosCLI.parse(json) + + raw = + raw + |> Map.put(:trust_scope, Keyword.get(opts, :trust_scope, raw.trust_scope)) + + {:ok, capability} = Capability.new(raw) + capability + end + + defp task_request(task_input) do + %TaskRequest{ + id: "tr-#{System.unique_integer([:positive])}", + source: :cli, + task_input: task_input, + submitted_at_ms: System.system_time(:millisecond), + routing_decision: %{ + kind: :user_level_plugin, + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :user_level_plugin_resolved, + plugin_id: "superpowers" + } + } + end + + describe "execute/2 — happy path" do + test "invokes via guarded runner and returns :invoked with execution result" do + runner = fn command, argv, _opts -> + send(self(), {:ran, command, argv}) + {:ok, %{status: 0, stdout: "superpowers list output", stderr: ""}} + end + + task = task_request("ooo superpowers list") + + assert {:ok, %{type: :user_level_plugin_invocation, status: :invoked} = result} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!()], + external_command_runner: runner + }) + + assert result.argv == ["superpowers", "list"] + assert result.command == "ouroboros" + assert result.execution.status == 0 + assert %PreflightResult{kind: :unique_match} = result.preflight + assert_received {:ran, "ouroboros", ["superpowers", "list"]} + end + + test "appends task args verbatim to argv" do + runner = fn _cmd, _argv, _opts -> {:ok, %{status: 0, stdout: "", stderr: ""}} end + + task = task_request("ooo superpowers tdd --goal \"add retry\"") + + assert {:ok, %{status: :invoked, argv: argv}} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!()], + external_command_runner: runner + }) + + assert ["superpowers", "test-driven-development", "--goal" | _rest] = argv + end + end + + describe "execute/2 — blocked paths (no execution)" do + test "trust missing blocks dispatch with structured reason" do + runner = fn _cmd, _argv, _opts -> + flunk("runner must not be called when trust is missing") + end + + task = task_request("ooo superpowers list") + + assert {:ok, %{status: :blocked, blocked_reason: :trust_missing} = result} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!(trust_scope: [])], + external_command_runner: runner + }) + + assert result.preflight.trust_state == :missing + assert result.preflight.remediation =~ "ouroboros plugin trust" + end + + test "unknown command blocks with :unknown_plugin_or_command" do + runner = fn _cmd, _argv, _opts -> flunk("must not run") end + task = task_request("ooo superpowers nope") + + assert {:ok, %{status: :blocked, blocked_reason: :unknown_plugin_or_command}} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!()], + external_command_runner: runner + }) + end + + test "destructive risk class blocks without explicit approval" do + runner = fn _cmd, _argv, _opts -> flunk("must not run") end + + {:ok, destructive_cap} = + Capability.new(%{ + plugin_id: "danger", + source: :fixture, + trust_scope: ["filesystem:write"], + commands: [%{name: "wipe", risk_class: "destructive"}] + }) + + task = %{task_request("ooo danger wipe") | routing_decision: %{ + kind: :user_level_plugin, + execution_route: :user_level_plugin, + runtime_source: :ouroboros, + transport: :auto, + requires_command_syntax?: false, + advanced_shortcut?: true, + reason: :user_level_plugin_resolved, + plugin_id: "danger" + }} + + assert {:ok, %{status: :blocked, blocked_reason: :destructive_action_requires_approval}} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [destructive_cap], + external_command_runner: runner + }) + end + + test "destructive risk class runs when explicit approval is granted" do + runner = fn _cmd, _argv, _opts -> {:ok, %{status: 0, stdout: "ok", stderr: ""}} end + + {:ok, destructive_cap} = + Capability.new(%{ + plugin_id: "danger", + source: :fixture, + trust_scope: ["filesystem:write"], + commands: [%{name: "wipe", risk_class: "destructive"}] + }) + + task = task_request("ooo danger wipe") + + assert {:ok, %{status: :invoked}} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [destructive_cap], + external_command_runner: runner, + destructive_action_approved?: true + }) + end + end + + describe "execute/2 — runner contract" do + test "missing runner errors :external_command_runner_not_configured" do + task = task_request("ooo superpowers list") + + assert {:error, :external_command_runner_not_configured} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!()] + }) + end + + test "invalid runner shape errors :invalid_external_command_runner" do + task = task_request("ooo superpowers list") + + assert {:error, :invalid_external_command_runner} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!()], + external_command_runner: :not_a_function + }) + end + + test "runner failure surfaces as blocked with structured reason" do + runner = fn _cmd, _argv, _opts -> + {:error, {:forbidden_external_command, :shell_wrapped_agent_command}} + end + + task = task_request("ooo superpowers list") + + assert {:ok, %{status: :blocked, blocked_reason: {:external_command_failed, _}}} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!()], + external_command_runner: runner + }) + end + end + + describe "execute/2 — argv shell-injection guard handoff" do + test "argv is a list with no shell concatenation, even with injection-shaped args" do + runner = fn command, argv, _opts -> + send(self(), {:argv, command, argv}) + {:ok, %{status: 0, stdout: "", stderr: ""}} + end + + task = task_request(~s|ooo superpowers tdd --goal "; rm -rf /"|) + + assert {:ok, %{status: :invoked}} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [superpowers_capability!()], + external_command_runner: runner + }) + + assert_received {:argv, "ouroboros", argv} + # Every arg is a separate list element. No element contains shell + # metacharacters interpolated into another arg. + assert is_list(argv) + assert Enum.all?(argv, &is_binary/1) + end + end + + describe "execute/2 — context validation" do + test "missing capabilities errors :capabilities_required_in_context" do + task = task_request("ooo superpowers list") + assert {:error, :capabilities_required_in_context} = + UserLevelPluginInvocation.execute(task, %{external_command_runner: fn _, _, _ -> {:ok, %{}} end}) + end + + test "non-Capability items in :capabilities errors :invalid_capabilities_in_context" do + task = task_request("ooo superpowers list") + + assert {:error, :invalid_capabilities_in_context} = + UserLevelPluginInvocation.execute(task, %{ + capabilities: [%{plugin_id: "x"}], + external_command_runner: fn _, _, _ -> {:ok, %{}} end + }) + end + end +end