Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Directory.Build.targets
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,11 @@
<PackageReference Include="Microsoft.WindowsAppSDK.Runtime" Condition="'$(_ReactorWinAppSDKFrameworkDependentApp)' == 'true'" />
</ItemGroup>

<!-- Make the GitHub.Copilot.SDK build-time CLI download resilient on offline /
restricted networks so `dotnet build` / `dotnet run` works for everyone; see
the target's own comment. Imported here (a late import) so $(CopilotCliVersion)
and the SDK's $(_CopilotPlatform)/$(_CopilotBinary) are already resolved. -->
<Import Project="$(MSBuildThisFileDirectory)build\Reactor.CopilotCli.targets"
Condition="Exists('$(MSBuildThisFileDirectory)build\Reactor.CopilotCli.targets')" />

</Project>
158 changes: 158 additions & 0 deletions build/Reactor.CopilotCli.targets
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<Project>

<!--
Resilient, internal/external-aware Copilot CLI acquisition for
GitHub.Copilot.SDK consumers (widget-creator, demo-script-tool, Reactor.Cli).

THE PROBLEM
The SDK's GitHub.Copilot.SDK.targets fetch the native `copilot.exe` at build
time with an unauthenticated MSBuild <DownloadFile> hardcoded to
registry.npmjs.org, then <Error> if it can't. That download:
* HARD-FAILS the build (MSB3923) on any offline / proxied / TLS-intercepted
network, and
* cannot use an authenticated Azure Artifacts npm feed (DownloadFile has no
credential support; Azure Artifacts npm feeds have no anonymous read).
See upstream github/copilot-sdk#921. The CLI is only a RUNTIME artifact — it is
not needed to compile — yet its download breaks `dotnet build` / `dotnet run`.

The SDK resolves the CLI at run time from COPILOT_CLI_PATH or the build-bundled
binary only (it does NOT download at run time), so we cannot simply skip: we
want to still bundle the pinned CLI whenever we can.

THE FIX (this file)
Take over acquisition ourselves whenever the SDK is referenced and neither the
developer nor CI has already pinned/skipped it, and turn OFF the SDK's own
hard-failing download. We decide this at EVALUATION time (this file is imported
from Directory.Build.targets AFTER the SDK's package targets, so the value we
evaluate for CopilotSkipCliDownload is the one the SDK targets' Conditions see —
a value set inside a BeforeTargets hook would be too late). We then obtain the
binary best-effort, in preference order, and place it in the output exactly like
the SDK would:

(1) A binary cached by a previous build -> reuse it.
(2) COPILOT_CLI_PATH -> a local copilot(.exe) -> bundle that.
(3) Public registry (external/OSS) -> <DownloadFile> (auth-less, fast).
(4) `npm pack` honoring the ambient .npmrc -> the supported path for
an AUTHENTICATED internal Azure Artifacts npm feed (internal dev/CI); also
a proxy-friendly fallback when (3) is blocked.
(5) Nothing worked -> warn and build WITHOUT a bundled CLI. The build still
succeeds; the app resolves the CLI at run time via COPILOT_CLI_PATH.

INTERNAL vs EXTERNAL
* External / OSS: the committed nuget.config + .npmrc stay on nuget.org /
registry.npmjs.org. (3) downloads the CLI directly. Nothing to configure.
* Internal: the build environment points npm at the internal Azure Artifacts
feed via an authenticated .npmrc (CI writes one; see build/pipelines). (4)
`npm pack` then resolves the CLI through that feed. Set CopilotNpmRegistryUrl
to force a specific registry; otherwise the ambient .npmrc registry is used.

Only active when GitHub.Copilot.SDK is referenced ($(CopilotCliVersion) is set by
its props). Inert when CopilotSkipCliDownload=true (CI default, see
Directory.Build.props) or the developer pinned CopilotCliBinaryPath.
-->

<PropertyGroup Condition="'$(CopilotCliVersion)' != '' and '$(_CopilotPlatform)' != '' and '$(CopilotSkipCliDownload)' != 'true' and '$(CopilotCliBinaryPath)' == ''">
<!-- We manage acquisition: disable the SDK's own download/copy/register targets
(they gate off CopilotSkipCliDownload) and bundle the CLI ourselves. -->
<_ReactorCopilotManage>true</_ReactorCopilotManage>
<CopilotSkipCliDownload>true</CopilotSkipCliDownload>

<_ReactorCopilotCacheDir>$(IntermediateOutputPath)copilot-cli\$(CopilotCliVersion)\$(_CopilotPlatform)</_ReactorCopilotCacheDir>
<_ReactorCopilotCacheBin>$(_ReactorCopilotCacheDir)\$(_CopilotBinary)</_ReactorCopilotCacheBin>
<_ReactorCopilotRegistry>$([System.String]::Copy('$(CopilotNpmRegistryUrl)').TrimEnd('/'))</_ReactorCopilotRegistry>
<_ReactorCopilotRegistry Condition="'$(_ReactorCopilotRegistry)' == ''">https://registry.npmjs.org</_ReactorCopilotRegistry>
<_ReactorCopilotRegistryIsPublic>false</_ReactorCopilotRegistryIsPublic>
<_ReactorCopilotRegistryIsPublic Condition="'$(_ReactorCopilotRegistry)' == 'https://registry.npmjs.org'">true</_ReactorCopilotRegistryIsPublic>
</PropertyGroup>

<!-- Obtain the CLI into the SDK's cache path, best-effort. Everything is
ContinueOnError so a restricted network / missing npm can't break the build. -->
<Target Name="_ReactorAcquireCopilotCli"
Condition="'$(_ReactorCopilotManage)' == 'true' and '$(TargetFramework)' != ''"
BeforeTargets="BeforeBuild">

<PropertyGroup>
<_ReactorCopilotTgzUrl>$(_ReactorCopilotRegistry)/@github/copilot-$(_CopilotPlatform)/-/copilot-$(_CopilotPlatform)-$(CopilotCliVersion).tgz</_ReactorCopilotTgzUrl>
<_ReactorCopilotArchive>$(_ReactorCopilotCacheDir)\copilot.tgz</_ReactorCopilotArchive>
<_ReactorCopilotNpmTgz>$(_ReactorCopilotCacheDir)\github-copilot-$(_CopilotPlatform)-$(CopilotCliVersion).tgz</_ReactorCopilotNpmTgz>
<_ReactorCopilotNpmRegistryArg Condition="'$(_ReactorCopilotRegistryIsPublic)' != 'true'"> --registry $(_ReactorCopilotRegistry)</_ReactorCopilotNpmRegistryArg>
<_ReactorCopilotTar Condition="$([MSBuild]::IsOSPlatform('Windows'))">$(SystemRoot)\System32\tar.exe</_ReactorCopilotTar>
<_ReactorCopilotTar Condition="'$(_ReactorCopilotTar)' == ''">tar</_ReactorCopilotTar>
</PropertyGroup>

<MakeDir Condition="!Exists('$(_ReactorCopilotCacheBin)')" Directories="$(_ReactorCopilotCacheDir)" />

<!-- (2) Developer-provided local CLI. -->
<Copy Condition="!Exists('$(_ReactorCopilotCacheBin)') and '$(COPILOT_CLI_PATH)' != '' and Exists('$(COPILOT_CLI_PATH)')"
SourceFiles="$(COPILOT_CLI_PATH)"
DestinationFiles="$(_ReactorCopilotCacheBin)"
SkipUnchangedFiles="true"
ContinueOnError="true" />

<!-- (3) Public registry: direct, auth-less download. -->
<DownloadFile Condition="!Exists('$(_ReactorCopilotCacheBin)') and '$(_ReactorCopilotRegistryIsPublic)' == 'true'"
SourceUrl="$(_ReactorCopilotTgzUrl)"
DestinationFolder="$(_ReactorCopilotCacheDir)"
DestinationFileName="copilot.tgz"
ContinueOnError="true" />
<Exec Condition="!Exists('$(_ReactorCopilotCacheBin)') and Exists('$(_ReactorCopilotArchive)')"
Command="&quot;$(_ReactorCopilotTar)&quot; -xzf &quot;$(_ReactorCopilotArchive)&quot; --strip-components=1 -C &quot;$(_ReactorCopilotCacheDir)&quot;"
ContinueOnError="true" />

<!-- (4) Authenticated internal feed (or proxy fallback): `npm pack` honors the
ambient .npmrc credentials that DownloadFile cannot use. -->
<Exec Condition="!Exists('$(_ReactorCopilotCacheBin)')"
Command="npm pack @github/copilot-$(_CopilotPlatform)@$(CopilotCliVersion)$(_ReactorCopilotNpmRegistryArg) --pack-destination &quot;$(_ReactorCopilotCacheDir)&quot;"
ContinueOnError="true" />
<Exec Condition="!Exists('$(_ReactorCopilotCacheBin)') and Exists('$(_ReactorCopilotNpmTgz)')"
Command="&quot;$(_ReactorCopilotTar)&quot; -xzf &quot;$(_ReactorCopilotNpmTgz)&quot; --strip-components=1 -C &quot;$(_ReactorCopilotCacheDir)&quot;"
ContinueOnError="true" />

<!-- (5) Give up gracefully. -->
<Warning Condition="!Exists('$(_ReactorCopilotCacheBin)')"
Code="REACTORCLI001"
Text="Copilot CLI $(CopilotCliVersion) could not be acquired (offline, restricted network, or no authenticated npm feed). Building '$(MSBuildProjectName)' without a bundled CLI. Set the COPILOT_CLI_PATH environment variable to a local Copilot CLI binary to enable generation at run time; internal builds should point npm at the internal Azure Artifacts feed (see build/pipelines)." />
</Target>

<!-- Register the bundled CLI as content so it flows to this project's output and
any referencing project. Mirrors the SDK's _RegisterCopilotCliForCopy but is a
no-op (no <Error>) when acquisition produced no binary. -->
<Target Name="_ReactorRegisterCopilotCli"
Condition="'$(_ReactorCopilotManage)' == 'true' and '$(TargetFramework)' != ''"
DependsOnTargets="_ReactorAcquireCopilotCli"
BeforeTargets="GetCopyToOutputDirectoryItems">
<PropertyGroup>
<_ReactorCopilotRuntimeNode>$(_ReactorCopilotCacheDir)\prebuilds\$(_CopilotPlatform)\runtime.node</_ReactorCopilotRuntimeNode>
</PropertyGroup>
<ItemGroup Condition="Exists('$(_ReactorCopilotCacheBin)')">
<ContentWithTargetPath Include="$(_ReactorCopilotCacheBin)"
TargetPath="runtimes\$(_CopilotRid)\native\$(_CopilotBinary)"
CopyToOutputDirectory="PreserveNewest" />
<ContentWithTargetPath Include="$(_ReactorCopilotRuntimeNode)"
TargetPath="runtimes\$(_CopilotRid)\native\$(_CopilotRuntimeLib)"
CopyToOutputDirectory="PreserveNewest"
Condition="Exists('$(_ReactorCopilotRuntimeNode)')" />
Comment on lines +128 to +134
</ItemGroup>
</Target>

<!-- Copy the bundled CLI into this project's output runtimes folder. Mirrors the
SDK's _CopyCopilotCliToOutput; guarded on Exists so it is a no-op when absent. -->
<Target Name="_ReactorCopyCopilotCliToOutput"
Condition="'$(_ReactorCopilotManage)' == 'true' and '$(TargetFramework)' != '' and Exists('$(_ReactorCopilotCacheBin)')"
DependsOnTargets="_ReactorAcquireCopilotCli"
AfterTargets="Build">
<PropertyGroup>
<_ReactorCopilotOutDir>$(OutDir)runtimes\$(_CopilotRid)\native</_ReactorCopilotOutDir>
<_ReactorCopilotRuntimeNode>$(_ReactorCopilotCacheDir)\prebuilds\$(_CopilotPlatform)\runtime.node</_ReactorCopilotRuntimeNode>
</PropertyGroup>
<MakeDir Directories="$(_ReactorCopilotOutDir)" />
<Copy SourceFiles="$(_ReactorCopilotCacheBin)"
DestinationFiles="$(_ReactorCopilotOutDir)\$(_CopilotBinary)"
SkipUnchangedFiles="true" />
<Copy SourceFiles="$(_ReactorCopilotRuntimeNode)"
DestinationFiles="$(_ReactorCopilotOutDir)\$(_CopilotRuntimeLib)"
SkipUnchangedFiles="true"
Condition="Exists('$(_ReactorCopilotRuntimeNode)')" />
</Target>

</Project>
8 changes: 7 additions & 1 deletion build/pipelines/templates/reactor-build-steps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ parameters:
- name: npmRegistry
displayName: 'Internal npm registry (ADO feed) for transitive npm downloads.'
type: string
default: 'https://github-private.pkgs.visualstudio.com/microsoft/_packaging/microsoft-ui-reactor/npm/registry/'
# Modern pkgs.dev.azure.com domain (org/project scoped), matching nuget.internal.config's
# feed. Equivalent to the legacy https://github-private.pkgs.visualstudio.com/microsoft/...
# form; the pkgs.dev.azure.com domain is Microsoft's preferred/required endpoint (agent
# firewall rules, reliability). The `microsoft-ui-reactor` feed proxies registry.npmjs.org
# via its npm upstream source, so transitive npm downloads (e.g. the GitHub.Copilot.SDK CLI)
# resolve internally with feed auth.
default: 'https://pkgs.dev.azure.com/github-private/microsoft/_packaging/microsoft-ui-reactor/npm/registry/'
- name: version
displayName: 'Explicit version; "auto" or blank => MinVer.'
type: string
Expand Down
22 changes: 22 additions & 0 deletions samples/apps/widget-creator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,28 @@ dotnet run --project samples/apps/widget-creator/widget-creator.csproj -p:Platfo
feed. Run `mur pack-local` if it's missing. Override the feed path with
`WIDGET_CREATOR_NUPKGS`.

### The Copilot CLI at build time (internal vs external)

The `GitHub.Copilot.SDK` bundles a native `copilot.exe` that it downloads at
**build** time from an npm registry. The repo makes this resilient for everyone
(`build/Reactor.CopilotCli.targets`), so `dotnet build` / `dotnet run` never
hard-fails on a restricted network:

- **External / OSS** — the committed `nuget.config` + `.npmrc` stay on public
`nuget.org` / `registry.npmjs.org`; the CLI downloads directly. Nothing to set up.
- **Internal (Microsoft)** — point npm at the internal Azure Artifacts feed
(`https://pkgs.dev.azure.com/github-private/microsoft/_packaging/microsoft-ui-reactor/npm/registry/`)
via an authenticated `.npmrc` (CI does this automatically; locally use
`az artifacts` or the feed's *Connect to feed* → npm instructions). The build then
acquires the CLI through that feed with `npm pack`, since Azure Artifacts feeds
require auth that the SDK's raw download can't provide.
- **Offline / no feed** — the build still succeeds *without* a bundled CLI (a
`REACTORCLI001` warning is emitted). To run generation you then need a Copilot
CLI binary available at run time: set `COPILOT_CLI_PATH` to a local `copilot.exe`
(or install the standalone Copilot CLI in its default per-user location). Note
`gh auth` only supplies Copilot credentials — it is not itself a runnable
`copilot.exe`.

Type a prompt, click **Generate & Run**. The generated source streams into the
right panel; the build + `wxc-exec` log streams below it. The widget window opens
sandboxed — close it to finish the run. If it crashes instead, the creator keeps
Expand Down
52 changes: 52 additions & 0 deletions samples/apps/widget-creator/Services/CopilotSdkClient.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
Expand Down Expand Up @@ -42,6 +44,18 @@ async Task<CopilotClient> GetClientAsync(CancellationToken ct)
{
if (_client is not null) return _client;
var options = new CopilotClientOptions();
// The SDK's default stdio transport looks ONLY for a CLI bundled next to
// the app (runtimes/<rid>/native/copilot.exe) and does not consult
// COPILOT_CLI_PATH. On builds where the CLI could not be bundled at build
// time (offline / restricted-network / authenticated-feed environments —
// see build/Reactor.CopilotCli.targets), resolve one at run time and pass
// it explicitly so generation still works.
var cliPath = ResolveCliPath();
if (cliPath is not null)
{
SessionLog.Write($"[CopilotSdk] no bundled CLI; using resolved CLI at {cliPath}");
options.Connection = RuntimeConnection.ForStdio(path: cliPath);
}
var client = new CopilotClient(options);
SessionLog.Write($"[CopilotSdk] starting CLI server (model={_model})");
await client.StartAsync().ConfigureAwait(false);
Expand All @@ -55,6 +69,44 @@ async Task<CopilotClient> GetClientAsync(CancellationToken ct)
}
}

/// <summary>
/// Resolves a Copilot CLI to spawn when the SDK's bundled binary is absent.
/// Returns <c>null</c> when the bundled CLI is present (let the SDK use it) or
/// when nothing could be found (let the SDK throw its descriptive error).
/// Order: bundled next to the app → <c>COPILOT_CLI_PATH</c> → a locally
/// installed Copilot CLI in its well-known per-user location. We deliberately
/// do NOT probe <c>PATH</c> for <c>copilot.exe</c> — that would let any
/// unrelated (or malicious) binary earlier on <c>PATH</c> be launched. To use
/// a CLI in a non-standard location, set <c>COPILOT_CLI_PATH</c> explicitly.
/// </summary>
static string? ResolveCliPath()
{
var rid = "win-" + (RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "arm64" : "x64");
var bundled = Path.Combine(AppContext.BaseDirectory, "runtimes", rid, "native", "copilot.exe");
if (File.Exists(bundled))
return null; // SDK's default resolution works.

var env = Environment.GetEnvironmentVariable("COPILOT_CLI_PATH");
if (!string.IsNullOrWhiteSpace(env) && File.Exists(env))
return env;

foreach (var candidate in EnumerateInstalledCliPaths())
if (File.Exists(candidate))
return candidate;

return null;
}

static IEnumerable<string> EnumerateInstalledCliPaths()
{
// Standalone GitHub Copilot CLI install (winget / gh), in its well-known
// per-user location. Intentionally a fixed, trusted path — never a PATH
// scan, so an unrelated copilot.exe on PATH can't be picked up and run.
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (!string.IsNullOrEmpty(localAppData))
yield return Path.Combine(localAppData, "GitHub CLI", "copilot", "copilot.exe");
}

public async Task<IModelConversation> StartConversationAsync(string systemPrompt, CancellationToken ct)
{
var client = await GetClientAsync(ct).ConfigureAwait(false);
Expand Down
12 changes: 6 additions & 6 deletions samples/apps/widget-creator/Services/MxcBinaryManifest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ public static class MxcBinaryManifest
{
["win-arm64"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["winhttp-proxy-shim.exe"] = "aca7cd110b09a1045c53a5c9bd73089f63e6c66f0b3d2627a5bdfb55bb524fdc",
["wxc-exec.exe"] = "e430d0e4f44f616e91db684f8d825a6dc93e06a1262b8d00bcaac7522a317aab",
["wxc-host-prep.exe"] = "3ef702332286a39153fc259310b5021e3de3c191751d7522684f6475f73af5ef",
["wxc-test-proxy.exe"] = "1d1a5821a65c9b4aceb2f1788ca54b08d06b92b784daa1926ab978f4a49f1f00",
["wxc-windows-sandbox-daemon.exe"] = "fc8079bddf5db77ee4ecea91d7f22a543fbec3618945a7ab97269dcfef3f66b1",
["wxc-windows-sandbox-guest.exe"] = "69c972ce4a65d337f15d828e40b92fcb2f89665d32f2cb598606b45c76adfde3",
["winhttp-proxy-shim.exe"] = "f1fdf37c66af032f4472f5de24fae766544d2fac19da75e45cecb38c5437caa8",
["wxc-exec.exe"] = "d12957f434871af4cee0fbccfe12a91a615d3e91d8b81c7713f4141eb1f466df",
["wxc-host-prep.exe"] = "ec91ae4a8d8b537b11c8401f3c1efd7ffc0fb4fba26d0f822f5138aa6cb08aee",
["wxc-test-proxy.exe"] = "553fbc073ff820b674a87e0a348ddb8814031144812e5df2bc87e4ce7ba34ffa",
["wxc-windows-sandbox-daemon.exe"] = "68054c012e8fcea6f8b82336edad58f96debb4d02b6f0a145befd9d7eaf301c1",
["wxc-windows-sandbox-guest.exe"] = "985b88a7871653518f6db0103abf491c2e510be2405bf6a3dff8e64dece7f6f2",
},
["win-x64"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
Expand Down
Binary file not shown.
Binary file modified samples/apps/widget-creator/tools/mxc/win-arm64/wxc-exec.exe
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading