From 86985efcf27074f329b5b7a00da5321abadbfcb4 Mon Sep 17 00:00:00 2001 From: "benjamin.baker" Date: Sat, 5 Sep 2026 14:20:07 -0400 Subject: [PATCH] Fix user-context collection, split agent log destinations, widen upload cap Mapped drives stopped reporting because the user-context path failed silently in three separate places, and none of them left a trace to read back. - Probe: launch via -EncodedCommand instead of staging a temp .ps1 and running powershell.exe -File. A missing or quarantined script file fails BEFORE any statement runs, so the script's own try/catch never sees it and the only symptom is exit -196608. - Spool: pin to the ShopFloor profile. Resolving it through WTSQueryUserToken is privileged, endpoint protection blocks it, and on failure the tray (writer) and service (reader) silently disagree about the path. - Spool: consume after a successful parse, so a stopped tray cannot keep re-reporting stale mapped drives as current. Unparseable files are left in place for the next cycle. - Logging: user-session components cannot write to Program Files or to the SYSTEM-owned ProgramData log directory, and the file sink degrades to a silent no-op when it cannot create its directory. Tray and probe now log to C:\Users\ShopFloor\ShopDB\Logs, installs transcript to ProgramData, and the sink records a SinkProblem instead of swallowing UnauthorizedAccessException. - Installer: provision the operator spool/log directories with ShopFloor:Modify before the tray first runs, and stop the transcript via trap so a throw cannot orphan exactly the failed install worth reading. - Upload cap 64 -> 128 MB: the self-contained tray publishes at ~69 MB and was rejected on SIZE, but the size check returns before the PE is parsed, so the operator only ever saw a version-detection failure. AGENT-CONTRACT.md gains a normative section defining the three security contexts, their log destinations, the fixed spool path and consume-on-read. Agent 1.8.0 -> 1.8.1, tray 1.1.0 -> 1.1.1 so the deployed build is identifiable from a log line. --- .github/copilot-instructions.md | 4 + .../EndpointCollector.cs | 8 +- .../FileLoggerProvider.cs | 57 ++- .../UserContextProbe.cs | 60 +-- .../UserContextSpool.cs | 60 ++- .../ShopDbEndpointAgent.Tray.csproj | 9 +- agent/ShopDbEndpointAgent.Tray/TrayContext.cs | 50 ++- agent/ShopDbEndpointAgent/Program.cs | 12 +- .../ShopDbEndpointAgent.csproj | 18 +- agent/installer/install-shopdbagent.ps1 | 84 +++- frontend/src/components/PluginAssetPanels.vue | 20 +- frontend/src/components/pinnedPanels.spec.js | 203 +++++++++ plugins/computers/frontend/views/PCDetail.vue | 390 ++++++++++++------ plugins/endpoints/AGENT-CONTRACT.md | 63 ++- plugins/endpoints/api/agentupload.py | 16 +- plugins/endpoints/api/routes.py | 131 ++++++ .../frontend/views/AgentKeySettings.vue | 18 +- plugins/endpoints/plugin.py | 61 ++- shopdb/config.py | 9 +- tests/test_plugin_migrations.py | 5 +- .../test_endpoints_agentupload.py | 25 ++ .../test_plugins/test_endpoints_ip_history.py | 148 +++++++ tests/test_plugins/test_endpoints_plugin.py | 2 +- 23 files changed, 1261 insertions(+), 192 deletions(-) create mode 100644 frontend/src/components/pinnedPanels.spec.js create mode 100644 tests/test_plugins/test_endpoints_ip_history.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8892e585..3a4394f5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,6 +4,10 @@ Follow these when suggesting code. They are enforced by a naming/style hook and by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build. `CONTRIBUTING.md` is the full authority; this is the short version. +## General Guidelines +- Finish the in-flight task before starting new workstreams. +- When asked to "take notes" on a future idea, capture it in the notes file only - do not implement it, and do not expand on it at length in replies. + ## Naming (LOCKED - the hook rejects violations) - **DB tables**: lowercase, concatenated, plural. No underscores, no dashes. diff --git a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs index 8efd363e..698b3126 100644 --- a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs +++ b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs @@ -272,7 +272,13 @@ public sealed class EndpointCollector : ICollector return lastUserContext; } - var collected = userContextSpool.TryReadResult(resultPath); + // Consume rather than peek: the file is removed once it has been read, so + // a tray that has died cannot keep this endpoint reporting the same + // mapped drives forever. lastUserContext still repeats the previous value + // between successful reads, because the tray collects on a much slower + // cadence than telemetry and dropping the key in between would read on + // the server as "the drives went away". + var collected = userContextSpool.ConsumeResult(resultPath); if (collected is not null) lastUserContext = collected; diff --git a/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs b/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs index fa374105..6172a107 100644 --- a/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs +++ b/agent/ShopDbEndpointAgent.Core/FileLoggerProvider.cs @@ -14,32 +14,76 @@ public sealed class FileLoggerProvider : ILoggerProvider private readonly string directory; private readonly LogLevel minLevel; private readonly int retentionDays; + private readonly string filePrefix; + private readonly bool stampFirst; private readonly object gate = new(); private readonly ConcurrentDictionary loggers = new(); private string currentDateStamp = ""; private StreamWriter? writer; + // Why the sink can be unusable, or null when it opened normally. The + // constructor cannot throw (see below), but a caller that has a second, + // known-writable place to complain to needs to be able to ask. + public string? SinkProblem { get; private set; } + public FileLoggerProvider(string directory, LogLevel minLevel = LogLevel.Information, - int retentionDays = 14) + int retentionDays = 14, string filePrefix = "agent", bool stampFirst = false) { this.directory = directory; this.minLevel = minLevel; this.retentionDays = retentionDays; + this.filePrefix = filePrefix; + this.stampFirst = stampFirst; // Best-effort: creating the log directory can fail on a locked-down host // or when a non-privileged user targets a SYSTEM-owned path. Never let // that abort construction; Write() degrades to a no-op if the sink is // unusable. + // + // The reason is KEPT rather than discarded. A silently dead sink is what + // made the user-context probe undiagnosable: running as the operator it + // could not write into the SYSTEM-owned ProgramData log directory, so a + // probe that failed and a probe that never ran produced identical + // silence. Callers that have a writable fallback can now surface this. try { Directory.CreateDirectory(directory); } - catch (Exception error) when (error is IOException or UnauthorizedAccessException) { } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + SinkProblem = $"{directory}: {error.Message}"; + } } // The default log directory when config leaves LogPath empty: // %ProgramData%\ShopDB\EndpointAgent\logs + // + // SERVICE-ONLY. This path is SYSTEM-owned and a signed-in operator cannot + // write to it, so anything running in the user's session must use + // UserProfileLogDirectory() instead. public static string DefaultDirectory() => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "ShopDB", "EndpointAgent", "logs"); + // The shop-floor operator account. Every shop-floor endpoint uses this same + // local account name, so the path is fixed rather than resolved through the + // interactive token: the token lookup is the part that fails on a hardened + // endpoint, and when it fails the writer and the reader silently disagree + // about where the spool lives. If a site ever uses a different account only + // user-context is lost - telemetry, commands and inventory are unaffected. + public const string ShopFloorUserName = "ShopFloor"; + + public static string ShopFloorProfileDirectory() + => Path.Combine(DrivesRoot, "Users", ShopFloorUserName); + + private static string DrivesRoot => + Path.GetPathRoot(Environment.GetFolderPath(Environment.SpecialFolder.Windows)) + ?? @"C:\"; + + // Where the TRAY and the user-context probe log: + // C:\Users\ShopFloor\ShopDB\Logs + // The operator owns this directory, so it is writable from the user session + // where ProgramData is not. + public static string UserProfileLogDirectory() + => Path.Combine(ShopFloorProfileDirectory(), "ShopDB", "Logs"); + public ILogger CreateLogger(string categoryName) => loggers.GetOrAdd(categoryName, name => new FileLogger(this, name)); @@ -85,7 +129,11 @@ public sealed class FileLoggerProvider : ILoggerProvider writer?.Flush(); writer?.Dispose(); currentDateStamp = stamp; - var path = Path.Combine(directory, $"agent-{stamp}.log"); + // stampFirst gives "-trayagent.log"; the service keeps its + // established "agent-.log" so existing log collection still finds + // it. Both roll daily and prune on the same pattern. + var name = stampFirst ? $"{stamp}-{filePrefix}.log" : $"{filePrefix}-{stamp}.log"; + var path = Path.Combine(directory, name); writer = new StreamWriter(new FileStream( path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) { @@ -99,7 +147,8 @@ public sealed class FileLoggerProvider : ILoggerProvider try { var cutoff = DateTime.Now.AddDays(-retentionDays); - foreach (var file in Directory.EnumerateFiles(directory, "agent-*.log")) + var pattern = stampFirst ? $"*-{filePrefix}.log" : $"{filePrefix}-*.log"; + foreach (var file in Directory.EnumerateFiles(directory, pattern)) { if (File.GetLastWriteTime(file) < cutoff) File.Delete(file); diff --git a/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs b/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs index 01cbe4a8..69105183 100644 --- a/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs +++ b/agent/ShopDbEndpointAgent.Core/UserContextProbe.cs @@ -31,6 +31,9 @@ public sealed class UserContextProbe private const string ProbeScript = """ $ErrorActionPreference = 'Stop' $result = [ordered]@{} + # Must match UserContextSpool.TryGetResultPath on the reader side. Uses + # USERPROFILE, which for the shop-floor operator resolves to + # C:\Users\ShopFloor - the same fixed location the service reads. $spool = Join-Path $env:USERPROFILE 'ShopDB' $target = Join-Path $spool 'usercontext.json' $temporary = "$target.tmp" @@ -145,6 +148,10 @@ public sealed class UserContextProbe // the telemetry path, so an unresponsive WMI call delays nothing but itself. private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(60); + // powershell.exe's exit code when it rejects its command line and prints + // usage instead of running anything. Named so the failure is recognisable. + private const int PowerShellStartupFailure = -196608; + private readonly ILogger logger; public UserContextProbe(ILogger logger) @@ -160,19 +167,34 @@ public sealed class UserContextProbe // failure must not take the tray or the agent process down with it. public bool Collect() { - string? scriptPath = null; try { - scriptPath = Path.Combine( - Path.GetTempPath(), $"shopdb-usercontext-{Guid.NewGuid():N}.ps1"); - File.WriteAllText(scriptPath, ProbeScript); + // Passed as -EncodedCommand rather than staged as a .ps1 and run with + // -File. The script used to be written into %TEMP% and executed from + // there, which put a dependency on that file still existing, and still + // being readable, at the moment powershell.exe started. On a hardened + // endpoint that window is not safe: endpoint protection quarantines a + // freshly written script in the temp directory between the write and + // the launch, and roaming/redirected profiles can make the path + // unusable outright. + // + // When powershell.exe cannot use its -File argument it prints its usage + // banner and exits -196608 (0xFFFD0000) WITHOUT running a single + // statement. That is a startup failure, not a script failure, so the + // script's own try/catch never runs, nothing is logged about drives, + // and the operator sees a silent "no mapped drives reported" while the + // log only shows the bare exit code. -EncodedCommand carries the script + // in the command line itself, so there is no file to lose and no + // execution-policy or quarantine step between us and the probe. + var encoded = Convert.ToBase64String( + System.Text.Encoding.Unicode.GetBytes(ProbeScript)); var startInfo = new System.Diagnostics.ProcessStartInfo { FileName = "powershell.exe", Arguments = - "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File " - + "\"" + scriptPath + "\"", + "-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand " + + encoded, WorkingDirectory = Path.GetTempPath(), UseShellExecute = false, CreateNoWindow = true, @@ -196,8 +218,16 @@ public sealed class UserContextProbe if (process.ExitCode != 0) { + // 0xFFFD0000 is powershell.exe refusing its own command line and + // printing usage: the probe never ran. Called out by name because + // as a bare number it reads like a script error and sends the + // reader looking for a WMI or drive fault that is not there. + var detail = process.ExitCode == PowerShellStartupFailure + ? " (powershell.exe rejected its command line; the probe did not run)" + : string.Empty; logger.LogWarning( - "The user-context probe exited with code {ExitCode}.", process.ExitCode); + "The user-context probe exited with code {ExitCode}.{Detail}", + process.ExitCode, detail); return false; } @@ -208,22 +238,6 @@ public sealed class UserContextProbe logger.LogWarning(exception, "The user-context probe failed."); return false; } - finally - { - if (scriptPath is not null) - { - try - { - if (File.Exists(scriptPath)) - File.Delete(scriptPath); - } - catch - { - // A leftover temp script is harmless; it is uniquely named - // and cleaned by the OS. - } - } - } } private void TryKill(System.Diagnostics.Process process) diff --git a/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs b/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs index dfe5a257..a1988674 100644 --- a/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs +++ b/agent/ShopDbEndpointAgent.Core/UserContextSpool.cs @@ -47,16 +47,36 @@ public sealed class UserContextSpool this.logger = logger; } - // Full path to the spool file for the signed-in user, or null (with a - // reason) when nobody is signed in. No session means no session-scoped data - // to collect, which is a normal state on an unattended machine, not a fault. + // Full path to the spool file. Fixed at C:\Users\ShopFloor\ShopDB, because + // every shop-floor endpoint signs in as the same local ShopFloor account. + // + // This used to be resolved through WTSQueryUserToken/GetUserProfileDirectory, + // and that lookup is precisely what fails on a hardened endpoint: querying + // another session's token is a privileged operation that endpoint protection + // blocks. When it failed the service simply reported "no session data" while + // the tray was writing a perfectly good file, so writer and reader silently + // disagreed about the location and the operator saw an empty card. + // + // The token lookup is kept ONLY as a fallback for a machine whose operator + // account is named something else. If both are unavailable there is no + // session data, which costs mapped drives and idle time but never affects + // telemetry, commands or inventory. public string? TryGetResultPath(out string? reason) { + var pinned = FileLoggerProvider.ShopFloorProfileDirectory(); + if (Directory.Exists(pinned)) + { + reason = null; + return Path.Combine(pinned, SpoolFolderName, ResultFileName); + } + var profileDirectory = InteractiveProcessLauncher.TryGetActiveUserProfileDirectory(out var error); if (string.IsNullOrWhiteSpace(profileDirectory)) { - reason = error ?? "The interactive user profile could not be resolved."; + reason = $"No {FileLoggerProvider.ShopFloorUserName} profile at {pinned}, " + + "and the interactive profile could not be resolved either: " + + (error ?? "unknown error"); return null; } @@ -159,6 +179,38 @@ public sealed class UserContextSpool } } + // Read the spool and then REMOVE it, so the next report can only carry a + // result the tray produced after this read. Leaving a consumed file in place + // means a tray that has stopped running keeps re-reporting the same drives + // as though they were current, which is worse than reporting nothing: the + // card looks healthy while the data is frozen. + // + // The removal is deliberately AFTER a successful parse. If the file was + // mid-write (truncated JSON) it is left alone rather than destroyed, so a + // read that raced the tray costs one cycle instead of a good result. The + // collectedat age check still applies as a second bound for the window + // between the tray writing and the service next waking up. + public Dictionary? ConsumeResult(string resultPath) + { + var parsed = TryReadResult(resultPath); + if (parsed is null) + return null; + + try + { + File.Delete(resultPath); + } + catch (Exception exception) + { + // Non-fatal: the value was already read. It does mean the next cycle + // may re-read the same result, which the age check will retire. + logger.LogWarning(exception, + "Read the user-context spool but could not remove it at {Path}", resultPath); + } + + return parsed; + } + // Age is taken from the stamp the probe wrote, not from the file's last // write time, because the file can be copied or restored with a roaming // profile and carry a misleading timestamp. A result with no readable stamp diff --git a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj index 21b6d833..c78d85ef 100644 --- a/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj +++ b/agent/ShopDbEndpointAgent.Tray/ShopDbEndpointAgent.Tray.csproj @@ -21,7 +21,14 @@ service version independently (they are separate exes and can be pushed apart), so the tray carries its own and the agent reports it as trayversion alongside its own agentversion (AGENT-CONTRACT.md 2). --> - 1.1.0 + + 1.1.1 true win-x64 true diff --git a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs index 54eb43f3..3bd64554 100644 --- a/agent/ShopDbEndpointAgent.Tray/TrayContext.cs +++ b/agent/ShopDbEndpointAgent.Tray/TrayContext.cs @@ -1,6 +1,8 @@ using System.Diagnostics; using System.Reflection; +using Microsoft.Extensions.Logging; + using ShopDbEndpointAgent.Core; namespace ShopDbEndpointAgent.Tray; @@ -44,8 +46,27 @@ internal sealed class TrayContext : ApplicationContext private bool userContextStarted; private bool collectingUserContext; + // The tray logs into the operator's own profile + // (C:\Users\ShopFloor\ShopDB\Logs\-trayagent.log), NOT into the + // service's ProgramData directory. The operator cannot write there, and the + // file sink fails silently when it cannot create its directory, so tray-side + // failures previously left no trace anywhere. Keeping the two apart means a + // tray problem is diagnosable from a file the operator can actually reach. + private readonly FileLoggerProvider logProvider; + private readonly ILogger logger; + public TrayContext() { + logProvider = new FileLoggerProvider( + FileLoggerProvider.UserProfileLogDirectory(), + filePrefix: "trayagent", + stampFirst: true); + logger = logProvider.CreateLogger("Tray"); + logger.LogInformation( + "Tray {Version} started; user-context probe in {Delay}, then every {Interval}.", + typeof(TrayContext).Assembly.GetName().Version?.ToString() ?? "unknown", + UserContextStartupDelay, UserContextInterval); + var menu = new ContextMenuStrip(); menu.Items.Add("Send Status now", null, OnSendStatus); menu.Items.Add("Report an issue...", null, OnReportIssue); @@ -90,16 +111,39 @@ internal sealed class TrayContext : ApplicationContext var agentExe = ResolveAgentExe(); if (agentExe is null) + { + // Previously a bare return. This is the single most likely reason for + // "no mapped drives" with nothing at all in any log, so it must say so. + logger.LogWarning( + "Skipping the user-context probe: {Agent} was not found beside the tray " + + "({Beside}) or at {Installed}.", + AgentExeName, AppContext.BaseDirectory, DefaultInstallDir); return; + } collectingUserContext = true; try { - await RunAgentAsync(agentExe, "--collect-usercontext"); + logger.LogInformation("Running the user-context probe: {Agent}", agentExe); + var exitCode = await RunAgentAsync(agentExe, "--collect-usercontext"); + if (exitCode == 0) + { + logger.LogInformation( + "The user-context probe finished; spool written to {Spool}.", + Path.Combine( + FileLoggerProvider.ShopFloorProfileDirectory(), "ShopDB")); + } + else + { + logger.LogWarning( + "The user-context probe exited with code {ExitCode}.", exitCode); + } } - catch + catch (Exception exception) { - // Intentionally silent. The agent logs the detail to its own file. + // No longer silent. The agent logs its own detail, but if the process + // could not even be started the agent never ran and never logged. + logger.LogWarning(exception, "Could not run the user-context probe."); } finally { diff --git a/agent/ShopDbEndpointAgent/Program.cs b/agent/ShopDbEndpointAgent/Program.cs index daa6f249..f8162d01 100644 --- a/agent/ShopDbEndpointAgent/Program.cs +++ b/agent/ShopDbEndpointAgent/Program.cs @@ -50,8 +50,18 @@ var logDirectory = string.IsNullOrWhiteSpace(config.LogPath) // diagnose. if (collectUserContext) { + // Logs to the OPERATOR's profile, not the service's ProgramData directory. + // This verb always runs as the signed-in user (the tray invokes it), and a + // non-privileged user cannot write to the SYSTEM-owned log directory. The + // file sink degrades to a no-op when it cannot create its directory, so + // pointing this at the service location meant every probe failure was + // discarded unread - which is exactly how a broken probe looked identical to + // a probe that was never invoked. using var probeLoggerFactory = LoggerFactory.Create(logging => - logging.AddProvider(new FileLoggerProvider(logDirectory))); + logging.AddProvider(new FileLoggerProvider( + FileLoggerProvider.UserProfileLogDirectory(), + filePrefix: "trayagent", + stampFirst: true))); var probe = new UserContextProbe( probeLoggerFactory.CreateLogger()); return probe.Collect() ? 0 : 1; diff --git a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj index 374c36a7..7c6fdb8e 100644 --- a/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj +++ b/agent/ShopDbEndpointAgent/ShopDbEndpointAgent.csproj @@ -7,6 +7,22 @@ enable ShopDbEndpointAgent shopdbagent + - 1.8.0 + 1.8.1 true win-x64 true diff --git a/agent/installer/install-shopdbagent.ps1 b/agent/installer/install-shopdbagent.ps1 index 5a08d3db..0185bb9f 100644 --- a/agent/installer/install-shopdbagent.ps1 +++ b/agent/installer/install-shopdbagent.ps1 @@ -157,10 +157,49 @@ function Uninstall-PreviousAgent { Start-Sleep -Milliseconds 500 } +# Installer transcript. Deliberately SEPARATE from both the service log +# (%ProgramData%\ShopDB\EndpointAgent\logs\agent-.log) and the tray log +# (C:\Users\ShopFloor\ShopDB\Logs\-trayagent.log): the installer runs +# elevated and one-shot, and mixing its output into the service's rolling log +# made an install indistinguishable from runtime behaviour when reading back a +# failure. This runs as admin, so ProgramData is writable here. +$installLogDir = Join-Path $env:ProgramData 'ShopDB\Logs' +try { + New-Item -ItemType Directory -Path $installLogDir -Force | Out-Null + $installLog = Join-Path $installLogDir ( + '{0}-shopdbinstalls.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss')) + Start-Transcript -Path $installLog -Append | Out-Null + $transcriptStarted = $true +} +catch { + # Never let logging block an install. + $transcriptStarted = $false + Write-Warning "could not start the install transcript: $($_.Exception.Message)" +} + +# The shop-floor operator profile. The tray and the user-context probe run as +# this local account on every shop-floor endpoint, and it is the one place both +# the operator (writer) and the SYSTEM service (reader) can reach. Defined here +# so the install can provision the directories and report their location. +$shopFloorProfile = Join-Path (Join-Path $env:SystemDrive 'Users') 'ShopFloor' + +# Stop the transcript on ANY terminating error, not just the success path. The +# script throws on several validation and smoke-test failures, and those are +# precisely the installs worth reading back afterwards - an orphaned transcript +# would truncate exactly the failure it was started to capture. +trap { + if ($transcriptStarted) { + try { Stop-Transcript | Out-Null } catch { } + $script:transcriptStarted = $false + } + break +} + Write-Host "== ShopDB Endpoint Agent install ==" -ForegroundColor Cyan Write-Host "BaseUrl : $BaseUrl" Write-Host "InstallDir : $InstallDir" Write-Host "ServiceName : $ServiceName" +if ($transcriptStarted) { Write-Host "InstallLog : $installLog" } # 0. Clear the Mark-of-the-Web on the SOURCE payload first. The operator # typically extracts installer.zip into Downloads, so every file carries a @@ -188,6 +227,43 @@ Write-Host "`n[1/5] Provisioning registry $regPath (Registry64) ..." Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "BaseUrl" -Value $BaseUrl Set-Hklm64Value -SubKey "SOFTWARE\GE\ShopDB" -Name "CollectorKey" -Value $CollectorKey +# 1a. Provision the user-context spool and tray log directories in the operator +# profile, and grant the operator Modify on them. +# +# This is the handoff point between the two security contexts (AGENT-CONTRACT.md +# section 5): the tray WRITES here as the ShopFloor operator, and the SYSTEM +# service READS and then removes the spool. The operator cannot write to +# Program Files or to the SYSTEM-owned ProgramData log directory, and the file +# log sink degrades to a silent no-op when it cannot create its directory - so +# without this step a tray-side failure leaves no trace anywhere on the machine. +# Creating them here, elevated, means the directories exist and are writable +# before the tray ever starts. +Write-Host "[1a/5] Provisioning operator spool/log directories under $shopFloorProfile ..." +if (Test-Path $shopFloorProfile) { + foreach ($dir in @( + (Join-Path $shopFloorProfile 'ShopDB'), + (Join-Path $shopFloorProfile 'ShopDB\Logs'))) { + try { + New-Item -ItemType Directory -Force -Path $dir | Out-Null + $acl = Get-Acl $dir + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + 'ShopFloor', 'Modify', + 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $acl.SetAccessRule($rule) + Set-Acl -Path $dir -AclObject $acl + Write-Host " provisioned $dir (ShopFloor: Modify)" + } + catch { + # Non-fatal: telemetry, commands and inventory are unaffected. Only + # user-context (mapped drives, idle time) depends on this path. + Write-Warning " could not provision $dir : $($_.Exception.Message)" + } + } +} +else { + Write-Warning " no ShopFloor profile at $shopFloorProfile; user-context (mapped drives, idle time) will be unavailable until that user has logged in once." +} + # 2. Copy agent files to the install directory. The agent and tray are # single-file self-contained executables, so the .exe plus appsettings.json is # the whole payload; there are no side-by-side runtime DLLs to copy. @@ -352,4 +428,10 @@ else { Write-Host "`nInstall complete." -ForegroundColor Green Write-Host "Service '$ServiceName' is running and set to auto-start." -Write-Host "Logs: $logDir" +# The three log destinations are separate on purpose; see AGENT-CONTRACT.md. +Write-Host "Service log : $logDir" +Write-Host "Tray log : $(Join-Path $shopFloorProfile 'ShopDB\Logs')" +if ($transcriptStarted) { + Write-Host "Install log : $installLog" + try { Stop-Transcript | Out-Null } catch { } +} diff --git a/frontend/src/components/PluginAssetPanels.vue b/frontend/src/components/PluginAssetPanels.vue index 0497af10..07633512 100644 --- a/frontend/src/components/PluginAssetPanels.vue +++ b/frontend/src/components/PluginAssetPanels.vue @@ -446,6 +446,15 @@ const props = defineProps({ // the detail pages that have not adopted tabs still do - so this stays // additive and no existing page changes behaviour. group: { type: String, default: null }, + // Render exactly these panel ids, ignoring group. For lifting one card out + // of its tab and showing it somewhere else - Network Interfaces is pinned to + // the top of Details while still belonging to monitors. Kept as a panel-id + // filter rather than a new group because a group would need the four-file + // vocabulary change AND would add a tab nobody asked for. + only: { type: Array, default: null }, + // The other half of `only`: the group instance must drop what was lifted out + // of it, or the card renders twice on two different tabs. + exclude: { type: Array, default: null }, }) const panels = ref([]) @@ -466,9 +475,14 @@ function groupOf(panel) { return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details' } -const groupedPanels = computed(() => - props.group ? panels.value.filter((p) => groupOf(p) === props.group) : panels.value -) +const groupedPanels = computed(() => { + if (props.only) return panels.value.filter((p) => props.only.includes(p.id)) + let list = props.group + ? panels.value.filter((p) => groupOf(p) === props.group) + : panels.value + if (props.exclude) list = list.filter((p) => !props.exclude.includes(p.id)) + return list +}) // Which groups actually have a panel with data, so a parent can hide a tab // rather than offering an empty one. diff --git a/frontend/src/components/pinnedPanels.spec.js b/frontend/src/components/pinnedPanels.spec.js new file mode 100644 index 00000000..f5ac0499 --- /dev/null +++ b/frontend/src/components/pinnedPanels.spec.js @@ -0,0 +1,203 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +// Network Interfaces is DECLARED in the monitors group but RENDERED at the top +// of the Details tab. That is done with a panel-id filter (`only`) rather than +// a new group, because a new group would need the four-file vocabulary change +// and would add a tab nobody asked for. +// +// The pair is the fragile part: `only` lifts the panel out, and `exclude` must +// drop it from the group instance it was lifted from. Miss the second half and +// the same card renders on two tabs, which looks like duplicated data rather +// than a layout bug. + +function read(relative) { + return readFileSync(fileURLToPath(new URL(relative, import.meta.url)), 'utf8') +} + +const panelsSource = () => read('./PluginAssetPanels.vue') +const pcDetailSource = () => read('../../../plugins/computers/frontend/views/PCDetail.vue') + +describe('lifting one panel out of its group', () => { + it('accepts an only prop', () => { + expect(panelsSource()).toMatch(/only:\s*\{\s*type:\s*Array/) + }) + + it('accepts an exclude prop', () => { + expect(panelsSource()).toMatch(/exclude:\s*\{\s*type:\s*Array/) + }) + + it('lets only bypass the group filter entirely', () => { + // Details renders a panel belonging to monitors, so `only` must NOT be + // and-ed with the group or it would match nothing. + const source = panelsSource() + const start = source.indexOf('const groupedPanels') + const block = source.slice(start, start + 400) + expect(block).toMatch(/if\s*\(props\.only\)/) + }) +}) + +describe('the pinned Network Interfaces card', () => { + it('renders at the top of Details by panel id', () => { + expect(pcDetailSource()).toContain("only=\"['endpoints-interfaces']\"") + }) + + it('is excluded from System Monitors so it cannot render twice', () => { + // Scoped to the monitors instance and matched on membership, not on the + // exact array text: more than one panel is lifted now. + const source = pcDetailSource() + const start = source.indexOf('group="monitors"') + const block = source.slice(start, start + 200) + expect(block).toContain('endpoints-interfaces') + }) + + it('is still declared in monitors, not moved to a new group', () => { + // One declaration, no vocabulary change. If somebody "fixes" this by + // adding a reports-style group, the four-file dance applies again. + // Bounded by the NEXT panel's id: the columns list contains '},' so + // slicing to that would cut the block off before the group key. + const plugin = read('../../../plugins/endpoints/plugin.py') + const start = plugin.indexOf("'id': 'endpoints-interfaces'") + const next = plugin.indexOf("'id': '", start + 10) + const block = plugin.slice(start, next === -1 ? undefined : next) + expect(block).toContain("'group': 'monitors'") + }) +}) + +describe('the header cloud identity block', () => { + it('shows the block on template presence, not on this PC having an id', () => { + // A site with no Intune/Entra at all must not see a permanent request for + // an id it will never have. + const source = pcDetailSource() + const start = source.indexOf('const hasCloudIdentity') + const block = source.slice(start, start + 260) + expect(block).toContain('entraUrlTemplate.value') + expect(block).toContain('intuneUrlTemplate.value') + }) + + it('waits for the portal templates before rendering', () => { + // The templates come from an async settings fetch. Rendering the ids + // first and adding the buttons a moment later looked like the buttons + // were missing until the page was manually refreshed. + const source = pcDetailSource() + const start = source.indexOf('const hasCloudIdentity') + const block = source.slice(start, start + 260) + expect(block).toContain('portalTemplatesLoaded.value') + }) + + it('marks the templates settled even when the fetch fails', () => { + // Otherwise a down settings backend hides the block permanently. + const source = pcDetailSource() + const start = source.indexOf('getEntraDeviceUrls()') + const block = source.slice(start, start + 420) + expect(block).toContain('finally') + expect(block).toContain('portalTemplatesLoaded.value = true') + }) + + it('names which id is missing rather than rendering an empty block', () => { + const source = pcDetailSource() + expect(source).toContain('Please provide DeviceID') + expect(source).toContain('Please provide ObjectID') + }) + + it('no longer carries the old Entra Device Info card', () => { + expect(pcDetailSource()).not.toContain('

Entra Device Info

') + }) + + it('no longer carries the hand-written Network card', () => { + // Its two halves are the IP History and IP Configuration panels now. + expect(pcDetailSource()).not.toContain('

Network

') + }) + + it('keeps the full ids on screen so they can be copied', () => { + // The move to the header was a MOVE, not a redesign. Rendering only an + // "Open device" link loses the id itself, which gets pasted into tickets + // and Graph queries. Both ids must still be printed in full. + const source = pcDetailSource() + expect(source).toContain('computer.computer.azuredeviceid }}') + expect(source).toContain('computer.computer.azureobjectid }}') + }) + + it('offers a copy button for each id', () => { + // Clicking to select still leaves the operator to press ctrl+c; these ids + // get pasted into tickets and Graph queries constantly. + const source = pcDetailSource() + expect(source).toContain("copyId(computer.computer.azuredeviceid, 'Device ID')") + expect(source).toContain("copyId(computer.computer.azureobjectid, 'Object ID')") + }) + + it('reports a failed copy instead of a false success', () => { + // clipboard.writeText is undefined over plain HTTP and rejects when the + // tab is unfocused. Claiming success there would be a lie. + const source = pcDetailSource() + const start = source.indexOf('async function copyId') + const block = source.slice(start, start + 420) + expect(block).toContain('catch') + expect(block).toContain('toast.error') + }) + + it('keeps the id selectable as a clipboard fallback', () => { + const source = pcDetailSource() + const start = source.indexOf('.hero-portal-id {') + const block = source.slice(start, start + 260) + expect(block).toContain('user-select: all') + }) + + it('does not truncate an id with an ellipsis', () => { + // A half-copied GUID is worse than no GUID: it looks complete. + const source = pcDetailSource() + const start = source.indexOf('.hero-portal-id {') + const block = source.slice(start, start + 260) + expect(block).not.toContain('text-overflow: ellipsis') + expect(block).toContain('user-select: all') + }) + + it('keeps the original Intune and Entra buttons', () => { + // These carry a race-condition fix. Relabelling them to "Open device" / + // "Open object" threw that away once already. + const source = pcDetailSource() + expect(source).toContain('>Intune') + expect(source).toContain('>Entra') + }) + + it('lays the portal block out horizontally, not as a narrow rail', () => { + // A fixed-width right rail wrapped 36-character GUIDs into a tall ragged + // stack that grew past the identity block beside it. + const source = pcDetailSource() + const start = source.indexOf('.hero-portals {') + const block = source.slice(start, start + 260) + expect(block).toContain('flex-wrap: wrap') + expect(block).not.toContain('width: 300px') + }) + + it('does not restyle the header classes other assets share', () => { + // Machines, Printers and friends share .hero-details/.hero-detail-* from + // global style.css. A PC-only header must not redefine them. + const source = pcDetailSource() + const style = source.slice(source.indexOf('