Skip to content

Commit dd49be9

Browse files
committed
codex_sdk: repair gaps from the plugin SDK support review
Fix any remaining plugin authoring, documentation, or test issues and rerun the full Phase G quality floor.
1 parent 9aa74e1 commit dd49be9

11 files changed

Lines changed: 346 additions & 170 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,9 @@ Use `Codex.Plugins.*` to create and update `.codex-plugin/plugin.json`,
393393
`.agents/plugins/marketplace.json`, and minimal local plugin trees with normal
394394
Elixir file IO. Use `Codex.AppServer.plugin_*` later if you want runtime
395395
verification against a running `codex app-server`. Normal authoring flows do not
396-
route through app-server `fs/*`.
396+
route through app-server `fs/*`. Phase-1 scaffold intentionally stops at the
397+
plugin tree, optional skill stub, and marketplace entry; it does not generate
398+
`mix.exs`, Dialyzer/PLT helpers, or `build_support/*`.
397399

398400
Raw versus typed plugin calls:
399401

guides/13-plugin-authoring.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ Stable rules enforced locally:
5757
- manifest component paths such as `skills`, `hooks`, `mcpServers`, `apps`, and
5858
interface asset paths must start with `./`
5959
- relative paths cannot escape with `..`
60+
- `interface.defaultPrompt` accepts at most 3 prompts and each prompt must be
61+
128 characters or fewer after whitespace normalization
6062
- writes are deterministic JSON with a trailing newline
6163
- scaffold does not generate `mix.exs`, `.formatter.exs`, `build_support/*`, or
6264
Dialyzer/PLT files in phase 1

guides/14-plugin-marketplaces.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ Use `add_marketplace_plugin/3` for read-modify-write updates:
5050
Behavior:
5151

5252
- preserves unrelated plugin entries
53-
- preserves unknown forward-compatible keys where possible
53+
- preserves unknown forward-compatible keys where possible, including overwrite
54+
updates on an existing entry
55+
- preserves optional compatible metadata such as `policy.products` unless you
56+
replace it explicitly
5457
- refuses duplicate plugin names unless `overwrite: true`
5558
- writes deterministic pretty JSON with a trailing newline
5659

@@ -59,9 +62,14 @@ Behavior:
5962
Marketplace source paths must:
6063

6164
- start with `./`
65+
- reject traversal segments such as `..`, even when the expanded path would
66+
still land under the marketplace root
6267
- stay within the marketplace root
6368
- resolve relative to the root owning `.agents/plugins/marketplace.json`
6469

70+
The root-containment check runs on read, write, and update flows when the
71+
actual marketplace path is known.
72+
6573
For repo scope that means:
6674

6775
- marketplace: `<repo-root>/.agents/plugins/marketplace.json`

lib/codex/plugins/manifest.ex

Lines changed: 61 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ defmodule Codex.Plugins.Manifest do
99
alias Codex.Plugins.Paths
1010
alias Codex.Schema
1111

12+
@max_default_prompt_count 3
13+
@max_default_prompt_length 128
1214
@key_mapping %{
1315
"mcp_servers" => "mcpServers",
1416
"display_name" => "displayName",
@@ -135,7 +137,7 @@ defmodule Codex.Plugins.Manifest do
135137

136138
def parse(data) do
137139
data
138-
|> normalize_input()
140+
|> Schema.normalize_input(@key_mapping)
139141
|> then(&Schema.parse(schema(), &1, :invalid_plugin_manifest))
140142
|> project()
141143
end
@@ -163,19 +165,19 @@ defmodule Codex.Plugins.Manifest do
163165
@spec to_map(t()) :: map()
164166
def to_map(%__MODULE__{} = value) do
165167
%{}
166-
|> maybe_put("name", value.name)
167-
|> maybe_put("version", value.version)
168-
|> maybe_put("description", value.description)
169-
|> maybe_put("author", encode_author(value.author))
170-
|> maybe_put("homepage", value.homepage)
171-
|> maybe_put("repository", value.repository)
172-
|> maybe_put("license", value.license)
173-
|> maybe_put("keywords", value.keywords)
174-
|> maybe_put("skills", value.skills)
175-
|> maybe_put("hooks", value.hooks)
176-
|> maybe_put("mcpServers", value.mcp_servers)
177-
|> maybe_put("apps", value.apps)
178-
|> maybe_put("interface", encode_interface(value.interface))
168+
|> Schema.put_present("name", value.name)
169+
|> Schema.put_present("version", value.version)
170+
|> Schema.put_present("description", value.description)
171+
|> Schema.put_present("author", encode_author(value.author))
172+
|> Schema.put_present("homepage", value.homepage)
173+
|> Schema.put_present("repository", value.repository)
174+
|> Schema.put_present("license", value.license)
175+
|> Schema.put_present("keywords", value.keywords)
176+
|> Schema.put_present("skills", value.skills)
177+
|> Schema.put_present("hooks", value.hooks)
178+
|> Schema.put_present("mcpServers", value.mcp_servers)
179+
|> Schema.put_present("apps", value.apps)
180+
|> Schema.put_present("interface", encode_interface(value.interface))
179181
|> Schema.merge_extra(value.extra)
180182
end
181183

@@ -209,30 +211,27 @@ defmodule Codex.Plugins.Manifest do
209211
@spec normalize_default_prompt(term(), keyword()) ::
210212
{:ok, [String.t()]} | {:error, String.t()}
211213
def normalize_default_prompt(value, _opts) when is_binary(value) do
212-
prompt = String.trim(value)
213-
214-
if prompt == "" do
215-
{:error, "expected a non-empty prompt string"}
216-
else
214+
with {:ok, prompt} <- normalize_prompt_string(value) do
217215
{:ok, [prompt]}
218216
end
219217
end
220218

221219
def normalize_default_prompt(values, _opts) when is_list(values) do
222-
values
223-
|> Enum.reduce_while({:ok, []}, fn
224-
value, {:ok, acc} when is_binary(value) ->
225-
prompt = String.trim(value)
226-
227-
if prompt == "" do
228-
{:halt, {:error, "expected non-empty prompt strings"}}
229-
else
230-
{:cont, {:ok, acc ++ [prompt]}}
231-
end
232-
233-
_value, _acc ->
234-
{:halt, {:error, "expected a string or a list of strings"}}
235-
end)
220+
if length(values) > @max_default_prompt_count do
221+
{:error, "expected at most #{@max_default_prompt_count} prompts"}
222+
else
223+
values
224+
|> Enum.reduce_while({:ok, []}, fn
225+
value, {:ok, acc} when is_binary(value) ->
226+
case normalize_prompt_string(value) do
227+
{:ok, prompt} -> {:cont, {:ok, acc ++ [prompt]}}
228+
{:error, reason} -> {:halt, {:error, reason}}
229+
end
230+
231+
_value, _acc ->
232+
{:halt, {:error, "expected a string or a list of strings"}}
233+
end)
234+
end
236235
end
237236

238237
def normalize_default_prompt(_value, _opts),
@@ -307,30 +306,30 @@ defmodule Codex.Plugins.Manifest do
307306

308307
defp encode_author(author) do
309308
%{}
310-
|> maybe_put("name", author[:name])
311-
|> maybe_put("email", author[:email])
312-
|> maybe_put("url", author[:url])
309+
|> Schema.put_present("name", author[:name])
310+
|> Schema.put_present("email", author[:email])
311+
|> Schema.put_present("url", author[:url])
313312
|> Schema.merge_extra(author[:extra] || %{})
314313
end
315314

316315
defp encode_interface(nil), do: nil
317316

318317
defp encode_interface(interface) do
319318
%{}
320-
|> maybe_put("displayName", interface[:display_name])
321-
|> maybe_put("shortDescription", interface[:short_description])
322-
|> maybe_put("longDescription", interface[:long_description])
323-
|> maybe_put("developerName", interface[:developer_name])
324-
|> maybe_put("category", interface[:category])
325-
|> maybe_put("capabilities", interface[:capabilities])
326-
|> maybe_put("websiteURL", interface[:website_url])
327-
|> maybe_put("privacyPolicyURL", interface[:privacy_policy_url])
328-
|> maybe_put("termsOfServiceURL", interface[:terms_of_service_url])
329-
|> maybe_put("defaultPrompt", interface[:default_prompt])
330-
|> maybe_put("brandColor", interface[:brand_color])
331-
|> maybe_put("composerIcon", interface[:composer_icon])
332-
|> maybe_put("logo", interface[:logo])
333-
|> maybe_put("screenshots", interface[:screenshots])
319+
|> Schema.put_present("displayName", interface[:display_name])
320+
|> Schema.put_present("shortDescription", interface[:short_description])
321+
|> Schema.put_present("longDescription", interface[:long_description])
322+
|> Schema.put_present("developerName", interface[:developer_name])
323+
|> Schema.put_present("category", interface[:category])
324+
|> Schema.put_present("capabilities", interface[:capabilities])
325+
|> Schema.put_present("websiteURL", interface[:website_url])
326+
|> Schema.put_present("privacyPolicyURL", interface[:privacy_policy_url])
327+
|> Schema.put_present("termsOfServiceURL", interface[:terms_of_service_url])
328+
|> Schema.put_present("defaultPrompt", interface[:default_prompt])
329+
|> Schema.put_present("brandColor", interface[:brand_color])
330+
|> Schema.put_present("composerIcon", interface[:composer_icon])
331+
|> Schema.put_present("logo", interface[:logo])
332+
|> Schema.put_present("screenshots", interface[:screenshots])
334333
|> Schema.merge_extra(interface[:extra] || %{})
335334
end
336335

@@ -410,48 +409,24 @@ defmodule Codex.Plugins.Manifest do
410409
)
411410
end
412411

413-
defp normalize_input(nil), do: %{}
414-
415-
defp normalize_input(list) when is_list(list) do
416-
if list != [] and Keyword.keyword?(list) do
417-
list
418-
|> Enum.into(%{})
419-
|> normalize_input()
420-
else
421-
list
422-
end
423-
end
424-
425-
defp normalize_input(%{} = value) do
426-
value
427-
|> Enum.map(fn {key, nested_value} ->
428-
{normalize_key(key), normalize_value(nested_value)}
429-
end)
430-
|> Map.new()
431-
end
432-
433-
defp normalize_input(value), do: value
412+
defp normalize_prompt_string(value) when is_binary(value) do
413+
prompt =
414+
value
415+
|> String.split()
416+
|> Enum.join(" ")
434417

435-
defp normalize_key(key) when is_atom(key), do: key |> Atom.to_string() |> normalize_key()
436-
defp normalize_key(key) when is_binary(key), do: Map.get(@key_mapping, key, key)
418+
cond do
419+
prompt == "" ->
420+
{:error, "expected a non-empty prompt string"}
437421

438-
defp normalize_value(%{} = value), do: normalize_input(value)
422+
String.length(prompt) > @max_default_prompt_length ->
423+
{:error, "expected prompts at most #{@max_default_prompt_length} characters"}
439424

440-
defp normalize_value(values) when is_list(values) do
441-
if values != [] and Keyword.keyword?(values) do
442-
values
443-
|> Enum.into(%{})
444-
|> normalize_input()
445-
else
446-
Enum.map(values, &normalize_value/1)
425+
true ->
426+
{:ok, prompt}
447427
end
448428
end
449429

450-
defp normalize_value(value), do: value
451-
452-
defp maybe_put(map, _key, nil), do: map
453-
defp maybe_put(map, key, value), do: Map.put(map, key, value)
454-
455430
defp author_empty?(author) do
456431
Enum.all?([author[:name], author[:email], author[:url]], &is_nil/1) and author[:extra] == %{}
457432
end

0 commit comments

Comments
 (0)