Compare commits

...

144 Commits

Author SHA1 Message Date
ethernet
6e29aa4668 w 2026-06-21 18:15:01 -04:00
ethernet
3b85da4248 si 2026-06-21 18:14:54 -04:00
ethernet
a4447926b4 wwwwww 2026-06-21 18:12:15 -04:00
ethernet
2b3a544ff2 script 2026-06-21 18:00:18 -04:00
ethernet
f39ba79304 exit on errors 2026-06-21 17:50:48 -04:00
ethernet
71daf78789 errors 2026-06-21 17:44:53 -04:00
ethernet
0e11079f2b slepe 2026-06-21 17:16:00 -04:00
ethernet
be57ae007b finish install 2026-06-21 17:15:45 -04:00
ethernet
92ce3287da wwwwww 2026-06-21 15:26:16 -04:00
ethernet
ef387d0c53 mmouse 2026-06-20 16:05:35 -04:00
ethernet
5ddf5b71fd fffmpeg t 600 2026-06-20 15:48:29 -04:00
ethernet
3b1cbc24c3 oopsie 2026-06-20 15:47:02 -04:00
ethernet
3f97545fe5 loggggggggiee 2026-06-20 15:47:02 -04:00
ethernet
a03a828ca6 logs better 2026-06-20 15:47:02 -04:00
ethernet
0791bf068f log 2026-06-20 15:47:02 -04:00
ethernet
ede9d9a0cc wip wip 2026-06-20 15:47:02 -04:00
ethernet
93618d2259 screenshot at end 2026-06-20 15:47:02 -04:00
ethernet
8ef0b9f929 omg oops 2026-06-20 15:47:02 -04:00
ethernet
a7e2d9c5eb idk 2026-06-20 15:47:02 -04:00
ethernet
41a3cca12c wwwwww 2026-06-20 15:47:02 -04:00
ethernet
7b63ef5c60 button update 2026-06-20 15:47:02 -04:00
ethernet
181ed08c6c w 2026-06-20 15:47:02 -04:00
ethernet
dfa8fa10ea variation 2026-06-20 15:47:02 -04:00
ethernet
0b33071e23 wwwww 2026-06-20 15:47:02 -04:00
ethernet
83b6e07b56 numge 2026-06-20 15:47:02 -04:00
ethernet
7fe0987630 www 2026-06-20 15:47:02 -04:00
ethernet
0b7dcb93bd errorstdout 2026-06-20 15:47:02 -04:00
ethernet
fb1d83d998 w 2026-06-20 15:47:02 -04:00
ethernet
65e67908a8 timeout oops 2026-06-20 15:47:02 -04:00
ethernet
e37dedd214 w 2026-06-20 15:47:02 -04:00
ethernet
4b1eba3af5 wwwww 2026-06-20 15:47:02 -04:00
ethernet
552ae71829 w 2026-06-20 15:47:02 -04:00
ethernet
25c0095a76 ffmpeg 2026-06-20 15:47:02 -04:00
ethernet
c2c47b3dfe WIP 2026-06-20 15:47:02 -04:00
ethernet
6029aa8018 wwww 2026-06-20 15:47:02 -04:00
ethernet
87633e1ec7 w 2026-06-20 15:47:02 -04:00
ethernet
304ca16e5e w 2026-06-20 15:47:02 -04:00
ethernet
924f7299da wwww 2026-06-20 15:47:02 -04:00
ethernet
de13517a58 wwwww 2026-06-20 15:47:01 -04:00
ethernet
3739879097 concluciosn 2026-06-20 15:47:01 -04:00
ethernet
ba72e50732 errorstdout 2026-06-20 15:47:01 -04:00
ethernet
90a90d1fe6 wwwwwww 2026-06-20 15:47:01 -04:00
ethernet
e1d6961569 wip wip 2026-06-20 15:47:01 -04:00
ethernet
d74293fada wwwwwwwwww 2026-06-20 15:47:01 -04:00
ethernet
d057cb1263 run the install! 2026-06-20 15:47:01 -04:00
ethernet
c6ee3aeee7 aight can we cop UV better 2026-06-20 15:47:01 -04:00
ethernet
14279dde3e irm iem 2026-06-20 15:47:01 -04:00
ethernet
5ec5994716 rip out useless tests
these just assert stuff in source code. they don't test behavior. this
is ridiculous lol
2026-06-20 15:47:01 -04:00
ethernet
68cb370b03 oopsie bnix hashes 2026-06-20 15:47:01 -04:00
ethernet
826505617c nix hashes 2026-06-20 15:47:01 -04:00
ethernet
4bccf1614c bindir 2026-06-20 15:46:40 -04:00
ethernet
887fb37311 nice 2026-06-20 15:46:40 -04:00
ethernet
d369b0427a wwww 2026-06-20 15:46:39 -04:00
ethernet
0eb778918f logs? 2026-06-20 15:46:39 -04:00
ethernet
f10b4e23f2 wwwwwww 2026-06-20 15:46:39 -04:00
ethernet
e49212cee1 logs 2026-06-20 15:46:39 -04:00
ethernet
2d1d828758 asdasd 2026-06-20 15:46:39 -04:00
ethernet
c062c8b397 clicky 2026-06-20 15:46:39 -04:00
ethernet
1b80963cad install 2026-06-20 15:46:39 -04:00
ethernet
c100eb7f37 mousescreen 2026-06-20 15:46:39 -04:00
ethernet
5b712335ff lick fix 2026-06-20 15:46:39 -04:00
ethernet
89e3d21ad5 f 2026-06-20 15:46:39 -04:00
ethernet
d2e8290e02 hehe 2026-06-20 15:46:39 -04:00
ethernet
557bc54982 fmt 2026-06-20 15:46:39 -04:00
ethernet
e3725df6e2 a 2026-06-20 15:46:39 -04:00
ethernet
c6ef14fc77 wwwww 2026-06-20 15:46:39 -04:00
ethernet
07cfb762c9 wwwwww 2026-06-20 15:46:39 -04:00
ethernet
0439005545 www 2026-06-20 15:46:39 -04:00
ethernet
5a68a7c4d2 keep going 2026-06-20 15:46:39 -04:00
ethernet
0d19346715 w 2026-06-20 15:46:39 -04:00
ethernet
a019ba90d6 logs 2026-06-20 15:46:39 -04:00
ethernet
08ccbd01a7 ggg 2026-06-20 15:46:39 -04:00
ethernet
77d4022c7b asdasdfasdf 2026-06-20 15:46:39 -04:00
ethernet
98bee11d4d wwww 2026-06-20 15:46:39 -04:00
ethernet
c605a2aeff wwwww 2026-06-20 15:46:39 -04:00
ethernet
54dd29d65c wwwwwwwwwwwwwwwww 2026-06-20 15:46:39 -04:00
ethernet
1e1e160e35 ccccca 2026-06-20 15:46:39 -04:00
ethernet
e36891f9bf cont 2026-06-20 15:46:39 -04:00
ethernet
ac39683f62 www\ 2026-06-20 15:46:39 -04:00
ethernet
1011216136 wwwwww 2026-06-20 15:46:39 -04:00
ethernet
9318cdda7d env? 2026-06-20 15:46:39 -04:00
ethernet
45ef144929 fix cache weird idk uploady 2026-06-20 15:46:39 -04:00
ethernet
f55d57eea2 wwwwwww 2026-06-20 15:46:39 -04:00
ethernet
2e2f9295b4 g 2026-06-20 15:46:39 -04:00
ethernet
6c5feac541 lalala
`
2026-06-20 15:46:39 -04:00
ethernet
b677ba9788 gg 2026-06-20 15:46:39 -04:00
ethernet
e3158549d6 wwwww 2026-06-20 15:46:39 -04:00
ethernet
69c703fb23 why 2026-06-20 15:46:39 -04:00
ethernet
ae0a0f3d61 wwww 2026-06-20 15:46:39 -04:00
ethernet
c765e3ec62 fix 2026-06-20 15:46:39 -04:00
ethernet
f9a161a9a8 a 2026-06-20 15:46:39 -04:00
ethernet
74c9850782 keep goin 2026-06-20 15:46:39 -04:00
ethernet
a1f2d78c62 www 2026-06-20 15:46:39 -04:00
ethernet
2812db3954 mkv 2026-06-20 15:46:39 -04:00
ethernet
be7873eaeb aawawa 2026-06-20 15:46:39 -04:00
ethernet
aa6b1087b2 cache bust 2026-06-20 15:46:39 -04:00
ethernet
530ea34445 dont pin to build commit 2026-06-20 15:46:39 -04:00
ethernet
ef3d207a4b babababa 2026-06-20 15:46:39 -04:00
ethernet
6436ba0a86 stdin redir? 2026-06-20 15:46:39 -04:00
ethernet
81bd481468 ffmpeg2 2026-06-20 15:46:39 -04:00
ethernet
8fe693b6e3 yay 2026-06-20 15:46:39 -04:00
ethernet
c34b104e39 asdasd 2026-06-20 15:46:39 -04:00
ethernet
f40c04a3d5 ww 2026-06-20 15:46:39 -04:00
ethernet
9ad002d37b ffmpeg 2026-06-20 15:46:39 -04:00
ethernet
a6255c3249 www 2026-06-20 15:46:39 -04:00
ethernet
297c0b9c9b dirs 2026-06-20 15:46:39 -04:00
ethernet
44ad72063d dir 2026-06-20 15:46:39 -04:00
ethernet
821b5e5fa0 installdir 2026-06-20 15:46:39 -04:00
ethernet
740936dbb5 ww 2026-06-20 15:46:39 -04:00
ethernet
3507cbc6c9 www 2026-06-20 15:46:39 -04:00
ethernet
8f3d6e3472 typo 2026-06-20 15:46:39 -04:00
ethernet
9d5850c417 bs 2026-06-20 15:46:39 -04:00
ethernet
00b6160c13 www 2026-06-20 15:46:39 -04:00
ethernet
1a2e8774ed fix weird checkout 2026-06-20 15:46:39 -04:00
ethernet
90869d4415 ffmpreg 2026-06-20 15:46:39 -04:00
ethernet
7e3c54b6d1 heckout optimization 2026-06-20 15:46:39 -04:00
ethernet
7241fdfc19 fixie 2026-06-20 15:46:39 -04:00
ethernet
af011b3b86 ahk and thing 2026-06-20 15:46:39 -04:00
ethernet
1c47631115 done 2026-06-20 15:46:39 -04:00
ethernet
621017e05e ahk dir correct 2026-06-20 15:46:39 -04:00
ethernet
33091daf33 aaa 2026-06-20 15:46:39 -04:00
ethernet
2e930d6964 w 2026-06-20 15:46:39 -04:00
ethernet
3e49a8c411 w 2026-06-20 15:46:39 -04:00
ethernet
a08860a908 windwos ache 2026-06-20 15:46:39 -04:00
ethernet
a5321b5bd3 dl artifact 2026-06-20 15:46:39 -04:00
ethernet
c8034c9d23 simpler installer cache 2026-06-20 15:46:39 -04:00
ethernet
8b2c8a359d wip 2026-06-20 15:46:39 -04:00
ethernet
c631f4b23a wwwwwwww 2026-06-20 15:46:39 -04:00
ethernet
e54bd943ad wip wip installer 2026-06-20 15:46:39 -04:00
ethernet
36b546d8ab cache 2026-06-20 15:46:39 -04:00
ethernet
5a7f88beb3 always npm 2026-06-20 15:46:39 -04:00
ethernet
b0e348af44 ww 2026-06-20 15:46:39 -04:00
ethernet
4d2e62b869 ahk 2026-06-20 15:46:39 -04:00
ethernet
641b889767 check 2026-06-20 15:46:39 -04:00
ethernet
a60ca2e90e winget paths 2026-06-20 15:46:39 -04:00
ethernet
0a759809ae installers 2026-06-20 15:46:39 -04:00
ethernet
bf1e60181a wwwwwwww 2026-06-20 15:46:39 -04:00
ethernet
7535251456 ahk 2026-06-20 15:46:39 -04:00
ethernet
5916bba2a5 t 2026-06-20 15:46:39 -04:00
ethernet
76f042e998 longer timeout 2026-06-20 15:46:39 -04:00
ethernet
9931c0bc23 p 2026-06-20 15:46:39 -04:00
ethernet
0b3bec6178 tc 2026-06-20 15:46:39 -04:00
ethernet
1440ea2cf4 w 2026-06-20 15:46:39 -04:00
ethernet
7f7a036c93 wip e2e 2026-06-20 15:46:39 -04:00
15 changed files with 942 additions and 332 deletions

View File

@@ -1,100 +0,0 @@
name: Build Windows Installer
on:
workflow_dispatch:
permissions:
contents: read
jobs:
# Gate: workflow_dispatch is already restricted to users with write access,
# but we want ADMIN-only. Explicitly check the triggering actor's repo
# permission via the API and fail fast for anyone below admin.
authorize:
name: Authorize (admins only)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check actor is a repo admin
env:
GH_TOKEN: ${{ github.token }}
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
perm=$(gh api \
"repos/${{ github.repository }}/collaborators/${ACTOR}/permission" \
--jq '.permission')
echo "Actor '${ACTOR}' has permission: ${perm}"
if [ "${perm}" != "admin" ]; then
echo "::error::'${ACTOR}' is not a repo admin (permission=${perm}). Refusing to build/sign."
exit 1
fi
echo "Authorized: '${ACTOR}' is an admin."
build:
name: Hermes-Setup.exe
needs: authorize
runs-on: windows-latest
timeout-minutes: 30
permissions:
contents: read
# Required for OIDC auth to Azure (azure/login federated credentials).
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- name: Install npm dependencies
run: npm ci
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Cache Rust targets
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: apps/bootstrap-installer/src-tauri
- name: Build installer
run: npm run tauri:build
working-directory: apps/bootstrap-installer
- name: Azure login (OIDC)
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Sign Hermes-Setup.exe with Azure Artifact Signing
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2
with:
endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }}
signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ vars.AZURE_SIGNING_CERTIFICATE_PROFILE }}
# Sign both the raw exe and the bundled NSIS installer.
files-folder: ${{ github.workspace }}\apps\bootstrap-installer\src-tauri\target\release
files-folder-filter: exe
files-folder-recurse: true
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
- name: Upload NSIS installer
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Hermes-Setup-installer
path: apps/bootstrap-installer/src-tauri/target/release/bundle/nsis/*.exe
- name: Upload raw exe
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Hermes-Setup-exe
path: apps/bootstrap-installer/src-tauri/target/release/Hermes-Setup.exe

385
.github/workflows/e2e-windows.yml vendored Normal file
View File

@@ -0,0 +1,385 @@
name: E2E Windows Desktop
on:
push:
branches: [ethie/e2e]
workflow_dispatch:
concurrency:
group: e2e-windows-${{ github.ref }}
cancel-in-progress: true
jobs:
# this is separated so we don't have node.js and stuff
build-installer:
name: Build Hermes-Setup.exe
runs-on: windows-latest
timeout-minutes: 30
steps:
- name: checkout cache inputs
uses: actions/checkout@v4
with:
sparse-checkout: |
package-lock.json
apps/bootstrap-installer
sparse-checkout-cone-mode: true
# The cache key is the exact installer build fingerprint. A hit means
# this package-lock + bootstrap-installer source combo was already built,
# so we can skip the entire Node/Rust/toolchain dance and just upload it.
- name: Restore installer build cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
id: installer-cache
with:
path: Hermes-Setup.exe
key: hermes-installer-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'apps/bootstrap-installer/**', '!apps/bootstrap-installer/src-tauri/target/**') }}
- name: Setup Node.js
if: steps.installer-cache.outputs.cache-hit != 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- name: Setup Rust
if: steps.installer-cache.outputs.cache-hit != 'true'
uses: dtolnay/rust-toolchain@1.96.0 # stable
- name: checkout full tree on cache miss
if: steps.installer-cache.outputs.cache-hit != 'true'
uses: actions/checkout@v4
- name: Install npm dependencies
if: steps.installer-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Build installer
if: steps.installer-cache.outputs.cache-hit != 'true'
run: npm run tauri:build
working-directory: apps/bootstrap-installer
timeout-minutes: 10
env:
HERMES_BUILD_PIN_BRANCH: "main" # build the installer exactly as it would build on `main`. we'll override this when running it.
# Only runs on cache miss. Pick the exe the Tauri build produced and
# normalize its name so downstream jobs always know what to download.
- name: Normalize installer artifact name
if: steps.installer-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
$candidates = @(
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes.exe',
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64.exe',
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64-setup.exe',
'apps/bootstrap-installer/src-tauri/target/release/Hermes.exe'
)
$installer = $null
foreach ($c in $candidates) {
if (Test-Path $c) {
$installer = Resolve-Path $c
break
}
}
if (-not $installer) {
$installer = Get-ChildItem -Path 'apps/bootstrap-installer/src-tauri/target/release' `
-Recurse -Filter '*.exe' | Where-Object { $_.Name -notlike '*setup*' -or $true } | Select-Object -First 1 -ExpandProperty FullName
}
if (-not $installer) {
throw 'Could not find built Hermes-Setup.exe'
}
Copy-Item -Path $installer -Destination 'Hermes-Setup.exe' -Force
Write-Host "Normalized installer: Hermes-Setup.exe (from $installer)"
e2e:
name: Run installer test
needs: build-installer
runs-on: windows-latest
timeout-minutes: 60
env:
# Isolated install directory so the real install flow doesn't touch the
# runner's user profile. Kept under the workspace for easy cleanup.
HERMES_HOME: ${{ github.workspace }}\.e2e-hermes-home
INSTALL_DIR: ${{ github.workspace }}\.e2e-hermes-home\hermes-agent
steps:
- uses: actions/checkout@v4
with:
path: source
- name: Restore installer from build cache
id: installer-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: Hermes-Setup.exe
key: hermes-installer-cache-${{ runner.os }}-${{ hashFiles('source/package-lock.json', 'source/apps/bootstrap-installer/**', '!source/apps/bootstrap-installer/src-tauri/target/**') }}
- name: Restore cached test tools
id: test-tools-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: test-bins
key: test-bins-${{ runner.os }}-v1
- name: Install AutoHotkey v2 and ffmpeg
if: steps.test-tools-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
# Install fresh when the cache missed.
New-Item -ItemType Directory -Path test-bins\autohotkey, test-bins\ffmpeg -Force | Out-Null
# AutoHotkey: copy its whole v2 directory so helper exes/dlls come along.
winget install -e --id AutoHotkey.AutoHotkey --silent --accept-source-agreements --accept-package-agreements --disable-interactivity
$ahkDir = "$env:ProgramW6432\AutoHotkey\v2"
if (-not (Test-Path $ahkDir)) {
throw "AutoHotkey install directory not found: $ahkDir"
}
Copy-Item -Path "$ahkDir\*" -Destination test-bins\autohotkey -Recurse -Force
# ffmpeg : just install into dir
winget install -e --id Gyan.FFmpeg --silent --accept-source-agreements --accept-package-agreements --disable-interactivity --location ffmpeg_dir
Copy-Item -Path "ffmpeg_dir\*\*" -Destination test-bins\ffmpeg -Recurse -Force
- name: Add test-bins to PATH
shell: pwsh
run: |
ls "$PWD\test-bins\ffmpeg"
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\autohotkey"
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\ffmpeg\bin"
# ── Prepare an isolated HERMES_HOME and copy checked-out repo ──────
# actions/checkout already has the right commit; just mirror it into the
# isolated home so the installer doesn't need to reach GitHub.
- name: Move checked-out workspace into isolated HERMES_HOME
shell: pwsh
run: |
New-Item -ItemType Directory -Path $env:INSTALL_DIR -Force
Get-ChildItem -Path ${{ github.workspace }}\source -Force | Move-Item -Destination $env:INSTALL_DIR -Force
Write-Host "Isolated install dir ready: $env:INSTALL_DIR"
# ── Run the headed installer + AHK helper ───────────────────────
- name: Launch Hermes-Setup.exe and install it
shell: pwsh
timeout-minutes: 10
env:
HERMES_SETUP_DEV_REPO_ROOT: ${{ env.INSTALL_DIR }}
run: |
$installer = "Hermes-Setup.exe"
# ── Start screen recording (live stdin pipe) ──────────────────
# ffmpeg must be started, fed, and stopped from the SAME step: the
# graceful-stop signal is the character 'q' written to ffmpeg's live
# stdin. A separate teardown step can't do this because the process
# that owns the writable stdin pipe dies when this step ends.
#
# Start-Process / -RedirectStandardInput <file> does NOT work: it
# hands ffmpeg a file handle opened once at EOF, so appending 'q' to
# the file on disk never reaches the running process. We need a real
# writable pipe, which only System.Diagnostics.Process exposes.
#
# -pix_fmt yuv420p keeps
# the output broadly playable.
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "ffmpeg"
$psi.Arguments = "-y -f gdigrab -framerate 15 -i desktop " +
"-hide_banner -loglevel error " +
"-c:v libx264 -preset ultrafast -pix_fmt yuv420p recording.mkv"
$psi.RedirectStandardInput = $true
$psi.UseShellExecute = $false
$ffmpeg = [System.Diagnostics.Process]::Start($psi)
$ffmpeg.Id | Out-File ffmpeg.pid
# Note: stderr is intentionally left attached to the console so it is
# captured in the step log. Do NOT redirect a stream we don't drain --
# ffmpeg's progress output would fill the pipe buffer and block.
Write-Host "ffmpeg recording started (pid $($ffmpeg.Id))"
$installerSuccess = $false
try {
# Launch the real installer
$proc = Start-Process -FilePath $installer -PassThru -NoNewWindow
$proc.Id | Out-File installer.pid
$ahkProc = Start-Process -FilePath ".\test-bins\autohotkey\AutoHotkey64.exe" `
-ArgumentList "$env:INSTALL_DIR\e2e\windows\install-hermes-desktop.ahk", "$PWD\ahk.log" -PassThru -NoNewWindow
# Wait for AHK helper to finish, and tail logs.
$logReader = $null
$logStream = $null
$logPath = Join-Path $env:HERMES_HOME "logs\bootstrap-installer.log"
# can take a long time for installer!
$deadline = (Get-Date).AddSeconds(60 * 8)
try {
while ((Get-Date) -lt $deadline -and -not $ahkProc.HasExited) {
if (-not $logReader) {
if (Test-Path $logPath) {
Write-Host "Found bootstrap-installer.log; tailing..."
# FileShare.ReadWrite is required: the installer almost
# certainly still has the file open for writing, and a
# plain Get-Content/File.Open would throw or lock it out.
$logStream = [System.IO.File]::Open(
$logPath, 'Open', 'Read', 'ReadWrite')
$logReader = New-Object System.IO.StreamReader($logStream)
}
} else {
$line = $logReader.ReadLine()
while ($null -ne $line) {
Write-Host "[bootstrap] $line"
$line = $logReader.ReadLine()
}
}
Start-Sleep -Milliseconds 500
}
# Drain anything written in the final tick before exit/timeout.
if ($logReader) {
$line = $logReader.ReadLine()
while ($null -ne $line) {
Write-Host "[bootstrap] $line"
$line = $logReader.ReadLine()
}
}
}
finally {
if ($logReader) { $logReader.Dispose() }
if ($logStream) { $logStream.Dispose() }
}
if (-not $ahkProc.HasExited) {
Write-Host "AHK helper is still running; stopping it"
Stop-Process -Id $ahkProc.Id -Force -ErrorAction SilentlyContinue
} else {
Write-Host "autohotkey helper exited"
}
}
finally {
# Gracefully stop ffmpeg by writing 'q' to its LIVE stdin pipe, so
# the container header/index are finalized and the mkv is playable.
# This runs in the same step that owns the pipe, even on failure.
if ($ffmpeg -and -not $ffmpeg.HasExited) {
Write-Host "Stopping ffmpeg gracefully (q on stdin)"
try {
$ffmpeg.StandardInput.Write("q")
$ffmpeg.StandardInput.Close()
} catch {
Write-Host "Failed to write q to ffmpeg stdin: $_"
}
if (-not $ffmpeg.WaitForExit(15000)) {
Write-Host "ffmpeg did not exit after 15s; killing"
$ffmpeg.Kill()
}
}
Write-Host "ffmpeg stopped"
# Installer should have exited
if ($proc.HasExited) {
# TODO check exit code once we add exit code in installer
$installerSuccess = $true
} else {
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
throw "Installer is still running. Install did not succeed."
}
if (-not $installerSuccess) {
throw "Installer did not exit after autohotkey script finished. Check installer logs!"
}
}
# ── Run Playwright against the installed binary ─────────────────
# (placeholder: will be enabled once installer completes successfully.)
- name: Launch installed app and run e2e
if: false
working-directory: source/apps/desktop
run: npx playwright test e2e/ --reporter=list
env:
# Point the e2e spec at the desktop binary that the installer built.
HERMES_E2E_INSTALL_ROOT: ${{ env.HERMES_HOME }}\hermes-agent
# ── Teardown & artifacts ────────────────────────────────────────
# ffmpeg is normally started AND gracefully stopped inside the launch
# step (so 'q' reaches its live stdin pipe). This step is only a
# safety net: if the launch step timed out or crashed before its
# finally block ran, force-kill any orphaned ffmpeg so the runner can
# release recording.mkv for upload. The mkv container survives a hard
# kill (only the trailing seek index is lost), so the artifact is still
# usable for coordinate discovery even on this fallback path.
- name: Stop orphaned screen recording (safety net)
if: always()
shell: pwsh
run: |
$ffmpegpid = Get-Content ffmpeg.pid -ErrorAction SilentlyContinue
if ($ffmpegpid) {
$proc = Get-Process -Id $ffmpegpid -ErrorAction SilentlyContinue
if ($proc) {
Write-Host "Orphaned ffmpeg (pid $ffmpegpid) still running; force-stopping"
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 2
} else {
Write-Host "ffmpeg already exited cleanly; nothing to do"
}
}
- name: Burn debug overlay into recording
if: always()
shell: pwsh
run: |
$logPath = Join-Path $PWD 'ahk.log'
$x = $y = $w = $h = $null
if (Test-Path $logPath) {
$line = Get-Content $logPath -Raw | Select-String -Pattern 'Window found at x=(\d+) y=(\d+) w=(\d+) h=(\d+)' -AllMatches | Select-Object -Last 1
if ($line) {
$x = [int]$line.Matches[0].Groups[1].Value
$y = [int]$line.Matches[0].Groups[2].Value
$w = [int]$line.Matches[0].Groups[3].Value
$h = [int]$line.Matches[0].Groups[4].Value
Write-Host "Parsed window rect: x=$x y=$y w=$w h=$h"
} else {
Write-Host "no window rect found in ahk.log; only timestamp will be burned"
}
} else {
Write-Host "ahk.log not found; only timestamp will be burned"
}
# Build the timestamp overlay
$vf = "drawtext=text='%{pts\:hms}':fontfile='C\:\\Windows\\Fonts\\arial.ttf':fontsize=20:fontcolor=white:box=1:boxcolor=black@0.5:x=8:y=8"
if ($x -ne $null -and $y -ne $null -and $w -ne $null -and $h -ne $null) {
# Window border + 16px grid only over the window + axis labels
$grid = "drawbox=x=$x`:y=$y`:w=$w`:hf=$h`:color=red@0.9:t=2,split=2[box][win];[win]crop=$w`:$h`:$x`:$y`:$y"
}
Write-Host "Overlay filter: $vf"
ffmpeg -y -i recording.mkv -vf "$vf" -c:v libx264 -preset veryfast -pix_fmt yuv420p recording-overlay.mkv
if ($LASTEXITCODE -ne 0) {
throw "ffmpeg overlay failed"
}
Move-Item -Path recording-overlay.mkv -Destination recording.mkv -Force
Write-Host "Overlayed recording saved as recording.mkv"
- name: Upload screen recording
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
id: upload-recording
if: always()
with:
name: screen-recording-${{ github.sha }}
path: recording.mkv
retention-days: 1
archive: false
overwrite: true
- name: Bootstrap Installer log
if: always()
shell: pwsh
run: |
Get-Content "$env:HERMES_HOME\logs\bootstrap-installer.log"
- name: Autohotkey log
if: always()
shell: pwsh
run: |
Get-Content ahk.log

View File

@@ -0,0 +1,208 @@
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
/**
* E2E smoke tests for the built Hermes desktop app.
*
* These tests launch the real packaged Electron binary (produced by
* `npm run pack` → `electron-builder --dir`) with:
* - HERMES_DESKTOP_BOOT_FAKE=1 — simulates boot progress without
* spawning a real Hermes backend
* - HERMES_DESKTOP_USER_DATA_DIR — isolated electron userData
* - HERMES_DESKTOP_IGNORE_EXISTING=1 — forces the bootstrap path
* - HERMES_HOME — isolated throwaway directory
* - All credential env vars stripped
*
* The binary path is resolved per-platform to match electron-builder's
* output layout. On Windows CI the app is built with `npm run pack`
* before these tests run.
*/
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
const BINARY_PATH: string = (() => {
const downloadsExe = path.join(os.homedir(), 'Downloads', 'Hermes-Setup.exe')
if (fs.existsSync(downloadsExe)) {
return downloadsExe
}
const platform = process.platform
if (platform === 'darwin') {
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes')
}
if (platform === 'win32') {
return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe')
}
return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes')
})()
// Credential-suffix filter — matches test-desktop.mjs's isCredentialEnvVar.
const CREDENTIAL_SUFFIXES: string[] = [
'_API_KEY',
'_TOKEN',
'_SECRET',
'_PASSWORD',
'_CREDENTIALS',
'_ACCESS_KEY',
'_PRIVATE_KEY',
'_OAUTH_TOKEN',
]
const CREDENTIAL_NAMES = new Set([
'ANTHROPIC_BASE_URL',
'ANTHROPIC_TOKEN',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SESSION_TOKEN',
'CUSTOM_API_KEY',
'GEMINI_BASE_URL',
'OPENAI_BASE_URL',
'OPENROUTER_BASE_URL',
'OLLAMA_BASE_URL',
'GROQ_BASE_URL',
'XAI_BASE_URL',
])
function isCredentialEnvVar(name: string): boolean {
if (CREDENTIAL_NAMES.has(name)) {return true}
return CREDENTIAL_SUFFIXES.some((suffix) => name.endsWith(suffix))
}
function buildSandboxEnv(): { env: Record<string, string>; sandbox: string } {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-e2e-'))
const userDataDir = path.join(sandbox, 'electron-user-data')
const hermesHome = path.join(sandbox, 'hermes-home')
fs.mkdirSync(userDataDir, { recursive: true })
fs.mkdirSync(hermesHome, { recursive: true })
// Strip credentials, inject sandboxed env.
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (!value) {continue}
if (isCredentialEnvVar(key)) {continue}
env[key] = value
}
// Fake boot: simulates progress steps without spawning the real backend.
env.HERMES_DESKTOP_BOOT_FAKE = '1'
env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS = '120'
// Force bootstrap path even if a hermes install exists on the runner.
env.HERMES_DESKTOP_IGNORE_EXISTING = '1'
// Isolate electron's userData and HERMES_HOME to the sandbox.
env.HERMES_DESKTOP_USER_DATA_DIR = userDataDir
env.HERMES_HOME = hermesHome
// Clear any dev-server override — we want the packaged renderer, not vite.
delete env.HERMES_DESKTOP_DEV_SERVER
delete env.HERMES_DESKTOP_HERMES
delete env.HERMES_DESKTOP_HERMES_ROOT
return { env, sandbox }
}
let app: ElectronApplication
let page: Page
let sandbox: string
test.beforeAll(async () => {
test.skip(
!fs.existsSync(BINARY_PATH),
`Built app binary not found: ${BINARY_PATH}. Run 'npm run pack' first.`
)
const { env, sandbox: dir } = buildSandboxEnv()
sandbox = dir
app = await _electron.launch({
executablePath: BINARY_PATH,
args: ['--disable-gpu', '--no-sandbox', '--disable-software-rasterizer'],
env,
})
page = await app.firstWindow()
})
test.afterAll(async () => {
await app?.close().catch(() => undefined)
try {
if (sandbox) {fs.rmSync(sandbox, { recursive: true, force: true })}
} catch {
// best-effort cleanup
}
})
test('window opens with the Hermes title', async () => {
// The main.cjs sets the window title to APP_NAME ('Hermes') during
// createBrowserWindow. Verify it before anything else.
const title = await page.title()
expect(title).toContain('Hermes')
})
test('renderer loads and shows DOM content', async () => {
// Wait for the React root to mount. The app renders into #root
// (see src/main.tsx). Give it a generous timeout for cold boot on CI.
await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 })
// The root should have children after React hydrates — the boot overlay
// or the main app shell.
const childCount = await page.locator('#root > *').count()
expect(childCount).toBeGreaterThan(0)
})
test('boot progress overlay fades out or shows error state', async () => {
// With BOOT_FAKE mode the app simulates boot progress steps. Without a
// real backend, boot will eventually fail — the app shows a
// BootFailureOverlay. Either outcome (success → overlay disappears,
// failure → error overlay renders) proves the renderer is working.
//
// Wait for one of:
// (a) the boot overlay disappears (renderer.ready), OR
// (b) an error message becomes visible (boot failure path)
//
// Use a waitForFunction so we don't depend on specific CSS selectors
// that might change between refactors.
await page.waitForFunction(
() => {
const root = document.getElementById('root')
if (!root) {return false}
const text = root.textContent ?? ''
// Error path: boot failure overlay renders an error message.
if (text.includes('error') || text.includes('Error') || text.includes('failed')) {
return true
}
// Success path: overlay disappears and the app renders. Look for
// a chat input, sidebar, or settings gear as indicators.
// If there's no "boot" / "starting" / "installing" text visible,
// boot has completed (either to the main UI or to onboarding).
const bootIndicators = ['starting', 'resolving', 'spawning', 'waiting', 'installing']
const lower = text.toLowerCase()
return !bootIndicators.some((word) => lower.includes(word))
},
{ timeout: 60_000 }
)
})
test('can capture a screenshot for the CI artifact', async () => {
// This doubles as both a sanity check (page is renderable) and a
// useful CI artifact — the screenshot is attached to the test report.
const screenshot = await page.screenshot()
expect(screenshot.byteLength).toBeGreaterThan(0)
})

View File

@@ -43,6 +43,8 @@
"lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'",
"fix": "npm run lint:fix && npm run fmt",
"test:e2e": "playwright test e2e/",
"test:e2e:headed": "cross-env HEADED=1 playwright test e2e/",
"test:ui": "vitest run --environment jsdom",
"preview": "node scripts/assert-root-install.cjs && vite preview --host 127.0.0.1 --port 4174"
},
@@ -106,6 +108,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.61.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",

View File

@@ -0,0 +1,21 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
/* Test files live under e2e/ so they never collide with the vitest suite
* under src/ or the node:test files under electron/. */
testDir: './e2e',
/* The desktop app can take a while to bootstrap on cold CI runners — 90 s
* per test gives us headroom without masking real hangs. */
timeout: 90_000,
retries: process.env.CI ? 1 : 0,
/* Each test gets its own worker so the Electron process is fully isolated. */
fullyParallel: false,
reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]],
use: {
/* Capture traces and videos on failure — invaluable when the CI runner
* has no display we can watch live. */
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
})

View File

@@ -21,5 +21,6 @@
}
},
"include": ["src", "../shared/src"],
"exclude": ["e2e", "electron", "playwright.config.ts"],
"references": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,115 @@
#Requires AutoHotkey v2.0
#SingleInstance Force
logPath := A_Args.Length >= 1 ? A_Args[1] : "ahk.log"
Log(text) {
msg := Format("[autohotkey] {}`n", text)
ToolTip(text)
FileAppend(msg, '*')
FileAppend(msg, logPath)
}
OnError(LogError)
LogError(err, mode) {
Log(Format("Unhandled error: {}", err.Message))
ExitApp(1)
return -1 ; suppress the standard error dialog
}
SetWorkingDir(A_ScriptDir)
CoordMode("Pixel", "Screen")
CoordMode("Mouse", "Screen")
ClickWithMarker(x, y, button := "Left") {
Click(x, y, button)
Sleep(10)
MouseMove(30, 30)
Log(Format("Clicking at {1}, {2}", x, y))
size := 20
g := Gui("-Caption +AlwaysOnTop +ToolWindow")
g.BackColor := "Red"
g.Show(Format(
"x{} y{} w{} h{} NoActivate"
, x - size // 2
, y - size // 2
, size
, size
))
hRegion := DllCall(
"CreateEllipticRgn"
, "Int", 0
, "Int", 0
, "Int", size
, "Int", size
, "Ptr"
)
DllCall("SetWindowRgn", "Ptr", g.Hwnd, "Ptr", hRegion, "Int", true)
WinSetTransparent(255, g.Hwnd)
SetTimer(() => g.Destroy(), -500)
}
ClickCenterOfImageInWindow(winTitle, imageFile, timeoutMs := 10000, intervalMs := 250)
{
WinGetPos(&wx, &wy, &ww, &wh, winTitle)
hBitmap := LoadPicture(imageFile)
if !hBitmap {
throw Error("LoadPicture failed: " imageFile)
}
bm := Buffer(32, 0) ; BITMAP structure on x64
DllCall("GetObject", "Ptr", hBitmap, "Int", bm.Size, "Ptr", bm)
width := NumGet(bm, 4, "Int")
height := NumGet(bm, 8, "Int")
startTime := A_TickCount
timeLeft := 1
Log(Format("Searching for button file {} in window {}... {}s left", imageFile, winTitle, Round(timeLeft / 1000, 2)))
searchImage := Format("*10 {}", imageFile)
Log("SearchImage: " searchImage)
while (timeLeft > 0)
{
if ImageSearch(&x, &y, wx, wy, wx + ww, wy + wh, searchImage)
{
ClickWithMarker(x + Floor(width / 2), y + Floor(height / 2))
Log("Found button!")
return
}
Sleep intervalMs
timeLeft := timeoutMs - (A_TickCount - startTime)
ToolTip(Format("Searching for button {} in window {}... {}s left", imageFile, winTitle, Round(timeLeft / 1000, 2)))
}
throw Error(Format("Failed to find button {} in window {}", imageFile, winTitle))
}
Log("Waiting for the installer window to appear...")
winTitle := "Hermes"
try {
WinWait(winTitle, , 30)
} catch {
throw Error("Hermes installer window did not appear within 30s")
}
WinGetPos(&x, &y, &w, &h, winTitle)
Log(Format("Window found at x={1} y={2} w={3} h={4}`n", x, y, w, h))
ClickCenterOfImageInWindow(winTitle, A_ScriptDir "\install-button.png")
ClickCenterOfImageInWindow(winTitle, A_ScriptDir "\launch-button.png", 1000 * 60 * 8)
Sleep(2000)
; done
ExitApp(0)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -16,6 +16,8 @@ import platform
import shutil
import subprocess
import tempfile
import urllib.request
import zipfile
from pathlib import Path
from typing import Optional
@@ -239,16 +241,70 @@ def _install_uv_posix(env: dict[str, str]) -> None:
def _install_uv_windows(env: dict[str, str]) -> None:
"""Invoke the PowerShell installer."""
cmd = (
'irm https://astral.sh/uv/install.ps1 | iex'
)
subprocess.run(
["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd],
env=env,
check=True,
capture_output=True,
)
"""Download the uv binary zip directly from GitHub releases.
We intentionally do NOT run the astral installer script
(``irm https://astral.sh/uv/install.ps1 | iex``) anymore. That script
calls ``Get-ExecutionPolicy`` internally (from the
``Microsoft.PowerShell.Security`` module), and on some Windows installs
that module fails to load -- killing the installer before it can download
anything. Downloading the zip ourselves with stdlib avoids spawning any
PowerShell child process at all, sidestepping the broken module entirely.
"""
# Detect the real OS architecture. platform.machine() reports the
# emulated view (AMD64 on ARM under Prism), so prefer the env vars that
# reflect the actual hardware. Mirrors Get-WindowsArch in install.ps1.
proc_arch = (
os.environ.get("PROCESSOR_ARCHITEW6432")
or os.environ.get("PROCESSOR_ARCHITECTURE", "")
).upper()
if proc_arch in ("ARM64",):
target_triple = "aarch64-pc-windows-msvc"
elif proc_arch in ("AMD64", "X64"):
target_triple = "x86_64-pc-windows-msvc"
elif proc_arch in ("X86",):
target_triple = "i686-pc-windows-msvc"
else:
# Fallback: platform.machine(). On native x64 this is "AMD64".
machine = platform.machine().upper()
if machine in ("ARM64", "AARCH64"):
target_triple = "aarch64-pc-windows-msvc"
elif machine in ("AMD64", "X64"):
target_triple = "x86_64-pc-windows-msvc"
else:
target_triple = "i686-pc-windows-msvc"
zip_name = f"uv-{target_triple}.zip"
urls = [
f"https://github.com/astral-sh/uv/releases/latest/download/{zip_name}",
f"https://releases.astral.sh/github/uv/releases/latest/download/{zip_name}",
]
with tempfile.TemporaryDirectory() as tmp:
zip_path = Path(tmp) / zip_name
last_err: Exception | None = None
for url in urls:
try:
logging.debug("Downloading uv from %s", url)
urllib.request.urlretrieve(url, zip_path)
break
except Exception as exc:
last_err = exc
logging.debug("Download failed from %s: %s", url, exc)
else:
raise RuntimeError(
f"Failed to download uv from all mirrors: {last_err}"
) from last_err
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(tmp)
# Move every .exe from the archive into the target's parent (the
# managed bin dir). The zip layout is flat (uv.exe, uvx.exe) but
# handle nested just in case.
bin_dir = env.get("UV_INSTALL_DIR") or str(Path(env.get("UV_UNMANAGED_INSTALL", "")).parent)
for exe in Path(tmp).rglob("*.exe"):
shutil.copy2(exe, Path(bin_dir) / exe.name)
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
True # dont remove me. ask ethernet

View File

@@ -18,11 +18,15 @@ let
# The workspace root — where the single package-lock.json lives.
src = ../.;
# npm dependencies for the workspace, shared by all members. importNpmLock
# resolves each package from the lockfile's own `integrity` hashes, so the
# lockfile is the single source of truth — no separate dependency hash to
# keep in sync with it.
npmDeps = pkgs.importNpmLock.importNpmLock { npmRoot = src; };
# Single npm deps fetch from the workspace root lockfile.
# All workspace packages share this derivation.
npmDepsHash = "sha256-k6v5qo56UQ3vx/thUrtDe3lX9jQTSZMIp2rQ9HotbL4=";
npmDeps = pkgs.fetchNpmDeps {
inherit src;
fetcherVersion = 2;
hash = npmDepsHash;
};
in
{
# Returns a buildNpmPackage-compatible attrs set that provides:

66
package-lock.json generated
View File

@@ -59,7 +59,7 @@
},
"apps/desktop": {
"name": "hermes",
"version": "0.15.1",
"version": "0.17.0",
"dependencies": {
"@assistant-ui/react": "^0.12.28",
"@assistant-ui/react-streamdown": "^0.1.11",
@@ -120,6 +120,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.61.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",
@@ -2585,6 +2586,22 @@
"node": ">=14.18.0"
}
},
"node_modules/@playwright/test": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz",
@@ -14610,6 +14627,53 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",

View File

@@ -330,36 +330,6 @@ function Install-AgentBrowser {
# Dependency checks
# ============================================================================
# Resolve the PowerShell host executable used to spawn child PowerShell
# processes (the astral uv installer below). We must NOT hardcode the bare
# name `powershell`: it names *Windows PowerShell* and only resolves when its
# System32 directory is on PATH. When install.ps1 is run under PowerShell 7+
# (`pwsh`) -- or any session where `powershell` isn't on PATH -- a bare
# `powershell` spawn dies with "The term 'powershell' is not recognized",
# aborting uv installation (field report: Windows install stuck, uv install
# failed with exactly that message). Prefer the absolute path of the host we
# are already running in (PATH-independent), then fall back to whichever of
# powershell/pwsh is resolvable, and only then to the bare name.
function Get-PowerShellHostExe {
try {
$hostExe = (Get-Process -Id $PID).Path
if ($hostExe -and (Test-Path $hostExe)) {
$leaf = Split-Path $hostExe -Leaf
# Only trust the current host when it is a real PowerShell CLI
# (not e.g. powershell_ise.exe or an embedded host that can't take
# `-ExecutionPolicy`/`-Command`).
if ($leaf -match '^(?i:powershell|pwsh)\.exe$') { return $hostExe }
}
} catch { }
foreach ($candidate in @("powershell", "pwsh")) {
$cmd = Get-Command $candidate -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($cmd -and $cmd.Source) { return $cmd.Source }
}
# Last-ditch: hand back the bare name so the spawn surfaces its own error.
return "powershell"
}
function Install-Uv {
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there —
# no PATH probing, no conda guards, no multi-location resolution chains.
@@ -375,20 +345,72 @@ function Install-Uv {
}
Write-Info "Installing managed uv into $HermesHome\bin ..."
New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null
$binDir = Join-Path $HermesHome "bin"
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
# Download the uv binary zip directly from GitHub releases instead of
# running the astral installer script (`irm https://astral.sh/uv/install.ps1 | iex`).
# The astral installer calls Get-ExecutionPolicy internally (from the
# Microsoft.PowerShell.Security module), and on some Windows installs that
# module fails to load -- killing the installer before it can download
# anything (field report: "The 'Get-ExecutionPolicy' command was found in
# the module 'Microsoft.PowerShell.Security', but the module could not be
# loaded"). Downloading the zip ourselves sidesteps the broken module
# entirely: no child powershell spawn, no execution-policy check, no
# script parsing. The astral installer is just a wrapper around this zip
# anyway.
$arch = Get-WindowsArch
$targetTriple = switch ($arch) {
"x64" { "x86_64-pc-windows-msvc" }
"arm64" { "aarch64-pc-windows-msvc" }
"x86" { "i686-pc-windows-msvc" }
default { throw "Unsupported Windows architecture for uv: $arch" }
}
$zipName = "uv-$targetTriple.zip"
# The /latest/download/ URL always serves the most recent release zip.
$downloadUrls = @(
"https://github.com/astral-sh/uv/releases/latest/download/$zipName",
"https://releases.astral.sh/github/uv/releases/latest/download/$zipName"
)
$tempZip = [System.IO.Path]::GetTempFileName() + ".zip"
$tempExtract = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName())
$downloaded = $false
foreach ($url in $downloadUrls) {
try {
Write-Info "Downloading uv from $url ..."
Invoke-WebRequest -Uri $url -OutFile $tempZip -UseBasicParsing -ErrorAction Stop
$downloaded = $true
break
} catch {
Write-Warn "Download failed from $url : $_"
}
}
if (-not $downloaded) {
Write-Err "Failed to download uv from all mirrors"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
Remove-Item $tempZip -Force -ErrorAction SilentlyContinue
return $false
}
# UV_INSTALL_DIR tells the astral installer to place the binary
# directly into $HermesHome\bin instead of ~/.local/bin.
$prevEAP = $ErrorActionPreference
try {
$ErrorActionPreference = "Continue"
$env:UV_INSTALL_DIR = Join-Path $HermesHome "bin"
# Spawn via the resolved host exe (see Get-PowerShellHostExe) rather
# than a bare `powershell`, which isn't guaranteed to be on PATH under
# PowerShell 7 / pwsh-only setups.
$psHostExe = Get-PowerShellHostExe
& $psHostExe -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
$ErrorActionPreference = $prevEAP
New-Item -ItemType Directory -Path $tempExtract -Force | Out-Null
Expand-Archive -Path $tempZip -DestinationPath $tempExtract -Force -ErrorAction Stop
# The zip contains uv.exe (and uvx.exe) either at the root or inside
# a subdirectory. Find and move them to the managed bin dir.
$executables = Get-ChildItem -Path $tempExtract -Recurse -Filter "*.exe" -ErrorAction SilentlyContinue
if (-not $executables) {
Write-Err "uv zip did not contain any executables"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
return $false
}
foreach ($exe in $executables) {
Copy-Item -Path $exe.FullName -Destination $binDir -Force -ErrorAction Stop
}
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
@@ -397,14 +419,16 @@ function Install-Uv {
return $true
}
Write-Err "uv installed but not found at $managedUv"
Write-Err "uv.exe not found at $managedUv after extraction"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
return $false
} catch {
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Write-Err "Failed to install uv: $_"
Write-Err "Failed to extract uv: $_"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
return $false
} finally {
Remove-Item $tempZip -Force -ErrorAction SilentlyContinue
Remove-Item $tempExtract -Recurse -Force -ErrorAction SilentlyContinue
}
}

View File

@@ -1,94 +0,0 @@
"""Regression tests for #48352: Windows PowerShell 5.1 native stderr.
PowerShell 5.1 turns stderr from native commands into ``NativeCommandError``
records when ``$ErrorActionPreference = "Stop"``. ``scripts/install.ps1`` has a
few git/uv calls where stderr can be normal progress output, so those calls must
run with EAP temporarily relaxed and then inspect ``$LASTEXITCODE``.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
def _install_ps1() -> str:
return INSTALL_PS1.read_text(encoding="utf-8")
def _assert_relaxed_call(text: str, command_pattern: str) -> None:
helper_block_pattern = (
r"Invoke-NativeWithRelaxedErrorAction\s*\{[^}]*"
+ command_pattern
+ r"[^}]*\}"
)
inline_pattern = (
r"\$ErrorActionPreference\s*=\s*\"Continue\"[\s\S]{0,900}?"
+ command_pattern
)
assert re.search(helper_block_pattern, text) or re.search(inline_pattern, text), (
f"install.ps1 must relax ErrorActionPreference around {command_pattern}"
)
def test_repository_stage_relieves_eap_for_ssh_and_https_git_clone() -> None:
text = _install_ps1()
assert "function Invoke-NativeWithRelaxedErrorAction" in text
_assert_relaxed_call(
text,
r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlSsh \$InstallDir",
)
_assert_relaxed_call(
text,
r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlHttps \$InstallDir",
)
def test_uv_venv_and_dependency_installs_relax_eap() -> None:
text = _install_ps1()
_assert_relaxed_call(text, r"& \$UvCmd venv venv --python \$PythonVersion")
_assert_relaxed_call(text, r"& \$UvCmd sync --extra all --locked")
_assert_relaxed_call(text, r"& \$UvCmd pip install -e \$tier\.Spec")
def test_uv_venv_failure_is_not_swallowed_after_eap_relax() -> None:
"""Relaxing EAP must not let a genuine `uv venv` failure pass as success.
Once EAP is relaxed, a real non-zero `uv venv` exit no longer aborts on its
own, so install.ps1 must capture $LASTEXITCODE right after the call and fail
fast — otherwise the `venv` stage falsely reports success (Invoke-Stage emits
ok=true) when no venv was created. Regression guard for the gap caught while
reviewing #48372 (the explicit check originally proposed in #48463).
"""
text = _install_ps1()
# The uv-venv invocation, then an exit-code capture, then a throw — all
# within a small window after the relaxed call.
guard = re.search(
r"& \$UvCmd venv venv --python \$PythonVersion[\s\S]{0,400}?"
r"\$LASTEXITCODE[\s\S]{0,200}?"
r"-ne 0[\s\S]{0,200}?throw",
text,
)
assert guard is not None, (
"install.ps1 must capture uv venv's exit code and throw on failure after "
"relaxing ErrorActionPreference, so a genuine venv-creation failure isn't "
"reported as a successful stage"
)
def test_native_eap_helper_always_restores_previous_preference() -> None:
text = _install_ps1()
m = re.search(
r"function Invoke-NativeWithRelaxedErrorAction \{(?P<body>[\s\S]*?)^\}",
text,
re.MULTILINE,
)
assert m is not None, "expected a shared helper for NativeCommandError-safe calls"
body = m.group("body")
assert "$prevEAP = $ErrorActionPreference" in body
assert '$ErrorActionPreference = "Continue"' in body
assert "finally" in body
assert "$ErrorActionPreference = $prevEAP" in body

View File

@@ -1,77 +0,0 @@
"""Regression: the Windows installer must not spawn a bare ``powershell``.
A user on Windows reported the installer getting stuck; running
``irm https://hermes-agent.nousresearch.com/install.ps1 | iex`` failed at the
uv step with::
[X] Failed to install uv: The term 'powershell' is not recognized as the
name of a cmdlet, function, script file, or operable program.
Root cause: ``Install-Uv`` spawned the astral uv installer via a hardcoded
bare ``powershell`` command. That name resolves only to *Windows PowerShell*
and only when its System32 directory is on ``PATH``. Under PowerShell 7+
(``pwsh``) -- or any session where ``powershell`` isn't on ``PATH`` -- the
spawn dies and uv installation aborts.
The fix resolves the PowerShell host executable (preferring the absolute path
of the running host, then ``powershell``/``pwsh`` via ``Get-Command``) and
invokes *that* instead of a bare name. These tests lock that contract at the
source level (the script only runs on Windows, so there's no runner to
execute it on Linux CI).
"""
from pathlib import Path
import pytest
_INSTALL_PS1 = Path(__file__).resolve().parents[1] / "scripts" / "install.ps1"
@pytest.fixture(scope="module")
def source() -> str:
return _INSTALL_PS1.read_text(encoding="utf-8")
def test_astral_uv_installer_not_spawned_via_bare_powershell(source: str):
"""The exact failing literal must be gone."""
forbidden = 'powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv'
assert forbidden not in source, (
"Install-Uv still spawns the astral uv installer via a bare "
"`powershell` — it must use the resolved PowerShell host exe so it "
"works under pwsh / when powershell isn't on PATH."
)
def test_astral_uv_installer_invoked_via_resolved_host_variable(source: str):
"""The astral uv installer line must use the call operator on a variable.
i.e. ``& $psHostExe -ExecutionPolicy ... irm https://astral.sh/uv...``
rather than naming a fixed executable.
"""
lines = [ln for ln in source.splitlines() if "astral.sh/uv/install.ps1 | iex" in ln]
# Exactly one invocation line carries the astral installer.
invocation = [ln for ln in lines if "irm https://astral.sh/uv/install.ps1 | iex" in ln]
assert invocation, "astral uv install invocation line not found"
for ln in invocation:
stripped = ln.strip()
assert stripped.startswith("& $"), (
f"astral uv installer must be invoked via the call operator on a "
f"resolved host variable (`& $...`), got: {stripped!r}"
)
def test_powershell_host_resolver_is_defined_and_portable(source: str):
"""A host-resolver helper must exist and be PATH-independent + pwsh-aware."""
assert "function Get-PowerShellHostExe" in source, (
"expected a Get-PowerShellHostExe helper that resolves the host exe"
)
# PATH-independent: derive the absolute path of the running host.
assert "Get-Process -Id $PID" in source, (
"resolver must derive the current host's absolute path "
"(Get-Process -Id $PID), which is independent of PATH"
)
# pwsh-aware fallback: PowerShell 7's executable is `pwsh`, not `powershell`.
assert "pwsh" in source, (
"resolver must fall back to pwsh (PowerShell 7) when powershell is "
"unavailable"
)