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
39 changes: 2 additions & 37 deletions .github/workflows/catalog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,42 +42,7 @@ jobs:
--mount type=bind,src="$PWD",dst=/work,readonly --workdir /work \
oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 bun scripts/catalog.ts --check
- name: Compile plugin sources in isolated containers
run: |
set -euo pipefail
image='oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895'
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --read-only --cap-drop=ALL --security-opt=no-new-privileges \
--pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=64m \
--mount type=bind,src="$PWD",dst=/work,readonly --workdir /work \
"$image" bun scripts/catalog.ts --plan > "$scratch/plan.json"
jq -c '.[]' "$scratch/plan.json" | while IFS= read -r item; do
repo="$(jq -r '.repository | sub("^https://github.com/"; "")' <<< "$item")"
commit="$(jq -r '.commit' <<< "$item")"
project="$scratch/project"
mkdir -p "$project"
curl --fail --location --silent --show-error --max-time 60 --max-filesize 20971520 \
"https://api.github.com/repos/$repo/tarball/$commit" -o "$scratch/source.tar.gz"
test "$(wc -c < "$scratch/source.tar.gz")" -le 20971520
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --cap-drop=ALL --security-opt=no-new-privileges \
--pids-limit=128 --memory=512m --cpus=1 --tmpfs /tmp:rw,nosuid,nodev,size=128m \
--mount type=bind,src="$project",dst=/work \
--mount type=bind,src="$scratch/source.tar.gz",dst=/source.tar.gz,readonly \
--mount type=bind,src="$PWD/scripts",dst=/scripts,readonly --workdir /work \
"$image" bun /scripts/safe-extract.ts /source.tar.gz /work
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --cap-drop=ALL --security-opt=no-new-privileges \
--pids-limit=128 --memory=2g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=128m \
--mount type=bind,src="$project",dst=/work --workdir /work \
"$image" bun install --frozen-lockfile --ignore-scripts
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp --network=none --cap-drop=ALL --security-opt=no-new-privileges \
--pids-limit=128 --memory=2g --cpus=2 --tmpfs /tmp:rw,nosuid,nodev,size=128m \
--mount type=bind,src="$project",dst=/work --workdir /work "$image" sh -c '
entrypoint="$(bun -e "const p=await Bun.file(\"package.json\").json();const e=p.temps?.entrypoint;if(typeof e!==\"string\"||!/^src\\/[a-zA-Z0-9_./-]+\\.tsx?$/.test(e)||e.includes(\"..\"))process.exit(1);console.log(e)")"
bun build "$entrypoint" --target=bun --compile --outfile /tmp/temps-plugin-check
'
echo "Build checked $repo@$commit (install scripts disabled; compile network disabled)"
rm -rf "$project" "$scratch/source.tar.gz"
done
run: bash scripts/validate-build.sh

refresh:
if: >-
Expand Down Expand Up @@ -127,7 +92,7 @@ jobs:
test ! -L verified-catalog/catalog.json
test "$(wc -c < verified-catalog/catalog.json)" -le 5242880
jq -e '
.schema_version == 1 and
(.schema_version == 1 or .schema_version == 2) and
(.generated_at | type == "string") and
(.plugins | type == "array" and all(.[];
(.name | type == "string") and
Expand Down
30 changes: 22 additions & 8 deletions registry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,40 @@ Open a pull request adding `registry/<plugin-name>.json`:
```json
{
"repo": "your-org/your-plugin",
"categories": ["observability"]
"categories": ["observability"],
"path": "plugins/my-plugin",
"ref": "release/v1"
}
```

The filename must match `temps.name` in the repository's root `package.json`.
Only public GitHub repositories with a root `package.json` and nonempty root
`README.md` are supported. Monorepo paths are deliberately not supported yet;
the installer must understand paths before listings may specify them. The
generator reads metadata from a pinned commit SHA, not a mutable branch URL.
The filename must match `temps.name` in the selected directory's `package.json`.
Only public GitHub repositories are supported. Optional `path` selects a plugin
subdirectory; omit it (or use an empty string) for the repository root. Optional
`ref` selects a branch, tag, or commit; omit it for the repository default branch.
The generator resolves this ref to an immutable commit before reading metadata or
building. Separate plugins in one repository use separate listing files and paths.
The same repository/path cannot be listed twice with different refs.

The selected directory must contain its own `package.json`, `bun.lock`, nonempty
`README.md`, and all sources and generated assets needed by the installer.
Only that subtree is passed to the builder: dependencies on parent workspace files
or sibling packages are not supported. Paths are relative, case-sensitive and
cannot contain empty segments, `.`, `..`, `.git`, backslashes or encoded separators.
Catalogs with subdirectory plugins use schema version 2, so older hosts fail closed
instead of silently installing the repository root. Upgrade Temps for these listings.
Catalog installation remains pinned to the reviewed commit; selecting a new ref
for an installed plugin is an explicit update action.

Allowed categories: `analytics`, `automation`, `databases`, `developer-tools`,
`observability`, `seo`, `security`, `other`. The first is the primary category.

The `temps` manifest may supply `title`, `summary`, `description`, `platforms`,
`docsUrl`, `logo`, and `screenshots` (objects with relative `path`, `alt`, and
optional `caption`). Logo and screenshot paths are relative to repository root.
optional `caption`). Logo and screenshot paths are relative to the selected plugin directory.
Other fields derive from `package.json` and GitHub repository metadata.

Pull-request validation checks metadata and README availability, then installs
dependencies with lifecycle scripts disabled and compiles the root manifest's
dependencies with lifecycle scripts disabled and compiles the selected directory's manifest
entrypoint in a capped, offline container. It does **not** execute the plugin or
audit its security. The generated catalog records `validation.build: "passed"`
only when the tested commit matches the catalog commit. Consumers must not
Expand Down
6 changes: 4 additions & 2 deletions scripts/catalog-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ for (const path of ["../.github/workflows/catalog.yml", "./validate-build.sh"])
expect(command).toContain('--cap-drop=ALL');
expect(command).toContain('--security-opt=no-new-privileges');
}
expect(source).toContain('--network=none');
expect(source).toContain('--ignore-scripts');
if (path === "./validate-build.sh") {
expect(source).toContain('--network=none');
expect(source).toContain('--ignore-scripts');
} else expect(source).toContain("bash scripts/validate-build.sh");
});
}
58 changes: 55 additions & 3 deletions scripts/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { parseListing, resolvePlugin } from "./catalog";
import { parseListing, resolvePlugin, generate, buildPlan } from "./catalog";

const sha = "a".repeat(40);
const listing = { repo: "gotempsh/temps-plugin-template", categories: ["developer-tools"] };
Expand All @@ -19,8 +19,8 @@ function fixture(packageJson: object = pkg, readme = "# My plugin\n") {

describe("catalog submissions", () => {
test("accepts minimal exact listing", () => expect(parseListing("my-plugin", listing)).toEqual(listing));
test("rejects path overrides and traversal", () => {
expect(() => parseListing("my-plugin", { ...listing, path: "subdir" })).toThrow("only repo and categories");
test("rejects unknown keys and traversal", () => {
expect(() => parseListing("my-plugin", { ...listing, directory: "subdir" })).toThrow("only repo, categories");
expect(() => parseListing("my-plugin", { ...listing, repo: "gotempsh/../evil" })).toThrow("invalid GitHub");
});
test("resolves real template-shaped manifest at immutable commit", async () => {
Expand All @@ -39,3 +39,55 @@ describe("catalog submissions", () => {
await expect(resolvePlugin("my-plugin", listing, fixture({ ...pkg, temps: { ...pkg.temps, logo: "../secret" } }))).rejects.toThrow("invalid asset path");
});
});

for (const path of ["../evil", "/absolute", "a//b", "a/./b", "a/../b", "a/", "a\\b", "%2e%2e/x", ".git/x", "a/.GIT/x", "x".repeat(513)]) {
test(`rejects unsafe path ${path}`, () => expect(() => parseListing("my-plugin", { ...listing, path })).toThrow("invalid plugin path"));
}
for (const ref of ["", "--upload-pack=evil", "x..y", "main:evil", "a b"]) {
test(`rejects unsafe ref ${ref}`, () => expect(() => parseListing("my-plugin", { ...listing, ref })).toThrow("invalid Git ref"));
}
test("pins a subdirectory at a slash-containing ref and makes assets directory-relative", async () => {
const calls: string[] = [];
const fetcher = (async (input: RequestInfo | URL) => {
const url = String(input); calls.push(url);
if (url.endsWith(`/repos/${listing.repo}`)) return Response.json({ full_name: listing.repo, default_branch: "main", private: false });
if (url.endsWith("/commits/release%2Fv1")) return Response.json({ sha });
if (url.endsWith(`/${sha}/plugins/demo/package.json`)) return Response.json({ ...pkg, temps: { ...pkg.temps, logo: "assets/logo.png" } });
if (url.endsWith(`/${sha}/plugins/demo/README.md`)) return new Response("# Demo");
return new Response("missing", { status: 404 });
}) as typeof fetch;
const result = await resolvePlugin("my-plugin", { ...listing, path: "plugins/demo", ref: "release/v1" }, fetcher);
expect(result.path).toBe("plugins/demo");
expect(result.ref).toBe("release/v1");
expect(result.logoUrl).toEndWith(`/${sha}/plugins/demo/assets/logo.png`);
expect(result.readmeUrl).toEndWith(`/${sha}/plugins/demo/README.md`);
expect(buildPlan([result])[0]).toEqual({ repository: result.repository, commit: sha, path: "plugins/demo", ref: "release/v1" });
expect(calls.some(url => url.endsWith("/commits/main"))).toBe(false);
});
test("missing custom ref fails without falling back to default branch", async () => {
const fetcher = (async (input: RequestInfo | URL) => String(input).includes("/commits/")
? new Response("missing", { status: 404 }) : Response.json({ full_name: listing.repo, default_branch: "main" })) as typeof fetch;
await expect(resolvePlugin("my-plugin", { ...listing, ref: "missing" }, fetcher)).rejects.toThrow("404");
});

test("multiple paths in one repository generate v2, but duplicate source paths fail", async () => {
const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const root = await mkdtemp(join(tmpdir(), "catalog-paths-"));
try {
await mkdir(join(root, "registry"));
await writeFile(join(root, "registry/my-plugin.json"), JSON.stringify({ ...listing, path: "plugins/a" }));
await writeFile(join(root, "registry/other.json"), JSON.stringify({ ...listing, path: "plugins/b" }));
const fetcher = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/plugins/b/package.json")) return Response.json({ ...pkg, temps: { ...pkg.temps, name: "other" } });
return fixture()(input);
}) as typeof fetch;
const catalog = await generate(root, fetcher);
expect(catalog.schema_version).toBe(2);
expect(catalog.plugins.map(p => p.path)).toEqual(["plugins/a", "plugins/b"]);
await writeFile(join(root, "registry/other.json"), JSON.stringify({ ...listing, path: "plugins/a", ref: "v2" }));
await expect(generate(root, fetcher)).rejects.toThrow("duplicate repository and path");
} finally { await rm(root, { recursive: true, force: true }); }
});
Loading
Loading