Index the plugins the forge already holds

8ac99fe03785 · AtlantisPleb · · parent 308aa1b14f1d

Index the plugins the forge already holds

A plugin is a forge repository with a typed manifest and a
digest-pinned artifact, so the registry is an index over what exists
rather than a new backend. This is the server half.

The manifest schema validates what a caller has to be able to trust
before running someone else's code: identity, an artifact digest
pinned as sha256 with a fixed shape, typed input and output schemas,
capability declarations for read-only mounts, host allowlist and
bounds, a discovery description, contributed surfaces, and the
reserved price and license fields. Anything that does not validate is
refused with a typed error naming the field, so a bad manifest is a
answerable complaint rather than a silent omission.

The index lists validated manifests discovered from forge
repositories, behind a classified route the CLI's capability tool can
query.

On selection, the workspace rule holds: no ad hoc keyword or substring
routing. There is no embedding path here yet, so the index publishes
the manifests and their discovery descriptions and leaves selection to
the caller rather than faking semantics with string matching.
Invocation stays exact-name. When a real semantic path exists it goes
here, and until then the honest surface is the one that does not
pretend.

Known bound: the forge adapter treats a repository's default branch as
the release identity, because no separate release model exists yet.

Built by a Devin child through the openagents coder's delegate tool;
42 plugin, controller, and route-authority tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 350 · 2026-08-25T06:36:14.424698Z

Changed files

  • added lib/openagents/plugins/forge_source.ex
  • added lib/openagents/plugins/index.ex
  • added lib/openagents/plugins/manifest.ex
  • modified lib/openagents_web/api_route_authority.ex
  • added lib/openagents_web/controllers/plugin_registry_controller.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • added test/fixtures/plugin_manifest.json
  • added test/openagents/plugins/index_test.exs
  • added test/openagents/plugins/manifest_test.exs
  • added test/openagents_web/plugin_registry_controller_test.exs
  • modified test/openagents_web/route_authority_test.exs

Diff

12 files changed, +1065 -0

lib/openagents/plugins/forge_source.ex added +89

@@ -0,0 +1,89 @@

1
defmodule OpenAgents.Plugins.ForgeSource do
2
  @moduledoc """
3
  Adapts the plugin index over forge repository data.
4
5
  The forge already stores repository identity and git objects. This source is
6
  the smallest discoverable-release adapter: it walks the public, ready
7
  repositories and reads `manifest.json` from the default branch. A missing
8
  manifest is not an error; a plugin registry entry only appears when a
9
  repository publishes a valid typed manifest.
10
11
  The artifact digest is the manifest's declared `artifact.digest`; this layer
12
  does not fetch or verify the artifact bytes. That remains the runtime's job
13
  when a caller selects a plugin by exact name and installs it.
14
  """
15
16
  import Ecto.Query, warn: false
17
18
  alias OpenAgents.Plugins.Index
19
20
  @doc "Return index entries for every public, ready repository that has a manifest.json on its default branch."
21
  @spec entries() :: [Index.Entry.t()]
22
  def entries do
23
    OpenAgents.Repositories.Repository
24
    |> from(where: [visibility: "public", lifecycle_state: "ready"])
25
    |> OpenAgents.Repo.all()
26
    |> Enum.flat_map(&entries_for_repository/1)
27
  end
28
29
  defp entries_for_repository(
30
         %{
31
           owner: owner,
32
           name: name,
33
           storage_key: storage_key,
34
           default_branch: branch
35
         } = repository
36
       )
37
       when is_binary(owner) and is_binary(name) and is_binary(storage_key) and is_binary(branch) do
38
    path = bare_path(repository)
39
    release = default_release(repository)
40
41
    case read_manifest_at_head(path, repository) do
42
      {:ok, manifest} ->
43
        [
44
          %Index.Entry{
45
            repository: display_path(repository),
46
            release: release,
47
            manifest: manifest
48
          }
49
        ]
50
51
      {:error, _reason} ->
52
        []
53
    end
54
  end
55
56
  defp entries_for_repository(_), do: []
57
58
  defp read_manifest_at_head(path, repository) do
59
    with refs when is_map(refs) <- OpenAgents.Forge.Repos.refs_at(path),
60
         sha when is_binary(sha) <- Map.get(refs, "refs/heads/#{repository.default_branch}") do
61
      read_manifest(path, sha)
62
    else
63
      _ ->
64
        {:error, :no_head}
65
    end
66
  end
67
68
  defp read_manifest(path, sha) do
69
    case OpenAgents.Forge.Repos.git(path, ["show", "#{sha}:manifest.json"]) do
70
      {output, 0} ->
71
        case Jason.decode(output) do
72
          {:ok, manifest} when is_map(manifest) ->
73
            {:ok, manifest}
74
75
          {:error, %Jason.DecodeError{}} ->
76
            {:error, :invalid_json}
77
        end
78
79
      _error ->
80
        {:error, :manifest_not_found}
81
    end
82
  end
83
84
  defp display_path(%{owner: owner, name: name}), do: "#{owner}/#{name}"
85
86
  defp bare_path(%{storage_key: storage_key}), do: OpenAgents.Forge.Repos.bare_path(storage_key)
87
88
  defp default_release(%{default_branch: branch}), do: branch
89
end
lib/openagents/plugins/index.ex added +103

@@ -0,0 +1,103 @@

1
defmodule OpenAgents.Plugins.Index do
2
  @moduledoc """
3
  A typed index of validated plugin manifests.
4
5
  The index accepts a source of `{repository, release, raw_manifest}` entries
6
  and surfaces only those whose manifests validate. It supports listing and
7
  exact-name lookup, leaving semantic selection to callers.
8
  """
9
10
  require Logger
11
12
  alias OpenAgents.Plugins.Manifest
13
14
  defmodule Entry do
15
    @moduledoc "One indexed, validated plugin release."
16
    defstruct [:repository, :release, :manifest]
17
18
    @type t :: %__MODULE__{
19
            repository: String.t(),
20
            release: String.t(),
21
            manifest: map()
22
          }
23
  end
24
25
  @doc "List validated plugin entries from the configured or provided source."
26
  @spec list(keyword()) :: [Entry.t()]
27
  def list(opts \\ []) do
28
    source = Keyword.get(opts, :source, default_source())
29
30
    source
31
    |> fetch()
32
    |> Enum.reduce([], fn entry, acc ->
33
      case validate_entry(entry) do
34
        {:ok, validated} ->
35
          [validated | acc]
36
37
        {:error, %Manifest.ValidationError{} = error} ->
38
          Logger.warning(
39
            "plugin_manifest_invalid repository=#{entry.repository} release=#{entry.release} field=#{error.field}"
40
          )
41
42
          acc
43
      end
44
    end)
45
    |> Enum.reverse()
46
  end
47
48
  @doc "Look up one validated manifest by exact plugin name."
49
  @spec get(String.t(), keyword()) :: {:ok, Entry.t()} | {:error, :not_found}
50
  def get(name, opts \\ []) when is_binary(name) do
51
    list(opts)
52
    |> Enum.find(fn %Entry{manifest: manifest} -> manifest["name"] == name end)
53
    |> case do
54
      %Entry{} = entry -> {:ok, entry}
55
      nil -> {:error, :not_found}
56
    end
57
  end
58
59
  @doc "Render an index entry as a JSON-friendly map."
60
  @spec to_map(Entry.t()) :: map()
61
  def to_map(%Entry{repository: repository, release: release, manifest: manifest}) do
62
    %{
63
      "repository" => repository,
64
      "release" => release,
65
      "manifest" => manifest
66
    }
67
  end
68
69
  defp fetch(module) when is_atom(module), do: module.entries()
70
71
  defp fetch(entries) when is_list(entries) do
72
    entries
73
    |> Enum.map(&to_entry/1)
74
    |> Enum.reject(&is_nil/1)
75
  end
76
77
  defp to_entry(%{repository: repository, release: release, raw_manifest: raw_manifest})
78
       when is_binary(repository) and is_binary(release) and is_map(raw_manifest) do
79
    %Entry{repository: repository, release: release, manifest: raw_manifest}
80
  end
81
82
  defp to_entry(%Entry{} = entry), do: entry
83
84
  defp to_entry(_invalid) do
85
    Logger.warning("plugin_index_malformed_entry")
86
    nil
87
  end
88
89
  defp validate_entry(%Entry{manifest: raw_manifest} = entry) do
90
    case Manifest.validate(raw_manifest) do
91
      {:ok, manifest} -> {:ok, %Entry{entry | manifest: manifest}}
92
      {:error, %Manifest.ValidationError{} = error} -> {:error, error}
93
    end
94
  end
95
96
  defp default_source do
97
    Application.get_env(:openagents, OpenAgents.Plugins.Index,
98
      source: OpenAgents.Plugins.ForgeSource
99
    )[
100
      :source
101
    ]
102
  end
103
end
lib/openagents/plugins/manifest.ex added +421

@@ -0,0 +1,421 @@

1
defmodule OpenAgents.Plugins.Manifest do
2
  @moduledoc """
3
  Typed manifest validation for plugin registries.
4
5
  A plugin manifest is the unit the registry indexes. It carries identity,
6
  a typed interface, declared capabilities, discovery text, and reserved
7
  economy fields. This module validates the wire shape without running the
8
  artifact and returns a field-keyed error for any malformed value.
9
  """
10
11
  alias __MODULE__.ValidationError
12
13
  defmodule ValidationError do
14
    @moduledoc "A typed validation refusal naming the field that failed."
15
    defstruct [:field, :reason]
16
17
    @type t :: %__MODULE__{
18
            field: String.t(),
19
            reason: atom()
20
          }
21
  end
22
23
  @manifest_keys ~w(manifest_version name version author description artifact
24
                    abi interface capabilities surfaces price_msats license)
25
26
  @required_keys ~w(manifest_version name version author description artifact
27
                    abi interface capabilities price_msats license)
28
29
  @name_pattern ~r/\A[a-z][a-z0-9_]{0,63}\z/
30
  @semver_pattern ~r/\A[0-9]+\.[0-9]+\.[0-9]+(?:[-+.A-Za-z0-9]+)?\z/
31
  @digest_pattern ~r/\Asha256:[0-9a-f]{64}\z/
32
33
  @schema_types MapSet.new(~w(object array string integer number boolean null))
34
35
  @doc "Validate a decoded manifest map and return the normalized manifest or a field-keyed error."
36
  @spec validate(map()) :: {:ok, map()} | {:error, ValidationError.t()}
37
  def validate(%{} = manifest) do
38
    with :ok <- required_keys(manifest, @required_keys),
39
         :ok <- exact_keys(manifest, @manifest_keys),
40
         :ok <- validate_identity(manifest),
41
         :ok <- validate_artifact(manifest["artifact"]),
42
         :ok <- validate_abi(manifest["abi"]),
43
         :ok <- validate_interface(manifest["interface"]),
44
         :ok <- validate_capabilities(manifest["capabilities"]),
45
         :ok <- validate_surfaces(manifest["surfaces"]),
46
         :ok <- validate_reserved(manifest) do
47
      {:ok, normalize(manifest)}
48
    end
49
  end
50
51
  def validate(_manifest), do: error("root", :not_a_map)
52
53
  defp required_keys(manifest, required) do
54
    keys = manifest |> Map.keys() |> Enum.map(&to_string/1) |> MapSet.new()
55
56
    case Enum.find(required, &(!MapSet.member?(keys, &1))) do
57
      nil -> :ok
58
      key -> error(key, :missing)
59
    end
60
  end
61
62
  defp exact_keys(manifest, allowed) do
63
    keys = Map.keys(manifest) |> Enum.map(&to_string/1) |> Enum.sort()
64
    allowed = Enum.sort(allowed)
65
66
    case keys -- allowed do
67
      [] -> :ok
68
      [extra | _] -> error(extra, :unexpected_field)
69
    end
70
  end
71
72
  defp exact_nested_keys(map, allowed, prefix) do
73
    keys = Map.keys(map) |> Enum.map(&to_string/1) |> Enum.sort()
74
    allowed = Enum.sort(allowed)
75
76
    case keys -- allowed do
77
      [] -> :ok
78
      [extra | _] -> error("#{prefix}.#{extra}", :unexpected_field)
79
    end
80
  end
81
82
  defp validate_identity(manifest) do
83
    with :ok <- require_integer(manifest, "manifest_version"),
84
         :ok <- require_string(manifest, "name", &valid_name?/1),
85
         :ok <- require_string(manifest, "version", &valid_version?/1),
86
         :ok <- require_string(manifest, "author", &non_empty?/1),
87
         :ok <- require_string(manifest, "description", &non_empty?/1) do
88
      :ok
89
    end
90
  end
91
92
  defp valid_name?(name), do: Regex.match?(@name_pattern, name)
93
  defp valid_version?(version), do: Regex.match?(@semver_pattern, version)
94
  defp non_empty?(value), do: is_binary(value) and String.trim(value) != ""
95
96
  defp validate_artifact(%{} = artifact) do
97
    with :ok <- exact_nested_keys(artifact, ~w(path digest), "artifact"),
98
         :ok <- require_string_value(artifact["path"], "artifact.path", &non_empty?/1),
99
         :ok <- require_string_value(artifact["digest"], "artifact.digest", &valid_digest?/1) do
100
      :ok
101
    end
102
  end
103
104
  defp validate_artifact(nil), do: error("artifact", :missing)
105
  defp validate_artifact(_), do: error("artifact", :not_a_map)
106
107
  defp valid_digest?(digest), do: Regex.match?(@digest_pattern, digest)
108
109
  defp validate_abi(%{} = abi) do
110
    with :ok <- exact_nested_keys(abi, ~w(kind entry alloc), "abi"),
111
         :ok <- require_string_value(abi["kind"], "abi.kind", &non_empty?/1),
112
         :ok <- require_string_value(abi["entry"], "abi.entry", &non_empty?/1),
113
         :ok <- require_string_value(abi["alloc"], "abi.alloc", &non_empty?/1) do
114
      :ok
115
    end
116
  end
117
118
  defp validate_abi(nil), do: error("abi", :missing)
119
  defp validate_abi(_), do: error("abi", :not_a_map)
120
121
  defp validate_interface(%{} = interface) do
122
    with :ok <- exact_nested_keys(interface, ~w(input output), "interface"),
123
         :ok <- validate_schema(interface["input"], "interface.input"),
124
         :ok <- validate_schema(interface["output"], "interface.output") do
125
      :ok
126
    end
127
  end
128
129
  defp validate_interface(nil), do: error("interface", :missing)
130
  defp validate_interface(_), do: error("interface", :not_a_map)
131
132
  defp validate_schema(nil, field), do: error(field, :missing)
133
134
  defp validate_schema(%{} = schema, field) do
135
    with :ok <- require_type(schema["type"], "#{field}.type"),
136
         :ok <- validate_schema_body(schema, field) do
137
      :ok
138
    end
139
  end
140
141
  defp validate_schema(_, field), do: error(field, :not_a_schema)
142
143
  defp require_type(nil, field), do: error(field, :missing)
144
145
  defp require_type(type, field) when is_binary(type) do
146
    if MapSet.member?(@schema_types, type),
147
      do: :ok,
148
      else: error(field, :invalid_type)
149
  end
150
151
  defp require_type(types, field) when is_list(types) and types != [] do
152
    Enum.reduce_while(types, :ok, fn type, :ok ->
153
      if is_binary(type) and MapSet.member?(@schema_types, type) do
154
        {:cont, :ok}
155
      else
156
        {:halt, error(field, :invalid_type)}
157
      end
158
    end)
159
  end
160
161
  defp require_type(_, field), do: error(field, :invalid_type)
162
163
  defp validate_schema_body(schema, field) do
164
    case schema["type"] do
165
      "object" -> validate_object_schema(schema, field)
166
      "array" -> validate_array_schema(schema, field)
167
      _ -> validate_simple_schema(schema, field)
168
    end
169
  end
170
171
  defp validate_object_schema(schema, field) do
172
    with :ok <-
173
           exact_nested_keys(
174
             schema,
175
             ~w(type properties required additionalProperties description),
176
             field
177
           ),
178
         :ok <- validate_schema_properties(schema["properties"], "#{field}.properties"),
179
         :ok <- validate_required_strings(schema["required"], "#{field}.required"),
180
         :ok <-
181
           validate_additional_properties(
182
             schema["additionalProperties"],
183
             "#{field}.additionalProperties"
184
           ) do
185
      :ok
186
    end
187
  end
188
189
  defp validate_schema_properties(nil, _field), do: :ok
190
191
  defp validate_schema_properties(%{} = props, field) do
192
    Enum.reduce_while(props, :ok, fn {name, sub_schema}, :ok ->
193
      key = to_string(name)
194
195
      case validate_schema(sub_schema, "#{field}.#{key}") do
196
        :ok -> {:cont, :ok}
197
        {:error, _} = err -> {:halt, err}
198
      end
199
    end)
200
  end
201
202
  defp validate_schema_properties(_, field), do: error(field, :not_a_map)
203
204
  defp validate_required_strings(nil, _field), do: :ok
205
206
  defp validate_required_strings(required, field) when is_list(required) do
207
    Enum.reduce_while(required, :ok, fn name, :ok ->
208
      case require_string_value(name, field, &non_empty?/1) do
209
        :ok -> {:cont, :ok}
210
        {:error, _} = err -> {:halt, err}
211
      end
212
    end)
213
  end
214
215
  defp validate_required_strings(_, field), do: error(field, :not_a_list)
216
217
  defp validate_additional_properties(nil, _field), do: :ok
218
  defp validate_additional_properties(value, _field) when is_boolean(value), do: :ok
219
  defp validate_additional_properties(_, field), do: error(field, :invalid_boolean)
220
221
  defp validate_array_schema(schema, field) do
222
    with :ok <- exact_nested_keys(schema, ~w(type items description), field),
223
         :ok <- validate_schema(schema["items"], "#{field}.items") do
224
      :ok
225
    end
226
  end
227
228
  defp validate_simple_schema(schema, field) do
229
    exact_nested_keys(schema, ~w(type description), field)
230
  end
231
232
  defp validate_capabilities(%{} = caps) do
233
    with :ok <-
234
           exact_nested_keys(caps, ~w(mounts hosts timeout_ms memory_max_mib), "capabilities"),
235
         :ok <- require_list(caps["mounts"], "capabilities.mounts"),
236
         :ok <- validate_mounts(caps["mounts"]),
237
         :ok <- require_list(caps["hosts"], "capabilities.hosts"),
238
         :ok <- validate_hosts(caps["hosts"]),
239
         :ok <- require_positive_integer(caps["timeout_ms"], "capabilities.timeout_ms"),
240
         :ok <- require_positive_integer(caps["memory_max_mib"], "capabilities.memory_max_mib") do
241
      :ok
242
    end
243
  end
244
245
  defp validate_capabilities(nil), do: error("capabilities", :missing)
246
  defp validate_capabilities(_), do: error("capabilities", :not_a_map)
247
248
  defp validate_mounts(mounts) when is_list(mounts) do
249
    Enum.reduce_while(Enum.with_index(mounts), :ok, fn {mount, idx}, :ok ->
250
      case validate_mount(mount, "capabilities.mounts.#{idx}") do
251
        :ok -> {:cont, :ok}
252
        {:error, _} = err -> {:halt, err}
253
      end
254
    end)
255
  end
256
257
  defp validate_mount(%{} = mount, field) do
258
    with :ok <- exact_nested_keys(mount, ~w(path readonly), field),
259
         :ok <- require_string_value(mount["path"], "#{field}.path", &non_empty?/1),
260
         :ok <- require_literal_true(mount["readonly"], "#{field}.readonly") do
261
      :ok
262
    end
263
  end
264
265
  defp validate_mount(_, field), do: error(field, :not_a_map)
266
267
  defp validate_hosts(hosts) when is_list(hosts) do
268
    Enum.reduce_while(Enum.with_index(hosts), :ok, fn {host, idx}, :ok ->
269
      case require_string_value(host, "capabilities.hosts.#{idx}", &non_empty?/1) do
270
        :ok -> {:cont, :ok}
271
        {:error, _} = err -> {:halt, err}
272
      end
273
    end)
274
  end
275
276
  defp validate_surfaces(nil), do: :ok
277
278
  defp validate_surfaces(surfaces) when is_list(surfaces) do
279
    Enum.reduce_while(Enum.with_index(surfaces), :ok, fn {surface, idx}, :ok ->
280
      case validate_surface(surface, "surfaces.#{idx}") do
281
        :ok -> {:cont, :ok}
282
        {:error, _} = err -> {:halt, err}
283
      end
284
    end)
285
  end
286
287
  defp validate_surfaces(_), do: error("surfaces", :not_a_list)
288
289
  defp validate_surface(%{} = surface, field) do
290
    with :ok <-
291
           exact_nested_keys(surface, ~w(kind name description slash_commands tools), field),
292
         :ok <- require_string_value(surface["name"], "#{field}.name", &non_empty?/1),
293
         :ok <-
294
           require_string_value(surface["description"], "#{field}.description", &non_empty?/1),
295
         :ok <- validate_slash_commands(surface["slash_commands"], "#{field}.slash_commands"),
296
         :ok <- validate_tools(surface["tools"], "#{field}.tools") do
297
      :ok
298
    end
299
  end
300
301
  defp validate_surface(_, field), do: error(field, :not_a_map)
302
303
  defp validate_slash_commands(nil, _field), do: :ok
304
305
  defp validate_slash_commands(commands, field) when is_list(commands) do
306
    Enum.reduce_while(Enum.with_index(commands), :ok, fn {cmd, idx}, :ok ->
307
      case validate_slash_command(cmd, "#{field}.#{idx}") do
308
        :ok -> {:cont, :ok}
309
        {:error, _} = err -> {:halt, err}
310
      end
311
    end)
312
  end
313
314
  defp validate_slash_commands(_, field), do: error(field, :not_a_list)
315
316
  defp validate_slash_command(%{} = cmd, field) do
317
    with :ok <- exact_nested_keys(cmd, ~w(command description), field),
318
         :ok <- require_string_value(cmd["command"], "#{field}.command", &non_empty?/1),
319
         :ok <- require_string_value(cmd["description"], "#{field}.description", &non_empty?/1) do
320
      :ok
321
    end
322
  end
323
324
  defp validate_slash_command(_, field), do: error(field, :not_a_map)
325
326
  defp validate_tools(nil, _field), do: :ok
327
328
  defp validate_tools(tools, field) when is_list(tools) do
329
    Enum.reduce_while(Enum.with_index(tools), :ok, fn {tool, idx}, :ok ->
330
      case validate_tool(tool, "#{field}.#{idx}") do
331
        :ok -> {:cont, :ok}
332
        {:error, _} = err -> {:halt, err}
333
      end
334
    end)
335
  end
336
337
  defp validate_tools(_, field), do: error(field, :not_a_list)
338
339
  defp validate_tool(%{} = tool, field) do
340
    with :ok <- exact_nested_keys(tool, ~w(name description), field),
341
         :ok <- require_string_value(tool["name"], "#{field}.name", &non_empty?/1),
342
         :ok <- require_string_value(tool["description"], "#{field}.description", &non_empty?/1) do
343
      :ok
344
    end
345
  end
346
347
  defp validate_tool(_, field), do: error(field, :not_a_map)
348
349
  defp validate_reserved(manifest) do
350
    with :ok <- validate_price(manifest["price_msats"]),
351
         :ok <- validate_license(manifest["license"]) do
352
      :ok
353
    end
354
  end
355
356
  defp validate_price(nil), do: :ok
357
  defp validate_price(price) when is_integer(price) and price >= 0, do: :ok
358
  defp validate_price(_), do: error("price_msats", :invalid_price_msats)
359
360
  defp validate_license(nil), do: :ok
361
  defp validate_license(license) when is_binary(license), do: :ok
362
  defp validate_license(_), do: error("license", :invalid_license)
363
364
  defp require_integer(manifest, key) do
365
    case manifest[key] do
366
      value when is_integer(value) and value > 0 -> :ok
367
      nil -> error(key, :missing)
368
      _ -> error(key, :invalid_integer)
369
    end
370
  end
371
372
  defp require_string(manifest, key, pred) do
373
    case manifest[key] do
374
      nil ->
375
        error(key, :missing)
376
377
      value when is_binary(value) ->
378
        if pred.(value), do: :ok, else: error(key, :invalid_string)
379
380
      _ ->
381
        error(key, :invalid_string)
382
    end
383
  end
384
385
  defp require_string_value(nil, key, _pred), do: error(key, :missing)
386
387
  defp require_string_value(value, key, pred) when is_binary(value) do
388
    if pred.(value), do: :ok, else: error(key, :invalid_string)
389
  end
390
391
  defp require_string_value(_, key, _pred), do: error(key, :invalid_string)
392
393
  defp require_list(nil, key), do: error(key, :missing)
394
  defp require_list(value, _key) when is_list(value), do: :ok
395
  defp require_list(_, key), do: error(key, :not_a_list)
396
397
  defp require_literal_true(nil, field), do: error(field, :missing)
398
  defp require_literal_true(true, _field), do: :ok
399
  defp require_literal_true(_, field), do: error(field, :invalid_readonly)
400
401
  defp require_positive_integer(nil, key), do: error(key, :missing)
402
403
  defp require_positive_integer(value, _key) when is_integer(value) and value > 0,
404
    do: :ok
405
406
  defp require_positive_integer(_, key), do: error(key, :invalid_positive_integer)
407
408
  defp error(field, reason) do
409
    {:error, %ValidationError{field: to_string(field), reason: reason}}
410
  end
411
412
  defp normalize(manifest) do
413
    Map.new(manifest, fn {key, value} -> {to_string(key), normalize_value(value)} end)
414
  end
415
416
  defp normalize_value(%{} = map),
417
    do: Map.new(map, fn {k, v} -> {to_string(k), normalize_value(v)} end)
418
419
  defp normalize_value(list) when is_list(list), do: Enum.map(list, &normalize_value/1)
420
  defp normalize_value(value), do: value
421
end
lib/openagents_web/api_route_authority.ex modified +3

@@ -123,6 +123,9 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

123 123
      "post /api/v1/device/authorizations/token" => {:anonymous, :device, :legacy},
124 124
      "post /api/v1/agents/register" => {:anonymous, :agent, :legacy},
125 125
      "get /api/v1/agents/:handle" => {:anonymous, :agent, :legacy},
126
      # Anonymous by design: the CLI discovers validated plugin manifests.
127
      "get /api/v1/plugins" => {:anonymous, :plugin, :legacy},
128
      "get /api/v1/plugins/:name" => {:anonymous, :plugin, :legacy},
126 129
      # pipe_through :optional_forge_api — public reads, bearer-widened.
127 130
      # The ancillary issue metadata reads. Anonymous callers still read public
128 131
      # repositories; a bearer token widens them to the private repositories
lib/openagents_web/controllers/plugin_registry_controller.ex added +30

@@ -0,0 +1,30 @@

1
defmodule OpenAgentsWeb.PluginRegistryController do
2
  @moduledoc """
3
  Public plugin registry discovery for the CLI.
4
5
  The index lists validated manifests and leaves semantic selection to the
6
  caller. Exact-name lookup is available for invocation.
7
  """
8
9
  use OpenAgentsWeb, :controller
10
11
  alias OpenAgents.Plugins.Index
12
13
  def index(conn, _params) do
14
    conn
15
    |> put_resp_header("cache-control", "public, max-age=60")
16
    |> json(%{"plugins" => Enum.map(Index.list(), &Index.to_map/1)})
17
  end
18
19
  def show(conn, %{"name" => name}) do
20
    case Index.get(name) do
21
      {:ok, entry} ->
22
        conn
23
        |> put_resp_header("cache-control", "public, max-age=60")
24
        |> json(%{"plugin" => Index.to_map(entry)})
25
26
      {:error, :not_found} ->
27
        OpenAgentsWeb.ApiError.not_found(conn)
28
    end
29
  end
30
end
lib/openagents_web/route_authority.ex modified +5

@@ -589,6 +589,11 @@ defmodule OpenAgentsWeb.RouteAuthority do

589 589
           false
590 590
         )
591 591
592
  defp policy(%{path: path, verb: verb})
593
       when path in ["/api/v1/plugins", "/api/v1/plugins/:name"] and
594
              verb in [:get, :head],
595
       do: declaration(:public_read, "anonymous", "plugins:discover", false)
596
592 597
  defp policy(%{path: path, verb: :post})
593 598
       when path in [
594 599
              "/api/v1/forum/topics",
lib/openagents_web/router.ex modified +3

@@ -417,6 +417,9 @@ defmodule OpenAgentsWeb.Router do

417 417
418 418
    get "/", ApiExtensionController, :show
419 419
420
    get "/plugins", PluginRegistryController, :index
421
    get "/plugins/:name", PluginRegistryController, :show
422
420 423
    post "/device/authorizations", DeviceAuthorizationController, :create
421 424
    post "/device/authorizations/token", DeviceAuthorizationController, :token
422 425
  end
test/fixtures/plugin_manifest.json added +98

@@ -0,0 +1,98 @@

1
{
2
  "manifest_version": 1,
3
  "name": "git_lost_work",
4
  "version": "0.1.0",
5
  "author": "OpenAgents",
6
  "description": "Scan a mounted .git directory for unreachable commits and stash entries. It reads HEAD, loose refs, packed-refs, reflogs, and loose objects directly. It does not shell out to git, write anything, or parse packfiles. Use it when asked what commits or stashes may have been lost from a local git repository.",
7
  "artifact": {
8
    "path": "git_lost_work.wasm",
9
    "digest": "sha256:366760578cb1d83ab49a0819150308014401972907a44c89618e74de3a906c36"
10
  },
11
  "abi": {
12
    "kind": "packet-v0",
13
    "entry": "handle_packet",
14
    "alloc": "packet_alloc"
15
  },
16
  "interface": {
17
    "input": {
18
      "type": "object",
19
      "properties": {
20
        "max_lost_commits": {
21
          "type": "integer",
22
          "description": "Maximum number of lost commits to report. Default 50, capped at 100."
23
        }
24
      },
25
      "required": [],
26
      "additionalProperties": false
27
    },
28
    "output": {
29
      "type": "object",
30
      "properties": {
31
        "ok": {
32
          "type": "object",
33
          "properties": {
34
            "head": {
35
              "type": "object",
36
              "properties": {
37
                "branch": { "type": ["string", "null"] },
38
                "sha": { "type": ["string", "null"] }
39
              }
40
            },
41
            "stash_entries": {
42
              "type": "array",
43
              "items": {
44
                "type": "object",
45
                "properties": {
46
                  "selector": { "type": "string" },
47
                  "sha": { "type": "string" },
48
                  "message": { "type": ["string", "null"] },
49
                  "timestamp": { "type": "integer" }
50
                }
51
              }
52
            },
53
            "lost_commits": {
54
              "type": "array",
55
              "items": {
56
                "type": "object",
57
                "properties": {
58
                  "sha": { "type": "string" },
59
                  "action": { "type": ["string", "null"] },
60
                  "timestamp": { "type": "integer" },
61
                  "packed": { "type": "boolean" },
62
                  "subject": { "type": ["string", "null"] },
63
                  "author": { "type": ["string", "null"] },
64
                  "author_date": { "type": ["integer", "null"] }
65
                }
66
              }
67
            },
68
            "summary": {
69
              "type": "object",
70
              "properties": {
71
                "total_lost_candidates": { "type": "integer" },
72
                "stashes_count": { "type": "integer" }
73
              }
74
            }
75
          }
76
        },
77
        "refusal": {
78
          "type": "object",
79
          "properties": {
80
            "code": { "type": "string" },
81
            "reason": { "type": "string" }
82
          },
83
          "required": ["code", "reason"]
84
        }
85
      }
86
    }
87
  },
88
  "capabilities": {
89
    "mounts": [
90
      { "path": ".", "readonly": true }
91
    ],
92
    "hosts": [],
93
    "timeout_ms": 10000,
94
    "memory_max_mib": 128
95
  },
96
  "price_msats": null,
97
  "license": "Apache-2.0"
98
}
test/openagents/plugins/index_test.exs added +82

@@ -0,0 +1,82 @@

1
defmodule OpenAgents.Plugins.IndexTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Plugins.Index
5
6
  @fixture_path "test/fixtures/plugin_manifest.json"
7
8
  defp shipping_manifest do
9
    @fixture_path
10
    |> File.read!()
11
    |> Jason.decode!()
12
  end
13
14
  test "lists only validated manifests" do
15
    entries = [
16
      %{
17
        repository: "OpenAgentsInc/git-lost-work",
18
        release: "main",
19
        raw_manifest: shipping_manifest()
20
      },
21
      %{repository: "OpenAgentsInc/bad", release: "main", raw_manifest: %{"name" => "bad"}}
22
    ]
23
24
    [validated] = Index.list(source: entries)
25
    assert validated.repository == "OpenAgentsInc/git-lost-work"
26
    assert validated.release == "main"
27
    assert validated.manifest["name"] == "git_lost_work"
28
  end
29
30
  test "returns an empty list when every manifest is invalid" do
31
    entries = [
32
      %{repository: "OpenAgentsInc/bad", release: "main", raw_manifest: %{}},
33
      %{repository: "OpenAgentsInc/bad2", release: "main", raw_manifest: "not a map"}
34
    ]
35
36
    assert Index.list(source: entries) == []
37
  end
38
39
  test "skips malformed source rows without crashing" do
40
    entries = [
41
      %{
42
        repository: "OpenAgentsInc/git-lost-work",
43
        release: "main",
44
        raw_manifest: shipping_manifest()
45
      },
46
      %{repository: "OpenAgentsInc/bad", release: "main"},
47
      %{release: "main", raw_manifest: %{}},
48
      "not a map"
49
    ]
50
51
    [validated] = Index.list(source: entries)
52
    assert validated.repository == "OpenAgentsInc/git-lost-work"
53
    assert validated.release == "main"
54
    assert validated.manifest["name"] == "git_lost_work"
55
  end
56
57
  test "finds a plugin by exact name" do
58
    entries = [
59
      %{
60
        repository: "OpenAgentsInc/git-lost-work",
61
        release: "main",
62
        raw_manifest: shipping_manifest()
63
      }
64
    ]
65
66
    assert {:ok, %Index.Entry{}} = Index.get("git_lost_work", source: entries)
67
    assert {:error, :not_found} = Index.get("unknown", source: entries)
68
  end
69
70
  test "renders an entry as a JSON map" do
71
    entry = %Index.Entry{
72
      repository: "OpenAgentsInc/git-lost-work",
73
      release: "main",
74
      manifest: shipping_manifest()
75
    }
76
77
    map = Index.to_map(entry)
78
    assert map["repository"] == "OpenAgentsInc/git-lost-work"
79
    assert map["release"] == "main"
80
    assert map["manifest"]["name"] == "git_lost_work"
81
  end
82
end
test/openagents/plugins/manifest_test.exs added +163

@@ -0,0 +1,163 @@

1
defmodule OpenAgents.Plugins.ManifestTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.Plugins.Manifest
5
6
  @fixture_path "test/fixtures/plugin_manifest.json"
7
8
  defp shipping_manifest do
9
    @fixture_path
10
    |> File.read!()
11
    |> Jason.decode!()
12
  end
13
14
  test "accepts the shipping git-lost-work manifest" do
15
    manifest = shipping_manifest()
16
    assert {:ok, validated} = Manifest.validate(manifest)
17
    assert validated["name"] == "git_lost_work"
18
    assert validated["version"] == "0.1.0"
19
    assert validated["price_msats"] == nil
20
    assert validated["license"] == "Apache-2.0"
21
  end
22
23
  test "rejects a manifest with an invalid name" do
24
    manifest = put_in(shipping_manifest(), ["name"], "Git Lost Work")
25
    assert {:error, %Manifest.ValidationError{field: "name"}} = Manifest.validate(manifest)
26
  end
27
28
  test "rejects a manifest with an invalid version" do
29
    manifest = put_in(shipping_manifest(), ["version"], "0.1")
30
    assert {:error, %Manifest.ValidationError{field: "version"}} = Manifest.validate(manifest)
31
  end
32
33
  test "rejects a manifest with a malformed artifact digest" do
34
    manifest = put_in(shipping_manifest(), ["artifact", "digest"], "sha256:deadbeef")
35
36
    assert {:error, %Manifest.ValidationError{field: "artifact.digest"}} =
37
             Manifest.validate(manifest)
38
  end
39
40
  test "rejects an artifact digest missing the sha256 prefix" do
41
    manifest =
42
      put_in(
43
        shipping_manifest(),
44
        ["artifact", "digest"],
45
        "366760578cb1d83ab49a0819150308014401972907a44c89618e74de3a906c36"
46
      )
47
48
    assert {:error, %Manifest.ValidationError{field: "artifact.digest"}} =
49
             Manifest.validate(manifest)
50
  end
51
52
  test "rejects an invalid interface.input schema" do
53
    manifest = put_in(shipping_manifest(), ["interface", "input"], "not a schema")
54
55
    assert {:error, %Manifest.ValidationError{field: "interface.input"}} =
56
             Manifest.validate(manifest)
57
  end
58
59
  test "rejects invalid nested capability types" do
60
    manifest = put_in(shipping_manifest(), ["capabilities", "timeout_ms"], -1)
61
62
    assert {:error, %Manifest.ValidationError{field: "capabilities.timeout_ms"}} =
63
             Manifest.validate(manifest)
64
  end
65
66
  test "rejects a capability host that is not a string" do
67
    manifest = put_in(shipping_manifest(), ["capabilities", "hosts"], [123])
68
69
    assert {:error, %Manifest.ValidationError{field: "capabilities.hosts.0"}} =
70
             Manifest.validate(manifest)
71
  end
72
73
  test "rejects a reserved price that is not null or a non-negative integer" do
74
    manifest = put_in(shipping_manifest(), ["price_msats"], "free")
75
    assert {:error, %Manifest.ValidationError{field: "price_msats"}} = Manifest.validate(manifest)
76
  end
77
78
  test "rejects a reserved license that is not null or a string" do
79
    manifest = put_in(shipping_manifest(), ["license"], 123)
80
    assert {:error, %Manifest.ValidationError{field: "license"}} = Manifest.validate(manifest)
81
  end
82
83
  test "rejects unknown top-level fields" do
84
    manifest = Map.put(shipping_manifest(), "extra", true)
85
    assert {:error, %Manifest.ValidationError{}} = Manifest.validate(manifest)
86
  end
87
88
  test "rejects a non-map manifest" do
89
    assert {:error, %Manifest.ValidationError{field: "root"}} = Manifest.validate("not a map")
90
  end
91
92
  test "rejects a missing required top-level field" do
93
    manifest = Map.delete(shipping_manifest(), "manifest_version")
94
95
    assert {:error, %Manifest.ValidationError{field: "manifest_version"}} =
96
             Manifest.validate(manifest)
97
  end
98
99
  test "rejects an artifact missing a nested required field" do
100
    manifest = put_in(shipping_manifest(), ["artifact"], %{"path" => "git_lost_work.wasm"})
101
102
    assert {:error, %Manifest.ValidationError{field: "artifact.digest"}} =
103
             Manifest.validate(manifest)
104
  end
105
106
  test "rejects an interface input schema missing a type" do
107
    manifest = put_in(shipping_manifest(), ["interface", "input"], %{"properties" => %{}})
108
109
    assert {:error, %Manifest.ValidationError{field: "interface.input.type"}} =
110
             Manifest.validate(manifest)
111
  end
112
113
  test "rejects an invalid nested schema type" do
114
    manifest =
115
      put_in(
116
        shipping_manifest(),
117
        ["interface", "input", "properties", "max_lost_commits", "type"],
118
        "notype"
119
      )
120
121
    assert {:error,
122
            %Manifest.ValidationError{field: "interface.input.properties.max_lost_commits.type"}} =
123
             Manifest.validate(manifest)
124
  end
125
126
  test "rejects a mount that is not read-only" do
127
    manifest =
128
      put_in(shipping_manifest(), ["capabilities", "mounts"], [
129
        %{"path" => ".", "readonly" => false}
130
      ])
131
132
    assert {:error, %Manifest.ValidationError{field: "capabilities.mounts.0.readonly"}} =
133
             Manifest.validate(manifest)
134
  end
135
136
  test "rejects a mount readonly that is not a literal boolean" do
137
    manifest =
138
      put_in(shipping_manifest(), ["capabilities", "mounts"], [
139
        %{"path" => ".", "readonly" => "true"}
140
      ])
141
142
    assert {:error, %Manifest.ValidationError{field: "capabilities.mounts.0.readonly"}} =
143
             Manifest.validate(manifest)
144
  end
145
146
  test "rejects a surface with a malformed slash command" do
147
    manifest =
148
      put_in(
149
        shipping_manifest(),
150
        ["surfaces"],
151
        [
152
          %{
153
            "name" => "chat",
154
            "description" => "A chat surface",
155
            "slash_commands" => [%{"description" => "missing command"}]
156
          }
157
        ]
158
      )
159
160
    assert {:error, %Manifest.ValidationError{field: "surfaces.0.slash_commands.0.command"}} =
161
             Manifest.validate(manifest)
162
  end
163
end
test/openagents_web/plugin_registry_controller_test.exs added +52

@@ -0,0 +1,52 @@

1
defmodule OpenAgentsWeb.PluginRegistryControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: true
3
4
  @fixture_path "test/fixtures/plugin_manifest.json"
5
6
  defp shipping_manifest do
7
    @fixture_path
8
    |> File.read!()
9
    |> Jason.decode!()
10
  end
11
12
  setup do
13
    old = Application.get_env(:openagents, OpenAgents.Plugins.Index)
14
15
    entries = [
16
      %{
17
        repository: "OpenAgentsInc/git-lost-work",
18
        release: "main",
19
        raw_manifest: shipping_manifest()
20
      },
21
      %{repository: "OpenAgentsInc/bad", release: "main", raw_manifest: %{"name" => "bad"}}
22
    ]
23
24
    Application.put_env(:openagents, OpenAgents.Plugins.Index, source: entries)
25
26
    on_exit(fn ->
27
      if is_nil(old),
28
        do: Application.delete_env(:openagents, OpenAgents.Plugins.Index),
29
        else: Application.put_env(:openagents, OpenAgents.Plugins.Index, old)
30
    end)
31
32
    :ok
33
  end
34
35
  test "GET /api/v1/plugins lists validated plugins only", %{conn: conn} do
36
    conn = get(conn, ~p"/api/v1/plugins")
37
    assert %{"plugins" => [plugin]} = json_response(conn, 200)
38
    assert plugin["repository"] == "OpenAgentsInc/git-lost-work"
39
    assert plugin["manifest"]["name"] == "git_lost_work"
40
  end
41
42
  test "GET /api/v1/plugins/:name returns the exact-name match", %{conn: conn} do
43
    conn = get(conn, ~p"/api/v1/plugins/git_lost_work")
44
    assert %{"plugin" => plugin} = json_response(conn, 200)
45
    assert plugin["manifest"]["name"] == "git_lost_work"
46
  end
47
48
  test "GET /api/v1/plugins/:name returns 404 for an unknown plugin", %{conn: conn} do
49
    conn = get(conn, ~p"/api/v1/plugins/unknown")
50
    assert_api_error(conn, 404, "not_found")
51
  end
52
end
test/openagents_web/route_authority_test.exs modified +16

@@ -313,6 +313,22 @@ defmodule OpenAgentsWeb.RouteAuthorityTest do

313 313
           ).pipe_through == [:fleet_promotion_api]
314 314
  end
315 315
316
  test "plugin discovery routes are public and not substring matched" do
317
    for path <- ["/api/v1/plugins", "/api/v1/plugins/:name"] do
318
      route = route!(:get, path)
319
320
      assert route.class == :public_read, inspect(route)
321
      assert route.principal == "anonymous", inspect(route)
322
      assert route.scope == "plugins:discover", inspect(route)
323
      refute route.mutation, inspect(route)
324
    end
325
326
    refute Enum.any?(
327
             OpenAgentsWeb.Router.__routes__(),
328
             &(&1.path == "/api/v1/pluginsXYZ")
329
           )
330
  end
331
316 332
  defp route!(verb, path) do
317 333
    Enum.find(RouteAuthority.inventory(), &(&1.verb == to_string(verb) and &1.path == path)) ||
318 334
      flunk("missing route #{verb} #{path}")

This page updates live while a promote is in flight · changelog