Skip to content

fix(install): refuse ConstrainedLanguage with a reason instead of a .NET error - #90128

Open
jackulau wants to merge 1 commit into
NousResearch:mainfrom
jackulau:fix/89857-install-constrained-language-mode
Open

jackulau wants to merge 1 commit into
NousResearch:mainfrom
jackulau:fix/89857-install-constrained-language-mode

Conversation

@jackulau

Copy link
Copy Markdown
Contributor

Read this first: it does not make the install work

Refs #89857, not Fixes. This does not let anyone install under Constrained Language Mode. It makes the failure legible, and it stops the installer from dying in a way that points the operator at the wrong thing.

Whether Hermes should support CLM at all is a maintainer decision, and I have put the measurements for it at the bottom rather than deciding it in a PR.

What does this PR do?

install.ps1 makes more than forty method calls on non-core .NET types. Constrained Language Mode refuses every one of them. The first is at script scope, so it fires before the script has looked at a single parameter:

$script:NormalizedProfilePaths = Set-LongProfileEnvVars   # line ~327, runs on load
    -> foreach ($name in @('TEMP','TMP','LOCALAPPDATA','APPDATA','USERPROFILE')) {
           $current = [Environment]::GetEnvironmentVariable($name)   # line 312 -- refused

That is why the report's log shows stage=__manifest__: -Manifest is a read-only query that touches nothing on disk, and it still cannot answer. Same for -ProtocolVersion. The bootstrap gets exit 1 and forwards a raw .NET error, localized into the host language, naming line 312 of a file in AppData\Local\hermes\bootstrap-cache\ that the operator did not write. Nothing in that output contains the words "language mode".

This adds a preflight at the top of the script that detects the mode and says so. Every construct in it is CLM-legal: a property read on an automatic variable, string concatenation, Write-Host, and Write-Error. $host.UI.WriteErrorLine is not legal there, and neither is [Console]::Error.WriteLine (which Write-PathDiag uses) -- both are method calls on non-core types and would throw the exact error the block exists to explain.

Related Issue

Refs #89857

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Security fix
  • Documentation update
  • Tests (adding or improving test coverage)
  • Refactor (no behavior change)
  • New skill (bundled or hub)

Changes Made

  • scripts/install.ps1 - a language mode preflight immediately after $ErrorActionPreference = "Stop", before the UTF-8 console block and well before the 8.3 normalization that currently kills the run. Fires on anything that is not FullLanguage, prints an explanation, and exits 1. 57 lines added, 0 changed - no existing line is touched.
  • scripts/tests/test-install-ps1-language-mode.ps1 - new, following the Assert-Equal/Assert-True shape of the three tests already in that directory.
  • .github/workflows/installer-tests.yml - runs the new file on pwsh 7 and Windows PowerShell 5.1, the same pair the 8.3 test already uses.

What the operator sees now

[X] Hermes cannot install in PowerShell ConstrainedLanguage mode.

    This session is restricted by an application control policy
    (AppLocker, WDAC, or Windows Defender Application Control).
    In this mode PowerShell refuses the .NET calls the installer
    needs to read environment variables and locate profile folders.

    -ExecutionPolicy Bypass does not help. Execution policy and
    language mode are separate controls; bypassing the first
    leaves the second exactly as it was.

    PowerShell applies the restriction because this script sits in
    a user-writable directory that the policy does not trust. Ask
    whoever administers the policy to either:

      * allow-list the installer's path, or
      * let you run it from a directory the policy already trusts
        (typically %ProgramFiles% or %SystemRoot%).

    Confirm the fix worked before re-running:
      $ExecutionContext.SessionState.LanguageMode
    must print FullLanguage.

The -ExecutionPolicy Bypass sentence is there because it is the reporter's own step 2. They tried it, it did not help, and nothing told them why. It is the sentence I would most want kept if the message gets trimmed.

Three decisions worth a maintainer's eye

1. It refuses rather than degrades. A partial port that made line 312 CLM-safe would move the crash to the next blocked call and leave a half-configured tree behind. I would rather the installer stop while it can still explain itself. If you would prefer best-effort-then-fail, the guard becomes a warning and the mutation table below tells you which test to expect to flip (M3).

2. Exit code 1, not a new one. The stage protocol documents 0 success, 1 generic failure, 2 unknown stage. A distinct code (say 3) would let the Rust bootstrap render this specially instead of forwarding text, but that extends a documented contract and drivers would need to learn it. I used 1. Say the word and I will add 3 plus the protocol doc entry.

3. No override flag. There is deliberately no -IgnoreLanguageMode. Anything that got past the guard would fail ~200 lines later with the original error, so the flag's only real effect would be to restore the confusing failure.

How to Test

powershell -NoProfile -ExecutionPolicy Bypass -File scripts\tests\test-install-ps1-language-mode.ps1   # 12/12
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\tests\test-install-ps1-stage-protocol.ps1  # unchanged, passes
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\tests\test-install-ps1-longpath.ps1        # unchanged, passes

No AppLocker or WDAC policy is needed on the runner: the test drops a child runspace into ConstrainedLanguage with $ExecutionContext.SessionState.LanguageMode = 'ConstrainedLanguage', which is the same restriction the policy applies, and runs install.ps1 inside it as a real subprocess.

To see the old behavior, revert scripts/install.ps1 and re-run the first file: five assertions fail and the raw error comes back.

Verification

The failure reproduces exactly, on a real Windows 11 box. Against pristine upstream/main in a constrained child:

Cannot invoke method. Method invocation is supported only on core types in this language mode.
At C:\Users\jackl\AppData\Local\Temp\install-pristine.ps1:312 char:9
+         $current = [Environment]::GetEnvironmentVariable($name)
+         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + FullyQualifiedErrorId : MethodInvocationNotSupportedInConstrainedLanguage

Same line, same character offset, same error id as the report -- in English rather than the reporter's Spanish, which is itself the point: the text an operator gets is host-localized, so it cannot be searched for.

Mutation proof - 6 mutations, 6 caught:

# Mutation Caught by
1 guard removed entirely (the reported bug) 5 assertions
2 condition inverted, fires on FullLanguage instead 9 assertions, including every FullLanguage regression check
3 exit 1 removed, so it warns and continues 2 - the run reaches line 312 and the raw error resurfaces
4 stderr via $host.UI.WriteErrorLine instead of Write-Error 1 - the guard itself throws under CLM
5 the -ExecutionPolicy Bypass sentence dropped 1
6 guard moved after the 8.3 normalization block 5 - placement, not presence, is what makes it work

Mutation 6 is the one worth looking at: the guard is only useful before Set-LongProfileEnvVars runs at script scope, and nothing about the code's appearance says so. That assertion is what stops a future reshuffle from silently reverting this.

The test cannot pass vacuously. Its first two assertions check the harness itself: that the child reports ConstrainedLanguage, and that [Environment]::GetEnvironmentVariable really is refused inside it. Without those, a host where the mode assignment silently did not take would run every remaining assertion against FullLanguage and report green.

Platform: Windows 11, Windows PowerShell 5.1. I could not run pwsh 7 locally - it is not installed on this machine - so the pwsh 7 job added to installer-tests.yml is the first real execution of this file under 7. Flagging that rather than implying I tested both.

scripts/install.ps1 stays pure ASCII with CRLF endings, as its own header requires.

Checklist

Code

Documentation & Housekeeping

  • I've updated relevant documentation or N/A: the block documents itself and names why each construct in it was chosen
  • I've updated cli-config.yaml.example if I added/changed config keys or N/A: no config keys
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows or N/A
  • I've considered cross-platform impact per the compatibility guide: Windows-only file. On any host reporting FullLanguage (which includes every non-Windows PowerShell) the guard is a single string comparison and does nothing
  • I've updated tool descriptions/schemas if I changed tool behavior or N/A

What a real CLM port would cost, if you want one

I measured this before choosing to refuse, and the answer is not "impossible", it is "a decision with trade-offs":

construct count CLM-legal replacement cost
[Environment]::GetEnvironmentVariable($n) (process scope) 5 (Get-Item "Env:$n").Value or $env:$n none - the file already uses Set-Item "Env:$name" for the write one line below the read that fails
[Environment]::GetEnvironmentVariable("Path","User"|"Machine") and the matching SetEnvironmentVariable 18 Get-ItemProperty/Set-ItemProperty on HKCU:\Environment and the machine key loses the WM_SETTINGCHANGE broadcast .NET does, so open shells keep a stale PATH
[Environment]::GetFolderPath(...) 4 no cmdlet equivalent already fallback-only in Get-LongProfileRoot; the shortcut sites at ~4131 have no equivalent
New-Object System.Diagnostics.Process 2 Start-Process changes stdout/stderr capture semantics
[System.IO.File]::WriteAllText($p,$s,$utf8NoBom) 3 Set-Content -Encoding utf8 behavior change on 5.1: it writes a BOM, and these sites pass UTF8Encoding($false) deliberately
Add-Type P/Invoke for GetLongPathNameW 1 none already wrapped in try/catch with two fallbacks, so it degrades today

I verified each replacement in a constrained runspace rather than reasoning about it -- Get-Item Env:, $env:, Get-ItemProperty HKCU:\Environment and ConvertTo-Json are all allowed; [Environment]::*, [Console]::* and $host.UI.WriteErrorLine are all refused.

The two rows that make it a decision rather than a chore are the PATH broadcast and the BOM. Both are silent behavior changes on hosts that are working fine today, in exchange for supporting a locked-down configuration. Happy to do the port as a follow-up if that trade is one you want to make, but it should be its own PR with its own argument, not smuggled in behind an error message.

@jackulau
jackulau requested a review from a team August 19, 2026 16:29
@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard platform/windows Native Windows-specific behavior or breakage area/install-update Installer, updater, packaging, wheels, doctor P2 Medium — degraded but workaround exists sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows labels Aug 19, 2026
@jackulau
jackulau force-pushed the fix/89857-install-constrained-language-mode branch from ca7770e to eadce10 Compare August 19, 2026 16:58
@jackulau

Copy link
Copy Markdown
Contributor Author

The Python tests / e2e red on the first run was a flake, not this branch. Recording what I checked so nobody has to repeat it.

FAILED tests/e2e/test_platform_commands.py::TestSlashCommands::
       test_plaintext_restart_gateway_in_group_stays_plain_text[telegram]
  AssertionError: Expected 'mock' to have been called once. Called 0 times.
=== 1 failed, 60 passed, 7 skipped in 15.22s ===

This branch changes three files: scripts/install.ps1, a new PowerShell test beside it, and the workflow that runs it. Nothing Python, and nothing that Python imports.

Checked rather than asserted, on Windows 11 with --extra dev --extra messaging:

# pristine upstream/main
pytest tests/e2e/test_platform_commands.py::TestSlashCommands -q -p no:randomly
  38 passed, 4 skipped

# this branch
pytest tests/e2e/test_platform_commands.py -q -p no:randomly
  53 passed, 4 skipped

So it is not a red main either, which was the other candidate worth ruling out. GitHub would not let me re-run the single job, so I rebased onto current main (the base had moved twice while the tests ran) and force-pushed, which re-rolls the whole matrix.

The other red, Review label gate, is the maintainer review gate. Its log ends with Set output 'ci_reviewed' / Set output 'review_status' and nothing else, so I have read it as awaiting a reviewer rather than as something this branch can fix. Say so if that is wrong.

@jackulau
jackulau force-pushed the fix/89857-install-constrained-language-mode branch from eadce10 to 02db514 Compare August 20, 2026 01:24
@jackulau

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and force-pushed. Full CI runs now (61 checks) and Installer tests / PowerShell installer tests is green, including the two steps this PR adds. The earlier Python tests / e2e red was on the old base and does not reproduce.

One check is still red, and it needs a maintainer rather than a commit:

Review label gate / Review label gate
##[error]CI-sensitive changes require the ci-reviewed label. Add the label and re-run this check.

review-labels.yml counts .github/workflows/** as CI-sensitive, and ci-reviewed is a label a contributor cannot apply. All required checks pass is red only because it aggregates that one. Everything else in the run is green.

Why there is a workflow hunk here at all

I would rather not touch .github/ in a fix PR, and I considered leaving it out. The reason it is in is written at the top of the file I am editing:

Before this workflow existed the files were in the tree but nothing ever ran them.

scripts/tests/ had exactly that problem until installer-tests.yml was added to solve it. Adding test-install-ps1-language-mode.ps1 without wiring it in recreates the same condition this workflow exists to prevent, and the guard it covers is the one that has to hold on a host where almost nothing else in install.ps1 can run. A test that never executes is not coverage.

What the hunk actually is, so the review is cheap

Thirteen lines, all of them two steps: entries appended to the existing powershell job:

  • no new job, no new workflow, no new runner
  • no new uses:, so no new third-party action and nothing to pin
  • no change to on:, permissions:, concurrency:, or the caller in ci.yaml
  • no secrets, and no network access beyond the checkout that is already there

Both steps invoke a script in scripts/tests/ exactly the way the two steps directly above them already do, once under pwsh and once under Windows PowerShell 5.1. The test drops its own child runspace into ConstrainedLanguage, so no AppLocker or WDAC policy is needed on the runner and nothing about the runner's own configuration changes.

The diff is the whole thing:

      - name: Language mode preflight (pwsh 7)
        shell: pwsh
        run: pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-language-mode.ps1

      - name: Language mode preflight (Windows PowerShell 5.1)
        shell: powershell
        run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-language-mode.ps1

If you would rather not label it

Entirely reasonable, and the alternative is one commit away: say the word and I will drop the workflow hunk, leaving this PR as install.ps1 plus the test script only, with nothing CI-sensitive in it. The cost is that the test sits unrun until someone with write access wires it in, so I would ask for an issue to track that rather than have it quietly become the thing the header comment warns about.

Either way, the fix itself and its regression test are unaffected and green.

andrexibiza commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Historical current-main validation receipt, superseded by the later topology decision below:

This comment no longer requests replacement or closure of #90128.

Copy link
Copy Markdown
Contributor

Current-main validation receipt: #91196 rebased this exact three-file implementation surface onto current main without changing the intended CLM preflight behavior, preserving @jackulau as Git author. Exact rebased head 11599ccf28a39b0b062944d4023d71df3c7405eb is green in CI 32433654721, Docker 32433654284, and Nix 32433654286.

GitHub currently reports this original PR mergeable. I am keeping #90128 as the canonical contribution/provenance owner and closing #91196 as a validation duplicate rather than replacing the original contributor's PR.

…NET error

AppLocker and WDAC enforcement put PowerShell in ConstrainedLanguage, which
refuses method calls on non-core .NET types. install.ps1 makes more than forty
of those calls, and the first runs at script scope inside Set-LongProfileEnvVars,
so the script dies before it honors any parameter. Even -Manifest and
-ProtocolVersion, which are read-only queries that touch nothing, fail with
MethodInvocationNotSupportedInConstrainedLanguage naming a line number inside a
cached copy of a script the operator never wrote (NousResearch#89857).

Detect the language mode first, using only constructs the mode allows, and
fail with what is wrong and what an administrator has to change. This is a
refusal rather than a workaround: the restriction is on the language, not on
this script, so no flag can make the rest of the file run.

The message pre-empts -ExecutionPolicy Bypass explicitly, because execution
policy and language mode are separate controls and reaching for the first is
the reported next step.

Refs NousResearch#89857
@jackulau
jackulau force-pushed the fix/89857-install-constrained-language-mode branch from 02db514 to 8b9984b Compare August 21, 2026 03:19
@jackulau

Copy link
Copy Markdown
Contributor Author

@andrexibiza that's a genuinely unusual thing to do and I want to name it rather than just say thanks: you did the rebase work, proved it green across CI/Docker/Nix, and then closed your own PR so the provenance stayed with the original contributor. Plenty of projects would have just merged #91196 and moved on. Noted and appreciated.

I've now put that on the canonical PR so your validation isn't stranded on a closed one. fix/89857-install-constrained-language-mode is rebased onto current main (it was 329 behind) and force-pushed at 8b9984b03d. The rebase was clean, no conflicts, and the three-file surface is unchanged from what you validated: scripts/install.ps1, scripts/tests/test-install-ps1-language-mode.ps1, .github/workflows/installer-tests.yml, +235/-0.

Re-ran the suite locally on the rebased head, Windows 11 / PowerShell 5.1, all 13 assertions pass:

-- harness --
OK: harness runspace reports ConstrainedLanguage
OK: harness refuses [Environment]::GetEnvironmentVariable (the call install.ps1 dies on)
-- ConstrainedLanguage --
OK: -ProtocolVersion exits 1 under ConstrainedLanguage
OK: the message names the language mode
OK: the message pre-empts the -ExecutionPolicy Bypass attempt (#89857's step 2)
OK: the message says what an administrator has to change
OK: the raw .NET error never reaches the operator
OK: the failure does not surface an install.ps1 line number
-- FullLanguage --
OK: -ProtocolVersion still exits 0 under FullLanguage
OK: -ProtocolVersion still emits an integer (got: 1)
OK: -Manifest still exits 0 under FullLanguage
OK: -Manifest still emits the manifest, so the guard did not pollute stdout

The two negative assertions are the ones I'd point a reviewer at: the raw .NET error never reaching the operator, and no install.ps1 line number leaking. Those are the actual bug in #89857, where the operator sees a .NET type-load failure and reasonably concludes the installer is broken rather than that policy is blocking it.

The only red check remaining is Review label gate, which needs the ci-reviewed label and isn't something I can set. Everything else is green.

One thing still open from my side, unchanged by the rebase: the guard refuses rather than degrading, so an operator in ConstrainedLanguage gets a clear stop instead of a partial install. I think refusing is right for an installer, but if maintainers would rather it attempt a reduced-functionality path, that's a policy call and I'd rather hear it than assume.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/install-update Installer, updater, packaging, wheels, doctor comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists platform/windows Native Windows-specific behavior or breakage sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants