Skip to content
Merged
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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ All notable changes to this project are documented here.
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added
- Usable brand assets: `assets/usable-icon.svg` (composer icon) and `assets/usable-logo.png`
(600×600 logo), taken from the Usable brand kit.
- Codex interface metadata under the `extensions["com.openai"]` namespace in `plugin.json`:
display name, short and long descriptions, developer name, category, website, privacy and
terms URLs, brand colour `#347cbf`, icon and logo paths, and a default prompt.
- Validation of client-extension file references: paths must be plugin-relative, must stay
inside the plugin root, and must exist. Three new self-tests cover a missing asset, a path
escaping the root, and a namespace without a reverse domain.

### Changed
- `assets/` is now included in the release archive allowlist.
- `homepage` and author URL now use the canonical `https://www.usable.dev`, which is where
the apex domain redirects.

### Notes
- Codex surfaced the plugin with a generic icon and "Website: Unavailable" because it reads
presentation metadata from an `interface` object, not from the Agent Plugins `homepage`
field. **Whether Codex reads that object from the inline `com.openai` extension has not been
verified visually** — the CLI exposes no way to inspect resolved interface metadata. If the
icon and website still do not render, the fallback is a `.codex-plugin/plugin.json` overlay,
which must be added carefully because a malformed overlay could disturb skill discovery that
currently works.

## 0.1.0 — 2026-08-07

First prerelease. No client has completed all five acceptance steps, so no client is listed
Expand Down
1 change: 1 addition & 0 deletions assets/usable-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/usable-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions docs/permissions-and-data-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ Intended for security reviewers deciding whether to allow this plugin in an orga
| Runtime dependencies | None. |
| Instruction text | `skills/**/*.md` — plain Markdown, fully auditable. |
| Network configuration | One remote MCP server declaration in `mcp.json`. |
| Static images | `assets/usable-icon.svg` and `assets/usable-logo.png` — branding only, no scripts. |
| Credentials | None. |
| Local filesystem access | None requested by the plugin itself. |

The SVG contains only path geometry. It declares no `<script>`, no external references, and
no event handlers, so it cannot execute anything when a client renders it.

The repository contains Node scripts under `scripts/` for validation and release builds.
These run in CI and for local development only, and are excluded from the release archive.

Expand Down
26 changes: 24 additions & 2 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"email": "support@usable.dev",
"url": "https://usable.dev"
},
"homepage": "https://usable.dev",
"homepage": "https://www.usable.dev",
"repository": "https://github.com/flowcore-io/usable-agent-plugin",
"license": "MIT",
"keywords": [
Expand All @@ -19,5 +19,27 @@
"agent-skills",
"retrieval",
"verification"
]
],
"extensions": {
"com.openai": {
"interface": {
"displayName": "Usable",
"shortDescription": "Search your team's knowledge before you build.",
"longDescription": "Grounds the agent in decisions your team already made. It searches Usable before proposing an approach, retrieves complete sources rather than snippets, separates evidence from assumptions, and verifies before claiming success. Verified outcomes can be captured back so the next person does not rediscover them.",
"developerName": "Flowcore",
"category": "Productivity",
"websiteURL": "https://www.usable.dev",
"privacyPolicyURL": "https://www.usable.dev/privacy",
"termsOfServiceURL": "https://www.usable.dev/terms",
"brandColor": "#347cbf",
"composerIcon": "./assets/usable-icon.svg",
"logo": "./assets/usable-logo.png",
"defaultPrompt": [
"Search Usable for prior decisions about this task before proposing an approach"
],
"capabilities": [],
"screenshots": []
}
}
}
}
1 change: 1 addition & 0 deletions scripts/build-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const ALLOWLIST = [
"plugin.json",
"mcp.json",
"skills/",
"assets/",
"docs/",
"README.md",
"LICENSE",
Expand Down
37 changes: 37 additions & 0 deletions scripts/validate-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ const pass = (msg) => passes.push(msg);
const read = (p) => readFileSync(join(ROOT, p), "utf8");
const readJson = (p) => JSON.parse(read(p));

/**
* Collects every string in a nested structure that looks like a plugin-relative
* path, so extension-declared assets can be checked for existence.
*/
function collectPluginRelativePaths(node, acc = []) {
if (typeof node === "string") {
if (node.startsWith("./") || /^\.\.?\//.test(node)) acc.push(node);
return acc;
}
if (Array.isArray(node)) {
for (const item of node) collectPluginRelativePaths(item, acc);
return acc;
}
if (node && typeof node === "object") {
for (const value of Object.values(node)) collectPluginRelativePaths(value, acc);
}
return acc;
}

/** Recursively walk the repo, skipping VCS and ignored build dirs. */
function walk(dir, acc = []) {
const SKIP = new Set([".git", "node_modules", "dist"]);
Expand Down Expand Up @@ -102,6 +121,24 @@ function validateManifest() {
fail(CHECK, `extension key "${key}" must use a reverse-domain namespace`);
}
}

// Client extensions may reference packaged files (icons, logos). A broken
// reference ships a plugin that renders without branding, which is exactly
// the sort of defect that is invisible until a user sees it.
for (const [ns, value] of Object.entries(m.extensions)) {
for (const path of collectPluginRelativePaths(value)) {
if (!path.startsWith("./")) {
fail(CHECK, `extension "${ns}" path ${JSON.stringify(path)} must be plugin-relative and begin with "./"`);
continue;
}
const resolved = resolve(ROOT, path);
if (!resolved.startsWith(ROOT + sep)) {
fail(CHECK, `extension "${ns}" path ${JSON.stringify(path)} escapes the plugin root`);
} else if (!existsSync(resolved)) {
fail(CHECK, `extension "${ns}" references missing file ${path}`);
}
}
}
}

if (errors.every((e) => !e.startsWith(CHECK))) pass(`${CHECK} conforms to Agent Plugins 1.0.0`);
Expand Down
31 changes: 30 additions & 1 deletion tests/smoke/validator.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function runValidator(dir) {
function inSandbox(mutate) {
const dir = mkdtempSync(join(tmpdir(), "uap-test-"));
try {
for (const entry of ["plugin.json", "mcp.json", "skills", "docs", "scripts", "LICENSE"]) {
for (const entry of ["plugin.json", "mcp.json", "skills", "assets", "docs", "scripts", "LICENSE"]) {
cpSync(join(ROOT, entry), join(dir, entry), { recursive: true });
}
mutate(dir);
Expand Down Expand Up @@ -179,6 +179,35 @@ const cases = [
expect: (r) => r.code === 1 && /LICENSE/.test(r.output),
describe: "should require a license file",
},
{
name: "rejects a missing extension asset",
mutate: (dir) => {
rmSync(join(dir, "assets", "usable-icon.svg"));
},
expect: (r) => r.code === 1 && /references missing file/.test(r.output),
describe: "should catch an icon path that does not resolve",
},
{
name: "rejects an extension path escaping the plugin root",
mutate: (dir) => {
const m = JSON.parse(readFileSync(join(dir, "plugin.json"), "utf8"));
m.extensions["com.openai"].interface.logo = "../../../etc/passwd";
writeFileSync(join(dir, "plugin.json"), JSON.stringify(m, null, 2));
},
expect: (r) => r.code === 1 && /(escapes the plugin root|must be plugin-relative)/.test(r.output),
describe: "should enforce containment on extension-declared paths",
},
{
name: "rejects an extension namespace without a reverse domain",
mutate: (dir) => {
const m = JSON.parse(readFileSync(join(dir, "plugin.json"), "utf8"));
m.extensions.codex = m.extensions["com.openai"];
delete m.extensions["com.openai"];
writeFileSync(join(dir, "plugin.json"), JSON.stringify(m, null, 2));
},
expect: (r) => r.code === 1 && /reverse-domain namespace/.test(r.output),
describe: "should require reverse-domain extension keys",
},
{
name: "rejects an undocumented MCP url",
mutate: (dir) => {
Expand Down