@hope_phenom/dsh-plugin-remote-use 0.0.0-stage → 0.2.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +207 -2
  3. package/README.zh.md +195 -0
  4. package/assets/icon.svg +47 -0
  5. package/cordis.patch.yml +41 -0
  6. package/lib/bridge/acp-bridge.js +905 -0
  7. package/lib/bridge/peer-connection.js +730 -0
  8. package/lib/client.js +2842 -0
  9. package/lib/core/acp-model-overlay.js +121 -0
  10. package/lib/core/approval-queue.js +216 -0
  11. package/lib/core/audit-log.js +322 -0
  12. package/lib/core/desktop-notify.js +173 -0
  13. package/lib/core/environment-prompt.js +76 -0
  14. package/lib/core/relay-supervisor.js +278 -0
  15. package/lib/core/review-engine.js +679 -0
  16. package/lib/core/sandbox-diagnosis.js +258 -0
  17. package/lib/core/sandbox-probe.js +310 -0
  18. package/lib/core/self-acp-spec.js +117 -0
  19. package/lib/core/selfcheck-runner.js +150 -0
  20. package/lib/core/settings-store.js +226 -0
  21. package/lib/core/shot-gc.js +334 -0
  22. package/lib/core/task-cwd.js +50 -0
  23. package/lib/core/task-memory.js +163 -0
  24. package/lib/core/task-registry.js +238 -0
  25. package/lib/core/toolcall-cache.js +206 -0
  26. package/lib/core/transfer-queue.js +58 -0
  27. package/lib/core/transfer.js +1144 -0
  28. package/lib/core/worker-arming.js +334 -0
  29. package/lib/index.js +2207 -0
  30. package/lib/provider/out-of-process-helpers.js +76 -0
  31. package/lib/provider/remote-provider.js +261 -0
  32. package/lib/tools/mediator-shim.mjs +180 -0
  33. package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
  34. package/lib/tools/remote-tools.js +680 -0
  35. package/lib/tools/selfcheck-fix-prompt.md +206 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +72 -4
  39. package/slave/acp-profile-approval.patch.yml +66 -0
  40. package/slave/file-transfer.mjs +552 -0
  41. package/slave/integrity-probe.ps1 +99 -0
  42. package/slave/make-launch-spec.mjs +184 -0
  43. package/slave/net-address.mjs +138 -0
  44. package/slave/node_modules/.package-lock.json +26 -0
  45. package/slave/node_modules/ws/LICENSE +20 -0
  46. package/slave/node_modules/ws/README.md +548 -0
  47. package/slave/node_modules/ws/browser.js +8 -0
  48. package/slave/node_modules/ws/index.js +22 -0
  49. package/slave/node_modules/ws/lib/buffer-util.js +131 -0
  50. package/slave/node_modules/ws/lib/constants.js +19 -0
  51. package/slave/node_modules/ws/lib/event-target.js +292 -0
  52. package/slave/node_modules/ws/lib/extension.js +203 -0
  53. package/slave/node_modules/ws/lib/limiter.js +55 -0
  54. package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
  55. package/slave/node_modules/ws/lib/receiver.js +743 -0
  56. package/slave/node_modules/ws/lib/sender.js +607 -0
  57. package/slave/node_modules/ws/lib/stream.js +161 -0
  58. package/slave/node_modules/ws/lib/subprotocol.js +62 -0
  59. package/slave/node_modules/ws/lib/validation.js +152 -0
  60. package/slave/node_modules/ws/lib/websocket-server.js +562 -0
  61. package/slave/node_modules/ws/lib/websocket.js +1421 -0
  62. package/slave/node_modules/ws/package.json +74 -0
  63. package/slave/node_modules/ws/wrapper.mjs +21 -0
  64. package/slave/pairing.mjs +175 -0
  65. package/slave/permission-pairing.mjs +240 -0
  66. package/slave/prune-shots.mjs +170 -0
  67. package/slave/relay.mjs +1351 -0
  68. package/slave/repair-workspace-integrity.ps1 +75 -0
  69. package/slave/screenshot.ps1 +114 -0
  70. package/slave/selfcheck.ps1 +350 -0
  71. package/slave/stage-app.ps1 +116 -0
  72. package/slave/workspace-integrity.mjs +157 -0
@@ -0,0 +1,75 @@
1
+ # repair-workspace-integrity.ps1 -- reset a workspace's stale Low mandatory label to Medium.
2
+ #
3
+ # WHY: DSH's workspace-write sandbox labels the workspace Low. The label survives the session and is
4
+ # inherited by files copied into the tree, and Windows launches a Low-labelled image at Low
5
+ # integrity. Under danger-full-access that leftover is what makes a GUI app unable to write
6
+ # %LOCALAPPDATA% even though the agent shell is High (PITFALLS F41).
7
+ #
8
+ # The relay repairs the tree automatically on start when it runs at danger-full-access with an
9
+ # explicit --task-dir. This script is the manual equivalent, and the repair path for a hand-managed
10
+ # relay whose task directory is not passed as a flag.
11
+ #
12
+ # ASCII-only, param() first: Windows PowerShell 5.1 reads BOM-less files as ANSI.
13
+ param(
14
+ [Parameter(Mandatory = $true)][string]$Path,
15
+ [switch]$WhatIf
16
+ )
17
+
18
+ $ErrorActionPreference = 'Stop'
19
+
20
+ if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
21
+ Write-Host "REFUSING: not a directory: $Path" -ForegroundColor Red
22
+ exit 2
23
+ }
24
+ $full = (Resolve-Path -LiteralPath $Path).Path
25
+ if ($full -match '^[A-Za-z]:\\?$') {
26
+ Write-Host "REFUSING: $full is a drive root, not a workspace." -ForegroundColor Red
27
+ exit 2
28
+ }
29
+
30
+ $protected = @($env:SystemRoot, $env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramData) |
31
+ Where-Object { $_ -and $_.Trim() -ne '' } |
32
+ ForEach-Object { $_.TrimEnd('\').ToLowerInvariant() }
33
+ $target = $full.TrimEnd('\').ToLowerInvariant()
34
+ foreach ($root in $protected) {
35
+ if ($target -eq $root -or $target.StartsWith($root + '\')) {
36
+ Write-Host "REFUSING: $full is a protected system location." -ForegroundColor Red
37
+ exit 2
38
+ }
39
+ }
40
+
41
+ $command = "icacls `"$full`" /setintegritylevel (OI)(CI)Medium /T /C /Q"
42
+ Write-Host "workspace : $full"
43
+ Write-Host "command : $command"
44
+ if ($WhatIf) {
45
+ Write-Host 'WHATIF: nothing changed.'
46
+ exit 0
47
+ }
48
+
49
+ # Show the label lines this tree carries BEFORE the reset, so the operator can see whether there was
50
+ # anything to fix. The label name is localized on some Windows installs, so the decision is still the
51
+ # unconditional idempotent set below rather than a text match.
52
+ Write-Host '--- labels before (matching lines only) ---'
53
+ $before = @(& icacls $full 2>&1)
54
+ @($before | Where-Object { $_ -match 'Mandatory|Label|S-1-16' }) | ForEach-Object { Write-Host $_ }
55
+
56
+ & icacls $full /setintegritylevel '(OI)(CI)Medium' /T /C /Q | Out-Null
57
+ if ($LASTEXITCODE -ne 0) {
58
+ Write-Host "FAILED: icacls exited $LASTEXITCODE" -ForegroundColor Red
59
+ exit 1
60
+ }
61
+
62
+ Write-Host '--- labels after (top level) ---'
63
+ $after = @(& icacls $full 2>&1)
64
+ @($after | Where-Object { $_ -match 'Mandatory|Label|S-1-16' }) | ForEach-Object { Write-Host $_ }
65
+
66
+ # Re-measure with the same probe the self-check uses. This is the difference between "the command
67
+ # returned 0" and "a process launched from here is no longer Low".
68
+ $probe = Join-Path $PSScriptRoot 'integrity-probe.ps1'
69
+ if (Test-Path -LiteralPath $probe) {
70
+ Write-Host '--- probe ---'
71
+ & $probe -Workspace $full
72
+ }
73
+
74
+ Write-Host 'RESULT: OK -- the workspace tree was relabeled to Medium.' -ForegroundColor Green
75
+ exit 0
@@ -0,0 +1,114 @@
1
+ # screenshot.ps1 -- capture the worker's desktop.
2
+ #
3
+ # MUST RUN IN THE INTERACTIVE SESSION. In session 0 (SSH/services) there is no desktop,
4
+ # `CopyFromScreen` throws or returns black, and `VirtualScreen` reports a stub size -- the
5
+ # same session-0 false negative documented for the display checks.
6
+ #
7
+ # ASCII-ONLY: Windows PowerShell 5.1 reads a BOM-less file as ANSI, so a single non-ASCII
8
+ # byte corrupts parsing (see the note in relay-ctl.ps1).
9
+ #
10
+ # The geometry report is written NEXT TO THIS SCRIPT ($PSScriptRoot), because the relay
11
+ # reads it from the script's own directory. Writing it to a fixed absolute path is exactly
12
+ # the mismatch that once made the self-check look like it produced no report, and it would
13
+ # silently zero the screenshot's geometry metadata.
14
+ #
15
+ # Usage: screenshot.ps1 -Out C:\path\shot.png
16
+ param([Parameter(Mandatory = $true)][string]$Out)
17
+
18
+ $ErrorActionPreference = 'Stop'
19
+ if ([string]::IsNullOrEmpty($PSScriptRoot)) { $PSScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path }
20
+ $report = Join-Path $PSScriptRoot 'screenshot-report.txt'
21
+ # Explicit UTF-8 rather than Tee-Object: in Windows PowerShell 5.1 Tee-Object writes
22
+ # UTF-16LE, and a UTF-8 reader then sees NUL bytes between characters so every prefix match
23
+ # fails -- the relay silently reported 0x0 geometry because of exactly that. The reader in
24
+ # relay.mjs also tolerates UTF-16 now, but the writer must not depend on it.
25
+ function W($m) {
26
+ Write-Output $m
27
+ Add-Content -Path $report -Value $m -Encoding UTF8
28
+ }
29
+
30
+ Remove-Item $report -Force -ErrorAction SilentlyContinue
31
+ Remove-Item $Out -Force -ErrorAction SilentlyContinue
32
+
33
+ W "session=$((Get-Process -Id $PID).SessionId) interactive=$([Environment]::UserInteractive) user=$env:USERNAME"
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Hide our own console BEFORE capturing.
37
+ #
38
+ # This is not redundant with the launcher's `-WindowStyle Hidden` / `windowsHide: true`:
39
+ # in practice a console window still reached the desktop and was photographed into the
40
+ # frame (an empty one, because this script's output goes to a pipe). Hiding the window we
41
+ # actually own is deterministic and independent of how we were started.
42
+ #
43
+ # It also removes a live focus hazard: a visible console can take foreground, which would
44
+ # corrupt the very GUI measurements a screenshot exists to support.
45
+ # ---------------------------------------------------------------------------
46
+ Add-Type @'
47
+ using System;
48
+ using System.Runtime.InteropServices;
49
+ public static class ConsoleHider {
50
+ [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
51
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
52
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
53
+ public static bool Hide() {
54
+ IntPtr h = GetConsoleWindow();
55
+ if (h == IntPtr.Zero) return false; // launched with CREATE_NO_WINDOW
56
+ ShowWindow(h, 0); // SW_HIDE
57
+ return !IsWindowVisible(h);
58
+ }
59
+ }
60
+ '@ -ErrorAction SilentlyContinue
61
+ $hidden = $false
62
+ try { $hidden = [ConsoleHider]::Hide() } catch { }
63
+ W "console hidden=$hidden"
64
+
65
+ Add-Type -AssemblyName System.Windows.Forms
66
+ Add-Type -AssemblyName System.Drawing
67
+
68
+ # Report the layout first: a useful screenshot is only meaningful next to the geometry it
69
+ # was taken from, and this is also how a wrong-session capture gets caught.
70
+ $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
71
+ W "virtualscreen x=$($vs.X) y=$($vs.Y) w=$($vs.Width) h=$($vs.Height)"
72
+ foreach ($s in [System.Windows.Forms.Screen]::AllScreens) {
73
+ W ("screen {0} primary={1} bounds={2} bpp={3}" -f $s.DeviceName, $s.Primary, $s.Bounds, $s.BitsPerPixel)
74
+ }
75
+
76
+ if ($vs.Width -le 0 -or $vs.Height -le 0) {
77
+ W "FAIL: virtual screen is empty -- almost certainly session 0"
78
+ W "RESULT: FAILED"
79
+ exit 1
80
+ }
81
+
82
+ $bmp = New-Object System.Drawing.Bitmap($vs.Width, $vs.Height)
83
+ $gfx = [System.Drawing.Graphics]::FromImage($bmp)
84
+ try {
85
+ $gfx.CopyFromScreen($vs.Left, $vs.Top, 0, 0, $bmp.Size)
86
+ } finally {
87
+ $gfx.Dispose()
88
+ }
89
+
90
+ # PNG, not JPEG: the point is often to read UI text back.
91
+ $bmp.Save($Out, [System.Drawing.Imaging.ImageFormat]::Png)
92
+ $bmp.Dispose()
93
+
94
+ if (-not (Test-Path $Out)) { W "FAIL: no file written"; W "RESULT: FAILED"; exit 1 }
95
+ $len = (Get-Item $Out).Length
96
+ W "wrote $Out ($len bytes)"
97
+
98
+ # A pure-black frame is the classic session-0 / no-desktop symptom, so check rather than
99
+ # hand back something that looks like a successful capture.
100
+ $probe = New-Object System.Drawing.Bitmap($Out)
101
+ try {
102
+ $distinct = @{}
103
+ for ($x = 0; $x -lt $probe.Width; $x += [Math]::Max(1, [int]($probe.Width / 40))) {
104
+ for ($y = 0; $y -lt $probe.Height; $y += [Math]::Max(1, [int]($probe.Height / 30))) {
105
+ $distinct[$probe.GetPixel($x, $y).ToArgb()] = $true
106
+ }
107
+ }
108
+ W "distinct sampled colors: $($distinct.Count)"
109
+ if ($distinct.Count -le 1) { W "WARN: the frame is a single flat color -- capture may be blank" }
110
+ } finally {
111
+ $probe.Dispose()
112
+ }
113
+
114
+ W "RESULT: OK"
@@ -0,0 +1,350 @@
1
+ # slave-selfcheck.ps1 -- DESIGN.md section 5.10 self-check.
2
+ # ASCII-ONLY ON PURPOSE: Windows PowerShell 5.1 reads BOM-less files as ANSI, and a
3
+ # non-ASCII byte sequence inside a string literal breaks parsing.
4
+ # Dual mode: SYSTEM (boot) checks the system layer; interactive (logon) adds S7/S8.
5
+ #
6
+ # Output goes NEXT TO THIS SCRIPT ($PSScriptRoot), not to a fixed absolute path: the
7
+ # relay reads the JSON from the script's own directory, so keeping them colocated makes
8
+ # the pair relocatable and removes a silent mismatch when either is moved.
9
+ #
10
+ # `param()` must be the FIRST statement after the comments: PowerShell rejects it after any
11
+ # assignment, and PS 5.1 reports that as a confusing "invalid assignment operator" further down.
12
+ param(
13
+ # The GUI application under test, for S18. Empty means "this worker does not verify a specific
14
+ # app", which is reported as unconfigured rather than missing.
15
+ [string]$App = '',
16
+ # The task directory, for S20. The relay passes the directory it actually hands the agent, so the
17
+ # integrity probe measures the tree remote work really runs in. Empty means no probe.
18
+ [string]$Workspace = ''
19
+ )
20
+ $ErrorActionPreference = 'Continue'
21
+ if ([string]::IsNullOrEmpty($PSScriptRoot)) { $PSScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path }
22
+ $outFile = Join-Path $PSScriptRoot 'selfcheck.json'
23
+ $txtFile = Join-Path $PSScriptRoot 'selfcheck.txt'
24
+
25
+ Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
26
+ Add-Type @'
27
+ using System;
28
+ using System.Text;
29
+ using System.Runtime.InteropServices;
30
+ public class SC {
31
+ [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
32
+ public struct DD {
33
+ public int cb;
34
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst=32)] public string DeviceName;
35
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceString;
36
+ public int StateFlags;
37
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceID;
38
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceKey;
39
+ }
40
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)]
41
+ public static extern bool EnumDisplayDevices(string dev, uint n, ref DD d, uint flags);
42
+ [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
43
+ public struct DEVMODE {
44
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst=32)] public string dmDeviceName;
45
+ public short dmSpecVersion, dmDriverVersion, dmSize, dmDriverExtra;
46
+ public int dmFields, dmPositionX, dmPositionY, dmDisplayOrientation, dmDisplayFixedOutput;
47
+ public short dmColor, dmDuplex, dmYResolution, dmTTOption, dmCollate;
48
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst=32)] public string dmFormName;
49
+ public short dmLogPixels;
50
+ public int dmBitsPerPel, dmPelsWidth, dmPelsHeight, dmDisplayFlags, dmDisplayFrequency;
51
+ public int dmICMMethod, dmICMIntent, dmMediaType, dmDitherType, dmReserved1, dmReserved2, dmPanningWidth, dmPanningHeight;
52
+ }
53
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern bool EnumDisplaySettings(string d,int m,ref DEVMODE dm);
54
+ [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X,Y; }
55
+ [DllImport("user32.dll")] public static extern bool SetCursorPos(int x,int y);
56
+ [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p);
57
+ [DllImport("user32.dll")] public static extern IntPtr OpenInputDesktop(uint f,bool i,uint a);
58
+ [DllImport("user32.dll")] public static extern bool CloseDesktop(IntPtr h);
59
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern bool GetUserObjectInformation(IntPtr h,int i,StringBuilder p,int n,out int need);
60
+ [DllImport("kernel32.dll")] public static extern uint WTSGetActiveConsoleSessionId();
61
+
62
+ public static string[] Adapters() {
63
+ var l = new System.Collections.Generic.List<string>();
64
+ var d = new DD(); d.cb = Marshal.SizeOf(typeof(DD)); uint i = 0;
65
+ while (EnumDisplayDevices(null, i, ref d, 0)) {
66
+ var mons = new System.Collections.Generic.List<string>();
67
+ var m = new DD(); m.cb = Marshal.SizeOf(typeof(DD)); uint j = 0;
68
+ while (EnumDisplayDevices(d.DeviceName, j, ref m, 0)) {
69
+ mons.Add(m.DeviceString + " flags=" + m.StateFlags);
70
+ m = new DD(); m.cb = Marshal.SizeOf(typeof(DD)); j++;
71
+ }
72
+ l.Add(d.DeviceName + " | " + d.DeviceString + " | flags=" + d.StateFlags + " | mons=" + mons.Count + " {" + string.Join(", ", mons) + "}");
73
+ d = new DD(); d.cb = Marshal.SizeOf(typeof(DD)); i++;
74
+ }
75
+ return l.ToArray();
76
+ }
77
+ public static string InputDesktop() {
78
+ IntPtr h = OpenInputDesktop(0, false, 0x0001);
79
+ if (h == IntPtr.Zero) return "<err " + Marshal.GetLastWin32Error() + ">";
80
+ try { var sb = new StringBuilder(256); int n; return GetUserObjectInformation(h, 2, sb, 512, out n) ? sb.ToString() : "?"; }
81
+ finally { CloseDesktop(h); }
82
+ }
83
+ public static string CursorTest(int x, int y) {
84
+ SetCursorPos(x - 3, y - 3); bool ok = SetCursorPos(x, y);
85
+ var p = new POINT(); GetCursorPos(out p);
86
+ return (p.X == x && p.Y == y) ? "ok" : ("FAIL set=" + ok + " got=" + p.X + "," + p.Y);
87
+ }
88
+ public static string ConsoleId() { return WTSGetActiveConsoleSessionId().ToString(); }
89
+ }
90
+ '@ -ErrorAction SilentlyContinue
91
+
92
+ $checks = New-Object System.Collections.ArrayList
93
+ function Add-Check($id, $name, $sev, $status, $expected, $actual, $fix) {
94
+ [void]$checks.Add([ordered]@{
95
+ id = $id; name = $name; severity = $sev; status = $status
96
+ expected = $expected; actual = $actual; fix = $fix
97
+ })
98
+ }
99
+
100
+ $sid = (Get-Process -Id $PID).SessionId
101
+ $interactive = [Environment]::UserInteractive
102
+ $adaps = @()
103
+ try { $adaps = [SC]::Adapters() } catch {}
104
+ $screens = @()
105
+ try { $screens = @([System.Windows.Forms.Screen]::AllScreens) } catch {}
106
+ $primary = $screens | Where-Object { $_.Primary } | Select-Object -First 1
107
+ $vddAdap = @($adaps | Where-Object { $_ -match 'Virtual Display|MttVDD|VDD' })
108
+ $edids = @()
109
+ try { $edids = @(Get-CimInstance -Namespace root\wmi -ClassName WmiMonitorID -ErrorAction SilentlyContinue) } catch {}
110
+ $edidNames = @($edids | ForEach-Object { -join ($_.UserFriendlyName | Where-Object { $_ -ne 0 } | ForEach-Object { [char]$_ }) })
111
+
112
+ # S1 display device attached to the desktop
113
+ Add-Check 'S1' 'display adapter attached' 'blocking' $(if ($adaps.Count -gt 0) { 'pass' } else { 'fail' }) '>=1 adapter' ($adaps.Count.ToString() + ' adapters') 'install/enable a display adapter'
114
+ # S2 virtual display present with EDID
115
+ Add-Check 'S2' 'virtual display present (not WinDisc fallback)' 'blocking' $(if ($vddAdap.Count -gt 0 -and $edids.Count -gt 0) { 'pass' } else { 'fail' }) 'VDD + EDID' ('vdd=' + $vddAdap.Count + ' edid=[' + ($edidNames -join ',') + ']') 'install/enable Virtual Display Driver'
116
+ # S3 resolution
117
+ $res = if ($primary) { "$($primary.Bounds.Width)x$($primary.Bounds.Height)" } else { '?' }
118
+ Add-Check 'S3' 'primary resolution' 'warning' $(if ($res -eq '1920x1080') { 'pass' } else { 'warn' }) '1920x1080' $res 'put the target resolution first in vdd_settings.xml'
119
+ # S4 primary at origin -- AND the report says whether that primary is the virtual display
120
+ #
121
+ # WHY THIS IS NOT A BLOCKING "MUST BE VIRTUAL" CHECK, after it briefly was one:
122
+ #
123
+ # DESIGN words S4 as "the virtual screen is primary AND at (0,0)". Enforcing the first half made a
124
+ # real deployment unusable, and the reason is worth keeping: Windows 11 draws the LOGON UI on the
125
+ # PRIMARY display only. Make the virtual display primary and the login screen is rendered on a
126
+ # display with no physical output -- the operator's monitor stays black and they cannot log in.
127
+ #
128
+ # The requirement existed to guarantee "a coordinate space independent of the operator's monitor".
129
+ # On a KVM that keeps presenting the monitor's EDID, Windows never sees the display disappear, so
130
+ # that guarantee is already provided by the hardware -- and insisting on the virtual display only
131
+ # moves the logon screen somewhere nobody can see it.
132
+ #
133
+ # What still blocks is the half that protects a running task: a primary that is not at (0,0) means
134
+ # every absolute coordinate is silently offset. Whether the primary is the virtual display or the
135
+ # operator's own is reported in `actual`, so the situation is visible without being fatal.
136
+ $okOrigin = $false; $origin = '?'; $primaryIsVirtual = $false; $primaryAdapter = ''
137
+ if ($primary) {
138
+ $origin = "primary=$($primary.DeviceName) at $($primary.Bounds.X),$($primary.Bounds.Y)"
139
+ $okOrigin = ($primary.Bounds.X -eq 0 -and $primary.Bounds.Y -eq 0)
140
+ # `Adapters()` returns one line per adapter, each starting with its device name (`\\.\DISPLAY1`),
141
+ # which is exactly what `Screen.DeviceName` gives us -- so no extra P/Invoke is needed.
142
+ $primaryAdapter = @($adaps | Where-Object { $_ -like "$($primary.DeviceName) *" }) | Select-Object -First 1
143
+ if ($primaryAdapter) { $primaryIsVirtual = [bool]($primaryAdapter -match 'Virtual Display|MttVDD|VDD') }
144
+ }
145
+ $primaryDesc = $origin + ' virtual=' + $primaryIsVirtual
146
+ if ($primaryAdapter) { $primaryDesc = $primaryDesc + ' [' + (($primaryAdapter -split ' \| ')[1]) + ']' }
147
+ Add-Check 'S4' 'primary monitor at origin' 'blocking' $(if ($okOrigin) { 'pass' } else { 'fail' }) 'primary at (0,0)' $primaryDesc 'ChangeDisplaySettingsEx CDS_SET_PRIMARY|CDS_NORESET then commit'
148
+ # S5 DPI
149
+ $scale = 1.00
150
+ try { $g = [System.Drawing.Graphics]::FromHwnd([IntPtr]::Zero); if ($g) { $scale = [math]::Round($g.DpiX / 96.0, 2); $g.Dispose() } } catch {}
151
+ Add-Check 'S5' 'DPI scale' 'blocking' $(if ($scale -eq 1.0) { 'pass' } else { 'fail' }) '1.00' $scale.ToString() 'set display scaling to 100%'
152
+ # S6 console session
153
+ $q = (& query.exe session 2>&1 | Out-String)
154
+ $cid = [SC]::ConsoleId()
155
+ $isConsole = ($q -match ('(?m)^>?console\s+\S*\s*' + $cid + '\s'))
156
+ Add-Check 'S6' 'session bound to console' 'blocking' $(if ($isConsole) { 'pass' } else { 'fail' }) ('console id=' + $cid) ('mySession=' + $sid + ' consoleId=' + $cid) 'tscon <id> /dest:console as SYSTEM'
157
+ # S7 unlocked (interactive only)
158
+ if ($interactive) {
159
+ $dsk = [SC]::InputDesktop()
160
+ Add-Check 'S7' 'session unlocked' 'blocking' $(if ($dsk -eq 'Default') { 'pass' } else { 'fail' }) 'Default' $dsk 'unlock the session; disable auto-lock'
161
+ } else {
162
+ Add-Check 'S7' 'session unlocked' 'blocking' 'unchecked' 'Default' 'n/a in system session' 'must be checked in an interactive session'
163
+ }
164
+ # S8 live input injection (interactive only)
165
+ if ($interactive) {
166
+ $r = [SC]::CursorTest(960, 540)
167
+ Add-Check 'S8' 'SendInput / SetCursorPos live' 'blocking' $(if ($r -eq 'ok') { 'pass' } else { 'fail' }) 'cursor moves' $r 'requires S1+S6+S7 to pass'
168
+ } else {
169
+ Add-Check 'S8' 'SendInput / SetCursorPos live' 'blocking' 'unchecked' 'cursor moves' 'n/a in system session' 'must be checked in an interactive session'
170
+ }
171
+ # S9 no sleep
172
+ function PwrIdx($sub, $set) {
173
+ $o = & powercfg /q SCHEME_CURRENT $sub $set 2>&1 | Out-String
174
+ $m = [regex]::Matches($o, '0x[0-9a-fA-F]{8}')
175
+ if ($m.Count -ge 2) { return @([Convert]::ToInt64($m[$m.Count - 2].Value, 16), [Convert]::ToInt64($m[$m.Count - 1].Value, 16)) }
176
+ return @(-1, -1)
177
+ }
178
+ $standby = PwrIdx 'SUB_SLEEP' 'STANDBYIDLE'
179
+ $hib = PwrIdx 'SUB_SLEEP' 'HIBERNATEIDLE'
180
+ $noSleep = ($standby[0] -eq 0) -and ($standby[1] -eq 0) -and ($hib[0] -eq 0) -and ($hib[1] -eq 0)
181
+ Add-Check 'S9' 'sleep/hibernate disabled' 'blocking' $(if ($noSleep) { 'pass' } else { 'fail' }) 'AC=0 DC=0' ('standby=' + ($standby -join '/') + ' hibernate=' + ($hib -join '/')) 'powercfg /change standby-timeout-ac 0 (AC and DC)'
182
+ # S10 screensaver
183
+ $ss = (Get-ItemProperty 'HKCU:\Control Panel\Desktop' -Name ScreenSaveActive -ErrorAction SilentlyContinue).ScreenSaveActive
184
+ Add-Check 'S10' 'screensaver off' 'warning' $(if ("$ss" -eq '0') { 'pass' } else { 'warn' }) '0' "$ss" 'HKCU Control Panel Desktop ScreenSaveActive=0'
185
+ # S11 no auto-lock policy
186
+ $il = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name InactivityTimeoutSecs -ErrorAction SilentlyContinue).InactivityTimeoutSecs
187
+ Add-Check 'S11' 'no auto-lock policy' 'warning' $(if ($null -eq $il) { 'pass' } else { 'warn' }) 'not set' $(if ($null -eq $il) { 'not set' } else { "$il" }) 'remove InactivityTimeoutSecs policy'
188
+ # S12 QuickEdit off
189
+ $qe = (Get-ItemProperty 'HKCU:\Console' -Name QuickEdit -ErrorAction SilentlyContinue).QuickEdit
190
+ Add-Check 'S12' 'console QuickEdit off' 'blocking' $(if ("$qe" -eq '0') { 'pass' } else { 'fail' }) '0' "$qe" 'HKCU Console QuickEdit=0 and InsertMode=0'
191
+ # S14 DSH installed
192
+ #
193
+ # The path is DERIVED, not hardcoded -- but "derive it from $env:LOCALAPPDATA" is NOT enough, and
194
+ # getting that wrong once already is why this comment is long.
195
+ #
196
+ # The history: it used to be a literal path under one user's profile, correct on exactly one machine. The
197
+ # first fix derived it from the environment, which is correct for an interactive run and WRONG for
198
+ # the other mode this script has -- a boot check under a SYSTEM scheduled task, where
199
+ # $env:LOCALAPPDATA points at C:\Windows\system32\config\systemprofile and no candidate matches.
200
+ # So the naive derivation replaced a check that was right on one machine with one that is wrong on
201
+ # all of them in that mode.
202
+ #
203
+ # The candidates are therefore gathered from every source that can name the install:
204
+ # 1. a RUNNING DSH process -- whatever account we are, it says where DSH actually is
205
+ # 2. the current account's install location
206
+ # 3. Program Files
207
+ # 4. every user profile on the machine -- what a SYSTEM run cannot see about itself
208
+ $dshCandidates = New-Object System.Collections.ArrayList
209
+ $runningDsh = Get-Process -Name 'DeepSeek Harness' -ErrorAction SilentlyContinue | Select-Object -First 1
210
+ if ($runningDsh -and $runningDsh.Path) { [void]$dshCandidates.Add((Split-Path -Parent $runningDsh.Path)) }
211
+ [void]$dshCandidates.Add((Join-Path $env:LOCALAPPDATA 'Programs\DeepSeek Harness'))
212
+ [void]$dshCandidates.Add((Join-Path $env:ProgramFiles 'DeepSeek Harness'))
213
+ if (${env:ProgramFiles(x86)}) { [void]$dshCandidates.Add((Join-Path ${env:ProgramFiles(x86)} 'DeepSeek Harness')) }
214
+ [void]$dshCandidates.Add('C:\Program Files\DeepSeek Harness')
215
+ $profileRoots = @()
216
+ if ($env:SystemDrive) { $profileRoots += (Join-Path $env:SystemDrive 'Users') }
217
+ foreach ($root in $profileRoots) {
218
+ Get-ChildItem $root -Directory -ErrorAction SilentlyContinue | ForEach-Object {
219
+ [void]$dshCandidates.Add((Join-Path $_.FullName 'AppData\Local\Programs\DeepSeek Harness'))
220
+ }
221
+ }
222
+ $dshCandidates = @($dshCandidates | Where-Object { $_ -and $_ -ne '' } | Select-Object -Unique)
223
+ $ver = ''
224
+ foreach ($candidate in $dshCandidates) {
225
+ if (-not (Test-Path $candidate)) { continue }
226
+ $exe = Get-ChildItem $candidate -Filter '*.exe' -ErrorAction SilentlyContinue | Select-Object -First 1
227
+ if ($exe) { $ver = $exe.VersionInfo.FileVersion; break }
228
+ }
229
+ Add-Check 'S14' 'DSH installed' 'blocking' $(if ($ver) { 'pass' } else { 'fail' }) 'installed' $(if ($ver) { $ver } else { 'nowhere in ' + $dshCandidates.Count + ' candidate locations' }) 'install DSH and match the master version'
230
+ # S15 credentials
231
+ # Same reasoning and the same sources: a SYSTEM run must still be able to find the account whose
232
+ # DSH is signed in. The report names WHICH path it looked at, and lists them all when it finds
233
+ # none, so a genuine "signed out" is distinguishable from "looked in the wrong place".
234
+ $credCandidates = New-Object System.Collections.ArrayList
235
+ [void]$credCandidates.Add((Join-Path $env:USERPROFILE '.dsh\.credentials.yaml'))
236
+ [void]$credCandidates.Add((Join-Path $env:APPDATA 'dsh\.credentials.yaml'))
237
+ foreach ($root in $profileRoots) {
238
+ Get-ChildItem $root -Directory -ErrorAction SilentlyContinue | ForEach-Object {
239
+ [void]$credCandidates.Add((Join-Path $_.FullName '.dsh\.credentials.yaml'))
240
+ }
241
+ }
242
+ $credCandidates = @($credCandidates | Where-Object { $_ -and $_ -ne '' } | Select-Object -Unique)
243
+ $credFound = ''
244
+ foreach ($candidate in $credCandidates) {
245
+ if (Test-Path $candidate) { $credFound = $candidate; break }
246
+ }
247
+ $hasCred = $credFound -ne ''
248
+ Add-Check 'S15' 'model credentials present' 'blocking' $(if ($hasCred) { 'pass' } else { 'fail' }) 'exists' $(if ($hasCred) { $credFound } else { 'missing in ' + $credCandidates.Count + ' locations, including ' + $credCandidates[0] }) 'sign in to DSH on the worker'
249
+ # S16 disk space
250
+ $d = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'" -ErrorAction SilentlyContinue
251
+ $freeGB = if ($d) { [math]::Round($d.FreeSpace / 1GB, 1) } else { 0 }
252
+ Add-Check 'S16' 'free disk space' 'warning' $(if ($freeGB -ge 50) { 'pass' } else { 'warn' }) '>=50 GB' "$freeGB GB" 'free up disk'
253
+ # S17 GPU (informational)
254
+ $gpu = (Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | ForEach-Object { $_.Name + ' ' + $_.DriverVersion }) -join ' ; '
255
+ Add-Check 'S17' 'GPU model/driver (reported)' 'info' 'pass' 'n/a' $gpu '-'
256
+ # S18 target app present
257
+ #
258
+ # The path is CONFIGURABLE, and the check says which path it looked at.
259
+ #
260
+ # It used to be hardcoded to the reference worker's build output
261
+ # ('C:\dsh-remote\avalonia-demo\...\avalonia-demo.exe'), so on every other machine it reported
262
+ # 'missing' -- the same defect class as S14/S15: a check that names a specific machine's layout
263
+ # and then reports the absence of that layout as a fault of the machine under test. It is a
264
+ # `warning` rather than `blocking`, so the verdict was unaffected, but a report that warns about
265
+ # something that is not wrong teaches the operator to stop reading the warnings.
266
+ #
267
+ # Unconfigured is not the same as missing, so it is reported as such. Pass -App <path>, or set
268
+ # DSH_SELFCHECK_APP, to check for a specific application.
269
+ $app = if (-not [string]::IsNullOrEmpty($App)) { $App } else { [string]$env:DSH_SELFCHECK_APP }
270
+ if ([string]::IsNullOrEmpty($app)) {
271
+ Add-Check 'S18' 'target app present' 'warning' 'warn' 'configured app exists' 'not configured (pass -App or set DSH_SELFCHECK_APP)' 'set the app under test if this worker verifies one'
272
+ } else {
273
+ $appThere = Test-Path $app
274
+ Add-Check 'S18' 'target app present' 'warning' $(if ($appThere) { 'pass' } else { 'warn' }) 'exists' $(if ($appThere) { 'present: ' + $app } else { 'missing at: ' + $app }) 'sync the app build output'
275
+ }
276
+
277
+ # S20 workspace launch integrity
278
+ #
279
+ # WHY THIS IS IN THE READY REPORT: the failure it detects (a GUI app launched from the workspace
280
+ # runs at Low integrity and cannot write %LOCALAPPDATA%) presents as an application or ACL fault.
281
+ # The setting says danger-full-access, the agent shell is High, and only a copied executable reveals
282
+ # the downgrade. The probe copies a signed system binary into the workspace and into %TEMP% and
283
+ # compares the mandatory labels of the two child tokens.
284
+ #
285
+ # It is a warning, not a blocker: GUI work is broken, but the worker is otherwise usable and the fix
286
+ # is a label reset rather than a redeploy. Unconfigured workspace means no probe, not a failure.
287
+ # The relay only passes -Workspace under danger-full-access; a workspace-write workspace is SUPPOSED
288
+ # to be Low, so warning about it there would be a false alarm.
289
+ $integrity = $null
290
+ if (-not [string]::IsNullOrEmpty($Workspace)) {
291
+ $probeScript = Join-Path $PSScriptRoot 'integrity-probe.ps1'
292
+ if (-not (Test-Path -LiteralPath $probeScript)) {
293
+ Add-Check 'S20' 'workspace launch integrity' 'warning' 'warn' 'same as %TEMP%' ('integrity-probe.ps1 missing next to selfcheck.ps1: ' + $probeScript) 'deploy integrity-probe.ps1 with the relay, or stage GUI apps outside the workspace'
294
+ } else {
295
+ try {
296
+ $raw = & $probeScript -Workspace $Workspace 2>$null
297
+ $integrity = ($raw | Out-String) | ConvertFrom-Json
298
+ $fmt = { param($x) if ($null -eq $x) { '?' } else { "$($x.level)($($x.sid))" } }
299
+ $actual = 'self=' + (& $fmt $integrity.self) + ' workspace=' + (& $fmt $integrity.workspace) + ' temp=' + (& $fmt $integrity.temp)
300
+ if ($integrity.error -ne '') { $actual = $actual + ' error=' + $integrity.error }
301
+ if ($integrity.degraded) {
302
+ Add-Check 'S20' 'workspace launch integrity' 'warning' 'warn' 'workspace at least %TEMP% integrity' $actual 'A Low label inherited from an earlier workspace-write session is downgrading apps in this tree. Under danger-full-access the relay repairs it on start (--task-dir), or run slave/repair-workspace-integrity.ps1 -Path <workspace>; otherwise stage the app outside the workspace with slave/stage-app.ps1'
303
+ } elseif ($null -eq $integrity.workspace -or $null -eq $integrity.temp) {
304
+ Add-Check 'S20' 'workspace launch integrity' 'warning' 'warn' 'same as %TEMP%' $actual 'the probe could not launch from one of the two directories; check that the task directory exists and is writable by this account'
305
+ } else {
306
+ Add-Check 'S20' 'workspace launch integrity' 'warning' 'pass' 'workspace at least %TEMP% integrity' $actual ''
307
+ }
308
+ } catch {
309
+ Add-Check 'S20' 'workspace launch integrity' 'warning' 'warn' 'same as %TEMP%' ('probe failed: ' + $_.Exception.Message) 'run slave/integrity-probe.ps1 -Workspace <dir> by hand to see the failure'
310
+ }
311
+ }
312
+ }
313
+
314
+ $blocking = @($checks | Where-Object { $_.severity -eq 'blocking' -and $_.status -eq 'fail' })
315
+ $warn = @($checks | Where-Object { $_.severity -in @('blocking', 'warning') -and $_.status -eq 'warn' })
316
+ $unchecked = @($checks | Where-Object { $_.status -eq 'unchecked' })
317
+ $verdict = if ($blocking.Count -gt 0) { 'blocked' } elseif ($unchecked.Count -gt 0) { 'partial' } elseif ($warn.Count -gt 0) { 'warn' } else { 'ready' }
318
+
319
+ $payload = [ordered]@{
320
+ schemaVersion = 1
321
+ generatedAt = (Get-Date).ToString('o')
322
+ worker = $env:COMPUTERNAME
323
+ sessionId = $sid
324
+ interactive = $interactive
325
+ # The master's dispatch gate needs to tell "the worker is broken" apart from "nobody
326
+ # is logged in on it": the first is a fault, the second is an operational state that
327
+ # cannot be fixed from the master. Without this field the gate only sees blocking
328
+ # failures and would report a logged-out worker as a fault (DESIGN section 5.10.6).
329
+ sessionSegment = $(if ($interactive) { 'ok' } else { 'unavailable' })
330
+ verdict = $verdict
331
+ blockingFailures = $blocking.Count
332
+ warnings = $warn.Count
333
+ unchecked = $unchecked.Count
334
+ # The task directory the integrity probe measured, and its raw readings. Null when no workspace
335
+ # was supplied -- "not probed" must be distinguishable from "probed and equal".
336
+ workspace = $Workspace
337
+ integrity = $integrity
338
+ checks = $checks
339
+ }
340
+ $payload | ConvertTo-Json -Depth 6 | Set-Content -Path $outFile -Encoding UTF8
341
+
342
+ $lines = @()
343
+ $lines += "verdict=$verdict blocking=$($blocking.Count) warn=$($warn.Count) unchecked=$($unchecked.Count) session=$sid interactive=$interactive"
344
+ foreach ($c in $checks) {
345
+ $mark = switch ($c.status) { 'pass' { 'OK ' } 'fail' { 'FAIL' } 'warn' { 'WARN' } 'unchecked' { '?? ' } default { ' ' } }
346
+ $lines += (" [" + $mark + "] " + $c.id + " " + $c.name + " | expected=" + $c.expected + " | actual=" + $c.actual)
347
+ }
348
+ $lines += "json: $outFile"
349
+ $lines | Set-Content -Path $txtFile -Encoding UTF8
350
+ $lines | ForEach-Object { Write-Output $_ }
@@ -0,0 +1,116 @@
1
+ # stage-app.ps1 -- copy a GUI app OUTSIDE the workspace before launching it.
2
+ #
3
+ # WHY: a workspace that a workspace-write session has ever used carries an inheritable Low mandatory
4
+ # label. Windows launches a Low-labelled image at Low integrity, and a Low process cannot write
5
+ # %LOCALAPPDATA% even when the account's ACL grants it. Copying the app to a normal user location
6
+ # (%LOCALAPPDATA%\dsh-remote-use\staging by default) removes the inherited label, which is the
7
+ # reliable alternative to repairing the workspace itself (PITFALLS F41).
8
+ #
9
+ # The relay repairs an explicitly armed workspace automatically under danger-full-access. This helper
10
+ # is for the other two cases: a hand-managed relay with no --task-dir, and an operator who prefers to
11
+ # keep GUI payloads out of the workspace entirely.
12
+ #
13
+ # ASCII-only, param() first: Windows PowerShell 5.1 reads BOM-less files as ANSI.
14
+ param(
15
+ [Parameter(Mandatory = $true)][string]$Source,
16
+ [string]$Destination = '',
17
+ [string]$ExeName = '',
18
+ [switch]$Force,
19
+ [switch]$Launch,
20
+ [string]$Arguments = ''
21
+ )
22
+
23
+ $ErrorActionPreference = 'Stop'
24
+
25
+ if (-not (Test-Path -LiteralPath $Source)) {
26
+ Write-Host "REFUSING: source does not exist: $Source" -ForegroundColor Red
27
+ exit 2
28
+ }
29
+ $sourceFull = (Resolve-Path -LiteralPath $Source).Path
30
+ $sourceItem = Get-Item -LiteralPath $sourceFull
31
+ $leaf = [System.IO.Path]::GetFileNameWithoutExtension($sourceItem.Name)
32
+ if ([string]::IsNullOrEmpty($leaf)) { $leaf = 'app' }
33
+
34
+ if ([string]::IsNullOrEmpty($Destination)) {
35
+ $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss')
36
+ $Destination = Join-Path $env:LOCALAPPDATA ("dsh-remote-use\staging\" + $leaf + '-' + $stamp)
37
+ }
38
+
39
+ $targetItem = $null
40
+ if ($sourceItem.PSIsContainer) {
41
+ $targetDir = $Destination
42
+ if ((Test-Path -LiteralPath $targetDir) -and -not $Force) {
43
+ Write-Host "REFUSING: destination exists: $targetDir (pass -Force to replace it)" -ForegroundColor Red
44
+ exit 2
45
+ }
46
+ if ((Test-Path -LiteralPath $targetDir) -and $Force) {
47
+ Remove-Item -LiteralPath $targetDir -Recurse -Force
48
+ }
49
+ New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
50
+ Copy-Item -Path (Join-Path $sourceFull '*') -Destination $targetDir -Recurse -Force
51
+ $targetItem = Get-Item -LiteralPath $targetDir
52
+ } else {
53
+ $targetDir = $Destination
54
+ if (Test-Path -LiteralPath $targetDir -PathType Leaf) {
55
+ $targetDir = Split-Path -Parent $targetDir
56
+ }
57
+ New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
58
+ $targetFile = Join-Path $targetDir $sourceItem.Name
59
+ if ((Test-Path -LiteralPath $targetFile) -and -not $Force) {
60
+ Write-Host "REFUSING: destination exists: $targetFile (pass -Force to replace it)" -ForegroundColor Red
61
+ exit 2
62
+ }
63
+ Copy-Item -LiteralPath $sourceFull -Destination $targetFile -Force
64
+ $targetItem = Get-Item -LiteralPath $targetFile
65
+ }
66
+
67
+ # Make the staged copy's label explicit instead of inherited. The destination is normally Medium
68
+ # already, but %LOCALAPPDATA% can itself be inside a tree that a sandbox has labelled.
69
+ $stagedPath = $targetItem.FullName
70
+ Write-Host "staged: $stagedPath"
71
+ if ($targetItem.PSIsContainer) {
72
+ & icacls $stagedPath /setintegritylevel '(OI)(CI)Medium' /T /C /Q | Out-Null
73
+ } else {
74
+ & icacls $stagedPath /setintegritylevel Medium | Out-Null
75
+ }
76
+ if ($LASTEXITCODE -ne 0) {
77
+ Write-Host "WARNING: could not set the staged copy's integrity label (icacls exit $LASTEXITCODE)" -ForegroundColor Yellow
78
+ }
79
+
80
+ $probe = Join-Path $PSScriptRoot 'integrity-probe.ps1'
81
+ if (Test-Path -LiteralPath $probe) {
82
+ $probeDir = if ($targetItem.PSIsContainer) { $stagedPath } else { $targetDir }
83
+ & $probe -Workspace $probeDir
84
+ }
85
+
86
+ if ($Launch) {
87
+ if ($targetItem.PSIsContainer) {
88
+ $exe = $null
89
+ if (-not [string]::IsNullOrEmpty($ExeName)) {
90
+ $candidate = Join-Path $stagedPath $ExeName
91
+ if (Test-Path -LiteralPath $candidate) { $exe = $candidate }
92
+ } else {
93
+ $exe = @(Get-ChildItem -LiteralPath $stagedPath -Filter '*.exe' -File -ErrorAction SilentlyContinue | Select-Object -First 1)[0].FullName
94
+ }
95
+ if ([string]::IsNullOrEmpty($exe)) {
96
+ Write-Host 'REFUSING to launch: no .exe in the staged directory (pass -ExeName)' -ForegroundColor Red
97
+ exit 3
98
+ }
99
+ if ([string]::IsNullOrEmpty($Arguments)) {
100
+ Start-Process -FilePath $exe -WorkingDirectory $stagedPath
101
+ } else {
102
+ Start-Process -FilePath $exe -ArgumentList $Arguments -WorkingDirectory $stagedPath
103
+ }
104
+ Write-Host "launched: $exe"
105
+ } else {
106
+ if ([string]::IsNullOrEmpty($Arguments)) {
107
+ Start-Process -FilePath $stagedPath -WorkingDirectory $targetDir
108
+ } else {
109
+ Start-Process -FilePath $stagedPath -ArgumentList $Arguments -WorkingDirectory $targetDir
110
+ }
111
+ Write-Host "launched: $stagedPath"
112
+ }
113
+ }
114
+
115
+ Write-Host 'RESULT: OK -- the app is staged outside the workspace.' -ForegroundColor Green
116
+ exit 0