Repository navigation
build: resolve the Copilot CLI npm mirror at MSBuild evaluation time - #1243
Alexandre Zollinger Chohfi (azchohfi) merged 10 commits into
Conversation
GitHub.Copilot.SDK's build targets download a native copilot.exe via an
MSBuild <DownloadFile>, hard-defaulting to registry.npmjs.org. On a network
that blocks that host the download fails the *build* with MSB3923, taking
out every entry point that builds src/Reactor.Cli or the two samples
referencing the SDK -- including the `mur docs compile` the docs freshness
gate in ci.yml tells contributors to run by hand.
bootstrap.ps1 already solved this for its own restores via
Resolve-ReactorNpmRegistry, but a plain `dotnet build` / `dotnet run` /
`dotnet test` never passes through bootstrap, so the same developer stayed
blocked. tools/CopilotNpmRegistry.props applies the same policy at MSBuild
evaluation time, so every documented command works unmodified on both
networks with no new script, flag, or CI change. Contributors on an
unrestricted network configure none of this, match nothing, and keep the
SDK's public default.
The policy mirrors Resolve-ReactorNpmRegistry: NPM_CONFIG_REGISTRY short-
circuits the user .npmrc; within the .npmrc the last `registry=` assignment
wins and surrounding quotes are stripped; the value must be https, on host
packagefeedproxy.microsoft.io, with no userinfo, query, or fragment. Scheme
and host compare case-insensitively and an explicit port is allowed, because
the resolver compares already-normalized System.Uri.Scheme/.Host.
Two properties are load-bearing and easy to regress:
* The host must be followed by `/` or the end of the value. Allowing an
unconstrained tail lets packagefeedproxy.microsoft.io.evil.example match
its own prefix, handing a native-binary download to an attacker-
controlled host -- and once a port is permitted, the same anchor is what
stops `host:443@evil.example` smuggling in a foreign host via userinfo.
* A registry URL can legitimately carry a token in its query string, and a
user .npmrc is mostly `_password=` feed tokens. Rejecting query/fragment
keeps a token out of CopilotNpmRegistryUrl, and the .npmrc read is nested
inside the Regex.Match argument so its contents never become a named
property -- which would publish them into every shared binlog.
Tested by tests/build_props/ci/CopilotNpmRegistry.Tests.ps1 (59 assertions),
which evaluates the props directly, probes through the real
Directory.Build.props, asserts no credential reaches a diagnostic-verbosity
log, and runs a differential suite feeding one .npmrc corpus to both this
file and Resolve-ReactorNpmRegistry, requiring them to agree. CI never
exercises any of this -- CI=true sets CopilotSkipCliDownload=true, making the
props inert -- so without that suite the whole file could rot unnoticed;
hence the dedicated path-filtered workflow.
Also corrects the neighbouring CopilotSkipCliDownload comment, which claimed
the native binary was only needed by the demo-script-tool sample. `mur loc
translate` starts it too, so a local blanket skip breaks the shipped CLI.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7432aaab-174d-4637-afc8-13428c4eb099
📦 Build metricsArtifact sizes for Packages (compressed .nupkg)
Assemblies in Microsoft.UI.Reactor
Assemblies in Microsoft.UI.Reactor.Advanced
Assemblies in Microsoft.UI.Reactor.Devtools
No size change beyond the noise floor. ✅ ✅ smaller / |
There was a problem hiding this comment.
🟡 Changes recommended
The resolver has a critical regex failure plus parity, path, port-validation, CI-filter, and changelog issues.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds MSBuild-time npm mirror resolution for Copilot SDK downloads, enabling standard .NET commands on restricted networks.
Changes:
- Resolves and validates npm mirrors during MSBuild evaluation.
- Adds parity, security, integration, and CI test coverage.
- Documents mirror behavior and updates Copilot download guidance.
File summaries
| File | Summary | Findings |
|---|---|---|
tools/CopilotNpmRegistry.props |
Resolves allowed npm mirrors during MSBuild evaluation. | Critical (3 votes): invalid variable-length regex lookbehind causes evaluation failure with existing .npmrc files. Moderate: whitespace environment parity (1), apostrophe path escaping (1), and invalid-port validation (2). |
tests/build_props/ci/CopilotNpmRegistry.Tests.ps1 |
Tests parsing, security, integration, and resolver parity. | No findings. |
README.md |
Documents automatic mirror resolution and CLI download behavior. | Nit (3 votes): add a corresponding [Unreleased] changelog entry with an issue/spec reference. |
Directory.Build.props |
Imports the new props and updates Copilot download guidance. | No findings. |
.github/workflows/build-props-lib-tests.yml |
Runs dedicated Windows build-props tests. | Moderate (3 votes): include the resolver path in both push and pull-request filters. |
Review details
Suppressed comments (2)
tools/CopilotNpmRegistry.props:104
- The reference resolver treats a whitespace
NPM_CONFIG_REGISTRYas unset (tools/BootstrapFeedResolver.ps1:31), but this branch checks only for the empty string. WithNPM_CONFIG_REGISTRY=' 'and a mirror in the user config, the props skips the.npmrcread and leavesCopilotNpmRegistryUrlempty, so plain builds diverge from bootstrap and still use the blocked public registry. Use the same null/whitespace semantics and add an environment-parity case.
<CopilotNpmRegistryUrl Condition="'$(CopilotNpmRegistryUrl)' == ''
and '$(NPM_CONFIG_REGISTRY)' == ''
and Exists('$(_ReactorNpmUserConfig)')">$([System.Text.RegularExpressions.Regex]::Match($([System.IO.File]::ReadAllText('$(_ReactorNpmUserConfig)')), '(?m)(?<=^[ \t]*registry[ \t]*=[ \t]*["\x27]?)(?i:https://packagefeedproxy\.microsoft\.io)(?::\d+)?(?:/[^\s?#"\x27]*)?(?=["\x27]?[ \t]*\r?$)(?![\s\S]*^[ \t]*registry[ \t]*=)'))</CopilotNpmRegistryUrl>
tools/CopilotNpmRegistry.props:104
- After the regex is fixed, this path interpolation still breaks valid user configurations whose
NPM_CONFIG_USERCONFIGcontains an apostrophe: the value is inserted intoReadAllText('...')without escaping, producing malformed MSBuild property-function syntax (for exampleC:\Users\O'Connor\.npmrc). Read the path through an escaping-safe argument mechanism and add a regression case for a quoted path, otherwise those users still cannot use automatic mirror detection.
and Exists('$(_ReactorNpmUserConfig)')">$([System.Text.RegularExpressions.Regex]::Match($([System.IO.File]::ReadAllText('$(_ReactorNpmUserConfig)')), '(?m)(?<=^[ \t]*registry[ \t]*=[ \t]*["\x27]?)(?i:https://packagefeedproxy\.microsoft\.io)(?::\d+)?(?:/[^\s?#"\x27]*)?(?=["\x27]?[ \t]*\r?$)(?![\s\S]*^[ \t]*registry[ \t]*=)'))</CopilotNpmRegistryUrl>
- Files reviewed: 5/5 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Follow-up to the Copilot review on #1243. Three of its six findings were real parity gaps against Resolve-ReactorNpmRegistry: * An all-whitespace NPM_CONFIG_REGISTRY or NPM_CONFIG_USERCONFIG counted as set. The resolver gates on [string]::IsNullOrWhiteSpace, so whitespace means "unset" there; here it short-circuited the .npmrc read and left the developer on the blocked public registry. Both env vars are now compared after .Trim(). * The port fragment was an unbounded \d+, so :65536 matched even though Uri.TryCreate rejects it, handing the SDK a URL bootstrap would have refused instead of falling through to no mirror. Now bounded to 0-65535. * The dedicated test workflow did not trigger on tools/BootstrapFeedResolver.ps1, which the suite's differential corpus dot-sources. A resolver change could therefore invalidate the claimed parity while this job stayed skipped and ordinary CI kept the props inert under CI=true. Added to both path filters. Suite is 65 assertions; the four new ones were mutation-checked by reverting the port bound and the .Trim() calls, which reddened exactly those assertions plus the matching differential-parity case. The other three findings were not acted on, with evidence: * "Variable-length lookbehind throws ArgumentException" (rated critical) is incorrect. .NET is one of the few engines that permits unbounded lookbehind; a direct MSBuild probe of the exact pattern against a real .npmrc returns the mirror, and the whole existing suite already evaluates it. * "Apostrophe in NPM_CONFIG_USERCONFIG breaks the property function" is incorrect. MSBuild parses property-function argument boundaries before expanding $(...), so an apostrophe inside the expanded value never participates; verified with a path containing both an apostrophe and a space. * Re-adding a CHANGELOG entry would contradict the convention block, which asks for a spec/issue cross-reference and significant-or-breaking scope. Nothing consumer-visible changes, and the closest precedent -- bootstrap.ps1's own mirror detection, documented in the same README paragraph -- has no entry. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7432aaab-174d-4637-afc8-13428c4eb099
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved resolver parity and path-handling issues remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
tools/CopilotNpmRegistry.props:103
- Bootstrap's fallback is
$env:USERPROFILE\.npmrc(tools/BootstrapFeedResolver.ps1:17,36), but this usesSpecialFolder.UserProfile. WhenUSERPROFILEis redirected, as the new test harness explicitly notes, bootstrap/npm and plain MSBuild inspect different files; a configured mirror is therefore silently missed by this path. Use the same USERPROFILE-based fallback or remove the parity claim and test the intentional divergence.
tools/CopilotNpmRegistry.props:105 - Bootstrap trims the configured value before URI validation (
tools/BootstrapFeedResolver.ps1:7) and returns the trimmed value (:55). This branch matches the raw$(NPM_CONFIG_REGISTRY), while the next branch is disabled whenever.Trim()is nonempty, soNPM_CONFIG_REGISTRY=' https://packagefeedproxy.microsoft.io/npm/ 'is accepted by bootstrap but leaves this property empty and still uses the public registry. Match against$(NPM_CONFIG_REGISTRY.Trim())and add that parity case.
tools/CopilotNpmRegistry.props:108
- The reference resolver's
-matchis case-insensitive (tools/BootstrapFeedResolver.ps1:41-45), but this pattern only applies case-insensitivity to the scheme/host; both literalregistrytokens remain lowercase. ARegistry=https://packagefeedproxy.microsoft.io/npm/assignment is therefore selected by bootstrap but ignored here, leaving plain builds on the public default and breaking the claimed differential parity. Make both key matches case-insensitive and add this form to the corpus.
and Exists('$(_ReactorNpmUserConfig)')">$([System.Text.RegularExpressions.Regex]::Match($([System.IO.File]::ReadAllText('$(_ReactorNpmUserConfig)')), '(?m)(?<=^[ \t]*registry[ \t]*=[ \t]*["\x27]?)(?i:https://packagefeedproxy\.microsoft\.io)(?::(?:6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|\d{1,4}))?(?:/[^\s?#"\x27]*)?(?=["\x27]?[ \t]*\r?$)(?![\s\S]*^[ \t]*registry[ \t]*=)'))</CopilotNpmRegistryUrl>
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
🧪 Merged coverageCoverage for
No coverage change beyond the noise floor. ✅ ✅ higher / |
* The `registry` key was matched case-sensitively. PowerShell's -match is
case-insensitive by default, so the bootstrap resolver honors `Registry=`
and `REGISTRY=`; this ignored both and left the developer on the blocked
public registry. Both key matches are now (?i:registry).
* The environment arm matched the raw NPM_CONFIG_REGISTRY while the .npmrc
arm was gated on its trimmed value, so a padded
' https://packagefeedproxy.microsoft.io/npm/ ' matched neither and
resolved to nothing, where bootstrap trims and accepts. Now matched on the
trimmed value.
* The default user-config fallback used SpecialFolder.UserProfile, but
bootstrap reads $env:USERPROFILE. With USERPROFILE redirected the two read
different files. Now $(UserProfile), which is both faithful and testable --
SpecialFolder ignores a redirect, which is precisely why that branch had no
real coverage before.
Suite is 73 assertions. Mutation-checking the fallback exposed that the new
USERPROFILE test was vacuous as first written: it asserted the same URL the
developer's real ~/.npmrc almost certainly contains, so it passed whether the
redirect was honored or ignored. The redirected profile now carries a distinct
URL, and reverting to SpecialFolder reddens it with `actual` showing the real
profile's value -- the divergence made visible rather than argued.
Two findings were not acted on, with evidence:
* "Apostrophe in NPM_CONFIG_USERCONFIG breaks evaluation" was raised again,
now as a live comment. It is still incorrect: MSBuild fixes property-
function argument boundaries before expanding $(...), so the apostrophe
never participates in parsing. Verified end-to-end through the real props
with NPM_CONFIG_USERCONFIG=C:\...\O'Brien\.npmrc, which resolves the mirror.
Added as a permanent regression case rather than re-argued a third time.
* The variable-length-lookbehind claim from round 1 remains false; .NET
permits unbounded lookbehind and the whole suite exercises it.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7432aaab-174d-4637-afc8-13428c4eb099
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate correctness and parity issues remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
Previously missed (1) — in code that hasn't changed since the last review.
tools/CopilotNpmRegistry.props:112
- This port-range regex rejects valid zero-padded ports such as
https://packagefeedproxy.microsoft.io:00080/npm/:Uri.TryCreateaccepts that as port 80, soResolve-ReactorNpmRegistryselects the mirror while this MSBuild path silently leaves the public default. The same pattern is duplicated in the.npmrcbranch; validate with URI parsing or otherwise normalize leading zeros, and add a parity case.
Directory.Build.props:134
- This hunk corrects the
sample-onlyexplanation, but the same stale claim remains inbuild/pipelines/OneBranch.CI.Reactor.yml:39-44andbuild/pipelines/OneBranch.Official.Reactor.yml:60-64, where the native binary is still documented as needed only bydemo-script-tool. Those comments now contradict the three-project list here and can continue to mislead maintainers about the shipped CLI's runtime dependency; update the duplicated pipeline comments with this correction.
"Runtime artifact" is NOT the same as "sample-only", so do not reach for
CopilotSkipCliDownload=true as a local fix for a failing download. Three projects
reference the SDK, and the first is the shipped CLI, not a sample:
tests/build_props/ci/CopilotNpmRegistry.Tests.ps1:344
- The same
Environment.GetFolderPath('UserProfile')assumption makes this default-path assertion fail when USERPROFILE is intentionally redirected, even though the props is supposed to follow that environment variable. Compute$expectedDefaultfrom$env:USERPROFILEso this test matches the behavior it documents.
$expectedDefault = Join-Path ([Environment]::GetFolderPath('UserProfile')) '.npmrc'
Assert-Equal $expectedDefault (Invoke-ProbeProperty -Property 'USERCONFIG') `
'with NPM_CONFIG_USERCONFIG unset the user config path falls back to ~/.npmrc'
tools/CopilotNpmRegistry.props:115
- The last-assignment lookahead treats any later
registry=line as an override, including a blank assignment.Resolve-ReactorNpmRegistryonly updates on a non-whitespace value (tools/BootstrapFeedResolver.ps1:42-45), so an.npmrccontainingregistry=<mirror>followed byregistry=resolves to the mirror in bootstrap but to an empty value here. Make the lookahead require a non-whitespace value and add that case to the differential corpus.
and Exists('$(_ReactorNpmUserConfig)')">$([System.Text.RegularExpressions.Regex]::Match($([System.IO.File]::ReadAllText('$(_ReactorNpmUserConfig)')), '(?m)(?<=^[ \t]*(?i:registry)[ \t]*=[ \t]*["\x27]?)(?i:https://packagefeedproxy\.microsoft\.io)(?::(?:6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|\d{1,4}))?(?:/[^\s?#"\x27]*)?(?=["\x27]?[ \t]*\r?$)(?![\s\S]*^[ \t]*(?i:registry)[ \t]*=)'))</CopilotNpmRegistryUrl>
- Files reviewed: 5/5 changed files
- Comments generated: 3
- Review effort level: Lite
…eview round 3)
The important one is not a parity nit. MSBuild's Exists() is true for
directories as well as files, so a NPM_CONFIG_USERCONFIG pointing at a
directory passed the guard, reached the nested ReadAllText, and threw:
error MSB4184: The expression "[System.IO.File]::ReadAllText(<dir>)" cannot
be evaluated. Access to the path '<dir>' is denied.
Because this props is imported by the root Directory.Build.props, that failed
EVERY project evaluation in the repo over one stray npm setting, while
Resolve-ReactorNpmRegistry (Test-Path -PathType Leaf) simply ignores such a
path. Now gated on System.IO.File::Exists. Verified: with a directory as the
user config, `-getProperty:CopilotNpmRegistryUrl` exits 0 and yields the public
default instead of failing the build.
Three further parity gaps against the resolver:
* Zero-padded ports. Uri.TryCreate reads :00080 as port 80 and bootstrap
selects the mirror; the bounded alternation rejected it. Added a leading 0*,
which still rejects :065536 exactly as Uri.TryCreate does.
* A trailing valueless `registry=` was treated as an override, clearing an
earlier mirror. The resolver's line pattern captures `(\S.*?)`, so a blank
or whitespace-only value never overwrites. The last-assignment lookahead now
requires a non-whitespace value.
* The suite derived its expected default user-config path from
GetFolderPath('UserProfile') while the props follows $(UserProfile). Those
agree on an ordinary machine and diverge on exactly the redirected-profile
setup the props exists to support, so the test would have failed a correct
implementation. Now derived from $env:USERPROFILE.
Also corrects two stale comments that duplicated the "demo-script-tool only"
claim fixed earlier in Directory.Build.props, in OneBranch.CI.Reactor.yml and
OneBranch.Official.Reactor.yml, and the workflow header comment that still
described the old SpecialFolder.UserProfile resolution.
Suite is 83 assertions, with the differential corpus extended to 27 cases.
Mutation-checked: reverting the port and blank-override changes reddens exactly
the six assertions that claim to cover them. The directory fix has its own
positive control -- the pre-fix behaviour is an MSB4184 hard error, measured
directly, not an assertion that silently agrees.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7432aaab-174d-4637-afc8-13428c4eb099
There was a problem hiding this comment.
🔵 Needs a closer look
The mirror parser does not match the reference resolver for valid registry URLs containing apostrophes in the path.
Review details
Suppressed comments (1)
tools/CopilotNpmRegistry.props:129
- This
.npmrcbranch is not fully equivalent toResolve-ReactorNpmRegistryfor valid registry URLs containing an apostrophe in the path. The reference resolver accepts any URL thatUri.TryCreateaccepts and only rejects userinfo/query/fragment (tools/BootstrapFeedResolver.ps1:7-11), soregistry=https://packagefeedproxy.microsoft.io/npm/o'clock/is selected there; the path character class on this line excludes apostrophes, so the end assertion fails and plain MSBuild stays on the public default. Either make the parser preserve apostrophes in unquoted/quoted URL paths or explicitly align the reference policy, and add this case to the differential corpus.
and $([System.IO.File]::Exists('$(_ReactorNpmUserConfig)'))">$([System.Text.RegularExpressions.Regex]::Match($([System.IO.File]::ReadAllText('$(_ReactorNpmUserConfig)')), '(?m)(?<=^[ \t]*(?i:registry)[ \t]*=[ \t]*["\x27]?)(?i:https://packagefeedproxy\.microsoft\.io)(?::0*(?:6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|\d{1,4}))?(?:/[^\s?#"\x27]*)?(?=["\x27]?[ \t]*\r?$)(?![\s\S]*^[ \t]*(?i:registry)[ \t]*=[ \t]*\S)'))</CopilotNpmRegistryUrl>
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
Resolve-ReactorNpmRegistry accepts any URL Uri.TryCreate accepts, rejecting only userinfo/query/fragment, so `registry=https://packagefeedproxy.microsoft.io/npm/o'clock/` is a valid mirror there. The .npmrc path class excluded apostrophes outright, so MSBuild stayed on the public default for it. The apostrophe could not simply be allowed: a single-quoted value (`registry='https://.../npm/'`) ends on one, and a greedy path would swallow the closing quote. The path group is now `(?:/(?:[^\s?#"]*[^\s?#"\x27])?)?` -- an apostrophe may appear inside the path but the value may not END on one, which keeps a closing quote distinguishable from path data while accepting the legal URL. A bare `/` path still matches, which the optional inner group is there for. Differentially verified against the resolver across 21 .npmrc shapes (quoting, ports, overrides, spoof controls): 0 mismatches. Suite is 87 assertions; reverting the path group reddens exactly the new assertion and its parity case. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7432aaab-174d-4637-afc8-13428c4eb099
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved moderate findings remain in locale-sensitive regex handling and test isolation.
Review details
Suppressed comments (4)
tests/build_props/ci/CopilotNpmRegistry.Tests.ps1:376
- These path-only probes leave
USERPROFILEuntouched, so a developer machine with~/.npmrccauses the productionReadAllTextbranch to consume the real config despite the test header promising no real-config reads. That makes the suite dependent on arbitrary local registry contents and feeds user credentials into evaluation; use the synthetic$redirectedprofile for both default-path assertions instead.
Assert-Equal (Join-Path $env:USERPROFILE '.npmrc') `
(Invoke-ProbeProperty -Property 'USERCONFIG' `
-Environment @{ NPM_CONFIG_USERCONFIG = ' ' }) `
'an all-whitespace NPM_CONFIG_USERCONFIG falls back to the default path'
tests/build_props/ci/CopilotNpmRegistry.Tests.ps1:158
- The phrase “BOTH orderings must land on the proxy” contradicts the following
proxyThenPubliccase, which intentionally expects no mirror when the later public assignment wins. Please describe only thepublicThenProxyordering here so the test rationale does not mislead future changes.
# npm lets a later line override an earlier one, so BOTH orderings must land
# on the proxy. A "first registry= line wins, then validate the host" reading
# would silently fail the first of these — the exact shape the bootstrap
# resolver's own tests pin.
tools/CopilotNpmRegistry.props:134
- This
Regex.Matchuses the inline(?i:...)flag withoutRegexOptions.CultureInvariant. Ignore-case matching is current-culture-sensitive; undertr-TR, the existing uppercaseHTTPS://PACKAGEFEEDPROXY.MICROSOFT.IO/...case can fail because ASCIII/ido not fold, so a configured mirror is ignored and the build falls back to the blocked public registry. PassCultureInvariantto this overload and add a non-en-US regression case; apply the same fix to the.npmrcregex below.
<CopilotNpmRegistryUrl>$([System.Text.RegularExpressions.Regex]::Match($(NPM_CONFIG_REGISTRY.Trim()), '^(?i:https://packagefeedproxy\.microsoft\.io)(?::0*(?:6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|\d{1,4}))?(?:/[^\s?#]*)?$'))</CopilotNpmRegistryUrl>
tools/CopilotNpmRegistry.props:137
- The
.npmrcparser has the same locale-sensitive(?i:...)matching as the environment branch. On a Turkish-locale build, an uppercase scheme/host that the PowerShell/URI resolver accepts can produce no match here, leavingCopilotNpmRegistryUrlempty and causing the native download to target npmjs.org. AddRegexOptions.CultureInvariantto thisRegex.Matchcall (the existing inline(?m)can remain) and cover it with a non-en-US test.
and $([System.IO.File]::Exists('$(_ReactorNpmUserConfig)'))">$([System.Text.RegularExpressions.Regex]::Match($([System.IO.File]::ReadAllText('$(_ReactorNpmUserConfig)')), '(?m)(?<=^[ \t]*(?i:registry)[ \t]*=[ \t]*["\x27]?)(?i:https://packagefeedproxy\.microsoft\.io)(?::0*(?:6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|\d{1,4}))?(?:/(?:[^\s?#"]*[^\s?#"\x27])?)?(?=["\x27]?[ \t]*\r?$)(?![\s\S]*^[ \t]*(?i:registry)[ \t]*=[ \t]*\S)'))</CopilotNpmRegistryUrl>
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
…und 5)
The scheme/host and `registry` key were matched with (?i:...). .NET ignore-case
folding is culture-sensitive, and that turns out to cost correctness in both
directions:
* Under tr-TR, ASCII `I` folds to dotless `ı`, so
HTTPS://PACKAGEFEEDPROXY.MICROSOFT.IO -- which Resolve-ReactorNpmRegistry
accepts -- stops matching, silently dropping the mirror and sending the build
back to the blocked public registry. Measured: (?i:...) returns False under
tr-TR and True under en-US for that input.
* More seriously, folding made the host matcher accept a Unicode homograph.
`https://packagefeedproxy.microsoft.Io/npm/` (dotted capital I, U+0130)
matches under (?i:...) on en-US -- so the allow-list could be satisfied by a
host that is not the mirror. That is the same class of hole the trailing-'/'
anchor exists to close.
RegexOptions.CultureInvariant would fix the first but not the second, and is
unreachable anyway: MSBuild's property-function allow-list has no three-argument
Regex::Match overload (MSB4186, measured). Both patterns now spell case
-insensitivity with explicit [Aa] classes, which match exactly the 26 ASCII
letters under every locale and fold nothing.
Verified identical results under en-US and tr-TR for lower, upper and mixed case,
with microsoft.io (dotless), mIcrosoft.io and microsoft.Io all rejected in both;
plus 0 mismatches against the resolver across 22 .npmrc shapes.
Also from this round:
* The two user-config path probes left USERPROFILE alone, so the production
ReadAllText branch consumed the developer's real ~/.npmrc -- contradicting the
suite header and running real feed credentials through evaluation. Both now
drive the synthetic redirected profile, so the suite is hermetic as claimed.
* Corrected a comment claiming "BOTH orderings must land on the proxy", which
contradicted the very next case after last-assignment-wins landed.
Suite is 91 assertions. Reverting the host classes to (?i:...) reddens the
homograph assertion, with `actual` showing the spoof host being accepted.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7432aaab-174d-4637-afc8-13428c4eb099
There was a problem hiding this comment.
🔵 Needs a closer look
The resolver needs ASCII-only port validation and trailing-quote parity with the reference resolver.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
tools/CopilotNpmRegistry.props:149
- The port grammar uses
\d, which in .NET matches every Unicode decimal digit, not just the ASCII digits accepted by URI port syntax. A value such ashttps://packagefeedproxy.microsoft.io:١٢٣/npm/can therefore pass this regex and be exported asCopilotNpmRegistryUrleven thoughResolve-ReactorNpmRegistry/Uri.TryCreatetakes the no-mirror path, causing a malformed download URL instead of parity. Use[0-9]throughout the port alternatives and add a non-ASCII-digit parity case; the same correction is needed in the.npmrcexpression below.
tools/CopilotNpmRegistry.props:152
- The
.npmrcparser is not fully parity-compatible withResolve-ReactorNpmRegistryfor a registry value whose unquoted URL ends in a quote character. The resolver captures the value and then calls.Trim('"').Trim("'")(tools/BootstrapFeedResolver.ps1:42-44), soregistry=https://packagefeedproxy.microsoft.io/npm/foo'resolves to the mirror, whereas this regex rejects a path ending in'(and likewise") and leaves the property empty. That makes the claimed differential contract false for a value the reference resolver accepts; either align the quote handling in both implementations or explicitly reject it in both, and add it to the parity corpus.
and $([System.IO.File]::Exists('$(_ReactorNpmUserConfig)'))">$([System.Text.RegularExpressions.Regex]::Match($([System.IO.File]::ReadAllText('$(_ReactorNpmUserConfig)')), '(?m)(?<=^[ \t]*[Rr][Ee][Gg][Ii][Ss][Tt][Rr][Yy][ \t]*=[ \t]*["\x27]?)[Hh][Tt][Tt][Pp][Ss]://[Pp][Aa][Cc][Kk][Aa][Gg][Ee][Ff][Ee][Ee][Dd][Pp][Rr][Oo][Xx][Yy]\.[Mm][Ii][Cc][Rr][Oo][Ss][Oo][Ff][Tt]\.[Ii][Oo](?::0*(?:6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|\d{1,4}))?(?:/(?:[^\s?#"]*[^\s?#"\x27])?)?(?=["\x27]?[ \t]*\r?$)(?![\s\S]*^[ \t]*[Rr][Ee][Gg][Ii][Ss][Tt][Rr][Yy][ \t]*=[ \t]*\S)'))</CopilotNpmRegistryUrl>
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
Outside RegexOptions.ECMAScript, .NET `\d` matches every Unicode decimal digit, not just ASCII. A port written in Arabic-Indic digits therefore satisfied the port grammar and was exported as CopilotNpmRegistryUrl, while Uri.TryCreate -- and so Resolve-ReactorNpmRegistry -- rejects it. The SDK would then have been handed a malformed download URL instead of falling back to no mirror. Measured: https://packagefeedproxy.microsoft.io:١٢٣/npm/ bootstrap [] \d https://packagefeedproxy.microsoft.io:١٢٣/npm/ [0-9] [] All six port alternatives now use [0-9]. This is the same family as the homograph fix in the previous commit: character classes in this file must mean ASCII, because the thing on the other side of the comparison is a URI parser. The round's other suggestion -- that an unquoted value ending in a quote character diverges -- does not reproduce. The resolver Trim()s quotes unconditionally, and the path group backtracks so a trailing quote falls to the lookahead, so both sides yield the same trimmed value. Verified for both `'` and `"` and added to the suite and the differential corpus so the equivalence is pinned rather than re-litigated. Suite is 95 assertions. Reverting [0-9] to \d reddens the new assertion, with `actual` showing the Arabic-Indic port being accepted. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7432aaab-174d-4637-afc8-13428c4eb099
There was a problem hiding this comment.
🔵 Needs a closer look
Update workflow filters for Directory.Packages.props and strengthen SDK target integration coverage.
Review details
Suppressed comments (1)
.github/workflows/build-props-lib-tests.yml:26
- This job is the only check that evaluates the mirror property—the ordinary matrix sets
CI=true, which makes the new props inert—but these filters omitDirectory.Packages.props. A futureGitHub.Copilot.SDKversion change can alter or remove theCopilotNpmRegistryUrlcontract while this job stays skipped and normal CI remains green. Add the package-version file to bothpushandpull_requestfilters, and preferably make the integration probe exercise the restored SDK target as well.
- 'tools/CopilotNpmRegistry.props'
# The suite's differential corpus dot-sources this resolver and requires
# the props to agree with it, so a change there can invalidate the claimed
# parity while this job stays skipped.
- 'tools/BootstrapFeedResolver.ps1'
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
…r (round 7)
This job is the only check that evaluates CopilotNpmRegistryUrl at all -- the
ordinary matrix sets CI=true, which makes the props inert -- and its path filters
omitted Directory.Packages.props. A GitHub.Copilot.SDK version bump could
therefore rename or drop the property this whole file exists to set, with the
dedicated job skipped and normal CI green.
Two changes close that:
* Directory.Packages.props is now in both push and pull_request filters, so the
job runs when the SDK version moves.
* The suite pins the reviewed SDK version (1.0.11). Reading the SDK's own
targets instead would require a package restore and a reachable feed, costing
this suite the hermeticity that makes it reliable; pinning keeps it offline
while still forcing a human to re-verify. The assertion names exactly what to
re-check on failure -- that CopilotNpmRegistryUrl, CopilotSkipCliDownload and
CopilotCliBinaryPath still behave as tools/CopilotNpmRegistry.props assumes.
The pin cannot pass vacuously: a broken extraction yields an empty string, which
does not equal the expected version.
Suite is 96 assertions.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7432aaab-174d-4637-afc8-13428c4eb099
There was a problem hiding this comment.
🔵 Needs a closer look
Two moderate resolver parity issues remain unresolved.
Review details
Suppressed comments (2)
tools/CopilotNpmRegistry.props:157
- The
.npmrcregex does not preserve the resolver's handling of a double-quoted URL whose path legitimately ends in an apostrophe, e.g.registry="https://packagefeedproxy.microsoft.io/npm/foo'".Resolve-ReactorNpmRegistrystrips the surrounding double quote and then the trailing apostrophe before validating (tools/BootstrapFeedResolver.ps1:42-44), so it selects the mirror, but this pattern cannot consume the apostrophe as path data or as the double-quote delimiter and returns no match. That makes plain MSBuild silently fall back to npmjs for a configuration the bootstrap path accepts; add this case to the parity corpus and adjust the delimiter/path pattern (or align the resolver) so both paths agree.
and $([System.IO.File]::Exists('$(_ReactorNpmUserConfig)'))">$([System.Text.RegularExpressions.Regex]::Match($([System.IO.File]::ReadAllText('$(_ReactorNpmUserConfig)')), '(?m)(?<=^[ \t]*[Rr][Ee][Gg][Ii][Ss][Tt][Rr][Yy][ \t]*=[ \t]*["\x27]?)[Hh][Tt][Tt][Pp][Ss]://[Pp][Aa][Cc][Kk][Aa][Gg][Ee][Ff][Ee][Ee][Dd][Pp][Rr][Oo][Xx][Yy]\.[Mm][Ii][Cc][Rr][Oo][Ss][Oo][Ff][Tt]\.[Ii][Oo](?::0*(?:6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[0-9]{1,4}))?(?:/(?:[^\s?#"]*[^\s?#"\x27])?)?(?=["\x27]?[ \t]*\r?$)(?![\s\S]*^[ \t]*[Rr][Ee][Gg][Ii][Ss][Tt][Rr][Yy][ \t]*=[ \t]*\S)'))</CopilotNpmRegistryUrl>
tools/CopilotNpmRegistry.props:157
- These matches preserve a trailing
/, butResolve-ReactorNpmRegistrystrips trailing slashes before passing-p:CopilotNpmRegistryUrl(tools/BootstrapFeedResolver.ps1:54-56). A normal.npmrctherefore produces a different SDK input; the parity loop currently hides that mismatch by calling.TrimEnd('/')on the actual value at tests/build_props/ci/CopilotNpmRegistry.Tests.ps1:568. Normalize the selected value withTrimEnd('/')in both branches and assert the exact normalized contract, avoiding a possible double slash when the SDK appends the package path.
<CopilotNpmRegistryUrl>$([System.Text.RegularExpressions.Regex]::Match($(NPM_CONFIG_REGISTRY.Trim()), '^[Hh][Tt][Tt][Pp][Ss]://[Pp][Aa][Cc][Kk][Aa][Gg][Ee][Ff][Ee][Ee][Dd][Pp][Rr][Oo][Xx][Yy]\.[Mm][Ii][Cc][Rr][Oo][Ss][Oo][Ff][Tt]\.[Ii][Oo](?::0*(?:6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[0-9]{1,4}))?(?:/[^\s?#]*)?$'))</CopilotNpmRegistryUrl>
<CopilotNpmRegistryUrl Condition="'$(CopilotNpmRegistryUrl)' == ''
and '$(NPM_CONFIG_REGISTRY.Trim())' == ''
and $([System.IO.File]::Exists('$(_ReactorNpmUserConfig)'))">$([System.Text.RegularExpressions.Regex]::Match($([System.IO.File]::ReadAllText('$(_ReactorNpmUserConfig)')), '(?m)(?<=^[ \t]*[Rr][Ee][Gg][Ii][Ss][Tt][Rr][Yy][ \t]*=[ \t]*["\x27]?)[Hh][Tt][Tt][Pp][Ss]://[Pp][Aa][Cc][Kk][Aa][Gg][Ee][Ff][Ee][Ee][Dd][Pp][Rr][Oo][Xx][Yy]\.[Mm][Ii][Cc][Rr][Oo][Ss][Oo][Ff][Tt]\.[Ii][Oo](?::0*(?:6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[0-9]{1,4}))?(?:/(?:[^\s?#"]*[^\s?#"\x27])?)?(?=["\x27]?[ \t]*\r?$)(?![\s\S]*^[ \t]*[Rr][Ee][Gg][Ii][Ss][Tt][Rr][Yy][ \t]*=[ \t]*\S)'))</CopilotNpmRegistryUrl>
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
… (round 8)
Resolve-ReactorNpmRegistry TrimEnd('/')s the selected URL before passing
-p:CopilotNpmRegistryUrl, and this props did not, so the two entry points handed
the SDK two spellings of the same URL. Functionally harmless -- the SDK trims
again before appending the package path -- but it made "matches the resolver" not
quite true.
The more useful half of the finding is about the test. The differential loop
called .TrimEnd('/') on the ACTUAL value before comparing, so the oracle
normalised away exactly the difference it existed to detect. That is the same
failure mode as an assertion whose healthy and broken branches coincide, and it
had been hiding a real (if benign) divergence for six rounds.
Both are fixed: the props normalizes once, after the allow-list has passed and
therefore on a value that carries no credentials, and the parity loop now
compares verbatim. Six literal expectations that had encoded the untrimmed form
were updated to the normalized contract.
Declined from this round: a double-quoted value whose URL path ends in an
apostrophe (`registry="https://.../npm/foo'"`). The resolver strips the double
quote and then the apostrophe, so it does diverge -- but supporting it means
making the closing delimiter depend on the opening one inside an already dense
security-relevant pattern, to accept an input nobody will write against a mirror
whose paths are /npm and /nuget. Not worth the regression risk.
Suite is 96 assertions and the differential corpus now compares exact values.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7432aaab-174d-4637-afc8-13428c4eb099
There was a problem hiding this comment.
🔵 Needs a closer look
Address the invalid environment URL handling and clarify the documented skip behavior.
Review details
Suppressed comments (2)
README.md:104
- The new README escape hatch omits the runtime consequence that this PR documents elsewhere: setting
CopilotSkipCliDownload=truealso removes the binary used bymur loc translateand both Copilot SDK samples (Directory.Build.props:132-147). A contributor following this advice to get past a blocked build will produce a CLI/sample that fails later at runtime; qualify this as compile-only and point users to the mirror option for a usable local build.
The same npm mirror detection also applies to plain `dotnet build` / `dotnet run` / `dotnet test`, which never pass through bootstrap. `src/Reactor.Cli` and two samples reference `GitHub.Copilot.SDK`, whose build targets download a native `copilot` CLI from `registry.npmjs.org`; on a network that blocks it, that download fails the build itself with `MSB3923`. [`tools/CopilotNpmRegistry.props`](tools/CopilotNpmRegistry.props) resolves the mirror from the same user configuration at evaluation time, so every documented command — including the `mur docs compile` the docs freshness gate asks you to run — works unmodified on both networks. Set `CopilotNpmRegistryUrl` explicitly to use a different mirror, or `CopilotSkipCliDownload=true` to skip the native CLI entirely (CI does the latter).
tools/CopilotNpmRegistry.props:154
- The environment-variable branch does not apply the same URI validation as
Resolve-ReactorNpmRegistry:[^ \s?#]*permits an unescaped"in the path, soNPM_CONFIG_REGISTRY=https://packagefeedproxy.microsoft.io/npm/foo"is exported even thoughTest-ReactorPackageFeedUrlrejects it viaUri.TryCreate(tools/BootstrapFeedResolver.ps1:4-11). That sends an invalid registry URL to the SDK instead of taking the no-mirror path; exclude"in this branch and add the corresponding environment case (the existing trailing-quote case only exercises.npmrc).
<CopilotNpmRegistryUrl>$([System.Text.RegularExpressions.Regex]::Match($(NPM_CONFIG_REGISTRY.Trim()), '^[Hh][Tt][Tt][Pp][Ss]://[Pp][Aa][Cc][Kk][Aa][Gg][Ee][Ff][Ee][Ee][Dd][Pp][Rr][Oo][Xx][Yy]\.[Mm][Ii][Cc][Rr][Oo][Ss][Oo][Ff][Tt]\.[Ii][Oo](?::0*(?:6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[0-9]{1,4}))?(?:/[^\s?#]*)?$'))</CopilotNpmRegistryUrl>
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
…round 9) The README offered CopilotSkipCliDownload=true alongside the mirror as a way past a blocked build, without the runtime consequence this PR documents in Directory.Build.props: it omits the native binary that `mur loc translate` and both Copilot SDK samples start at run time. A contributor following the README to unblock a build would have produced a CLI that fails later, which is the exact trap the Directory.Build.props comment was rewritten to close. Now qualified as compile-only, with the mirror recommended for a working local CLI. The round's other suggestion -- excluding `"` from the environment path class because Uri.TryCreate supposedly rejects it -- does not hold. Measured: NPM_CONFIG_REGISTRY=https://packagefeedproxy.microsoft.io/npm/foo" bootstrap https://packagefeedproxy.microsoft.io/npm/foo" current pattern https://packagefeedproxy.microsoft.io/npm/foo" (agree) with `"` excluded <empty> (diverge) Uri.TryCreate accepts a double quote in the path, so Test-ReactorPackageFeedUrl does not reject it; applying the suggestion would have CREATED the divergence it was meant to remove. Added as a pinned assertion so the agreement is a measured fact rather than a recurring argument. Suite is 97 assertions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7432aaab-174d-4637-afc8-13428c4eb099
Cut the 0.1.0-preview.16 release per docs/contributing/release-runbook.md.
- CHANGELOG: rename [Unreleased] to [0.1.0-preview.16] - 2026-09-22 and open a
fresh [Unreleased] with all six Keep-a-Changelog buckets.
- Directory.Build.props: bump <ReactorPublicVersion> 0.1.0-preview.15 ->
0.1.0-preview.16. That single property is the source of truth.
- docs/guide: recompiled via `mur docs compile --skip-screenshots
--skip-diagrams`. Exactly 12 {{reactorVersion}} token substitutions across 8
pages; no snippet churn.
Changelog audit of the 28 commits since v0.1.0-preview.15 found no gaps. The
three commits that touched shipped source without a changelog entry were each
dispositioned: #1182 and #1181 are already covered by entries their companion
PRs (#1266, #1215) added, and #1243 is build infrastructure only. All seven
Fixed entries were verified against v0.1.0-preview.15 to confirm each bug
genuinely shipped broken rather than being introduced and fixed inside this
window.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Problem
GitHub.Copilot.SDK's build targets download a nativecopilot.exevia an MSBuild<DownloadFile>, hard-defaulting toregistry.npmjs.org. On a network that blocks that host, the download fails the build:That takes out every entry point building
src/Reactor.Clior the two samples referencing the SDK — including the command the docs freshness gate inci.ymltells contributors to run by hand:bootstrap.ps1already solved this for its own restores viaResolve-ReactorNpmRegistry, but a plaindotnet build/dotnet run/dotnet testnever passes through bootstrap, so the same developer stayed blocked.Approach
tools/CopilotNpmRegistry.props(imported byDirectory.Build.props) applies that existing policy at MSBuild evaluation time. Every documented command then works unmodified on both networks — no new script, no new flag, no CI change.A wrapper script was the alternative. It was rejected because it would fix only
docs compile, would require changing the CI failure message, and would leavedotnet build/dotnet test/ the samples broken.CopilotNpmRegistryUrl~/.npmrc/NPM_CONFIG_REGISTRYhttps://registry.npmjs.org— unchangedCI=true)CopilotSkipCliDownload=truealready skips the downloadContributors on an unrestricted network configure none of this, match nothing, and keep the SDK's default.
Policy
Mirrors
Resolve-ReactorNpmRegistry:NPM_CONFIG_REGISTRYshort-circuits the user.npmrc; within the.npmrcthe lastregistry=assignment wins and surrounding quotes are stripped; the value must be https, on the allow-listed host, with no userinfo, query, or fragment. Scheme and host compare case-insensitively and an explicit port is allowed, because the resolver compares already-normalizedSystem.Uri.Scheme/.Host.Two properties are load-bearing and easy to regress:
/or the end of the value. An unconstrained tail letspackagefeedproxy.microsoft.io.evil.examplematch its own prefix, handing a native-binary download to an attacker-controlled host. Once a port is permitted, that same anchor is also what stopshost:443@evil.examplesmuggling in a foreign host via userinfo..npmrcis mostly_password=feed tokens. The.npmrcread is nested inside theRegex.Matchargument so its contents never become a named property — which would publish them into every shared binlog.Testing
tests/build_props/ci/CopilotNpmRegistry.Tests.ps1— 59 assertions:Directory.Build.props, so deleting the import fails the suite;.npmrcreaches a-v:diaglog, with a positive control;.npmrccorpus to both this file andResolve-ReactorNpmRegistryand requires them to agree.CI never exercises any of this —
CI=truemakes the props inert — so without the suite the file could rot unnoticed. Hence the dedicated path-filtered workflow.Five mutations confirmed the oracles are live: relaxing the host anchor, dropping last-assignment-wins, re-allowing query/fragment, removing case/port handling, and hoisting the
.npmrcread into a named property each reddened exactly the assertions that claim to cover them.Verified end-to-end on a clean tree: the failing command above now completes, fetching the tarball through the mirror.
Also
Corrects the neighbouring
CopilotSkipCliDownloadcomment, which claimed the native binary was only needed by the demo-script-tool sample.mur loc translatestarts it too (CopilotTranslationProvider→new CopilotClient()), so a local blanket skip breaks the shipped CLI.Note for reviewers
While tracing that comment I noticed a possible pre-existing tension, not touched by this PR:
Directory.Build.propsskips the download wheneverCI=true, yetdocs/security/threat-model.mdstates the binary is present in the releasedmurpublish artifact. Those can't both hold unless the release path overrides the skip. Might be worth a separate look.