Files
awesome-copilot/eng/canvas-smoke-test.test.mjs
T
d82df27473 Phase 3: canvas smoke-test evidence and weekly review metrics (#4184) (#4191)
* Phase 3: canvas smoke-test evidence, safe auto-merge, and weekly review metrics (#4184)

Adds the canvas-smoke-test check with review evidence, a config-gated safe auto-merge workflow (disabled by default), and a weekly review operating metrics workflow, plus maintainer docs.

Refs github/awesome-copilot#4184

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

* Trigger safe auto-merge sweep after Submission Gate Writer completes

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

* Rename thirdParty identifier to satisfy codespell

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

* Address Copilot review on canvas smoke test, auto-merge, and metrics

- Bind canvas report writer to the triggering workflow_run PR (base/head repo, ref, SHA)
- Bound PNG inflation by IHDR-derived size and a decoded-bytes budget
- Classify file: URLs as unsafe manifest paths
- Follow CommonJS require() and literal dynamic imports; error on missing
  reachable targets and devDependency-only runtime imports
- Validate removed extension paths and orphaned plugins instead of skipping
- Warn on .py/.rb/.pl scripts
- Auto-merge: support CODEOWNERS teams (fail closed), front matter authors,
  list PRs by head SHA, only fall back to direct merge on clean status,
  drop ineffective check_run trigger
- Metrics: ignore reviews before the PR was ready for review

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

* Docs: note microsoft/azure-dev-tools as a portable marketplace for the review model

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

* Auto-merge: only trust submission-gate runs from the Submission Gate Writer

Adds trusted_checks (default submission-gate -> external_id
submission-gate-writer). Any other check run or status with that name on the
head SHA blocks arming, so a PR-defined job cannot satisfy the gate.

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

* Harden canvas smoke-test checker

- String-, template-, and regex-aware comment stripping so literals containing // cannot hide imports
- Never follow symlinks or read non-regular files; bounded, contained reads
- Validate plugins whose plugin.json was deleted
- Reject unsafe extension IDs in plugin manifests before materialization

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

* Phase 3: remove auto-merge, isolate canvas smoke test, harden checker

- Remove safe auto-merge (config, workflow, script, tests, labels) per
  maintainer review; documented as deferred pending a security review.
- Restore validate-canvas-extensions.yml to main and move the smoke test
  to its own canvas-smoke-test.yml workflow scoped to extensions/**.
- Reject duplicate IHDR chunks in preview PNGs.
- Mask string/template/regex literal contents before extracting dynamic
  import() and require() specifiers.
- Resolve package.json imports aliases through the same classifier and
  module graph; fail closed on alias shapes that cannot be analyzed.
- Rename docs to canvas-evidence-and-metrics.md and update references.

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

* Fix canvas smoke and review metrics findings

- Trigger canvas smoke tests for plugin changes while skipping expensive setup when target detection finds no canvas coverage.
- Fail reachable data: imports while preserving warnings for unreachable module data: imports.
- Use base plugin manifests for deleted bundle plugin detection and fail closed when base manifests cannot be read.
- Paginate GraphQL PR review pages before computing weekly review metrics.
- Report workflow run API collection errors as incomplete automation health instead of not found.

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

* Address follow-up canvas review findings

Fail closed on non-literal runtime imports, require canonical PNG termination, restrict comment updates to github-actions[bot], and paginate external-plugin label events.

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: Aaron Powell <me@aaron-powell.com>
2026-10-07 16:36:13 +11:00

673 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import zlib from "node:zlib";
import { test } from "node:test";
import {
checkExtensionModules,
checkPreview,
classifySpecifier,
detectCanvasTargets,
findDynamicImportSpecifiers,
findNonLiteralRuntimeLoads,
findRequireSpecifiers,
findUnsafeManifestPaths,
importsAliasTargets,
inspectExtensionFiles,
inspectPng,
parseEsModule,
isSafeExtensionId,
readRegularFile,
renderMarkdownReport,
runCanvasSmokeTest,
stripComments,
unsafeExtensionRefs,
unsafePathReason,
} from "./canvas-smoke-test.mjs";
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let k = 0; k < 8; k++) crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
}
return (crc ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, "latin1"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([length, body, crc]);
}
function makePng(width, height, { truncate = false } = {}) {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8; // bit depth
header[9] = 2; // truecolor
const rows = Buffer.alloc(height * (width * 3 + 1));
const raw = truncate ? rows.subarray(0, rows.length / 2) : rows;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", header),
chunk("IDAT", zlib.deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
function makeRepo(files) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "canvas-smoke-test-"));
for (const [relative, content] of Object.entries(files)) {
const target = path.join(root, relative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, typeof content === "string" || Buffer.isBuffer(content) ? content : JSON.stringify(content, null, 2));
}
return root;
}
function git(root, args) {
const result = spawnSync("git", args, { cwd: root, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr || result.stdout);
return result.stdout.trim();
}
const PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
function extensionPlugin(name) {
return {
$schema: PLUGIN_SCHEMA,
name,
description: `${name} canvas`,
version: "1.0.0",
extensions: { "com.github.copilot": { logo: "assets/preview.png" } },
};
}
test("inspectPng accepts a valid PNG and reports dimensions", () => {
const result = inspectPng(makePng(32, 16));
assert.equal(result.ok, true);
assert.equal(result.width, 32);
assert.equal(result.height, 16);
});
test("inspectPng rejects non-PNG, corrupted, and truncated data", () => {
assert.match(inspectPng(Buffer.from("GIF89a-not-a-png-at-all")).errors[0], /signature/);
const corrupted = makePng(8, 8);
corrupted[corrupted.length - 20] ^= 0xff;
assert.equal(inspectPng(corrupted).ok, false);
const truncated = inspectPng(makePng(8, 8, { truncate: true }));
assert.equal(truncated.ok, false);
assert.match(truncated.errors[0], /truncated/);
});
test("inspectPng rejects malformed IEND chunks and trailing data", () => {
const valid = makePng(8, 8);
assert.match(inspectPng(Buffer.concat([valid, Buffer.from("trailing")])).errors[0], /after the IEND/);
const iendOffset = valid.length - 12;
const nonEmptyIend = Buffer.concat([
valid.subarray(0, iendOffset),
chunk("IEND", Buffer.from([0])),
]);
assert.match(inspectPng(nonEmptyIend).errors[0], /IEND chunk has invalid length/);
});
test("checkPreview enforces configurable minimum dimensions", () => {
const root = makeRepo({ "ext/assets/preview.png": makePng(100, 50) });
const small = checkPreview(path.join(root, "ext"), { minWidth: 400, minHeight: 160 });
assert.match(small.errors[0], /minimum is 400×160/);
const ok = checkPreview(path.join(root, "ext"), { minWidth: 100, minHeight: 50 });
assert.deepEqual(ok.errors, []);
const missing = checkPreview(path.join(root, "missing"), { minWidth: 1, minHeight: 1 });
assert.match(missing.errors[0], /missing/);
});
test("unsafePathReason flags absolute and traversal paths", () => {
assert.equal(unsafePathReason("./assets/preview.png"), null);
assert.equal(unsafePathReason("assets/preview.png"), null);
assert.match(unsafePathReason("../secrets.txt"), /traversal/);
assert.match(unsafePathReason("./a/../../b"), /traversal/);
assert.match(unsafePathReason("/etc/passwd"), /absolute/);
assert.match(unsafePathReason("C:\\Windows\\system32"), /Windows/);
assert.match(unsafePathReason("file:///tmp/x.mjs"), /file:/);
});
test("findUnsafeManifestPaths ignores prose and URLs", () => {
const findings = findUnsafeManifestPaths({
description: "Reads ../ style docs and / separators in text",
homepage: "https://example.com/../x",
extensions: { "com.github.copilot": { logo: "../outside.png" } },
main: "/abs/entry.mjs",
});
assert.deepEqual(findings.map((finding) => finding.field).sort(), ["$.extensions.com.github.copilot.logo", "$.main"]);
});
test("classifySpecifier distinguishes builtins, host packages, dependencies, and unsafe specifiers", () => {
const pkg = { dependencies: { playwright: "1.0.0" }, devDependencies: { vitest: "1.0.0" } };
assert.equal(classifySpecifier("node:fs", pkg).kind, "builtin");
assert.equal(classifySpecifier("path", pkg).kind, "builtin");
assert.equal(classifySpecifier("@github/copilot-sdk/extension", pkg).kind, "host");
assert.equal(classifySpecifier("playwright/test", pkg).kind, "dependency");
assert.equal(classifySpecifier("vitest", pkg).kind, "dev-dependency");
assert.equal(classifySpecifier("left-pad", pkg).kind, "undeclared");
assert.equal(classifySpecifier("/tmp/evil.mjs", pkg).kind, "unsafe");
assert.equal(classifySpecifier("https://example.com/x.mjs", pkg).kind, "remote");
assert.equal(classifySpecifier("./local.mjs", pkg).kind, "relative");
});
test("parseEsModule returns static specifiers without executing code", () => {
const marker = path.join(os.tmpdir(), `canvas-smoke-exec-${process.pid}-${Date.now()}`);
const source = `import fs from "node:fs";\nimport { x } from "./x.mjs";\nfs.writeFileSync(${JSON.stringify(marker)}, "ran");\n`;
const result = parseEsModule(source, "entry.mjs");
assert.equal(result.ok, true);
assert.deepEqual(result.specifiers.sort(), ["./x.mjs", "node:fs"]);
assert.equal(fs.existsSync(marker), false);
const broken = parseEsModule("export const = 1;", "broken.mjs");
assert.equal(broken.ok, false);
});
test("checkExtensionModules reports syntax errors, missing files, traversal, and undeclared packages", () => {
const root = makeRepo({
"extensions/bad/extension.mjs": [
'import { joinSession } from "@github/copilot-sdk/extension";',
'import { spawn } from "node:child_process";',
'import helper from "./lib/helper.mjs";',
'import missing from "./lib/missing.mjs";',
'import outside from "../other/secret.mjs";',
'import pad from "left-pad";',
"export default { joinSession, spawn, helper, missing, outside, pad };",
].join("\n"),
"extensions/bad/lib/helper.mjs": "export default fetch;\nexport const broken = ;\n",
"extensions/bad/public/app.js": 'import { h } from "/vendor/preact.js";\nexport default h;\n',
"extensions/bad/package.json": { name: "bad", version: "1.0.0" },
"extensions/other/secret.mjs": "export default 1;\n",
});
const result = checkExtensionModules(path.join(root, "extensions", "bad"));
const text = result.errors.join("\n");
assert.match(text, /lib\/helper\.mjs: syntax error/);
assert.match(text, /\.\/lib\/missing\.mjs" references a missing file/);
assert.match(text, /escapes the extension directory/);
assert.match(text, /"left-pad" is not a Node\.js builtin/);
assert.doesNotMatch(text, /public\/app\.js/);
assert.match(result.warnings.join("\n"), /public\/app\.js: import "\/vendor\/preact\.js" uses an unsafe absolute path \(module is not reachable/);
assert.deepEqual(result.builtins, ["child_process"]);
});
test("inspectExtensionFiles flags native binaries, executables, and vendored node_modules", () => {
const elf = Buffer.concat([Buffer.from([0x7f, 0x45, 0x4c, 0x46]), Buffer.alloc(32)]);
const root = makeRepo({
"extensions/bin/extension.mjs": "export {};\n",
"extensions/bin/tool": elf,
"extensions/bin/addon.node": "not really native",
"extensions/bin/run.sh": "#!/bin/sh\necho hi\n",
"extensions/bin/assets/preview.png": makePng(4, 4),
"extensions/bin/node_modules/dep/index.js": "module.exports = 1;\n",
"extensions/bin/data.bin.dat": Buffer.from([0, 1, 2, 3]),
"extensions/bin/tool.py": "print(1)\n",
});
const modes = new Map([["extensions/bin/run.sh", "100755"]]);
const result = inspectExtensionFiles(path.join(root, "extensions", "bin"), { rootDir: root, fileModes: modes });
const errors = result.errors.join("\n");
assert.match(errors, /tool: contains a ELF executable/);
assert.match(errors, /addon\.node: native\/compiled binary/);
assert.match(errors, /run\.sh: file is marked executable/);
assert.match(errors, /node_modules\/: vendored node_modules/);
assert.match(result.warnings.join("\n"), /run\.sh: script file/);
assert.match(result.warnings.join("\n"), /data\.bin\.dat: unexpected binary content/);
assert.match(result.warnings.join("\n"), /tool\.py: script file/);
assert.doesNotMatch(result.warnings.join("\n"), /preview\.png/);
});
test("detectCanvasTargets maps changed paths to extensions and extension-bearing plugins", () => {
const root = makeRepo({
"extensions/orb/extension.mjs": "export {};\n",
"extensions/shared/extension.mjs": "export {};\n",
"plugins/orb/plugin.json": extensionPlugin("orb"),
"plugins/bundle/plugin.json": {
$schema: PLUGIN_SCHEMA,
name: "bundle",
description: "bundle",
version: "1.0.0",
extensions: { "com.github.awesome-copilot": { extensions: ["./extensions/shared"] } },
},
"plugins/plain/plugin.json": { $schema: PLUGIN_SCHEMA, name: "plain", description: "plain", version: "1.0.0" },
});
assert.deepEqual(detectCanvasTargets(["README.md", "plugins/plain/README.md", "skills/x/SKILL.md"], { rootDir: root }).extensions, []);
const orb = detectCanvasTargets(["extensions/orb/extension.mjs"], { rootDir: root });
assert.deepEqual(orb.extensions, ["orb"]);
assert.deepEqual(orb.plugins, ["orb"]);
const bundle = detectCanvasTargets(["plugins/bundle/plugin.json", "extensions/gone/extension.mjs"], { rootDir: root });
assert.deepEqual(bundle.extensions, ["shared"]);
assert.deepEqual(bundle.plugins, ["bundle"]);
assert.deepEqual(bundle.removedExtensions, ["gone"]);
});
test("runCanvasSmokeTest reports skipped when no canvas paths change", async () => {
const root = makeRepo({ "plugins/plain/plugin.json": { name: "plain" } });
const report = await runCanvasSmokeTest({ rootDir: root, changedFiles: ["docs/README.md"], install: "never" });
assert.equal(report.status, "skipped");
assert.match(renderMarkdownReport(report), /Skipped/);
});
test("runCanvasSmokeTest materializes a valid extension plugin and renders evidence", async () => {
const root = makeRepo({
"extensions/orb/extension.mjs": 'import { joinSession } from "@github/copilot-sdk/extension";\nimport http from "node:http";\nexport default { joinSession, http };\n',
"extensions/orb/package.json": { name: "orb", version: "1.0.0", type: "module" },
"extensions/orb/assets/preview.png": makePng(800, 400),
"plugins/orb/plugin.json": extensionPlugin("orb"),
"plugins/orb/README.md": "# Orb\n",
});
const report = await runCanvasSmokeTest({ rootDir: root, changedFiles: ["extensions/orb/extension.mjs"], install: "never" });
assert.equal(report.status, "pass", JSON.stringify(report, null, 2));
assert.equal(report.smoke.materialize.orb.status, "pass");
assert.equal(report.smoke.installStatus, "skipped");
assert.equal(report.extensions[0].preview.width, 800);
const markdown = renderMarkdownReport(report, { previewBaseUrl: "https://raw.githubusercontent.com/o/r/sha/" });
assert.match(markdown, /<!-- canvas-smoke-test -->/);
assert.match(markdown, /Network sockets/);
assert.match(markdown, /extensions\/orb\/assets\/preview\.png/);
});
test("runCanvasSmokeTest fails for an unregistered extension with a missing preview", async () => {
const root = makeRepo({ "extensions/lonely/extension.mjs": "export {};\n" });
const report = await runCanvasSmokeTest({ rootDir: root, changedFiles: ["extensions/lonely/extension.mjs"], install: "never" });
assert.equal(report.status, "fail");
const errors = report.extensions[0].errors.join("\n");
assert.match(errors, /preview\.png is missing/);
assert.match(errors, /not registered by any plugin/);
});
test("inspectPng rejects images whose decoded data exceeds the budget before inflating", () => {
const header = Buffer.alloc(13);
header.writeUInt32BE(16000, 0);
header.writeUInt32BE(16000, 4);
header[8] = 16; // bit depth
header[9] = 6; // RGBA
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", header),
chunk("IDAT", zlib.deflateSync(Buffer.alloc(1024))),
chunk("IEND", Buffer.alloc(0)),
]);
const result = inspectPng(png);
assert.equal(result.ok, false);
assert.match(result.errors[0], /decoded image data would be/);
});
test("inspectPng rejects image data larger than IHDR allows", () => {
const header = Buffer.alloc(13);
header.writeUInt32BE(4, 0);
header.writeUInt32BE(4, 4);
header[8] = 8;
header[9] = 2;
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", header),
chunk("IDAT", zlib.deflateSync(Buffer.alloc(10_000))),
chunk("IEND", Buffer.alloc(0)),
]);
assert.match(inspectPng(png).errors[0], /larger than the IHDR/);
});
test("findUnsafeManifestPaths reports file: URLs", () => {
const findings = findUnsafeManifestPaths({ main: "file:///tmp/x.mjs", homepage: "https://example.com/x" });
assert.deepEqual(findings.map((finding) => finding.field), ["$.main"]);
assert.match(findings[0].reason, /file:/);
});
test("checkExtensionModules follows CommonJS requires and literal dynamic imports", () => {
const root = makeRepo({
"extensions/graph/extension.mjs": [
'import cjs from "./lib/legacy.cjs";',
'const worker = await import("./lib/worker.mjs");',
'const later = await import("./lib/missing.mjs");',
"// import(\"./lib/commented.mjs\")",
"export default { cjs, worker, later };",
].join("\n"),
"extensions/graph/lib/legacy.cjs": [
'const helper = require("./helper");',
'const pad = require("left-pad");',
'const outside = require("../../other/x.js");',
"// require(\"ignored-in-comment\")",
"module.exports = { helper, pad, outside };",
].join("\n"),
"extensions/graph/lib/helper.js": "module.exports = 1;\n",
"extensions/graph/lib/worker.mjs": 'import { spawn } from "node:child_process";\nimport vitest from "vitest";\nexport default { spawn, vitest };\n',
"extensions/graph/package.json": { name: "graph", version: "1.0.0", devDependencies: { vitest: "1.0.0" } },
"extensions/other/x.js": "module.exports = 1;\n",
});
const result = checkExtensionModules(path.join(root, "extensions", "graph"));
const errors = result.errors.join("\n");
assert.match(errors, /legacy\.cjs: require\("left-pad"\) is not a Node\.js builtin/);
assert.match(errors, /legacy\.cjs: require\("\.\.\/\.\.\/other\/x\.js"\) escapes the extension directory/);
assert.doesNotMatch(errors, /require\("\.\/helper"\)/);
assert.doesNotMatch(errors, /ignored-in-comment|commented\.mjs/);
assert.match(errors, /dynamic import\("\.\/lib\/missing\.mjs"\) references a missing file/);
assert.match(errors, /worker\.mjs: import "vitest" is only declared in devDependencies/);
assert.deepEqual(result.builtins, ["child_process"]);
assert.ok(result.modules.find((entry) => entry.path === "lib/worker.mjs").reachable);
assert.ok(result.modules.find((entry) => entry.path === "lib/helper.js").reachable);
});
test("checkExtensionModules fails reachable data URL imports and warns for unreachable dynamic data imports", () => {
const root = makeRepo({
"extensions/data/extension.mjs": [
'import inline from "data:text/javascript,export default 1";',
'const later = await import("data:text/javascript,export default 2");',
"export default { inline, later };",
].join("\n"),
"extensions/data/public/app.js": 'await import("data:text/javascript,export default 3");\n',
});
const result = checkExtensionModules(path.join(root, "extensions", "data"));
const errors = result.errors.join("\n");
assert.match(errors, /extension\.mjs: import "data:text\/javascript,export default 1" is a data: URL import/);
assert.match(errors, /extension\.mjs: dynamic import\("data:text\/javascript,export default 2"\) is a data: URL import/);
assert.match(result.warnings.join("\n"), /public\/app\.js: dynamic import\("data:text\/javascript,export default 3"\) is a data: URL import \(module is not reachable/);
});
test("removed canvas paths are validated instead of skipped", async () => {
// Entry point deleted but the extension directory remains.
const partial = makeRepo({
"extensions/orb/assets/preview.png": makePng(800, 400),
"plugins/orb/plugin.json": extensionPlugin("orb"),
});
const partialReport = await runCanvasSmokeTest({ rootDir: partial, changedFiles: ["extensions/orb/extension.mjs"], install: "never" });
assert.equal(partialReport.status, "fail");
assert.match(partialReport.extensions[0].errors.join("\n"), /extension\.mjs: entry point is missing/);
// Extension deleted, but its direct plugin and a bundling plugin remain.
const orphaned = makeRepo({
"plugins/orb/plugin.json": extensionPlugin("orb"),
"plugins/bundle/plugin.json": {
$schema: PLUGIN_SCHEMA,
name: "bundle",
description: "bundle",
version: "1.0.0",
extensions: { "com.github.awesome-copilot": { extensions: ["./extensions/orb"] } },
},
});
const orphanedTargets = detectCanvasTargets(["extensions/orb/extension.mjs"], { rootDir: orphaned });
assert.deepEqual(orphanedTargets.plugins, ["bundle", "orb"]);
const orphanedReport = await runCanvasSmokeTest({ rootDir: orphaned, changedFiles: ["extensions/orb/extension.mjs"], install: "never" });
assert.equal(orphanedReport.status, "fail");
const pluginErrors = orphanedReport.plugins.flatMap((plugin) => plugin.errors).join("\n");
assert.match(pluginErrors, /plugins\/orb is the plugin for removed extension/);
assert.match(pluginErrors, /plugins\/bundle\/plugin\.json references missing extension extensions\/orb/);
// Extension and plugin both deleted: accepted.
const clean = makeRepo({ "plugins/plain/plugin.json": { name: "plain" } });
const cleanReport = await runCanvasSmokeTest({
rootDir: clean,
changedFiles: ["extensions/orb/extension.mjs", "plugins/orb/plugin.json"],
install: "never",
});
assert.equal(cleanReport.status, "pass");
assert.match(renderMarkdownReport(cleanReport), /removed extensions/);
});
test("stripComments keeps string, template, and regex literals that contain comment markers", () => {
const source = [
'const s = "x//y"; await import("./a.mjs");',
"const t = `/* ${'//'} */`; await import(\"./b.mjs\");",
'const r = /\\/\\*/; await import("./c.mjs");',
'// await import("./hidden.mjs");',
'/* await import("./hidden2.mjs"); */',
'const q = a / b; const w = c / d; await import("./d.mjs");',
].join("\n");
const text = stripComments(source);
for (const name of ["a", "b", "c", "d"]) assert.match(text, new RegExp(`import\\("\\./${name}\\.mjs"\\)`));
assert.doesNotMatch(text, /hidden/);
assert.equal(text.split("\n").length, source.split("\n").length);
});
test("checkExtensionModules follows imports placed after strings containing //", () => {
const root = makeRepo({
"extensions/str/extension.mjs": 'const s = "x//y"; const w = await import("./worker.mjs");\nexport default { s, w };\n',
"extensions/str/worker.mjs": 'import pad from "left-pad";\nexport default pad;\n',
});
const result = checkExtensionModules(path.join(root, "extensions", "str"));
assert.match(result.errors.join("\n"), /worker\.mjs: import "left-pad" is not a Node\.js builtin/);
assert.ok(result.modules.find((entry) => entry.path === "worker.mjs").reachable);
});
test("inspectPng rejects a duplicate IHDR chunk", () => {
const valid = makePng(4, 4);
const ihdr = valid.subarray(8, 8 + 25);
const png = Buffer.concat([valid.subarray(0, 33), ihdr, valid.subarray(33)]);
const result = inspectPng(png);
assert.equal(result.ok, false);
assert.match(result.errors.join("\n"), /duplicate IHDR chunk/);
});
test("dynamic import and require extraction ignores call-shaped text inside literals", () => {
const source = [
'const a = \'import("./in-single.mjs")\';',
'const b = "require(\\"./in-double.cjs\\")";',
"const c = `import(\"./in-template.mjs\") ${await import(\"./in-expr.mjs\")}`;",
'const d = /import\\("\\.\\/in-regex\\.mjs"\\)/;',
'const e = await import("./real.mjs"); const f = require("./real.cjs");',
].join("\n");
assert.deepEqual(findDynamicImportSpecifiers(source).sort(), ["./in-expr.mjs", "./real.mjs"]);
assert.deepEqual(findRequireSpecifiers(source), ["./real.cjs"]);
const root = makeRepo({
"extensions/lit/extension.mjs": 'const note = \'import("./missing.mjs")\';\nexport default { note };\n',
});
assert.deepEqual(checkExtensionModules(path.join(root, "extensions", "lit")).errors, []);
});
test("non-literal runtime loads are detected outside comments and literals", () => {
const source = [
'const literalImport = import("./literal.mjs");',
'const literalRequire = require("left-pad");',
'const computedImport = import("node:" + moduleName);',
"const computedRequire = require(moduleName);",
'const text = "require(hidden)";',
"// import(variable)",
"const templateImport = import(`./${name}.mjs`);",
].join("\n");
assert.deepEqual(findNonLiteralRuntimeLoads(source), ["dynamic import", "dynamic import", "require"]);
const root = makeRepo({
"extensions/runtime/extension.mjs": 'const name = "fs";\nexport default import("node:" + name);\n',
});
assert.match(
checkExtensionModules(path.join(root, "extensions", "runtime")).errors.join("\n"),
/extension\.mjs: non-literal dynamic import cannot be analyzed safely/,
);
});
test("checkExtensionModules resolves package.json imports aliases and fails closed on unknown shapes", () => {
assert.deepEqual(importsAliasTargets({ node: "./a.mjs", default: { import: "./b.mjs" } }), ["./a.mjs", "./b.mjs"]);
assert.equal(importsAliasTargets(["./a.mjs"]), null);
const root = makeRepo({
"extensions/alias/extension.mjs": [
'import ok from "#ok";',
'import gone from "#gone";',
'import pad from "#pad";',
'import odd from "#odd";',
'import up from "#up";',
"export default { ok, gone, pad, odd, up };",
].join("\n"),
"extensions/alias/lib/ok.mjs": 'import pad from "left-pad";\nexport default pad;\n',
"extensions/alias/package.json": {
name: "alias",
version: "1.0.0",
type: "module",
imports: { "#ok": "./lib/ok.mjs", "#gone": "./lib/gone.mjs", "#pad": "left-pad", "#odd": ["./lib/ok.mjs"], "#up": "../x.mjs" },
},
});
const result = checkExtensionModules(path.join(root, "extensions", "alias"));
const errors = result.errors.join("\n");
assert.ok(result.modules.find((entry) => entry.path === "lib/ok.mjs").reachable);
assert.match(errors, /lib\/ok\.mjs: import "left-pad" is not a Node\.js builtin/);
assert.match(errors, /import "#gone" -> "\.\/lib\/gone\.mjs" references a missing file/);
assert.match(errors, /import "#pad" -> "left-pad" is not a Node\.js builtin/);
assert.match(errors, /import "#odd" uses a package\.json "imports" alias that cannot be analyzed/);
assert.match(errors, /import "#up" uses a package\.json "imports" alias that cannot be analyzed/);
});
test("readRegularFile and checkExtensionModules refuse symlinked files", (t) => {
const root = makeRepo({
"outside/secret.txt": "secret\n",
"extensions/link/extension.mjs": 'import x from "./lib.mjs";\nexport default x;\n',
});
const extensionDir = path.join(root, "extensions", "link");
try {
fs.symlinkSync(path.join(root, "outside", "secret.txt"), path.join(extensionDir, "lib.mjs"));
fs.symlinkSync(path.join(root, "outside", "secret.txt"), path.join(extensionDir, "package.json"));
} catch (error) {
if (["EPERM", "EACCES"].includes(error.code)) return t.skip("symlinks unavailable");
throw error;
}
assert.match(readRegularFile(path.join(extensionDir, "lib.mjs")).error, /symbolic link/);
const errors = checkExtensionModules(extensionDir).errors.join("\n");
assert.match(errors, /package\.json/);
assert.match(errors, /lib\.mjs/);
assert.doesNotMatch(errors, /secret/);
});
test("readRegularFile enforces the size cap and containment root", () => {
const root = makeRepo({ "a/big.json": "x".repeat(64), "b/ok.json": "{}" });
assert.match(readRegularFile(path.join(root, "a", "big.json"), { maxBytes: 10 }).error, /limit/);
assert.match(readRegularFile(path.join(root, "b", "ok.json"), { root: path.join(root, "a") }).error, /outside/);
assert.equal(readRegularFile(path.join(root, "b", "ok.json"), { root: path.join(root, "b") }).text, "{}");
assert.equal(readRegularFile(path.join(root, "b", "missing.json")).missing, true);
});
test("detectCanvasTargets still validates a plugin whose plugin.json was deleted", async () => {
const root = makeRepo({
"extensions/orb/extension.mjs": "export {};\n",
"extensions/orb/assets/preview.png": makePng(800, 400),
});
const targets = detectCanvasTargets(["plugins/orb/plugin.json"], { rootDir: root });
assert.deepEqual(targets.plugins, ["orb"]);
assert.deepEqual(targets.extensions, ["orb"]);
const report = await runCanvasSmokeTest({ rootDir: root, changedFiles: ["plugins/orb/plugin.json"], install: "never" });
assert.equal(report.status, "fail");
});
test("detectCanvasTargets uses base plugin manifests when deleted bundle manifests referenced extensions", async () => {
const root = makeRepo({
"extensions/daily-focus-board/extension.mjs": "export {};\n",
"extensions/daily-focus-board/assets/preview.png": makePng(800, 400),
"plugins/ember/plugin.json": {
$schema: PLUGIN_SCHEMA,
name: "ember",
description: "Ember bundle",
version: "1.0.0",
extensions: { "com.github.awesome-copilot": { extensions: ["./extensions/daily-focus-board"] } },
},
});
git(root, ["init"]);
git(root, ["config", "user.email", "test@example.com"]);
git(root, ["config", "user.name", "Test User"]);
git(root, ["add", "."]);
git(root, ["commit", "-m", "base"]);
const baseRef = git(root, ["rev-parse", "HEAD"]);
fs.rmSync(path.join(root, "plugins", "ember", "plugin.json"));
const targets = detectCanvasTargets(["plugins/ember/plugin.json"], { rootDir: root, baseRef });
assert.deepEqual(targets.extensions, ["daily-focus-board"]);
assert.deepEqual(targets.plugins, ["ember"]);
const report = await runCanvasSmokeTest({ rootDir: root, changedFiles: ["plugins/ember/plugin.json"], baseRef, install: "never" });
assert.equal(report.status, "fail");
assert.equal(report.extensions[0].id, "daily-focus-board");
assert.match(report.plugins[0].errors.join("\n"), /plugins\/ember\/plugin\.json is missing/);
});
test("deleted plugin manifest base read errors fail closed when a base ref is provided", async () => {
const root = makeRepo({});
const report = await runCanvasSmokeTest({
rootDir: root,
changedFiles: ["plugins/ember/plugin.json"],
baseRef: "missing-base",
install: "never",
});
assert.equal(report.status, "fail");
assert.match(report.plugins[0].errors.join("\n"), /cannot read deleted plugins\/ember\/plugin\.json from base missing-base/);
});
test("detectCanvasTargets unions base and head extension references for an edited bundle manifest", () => {
const manifest = (refs) => ({
$schema: PLUGIN_SCHEMA,
name: "ember",
description: "Ember bundle",
version: "1.0.0",
extensions: { "com.github.awesome-copilot": { extensions: refs } },
});
const root = makeRepo({
"extensions/daily-focus-board/extension.mjs": "export {};\n",
"extensions/daily-focus-board/assets/preview.png": makePng(800, 400),
"extensions/orb/extension.mjs": "export {};\n",
"extensions/orb/assets/preview.png": makePng(800, 400),
"plugins/ember/plugin.json": manifest(["./extensions/daily-focus-board"]),
});
git(root, ["init"]);
git(root, ["config", "user.email", "test@example.com"]);
git(root, ["config", "user.name", "Test User"]);
git(root, ["add", "."]);
git(root, ["commit", "-m", "base"]);
const baseRef = git(root, ["rev-parse", "HEAD"]);
fs.writeFileSync(
path.join(root, "plugins", "ember", "plugin.json"),
`${JSON.stringify(manifest(["./extensions/orb"]), null, 2)}\n`,
);
const targets = detectCanvasTargets(["plugins/ember/plugin.json"], { rootDir: root, baseRef });
assert.deepEqual(targets.extensions, ["daily-focus-board", "orb"]);
assert.deepEqual(targets.plugins, ["ember"]);
});
test("the comment stripper treats a slash after a postfix update as division", () => {
const source = [
'for (let i = 0; i < n; i++) {}',
'const r = x++ / y; const dep = require("left-pad");',
'const s = count-- / total; const dyn = import("./real.mjs");',
'const t = ++x / y;',
].join("\n");
assert.deepEqual(findRequireSpecifiers(source), ["left-pad"]);
assert.deepEqual(findDynamicImportSpecifiers(source), ["./real.mjs"]);
// A prefix update still allows a regex literal to follow.
assert.deepEqual(findRequireSpecifiers('const re = ++i, m = /require("hidden")/;'), []);
});
test("unsafe extension references in plugin manifests are rejected and never materialized", async () => {
assert.equal(isSafeExtensionId("orb"), true);
for (const id of ["..", "../x", "a/b", "", ".hidden"]) assert.equal(isSafeExtensionId(id), false, id);
const manifest = {
$schema: PLUGIN_SCHEMA,
name: "evil",
description: "evil",
version: "1.0.0",
extensions: { "com.github.awesome-copilot": { extensions: ["./extensions/../../x", "./extensions/ok"] } },
};
assert.deepEqual(unsafeExtensionRefs(manifest), ["./extensions/../../x"]);
const root = makeRepo({
"plugins/evil/plugin.json": manifest,
"extensions/ok/extension.mjs": "export {};\n",
"extensions/ok/assets/preview.png": makePng(800, 400),
});
const targets = detectCanvasTargets(["plugins/evil/plugin.json"], { rootDir: root });
assert.deepEqual(targets.plugins, ["evil"]);
assert.deepEqual(targets.extensions, ["ok"]);
const report = await runCanvasSmokeTest({ rootDir: root, changedFiles: ["plugins/evil/plugin.json"], install: "never" });
assert.equal(report.status, "fail");
const pluginErrors = report.plugins.flatMap((plugin) => plugin.errors).join("\n");
assert.match(pluginErrors, /\.\/extensions\/\.\.\/\.\.\/x/);
assert.notEqual(report.smoke.materialize.evil?.status, "pass");
});