Files
awesome-copilot/eng/external-plugin-intake.test.mjs
T
Tim Mulholland 63c2527ace Accept nested extensions/<name>/extension.mjs in external-plugin canvas checks (#2403)
* Accept nested extensions/<name>/extension.mjs in external-plugin canvas checks

The external-plugin canvas structure check (quality gate) and intake
validation both hardcoded a flat extensions/extension.mjs entry point,
falsely rejecting the documented nested extensions/<name>/extension.mjs
layout that installs and runs fine.

Scan the extensions/ directory for a nested subfolder containing
extension.mjs while still accepting the flat form for backward
compatibility. Applied to both runCanvasStructureGate (git-object
lookups) and validateCanvasPluginMetadata (Contents API), keeping them
behaviorally aligned. Added regression coverage for both paths.

Fixes #2402

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Harden nested canvas extension detection after adversarial review

Address multi-model review findings on the nested canvas extension fix:

- Quality gate: enumerate extensions/ via 'git ls-tree -z' with spawnSync (NUL-delimited, untruncated) so large directories no longer drop the real entry past the 12KB output cap.

- Intake: decouple the flat extensions/extension.mjs check from the directory listing, require an array listing (Array.isArray) before treating it as a directory, and surface an unverifiable (warning) result instead of a false rejection when the listing or a nested lookup hits a transient API error.

- Add regression tests: nested entry beyond the legacy output cap (gate) and unverifiable/flat-still-accepted paths when the listing errors (intake).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Enumerate canvas extensions via a single recursive Git Trees call in intake

Address PR review: the Contents API caps directory listings at 1,000 entries and required one request per extension subfolder, so a nested entry beyond the cap could be falsely rejected (the same truncation class the git gate avoids) and large repos risked latency / rate-limit exhaustion.

Replace the per-subfolder Contents API enumeration with one recursive 'git/trees/<locator>?recursive=1' fetch and inspect 'extensions/extension.mjs' and immediate 'extensions/<name>/extension.mjs' paths locally. A truncated tree without a located entry point is reported as unverifiable (warning) rather than rejected, and refs are normalized so 'refs/tags/<tag>' resolves as a tree-ish.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Bound canvas extension discovery to plugin scope

Address reviewer feedback on unbounded scaling for untrusted/large repos:

- Quality gate: replace the per-candidate-directory git cat-file spawns in
  locateCanvasEntryPoint with a single recursive git ls-tree over the
  extensions subtree, classifying flat/nested entry points in memory. Process
  count is now constant regardless of how many folders live under extensions/.

- Intake: stop fetching the recursive git tree from the repo root (which a
  large unrelated monorepo can push past the Trees API truncation limit and
  never validate). Walk to the plugin's extensions directory one level at a
  time to resolve its tree SHA, then fetch only that subtree recursively, so
  verifiability depends on the plugin's own size, not the whole repository.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: aaronpowell <434140+aaronpowell@users.noreply.github.com>
2026-07-29 14:05:31 +10:00

261 lines
7.7 KiB
JavaScript

import assert from "node:assert/strict";
import { test } from "node:test";
import { validateCanvasPluginMetadata } from "./external-plugin-intake.mjs";
const REPO = "owner/repo";
const SHA = "0123456789abcdef0123456789abcdef01234567";
const PLUGIN_ROOT = "plugins/upgrade-agent";
const TREE_PLUGINS = "tree-plugins";
const TREE_UPGRADE_AGENT = "tree-upgrade-agent";
const TREE_EXTENSIONS = "tree-extensions";
function fileNode(content) {
return { type: "file", content: Buffer.from(content, "utf8").toString("base64") };
}
function treeEntry(path, type, sha) {
return { path, type, mode: type === "tree" ? "040000" : "100644", sha: sha ?? "deadbeef" };
}
function treeResponse(entries, { truncated = false } = {}) {
return { status: 200, data: { sha: "resolved", truncated, tree: entries } };
}
function decodeContentsPath(url) {
const { pathname } = new URL(url);
const afterContents = pathname.split("/contents/")[1] ?? "";
return afterContents
.split("/")
.filter(Boolean)
.map((segment) => decodeURIComponent(segment))
.join("/");
}
function decodeTreeish(url) {
const match = String(url).match(/\/git\/trees\/([^/?]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
async function withMockedFetch({ contents = {}, trees = {} }, run) {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const requestUrl = String(url);
let route;
if (requestUrl.includes("/git/trees/")) {
route = trees[decodeTreeish(requestUrl)] ?? { status: 404, data: {} };
} else {
route = contents[decodeContentsPath(requestUrl)] ?? { status: 404, data: {} };
}
return {
ok: route.status >= 200 && route.status < 300,
status: route.status,
json: async () => route.data ?? {},
};
};
try {
return await run();
} finally {
globalThis.fetch = originalFetch;
}
}
const canvasManifest = fileNode(
JSON.stringify({
name: "upgrade-agent",
version: "1.0.0",
description: "Canvas plugin",
logo: "assets/preview.png",
}),
);
function baseContents(extra) {
return {
[`${PLUGIN_ROOT}/.github/plugin/plugin.json`]: { status: 200, data: canvasManifest },
[`${PLUGIN_ROOT}/assets/preview.png`]: { status: 200, data: fileNode("binary") },
...extra,
};
}
// Wires up the directory-walk that resolves plugins/upgrade-agent/extensions to a tree SHA.
// `extensionsEntry` controls what the walk finds at the final ".../extensions" step, and
// `extensionsSubtree` is the recursive listing returned for that resolved tree SHA.
function buildTrees({ extensionsEntry, extensionsSubtree, overrides } = {}) {
const trees = {
[SHA]: treeResponse([treeEntry("plugins", "tree", TREE_PLUGINS)]),
[TREE_PLUGINS]: treeResponse([treeEntry("upgrade-agent", "tree", TREE_UPGRADE_AGENT)]),
[TREE_UPGRADE_AGENT]: treeResponse([
extensionsEntry ?? treeEntry("extensions", "tree", TREE_EXTENSIONS),
]),
};
if (extensionsSubtree) {
trees[TREE_EXTENSIONS] = extensionsSubtree;
}
return { ...trees, ...overrides };
}
function makePlugin() {
return {
name: "upgrade-agent",
keywords: ["canvas"],
source: { source: "github", repo: REPO, sha: SHA, path: PLUGIN_ROOT },
};
}
async function runValidation({ contents, trees }) {
const errors = [];
const warnings = [];
await withMockedFetch({ contents: baseContents(contents), trees }, () =>
validateCanvasPluginMetadata(makePlugin(), errors, warnings, null),
);
return { errors, warnings };
}
test("validateCanvasPluginMetadata accepts a nested extension entry point", async () => {
const { errors, warnings } = await runValidation({
trees: buildTrees({
extensionsSubtree: treeResponse([
treeEntry("modernize-dashboard", "tree"),
treeEntry("modernize-dashboard/extension.mjs", "blob"),
]),
}),
});
assert.deepEqual(errors, []);
assert.deepEqual(warnings, []);
});
test("validateCanvasPluginMetadata accepts a flat extension entry point", async () => {
const { errors, warnings } = await runValidation({
trees: buildTrees({
extensionsSubtree: treeResponse([treeEntry("extension.mjs", "blob")]),
}),
});
assert.deepEqual(errors, []);
assert.deepEqual(warnings, []);
});
test("validateCanvasPluginMetadata rejects when no extension.mjs exists flat or nested", async () => {
const { errors } = await runValidation({
trees: buildTrees({
extensionsSubtree: treeResponse([
treeEntry("modernize-dashboard", "tree"),
treeEntry("modernize-dashboard/index.mjs", "blob"),
]),
}),
});
assert.equal(
errors.some((message) => /must include a canvas extension entry point/.test(message)),
true,
);
});
test("validateCanvasPluginMetadata rejects when the extensions directory is missing", async () => {
const { errors } = await runValidation({
trees: buildTrees({
extensionsEntry: treeEntry("other-dir", "tree", "tree-other"),
}),
});
assert.equal(
errors.some((message) => /must include an "extensions" directory/.test(message)),
true,
);
});
test("validateCanvasPluginMetadata rejects when extensions is a file rather than a directory", async () => {
const { errors } = await runValidation({
trees: buildTrees({
extensionsEntry: treeEntry("extensions", "blob", "blob-extensions"),
}),
});
assert.equal(
errors.some((message) => /"extensions" must be a directory/.test(message)),
true,
);
});
test("validateCanvasPluginMetadata rejects when extensions/extension.mjs is a directory", async () => {
const { errors } = await runValidation({
trees: buildTrees({
extensionsSubtree: treeResponse([
treeEntry("extension.mjs", "tree"),
treeEntry("extension.mjs/placeholder.txt", "blob"),
]),
}),
});
assert.equal(
errors.some((message) => /"extensions\/extension\.mjs" must be a file/.test(message)),
true,
);
});
test("validateCanvasPluginMetadata surfaces an unverifiable result when the tree walk errors", async () => {
const { errors, warnings } = await runValidation({
trees: buildTrees({
overrides: { [SHA]: { status: 500, data: {} } },
}),
});
assert.deepEqual(errors, []);
assert.equal(
warnings.some((message) => /could not verify the canvas extension entry point/.test(message)),
true,
);
});
test("validateCanvasPluginMetadata treats a truncated walk level as unverifiable", async () => {
const { errors, warnings } = await runValidation({
trees: buildTrees({
overrides: {
[TREE_UPGRADE_AGENT]: treeResponse(
[treeEntry("extensions", "tree", TREE_EXTENSIONS)],
{ truncated: true },
),
},
}),
});
assert.deepEqual(errors, []);
assert.equal(
warnings.some((message) => /could not verify the canvas extension entry point/.test(message)),
true,
);
});
test("validateCanvasPluginMetadata treats a truncated extensions subtree without an entry point as unverifiable", async () => {
const { errors, warnings } = await runValidation({
trees: buildTrees({
extensionsSubtree: treeResponse([treeEntry("modernize-dashboard", "tree")], { truncated: true }),
}),
});
assert.deepEqual(errors, []);
assert.equal(
warnings.some((message) => /could not verify the canvas extension entry point/.test(message)),
true,
);
});
test("validateCanvasPluginMetadata accepts an entry point found within a truncated subtree", async () => {
const { errors, warnings } = await runValidation({
trees: buildTrees({
extensionsSubtree: treeResponse(
[
treeEntry("modernize-dashboard", "tree"),
treeEntry("modernize-dashboard/extension.mjs", "blob"),
],
{ truncated: true },
),
}),
});
assert.deepEqual(errors, []);
assert.deepEqual(warnings, []);
});