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.
This commit is contained in:
benjamin.baker
2026-09-05 14:20:07 -04:00
parent 7f84e8ca1e
commit 86985efcf2
23 changed files with 1261 additions and 192 deletions

View File

@@ -34,32 +34,103 @@
<img :src="withBase(computer.computer.imageurl)" :alt="computer.computer.modelname || 'Model photo'" />
</div>
<div class="hero-content">
<div class="hero-title">
<h1>{{ computer.assetnumber }}</h1>
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-info">Computer</span>
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
{{ computer.statusname || 'Unknown' }}
</span>
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ formatWarrantyDate(heroWarranty.enddate) }}</template>
</span>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="computer.computer?.computertypename">
<span class="hero-detail-label">PC Type</span>
<span class="hero-detail-value">{{ computer.computer.computertypename }}</span>
<!-- Identity, then the cloud row beneath it. An earlier cut put the
portal block in a fixed-width rail on the right; GUIDs are too
long for a narrow column and it wrapped into a tall ragged
stack. Full width in the normal flow costs one short row. -->
<div class="hero-primary">
<div class="hero-identity">
<div class="hero-title">
<h1>{{ computer.assetnumber }}</h1>
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-info">Computer</span>
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
{{ computer.statusname || 'Unknown' }}
</span>
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ formatWarrantyDate(heroWarranty.enddate) }}</template>
</span>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="computer.computer?.computertypename">
<span class="hero-detail-label">PC Type</span>
<span class="hero-detail-value">{{ computer.computer.computertypename }}</span>
</div>
<div class="hero-detail" v-if="computer.computer?.osname">
<span class="hero-detail-label">OS</span>
<span class="hero-detail-value">{{ computer.computer.osname }}</span>
</div>
<div class="hero-detail" v-if="computer.locationname">
<span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ computer.locationname }}</span>
</div>
</div>
</div>
<div class="hero-detail" v-if="computer.computer?.osname">
<span class="hero-detail-label">OS</span>
<span class="hero-detail-value">{{ computer.computer.osname }}</span>
</div>
<div class="hero-detail" v-if="computer.locationname">
<span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ computer.locationname }}</span>
<!-- Cloud identity. Laid out as a horizontal row in the same
label-over-value language as hero-details, not as a narrow
right rail: a 36-character GUID cannot wrap sanely into a
300px column, and the ragged stack that produced grew taller
than the identity block beside it.
Held back until the portal templates resolve so the row does
not paint once without its buttons and then again with them. -->
<div v-if="hasCloudIdentity" class="hero-portals">
<!-- The link sits beside the id it is built from, not in a row
of its own: the pairing is counterintuitive - the DEVICE id
opens Intune and the OBJECT id opens Entra - and a separate
action row would have to re-label which is which. The full
id stays selectable because it gets copied into tickets. -->
<div class="hero-portal">
<span class="hero-portal-label">
Device ID
<a
v-if="intuneDeviceLink"
:href="intuneDeviceLink"
target="_blank"
rel="noopener noreferrer"
class="portal-link"
>Intune</a>
</span>
<span v-if="computer.computer?.azuredeviceid" class="hero-portal-idrow">
<span class="hero-portal-id mono">{{ computer.computer.azuredeviceid }}</span>
<button
type="button"
class="copy-btn"
title="Copy Device ID"
@click="copyId(computer.computer.azuredeviceid, 'Device ID')"
>Copy</button>
</span>
<span v-else class="hero-portal-missing">Please provide DeviceID</span>
</div>
<div class="hero-portal">
<span class="hero-portal-label">
Object ID
<a
v-if="entraDeviceLink"
:href="entraDeviceLink"
target="_blank"
rel="noopener noreferrer"
class="portal-link"
>Entra</a>
</span>
<span v-if="computer.computer?.azureobjectid" class="hero-portal-idrow">
<span class="hero-portal-id mono">{{ computer.computer.azureobjectid }}</span>
<button
type="button"
class="copy-btn"
title="Copy Object ID"
@click="copyId(computer.computer.azureobjectid, 'Object ID')"
>Copy</button>
</span>
<span v-else class="hero-portal-missing">Please provide ObjectID</span>
</div>
<div class="hero-portal" v-if="computer.computer?.azuredevicecategory">
<span class="hero-portal-label">Category</span>
<span class="hero-portal-value">{{ computer.computer.azuredevicecategory }}</span>
</div>
</div>
</div>
@@ -96,8 +167,35 @@
answering for this asset type. -->
<AssetDetailTabs v-model="activegroup" :tabs="detailtabs" label="Computer detail sections" />
<!-- Network interfaces is pinned to the top of Details rather than left
in System Monitors: which adapter is up and what address it holds is
identity, asked at the same moment as "what is this box". It still
BELONGS to monitors (declared there, and excluded from that tab
below), so there is one declaration and no new group. Full width and
outside content-grid because the table has eight columns and would
be unreadable in a half-width column. -->
<div v-show="activegroup === 'details'" class="detail-full">
<PluginAssetPanels
:assetid="computer.assetid"
:only="['endpoints-interfaces']"
/>
</div>
<div v-show="activegroup === 'details'" class="content-grid">
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" group="details" />
<PluginAssetPanels
ref="assetPanels"
:assetid="computer.assetid"
group="details"
/>
<!-- Mapped drives is lifted the same way as network interfaces: a
missing share is a "why can't this operator open their program"
question, which gets asked on Details, not under monitors. It
stays declared in monitors and is excluded from that tab. -->
<PluginAssetPanels
:assetid="computer.assetid"
:only="['endpoints-mappeddrives']"
/>
<!-- Left Column -->
<div class="content-column">
@@ -199,70 +297,20 @@
</div>
</div>
<!-- Entra / Azure device info -->
<div
v-if="computer.computer?.azuredeviceid || computer.computer?.azureobjectid || computer.computer?.azuredevicecategory"
class="section-card"
>
<h3 class="section-title">Entra Device Info</h3>
<div class="info-list">
<!-- The link sits beside the id it is built from, not in a row
of its own at the top: the pairing is counterintuitive - the
DEVICE id opens Intune and the OBJECT id opens Entra - and a
separate action row would have to re-label which is which. -->
<div v-if="computer.computer?.azuredeviceid" class="info-row">
<span class="info-label">Device ID</span>
<span class="info-value">
{{ computer.computer.azuredeviceid }}
<a
v-if="intuneDeviceLink"
:href="intuneDeviceLink"
target="_blank"
rel="noopener noreferrer"
class="portal-link"
>Intune</a>
</span>
</div>
<div v-if="computer.computer?.azureobjectid" class="info-row">
<span class="info-label">Object ID</span>
<span class="info-value">
{{ computer.computer.azureobjectid }}
<a
v-if="entraDeviceLink"
:href="entraDeviceLink"
target="_blank"
rel="noopener noreferrer"
class="portal-link"
>Entra</a>
</span>
</div>
<div v-if="computer.computer?.azuredevicecategory" class="info-row">
<span class="info-label">Device Category</span>
<span class="info-value">{{ computer.computer.azuredevicecategory }}</span>
</div>
</div>
</div>
<!-- The Entra Device Info card lived here. Its links moved to the
header's right-hand block, which was dead space at every width,
so they now cost no vertical room and sit beside the identity
they belong to rather than partway down the page. -->
</div>
<!-- Right Column -->
<div class="content-column">
<!-- Network -->
<div class="section-card">
<h3 class="section-title">Network</h3>
<div v-if="computer.communications?.length" class="network-list">
<div v-for="comm in computer.communications" :key="comm.communicationid" class="network-item">
<div class="network-primary">
<span class="ip-address mono">{{ comm.ipaddress || comm.address || '-' }}</span>
<span v-if="comm.communicationtypename" class="comm-type">{{ comm.communicationtypename }}</span>
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
</div>
<div class="network-secondary" v-if="comm.macaddress">
<span class="mac-address mono">{{ comm.macaddress }}</span>
</div>
</div>
</div>
<p v-else class="muted">No network addresses on record</p>
</div>
<!-- The hand-written Network card lived here. It listed current
addresses with a Primary badge and nothing else, which undersold
rows that also carry subnet, gateway, DNS, and their own
first-seen/last-changed stamps. Split into the IP History and IP
Configuration panels under System Monitors, which read the same
Communication rows. -->
<!-- Location & Organization -->
<div class="section-card">
@@ -344,7 +392,11 @@
rather than the long lists that make Software and Activity go full
width. -->
<div v-show="activegroup === 'monitors'" class="content-grid">
<PluginAssetPanels :assetid="computer.assetid" group="monitors" />
<PluginAssetPanels
:assetid="computer.assetid"
group="monitors"
:exclude="['endpoints-interfaces', 'endpoints-mappeddrives']"
/>
</div>
<div v-show="activegroup === 'serviceprocess'" class="content-grid">
<PluginAssetPanels :assetid="computer.assetid" group="serviceprocess" />
@@ -433,6 +485,11 @@ const activegroup = ref('details')
// blade paths, so guessing one would send people to a dead page.
const entraUrlTemplate = ref('')
const intuneUrlTemplate = ref('')
// The templates arrive from an async settings fetch. Until that settles the
// block stays hidden: rendering the ids first and popping the Intune/Entra
// buttons in a moment later reads as the buttons being missing, which is
// exactly what it looked like before a manual refresh warmed the cache.
const portalTemplatesLoaded = ref(false)
function fillTemplate(template, token, value) {
if (!template || !value) return null
@@ -443,6 +500,30 @@ const entraDeviceLink = computed(() => fillTemplate(
const intuneDeviceLink = computed(() => fillTemplate(
intuneUrlTemplate.value, '{deviceid}', computer.value?.computer?.azuredeviceid))
// clipboard.writeText needs a secure context: it is undefined over plain HTTP
// and rejects when the tab is not focused. Say copy failed and leave the id
// selectable rather than reporting a success that did not happen.
async function copyId(value, label) {
if (!value) return
try {
await navigator.clipboard.writeText(value)
toast.success(`${label} copied.`)
} catch (err) {
toast.error(`Copy failed - select the ${label} and copy manually.`)
}
}
// Whether to show the header's cloud block at all.
//
// Gated on the site having CONFIGURED a portal template, not on this PC having
// an id. A site that does not use Intune/Entra has no templates, so the block
// is absent entirely rather than permanently asking for an id it will never
// get. Where templates exist, a PC missing an id shows which one is missing -
// that is the actionable case the empty state was requested for.
const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
entraUrlTemplate.value || intuneUrlTemplate.value
|| computer.value?.computer?.azuredevicecategory))
// Only offer a tab that has something in it. An empty tab is worse than an
// absent one - it reads as broken rather than as "this site does not collect
// that". Details always shows because core fills it regardless of plugins.
@@ -534,7 +615,11 @@ onMounted(async () => {
getEntraDeviceUrls().then(({ entraUrl, intuneUrl }) => {
entraUrlTemplate.value = entraUrl
intuneUrlTemplate.value = intuneUrl
}).catch(() => {})
}).catch(() => {}).finally(() => {
// Settled either way. On failure the templates stay empty and the block
// falls back to whatever else it has, rather than hiding forever.
portalTemplatesLoaded.value = true
})
try {
const response = await computersApi.get(route.params.id)
@@ -722,6 +807,96 @@ async function confirmRetire() {
}
.portal-link:hover { border-color: var(--primary); }
/* PC-only header additions. These class names exist nowhere else in the app -
Machines, Printers and the rest share only .hero-details/.hero-detail-*
from global style.css, which is deliberately left alone. */
.hero-primary {
display: flex;
flex-direction: column;
gap: 1rem;
}
.hero-identity {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
/* Horizontal, in the same visual language as .hero-details directly above it,
separated by a rule rather than sitting in a box. Wraps to a second line on
a narrow window instead of forcing a scrollbar. */
.hero-portals {
display: flex;
flex-wrap: wrap;
gap: 2rem;
padding-top: 0.85rem;
border-top: 1px solid var(--border);
}
.hero-portal {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
/* The portal button lives on the label line, so it reads as an action on the
id below it and adds no height of its own. */
.hero-portal-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-light);
}
.hero-portal-value {
font-size: 15px;
font-weight: 500;
color: var(--text);
}
/* Full id, never truncated: a half-copied GUID looks complete and is worse
than none. user-select:all keeps manual selection working as a fallback for
when the clipboard API is unavailable over plain HTTP. */
.hero-portal-idrow {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.hero-portal-id {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 13px;
color: var(--text);
user-select: all;
white-space: nowrap;
}
.copy-btn {
flex: 0 0 auto;
padding: 0.1rem 0.4rem;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-light);
background: none;
border: 1px solid var(--border);
border-radius: 0.25rem;
cursor: pointer;
}
.copy-btn:hover {
color: var(--primary);
border-color: var(--primary);
}
.hero-portal-missing {
font-size: 13px;
color: var(--text-light);
font-style: italic;
}
@media (max-width: 900px) {
.hero-portals { gap: 1rem 1.5rem; }
/* Only here does the id need to break: at this width it cannot fit a line. */
.hero-portal-id { white-space: normal; overflow-wrap: anywhere; }
}
/* PC-specific styles - shared styles are in global style.css */
@@ -810,43 +985,8 @@ async function confirmRetire() {
color: var(--text-light);
}
/* Network card (IP / MAC list) */
.network-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.network-item {
padding: 0.5rem 0.7rem;
background: var(--bg);
border-radius: 6px;
}
.network-primary {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.ip-address {
font-weight: 600;
color: var(--text);
}
.comm-type {
font-size: 0.8rem;
color: var(--text-light);
}
.primary-badge {
padding: 0.1rem 0.5rem;
font-size: 0.72rem;
font-weight: 600;
background: var(--primary);
color: #fff;
border-radius: 10px;
}
.mac-address {
font-size: 0.82rem;
color: var(--text-light);
}
/* Network card styles removed with the card itself - IP History and IP
Configuration are plugin panels and carry their own table styling. */
.muted {
color: var(--text-light);
margin: 0;