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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Copy this file to .env and set a strong random token before running podman/docker compose.
# Generate one with: openssl rand -hex 32
RHESS_ADMIN_TOKEN=

# Public origin for /.well-known artifact URLs and installCommand (no trailing slash).
# Required behind TLS-terminating proxies so clients get https:// URLs.
# PUBLIC_BASE_URL=https://rhess.example.com
4 changes: 4 additions & 0 deletions deploy/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ spec:
value: /data/rhess.db
- name: PORT
value: "3000"
# Public origin used in /.well-known artifact URLs and installCommand.
# Must be the external https:// route URL when TLS terminates at the ingress.
# - name: PUBLIC_BASE_URL
# value: https://rhess.example.com
volumeMounts:
- name: data
mountPath: /data
Expand Down
8 changes: 4 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ List all indexed skills (paginated).
"allowedTools": [],
"skillPath": "skills/agent-ready/SKILL.md",
"frontmatter": {},
"installCommand": "npx skills add http://localhost:3000/api/v1/skills/rhdh-skills/agent-ready/artifact",
"installCommand": "npx skills add http://localhost:3000 --skill agent-ready",
"lastModified": "2026-06-19T14:32:00Z"
}
],
Expand Down Expand Up @@ -133,7 +133,7 @@ Full skill detail including all files.
"allowedTools": [],
"skillPath": "skills/agent-ready/SKILL.md",
"frontmatter": { "version": "1.0" },
"installCommand": "npx skills add http://localhost:3000/api/v1/skills/rhdh-skills/agent-ready/artifact",
"installCommand": "npx skills add http://localhost:3000 --skill agent-ready",
"lastModified": "2026-06-19T14:32:00Z",
"content": "---\nname: agent-ready\n…",
"files": [
Expand All @@ -155,7 +155,7 @@ Download the raw skill artifact.
- Returns `text/markdown` for `skill-md` type.
- Returns `application/gzip` (tar.gz) for `archive` type.

This is the URL referenced in the Agent Skills discovery index and used in `installCommand`.
This is the URL referenced in the Agent Skills discovery index (`url` field). Install via the host + `--skill` form in `installCommand`, not this artifact URL directly.

**Response `404`** — skill not found.

Expand Down Expand Up @@ -381,7 +381,7 @@ Agent Skills CLI discovery manifest ([spec](https://agentskills.io/spec/)). List

```json
{
"$schema": "https://agentskills.io/schema/v0.2.0/index.json",
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "agent-ready",
Expand Down
11 changes: 11 additions & 0 deletions src/server/ingestion/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from "node:path";
import { clone, discoverSkills, parseFrontmatter, bundleSkill } from "./index.js";
import type { Repositories } from "../db/init.js";
import type { UpsertSkillInput } from "../db/types.js";
import { isValidSkillInstallId } from "../utils/skillInstallId.js";

export interface SkillIndexEntry {
slug: string;
Expand Down Expand Up @@ -48,6 +49,16 @@ export async function ingestFromClonedPath(
for (const candidate of candidates) {
const relativePath = path.relative(repoPath, candidate.skillMdPath);
try {
if (!isValidSkillInstallId(candidate.slug)) {
failures.push({
path: relativePath,
reason:
`Invalid skill install id '${candidate.slug}': must be 1–64 lowercase ` +
"kebab-case characters ([a-z0-9]+(?:-[a-z0-9]+)*) for npx skills CLI compatibility",
});
continue;
}

const bundleResult = await bundleSkill(candidate);

// For skill-md, the artifact IS the raw SKILL.md content — reuse it to
Expand Down
24 changes: 10 additions & 14 deletions src/server/routes/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { SearchProvider } from "../search/types.js";
import type { Repositories } from "../db/init.js";
import { createAdminAuthHook } from "../plugins/adminAuth.js";
import { ingestSource } from "../ingestion/ingest.js";
import { resolveBaseUrl } from "../utils/resolveBaseUrl.js";

/**
* Decode a base64 tar.gz archive and return all contained files as
Expand Down Expand Up @@ -48,20 +49,12 @@ async function expandArchiveToFiles(
}
}

/**
* Derive the server's public base URL for constructing install commands.
* Mirrors the logic in wellKnown.ts.
*/
function resolveBaseUrl(req: FastifyRequest): string {
const configured = process.env["PUBLIC_BASE_URL"];
if (configured) return configured.replace(/\/+$/, "");
const raw = req.headers.host;
const host = (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? req.hostname;
return `${req.protocol}://${host}`;
}

function skillToResponse(skill: Skill, source: Source | undefined, baseUrl: string) {
const installCommand = `npx skills add ${baseUrl}/api/v1/skills/${encodeURIComponent(skill.sourceSlug)}/${encodeURIComponent(skill.slug)}/artifact`;
// Point at the host so the CLI discovers via /.well-known/agent-skills/index.json,
// then select this skill. Artifact URLs are not a valid skills-add source by themselves
// and would make the CLI fetch every skill in the index.
// Use --skill=<id> (single argv token) so odd slug characters cannot split the command.
const installCommand = `npx skills add ${baseUrl} --skill=${skill.slug}`;
return {
id: skill.id,
source: skill.sourceSlug,
Expand Down Expand Up @@ -122,7 +115,10 @@ const skillSchema = {
allowedTools: { type: "array", items: { type: "string" }, description: "Tools the skill is allowed to use" },
skillPath: { type: "string", description: "Relative path to SKILL.md within the source repository" },
frontmatter: { type: "object", additionalProperties: true, description: "Full parsed frontmatter map (excluding name/description)" },
installCommand: { type: "string", description: "npx skills add … command to install this skill" },
installCommand: {
type: "string",
description: "npx skills add <host> --skill <slug> command to install this skill via well-known discovery",
},
lastModified: { type: "string", description: "ISO 8601 timestamp of last update" },
},
required: ["id", "source", "slug", "name", "description", "artifactType", "digest", "allowedTools", "skillPath", "installCommand", "lastModified"],
Expand Down
51 changes: 19 additions & 32 deletions src/server/routes/wellKnown.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,12 @@
import type { FastifyPluginAsync, FastifyRequest } from "fastify";
import type { SkillRepository } from "../db/types.js";
import { resolveBaseUrl } from "../utils/resolveBaseUrl.js";
import { isValidSkillInstallId } from "../utils/skillInstallId.js";

interface WellKnownOptions {
skills: SkillRepository;
}

/**
* Derive the server's public base URL.
*
* Priority:
* 1. PUBLIC_BASE_URL env var — the only trusted source for proxy deployments.
* Must be set when RHESS sits behind a reverse proxy.
* 2. req.protocol + Host header — safe for direct (non-proxied) access.
* req.hostname strips the port, so we use req.headers.host (e.g.
* "localhost:3000") to preserve it. Do NOT read X-Forwarded-* headers
* directly; they are client-controlled without trustProxy configuration.
*/
function resolveBaseUrl(req: FastifyRequest): string {
const configured = process.env["PUBLIC_BASE_URL"];
if (configured) return configured.replace(/\/+$/, "");
// Host header is an HTTP/1.1 request requirement and comes from the actual
// TCP connection in direct deployments — more trustworthy than X-Forwarded-*
// but must be normalized: take first value if somehow multi-valued, strip
// any surrounding whitespace, and fall back to req.hostname (port-less) only
// as a last resort.
const raw = req.headers.host;
const host = (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? req.hostname;
return `${req.protocol}://${host}`;
}

const wellKnownPlugin: FastifyPluginAsync<WellKnownOptions> = async (fastify, opts) => {
fastify.get("/agent-skills/index.json", {
schema: {
Expand Down Expand Up @@ -66,14 +44,23 @@ const wellKnownPlugin: FastifyPluginAsync<WellKnownOptions> = async (fastify, op
const baseUrl = resolveBaseUrl(req);
const entries = opts.skills.findAllDiscoveryEntries();
return reply.send({
$schema: "https://agentskills.io/schema/v0.2.0/index.json",
skills: entries.map((s) => ({
name: s.name,
type: s.artifactType,
description: s.description,
url: `${baseUrl}/api/v1/skills/${encodeURIComponent(s.sourceSlug)}/${encodeURIComponent(s.slug)}/artifact`,
digest: `sha256:${s.digest}`,
})),
// Opaque schema URI required by the Agent Skills discovery RFC / npx skills CLI.
// Clients match this exactly; a different v0.2.0-looking URI is rejected.
$schema: "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
skills: entries
.filter((s) => isValidSkillInstallId(s.slug))
.map((s) => ({
// Discovery `name` must be a kebab-case install id ([a-z0-9-]+). Prefer slug
// over frontmatter display names that may contain spaces or mixed case.
name: s.slug,
type: s.artifactType,
// CLI rejects descriptions longer than 1024 characters.
description: s.description.length > 1024
? `${s.description.slice(0, 1023)}…`
: s.description,
url: `${baseUrl}/api/v1/skills/${encodeURIComponent(s.sourceSlug)}/${encodeURIComponent(s.slug)}/artifact`,
digest: `sha256:${s.digest}`,
})),
});
});
};
Expand Down
68 changes: 68 additions & 0 deletions src/server/utils/resolveBaseUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { FastifyRequest } from "fastify";

/**
* Host header values safe to embed in public URLs / install commands.
* Parses the value as an HTTP authority (via WHATWG URL) so hostname/IPv4/
* bracketed IPv6 (+ optional port) are accepted, while credentials, paths,
* queries, fragments, and other spoof markers are rejected.
*/
export function isSafeHostHeader(host: string | undefined): boolean {
if (!host) return false;
if (host.length > 253) return false;
// Reject obvious spoof / multi-value markers before URL parsing.
if (/[\s@\\/,]/.test(host)) return false;

try {
const parsed = new URL(`http://${host}`);
// Authority-only: no userinfo, path (beyond "/"), query, or fragment.
if (parsed.username || parsed.password) return false;
if (parsed.pathname !== "/") return false;
if (parsed.search || parsed.hash) return false;
if (!parsed.hostname) return false;

// Port must be empty or an integer in 1–65535 (URL parser already validates syntax).
if (parsed.port) {
const port = Number(parsed.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
}

// URL parsing lowercases DNS/IPv6; compare case-insensitively so valid Host
// values are not rejected solely due to canonicalization.
return parsed.host.toLowerCase() === host.toLowerCase();
} catch {
return false;
}
}

/**
* Return a canonical host authority for embedding in public URLs, or null if unsafe.
* Uses the URL parser's normalized form (lowercase DNS/IPv6, bracketed IPv6).
*/
export function canonicalizeHostHeader(host: string | undefined): string | null {
if (!isSafeHostHeader(host)) return null;
try {
return new URL(`http://${host}`).host;
} catch {
return null;
}
}

/**
* Derive the server's public base URL.
*
* Priority:
* 1. PUBLIC_BASE_URL env var — the only trusted source for proxy deployments.
* 2. req.protocol + validated Host header — for direct (non-proxied) access.
* Invalid/spoofed Host values fall back to `localhost` (no port) rather than
* reflecting attacker-controlled origins into install commands / discovery URLs.
*/
export function resolveBaseUrl(req: FastifyRequest): string {
const configured = process.env["PUBLIC_BASE_URL"];
if (configured) return configured.replace(/\/+$/, "");

const raw = req.headers.host;
const candidate = (Array.isArray(raw) ? raw[0] : raw)?.trim();
const host = canonicalizeHostHeader(candidate) ?? "localhost";
const protocol = req.protocol === "https" ? "https" : "http";
return `${protocol}://${host}`;
}
9 changes: 9 additions & 0 deletions src/server/utils/skillInstallId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Agent Skills / npx skills CLI install-id rules (discovery `name` / `--skill`).
* Must be 1–64 chars, lowercase kebab-case, no leading/trailing hyphen, no `--`.
*/
const SKILL_INSTALL_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

export function isValidSkillInstallId(value: string): boolean {
return value.length >= 1 && value.length <= 64 && SKILL_INSTALL_ID_RE.test(value);
}
23 changes: 23 additions & 0 deletions test/server/ingestion/ingest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,29 @@ describe("ingestFromClonedPath", () => {
expect(slugs).toContain("beta-skill");
});

it("rejects directory names that are not CLI-compatible install ids", async () => {
tmpDir = makeTmpDir();
await initRepo(tmpDir);

writeSkill(tmpDir, "skills", "alpha-skill", VALID_SKILL_A);
writeSkill(tmpDir, "skills", "Bad Skill Name", VALID_SKILL_B);
writeSkill(tmpDir, "skills", "has_underscore", VALID_SKILL_C);
await commitAll(tmpDir);

const repos = makeRepos();
const source = repos.sources.create({ slug: "test-source", label: "test-source", url: "file:///test" });
const report = await ingestFromClonedPath(source.id, source.slug, tmpDir, repos);

expect(report.discovered).toBe(3);
expect(report.indexed).toBe(1);
expect(report.failed).toBe(2);
expect(report.failures.every((f) => /install id/i.test(f.reason))).toBe(true);

const allSkills = repos.skills.findAll({ perPage: 100 });
expect(allSkills).toHaveLength(1);
expect(allSkills[0]!.slug).toBe("alpha-skill");
});

it("scenario 2: malformed frontmatter → skipped + reported", async () => {
tmpDir = makeTmpDir();
await initRepo(tmpDir);
Expand Down
26 changes: 26 additions & 0 deletions test/server/routes/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,32 @@ describe("GET /api/v1/skills", () => {
expect(body.data[0]).toHaveProperty("allowedTools");
});

it("installCommand targets the well-known host with --skill filter", async () => {
const res = await app.inject({
method: "GET",
url: "/api/v1/skills",
headers: { host: "localhost:3000" },
});
expect(res.statusCode).toBe(200);
const skill = res.json().data.find((s: { slug: string }) => s.slug === "react-patterns");
expect(skill.installCommand).toBe(
"npx skills add http://localhost:3000 --skill=react-patterns",
);
});

it("installCommand does not reflect spoofed Host headers", async () => {
const res = await app.inject({
method: "GET",
url: "/api/v1/skills",
headers: { host: "evil.example.com@attacker.test" },
});
expect(res.statusCode).toBe(200);
for (const skill of res.json().data) {
expect(skill.installCommand).not.toContain("attacker.test");
expect(skill.installCommand).toMatch(/^npx skills add http:\/\/localhost --skill=/);
}
});

it("paginates correctly", async () => {
const res = await app.inject({ method: "GET", url: "/api/v1/skills?page=1&per_page=2" });
expect(res.statusCode).toBe(200);
Expand Down
Loading
Loading