Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions lib/ourocode/plugin/user_level/artifact_watcher.ex
Original file line number Diff line number Diff line change
@@ -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
124 changes: 124 additions & 0 deletions lib/ourocode/plugin/user_level/capability.ex
Original file line number Diff line number Diff line change
@@ -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
129 changes: 129 additions & 0 deletions lib/ourocode/plugin/user_level/capability/command.ex
Original file line number Diff line number Diff line change
@@ -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
Loading