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

@@ -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.

View File

@@ -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('<h3 class="section-title">Entra Device Info</h3>')
})
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('<h3 class="section-title">Network</h3>')
})
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</a>')
expect(source).toContain('>Entra</a>')
})
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('<style scoped>'))
expect(style).not.toContain('.hero-details {')
expect(style).not.toContain('.hero-detail-label {')
expect(style).not.toContain('.hero-detail-value {')
})
})
describe('the lifted Mapped drives card', () => {
it('renders on Details', () => {
expect(pcDetailSource()).toContain("['endpoints-mappeddrives']")
})
it('is excluded from System Monitors so it renders once', () => {
const source = pcDetailSource()
const start = source.indexOf('group="monitors"')
const block = source.slice(start, start + 200)
expect(block).toContain('endpoints-mappeddrives')
})
it('is still declared in monitors, not moved to a new group', () => {
const plugin = read('../../../plugins/endpoints/plugin.py')
const start = plugin.indexOf("'id': 'endpoints-mappeddrives'")
const block = plugin.slice(start, start + 1200)
expect(block).toContain("'group': 'monitors'")
})
})