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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Diese Datei ist der Index; die Tiefe liegt in `docs/`:

- **Backend:** ASP.NET Core Web API, .NET 10, Windows-only (`net10.0-windows`)
- **Datenbank:** PostgreSQL (default) / SQL Server (`Database:Provider` = `postgres` | `sqlserver`). SQLite nur als Test-In-Memory-Backend.
- **Remote Execution:** PowerShell SDK / WinRM, agentless. `Remote:Provider`: `winrm` (default) | `noop` (`noop` muss per `Remote:AllowNoop=true` bzw. `NODEPILOT_ALLOW_NOOP_REMOTE=1` quittiert werden, sonst Boot-Abbruch)
- **Remote Execution:** PowerShell SDK / WinRM, agentless. `Remote:Provider`: `winrm` (default) | `noop` (`noop` muss per `Remote:AllowNoop=true` bzw. `NODEPILOT_ALLOW_NOOP_REMOTE=1` quittiert werden, sonst Boot-Abbruch). Engine-local (In-Proc-Pool): implizite WinPS-Kompatibilität **deaktiviert** (Desktop-only-Module → lauter Fehler statt `powershell.exe -s`-Session-Leak; `Microsoft.PowerShell.Archive` gebündelt) — Details `docs/claude-reference.md` + `docs/performance-improvements.md`
- **Real-time:** SignalR (`/hubs/execution`)
- **Logging:** Serilog. Format via `Logging:Format`: `text`|`cmtrace`|`json`|`ecs-json` (ECS 1.x für SIEM, siehe `docs/siem-logging.md`). Support-Log: File + DB-Projektion
- **MCP-Server (opt-in):** `nodepilot-mcp` (stdio) — AI-Agent steuert/editiert Workflows über 99 Tools, HTTP-only gegen die REST-API
Expand Down Expand Up @@ -251,7 +251,7 @@ Standard-Invocations (`dotnet build|test`, in `src/nodepilot-ui` die `package.js

**E2E (Playwright):** hermetische Specs in `src/nodepilot-ui/e2e/`, alle APIs gemockt (kein Backend/Postgres nötig). Konventionen: `src/nodepilot-ui/CLAUDE.md` + `src/nodepilot-ui/e2e/README.md`.

**Desktop-Shell:** `src/nodepilot-desktop` hat eine eigene vitest-Suite (node-Env) für die reine Logik — `config.ts` (desktop.json-Handoff-Validierung) + `security.ts` (Cert-Pinning, Navigations-Containment). `npm run test:run`; eigener CI-Job `desktop`.
**Desktop-Shell:** `src/nodepilot-desktop` hat eine eigene vitest-Suite (node-Env) für die reine Logik — `config.ts` (desktop.json-Handoff-Validierung), `security.ts` (Cert-Pinning, Navigations-Containment) + `skins.ts` (Skin-Icon-Auflösung aus der Favicon-Meldung der SPA). `npm run test:run`; eigener CI-Job `desktop`.

**Nightly:** Windows-Task `NodePilot Nightly Tests` (täglich 22:00) fährt via `scripts/nightly-tests.ps1` alle vier Suiten (je 1× Retry bei Flake), Report nach `C:\temp\nodepilot-nightly\` (+ `latest.md`). Das Skript gibt vorm Rebuild Port 5000 frei + killt verwaiste `testhost`-Prozesse. Manuell: `powershell -File scripts/nightly-tests.ps1`; Zeit ändern: `scripts/register-nightly-task.ps1 -Time HH:mm`.

Expand Down Expand Up @@ -336,4 +336,4 @@ Getrennt vom Workflow-Export: voller DR-Snapshot der Konfiguration (Workflows+Fo

Produktiv-Rollout über `deploy/`-Skripte — Claude führt sie **nicht** aus. Vollständige Doku: `deploy/README.md`. Architektur (gMSA, Kestrel-HTTPS, Install-Dir-Split, Config-Keys, Stolperfallen): siehe `docs/claude-reference.md`.

**Desktop-App (Electron, `deploy/desktop/`):** zweites Shipping-Ziel — offline Win-11-x64-Installer, alles als Boot-Start-Dienste. Posture `Deployment:Mode` (`Server`|`Desktop`, default `Server`): Desktop relaxiert **nur** loopback-DB-TLS + Kestrel-`ListenLocalhost` + 120s-Postgres-Wait vor dem Migration-Bootstrap; Rest bleibt Production-gehärtet. Volle Doku (Architektur, Dienste-Identitäten, First-Run-Admin-Handoff): `deploy/desktop/README.md`.
**Desktop-App (Electron, `deploy/desktop/`):** zweites Shipping-Ziel — offline Win-11-x64-Installer, alles als Boot-Start-Dienste. Posture `Deployment:Mode` (`Server`|`Desktop`, default `Server`): Desktop relaxiert **nur** loopback-DB-TLS + Kestrel-`ListenLocalhost` + 120s-Postgres-Wait vor dem Migration-Bootstrap; Rest bleibt Production-gehärtet. Icons kommen aus `scripts/generate-desktop-icons.ps1` (Default blau, Fenster-/Tray-Icon folgt zur Laufzeit dem SPA-Skin über `page-favicon-updated`). Volle Doku (Architektur, Dienste-Identitäten, First-Run-Admin-Handoff): `deploy/desktop/README.md`.
40 changes: 40 additions & 0 deletions Directory.Build.targets
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<Project>

<!--
Repo-wide MSBuild targets. Imported automatically by every .csproj under the repo root.

NodePilotPlacePowerShellSdkConfig: puts src/NodePilot.Engine/PowerShell/powershell.config.json
next to the PowerShell SDK's System.Management.Automation.dll in the output. The SDK reads
`$PSHOME/powershell.config.json`, and $PSHOME is wherever that DLL was loaded from — so this
file is how `DisableImplicitWinCompat` reaches the in-process runspace pool. Without it,
a script calling a Windows-PowerShell-only cmdlet makes the pool silently spawn a
`powershell.exe -Version 5.1 -s` compatibility session per pool runspace that is never closed
(the 2026-07-30 memory/thread leak). With it, such calls fail loudly (CommandNotFound).

Scope is exactly the in-process SDK: standalone powershell.exe (5.1) and pwsh.exe processes
have their own $PSHOME and never see this file, so the process-spawn engines are unaffected.

Two layouts, hence two targets:
- Build output keeps the NuGet runtime asset layout (runtimes\win\lib\net10.0\).
- Publish flattens the runtime assets into the app root.
The Exists() conditions make this a no-op for projects that don't pull in the PowerShell SDK.
-->
<PropertyGroup>
<NodePilotPsConfigSource>$(MSBuildThisFileDirectory)src\NodePilot.Engine\PowerShell\powershell.config.json</NodePilotPsConfigSource>
</PropertyGroup>

<Target Name="NodePilotPlacePowerShellSdkConfig" AfterTargets="Build"
Condition="Exists('$(NodePilotPsConfigSource)') And Exists('$(OutDir)runtimes\win\lib\net10.0\System.Management.Automation.dll')">
<Copy SourceFiles="$(NodePilotPsConfigSource)"
DestinationFolder="$(OutDir)runtimes\win\lib\net10.0"
SkipUnchangedFiles="true" />
</Target>

<Target Name="NodePilotPlacePowerShellSdkConfigOnPublish" AfterTargets="Publish"
Condition="Exists('$(NodePilotPsConfigSource)') And Exists('$(PublishDir)System.Management.Automation.dll')">
<Copy SourceFiles="$(NodePilotPsConfigSource)"
DestinationFolder="$(PublishDir)"
SkipUnchangedFiles="true" />
</Target>

</Project>
84 changes: 5 additions & 79 deletions deploy/desktop/Build-DesktopInstaller.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ $Stage = Join-Path $OutputRoot 'stage'
$DesktopDir = Join-Path $RepoRoot 'src\nodepilot-desktop'
$UiDir = Join-Path $RepoRoot 'src\nodepilot-ui'
$ApiCsproj = Join-Path $RepoRoot 'src\NodePilot.Api\NodePilot.Api.csproj'
$AssetsDir = Join-Path $DesktopDir 'assets'

function Write-Step([string] $m) { Write-Host "==> $m" -ForegroundColor Cyan }
function Assert-Tool([string] $name, [string] $probe) {
Expand Down Expand Up @@ -63,85 +62,12 @@ if (Test-Path -LiteralPath $Stage) { Remove-Item -LiteralPath $Stage -Recurse -F
New-Item -ItemType Directory -Force -Path $Stage, $OutputRoot | Out-Null

# --- icons -----------------------------------------------------------------------------------
# Derived from the tracked brand asset so the installer, Start-Menu entry, taskbar and tray all
# Derived from the tracked brand assets so the installer, Start-Menu entry, taskbar and tray all
# show the real NodePilot logo. The generated files stay out of git (assets/.gitignore); the
# SOURCE is versioned, which is why a clean clone can always rebuild them.
Write-Step 'Generating application icons from the brand asset'
New-Item -ItemType Directory -Force -Path $AssetsDir | Out-Null
Add-Type -AssemblyName System.Drawing

$brandIcon = Join-Path $UiDir 'public\appicon.png'
if (-not (Test-Path -LiteralPath $brandIcon)) {
throw "Brand icon not found: $brandIcon. The desktop package must not ship a placeholder icon."
}
$brandBitmap = [System.Drawing.Image]::FromFile($brandIcon)

function New-ScaledBitmap([System.Drawing.Image] $source, [int] $size) {
# 32bpp ARGB + HighQualityBicubic keeps the logo's transparency and edges intact when the
# 635x635 source is reduced to icon sizes.
$bmp = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality
$g.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality
$g.Clear([System.Drawing.Color]::Transparent)
$g.DrawImage($source, (New-Object System.Drawing.Rectangle(0, 0, $size, $size)))
$g.Dispose()
return $bmp
}

# Multi-resolution ICO, written by hand: System.Drawing's Icon.Save(GetHicon()) emits a single
# resolution, which Windows then rescales badly in Explorer and the taskbar. The ICO container is
# a 6-byte header + one 16-byte directory entry per image + the PNG payloads (PNG-compressed
# entries are valid since Vista and keep the file small).
function Write-MultiSizeIco([System.Drawing.Image] $source, [int[]] $sizes, [string] $path) {
$payloads = foreach ($s in $sizes) {
$bmp = New-ScaledBitmap $source $s
$ms = New-Object System.IO.MemoryStream
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
[pscustomobject]@{ Size = $s; Bytes = $ms.ToArray() }
$ms.Dispose()
}

$fs = [System.IO.File]::Create($path)
$bw = New-Object System.IO.BinaryWriter($fs)
try {
$bw.Write([uint16]0) # reserved
$bw.Write([uint16]1) # type: icon
$bw.Write([uint16]$payloads.Count)
$offset = 6 + (16 * $payloads.Count) # header + directory
foreach ($p in $payloads) {
# 256 is encoded as 0 in the single-byte width/height fields.
$dim = if ($p.Size -ge 256) { 0 } else { $p.Size }
$bw.Write([byte]$dim); $bw.Write([byte]$dim)
$bw.Write([byte]0) # palette colours (0 = truecolour)
$bw.Write([byte]0) # reserved
$bw.Write([uint16]1) # colour planes
$bw.Write([uint16]32) # bits per pixel
$bw.Write([uint32]$p.Bytes.Length)
$bw.Write([uint32]$offset)
$offset += $p.Bytes.Length
}
foreach ($p in $payloads) { $bw.Write($p.Bytes) }
} finally { $bw.Dispose(); $fs.Dispose() }
}

$icoPngPath = Join-Path $AssetsDir 'icon.png'
$trayPath = Join-Path $AssetsDir 'tray.png'
$setupIco = Join-Path $Stage 'setup-icon.ico'
$appIco = Join-Path $AssetsDir 'icon.ico'

$big = New-ScaledBitmap $brandBitmap 256
$big.Save($icoPngPath, [System.Drawing.Imaging.ImageFormat]::Png)
$big.Dispose()
# Tray sits in the notification area at 16px (Windows picks 20/24 on high DPI, hence 32 as source).
(New-ScaledBitmap $brandBitmap 32).Save($trayPath, [System.Drawing.Imaging.ImageFormat]::Png)
Write-MultiSizeIco -source $brandBitmap -sizes @(16, 32, 48, 256) -path $appIco
$brandBitmap.Dispose()
Copy-Item -LiteralPath $appIco -Destination $setupIco -Force
Write-Host (" icon.ico {0:N0} KB (16/32/48/256), tray.png, icon.png" -f ((Get-Item $appIco).Length / 1KB))
# SOURCES are versioned, which is why a clean clone can always rebuild them. The generator also
# emits assets/skins/<id>.* so the shell can recolor its window + tray icon with the SPA skin.
Write-Step 'Generating application icons from the brand assets'
& (Join-Path $RepoRoot 'scripts\generate-desktop-icons.ps1') -SetupIconPath (Join-Path $Stage 'setup-icon.ico')

# --- 1. API (self-contained) -----------------------------------------------------------------
Write-Step 'Publishing API (self-contained win-x64)'
Expand Down
45 changes: 39 additions & 6 deletions deploy/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ and a PostgreSQL 16 binaries folder (the `pgsql` directory from the EDB zip dist
# -> out\NodePilot-Desktop-Setup-1.0.0.exe (sign with your Authenticode cert before distribution)
```

The build generates the icons from the tracked brand asset `src/nodepilot-ui/public/appicon.png`
(multi-resolution `.ico`: 16/32/48/256), publishes the API self-contained (`-r win-x64
The build generates the icons via `scripts/generate-desktop-icons.ps1` (see **Icons** below),
publishes the API self-contained (`-r win-x64
--self-contained true`, no single-file — the PowerShell SDK is folder-deployed), builds the SPA into
`app\wwwroot`, packages the Electron shell with Electron Forge, stages the Postgres server runtime +
scripts, and compiles the installer.
Expand All @@ -122,6 +122,32 @@ Two build steps are load-bearing and easy to break by accident:
is not optional — `initdb` fails without `postgres.bki` and the timezone data. `-PgBinariesPath`
may therefore point at a full EDB folder or an already-trimmed one.

### Icons

`scripts/generate-desktop-icons.ps1` renders `src/nodepilot-desktop/assets/` from the SPA's tracked
brand assets (`src/nodepilot-ui/public/appicon-<skin>.png`). The output is gitignored; the sources
are versioned, so a clean clone can always rebuild it.

| Output | Used for |
|---|---|
| `icon.ico` (16/32/48/256) | exe, installer, Start-Menu entry, Explorer |
| `icon.png` / `tray.png` | every window + the tray until the SPA reports its skin |
| `skins\<id>.png` / `<id>-tray.png` | window + tray icon per SPA color skin |

The static default is **blue** — rendered from `appicon-dark.png`, not from the untinted orange
source art `appicon.png` (`-DefaultSkin` picks a different one). At runtime the shell follows the
skin: the SPA rewrites `<link rel="icon">` to `/appicon-<skin>.png` on every skin switch, Chromium
reports that as `page-favicon-updated`, and `src/skins.ts` maps it back onto `skins\<id>.*`. That
keeps the production SPA window preload-less and IPC-free — the shell reads a one-way signal the
renderer already broadcasts. The per-skin set is discovered from the `appicon-*.png` files, so a new
UI skin needs no change here.

The `.exe`/installer/Start-Menu icon cannot follow a skin — Windows resolves those from the file
itself, which is why the shipped default matters.

Running the Electron shell straight from source (`npm start`, see below) starts with an empty
`assets/`: run `npm run icons` in `src/nodepilot-desktop` once to populate it.

## Install / update / uninstall

- **Install:** run the `.exe` as a local administrator (UAC). It lays down files, runs
Expand All @@ -141,6 +167,7 @@ Two build steps are load-bearing and easy to break by accident:
| File | Role |
|---|---|
| `Build-DesktopInstaller.ps1` | Build orchestrator (icons + publish + SPA + Modules + Electron + PG subset + ISCC). |
| `../../scripts/generate-desktop-icons.ps1` | Icon set from the SPA brand assets (default + per-skin); also runnable standalone. |
| `Sync-DesktopApp.ps1` | Dev loop: pushes local changes into an installed app in ~1 min (see below). |
| `NodePilot.iss` | Inno Setup installer definition. |
| `Provision-LocalDb.ps1` | First-run/repeatable runtime provisioner (DB, services, cert, config, handoff). |
Expand All @@ -156,6 +183,7 @@ app is just files plus two services, so day-to-day changes have much shorter loo
| Changing | Fastest loop | Time |
|---|---|---|
| Electron shell | `cd src/nodepilot-desktop; npm start` — runs **from source** against the installed backend (it reads `%ProgramData%\NodePilot\desktop.json`), no packaging at all | seconds |
| Electron shell, icons | `npm run icons` in the same folder — regenerates `assets/` (empty in a fresh clone) | seconds |
| Backend / SPA, normal work | ordinary dev mode (backend on 5000, Vite on 5173 with HMR) | seconds |
| Backend / SPA, **as packaged** | `Sync-DesktopApp.ps1 -Component api\|spa\|all` (elevated) — incremental publish/build, robocopy into the installation, service restart + health poll | ~1 min |
| Distribution | `Build-DesktopInstaller.ps1` | ~10–15 min |
Expand All @@ -164,17 +192,22 @@ Use the sync script when the *packaging* matters — service identity is LocalSy
bundled Postgres, TLS is the pinned loopback cert — none of which dev mode reproduces. It never
mirrors over `app\Modules` or `app\wwwroot`, so the PowerShell modules and SPA stay intact.

Quit the installed shell first (tray → *Quit Electron*) before `npm start`: both resolve to the same
`productName`, so the single-instance lock makes the second one focus the first and exit. Shell
changes reach the *installed* app only through a new installer — `app.asar` is not patchable.

## Known gaps (deliberately not covered in v1)

Honest inventory so nobody assumes more coverage than exists:

- **The Electron module's pure logic is unit-tested; its Electron-runtime behaviour is not.**
`npm run test:run` in `src/nodepilot-desktop` (vitest, node environment) covers `config.ts`
(desktop.json handoff validation — origin, fingerprint, serviceName injection barrier) and
(desktop.json handoff validation — origin, fingerprint, serviceName injection barrier),
`security.ts` (certificate-pin match/mismatch/parse-failure, non-loopback rejection, permission
and download blocking, navigation containment). What still needs a real Electron process — the
setup-token IPC guard, the elevated `restartBackend` path, window lifecycle — is verified only by
hand. The backend half of the feature *is* unit-tested (`DeploymentModeTests`,
and download blocking, navigation containment) and `skins.ts` (favicon → skin-icon resolution,
including the path-charset guard on the renderer-supplied id). What still needs a real Electron
process — the setup-token IPC guard, the elevated `restartBackend` path, window lifecycle, and
whether Chromium actually reports the SPA's favicon swap — is verified only by hand. The backend half of the feature *is* unit-tested (`DeploymentModeTests`,
`DatabaseTlsBootValidatorTests`, `DatabaseReadinessGateTests`, `KestrelHttpsConfiguratorTests`).
- **No CI coverage for `deploy/desktop/*`.** The `desktop` CI job runs `npm audit`, typecheck and
vitest for `src/nodepilot-desktop`, and the nightly script adds a `desktop-vitest` suite; there is
Expand Down
7 changes: 7 additions & 0 deletions docs/ai-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,13 @@ DB-Tools nutzen Strict Function Schemas; inkompatible lokale Endpoints erhalten
Best-Effort-Retry. SQL-Text wird nicht auditiert, stattdessen nur Anzahl und SHA-256-Kurzfingerprints.
Text2SQL ist nur als Capability sichtbar, wenn das aktive Profil `EnableToolCalling=true` hat.

**Einstiegsvorschläge folgen den Capabilities.** Der Empty-State hält zwei Prompt-Sets in i18n
(`ai:knowledge.examples` + `ai:knowledge.examplesLite`, je 8 Strings) und wählt anhand von `caps.db`:
mit DB-Quelle Betriebsauswertungen (letzte Fehlläufe, hängende Runs, unerreichbare Maschinen,
Audit-Trail), sonst Doku- und Zeitplan-Fragen. Grund: die Alltagsfragen brauchen text2sql, das per
Default aus **und** Admin/Operator-only ist — einem Viewer würden sie nur „Quelle nicht verfügbar"
liefern. Das Grid rendert erst nach dem Auflösen der Capabilities, sonst blitzt das Lite-Set auf.

---

## Bewusst auf v2 verschoben
Expand Down
Loading
Loading