diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 023c0c2..c5d57f1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -16,6 +16,7 @@ on:
- crates/dashboard-app/build.rs
- crates/dashboard-app/src/mark.rs
- crates/dashboard-app/src/main.rs
+ - crates/dashboard-core/src/config.rs
- Cargo.toml
- Cargo.lock
- crates/*/Cargo.toml
@@ -89,6 +90,23 @@ jobs:
path: native-captures/
retention-days: 14
+ # Issue 83: real clicks on the release exe, an Administrators-owned config and a filtered token.
+ - name: Native Windows view-save capture (issue 83)
+ if: github.event_name == 'pull_request'
+ timeout-minutes: 10
+ shell: pwsh
+ env:
+ CAPTURE_HEAD: ${{ github.event.pull_request.head.sha }}
+ run: ./scripts/native-capture/Invoke-ViewSaveCapture.ps1 -ExpectedHead $env:CAPTURE_HEAD
+
+ - name: Upload view-save evidence
+ if: always() && github.event_name == 'pull_request'
+ uses: actions/upload-artifact@v4
+ with:
+ name: view-save-windows-${{ github.event.pull_request.head.sha }}
+ path: view-save-captures/
+ retention-days: 14
+
- name: Upload the package
if: always()
uses: actions/upload-artifact@v4
diff --git a/AGENTS.md b/AGENTS.md
index 7b2d9d7..3ab79bb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -66,6 +66,10 @@ cargo deny --locked check advisories # needs cargo inst
CAPTURE_DIR=$PWD/captures cargo test -p dashboard-app captures -- --ignored # PNGs (Linux or macOS; absolute path)
```
+The Release workflow's PR runs also start the release exe on the hosted Windows desktop under a filtered token, with an
+Administrators-owned `config.toml`, and click Project, Backlog and Fleet (`scripts/native-capture/Invoke-ViewSaveCapture.ps1`,
+#83). Its screenshots are in the `view-save-windows-
` artifact.
+
On Linux, building needs GPUI's headers:
`sudo apt install libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libfontconfig-dev libfreetype-dev libxcb1-dev libx11-xcb-dev`.
diff --git a/scripts/native-capture/FilteredToken.cs b/scripts/native-capture/FilteredToken.cs
new file mode 100644
index 0000000..3733766
--- /dev/null
+++ b/scripts/native-capture/FilteredToken.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Text;
+
+// Starts a process the way UAC starts a non-elevated one from an administrator's logon: Administrators held
+// deny-only, no extra privileges, medium integrity. Such a token can't name Administrators as a file's owner.
+public static class FilteredToken {
+ const string Administrators = "S-1-5-32-544";
+ const string MediumIntegrity = "S-1-16-8192";
+ const uint TOKEN_ALL_ACCESS = 0xF01FF;
+ const uint DISABLE_MAX_PRIVILEGE = 1, LUA_TOKEN = 4;
+ const uint SE_GROUP_USE_FOR_DENY_ONLY = 0x10, SE_GROUP_INTEGRITY = 0x20;
+ const int TokenGroups = 2, TokenIntegrityLevel = 25;
+ const uint CREATE_UNICODE_ENVIRONMENT = 0x400, CREATE_NO_WINDOW = 0x08000000;
+
+ [StructLayout(LayoutKind.Sequential)] struct SID_AND_ATTRIBUTES { public IntPtr Sid; public uint Attributes; }
+ [StructLayout(LayoutKind.Sequential)] struct PROCESS_INFORMATION { public IntPtr Process, Thread; public uint ProcessId, ThreadId; }
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] struct STARTUPINFO {
+ public int Size; public string Reserved, Desktop, Title;
+ public uint X, Y, Width, Height, CountChars, CountLines, Fill, Flags;
+ public ushort Show, Reserved2; public IntPtr Reserved3, Input, Output, Error;
+ }
+
+ [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
+ [DllImport("kernel32.dll", SetLastError = true)] static extern bool CloseHandle(IntPtr handle);
+ [DllImport("advapi32.dll", SetLastError = true)] static extern bool OpenProcessToken(IntPtr process, uint access, out IntPtr token);
+ [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] static extern bool ConvertStringSidToSidW(string text, out IntPtr sid);
+ [DllImport("advapi32.dll")] static extern int GetLengthSid(IntPtr sid);
+ [DllImport("advapi32.dll")] static extern bool EqualSid(IntPtr a, IntPtr b);
+ [DllImport("advapi32.dll", SetLastError = true)] static extern bool CreateRestrictedToken(IntPtr existing, uint flags, uint disableCount, SID_AND_ATTRIBUTES[] disable, uint deleteCount, IntPtr delete, uint restrictCount, IntPtr restrict, out IntPtr result);
+ [DllImport("advapi32.dll", SetLastError = true)] static extern bool SetTokenInformation(IntPtr token, int kind, ref SID_AND_ATTRIBUTES label, int length);
+ [DllImport("advapi32.dll", SetLastError = true)] static extern bool GetTokenInformation(IntPtr token, int kind, IntPtr buffer, int length, out int needed);
+ [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] static extern bool CreateProcessAsUserW(IntPtr token, string application, StringBuilder commandLine, IntPtr processAttributes, IntPtr threadAttributes, bool inherit, uint flags, string environment, string directory, ref STARTUPINFO startup, out PROCESS_INFORMATION information);
+
+ static void Check(bool ok, string what) {
+ if (!ok) throw new InvalidOperationException(what + " failed: Windows error " + Marshal.GetLastWin32Error());
+ }
+ static IntPtr Sid(string text) {
+ IntPtr sid;
+ Check(ConvertStringSidToSidW(text, out sid), "ConvertStringSidToSid");
+ return sid;
+ }
+
+ // True only when the token holds Administrators and may use it for nothing but deny entries.
+ static bool AdministratorsIsDenyOnly(IntPtr token) {
+ int needed;
+ GetTokenInformation(token, TokenGroups, IntPtr.Zero, 0, out needed);
+ IntPtr buffer = Marshal.AllocHGlobal(needed);
+ try {
+ Check(GetTokenInformation(token, TokenGroups, buffer, needed, out needed), "GetTokenInformation");
+ int count = Marshal.ReadInt32(buffer);
+ int size = Marshal.SizeOf(typeof(SID_AND_ATTRIBUTES));
+ IntPtr administrators = Sid(Administrators);
+ for (int i = 0; i < count; i++) {
+ var group = (SID_AND_ATTRIBUTES)Marshal.PtrToStructure(buffer + IntPtr.Size + i * size, typeof(SID_AND_ATTRIBUTES));
+ if (EqualSid(group.Sid, administrators)) return (group.Attributes & SE_GROUP_USE_FOR_DENY_ONLY) != 0;
+ }
+ return false;
+ } finally { Marshal.FreeHGlobal(buffer); }
+ }
+
+ // A restricted copy of the caller's own token needs no SeAssignPrimaryTokenPrivilege.
+ public static int Start(string application, string directory, IDictionary environment) {
+ IntPtr own, filtered;
+ Check(OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, out own), "OpenProcessToken");
+ try {
+ var disable = new[] { new SID_AND_ATTRIBUTES { Sid = Sid(Administrators) } };
+ Check(CreateRestrictedToken(own, DISABLE_MAX_PRIVILEGE | LUA_TOKEN, 1, disable, 0, IntPtr.Zero, 0, IntPtr.Zero, out filtered), "CreateRestrictedToken");
+ } finally { CloseHandle(own); }
+ try {
+ var label = new SID_AND_ATTRIBUTES { Sid = Sid(MediumIntegrity), Attributes = SE_GROUP_INTEGRITY };
+ Check(SetTokenInformation(filtered, TokenIntegrityLevel, ref label, Marshal.SizeOf(typeof(SID_AND_ATTRIBUTES)) + GetLengthSid(label.Sid)), "SetTokenInformation");
+ if (!AdministratorsIsDenyOnly(filtered)) throw new InvalidOperationException("Administrators is not deny-only in the filtered token.");
+ var names = new List();
+ foreach (DictionaryEntry entry in environment) names.Add(entry.Key + "=" + entry.Value);
+ names.Sort(StringComparer.OrdinalIgnoreCase);
+ var startup = new STARTUPINFO { Size = Marshal.SizeOf(typeof(STARTUPINFO)) };
+ PROCESS_INFORMATION information;
+ Check(CreateProcessAsUserW(filtered, application, new StringBuilder("\"" + application + "\""), IntPtr.Zero, IntPtr.Zero, false,
+ CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW, string.Join("\0", names) + "\0", directory, ref startup, out information), "CreateProcessAsUser");
+ CloseHandle(information.Thread);
+ CloseHandle(information.Process);
+ return (int)information.ProcessId;
+ } finally { CloseHandle(filtered); }
+ }
+}
diff --git a/scripts/native-capture/Invoke-ViewSaveCapture.ps1 b/scripts/native-capture/Invoke-ViewSaveCapture.ps1
new file mode 100644
index 0000000..2938442
--- /dev/null
+++ b/scripts/native-capture/Invoke-ViewSaveCapture.ps1
@@ -0,0 +1,239 @@
+# Issue 83 on the hosted Windows desktop: the release exe, a real Administrators-owned config.toml and a token with
+# Administrators deny-only, which is what Franz's non-elevated app has. Each real click must save its view with no banner.
+# Every view is photographed even when an earlier one fails, so a failed run shows the whole problem.
+param(
+ [string]$Exe = 'target/release/project-dashboard.exe',
+ [string]$OutDir = 'view-save-captures',
+ [Parameter(Mandatory)][string]$ExpectedHead
+)
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+New-Item -ItemType Directory -Force $OutDir | Out-Null
+$OutDir = (Resolve-Path $OutDir).Path
+$root = Join-Path ([IO.Path]::GetTempPath()) "dashboard-view-save-$([Guid]::NewGuid().ToString('N'))"
+$app = $null; $fixture = $null; $ocr = $null
+$failures = [Collections.Generic.List[string]]::new()
+$readId = 0
+. (Join-Path $PSScriptRoot 'OcrPipe.ps1')
+$manifest = [ordered]@{
+ head = (git rev-parse HEAD); expectedHead = $ExpectedHead
+ runner = 'GitHub-hosted windows-latest'; run = "https://github.com/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID"
+ syntheticLoopbackFixture = $true; success = $false; views = @(); failures = @()
+ startedAt = [DateTimeOffset]::UtcNow.ToString('o')
+}
+
+$essentials = @('PATH','SystemRoot','WINDIR','USERPROFILE','APPDATA','LOCALAPPDATA','TEMP','TMP','COMSPEC','PATHEXT','ProgramFiles','ProgramFiles(x86)','ProgramData','SystemDrive')
+function Get-Essentials {
+ $values = @{}
+ foreach ($name in $essentials) {
+ $value = [Environment]::GetEnvironmentVariable($name)
+ if ($value) { $values[$name] = $value }
+ }
+ $values
+}
+function New-HelperStart([string]$File, [string[]]$Arguments) {
+ $start = [Diagnostics.ProcessStartInfo]::new($File)
+ $start.UseShellExecute = $false
+ $start.CreateNoWindow = $true
+ $start.Environment.Clear()
+ $values = Get-Essentials
+ foreach ($name in $values.Keys) { $start.Environment[$name] = $values[$name] }
+ foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) }
+ $start
+}
+function Stop-Helper($Process) {
+ if ($null -ne $Process) {
+ if (-not $Process.HasExited) { $Process.Kill($true); [void]$Process.WaitForExit(5000) }
+ if (-not $Process.HasExited) { throw "Owned process $($Process.Id) did not stop." }
+ $Process.Dispose()
+ }
+}
+function Get-WindowBounds {
+ $app.Refresh()
+ if ($app.HasExited) { throw 'Dashboard exited during the attempt.' }
+ if ($app.MainWindowTitle -cne 'Project Dashboard') { throw 'Owned window title changed.' }
+ if ([NativeDesktop]::InputDesktop() -ne 'Default') { throw 'Locked/noninteractive input desktop.' }
+ [void][NativeDesktop]::SetForegroundWindow($app.MainWindowHandle)
+ $deadline = [DateTime]::UtcNow.AddSeconds(2)
+ while ([NativeDesktop]::GetForegroundWindow() -ne $app.MainWindowHandle -and [DateTime]::UtcNow -lt $deadline) {
+ Start-Sleep -Milliseconds 50
+ }
+ if ([NativeDesktop]::GetForegroundWindow() -ne $app.MainWindowHandle) { throw 'Dashboard is not foreground.' }
+ $rect = [NativeDesktop+RECT]::new()
+ if (-not [NativeDesktop]::GetWindowRect($app.MainWindowHandle,[ref]$rect)) { throw 'GetWindowRect failed.' }
+ [Drawing.Rectangle]::FromLTRB($rect.Left,$rect.Top,$rect.Right,$rect.Bottom)
+}
+function Read-Image([string]$Path) {
+ $script:readId++
+ # The first read also starts the OCR engine, which takes longer.
+ (Invoke-OcrRead $ocr $Path $readId $(if ($readId -eq 1) { 120000 } else { 60000 })).result
+}
+function Save-Window([string]$Name) {
+ $bounds = Get-WindowBounds
+ $path = Join-Path $OutDir "$Name.png"
+ $bitmap = [Drawing.Bitmap]::new($bounds.Width,$bounds.Height)
+ $graphics = [Drawing.Graphics]::FromImage($bitmap)
+ try {
+ # Only GetWindowRect of this owned foreground process, never the desktop.
+ $graphics.CopyFromScreen($bounds.Location,[Drawing.Point]::Empty,$bounds.Size)
+ $bitmap.Save($path,[Drawing.Imaging.ImageFormat]::Png)
+ } finally { $graphics.Dispose(); $bitmap.Dispose() }
+ @{bounds=$bounds; path=$path; file="$Name.png"; text=(Read-Image $path)}
+}
+function Find-Tab($Shot, [string]$Name) {
+ # The title bar also says "Project", so anchor on Backlog, the one tab whose word is unique near the top.
+ $anchors = @($Shot.text.words | Where-Object { $_.text -ceq 'Backlog' } | Sort-Object y)
+ if ($anchors.Count -lt 1) { throw "Cannot find the Backlog tab. OCR: $($Shot.text.text)" }
+ $anchor = $anchors[0]
+ if ($Name -ceq 'Backlog') { return $anchor }
+ $row = @($Shot.text.words | Where-Object { $_.text -ceq $Name -and [Math]::Abs($_.y-$anchor.y) -lt $anchor.height })
+ if ($row.Count -ne 1) { throw "Cannot find one $Name tab beside Backlog. OCR: $($Shot.text.text)" }
+ $row[0]
+}
+function Read-SavedView {
+ $text = [IO.File]::ReadAllText($configPath)
+ if ($text -notmatch "(?m)^view\s*=\s*['""](\w+)['""]") { throw "config.toml has no view line: $text" }
+ $Matches[1]
+}
+function Test-Saved([string]$View) {
+ $deadline = [DateTime]::UtcNow.AddSeconds(5)
+ while ((Read-SavedView) -cne $View -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 100 }
+ (Read-SavedView) -ceq $View
+}
+function Assert-View([string]$Step, [string]$View, [string]$Marker, $Shot) {
+ $visible = [regex]::Replace($Shot.text.text,'\s+',' ')
+ $problem = @()
+ if ($visible -match "can.?t write|security ID|os error|1307") { $problem += 'the save-error banner is showing' }
+ if (-not (Test-Saved $View)) { $problem += "config.toml still says view = '$(Read-SavedView)'" }
+ if ($visible -notmatch $Marker) { $problem += "the $View view's content ($Marker) isn't visible" }
+ $security = Get-Acl -LiteralPath $configPath
+ $manifest.views += @{
+ step=$Step; view=$View; file=$Shot.file; sha256=(Get-FileHash $Shot.path -Algorithm SHA256).Hash.ToLowerInvariant()
+ savedView=(Read-SavedView); owner=$security.Owner; visibleText=$Shot.text.text; problems=$problem
+ }
+ foreach ($item in $problem) { $failures.Add("${Step}: $item") }
+ Write-Host "$Step ($View): owner $($security.Owner), $(if ($problem) { $problem -join '; ' } else { 'saved, no banner' })"
+}
+function Switch-View($From, [string]$Tab, [string]$Step, [string]$View, [string]$Marker, [int]$SettleSeconds) {
+ $word = Find-Tab $From $Tab
+ $bounds = Get-WindowBounds
+ $x = $bounds.X+[int]($word.x+$word.width/2); $y = $bounds.Y+[int]($word.y+$word.height/2)
+ if (-not [NativeDesktop]::OwnsPoint($app.MainWindowHandle,$x,$y)) { throw "The $Tab tab isn't inside the Dashboard window." }
+ [NativeDesktop]::Click($x,$y)
+ Start-Sleep -Seconds $SettleSeconds
+ $shot = Save-Window $Step
+ Assert-View $Step $View $Marker $shot
+ $shot
+}
+
+try {
+ if (-not $IsWindows) { throw 'This capture requires Windows.' }
+ if ($manifest.head -ne $ExpectedHead) { throw 'Checkout is not the PR head.' }
+ $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
+ if (-not [Security.Principal.WindowsPrincipal]::new($identity).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ throw 'Creating an Administrators-owned config needs an elevated runner.'
+ }
+ $Exe = (Resolve-Path $Exe).Path
+ $manifest.exeSha256 = (Get-FileHash $Exe -Algorithm SHA256).Hash.ToLowerInvariant()
+ Add-Type -Path (Join-Path $PSScriptRoot 'NativeDesktop.cs')
+ Add-Type -Path (Join-Path $PSScriptRoot 'FilteredToken.cs')
+ Add-Type -AssemblyName System.Windows.Forms,System.Drawing
+ [void][NativeDesktop]::SetThreadDpiAwarenessContext([IntPtr](-4))
+ $manifest.inputDesktop = [NativeDesktop]::InputDesktop()
+ if (-not [Environment]::UserInteractive -or $manifest.inputDesktop -ne 'Default') { throw 'Locked/noninteractive runner desktop.' }
+ $area = [Windows.Forms.Screen]::PrimaryScreen.WorkingArea
+
+ New-Item -ItemType Directory -Path (Join-Path $root 'checkout/.git') -Force | Out-Null
+ [IO.File]::WriteAllText((Join-Path $root 'checkout/.git/config'),"[remote `"origin`"]`nurl = https://github.com/fixture-org/synthetic-dashboard.git`n")
+ [IO.File]::WriteAllText((Join-Path $root 'read-token'),'synthetic-issue31-token')
+ [IO.File]::WriteAllText((Join-Path $root 'state.txt'),'normal')
+
+ $ocrStart = New-HelperStart (Join-Path $env:SystemRoot 'System32/WindowsPowerShell/v1.0/powershell.exe') @('-NoLogo','-NoProfile','-NonInteractive','-File',(Join-Path $PSScriptRoot 'Read-WindowText.ps1'))
+ $ocrStart.RedirectStandardInput=$true; $ocrStart.RedirectStandardOutput=$true; $ocrStart.RedirectStandardError=$true
+ $ocrStart.StandardInputEncoding=[Text.UTF8Encoding]::new($false)
+ $ocrStart.StandardOutputEncoding=[Text.UTF8Encoding]::new($false)
+ $ocr = [Diagnostics.Process]::Start($ocrStart)
+ $null = $ocr.StandardError.ReadToEndAsync()
+
+ $fixture = [Diagnostics.Process]::Start((New-HelperStart (Join-Path $PSHOME 'pwsh.exe') @('-NoProfile','-File',(Join-Path $PSScriptRoot 'Start-LoopbackPmd.ps1'),'-Root',$root)))
+ $deadline = [DateTime]::UtcNow.AddSeconds(15)
+ while (-not (Test-Path (Join-Path $root 'port.txt'))) {
+ if ($fixture.HasExited -or [DateTime]::UtcNow -ge $deadline) { throw 'Synthetic loopback fixture did not start within 15 seconds.' }
+ Start-Sleep -Milliseconds 100
+ }
+ $port = (Get-Content (Join-Path $root 'port.txt') -Raw).Trim()
+
+ # The reported shape: Administrators owns the file, the user has full control through inherited entries.
+ $configPath = Join-Path $root 'config.toml'
+ $configText = @"
+# keep this comment
+view = "project"
+repositories = ['$(Join-Path $root 'checkout')']
+refresh_minutes = 0
+parallelism = 1
+pmd_url = 'http://127.0.0.1:$port'
+pmd_token_path = 'read-token'
+t3_home = '$root'
+"@
+ [IO.File]::WriteAllText($configPath,$configText)
+ $administrators = [Security.Principal.SecurityIdentifier]::new([Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid,$null)
+ $security = Get-Acl -LiteralPath $configPath
+ $security.SetOwner($administrators)
+ Set-Acl -LiteralPath $configPath -AclObject $security
+ $before = Get-Acl -LiteralPath $configPath
+ if ($before.GetOwner([Security.Principal.SecurityIdentifier]) -ne $administrators) { throw "Fixture owner is $($before.Owner), not Administrators." }
+ $manifest.ownerBefore = $before.Owner
+ $manifest.accessBefore = $before.AccessToString
+
+ $environment = Get-Essentials
+ $environment['PROJECT_DASHBOARD_CONFIG_DIR'] = $root
+ $app = [Diagnostics.Process]::GetProcessById([FilteredToken]::Start($Exe,$root,$environment))
+ $manifest.dashboardProcessId = $app.Id
+ $deadline = [DateTime]::UtcNow.AddSeconds(60)
+ do {
+ Start-Sleep -Milliseconds 200; $app.Refresh()
+ if ($app.HasExited) { throw 'Dashboard exited before showing a window.' }
+ } until ($app.MainWindowTitle -eq 'Project Dashboard' -or [DateTime]::UtcNow -ge $deadline)
+ if ($app.MainWindowTitle -ne 'Project Dashboard') { throw 'No Dashboard window within 60 seconds.' }
+ [void][NativeDesktop]::ShowWindow($app.MainWindowHandle,9)
+ if (-not [NativeDesktop]::MoveWindow($app.MainWindowHandle,$area.X+8,$area.Y+8,[Math]::Min(1400,$area.Width-16),[Math]::Min(1000,$area.Height-16),$true)) { throw 'MoveWindow failed.' }
+ $deadline = [DateTime]::UtcNow.AddSeconds(30)
+ while (-not (Test-Path (Join-Path $root 'completed-normal')) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 100 }
+ if (-not (Test-Path (Join-Path $root 'completed-normal'))) { throw 'No production fetch completed within 30 seconds.' }
+ Start-Sleep -Seconds 3
+
+ $projectMarker = 'fixture.?[o0]rg'
+ $shot = Save-Window '1-project-at-start'
+ Assert-View '1-project-at-start' 'project' $projectMarker $shot
+ $shot = Switch-View $shot 'Backlog' '2-backlog' 'backlog' 'SYNTHETIC FIXTURE' 2
+ # Fleet fetches on selection, and needs time to show its rows.
+ $shot = Switch-View $shot 'Fleet' '3-fleet' 'fleet' 'synthetic-' 5
+ $shot = Switch-View $shot 'Project' '4-project' 'project' $projectMarker 2
+
+ $after = Get-Acl -LiteralPath $configPath
+ $manifest.ownerAfter = $after.Owner
+ $manifest.accessAfter = $after.AccessToString
+ $manifest.groupUnchanged = $after.Group -eq $before.Group
+ if ($after.GetOwner([Security.Principal.SecurityIdentifier]) -eq $administrators) { $failures.Add('config.toml is still owned by Administrators, so no save replaced it.') }
+ if ($after.AccessToString -cne $before.AccessToString) { $failures.Add('The save changed who can access config.toml.') }
+ if ($after.Group -ne $before.Group) { $failures.Add('The save changed the group on config.toml.') }
+ # One round trip back to the starting view must leave every byte as it was. Saves write the value in double quotes.
+ $saved = [IO.File]::ReadAllText($configPath)
+ if ($saved -cne $configText) { $failures.Add("config.toml changed outside its view value: $saved") }
+ if ($failures.Count) { throw "Views failed: $($failures -join ' | ')" }
+ $manifest.success = $true
+} catch {
+ $manifest.failure = $_.Exception.Message
+ throw
+} finally {
+ $manifest.failures = @($failures)
+ $cleanupErrors = @()
+ foreach ($owned in @($app,$fixture,$ocr)) {
+ try { Stop-Helper $owned } catch { $cleanupErrors += $_.Exception.Message }
+ }
+ try { if (Test-Path $root) { Remove-Item $root -Recurse -Force } } catch { $cleanupErrors += $_.Exception.Message }
+ if ($cleanupErrors.Count) { $manifest.success=$false; $manifest.cleanupErrors=$cleanupErrors }
+ $manifest.finishedAt = [DateTimeOffset]::UtcNow.ToString('o')
+ $manifest | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $OutDir 'manifest.json')
+ if ($cleanupErrors.Count) { throw "Cleanup failed: $($cleanupErrors -join '; ')" }
+}
diff --git a/scripts/native-capture/Start-LoopbackPmd.ps1 b/scripts/native-capture/Start-LoopbackPmd.ps1
index 9b24147..3ec153e 100644
--- a/scripts/native-capture/Start-LoopbackPmd.ps1
+++ b/scripts/native-capture/Start-LoopbackPmd.ps1
@@ -57,6 +57,20 @@ try {
if ($uri.Query -match 'cursor=') { $body=@{items=@();nextCursor=$null} }
else { $body=@{items=@($item);nextCursor='synthetic-next-page'} }
}
+ # Backlog and Fleet, so the view-save capture shows them populated.
+ '/v1/dispatch/plan' { $body=@{recommendations=@(@{workItem=@{id=$item.id};reason='SYNTHETIC FIXTURE recommendation'});skipped=@();projects=@()} }
+ '/v1/portfolio' { $body=@{} }
+ '/v1/assignments' { $body=@{assignments=@();truncated=$false} }
+ '/v1/machines' { $body=@{machines=@(@{name='synthetic-windows';os='windows';maxSessions=6},@{name='synthetic-linux';os='linux';maxSessions=3})} }
+ '/v1/capacity' { $body=@{machines=@(@{name='synthetic-windows';used=0;chiefStarted=0;free=6},@{name='synthetic-linux';used=0;chiefStarted=0;free=3})} }
+ '/v1/fleet/status' {
+ $at=[DateTimeOffset]::UtcNow.ToString('o')
+ $body=@{schema=1;machines=@(@{name='synthetic-windows';online=$true;probeOk=$true;checkedAt=$at;attemptedAt=$at;serverVersion='0.0.0-fixture';latestVersion='0.0.0-fixture';clientStatus='not_applicable'},@{name='synthetic-linux';online=$true;probeOk=$true;checkedAt=$at;attemptedAt=$at;serverVersion='0.0.0-fixture';latestVersion='0.0.0-fixture';clientStatus='not_applicable'})}
+ }
+ '/v1/fleet/activity' {
+ $at=[DateTimeOffset]::UtcNow.ToString('o')
+ $body=@{schema=1;observedAt=$at;machines=@(@{name='synthetic-windows';observedAt=$at;state='ok';activeCount=0;chiefActive=$false;sessions=@()},@{name='synthetic-linux';observedAt=$at;state='ok';activeCount=0;chiefActive=$false;sessions=@()})}
+ }
default { $code=404; $body=@{error=@{code='not_found';message='synthetic fixture unknown route'}} }
}
}