From c93eda0c9afa093b322f94a46977c64b39c96c7b Mon Sep 17 00:00:00 2001 From: Alex Weininger Date: Fri, 23 Oct 2026 11:14:56 -0700 Subject: [PATCH] Add invocation counting with Canvas Authoring 0.1.2 Import the frozen production-candidate workflow artifact without rebuilding or changing customer documentation. Keep the historical receipt and verify current authoring releases using their versioned external receipts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/plugin/marketplace.json | 2 +- docs/canvas-authoring/0.1.2/SHA256SUMS | 27 +++++ docs/release-process.md | 7 ++ plugins/canvas-authoring/README.md | 101 ++++++++---------- .../extensions/canvas-authoring/extension.mjs | 3 + plugins/canvas-authoring/plugin.json | 2 +- .../references/toolkit/README.md | 56 ++++++++++ .../references/toolkit/auth.md | 34 ++++-- .../references/toolkit/provenance.json | 4 +- scripts/verify-plugin-marketplace.mjs | 9 +- test/canvas-release-receipts.test.mjs | 28 +++++ test/fixtures/marketplace.candidate.json | 2 +- test/plugin-marketplace.test.mjs | 66 +++++++++++- 13 files changed, 269 insertions(+), 72 deletions(-) create mode 100644 docs/canvas-authoring/0.1.2/SHA256SUMS create mode 100644 plugins/canvas-authoring/com.github.copilot/extensions/canvas-authoring/extension.mjs diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index b9a75b7..4d371f9 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -23,7 +23,7 @@ { "name": "canvas-authoring", "description": "Build canvas apps with the native create-canvas skill and a toolkit setup companion.", - "version": "0.1.1", + "version": "0.1.2", "source": "plugins/canvas-authoring" }, { diff --git a/docs/canvas-authoring/0.1.2/SHA256SUMS b/docs/canvas-authoring/0.1.2/SHA256SUMS new file mode 100644 index 0000000..b2fe40a --- /dev/null +++ b/docs/canvas-authoring/0.1.2/SHA256SUMS @@ -0,0 +1,27 @@ +e4f0de3656e1835a295b9a0963b35bb87af4163adf113b4248ea1cce90125c5c README.md +2c4eb2635f0c3ef0f81a2b1e7b4650933759a0d66c53210af078c27e1967f51c com.github.copilot/extensions/canvas-authoring/extension.mjs +d9a85d9c233bca2c8287b29dc0db92c4aa245789b31d0f2ede249bd078a78cb1 plugin.json +18f308efc70199eaf41fb6f52b85e5b5ceb21c790d91effa0c88f94dfb82d872 skills/create-canvas-app/SKILL.md +b5824155c9558d115a16a503de581cc001194f8414057292ab5cc04e14792ea0 skills/create-canvas-app/references/toolkit.md +d25deed12d803f6c973597348627b1939e2d19bbc28fda83c93ea0cde44702da skills/create-canvas-app/references/toolkit/LICENSE +40d66d89c564be6bcb3e6741deed224417d3bc051cd3585ec665aa7a2eeae6a8 skills/create-canvas-app/references/toolkit/README.md +b22b317cd93b5fb40636fbaccba2750501faf41b54bcf9c6bb971c5355c9e23b skills/create-canvas-app/references/toolkit/auth.md +70d8d6683e31c01ce8538d339f676901ac1332710dc19ff8afbfa443b0ae399b skills/create-canvas-app/references/toolkit/examples/README.md +18ea90a17fb7d39104f343b3a53079dfed536bdd9a6dafc71321de5158be61bb skills/create-canvas-app/references/toolkit/examples/custom-fetch-client.mjs +3d81ea6de1551da83f008251f204e06e736e9655aed450765b4f416a834d1eb8 skills/create-canvas-app/references/toolkit/examples/resource-groups.mjs +5809596d28119f393975235a44b5784d95a82354eb56d84c2adcbba0f2ab34ce skills/create-canvas-app/references/toolkit/examples/run-read-only.mjs +e9adf9797984f6b23f2f7b0807898a551f2ca5ab122f5f644033e705e2efa1cc skills/create-canvas-app/references/toolkit/examples/sdk-client.mjs +64a244c012e41bc8da6089575e1cf531d30a113ad931f464402ba02226c3465b skills/create-canvas-app/references/toolkit/provenance.json +90d339293305a4131381db2170bda0b68fd3dba63a78259f21d373b096bfae5e skills/create-canvas-app/references/toolkit/quickstart.md +721004c52d2ce05d5da548d98dbc581da2198840b4e481deed3b0ddad21aad8d skills/create-canvas-app/scripts/setup-toolkit.mjs +2e517e47f4f537dfa4362abe8dac4fe754dfd076459231526298997c3e592b2e skills/create-canvas-app/templates/azure-resource-groups/README.md +67451cd55721f065083817d8a6cf0e1068b54a828e000cf00627fe25fe44ca6f skills/create-canvas-app/templates/azure-resource-groups/package.json +3bcf34bcc5dd31561430a860e289e35a641d7e328e7cf20b08ee4fc819320c15 skills/create-canvas-app/templates/azure-resource-groups/scripts/check-entry.mjs +94397d5f1426d39ba319d5f03dfd6c03b8c9e2837cb04438c897f687eeecdd9a skills/create-canvas-app/templates/azure-resource-groups/scripts/smoke.mjs +8ad79093b4bcc4aa319918ecd4c8258d729a791439bdd1e7c277458aee5763cd skills/create-canvas-app/templates/azure-resource-groups/src/browser/app.css +99943892a9a29ec75aed93150b208f9d1ff014d0d3649310158399c3043ee3e6 skills/create-canvas-app/templates/azure-resource-groups/src/browser/app.mjs +25cef53f8e5218625b86507312b43942ad9332141037f21dbaa590cc783223e8 skills/create-canvas-app/templates/azure-resource-groups/src/browser/index.html +16dfd1d2bd48e3563e84597337b97e87d6ed20960336ac7249fa131ed641dd54 skills/create-canvas-app/templates/azure-resource-groups/src/canvas.mjs +62b66ccbf5d74cd8b59c53a3a5ba74772e58f51893a0199c92e36c05db127b12 skills/create-canvas-app/templates/azure-resource-groups/src/domain.mjs +f8f66cbd63fa3a07427676085b64e2684f60398e1459e7a3927b2104d8fb8186 skills/create-canvas-app/templates/azure-resource-groups/src/fixture.mjs +55e688f04faa2d6bf4587f6a5f9bdd445494c2e5b13495eef58ef2004497daa4 skills/create-canvas-app/templates/azure-resource-groups/tests/app.test.mjs diff --git a/docs/release-process.md b/docs/release-process.md index ba4c8d0..bbac4fb 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -74,6 +74,13 @@ verifier must check that the earlier product release is an ancestor, that the skill-only package tree and full checksum inventory match its tag, and that it has no extension or canvas. Its package root may be `plugins/` with `plugin.json` directly in that directory rather than the canvas layout. +Canvas Authoring's current full-file receipt lives outside the installable +payload at `docs/canvas-authoring//SHA256SUMS`, selected from +the package's marketplace version. Preserve the historical 0.1.1 receipt at +`docs/canvas-authoring/SHA256SUMS` and verify it against its immutable tag. +The authoring companion may include its reviewed canvas-less session telemetry +provider under `com.github.copilot/extensions/canvas-authoring/`; this does not +add a canvas or an `extensions` field to its root plugin manifest. For a subsequent combined canvas and skill-only patch release, all new source-qualified tags point to one later approved merge commit descending from the previous builder release. Verify the prior canvas and builder diff --git a/plugins/canvas-authoring/README.md b/plugins/canvas-authoring/README.md index a645a73..cb5be4f 100644 --- a/plugins/canvas-authoring/README.md +++ b/plugins/canvas-authoring/README.md @@ -1,83 +1,76 @@ # Canvas authoring plugin -Create a canvas app with the Microsoft Canvas Toolkit. This skill-only -companion adds toolkit setup and a counter or read-only Azure resource-group -starter to the host's native `create-canvas` workflow. Installing it does not -open a canvas. - -## Requirements - -- A canvas-capable GitHub Copilot host with its installed `create-canvas` skill. - The plugin does not replace or install that host skill. -- Node.js 22+ (24 recommended) and npm. -- The exact `@microsoft/canvas-toolkit@0.1.0-preview.2` release exporting - `@microsoft/canvas-toolkit/build`, or an approved local - `.tgz` containing that API. Do not treat a missing `/build` export as success. -- For live Azure reads: Azure CLI 2.61+ and permission to read the subscription. +Create a canvas app with the Microsoft Canvas Toolkit. This **skill-only +companion** gives the host's native `create-canvas` workflow toolkit setup +and a counter or read-only Azure resource-group starter; installing it does +not open a canvas. ## Install -Version 0.1.1 is published at the immutable -`canvas-authoring-v0-1-1-8af10f8` tag, and `canvas-authoring-latest` points to -that release. Add the production marketplace and install the plugin: +If your GitHub Copilot host shows the **awesome-copilot** marketplace, open +**Customize → Plugins** and install **canvas-authoring**. Its +[listed 0.1.1 package](https://github.com/microsoft/azure-dev-tools/tree/a6d394bbaa6fb1dc0151257a85cbac0de772b138/plugins/canvas-authoring) +is a skill-only companion, not a canvas extension. If the marketplace is +missing, add it first: ```sh -copilot plugin marketplace add microsoft/azure-dev-tools -copilot plugin install canvas-authoring@azure-dev-tools +copilot plugin marketplace add github/awesome-copilot +copilot plugin install canvas-authoring@awesome-copilot ``` -To install the exact tagged package: - -```sh -git clone --depth 1 --branch canvas-authoring-v0-1-1-8af10f8 https://github.com/microsoft/azure-dev-tools.git canvas-authoring-plugin -copilot --plugin-dir "./canvas-authoring-plugin/plugins/canvas-authoring" -``` - -To try another approved local export: +For an approved local export, run: ```sh copilot --plugin-dir "/plugins/canvas-authoring" ``` -Use the **exported plugin**, not the source folder `build/canvas-authoring/`. -This page describes the installed plugin; its bundled documentation links -resolve after export. Installing it adds a skill, not a running canvas app. +Install the exported plugin, not this source folder. -## Create an app +You need a canvas-capable Copilot host with the native `create-canvas` skill, +Node.js 22+, and an available toolkit package; the Azure starter also needs +Azure CLI and subscription read access. See [requirements](#requirements). -Ask your agent: +## Try it -```text -Build a read-only Azure canvas that lists resource groups. -Use the native create-canvas skill and the create-canvas-app companion's -Azure starter. Help me choose a compatible toolkit version. -``` - -The agent creates the native scaffold, generates the source app, and connects -the copied host entry. You choose the project location and toolkit package. -For a non-Azure example, ask for the counter starter. +Ask your agent to **Build a read-only Azure canvas that lists resource +groups. Use the native create-canvas skill and the create-canvas-app +companion's Azure starter.** Choose a compatible toolkit package and project +location when prompted. For a non-Azure example, ask for the counter starter. ## What you can do - Generate a native canvas scaffold with a working counter starter. -- Start a read-only Azure resource-group viewer with explicit subscription - choice. -- Build and smoke-test the generated app before installing its complete - output. +- Start a read-only Azure resource-group viewer with explicit subscription choice. +- Build and smoke-test the generated app before installing its complete output. ## Prompts to try -```text -Use the native create-canvas skill and the create-canvas-app companion to build a counter canvas. -``` +> Use the native create-canvas skill and the create-canvas-app companion to build a counter canvas. -```text -Build a read-only Azure canvas that lists resource groups. Use the native create-canvas skill and the create-canvas-app companion's Azure starter. -``` +> Build a read-only Azure canvas that lists resource groups. Use the native create-canvas skill and the create-canvas-app companion's Azure starter. -```text -Use the create-canvas-app companion to help me choose a compatible toolkit version before creating files. -``` +## Requirements + +- A canvas-capable GitHub Copilot host with its installed `create-canvas` skill. + The plugin does not replace or install that host skill. +- Node.js 22+ (24 recommended) and npm. +- The exact `@microsoft/canvas-toolkit@0.1.0-preview.2` release exporting + `@microsoft/canvas-toolkit/build`, once published, or an approved local + `.tgz` containing that API. Do not treat a missing `/build` export as success. +- For live Azure reads: Azure CLI 2.61+ and permission to read the subscription. + +## Usage data + +The plugin counts how often its `create-canvas-app` skill is invoked. Each +count records the skill name, how it was invoked, the plugin name and version, +and a timestamp. Usage telemetry may include a pseudonymous account identifier +to understand usage over time; it is retained for up to 30 days. The plugin +does not send prompts, project files or source code. + +## Create an app + +The agent creates the native scaffold, generates the source app, and connects +the copied host entry. You choose the project location and toolkit package. Setup accepts one exact npm version or one local tarball. It does not install dependencies or contact Azure, and it refuses existing output directories diff --git a/plugins/canvas-authoring/com.github.copilot/extensions/canvas-authoring/extension.mjs b/plugins/canvas-authoring/com.github.copilot/extensions/canvas-authoring/extension.mjs new file mode 100644 index 0000000..1f7815b --- /dev/null +++ b/plugins/canvas-authoring/com.github.copilot/extensions/canvas-authoring/extension.mjs @@ -0,0 +1,3 @@ +import { createRequire as __canvasCreateRequire } from "node:module"; +const require = __canvasCreateRequire(import.meta.url); +import{joinSession}from"@github/copilot-sdk/extension";import{existsSync}from"node:fs";import path2 from"node:path";import{fileURLToPath}from"node:url";import{randomUUID}from"node:crypto";import{execFile}from"node:child_process";var ID=/^[a-z][a-z0-9_.-]{0,79}$/;var VERSION=/^[a-zA-Z0-9][a-zA-Z0-9.+_-]{0,39}$/;var SOURCES=new Set(["model","panel","system","unknown"]);var CONTROL_TYPES=new Set(["button","link","select","input","textarea","details","other"]);var INTERACTIONS=new Set(["click","change","submit"]);var FAILURE_CODES=Object.freeze({succeeded:"",failed:"action_error",rejected:"action_rejected",cancelled:"action_cancelled"});var RESERVED=new Set(["__proto__","constructor","prototype"]);var USAGE_ENVELOPE_SCHEMA_VERSION=2;var DEFAULT_REQUEST_TIMEOUT_MS=1e4;var PUBLIC_CANVAS_USAGE_SERVICE=Object.freeze({serviceId:"public-canvas-product-usage",schemaVersion:USAGE_ENVELOPE_SCHEMA_VERSION,dataClassification:"pseudonymous_product_usage",endpoint:"https://canvas-metrics-pilot-0b8944-add3hsghhddcbxe9.b01.azurefd.net/api/events"});var PUBLIC_CANVAS_USAGE_ENDPOINT=PUBLIC_CANVAS_USAGE_SERVICE.endpoint;var GITHUB_USER_ID=/^[1-9][0-9]{0,19}$/;function githubCliEnvironment(source){const env={...source};for(const key of Object.keys(env)){if(["GH_TOKEN","GITHUB_TOKEN"].includes(key.toUpperCase()))delete env[key]}return env}function runFile(command,args,options,execFileImpl){return new Promise((resolve,reject)=>{execFileImpl(command,args,options,(error,stdout)=>{if(error)reject(error);else resolve(stdout)})})}async function resolveGitHubCliUserId({signal,env=process.env,execFileImpl=execFile}={}){if(typeof execFileImpl!=="function")throw new TypeError("GitHub CLI executor must be a function.");let stdout;try{stdout=await runFile("gh",["api","--hostname","github.com","user","--jq",".id"],{env:githubCliEnvironment(env),signal,timeout:5e3,maxBuffer:1024,windowsHide:true},execFileImpl)}catch{throw new Error("GitHub identity is unavailable.")}const githubUserId=String(stdout).trim();if(!GITHUB_USER_ID.test(githubUserId))throw new Error("GitHub identity is unavailable.");return githubUserId}function identifier(value,label,pattern=ID){if(typeof value!=="string"||!pattern.test(value)||RESERVED.has(value)){throw new TypeError(`${label} must be a bounded, code-defined identifier.`)}return value}function record(value,label){if(!value||typeof value!=="object"||![Object.prototype,null].includes(Object.getPrototypeOf(value))){throw new TypeError(`${label} must be a plain record.`)}return value}function integer(value,min,max,label){if(!Number.isSafeInteger(value)||valuemax){throw new TypeError(`${label} must be an integer from ${min} to ${max}.`)}return value}function endpoint(value){try{const url=new URL(value);if(url.protocol==="https:"&&!url.username&&!url.password&&!url.search&&!url.hash)return url.href}catch{}throw new TypeError("Telemetry endpoint must be credential-free HTTPS without a query or fragment.")}function createUsageMetrics(options,skillContract){const{contract,canvasVersion,releaseChannel="development",enabled=false,endpoint:destination,audience,getAccessToken,transport,fetchImpl=globalThis.fetch,getGitHubUserId,now=Date.now,randomId=randomUUID,onDiagnostic,batchSize=20,maxQueueSize=100,flushIntervalMs=1e3,requestTimeoutMs=DEFAULT_REQUEST_TIMEOUT_MS,closeTimeoutMs=DEFAULT_REQUEST_TIMEOUT_MS}=options;if(contract!==void 0){record(contract,"contract");if(["canvasId","host","actionUsage","controls"].some(key=>Object.hasOwn(options,key))){throw new TypeError("Contract identity and registries cannot be overridden.")}}const canvasId=contract?.canvasId??options.canvasId;const host=contract?.host??options.host??"copilot_app";const actionUsage=contract?.actions??options.actionUsage??{};const controls=contract?.controls??options.controls??{};identifier(canvasId,"canvasId");identifier(canvasVersion,"canvasVersion",VERSION);if(!["copilot_app","mcp_app"].includes(host))throw new TypeError("Unsupported telemetry host.");if(contract!==void 0){if(!Array.isArray(contract.versions)||!contract.versions.length||contract.versions.some(version=>typeof version!=="string"||!VERSION.test(version))){throw new TypeError("Contract versions must be bounded version identifiers.")}if(!contract.versions.includes(canvasVersion)){throw new TypeError("Canvas version is not registered by the telemetry contract.")}}if(!["development","internal","preview","stable"].includes(releaseChannel)){throw new TypeError("Unsupported telemetry release channel.")}if(typeof enabled!=="boolean")throw new TypeError("enabled must be a boolean.");const url=destination===void 0&&(!enabled||transport!==void 0)?void 0:endpoint(destination);for(const[name,fn]of Object.entries({now,randomId})){if(typeof fn!=="function")throw new TypeError(`${name} must be a function.`)}if(transport!==void 0&&typeof transport!=="function")throw new TypeError("transport must be a function.");if(getAccessToken!==void 0&&typeof getAccessToken!=="function")throw new TypeError("getAccessToken must be a function.");if(enabled&&!transport&&typeof fetchImpl!=="function")throw new TypeError("fetchImpl must be a function.");if(getGitHubUserId!==void 0&&typeof getGitHubUserId!=="function"){throw new TypeError("getGitHubUserId must be a function.")}if(audience!==void 0&&(typeof audience!=="string"||!audience.trim()||audience.length>512||/[\r\n]/.test(audience)))throw new TypeError("audience must be a bounded nonempty string.");if(onDiagnostic!==void 0&&typeof onDiagnostic!=="function")throw new TypeError("onDiagnostic must be a function.");integer(batchSize,1,100,"batchSize");integer(maxQueueSize,batchSize,1e3,"maxQueueSize");integer(flushIntervalMs,10,6e4,"flushIntervalMs");integer(requestTimeoutMs,10,3e4,"requestTimeoutMs");integer(closeTimeoutMs,10,3e4,"closeTimeoutMs");const actions=new Map(Object.entries(record(actionUsage,"actionUsage")).map(([name,entry])=>{identifier(name,"action name");record(entry,"action usage");identifier(entry.featureId,"featureId");identifier(entry.featureArea,"featureArea");if(!["intentional","automatic","excluded"].includes(entry.usageClass)||typeof entry.mutates!=="boolean"){throw new TypeError("Action usage requires a supported usageClass and boolean mutates.")}return[name,Object.freeze({featureId:entry.featureId,featureArea:entry.featureArea,usageClass:entry.usageClass,mutates:entry.mutates})]}));const controlRegistry=new Map(Object.entries(record(controls,"controls")).map(([id,type])=>{identifier(id,"control ID");if(!CONTROL_TYPES.has(type))throw new TypeError("Unsupported telemetry control type.");return[id,type]}));const queue=[];const stats={delivered:0,dropped:0,invalid:0};const reported=new Set;const shutdown2=new AbortController;let timer,activeFlush,activeController,closePromise;let inFlight=0,closing=false;let resolvedGitHubUserId;function diagnose(code){if(reported.has(code))return;reported.add(code);const warn2=message=>{try{process.emitWarning(message,{code:"CANVAS_USAGE_METRICS"})}catch{}};if(onDiagnostic){const reportFailure=()=>warn2("Canvas telemetry diagnostic callback failed.");try{Promise.resolve(onDiagnostic(Object.freeze({code}))).catch(reportFailure)}catch{reportFailure()}}else{warn2(`Canvas telemetry: ${code}.`)}}function invalid(){stats.invalid+=1;diagnose("invalid_event");return false}async function githubIdentity(signal){if(resolvedGitHubUserId)return resolvedGitHubUserId;try{const value=await getGitHubUserId(signal);if(signal.aborted)return;if(typeof value==="string"&&GITHUB_USER_ID.test(value)){resolvedGitHubUserId=value;return value}}catch{}diagnose("identity_unavailable")}function schedule(){if(closing||timer||activeFlush||!queue.length)return;timer=setTimeout(()=>{timer=void 0;void flush()},queue.length>=batchSize?0:flushIntervalMs);timer.unref?.()}function enqueue(eventName,fields={}){if(!enabled||closing)return false;try{const eventId=randomId();if(typeof eventId!=="string"||!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(eventId)){return invalid()}const event=Object.freeze({...skillContract?{schemaVersion:2,pluginName:skillContract.pluginName,pluginVersion:canvasVersion}:{schemaVersion:1,canvasId,canvasVersion},host,releaseChannel,timestamp:new Date(now()).toISOString(),eventId,eventName,invocationSource:"unknown",...fields});const overflow=queue.length===maxQueueSize;if(overflow){queue.shift();stats.dropped+=1}queue.push(event);if(overflow)diagnose("queue_overflow");if(queue.length>=batchSize&&!activeFlush)void flush();else schedule();return true}catch{stats.dropped+=1;diagnose("event_construction_failed");return false}}async function deliver(batch){const controller=new AbortController;activeController=controller;const stop=()=>controller.abort(shutdown2.signal.reason);const deadline=setTimeout(()=>controller.abort("delivery_timeout"),requestTimeoutMs);deadline.unref?.();shutdown2.signal.addEventListener("abort",stop,{once:true});if(shutdown2.signal.aborted)stop();let onAbort;const aborted=new Promise(resolve=>{onAbort=()=>resolve(controller.signal.reason);controller.signal.addEventListener("abort",onAbort,{once:true});if(controller.signal.aborted)onAbort()});const request=Promise.resolve().then(async()=>{if(controller.signal.aborted)return controller.signal.reason;if(transport){await transport(Object.freeze(batch),{signal:controller.signal});return}const headers={"Content-Type":"application/json"};if(getAccessToken){const credential=await getAccessToken(audience,controller.signal);if(controller.signal.aborted)return controller.signal.reason;const token=typeof credential==="string"?credential:credential?.accessToken;if(typeof token!=="string"||!token.trim()||/[\r\n]/.test(token))return"delivery_failed";headers.Authorization=`Bearer ${token}`}const githubUserId=getGitHubUserId?await githubIdentity(controller.signal):void 0;if(getGitHubUserId){if(controller.signal.aborted)return controller.signal.reason}const response=await fetchImpl(url,{method:"POST",headers,body:JSON.stringify({schemaVersion:USAGE_ENVELOPE_SCHEMA_VERSION,...githubUserId?{githubUserId}:{},events:batch}),signal:controller.signal,redirect:"error",credentials:"omit"});const accepted=response?.ok;await response?.body?.cancel();return accepted?void 0:"delivery_rejected"}).catch(()=>"delivery_failed");try{const failure=await Promise.race([request,aborted]);if(failure)diagnose(failure);return{delivered:failure?0:batch.length,dropped:failure?batch.length:0}}finally{clearTimeout(deadline);shutdown2.signal.removeEventListener("abort",stop);controller.signal.removeEventListener("abort",onAbort);if(activeController===controller)activeController=void 0}}function flush(){if(activeFlush)return activeFlush;clearTimeout(timer);timer=void 0;if(!queue.length)return Promise.resolve({delivered:0,dropped:0});const batch=queue.splice(0,batchSize);inFlight=batch.length;activeFlush=deliver(batch).then(result=>{stats.delivered+=result.delivered;stats.dropped+=result.dropped;return result}).finally(()=>{inFlight=0;activeFlush=void 0;schedule()});return activeFlush}function feature(actionName,invocationSource){const usage2=actions.get(actionName);if(!usage2||!SOURCES.has(invocationSource))return invalid();if(usage2.usageClass==="excluded")return false;return{...usage2,actionName,invocationSource}}function cancelPending(){activeController?.abort("delivery_cancelled")}function close({flush:drain=true}={}){if(!drain){closing=true;stats.dropped+=queue.length;queue.length=0;shutdown2.abort("delivery_cancelled");cancelPending()}if(closePromise)return closePromise;closing=true;clearTimeout(timer);timer=void 0;closePromise=(async()=>{const deadline=setTimeout(()=>shutdown2.abort("shutdown_timeout"),closeTimeoutMs);deadline.unref?.();try{while((queue.length||activeFlush)&&!shutdown2.signal.aborted)await flush();if(activeFlush)await activeFlush;stats.dropped+=queue.length;queue.length=0;return{delivered:stats.delivered,dropped:stats.dropped}}finally{clearTimeout(deadline)}})();return closePromise}const lifecycle={get enabled(){return enabled&&!closing},getStats:()=>Object.freeze({...stats,queued:queue.length,inFlight,closed:closing}),flush,close,cancelPending};if(skillContract){const skills=new Set(skillContract.skills);const workflowId=value=>value===""||typeof value==="string"&&/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);return Object.freeze({...lifecycle,get enabled(){return enabled&&!closing},trackSkillInvoked({skillName,skillTrigger,workflowId:id=""}={}){if(!enabled||closing)return false;if(!skills.has(skillName)||!["user-invoked","agent-invoked","context-load","file-read","unknown"].includes(skillTrigger)||!workflowId(id))return invalid();return enqueue("skill_invoked",{skillName,skillTrigger,workflowId:id,invocationSource:"system"})},trackWorkflowCompleted(actionName,{workflowId:id,skillNames,outcome,durationMs}={}){if(!enabled||closing)return false;const fields=feature(actionName,"system");if(!fields)return false;if(!workflowId(id)||!id||!Array.isArray(skillNames)||skillNames.length>skills.size||skillNames.some(name=>!skills.has(name))||new Set(skillNames).size!==skillNames.length||typeof outcome!=="string"||!Object.hasOwn(FAILURE_CODES,outcome)||!Number.isFinite(durationMs))return invalid();return enqueue("skill_workflow_completed",{...fields,canvasId:skillContract.workflows[actionName].canvasId,workflowId:id,skillNames:Object.freeze([...skillNames].sort()),outcome,durationMs:Math.max(0,Math.min(Math.round(durationMs),36e5)),failureCode:FAILURE_CODES[outcome]})}})}return Object.freeze({...lifecycle,get enabled(){return enabled&&!closing},hasAction:name=>actions.has(name),trackCanvasOpened(invocationSource="unknown"){if(!enabled||closing)return false;if(!SOURCES.has(invocationSource))return invalid();return enqueue("canvas_opened",{invocationSource})},trackUiInteraction(input={}){if(!enabled||closing)return false;if(!input||!INTERACTIONS.has(input.interactionType)||!controlRegistry.has(input.controlId)||controlRegistry.get(input.controlId)!==input.controlType)return invalid();return enqueue("ui_interaction",{invocationSource:"panel",interactionType:input.interactionType,controlId:input.controlId,controlType:input.controlType})},trackFeatureInvoked(actionName,invocationSource="unknown"){if(!enabled||closing)return false;const fields=feature(actionName,invocationSource);return fields?enqueue("feature_invoked",fields):false},trackFeatureCompleted(actionName,{outcome,durationMs,invocationSource="unknown",failureCode}={}){if(!enabled||closing)return false;const fields=feature(actionName,invocationSource);if(!fields)return false;if(typeof outcome!=="string"||!Object.hasOwn(FAILURE_CODES,outcome)||!Number.isFinite(durationMs)||failureCode!==void 0&&failureCode!==FAILURE_CODES[outcome])return invalid();return enqueue("feature_completed",{...fields,outcome,durationMs:Math.max(0,Math.min(Math.round(durationMs),36e5)),failureCode:FAILURE_CODES[outcome]})}})}function createSkillUsageMetrics({contract,pluginVersion,...options}={}){record(contract,"skill contract");identifier(contract.pluginName,"pluginName");if(!Array.isArray(contract.skills)||!contract.skills.length||contract.skills.length>64||new Set(contract.skills).size!==contract.skills.length)throw new TypeError("Register 1-64 unique skills.");for(const name of contract.skills)identifier(name,"skill name");const workflows=record(contract.workflows,"workflows");for(const entry of Object.values(workflows)){record(entry,"workflow");identifier(entry.canvasId,"workflow canvasId")}if(["canvasId","canvasVersion","host","actionUsage","controls"].some(key=>Object.hasOwn(options,key))){throw new TypeError("Skill contract identity cannot be overridden.")}const reviewed=Object.freeze({pluginName:contract.pluginName,skills:Object.freeze([...contract.skills]),workflows:Object.freeze(Object.fromEntries(Object.entries(workflows).map(([name,entry])=>[name,Object.freeze({...entry})])))});return createUsageMetrics({...options,contract:{canvasId:contract.pluginName,host:contract.host,versions:contract.versions,actions:workflows,controls:{}},canvasVersion:pluginVersion},reviewed)}function createPublicSkillUsageMetrics(options={}){for(const key of["enabled","endpoint","audience","getAccessToken","transport","now","randomId"]){if(Object.hasOwn(options,key))throw new TypeError(`Public skill telemetry does not accept ${key}.`)}return createSkillUsageMetrics({...options,enabled:true,endpoint:PUBLIC_CANVAS_USAGE_SERVICE.endpoint,getGitHubUserId:options.getGitHubUserId??resolveGitHubCliUserId})}import{randomUUID as randomUUID2}from"node:crypto";import path from"node:path";function createSkillUsageObserver({contract,metrics:metrics2,skillPaths={},now=Date.now,randomId=randomUUID2,onDiagnostic}={}){const skills=new Set(contract.skills);const files=new Map(Object.entries(skillPaths).filter(([name])=>skills.has(name)).map(([name,file])=>[path.resolve(file),name]));const seen=new Set,pendingReads=new Map,workflows=new Map;let disposed=false;const reported=new Set;function diagnose(code){if(reported.has(code))return;reported.add(code);try{Promise.resolve(onDiagnostic?.({code})).catch(()=>console.warn("Skill telemetry diagnostic callback failed."));if(!onDiagnostic)console.warn(`Skill telemetry: ${code}.`)}catch{console.warn("Skill telemetry diagnostic callback failed.")}}function safe(run){try{return run()}catch{diagnose("skill_observation_failed");return false}}function remember(id){if(typeof id!=="string"||!id||id.length>256||seen.has(id))return false;seen.add(id);if(seen.size>500)seen.delete(seen.values().next().value);return true}function record2(skillName,skillTrigger,event,candidates){if(!skills.has(skillName))return false;const eligible=candidates.filter(item=>workflows.get(item.key)===item&&Date.parse(event.timestamp)>=item.startedAt);const workflow=eligible.length===1&&!event.agentId&&skillTrigger!=="context-load"?eligible[0]:null;const recorded=metrics2.trackSkillInvoked({skillName,skillTrigger,workflowId:workflow?.id??""});if(recorded&&workflow)workflow.skills.add(skillName);return recorded}return Object.freeze({beginWorkflow(key,actionName){if(disposed||!metrics2.enabled)return false;return safe(()=>{if(typeof key!=="string"||!key||key.length>256||!Object.hasOwn(contract.workflows,actionName))return false;if(workflows.has(key))return true;if(workflows.size>=16){diagnose("skill_workflow_limit");return false}workflows.set(key,{key,actionName,id:randomId(),startedAt:now(),skills:new Set});return true})},completeWorkflow(key,outcome){if(disposed||!metrics2.enabled)return false;return safe(()=>{const workflow=workflows.get(key);if(!workflow||!["succeeded","failed","rejected","cancelled"].includes(outcome))return false;workflows.delete(key);return metrics2.trackWorkflowCompleted(workflow.actionName,{workflowId:workflow.id,skillNames:[...workflow.skills],outcome,durationMs:now()-workflow.startedAt})})},observe(event){if(disposed||!metrics2.enabled)return false;return safe(()=>{if(!event||!["skill.invoked","tool.execution_start","tool.execution_complete"].includes(event.type))return false;if(!remember(event.id))return false;const data=event.data;if(!data||typeof data!=="object")return false;if(event.type==="skill.invoked"){const name=data.name;if(data.pluginName?data.pluginName!==contract.pluginName:files.get(typeof data.path==="string"?path.resolve(data.path):"")!==name)return false;const trigger=["user-invoked","agent-invoked","context-load"].includes(data.trigger)?data.trigger:"unknown";return record2(name,trigger,event,[...workflows.values()])}if(event.type==="tool.execution_start"){if(!["view","Read","read_file"].includes(data.toolName)||typeof data.toolCallId!=="string"||data.toolCallId.length>256)return false;const file=data.arguments?.path??data.arguments?.filePath??data.arguments?.file_path;const name=files.get(typeof file==="string"?path.resolve(file):"");if(!name)return false;if(pendingReads.size>=100){diagnose("skill_read_limit");return false}pendingReads.set(data.toolCallId,{name,candidates:[...workflows.values()].filter(item=>Date.parse(event.timestamp)>=item.startedAt),agentId:event.agentId});return true}const read=pendingReads.get(data.toolCallId);pendingReads.delete(data.toolCallId);return read&&data.success===true?record2(read.name,"file-read",{...event,agentId:read.agentId||event.agentId},read.candidates):false})},dispose(){disposed=true;seen.clear();pendingReads.clear();workflows.clear()}})}var CANVAS_AUTHORING_SKILL_USAGE_CONTRACT=Object.freeze({pluginName:"canvas-authoring",host:"copilot_app",skills:Object.freeze(["create-canvas-app"]),workflows:Object.freeze({})});var warn=({code})=>console.warn(`[skill-usage] ${code}`);function skillUsageContract(pluginVersion){return Object.freeze({...CANVAS_AUTHORING_SKILL_USAGE_CONTRACT,versions:Object.freeze([pluginVersion])})}function locateSkills(entryUrl,names=CANVAS_AUTHORING_SKILL_USAGE_CONTRACT.skills){const folder=path2.dirname(fileURLToPath(entryUrl));for(const root of[path2.resolve(folder,"../skills"),path2.resolve(folder,"../../../skills")]){const found=Object.fromEntries(names.map(name=>[name,path2.join(root,name,"SKILL.md")]).filter(([,file])=>existsSync(file)));if(Object.keys(found).length)return found}return{}}function createSkillMetrics({pluginVersion,env=process.env,onDiagnostic=warn}={}){const contract=skillUsageContract(pluginVersion);const disabled=()=>createSkillUsageMetrics({contract,pluginVersion,enabled:false});if(env.CANVAS_AUTHORING_TEST_MODE==="1")return disabled();try{return createPublicSkillUsageMetrics({contract,pluginVersion,releaseChannel:"preview",onDiagnostic})}catch{console.warn("[skill-usage] invalid_configuration");return disabled()}}function observeSkillUsage({session,metrics:metrics2,pluginVersion,skillPaths={},onDiagnostic=warn}){let observer,stop;if(!metrics2.enabled)return Object.freeze({dispose(){}});try{if(typeof session?.on!=="function")throw new TypeError("Session events are unavailable.");observer=createSkillUsageObserver({contract:skillUsageContract(pluginVersion),metrics:metrics2,skillPaths,onDiagnostic});stop=session.on(event=>{observer.observe(event)})}catch{console.warn("[skill-usage] session_observation_unavailable")}return Object.freeze({dispose(){try{stop?.()}catch{console.warn("[skill-usage] unsubscribe_failed")}observer?.dispose()}})}var PLUGIN_VERSION=true?"0.1.2":"0.0.0-source";var metrics=createSkillMetrics({pluginVersion:PLUGIN_VERSION});var usage=Object.freeze({dispose(){}});async function shutdown(){usage.dispose();await metrics.close()}for(const signal of["SIGTERM","SIGINT","disconnect"]){process.once(signal,()=>{void shutdown().finally(()=>process.exit(0))})}try{const session=await joinSession({});usage=observeSkillUsage({session,metrics,pluginVersion:PLUGIN_VERSION,skillPaths:locateSkills(import.meta.url)})}catch{console.warn("[skill-usage] session_unavailable")} diff --git a/plugins/canvas-authoring/plugin.json b/plugins/canvas-authoring/plugin.json index 19da0ed..6b949ee 100644 --- a/plugins/canvas-authoring/plugin.json +++ b/plugins/canvas-authoring/plugin.json @@ -1,6 +1,6 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "canvas-authoring", - "version": "0.1.1", + "version": "0.1.2", "description": "A toolkit setup companion to the GitHub Copilot app's installed create-canvas skill, with an Azure starter, safe asset packaging and a focused browser smoke." } diff --git a/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/README.md b/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/README.md index f60f450..00d6ae7 100644 --- a/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/README.md +++ b/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/README.md @@ -270,6 +270,52 @@ fields. The public service may add a server-derived GitHub user key; no raw ID is persisted and lower-level/custom collectors add no identity unless their caller explicitly configures an identity resolver. +### Skill use and workflow completion + +The shared skill API is independent of any canvas product. A product defines a +`SkillUsageContract`: plugin name, host, versions, skill names, and reviewed +workflow-to-canvas mappings. Register the same contract in the gateway before +collection. Other canvases do not need to change. + +```js +import { createPublicSkillUsageMetrics } from "@microsoft/canvas-toolkit/telemetry"; +import { createSkillUsageObserver } from "@microsoft/canvas-toolkit/skill-telemetry"; + +const metrics = createPublicSkillUsageMetrics({ + contract: PRODUCT_SKILL_USAGE_CONTRACT, pluginVersion: PRODUCT_VERSION, +}); +const observer = createSkillUsageObserver({ + contract: PRODUCT_SKILL_USAGE_CONTRACT, metrics, + // Optional exact local SKILL.md paths for development-mode reads. + skillPaths: registeredSkillPaths, +}); +const unsubscribe = session.on(event => observer.observe(event)); + +// Use the controller's local run key. It never leaves the observer. +observer.beginWorkflow(localRunKey, "registered_action"); +// Call only after the controller accepts the terminal report. +observer.completeWorkflow(localRunKey, "succeeded"); +// At shutdown: unsubscribe(); observer.dispose(); await metrics.close(); +``` + +`skill_invoked` records live `skill.invoked` events or successful reads of exact +registered skill files. Catalog loading and prompt text do not count. Do not +replay session history. `skill_workflow_completed` records the outcome, duration, +and skills observed during one active workflow. Empty skill lists are valid. +Context loads, subagent events, and events with ambiguous workflow ownership +have no workflow association. A fresh UUID joins these two events; it is not a +session, device, or controller ID. Paths, event content, and local run keys stay +local. Association does not prove that a skill caused a better result. + +Skill collection uses its own bounded queue and the existing delivery limits. +An older gateway can reject skill batches without dropping legacy canvas +batches. Neither collector waits in the action path. Failure produces a fixed +diagnostic, not a panel error or retry loop. Data stays in process memory; lost +events are not restored. Use `createSkillUsageMetrics` for a caller-owned policy +or custom transport, with the same default-off behavior as the canvas API. + +### Canvas action instrumentation + `usageClass` distinguishes `intentional` actions from `automatic` background work; `excluded` actions produce no events. The wrappers preserve results and original exceptions, receivers and arguments. Resolved `ok: false` or @@ -736,6 +782,7 @@ picker.open({ confirmDefault: true }); | `triggerVariant` | `"toolbar"` (default icon pill and Change segment) or `"field"` (full-width, single-outline field with a leading icon and trailing chevron) | | `mount` | Optional dialog container; defaults to `document.body` | | `selectionMode` | `"multiple"` (checkboxes, default) or `"single"` (native radios) | +| `commitMode` | `"explicit"` (default Apply/Cancel) or opt-in `"immediate"` (row/checkbox and keyboard changes call `onApply` without Apply; Done closes multi-selection) | | `singleGroup` | Defaults to `false`; when true, selected items must share a case-insensitive `tenantId` and exact `cloud` | | `onApply(items)` | Required callback receiving snapshots of selected item objects; may return a promise | | `onRefresh()` | Optional async callback enabling a Refresh subscriptions button inside the picker; update candidates with `setState` and reject on failure | @@ -752,6 +799,15 @@ This changes only the trigger, not the dialog size, selection model, search, keyboard interaction or confirmation behavior. The existing toolbar variant remains the default. +Use `commitMode: "immediate"` only when the consumer supports explicit empty +multi-selection and updates scope without a second confirmation. The Azure-backed +wrapper forwards this option and sends an empty scope when the final subscription +is deselected; it never substitutes a default subscription. Pending changes show +an updating status, serialize `onApply` calls and coalesce rapid changes to the +latest selection. A failed final change restores the last acknowledged scope and +shows the error. Done does not undo acknowledged changes. Other consumers retain +the default explicit commit behavior. + Each subscription has required string `key`, `id`, `name`, and `tenantId` fields. Optional fields are `tenantName`, `cloud`, `isDefault`, `disabled`, and `disabledReason`. `key` must be unique and nonempty. Keys are never normalized or diff --git a/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/auth.md b/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/auth.md index 3dff11b..2bfd61a 100644 --- a/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/auth.md +++ b/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/auth.md @@ -20,8 +20,10 @@ support targets do not establish live sign-in coverage for every combination. ## Responsibility and prerequisites - Use Node.js **22+** and, for the default source, Azure CLI **2.61+**. - Azure Identity supplies the credential and bearer-token caching; the CLI owns - sign-in and its token store. + The toolkit obtains CLI tokens through the same executable and environment + used for profile discovery, and coalesces simultaneous requests for the same + audience and Azure context. Azure Identity supplies bearer-token caching; + the CLI owns sign-in and its token store. - Alternatively, supply a `TokenCredential` with matching identity metadata. You own its configuration and disposal; the toolkit does not mix it with CLI accounts or fall back to another provider. @@ -77,8 +79,19 @@ const auth = createAzureAuthSession({ ``` These options apply to profile, login, refresh and token operations. Preserve -proxy and certificate settings when constructing `childEnvironment`. Omit the -options to use normal `az` discovery and the inherited environment. +proxy and certificate settings when constructing `childEnvironment`; a supplied +environment is forwarded exactly as given. Omit the options to use normal `az` +discovery and the inherited environment. The inherited environment excludes +session identifiers injected by the Copilot host (`SESSION_ID`, and +`COPILOT_*`/`AGENCY_*` names containing `SESSION`), which Azure CLI does not use; +forwarding them coincided with Entra rejecting CLI token requests on Windows. +Platform session variables such as `SESSIONNAME`, `WT_SESSION`, and +`DBUS_SESSION_BUS_ADDRESS` are kept. + +A canvas that must start `az` itself (for example, `az resource show`, which +picks resource-specific API versions) must build that child's environment with +`azureCliEnvironment(process.env)` from `@microsoft/canvas-toolkit/auth`, then +add its own variables. Never forward `process.env` directly. ## Public session API @@ -120,7 +133,7 @@ not credentials or evidence of permission to a resource: `source` is `"cli"` or `"credential"`; `identity` is a fingerprint string or `null`, not a credential. Snapshot `cloud` is a metadata object or `null`. `warnings` is an array of safe strings; `error` is -`{ code, message, remedy? }` or `null`. `freshness` is +`{ code, message, remedy?, diagnostic? }` or `null`. `freshness` is `{ source, refreshedAt, complete }`: `source` is `"cli-profile"`, `"azure-refresh"`, `"caller-provided"`, or `null`; `refreshedAt` is a timestamp string or `null`; `complete` is `false`. It deliberately does not certify a @@ -387,8 +400,14 @@ previously issued access tokens remain valid according to Azure. ## Errors and security boundaries -`AuthError(code, message, remedy?)` exposes safe `code`, `message`, and an optional -string `remedy`. Render remedies as escaped text. Handle cancellation distinctly +`AuthError(code, message, remedy?, diagnostic?)` exposes safe `code`, `message`, an +optional string `remedy`, and an optional structured `diagnostic`. Render remedies +as escaped text. CLI token failures (`token-failed`, `claims-required`, +`cli-timeout`) carry `diagnostic: { operation: "get-access-token", reason: +"exit" | "timeout", exitCode?, aadCode?, claimsChallenge }`, extracted from +bounded CLI output that is then discarded. `aadCode` is an Entra code such as +`AADSTS901001`. Treat every token failure as an authentication problem, never as +evidence of a missing Azure role. Handle cancellation distinctly from authentication failures (`code === "cancelled"` for auth operations, or `name === "AbortError"` for an SDK/HTTP cancellation); do not turn a failed or cancelled discovery into an empty subscription list. @@ -410,6 +429,7 @@ Examples of actionable auth codes include: | `auth-invalidated`, `disposed` | Retire old work/clients. Rebind to a current session, or create a new owner after disposal. | | `tokens-invalidated` | The acquisition was retired; an existing callback can be called again while its context is active. | | `unsupported-operation` | Do not use CLI server refresh with an injected source; reload caller-provided metadata instead. | +| `token-failed`, `claims-required`, `cli-timeout` | Report an authentication failure with the safe `diagnostic`; do not suggest role assignments or launch login from a failed request. | | `cancelled` | End only the cancelled operation; do not report it as signed-out. | Explicit subscription/tenant/cloud binding prevents mutable-selection mistakes; diff --git a/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/provenance.json b/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/provenance.json index 2b55dcb..0c0b2f8 100644 --- a/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/provenance.json +++ b/plugins/canvas-authoring/skills/create-canvas-app/references/toolkit/provenance.json @@ -3,8 +3,8 @@ "version": "0.1.0", "files": { "LICENSE": "d25deed12d803f6c973597348627b1939e2d19bbc28fda83c93ea0cde44702da", - "README.md": "f51a3c75c5a092757d99bd479e77c122e1343c14424e10cacae926e236ba45ab", - "auth.md": "d33a60ee92d725c10b4c3b7c958b5b58ed999eb7084da630c333145f8544ae04", + "README.md": "40d66d89c564be6bcb3e6741deed224417d3bc051cd3585ec665aa7a2eeae6a8", + "auth.md": "b22b317cd93b5fb40636fbaccba2750501faf41b54bcf9c6bb971c5355c9e23b", "examples/README.md": "70d8d6683e31c01ce8538d339f676901ac1332710dc19ff8afbfa443b0ae399b", "examples/custom-fetch-client.mjs": "18ea90a17fb7d39104f343b3a53079dfed536bdd9a6dafc71321de5158be61bb", "examples/resource-groups.mjs": "3d81ea6de1551da83f008251f204e06e736e9655aed450765b4f416a834d1eb8", diff --git a/scripts/verify-plugin-marketplace.mjs b/scripts/verify-plugin-marketplace.mjs index 94a251b..5a66ef3 100644 --- a/scripts/verify-plugin-marketplace.mjs +++ b/scripts/verify-plugin-marketplace.mjs @@ -29,7 +29,7 @@ const packages = { path: "plugins/canvas-authoring", manifest: "plugin.json", skills: ["./skills/create-canvas-app/"], - receipt: "docs/canvas-authoring/SHA256SUMS", + receipt: "docs/canvas-authoring/{version}/SHA256SUMS", }, "azure-cost-health-check": { path: "canvases/azure-cost-health-check", @@ -444,7 +444,8 @@ export function verifyTagSource(name, version, tag) { export function verifyPlugin({ source, name, version }, { candidate = false } = {}) { const product = packages[name]; verifyCurrentVersion(name, version); - verifyReceiptPath(product); + const receiptPath = product.receipt.replace("{version}", version); + verifyReceiptPath({ receipt: receiptPath }); const path = product.path; if (source !== path) { throw new Error(`${name}: source must use its own repo-relative path`); @@ -539,8 +540,8 @@ export function verifyPlugin({ source, name, version }, { candidate = false } = } requireFile(revision, `${path}/${skill.slice(2)}SKILL.md`); } - const receipt = fileAt(revision, product.receipt); - if (releaseTag && !receipt.equals(fileAt(releaseTag, product.receipt))) { + const receipt = fileAt(revision, receiptPath); + if (releaseTag && !receipt.equals(fileAt(releaseTag, receiptPath))) { throw new Error("current checksum receipt differs from immutable release tag"); } const entries = receipt.toString("utf8").trimEnd().split("\n").map((line) => { diff --git a/test/canvas-release-receipts.test.mjs b/test/canvas-release-receipts.test.mjs index 49fc3e6..bfec077 100644 --- a/test/canvas-release-receipts.test.mjs +++ b/test/canvas-release-receipts.test.mjs @@ -115,6 +115,34 @@ test("Cost Health 0.4.5 pins every protected Agent Plugins file without pinning } }); +test("canvas-authoring candidate receipt covers every exported file and its toolkit provenance", () => { + const product = catalog.plugins.find(({ name }) => name === "canvas-authoring"); + const read = (file) => readFileSync(new URL(`${product.source}/${file}`, root)); + const manifest = JSON.parse(read("plugin.json")); + assert.equal(manifest.version, product.version); + assert.equal(manifest.name, product.name); + assert.ok(!Object.hasOwn(manifest, "extensions") && !Object.hasOwn(manifest, "canvases")); + const receipt = readFileSync(new URL(`docs/canvas-authoring/${product.version}/SHA256SUMS`, root), "utf8"); + const entries = receipt.trimEnd().split("\n").map((line) => { + const match = /^([0-9a-f]{64}) (.+)$/.exec(line); + assert.ok(match, `malformed authoring receipt: ${line}`); + return { hash: match[1], file: match[2] }; + }); + const files = execFileSync("git", ["ls-files", "--", product.source], { + cwd: root, encoding: "utf8", + }).trimEnd().split("\n").map((file) => file.slice(product.source.length + 1)).sort(); + assert.deepEqual(entries.map(({ file }) => file).sort(), files); + assert.equal(new Set(entries.map(({ file }) => file)).size, entries.length); + for (const { hash, file } of entries) { + assert.equal(createHash("sha256").update(read(file)).digest("hex"), hash, file); + } + const toolkit = "skills/create-canvas-app/references/toolkit"; + const provenance = JSON.parse(read(`${toolkit}/provenance.json`)); + for (const [file, hash] of Object.entries(provenance.files)) { + assert.equal(createHash("sha256").update(read(`${toolkit}/${file}`)).digest("hex"), hash, file); + } +}); + test("canvas-authoring 0.1.1 receipt covers the skill-only package and its toolkit provenance", () => { const packagePath = "plugins/canvas-authoring/"; const snapshot = taggedSnapshot("canvas-authoring", "0.1.1"); diff --git a/test/fixtures/marketplace.candidate.json b/test/fixtures/marketplace.candidate.json index 0260ca1..748a748 100644 --- a/test/fixtures/marketplace.candidate.json +++ b/test/fixtures/marketplace.candidate.json @@ -16,7 +16,7 @@ }, { "name": "canvas-authoring", - "version": "0.1.1", + "version": "0.1.2", "source": "plugins/canvas-authoring" }, { diff --git a/test/plugin-marketplace.test.mjs b/test/plugin-marketplace.test.mjs index 0b4d196..c1c6ca9 100644 --- a/test/plugin-marketplace.test.mjs +++ b/test/plugin-marketplace.test.mjs @@ -4,6 +4,7 @@ import { createHash } from "node:crypto"; import { copyFileSync, existsSync, + mkdirSync, mkdtempSync, readFileSync, realpathSync, @@ -56,7 +57,7 @@ test("requires release tags for full verification, then checks every catalog plu assert.equal(results.length, fixture.plugins.length); assert.ok(results[0].startsWith(`azure-functions-hosted-skills@${versionOf("azure-functions-hosted-skills")} azure-functions-hosted-skills-v`)); assert.ok(results[1].startsWith(`azure-resources-query@${versionOf("azure-resources-query")} azure-resources-query-v`)); - assert.match(results[2], /canvas-authoring@0\.1\.1 canvas-authoring-v0-1-1-/); + assert.ok(results[2].startsWith(`canvas-authoring@${versionOf("canvas-authoring")} canvas-authoring-v`)); assert.match(results[3], /azure-cost-health-check@0\.4\.5 azure-cost-health-check-v0-4-5-/); assert.match(results[4], /azure-sre-agent@0\.2\.7 azure-sre-agent-v0-2-7-/); assert.ok(results[5].startsWith(`azure-storage-canvas@${versionOf("azure-storage-canvas")} azure-storage-canvas-v`)); @@ -73,7 +74,7 @@ test("candidate validates every reviewed package and omits only missing immutabl }).trim().split("\n").filter(Boolean); assert.equal(results[index], `${name}@${version} ${tags[0] ?? "(candidate; immutable tag pending)"}`); } - assert.match(results[2], /canvas-authoring-v0-1-1-8af10f8/); + assert.ok(results[2].startsWith(`canvas-authoring@${versionOf("canvas-authoring")} `)); assert.match(results[4], /azure-sre-agent@0\.2\.7 azure-sre-agent-v0-2-7-9da2e40/); assert.equal(results[6], "containers@0.1.1 (candidate; immutable tag pending)"); }); @@ -179,6 +180,67 @@ test("a subsequent Cost candidate version validates without a verifier edit", () } }); +test("canvas-authoring uses versioned receipts without changing historical receipts or verifier versions", () => { + const checkout = mkdtempSync(join(tmpdir(), "authoring-receipt-")); + const clone = join(checkout, "repo"); + const git = (...args) => execFileSync("git", ["-C", clone, ...args], { + encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], + }).trim(); + const product = structuredClone(catalog.plugins.find(({ name }) => name === "canvas-authoring")); + const verify = (candidate = true) => { + try { + return execFileSync("node", ["--input-type=module", "-e", + `import { verifyPlugin } from "./${verifier}"; console.log(verifyPlugin(${JSON.stringify(product)}, { candidate: ${candidate} }));`, + ], { cwd: clone, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + } catch (error) { + throw new Error(error.stderr); + } + }; + try { + execFileSync("git", ["clone", "--quiet", "--local", "--no-hardlinks", root, clone]); + git("config", "user.name", "Release policy test"); + git("config", "user.email", "release-policy@example.invalid"); + const historical = "docs/canvas-authoring/SHA256SUMS"; + assert.equal(git("show", `HEAD:${historical}`), + git("show", `canvas-authoring-v0-1-1-8af10f8:${historical}`)); + assert.doesNotThrow(verify); + assert.throws(() => verify(false), /expected exactly one reviewed immutable release tag/); + + const receipt = `docs/canvas-authoring/${product.version}/SHA256SUMS`; + const original = readFileSync(join(clone, receipt), "utf8"); + writeFileSync(join(clone, receipt), original.split("\n").filter((line) => + !line.endsWith(" com.github.copilot/extensions/canvas-authoring/extension.mjs")).join("\n")); + git("add", "--", receipt); + git("commit", "--quiet", "-m", "Remove authoring runtime receipt"); + assert.throws(verify, /checksum receipt must cover every protected plugin file exactly once/); + writeFileSync(join(clone, receipt), original.replace( + /^[0-9a-f]{64}(?= plugin.json$)/m, "0".repeat(64))); + git("add", "--", receipt); + git("commit", "--quiet", "-m", "Corrupt authoring manifest receipt"); + assert.throws(verify, /plugin file differs from checksum receipt: plugin.json/); + + writeFileSync(join(clone, receipt), original); + const parts = product.version.split(".").map(Number); + parts[2]++; + product.version = parts.join("."); + const manifestPath = `${product.source}/plugin.json`; + const manifest = JSON.parse(readFileSync(join(clone, manifestPath))); + manifest.version = product.version; + const bytes = `${JSON.stringify(manifest, null, 2)}\n`; + writeFileSync(join(clone, manifestPath), bytes); + const nextReceipt = `docs/canvas-authoring/${product.version}/SHA256SUMS`; + mkdirSync(join(clone, `docs/canvas-authoring/${product.version}`), { recursive: true }); + writeFileSync(join(clone, nextReceipt), original.replace( + /^[0-9a-f]{64}(?= plugin.json$)/m, + createHash("sha256").update(bytes).digest("hex"))); + git("add", "--", manifestPath, "docs/canvas-authoring"); + git("commit", "--quiet", "-m", "Prepare next authoring receipt"); + assert.doesNotThrow(verify); + } finally { + rmSync(checkout, { recursive: true, force: true }); + } +}); + test("rejects missing products and duplicate entries", () => { assert.throws(() => verifyMarketplace(modified((m) => m.plugins.pop())), /exactly the reviewed production products/); assert.throws(() => verifyMarketplace(modified((m) => {