Compare commits

..

156 Commits

Author SHA1 Message Date
ethernet
c1acf25ee5 wip wip 2026-07-10 19:53:30 -04:00
ethernet
221ef52ed2 Merge remote-tracking branch 'origin/ethie/local-dev-sandbox' into ethie/e2e 2026-07-10 17:16:17 -04:00
ethernet
dbf2a0d94b use latest installer 2026-07-10 17:15:10 -04:00
packet
edddf0530c cache key bust 2026-07-10 17:15:10 -04:00
packet
4a82329e2a close installer when done 2026-07-10 17:15:10 -04:00
packet
046145a590 oh duh we need recurse 2026-07-10 17:15:10 -04:00
packet
b180eea236 wwwwww 2026-07-10 17:15:10 -04:00
packet
fe35926c46 tf 2026-07-10 17:15:10 -04:00
packet
7ac9cb1f90 fixie 2026-07-10 17:15:10 -04:00
packet
6f98cc2fae act gitignore 2026-07-10 17:15:10 -04:00
packet
095f9ab234 fix copy overwrite 2026-07-10 17:15:10 -04:00
ethernet
f511e81bf0 w 2026-07-10 17:15:10 -04:00
ethernet
b87bdd5a73 si 2026-07-10 17:15:10 -04:00
ethernet
8416a70a58 wwwwww 2026-07-10 17:15:10 -04:00
ethernet
d66585a2a1 script 2026-07-10 17:15:10 -04:00
ethernet
16b2ceb71a exit on errors 2026-07-10 17:15:10 -04:00
ethernet
94278f4543 errors 2026-07-10 17:15:10 -04:00
ethernet
12e259087d slepe 2026-07-10 17:15:10 -04:00
ethernet
9695c65ee4 finish install 2026-07-10 17:15:10 -04:00
ethernet
b99fd0eefb wwwwww 2026-07-10 17:15:10 -04:00
ethernet
2d6747fbb6 mmouse 2026-07-10 17:15:10 -04:00
ethernet
ebcb275fcf fffmpeg t 600 2026-07-10 17:15:10 -04:00
ethernet
6ab3e3bba5 oopsie 2026-07-10 17:15:10 -04:00
ethernet
feaa22f746 loggggggggiee 2026-07-10 17:15:10 -04:00
ethernet
2114a8b3d6 logs better 2026-07-10 17:15:10 -04:00
ethernet
38939a0ca4 log 2026-07-10 17:15:10 -04:00
ethernet
887239e566 wip wip 2026-07-10 17:15:10 -04:00
ethernet
df7d3c57bf screenshot at end 2026-07-10 17:15:10 -04:00
ethernet
e30de94c73 omg oops 2026-07-10 17:15:10 -04:00
ethernet
9d08ac703a idk 2026-07-10 17:15:10 -04:00
ethernet
1091f48cf9 wwwwww 2026-07-10 17:15:10 -04:00
ethernet
a46f56702e button update 2026-07-10 17:15:10 -04:00
ethernet
df7d13bd19 w 2026-07-10 17:15:10 -04:00
ethernet
b5d9ef054a variation 2026-07-10 17:15:10 -04:00
ethernet
f0f60e7ec6 wwwww 2026-07-10 17:15:10 -04:00
ethernet
71a8c7d80e numge 2026-07-10 17:15:10 -04:00
ethernet
3b791dc835 www 2026-07-10 17:15:10 -04:00
ethernet
caec80dc38 errorstdout 2026-07-10 17:15:10 -04:00
ethernet
998cd090af w 2026-07-10 17:15:10 -04:00
ethernet
064a1cdc00 timeout oops 2026-07-10 17:15:10 -04:00
ethernet
d86305d402 w 2026-07-10 17:15:10 -04:00
ethernet
1ed98dc240 wwwww 2026-07-10 17:15:10 -04:00
ethernet
d95eb3b5f9 w 2026-07-10 17:15:10 -04:00
ethernet
ddd7694af7 ffmpeg 2026-07-10 17:15:10 -04:00
ethernet
f209b283d0 WIP 2026-07-10 17:15:10 -04:00
ethernet
a25dda5972 wwww 2026-07-10 17:15:10 -04:00
ethernet
754c7e77c8 w 2026-07-10 17:15:10 -04:00
ethernet
3578861070 w 2026-07-10 17:15:10 -04:00
ethernet
0e7eeda906 wwww 2026-07-10 17:15:10 -04:00
ethernet
2578378f86 wwwww 2026-07-10 17:15:10 -04:00
ethernet
1a931f856f concluciosn 2026-07-10 17:15:10 -04:00
ethernet
8018aec689 errorstdout 2026-07-10 17:15:10 -04:00
ethernet
d312733c52 wwwwwww 2026-07-10 17:15:10 -04:00
ethernet
ef1d05e784 wip wip 2026-07-10 17:15:10 -04:00
ethernet
a6c8d8c26e wwwwwwwwww 2026-07-10 17:15:10 -04:00
ethernet
1b505e71b6 run the install! 2026-07-10 17:15:10 -04:00
ethernet
28ac374cd5 aight can we cop UV better 2026-07-10 17:15:10 -04:00
ethernet
3e45df24cf irm iem 2026-07-10 17:15:10 -04:00
ethernet
16987a523f rip out useless tests
these just assert stuff in source code. they don't test behavior. this
is ridiculous lol
2026-07-10 17:15:10 -04:00
ethernet
c57d4f2cb9 oopsie bnix hashes 2026-07-10 17:15:10 -04:00
ethernet
030ffd6000 nix hashes 2026-07-10 17:15:10 -04:00
ethernet
65a3bb2d1a bindir 2026-07-10 17:15:10 -04:00
ethernet
e2bb518dfe nice 2026-07-10 17:15:10 -04:00
ethernet
197599505e wwww 2026-07-10 17:15:10 -04:00
ethernet
f96f644c22 logs? 2026-07-10 17:15:10 -04:00
ethernet
4461fbcc49 wwwwwww 2026-07-10 17:15:10 -04:00
ethernet
4556c10e25 logs 2026-07-10 17:15:10 -04:00
ethernet
7bd1c38bd0 asdasd 2026-07-10 17:15:10 -04:00
ethernet
d6c82c1ae2 clicky 2026-07-10 17:15:10 -04:00
ethernet
588f46cc25 install 2026-07-10 17:15:10 -04:00
ethernet
fbd24a9b46 mousescreen 2026-07-10 17:15:10 -04:00
ethernet
9f32459e97 lick fix 2026-07-10 17:15:10 -04:00
ethernet
d9b131c93e f 2026-07-10 17:15:10 -04:00
ethernet
f69eceb415 hehe 2026-07-10 17:15:10 -04:00
ethernet
5d605c5038 fmt 2026-07-10 17:15:10 -04:00
ethernet
874521acac a 2026-07-10 17:15:10 -04:00
ethernet
f8338a5a6a wwwww 2026-07-10 17:15:10 -04:00
ethernet
26183609a2 wwwwww 2026-07-10 17:15:10 -04:00
ethernet
c125d07354 www 2026-07-10 17:15:10 -04:00
ethernet
4840897b4d keep going 2026-07-10 17:15:10 -04:00
ethernet
59f9b4c057 w 2026-07-10 17:15:10 -04:00
ethernet
64d17d690e logs 2026-07-10 17:15:10 -04:00
ethernet
a4fb0caff8 ggg 2026-07-10 17:15:10 -04:00
ethernet
ff1796bb1a asdasdfasdf 2026-07-10 17:15:10 -04:00
ethernet
012184aa06 wwww 2026-07-10 17:15:10 -04:00
ethernet
565724703b wwwww 2026-07-10 17:15:10 -04:00
ethernet
e39c5bca91 wwwwwwwwwwwwwwwww 2026-07-10 17:15:10 -04:00
ethernet
1219b3bfd3 ccccca 2026-07-10 17:15:10 -04:00
ethernet
0621fad0e2 cont 2026-07-10 17:15:10 -04:00
ethernet
7085ff325f www\ 2026-07-10 17:15:10 -04:00
ethernet
cbd853f194 wwwwww 2026-07-10 17:15:10 -04:00
ethernet
839a9b4c78 env? 2026-07-10 17:15:10 -04:00
ethernet
a78838c1af fix cache weird idk uploady 2026-07-10 17:15:10 -04:00
ethernet
acfc64dd1c wwwwwww 2026-07-10 17:15:10 -04:00
ethernet
47d90bdf11 g 2026-07-10 17:15:10 -04:00
ethernet
ec9bfc5491 lalala
`
2026-07-10 17:15:10 -04:00
ethernet
52d59e7d3a gg 2026-07-10 17:15:09 -04:00
ethernet
50f2681628 wwwww 2026-07-10 17:15:09 -04:00
ethernet
c69e5c476c why 2026-07-10 17:15:09 -04:00
ethernet
af50946d68 wwww 2026-07-10 17:15:09 -04:00
ethernet
ff42474ce3 fix 2026-07-10 17:15:09 -04:00
ethernet
753bc35c91 a 2026-07-10 17:15:09 -04:00
ethernet
c69ec0566d keep goin 2026-07-10 17:15:09 -04:00
ethernet
1b49da6ba6 www 2026-07-10 17:15:09 -04:00
ethernet
417fb844fc mkv 2026-07-10 17:15:09 -04:00
ethernet
1dbce85abe aawawa 2026-07-10 17:15:09 -04:00
ethernet
c57cdbac54 cache bust 2026-07-10 17:15:09 -04:00
ethernet
56c3de01e3 dont pin to build commit 2026-07-10 17:15:09 -04:00
ethernet
69015b460d babababa 2026-07-10 17:15:09 -04:00
ethernet
0feb19e622 stdin redir? 2026-07-10 17:15:09 -04:00
ethernet
77c3bf5222 ffmpeg2 2026-07-10 17:15:09 -04:00
ethernet
4bd9bd6777 yay 2026-07-10 17:15:09 -04:00
ethernet
900ed4047c asdasd 2026-07-10 17:15:09 -04:00
ethernet
7147e0c5a9 ww 2026-07-10 17:15:09 -04:00
ethernet
ab9c1ec9a7 ffmpeg 2026-07-10 17:15:09 -04:00
ethernet
8fdd076c11 www 2026-07-10 17:15:09 -04:00
ethernet
eab8a52fd9 dirs 2026-07-10 17:15:09 -04:00
ethernet
9fcc8e2090 dir 2026-07-10 17:15:09 -04:00
ethernet
97521bedd2 installdir 2026-07-10 17:15:09 -04:00
ethernet
f9e3b10d4f ww 2026-07-10 17:15:09 -04:00
ethernet
f56da0483e www 2026-07-10 17:15:09 -04:00
ethernet
17cf3a37a1 typo 2026-07-10 17:15:09 -04:00
ethernet
c8cf31d3fb bs 2026-07-10 17:15:09 -04:00
ethernet
497b14788a www 2026-07-10 17:15:09 -04:00
ethernet
f39818051d fix weird checkout 2026-07-10 17:15:09 -04:00
ethernet
768f1bf95a ffmpreg 2026-07-10 17:15:09 -04:00
ethernet
556baad5ce heckout optimization 2026-07-10 17:15:09 -04:00
ethernet
138577052b fixie 2026-07-10 17:15:09 -04:00
ethernet
9330c11839 ahk and thing 2026-07-10 17:15:09 -04:00
ethernet
25daa4d290 done 2026-07-10 17:15:09 -04:00
ethernet
7c33990b72 ahk dir correct 2026-07-10 17:15:09 -04:00
ethernet
fe2e07a20f aaa 2026-07-10 17:15:09 -04:00
ethernet
0c9c34ba46 w 2026-07-10 17:15:09 -04:00
ethernet
f23926b220 w 2026-07-10 17:15:09 -04:00
ethernet
a17626cc41 windwos ache 2026-07-10 17:15:09 -04:00
ethernet
f5368d4263 dl artifact 2026-07-10 17:15:09 -04:00
ethernet
e6566b614e simpler installer cache 2026-07-10 17:15:09 -04:00
ethernet
516692d034 wip 2026-07-10 17:15:09 -04:00
ethernet
de709ed677 wwwwwwww 2026-07-10 17:15:09 -04:00
ethernet
b5452e4b6b wip wip installer 2026-07-10 17:15:09 -04:00
ethernet
0d867fe714 cache 2026-07-10 17:15:09 -04:00
ethernet
96f6f5e614 always npm 2026-07-10 17:15:09 -04:00
ethernet
6d03a60cb6 ww 2026-07-10 17:15:09 -04:00
ethernet
89bd6ba230 ahk 2026-07-10 17:15:09 -04:00
ethernet
cfe03d8537 check 2026-07-10 17:15:09 -04:00
ethernet
81eac10f7a winget paths 2026-07-10 17:15:09 -04:00
ethernet
120c8213ac installers 2026-07-10 17:15:09 -04:00
ethernet
c6d8d53a5a wwwwwwww 2026-07-10 17:15:09 -04:00
ethernet
11f857ef04 ahk 2026-07-10 17:15:09 -04:00
ethernet
a626bfe03a t 2026-07-10 17:15:09 -04:00
ethernet
602df2bcfc longer timeout 2026-07-10 17:15:09 -04:00
ethernet
61526ce826 p 2026-07-10 17:15:09 -04:00
ethernet
9ffec3f8eb w 2026-07-10 17:14:51 -04:00
ethernet
50a447c1c3 wip e2e 2026-07-10 17:14:50 -04:00
ethernet
624d91211c feat(dev): add isolated sandbox script for local dev
scripts/desktop-sandbox.sh runs a Hermes desktop instance in an isolated
sandbox — separate HERMES_HOME, separate Electron userData, and a
distinct
app name (HERMES_DESKTOP_APP_NAME) so it doesn't compete with the main
desktop instance's single-instance lock.

Two modes:
- Ephemeral (default): temp dir, cleaned up on exit
- --persistent: stored under .hermes-sandbox/ in the worktree git root,
  survives restarts for repeat testing

In the Nix devShell the script is available as 'sandbox'.

Also makes APP_NAME overridable via HERMES_DESKTOP_APP_NAME in main.ts —
app.setName() runs before requestSingleInstanceLock(), so the overridden
name changes the lock key. collectRelaunchEnv already preserves
HERMES_DESKTOP_* vars through self-update relaunches; test updated to
cover the new env var.
2026-07-10 14:45:05 -04:00
ethernet
1dc9f7178a fix(desktop): don't emit js files when we build desktop 2026-07-10 12:41:36 -04:00
23 changed files with 1894 additions and 315 deletions

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

@@ -0,0 +1,395 @@
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 polluting the system
build-installer:
if: false # NOTE: build-installer is disabled for now, since we don't ship updates to the installer. when we do, re-enable it.
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-built-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 dev installer for this branch
if: steps.installer-cache.outputs.cache-hit != 'true'
run: npm run tauri:build
working-directory: apps/bootstrap-installer
timeout-minutes: 10
# 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: Install this commit as latest main
# 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
if: false # build-installer is since we don't update the installer right now. instead, we download the latest installer from the website
id: installer-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: Hermes-Setup.exe
key: hermes-installer-built-cache-${{ runner.os }}-${{ hashFiles('source/package-lock.json', 'source/apps/bootstrap-installer/**', '!source/apps/bootstrap-installer/src-tauri/target/**') }}
- name: Download latest production installer
id: download-installer
shell: pwsh
run: |
Invoke-WebRequest https://hermes-assets.nousresearch.com/Hermes-Setup.exe -OutFile Hermes-Setup.exe
- 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 ))"
$e2eDir = "$env:RUNNER_TEMP\e2e-windows"
Copy-Item -Path "$env:INSTALL_DIR\e2e\windows" -Destination $e2eDir -Force -Recurse
$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 "$e2eDir\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

3
.gitignore vendored
View File

@@ -4,6 +4,8 @@
/_pycache/
*.pyc*
__pycache__/
act/
.act-sandbox-agent.*
.venv/
.venv
.vscode/
@@ -54,6 +56,7 @@ __pycache__/
hermes_agent.egg-info/
wandb/
testlogs
playwright-report/
# CLI config (may contain sensitive SSH paths)
cli-config.yaml

View File

@@ -118,17 +118,6 @@ def is_write_denied(path: str) -> bool:
continue
for base_real in hermes_dirs:
# Session transcripts are application-owned state. Letting the agent's
# generic file tools rewrite state.db or legacy JSON snapshots can
# falsify conversation history and invalidate resume/compression state.
try:
if resolved == os.path.realpath(os.path.join(base_real, "state.db")):
return True
sessions_real = os.path.realpath(os.path.join(base_real, "sessions"))
if resolved == sessions_real or resolved.startswith(sessions_real + os.sep):
return True
except Exception:
pass
try:
mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))
if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):

View File

@@ -0,0 +1,51 @@
/**
* E2E boot-failure tests — verify the app shows an error overlay when the
* backend can't reach the inference provider.
*
* Launches the app with a provider pointing at a dead endpoint (port 1).
* The `hermes serve` backend starts, but when the renderer tries to connect
* or when a runtime check fails, the app should show a boot failure or
* onboarding error overlay.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from '@playwright/test'
import {
setupDeadBackend,
type DeadBackendFixture,
waitForBootFailure,
} from './fixtures'
let fixture: DeadBackendFixture | null = null
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('boot failure with dead provider endpoint', () => {
test('app shows error state or onboarding', async () => {
fixture = await setupDeadBackend()
// With a dead provider endpoint, the app should eventually show either:
// 1. A boot failure overlay (if the backend fails to start), or
// 2. An onboarding overlay with an error (if the runtime check fails)
// Both outcomes prove the app is handling provider failures gracefully.
//
// We give it a generous timeout — the backend needs to start, the
// renderer needs to boot, and then the runtime check needs to fail.
await waitForBootFailure(fixture.page, 90_000)
})
test('screenshot of error state', async () => {
if (!fixture) {
test.skip(true, 'Previous test failed — no app running')
return
}
const screenshot = await fixture.page.screenshot()
expect(screenshot.byteLength).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,74 @@
/**
* E2E smoke tests for the dev-mode desktop app.
*
* These tests launch the Electron app from the built dist/ (not the
* packaged binary) with a real `hermes serve` backend pointed at a mock
* inference server. The full chain is exercised:
*
* electron → hermes serve (python) → mock provider → renderer
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
* Run from the nix devshell:
* sandbox --persistent -- npx playwright test e2e/boot.spec.ts --reporter=list
*/
import { expect, test } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('dev-mode boot with mock backend', () => {
test('window opens with Hermes title', async () => {
const title = await fixture!.page.title()
expect(title).toContain('Hermes')
})
test('renderer mounts and shows DOM content', async () => {
const page = fixture!.page
// Wait for the React root to mount. The app renders into #root
// (see src/main.tsx), but content may arrive through portals — so
// check the body for any interactive content instead.
await page.waitForSelector('body', { state: 'attached' })
// Wait for the main app shell — the composer is always present.
await page.waitForSelector('textarea, [contenteditable="true"]', {
state: 'attached',
timeout: 30_000,
})
})
test('backend boots and app becomes ready', async () => {
// This is the big one — wait for the full boot chain to complete:
// electron starts → hermes serve is spawned → WS connects → config
// loaded → sessions loaded → boot overlay dismissed → composer visible.
await waitForAppReady(fixture!.page, 120_000)
})
test('screenshot after boot', async () => {
// Use a screenshot without waiting for fonts — the default
// page.screenshot() waits for fonts to load, which can hang in
// headless Electron. page.screenshot({ type: 'png', timeout: 10000 })
// with a shorter timeout is more reliable.
const screenshot = await fixture!.page.screenshot({ timeout: 10_000 }).catch(() => null)
if (screenshot) {
expect(screenshot.byteLength).toBeGreaterThan(0)
} else {
// If screenshot fails (e.g. GPU issues in headless), just skip —
// the important tests are the boot and interaction ones above.
test.skip(true, 'Screenshot timed out — likely a GPU/rendering issue in headless mode')
}
})
})

View File

@@ -0,0 +1,88 @@
/**
* E2E chat tests — send a message and verify a response appears.
*
* Requires the full boot chain to complete (hermes serve + mock inference
* provider). The mock server returns a canned reply, so we verify the
* response text shows up in the chat transcript.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from '@playwright/test'
import {
setupMockBackend,
type MockBackendFixture,
waitForAppReady,
} from './fixtures'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture.page, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('chat interaction with mock backend', () => {
test('send a message and receive a response', async () => {
const page = fixture!.page
// Find the composer — it's a contenteditable textbox.
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 10_000 })
// Click to focus, then type the message character by character.
// Using `type` instead of `fill` because the composer is a
// contenteditable div with custom keydown handling that tracks
// IME composition state — `fill` bypasses the event chain.
await composer.click()
await composer.type('Hello, can you hear me?', { delay: 20 })
// Submit with Enter — the composer's keydown handler intercepts
// plain Enter (without Shift) and calls submitDraft().
await page.keyboard.press('Enter')
// Wait for the user's message to appear in the transcript.
// The message renders as an assistant-ui message in the chat view.
await page.waitForFunction(
() => {
const body = document.body
if (!body) {
return false
}
return (body.textContent ?? '').includes('Hello, can you hear me?')
},
{ timeout: 15_000 },
)
// Wait for the mock response to appear. The canned reply is:
// "Hello from the mock inference server! The full boot chain is working."
// Give it a generous timeout — the inference request goes through the
// gateway → hermes serve → mock server → streaming SSE back.
await page.waitForFunction(
() => {
const body = document.body
if (!body) {
return false
}
const text = body.textContent ?? ''
return text.includes('mock inference server') || text.includes('boot chain is working')
},
{ timeout: 60_000 },
)
})
test('screenshot of chat with messages', async () => {
const screenshot = await fixture!.page.screenshot({ timeout: 10_000 }).catch(() => null)
if (screenshot) {
expect(screenshot.byteLength).toBeGreaterThan(0)
} else {
test.skip(true, 'Screenshot timed out — likely a GPU/rendering issue in headless mode')
}
})
})

View File

@@ -0,0 +1,569 @@
/**
* Shared E2E fixtures for the Hermes desktop Playwright suite.
*
* Two fixture modes:
*
* 1. `mockBackend` — starts a mock inference server, writes a config.yaml
* that points at it, and launches the desktop app so the full chain
* (electron → hermes serve → provider → inference → renderer) is
* exercised with a real backend but a fake LLM.
*
* 2. `noProvider` — launches the app with an empty config (no provider
* configured). The onboarding overlay should appear. Used to test the
* first-run flow without real credentials.
*
* Both modes launch the *dev* Electron app (`electron .` against the built
* `dist/`), not the packaged binary. This avoids the multi-minute
* `electron-builder --dir` step and matches `hermes desktop --source`. The
* packaged-binary path is already covered by `launch.spec.ts`.
*
* Prerequisite: `npm run build` must have been run so that `dist/` exists.
*/
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { spawnSync } from 'node:child_process'
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
import { startMockServer } from './mock-server'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
// ─── Credential stripping (matches launch.spec.ts) ──────────────────────
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 stripCredentials(env: Record<string, string | undefined>): Record<string, string> {
const clean: Record<string, string> = {}
for (const [key, value] of Object.entries(env)) {
if (!value) {
continue
}
if (isCredentialEnvVar(key)) {
continue
}
clean[key] = value
}
return clean
}
// ─── Sandbox creation ──────────────────────────────────────────────────
export interface Sandbox {
root: string
hermesHome: string
userDataDir: string
cleanup: () => void
}
function createSandbox(prefix: string): Sandbox {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-e2e-${prefix}-${Math.random()}`))
const hermesHome = path.join(root, 'hermes-home')
const userDataDir = path.join(root, 'electron-user-data')
fs.mkdirSync(hermesHome, { recursive: true })
fs.mkdirSync(userDataDir, { recursive: true })
return {
root,
hermesHome,
userDataDir,
cleanup: () => {
try {
fs.rmSync(root, { recursive: true, force: true })
} catch {
// best-effort
}
},
}
}
// ─── Config writing ─────────────────────────────────────────────────────
/**
* Write a config.yaml that pre-configures a mock provider pointing at the
* mock inference server. The provider is set as the active model provider so
* the desktop app skips onboarding and boots straight to the chat UI.
*/
function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
const configPath = path.join(hermesHome, 'config.yaml')
const config = `# Auto-generated by E2E test fixtures
model: mock-model
providers:
mock:
api: ${mockUrl}/v1
name: Mock
api_mode: chat_completions
key_env: MOCK_API_KEY
models:
mock-model: {}
context_length: 4096
`
fs.writeFileSync(configPath, config, 'utf8')
}
/**
* Write a minimal .env with the mock API key. The key_env in config.yaml
* references MOCK_API_KEY, so the backend resolves credentials from here.
*/
function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
const envPath = path.join(hermesHome, '.env')
fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8')
}
/**
* Write an empty config (no providers). The desktop app should show the
* onboarding overlay because no inference provider is configured.
*/
function writeEmptyConfig(hermesHome: string): void {
const configPath = path.join(hermesHome, 'config.yaml')
fs.writeFileSync(configPath, '# Auto-generated by E2E test fixtures — no providers configured\n', 'utf8')
}
// ─── Env building ──────────────────────────────────────────────────────
/**
* Build the environment for the Electron app process.
*
* Key env vars:
* - HERMES_HOME → sandbox hermes-home (isolated config/sessions)
* - HERMES_DESKTOP_USER_DATA_DIR → sandbox electron-user-data
* - HERMES_DESKTOP_IGNORE_EXISTING=1 → don't pick up `hermes` from PATH
* (we want the dev checkout at REPO_ROOT)
* - HERMES_DESKTOP_HERMES_ROOT → REPO_ROOT (dev checkout resolution)
* - HERMES_DESKTOP_APP_NAME → unique-ish per test (avoids single-instance lock)
* - XDG_RUNTIME_DIR → ensure Electron has a writable runtime dir on Linux
*/
function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}): Record<string, string> {
const clean = stripCredentials(process.env)
// XDG_RUNTIME_DIR is needed for Electron on Linux when running in a
// headless/CI context — without it the zygote may fail to initialize.
if (!clean.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR) {
clean.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR
}
// DISPLAY — needed for Electron to open a window.
if (!clean.DISPLAY && process.env.DISPLAY) {
clean.DISPLAY = process.env.DISPLAY
}
return {
...clean,
HERMES_HOME: sandbox.hermesHome,
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
HERMES_DESKTOP_IGNORE_EXISTING: '1',
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`,
// Clear dev-server override — we want the built dist/, not a vite server.
// The dev-server check in main.ts looks for this env var; if it's set,
// it loads from the vite URL instead of the local file.
...extra,
}
}
// ─── Electron launch ────────────────────────────────────────────────────
/**
* Verify that the desktop app has been built (dist/ exists). Playwright
* tests can't run without it — the Electron main process loads
* dist/electron-main.mjs and the renderer loads dist/index.html.
*/
function assertDistBuilt(): void {
const distDir = path.join(DESKTOP_ROOT, 'dist')
const electronMain = path.join(distDir, 'electron-main.mjs')
const indexHtml = path.join(distDir, 'index.html')
if (!fs.existsSync(electronMain)) {
throw new Error(
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
`Missing: ${electronMain}`,
)
}
if (!fs.existsSync(indexHtml)) {
throw new Error(
`Desktop dist/index.html not found. Run 'cd apps/desktop && npm run build' first.\n` +
`Missing: ${indexHtml}`,
)
}
}
/**
* Find the Electron binary. In the nix devshell, `electron` is on PATH.
* As a fallback, use the node_modules/.bin/electron from the desktop package.
*/
function findElectron(): string {
// In dev mode, we use the `electron` binary directly (not the packaged app).
// The dev:electron script in package.json does exactly this: `electron .`
// after building. We replicate that here.
const localElectron = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron')
if (fs.existsSync(localElectron)) {
return localElectron
}
// Fall back to PATH
const result = spawnSync('which', ['electron'], {
encoding: 'utf8',
})
if (result.status === 0 && result.stdout.trim()) {
return result.stdout.trim()
}
throw new Error(
'Electron binary not found. Run "npm install" from the repo root to install devDependencies.',
)
}
/**
* Launch the desktop app in dev mode.
*
* @param sandbox - isolated HERMES_HOME + userData
* @param env - the process environment (already has HERMES_HOME etc.)
* @returns the ElectronApplication + first Page
*/
async function launchDesktop(
env: Record<string, string>,
): Promise<{ app: ElectronApplication; page: Page }> {
assertDistBuilt()
const electronBin = findElectron()
// `electron .` loads from the package.json `main` field
// (dist/electron-main.mjs after build).
const app = await _electron.launch({
executablePath: electronBin,
args: [
DESKTOP_ROOT, // `electron .` — the `.` is the desktop package dir
'--disable-gpu',
'--no-sandbox',
'--disable-software-rasterizer',
],
env,
cwd: DESKTOP_ROOT,
})
const page = await app.firstWindow()
return { app, page }
}
// ─── Public fixtures ────────────────────────────────────────────────────
export interface MockBackendFixture {
app: ElectronApplication
page: Page
mockUrl: string
sandbox: Sandbox
cleanup: () => Promise<void>
}
/**
* Set up a full mock-backend E2E environment:
* 1. Start the mock inference server
* 2. Create a sandbox with config.yaml pointing at it
* 3. Launch the desktop app
* 4. Return handles for test interaction
*/
export async function setupMockBackend(): Promise<MockBackendFixture> {
// 1. Start mock server
const mock = await startMockServer()
// 2. Create sandbox + write config
const sandbox = createSandbox('mock')
writeMockProviderConfig(sandbox.hermesHome, mock.url)
writeEnvFile(sandbox.hermesHome)
// 3. Build env + launch
const env = buildAppEnv(sandbox)
const { app, page } = await launchDesktop(env)
return {
app,
page,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}
}
export interface NoProviderFixture {
app: ElectronApplication
page: Page
sandbox: Sandbox
cleanup: () => Promise<void>
}
/**
* Launch the app with no provider configured. The onboarding overlay should
* appear because there's no inference provider in config.yaml.
*/
export async function setupNoProvider(): Promise<NoProviderFixture> {
const sandbox = createSandbox('noprovider')
writeEmptyConfig(sandbox.hermesHome)
const env = buildAppEnv(sandbox)
const { app, page } = await launchDesktop(env)
return {
app,
page,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
sandbox.cleanup()
},
}
}
export interface DeadBackendFixture {
app: ElectronApplication
page: Page
sandbox: Sandbox
cleanup: () => Promise<void>
}
/**
* Launch the app with a provider pointing at a dead endpoint (port 1, which
* nothing listens on). The boot should fail with a connection error,
* triggering the BootFailureOverlay.
*/
export async function setupDeadBackend(): Promise<DeadBackendFixture> {
const sandbox = createSandbox('dead')
const configPath = path.join(sandbox.hermesHome, 'config.yaml')
fs.writeFileSync(
configPath,
`# Auto-generated by E2E test fixtures — dead provider
model: mock-model
providers:
mock:
api: http://127.0.0.1:1/v1
name: Mock
api_mode: chat_completions
key_env: MOCK_API_KEY
models:
mock-model: {}
context_length: 4096
`,
'utf8',
)
writeEnvFile(sandbox.hermesHome)
const env = buildAppEnv(sandbox)
const { app, page } = await launchDesktop(env)
return {
app,
page,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
sandbox.cleanup()
},
}
}
// ─── Packaged-binary fixture ───────────────────────────────────────────
/**
* Resolve the packaged Electron binary path, per-platform, matching
* electron-builder's output layout under release/.
*/
function resolvePackagedBinaryPath(): string {
// The Hermes-Setup.exe in Downloads is the Tauri installer, not the
// Electron desktop binary — it's only launchable on Windows. Only use
// it as the packaged binary on win32.
if (process.platform === 'win32') {
const downloadsExe = path.join(os.homedir(), 'Downloads', 'Hermes-Setup.exe')
if (fs.existsSync(downloadsExe)) {
return downloadsExe
}
return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe')
}
if (process.platform === 'darwin') {
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes')
}
return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes')
}
export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath()
export function packagedBinaryExists(): boolean {
return fs.existsSync(PACKAGED_BINARY_PATH)
}
export interface PackagedAppFixture {
app: ElectronApplication
page: Page
sandbox: Sandbox
cleanup: () => Promise<void>
}
/**
* Launch the *packaged* Electron binary (from `npm run pack` →
* `electron-builder --dir`) with `BOOT_FAKE=1` so it simulates boot
* progress without spawning a real Hermes backend.
*
* Uses the same sandbox isolation (credential stripping, isolated
* HERMES_HOME + userData, unique app name) as the dev-mode fixtures.
*
* Skips if the packaged binary doesn't exist — run `npm run pack` first.
*/
export async function setupPackagedApp(): Promise<PackagedAppFixture> {
if (!packagedBinaryExists()) {
throw new Error(
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
)
}
const sandbox = createSandbox('packaged')
// Build the sandbox env using the shared helpers, then add the
// packaged-binary-specific overrides.
const env = buildAppEnv(sandbox, {
// Fake boot: simulates progress steps without spawning the real backend.
HERMES_DESKTOP_BOOT_FAKE: '1',
HERMES_DESKTOP_BOOT_FAKE_STEP_MS: '120',
})
// Clear dev-server + hermes-root overrides — the packaged binary
// should use its own bundled renderer, not the dev checkout.
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_DEV_SERVER
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES_ROOT
const app = await _electron.launch({
executablePath: PACKAGED_BINARY_PATH,
args: ['--disable-gpu', '--no-sandbox', '--disable-software-rasterizer'],
env,
})
const page = await app.firstWindow()
return {
app,
page,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
sandbox.cleanup()
},
}
}
// ─── Wait helpers ──────────────────────────────────────────────────────
/**
* Wait for the desktop app to finish booting and show the main chat UI.
*
* The boot overlay disappears when `completeDesktopBoot()` fires in the
* renderer — at that point the gateway is open, config is loaded, and
* sessions are loaded. We detect this by waiting for the boot/connecting
* overlay to become invisible and the main app shell to be present.
*/
export async function waitForAppReady(page: Page, timeoutMs = 60_000): Promise<void> {
// The connecting overlay has a data-testid or we can check for the
// absence of boot indicators. The simplest reliable approach is to wait
// for the composer (chat input) to become visible — it's disabled until
// the gateway is open.
await page.waitForSelector('textarea, [contenteditable="true"]', {
state: 'visible',
timeout: timeoutMs,
})
}
/**
* Wait for the onboarding overlay to appear (no provider configured).
*/
export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise<void> {
// The onboarding overlay contains a heading with "Choose your provider"
// or similar text. We look for any text that indicates the picker.
await page.waitForFunction(
() => {
const root = document.getElementById('root')
if (!root) {
return false
}
const text = root.textContent ?? ''
return (
text.includes('provider') ||
text.includes('Provider') ||
text.includes('Choose') ||
text.includes('API key') ||
text.includes('Sign in')
)
},
{ timeout: timeoutMs },
)
}
/**
* Wait for the boot failure overlay to appear.
*/
export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise<void> {
await page.waitForFunction(
() => {
const root = document.getElementById('root')
if (!root) {
return false
}
const text = root.textContent ?? ''
// The boot failure overlay shows an error message + retry/repair
// buttons. Look for error-related text.
return (
text.includes('error') ||
text.includes('Error') ||
text.includes('failed') ||
text.includes('Failed') ||
text.includes('Retry') ||
text.includes('Repair')
)
},
{ timeout: timeoutMs },
)
}

View File

@@ -0,0 +1,82 @@
import { expect, test } from '@playwright/test'
import {
packagedBinaryExists,
PACKAGED_BINARY_PATH,
setupPackagedApp,
type PackagedAppFixture,
} from './fixtures'
/**
* E2E smoke tests for the packaged Hermes desktop app.
*
* Launches the real packaged Electron binary (produced by `npm run pack` →
* `electron-builder --dir`) with BOOT_FAKE=1 and full sandbox isolation
* (credential stripping, isolated HERMES_HOME + userData, unique app name).
*
* Skips if the packaged binary doesn't exist — run `npm run pack` first.
*/
let fixture: PackagedAppFixture | null = null
test.beforeAll(async () => {
test.skip(
!packagedBinaryExists(),
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
)
fixture = await setupPackagedApp()
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test('window opens with the Hermes title', async () => {
const title = await fixture!.page.title()
expect(title).toContain('Hermes')
})
test('renderer loads and shows DOM content', async () => {
const page = fixture!.page
await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 })
const childCount = await page.locator('#root > *').count()
expect(childCount).toBeGreaterThan(0)
})
test('boot progress overlay fades out or shows error state', async () => {
const page = fixture!.page
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. 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 () => {
const screenshot = await fixture!.page.screenshot({ timeout: 10_000 }).catch(() => null)
if (screenshot) {
expect(screenshot.byteLength).toBeGreaterThan(0)
} else {
test.skip(true, 'Screenshot timed out — likely a GPU/rendering issue in headless mode')
}
})

View File

@@ -0,0 +1,203 @@
/**
* Minimal OpenAI-compatible mock inference server for E2E tests.
*
* Implements just enough of the /v1/* surface for `hermes serve` to resolve a
* provider, list models, and stream a canned chat completion back to the
* desktop app — without any real LLM.
*
* Endpoints:
* GET /v1/models → { data: [{ id, ... }] }
* POST /v1/chat/completions → streaming (SSE) or non-streaming response
*
* The canned response is a short, deterministic assistant message. Tool-call
* requests are not simulated — the E2E tests only need the chat surface to
* prove the full boot → gateway → inference → renderer chain works.
*/
import http from 'node:http'
/** A canned assistant reply used for every chat completion request. */
const CANNED_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
/**
* Start the mock server on an ephemeral port.
*
* @returns a handle with `port`, `url`, and `close()`.
*/
export function startMockServer(): Promise<{ port: number; url: string; close: () => Promise<void> }> {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
// CORS headers — the Electron renderer doesn't need them, but they
// don't hurt and make the server usable from a browser context too.
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
// GET /v1/models — return a single fake model.
if (req.method === 'GET' && req.url === '/v1/models') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
object: 'list',
data: [
{
id: 'mock-model',
object: 'model',
created: 0,
owned_by: 'mock',
},
],
}),
)
return
}
// POST /v1/chat/completions — return a canned response.
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
let body = ''
req.on('data', (chunk: Buffer) => {
body += chunk.toString()
})
req.on('end', () => {
let parsed: any = {}
try {
parsed = JSON.parse(body)
} catch {
// malformed JSON — treat as non-streaming with defaults
}
const stream = parsed.stream === true
const model = parsed.model || 'mock-model'
if (stream) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
// Send the content in a few chunks to simulate streaming.
const words = CANNED_REPLY.split(' ')
let i = 0
const sendChunk = () => {
if (i >= words.length) {
// Final chunk with finish_reason
res.write(
`data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [
{
index: 0,
delta: {},
finish_reason: 'stop',
},
],
})}\n\n`,
)
res.write('data: [DONE]\n\n')
res.end()
return
}
const word = i === 0 ? words[i] : ' ' + words[i]
res.write(
`data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [
{
index: 0,
delta: { content: word },
finish_reason: null,
},
],
})}\n\n`,
)
i++
// Small delay between chunks to simulate real streaming.
setTimeout(sendChunk, 20)
}
sendChunk()
} else {
// Non-streaming response
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
id: 'mock-completion',
object: 'chat.completion',
created: 0,
model,
choices: [
{
index: 0,
message: { role: 'assistant', content: CANNED_REPLY },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
},
}),
)
}
})
req.on('error', () => {
res.writeHead(400)
res.end('Bad request')
})
return
}
// Fallback — 404 for anything else
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found' }))
})
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const addr = server.address()
if (addr === null || typeof addr === 'string') {
reject(new Error('Failed to get server address'))
return
}
const port = addr.port
const url = `http://127.0.0.1:${port}`
resolve({
port,
url,
close: () =>
new Promise((resolveClose, rejectClose) => {
server.close((err) => {
if (err) {
rejectClose(err)
} else {
resolveClose()
}
})
}),
})
})
})
}

View File

@@ -0,0 +1,73 @@
/**
* E2E onboarding tests — verify the provider picker appears when no
* inference provider is configured.
*
* Launches the app with an empty config.yaml (no providers). The renderer
* should detect the unconfigured state and show the DesktopOnboardingOverlay
* with provider options / API key form.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from '@playwright/test'
import {
setupNoProvider,
type NoProviderFixture,
waitForOnboarding,
} from './fixtures'
let fixture: NoProviderFixture | null = null
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('onboarding with no provider configured', () => {
test('onboarding overlay appears on first boot', async () => {
fixture = await setupNoProvider()
// The app should boot (hermes serve starts fine even without a provider),
// but the renderer should show the onboarding overlay because no
// provider is configured.
await waitForOnboarding(fixture.page, 90_000)
})
test('onboarding shows provider options or API key form', async () => {
if (!fixture) {
test.skip(true, 'Previous test failed — no app running')
return
}
const page = fixture.page
// The onboarding overlay should contain provider-related text.
// It might show OAuth providers, an API key form, or a "choose later"
// link. Verify at least one of these is visible.
const rootText = await page.evaluate(() => {
const root = document.getElementById('root')
return root?.textContent ?? ''
})
const hasProviderText =
rootText.includes('provider') ||
rootText.includes('Provider') ||
rootText.includes('API key') ||
rootText.includes('Sign in') ||
rootText.includes('OpenRouter') ||
rootText.includes('OpenAI')
expect(hasProviderText).toBe(true)
})
test('screenshot of onboarding overlay', async () => {
if (!fixture) {
test.skip(true, 'Previous test failed — no app running')
return
}
const screenshot = await fixture.page.screenshot()
expect(screenshot.byteLength).toBeGreaterThan(0)
})
})

View File

@@ -44,6 +44,8 @@
"lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' '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.mjs && vite preview --host 127.0.0.1 --port 4174"
},
@@ -120,6 +122,7 @@
"devDependencies": {
"@electron/rebuild": "^4.0.6",
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.61.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/d3-force": "^3.0.10",

View File

@@ -0,0 +1,18 @@
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: {
screenshot: 'on',
trace: 'on',
},
})

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,126 @@
#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)
}
FindImageInWindow(winTitle, imageFile, &outX, &outY, 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)
{
outX := x + Floor(width / 2)
outY := 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))
}
ClickCenterOfImageInWindow(winTitle, imageFile, timeoutMs := 10000, intervalMs := 250)
{
FindImageInWindow(winTitle, imageFile, &x, &y, timeoutMs, intervalMs)
ClickWithMarker(x, y)
}
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")
; wait for install to finish
FindImageInWindow(winTitle, A_ScriptDir "\launch-button.png", &launchX, &launchY, 1000 * 60 * 8)
; after we find the install button, close the window so we don't launch hermes.
WinClose(winTitle)
; yay :D
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:

64
package-lock.json generated
View File

@@ -133,6 +133,7 @@
"devDependencies": {
"@electron/rebuild": "^4.0.6",
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.61.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/d3-force": "^3.0.10",
@@ -3299,6 +3300,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",
@@ -15472,6 +15489,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

@@ -412,36 +412,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.
@@ -457,20 +427,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
@@ -479,14 +501,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,73 +0,0 @@
"""Session transcript stores are read-only to agent file tools.
Inspired by Claude Code 2.1.205's auto-mode rule preventing transcript
manipulation. Hermes keeps canonical conversation history in state.db and may
also emit legacy JSON snapshots under sessions/; agent tools must not rewrite
or delete either store.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
@pytest.fixture()
def fake_homes(tmp_path, monkeypatch):
import agent.file_safety as fs
root = tmp_path / ".hermes"
profile = root / "profiles" / "work"
profile.mkdir(parents=True)
monkeypatch.setattr(fs, "_hermes_home_path", lambda: profile)
monkeypatch.setattr(fs, "_hermes_root_path", lambda: root)
return root, profile
@pytest.mark.parametrize("relative", ["state.db", "sessions/session_abc.json"])
def test_session_state_paths_are_write_denied(fake_homes, relative):
from agent.file_safety import is_write_denied
_root, profile = fake_homes
target = profile / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("existing transcript", encoding="utf-8")
assert is_write_denied(str(target)) is True
def test_default_profile_state_db_is_write_denied_from_profile(fake_homes):
from agent.file_safety import is_write_denied
root, _profile = fake_homes
target = root / "state.db"
target.write_text("canonical transcript", encoding="utf-8")
assert is_write_denied(str(target)) is True
def test_project_local_state_db_remains_writable(fake_homes, tmp_path):
from agent.file_safety import is_write_denied
target = tmp_path / "project" / "state.db"
target.parent.mkdir()
target.write_text("application database", encoding="utf-8")
assert is_write_denied(str(target)) is False
def test_write_file_tool_preserves_existing_session_snapshot(fake_homes, monkeypatch):
import tools.file_tools as ft
_root, profile = fake_homes
target = profile / "sessions" / "session_abc.json"
target.parent.mkdir(parents=True)
target.write_text("original transcript", encoding="utf-8")
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
result = json.loads(ft.write_file_tool(str(target), "tampered"))
assert "error" in result
assert target.read_text(encoding="utf-8") == "original transcript"

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"
)