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>
This commit is contained in:
Tim Mulholland
2026-07-28 21:05:31 -07:00
committed by GitHub
parent b34ac0918c
commit 63c2527ace
4 changed files with 560 additions and 34 deletions
+92 -5
View File
@@ -529,6 +529,91 @@ function checkPathExistsAtLocator(repoDir, readRef, locator, repoPath, expectedT
};
}
function listTreeEntries(repoDir, readRef, locator, treePath, { recursive = false } = {}) {
// Parse the full, untruncated tree listing directly. runCommand()/truncateOutput()
// would cap stdout at MAX_OUTPUT_LENGTH and silently drop later entries, and the
// default (non-"-z") output quotes unusual names; "-z" gives raw, NUL-delimited records.
// "-r -t" recurses in a single process and still lists intermediate tree objects, so a
// whole subtree can be inspected without spawning one git process per candidate path.
const args = recursive
? ["ls-tree", "-r", "-t", "-z", `${readRef}:${treePath}`]
: ["ls-tree", "-z", `${readRef}:${treePath}`];
const result = spawnSync("git", args, {
cwd: repoDir,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
if (result.status !== 0) {
const detail = truncateOutput(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
return {
entries: [],
output: `Unable to list directory "${treePath}" at "${locator}": ${detail}`,
};
}
const entries = [];
for (const record of String(result.stdout ?? "").split("\0")) {
if (!record) {
continue;
}
const tabIndex = record.indexOf("\t");
if (tabIndex === -1) {
continue;
}
const meta = record.slice(0, tabIndex).trim().split(/\s+/);
const name = record.slice(tabIndex + 1);
if (!name) {
continue;
}
entries.push({ type: meta[1] ?? "", name });
}
return { entries, output: "" };
}
function locateCanvasEntryPoint(repoDir, readRef, locator, extensionsDir) {
// Enumerate the extensions subtree with a single recursive git process rather than
// spawning a git cat-file per candidate directory, so discovery stays bounded no matter
// how many folders an untrusted repository packs under "extensions/". "-r" yields paths
// relative to extensionsDir, so nested entry points appear as "<name>/extension.mjs".
const listing = listTreeEntries(repoDir, readRef, locator, extensionsDir, { recursive: true });
if (listing.output) {
return { entryPoint: null, output: listing.output };
}
let flatIsBlob = false;
let flatIsTree = false;
let nestedEntryPoint = null;
for (const entry of listing.entries) {
if (entry.name === "extension.mjs") {
if (entry.type === "blob") {
flatIsBlob = true;
} else if (entry.type === "tree") {
flatIsTree = true;
}
continue;
}
const segments = entry.name.split("/");
if (segments.length === 2 && segments[1] === "extension.mjs" && entry.type === "blob" && !nestedEntryPoint) {
nestedEntryPoint = toPosixPath(extensionsDir, segments[0], "extension.mjs");
}
}
if (flatIsBlob) {
return { entryPoint: toPosixPath(extensionsDir, "extension.mjs"), output: "" };
}
if (nestedEntryPoint) {
return { entryPoint: nestedEntryPoint, output: "" };
}
return { entryPoint: null, output: "", flatKindMismatch: flatIsTree };
}
export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) {
if (!hasCanvasKeyword(plugin)) {
return {
@@ -589,23 +674,25 @@ export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) {
continue;
}
const extensionEntryCheck = checkPathExistsAtLocator(repoDir, readRef, locator, extensionEntryPoint, "blob");
const extensionEntryCheck = locateCanvasEntryPoint(repoDir, readRef, locator, extensionsDir);
if (extensionEntryCheck.output) {
hasInfraError = true;
messages.push(`- ${locator}: ${extensionEntryCheck.output}`);
continue;
}
if (!extensionEntryCheck.exists) {
if (!extensionEntryCheck.entryPoint) {
hasFailure = true;
if (extensionEntryCheck.kindMismatch) {
if (extensionEntryCheck.flatKindMismatch) {
messages.push(`- ${locator}: "${extensionEntryPoint}" must be a file.`);
} else {
messages.push(`- ${locator}: missing required canvas extension entry point "${extensionEntryPoint}".`);
messages.push(
`- ${locator}: missing required canvas extension entry point "${extensionEntryPoint}" (or a nested "${extensionsDir}/<extension>/extension.mjs").`,
);
}
continue;
}
messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryPoint}".`);
messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryCheck.entryPoint}".`);
}
if (hasInfraError) {