Skip to content

fog-agent: enrollment and authenticated channel (server side) - #1707

Draft
mastacontrola wants to merge 116 commits into
working-1.6from
feat/agent-enroll
Draft

fog-agent: enrollment and authenticated channel (server side)#1707
mastacontrola wants to merge 116 commits into
working-1.6from
feat/agent-enroll

Conversation

@mastacontrola

Copy link
Copy Markdown
Member

Server side of fog-agent, the in-progress replacement for the FOG client. This PR stays in draft and collects every fogproject commit for the agent work until the agent is ready; nothing here is reachable from the existing client.

What is in it so far

Enrollment (POST /agent/v1/enroll, unauthenticated)

  • agentEnrollment / agentEnrollToken tables and four hostAgent* columns (schema step 416).
  • FOG\Agent\Enrollment matches SMBIOS identity and CSR key against existing hosts, pends unknown or rebinding machines for an admin, auto-approves via a minted token or an active deploy task.
  • fog-sign-node-cert agent: clientAuth-only leaf signed by a new agent intermediate CA. The CN carries the host id, never a name.
  • Admin routes GET /agent/enrollments, POST /agent/enrollment/{id}/{action}.

Authenticated channel (client certificate)

  • FOG\Agent\Principal re-verifies the presented certificate in PHP against the agent CA bundle and binds it to a host by SPKI fingerprint.
  • Route gates every /agent/v1/* path except enroll on that principal; POST /agent/v1/poll records version and check-in.
  • Installer publishes the bundle and configures nginx and Apache for optional client verification. Apache path is untested (lab is nginx).

Fixes found on the way

  • Host::addMAC() before save() wrote hostMAC rows with an empty hostID, in Boot\Registration too.
  • BootFileManager->find() (1.5 API, swallowed by a catch) in FOGPage::_bootFileRow.
  • PHPStan extension typing getClass('Name') so 1.5-era method names on managers are findings. The 16 pre-existing findings it surfaced are baselined for a separate cleanup.

Verified

  • Lab, 2026-09-03: enroll → pending → approve → issued → poll with the certificate updates the host row.
  • tests/agent-principal.test.php (new) plus the existing suites: 252/252. phpstan clean.

Still to come under this PR

Pending Agents UI page, token minting UI, capabilities in the poll answer, certificate renewal route.

🤖 Generated with Claude Code

https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh

mastacontrola and others added 30 commits September 3, 2026 09:08
Server side of the fog-agent replacement for the FOG client. Testing
only at this stage; nothing here is reachable from the existing client.

Enrollment (POST /agent/v1/enroll, unauthenticated):
- agentEnrollment / agentEnrollToken tables and four hostAgent* columns
  (schema step 416, manifest, FK map group 12, route-column contract).
- FOG\Agent\Enrollment matches the SMBIOS identity and CSR key against
  existing hosts, pends unknown or rebinding machines for an admin, and
  auto-approves via a minted token or an active deploy task.
- fog-sign-node-cert gains an `agent` type: clientAuth-only leaf signed
  by a new agent intermediate CA, CN carries the host id, no names.
- Admin routes: GET /agent/enrollments, POST /agent/enrollment/{id}/{action}.

Authenticated channel (client certificate):
- FOG\Agent\Principal re-verifies the presented certificate in PHP
  against management/other/agent-ca-bundle.pem (X509_PURPOSE_SSL_CLIENT)
  and binds it to a host by SPKI fingerprint with a direct prepared
  statement. Route::getIds() cannot be used here: it adds the calling
  user's site scope to the WHERE, and with no user that is `1=0`.
- Route gates every /agent/v1/* path except enroll on that principal
  (401 JSON otherwise); POST /agent/v1/poll records version and check-in.
- Installer: agent CA bundle, nginx ssl_verify_client optional with
  SSL_CLIENT_VERIFY / SSL_CLIENT_CERT params, Apache SSLVerifyClient
  optional with ExportCertData. Apache path is untested (lab is nginx).

Fixes found on the way:
- Host::addMAC() before save() wrote hostMAC rows with an empty hostID;
  reordered in Enrollment and both new-host sites in Boot\Registration.
- BootFileManager->find() in FOGPage::_bootFileRow (a 1.5 API, swallowed
  by a catch) replaced with getIds().
- PHPStan extension build/phpstan/GetClassReturnTypeExtension.php types
  getClass('Name') so a wrong method name on a manager is a finding;
  the 16 pre-existing findings it surfaced are baselined for a later pass.

Proven on the lab 2026-09-03: enroll -> pending -> approve -> issued ->
poll with the certificate updates the host row. tests/agent-principal
.test.php and the existing suites pass (252/252); phpstan clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Hosts > Pending Agents: the admin side of fog-agent enrollment, sibling
of Pending Hosts and Pending MACs and built on the Pending MACs shape
(HostManagement::pendingAgents / pendingAgentsAjax / getPendingAgentList,
fog.host.pendingAgents.js). Select rows, Approve or Deny with a confirm
modal; each decision runs through FOG\Agent\Enrollment, the same code the
JSON route uses. The dashboard gets a "Pending agents" alert beside the
pending hosts and MACs ones.

The grid is client-side over the same whitelisted payload GET
/agent/enrollments serves, not Route::listem(): agentenrollment is
deliberately not an API class, since every row carries a CSR and, once
approved, a certificate. The list is bounded by what an admin has not yet
looked at, never by the fleet.

Permissions fall out of Authorization::_subToAction unchanged: the page
is host.view, the POST is host.edit, the list source is host.view.

Also: the first commit left two suite gates red that the earlier run did
not cover. psr4-scan now places Agent\Enrollment and Agent\Principal
(both extend FOGBase directly, so ancestry cannot), and
all-classes-load skips build/, which is PHPStan tooling loaded by the
root composer autoload-dev and implements interfaces FOG's own autoloader
has no way to declare.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Renewal: POST /agent/v1/renew, over the certificate being renewed. The
same gate as poll binds the caller to its host; the body carries a CSR
for the same key, and the answer is the enroll "issued" shape. Same key
only: a different key is a new claim on the machine and goes through
enroll and an admin. Enrollment::renew() signs through the existing
helper, moves hostAgentNotAfter and audits.

Tokens: FOG\Agent\Token mints, lists and revokes enrollment tokens (the
credential that lets a machine enroll without an admin clicking, design
0001 agent-based registration). The token is a 48-hex-character secret
shown exactly once; only its sha256 is stored. An expiry is required;
uses count down, or -1 is unlimited until expiry. Routes GET
/agent/tokens (host.view), POST /agent/token (host.create), DELETE
/agent/token/{id} (host.delete). Page Hosts > Agent Tokens, on the
Pending Agents shape, with the mint modal handing the token over once
and the ajax subs named create*/delete* so the permissions derive from
the names. Audited as agent.token.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
The convergence half of the protocol (design 0001 section 2). The poll
now lists the capabilities this server offers the host and the revision
of its desired state; GET /agent/v1/state returns that state and POST
/agent/v1/result records what a provider did with it. Same certificate
gate as poll.

A capability is listed when its legacy module is on for the host: the
global FOG_CLIENT_*_ENABLED setting and the host's resolved module set,
the two checks the old client's endpoints make, so existing per-host and
per-group module choices carry over unchanged. The first capability is
hostname: the host record's name and its enforce flag. The revision is a
digest of the state, so "anything changed?" costs the poll one compare.

Results are agent.result audit rows on the host, where FOG already shows
what happened to a host; no table until inventory needs one. Writes the
client certificate authorized carry authSource 'agent', not anonymous
(renewal corrected to match).

Also: the Agent Tokens page's mint and revoke posts fell through to the
host list, because FOGPageManager appends the Ajax suffix only after
method_exists() passes for the bare name. Both handlers now have their
bare twin. Found by the first browser run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
A host created by an agent enrollment had no module rows, so
State::capabilities() resolved to [] and the agent never received the
hostname capability even though FOG_CLIENT_HOSTNAMECHANGER_ENABLED was
on. Resolver::resolveModules has no default tier: a host only has the
modules explicitly attached to it or granted through a group. Match
Boot\Registration and HostManagement::addPost by attaching the isDefault
modules at creation.

Also clear the token name field when the mint modal opens, so the second
token does not silently inherit the first one's name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Desired state gains a `task` block (capability taskreboot, module
taskreboot): the task waiting for the host in a state that needs it to
boot into FOS, the same answer Client\Jobs gives the old client, with
FOG_TASK_FORCE_REBOOT as its force flag. Present only while one waits,
so queueing or canceling a task moves the revision. A `reboot` block
carries FOG_GRACE_TIMEOUT with any non-empty capability list.

The agent's reboot coordinator reports its decisions as results with
capability `reboot`, so results now accept that alongside the
capabilities proper.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
On a server with sites configured, every Route::getIds on a scoped node
inside an /agent/v1/ request answered empty: the site boundary asks
which objects THIS USER may see, an agent request has no user, and
Authorization only lifts the boundary for an entry point that declares
FOG_MACHINE_REQUEST, as every service/*.php does. The agent's desired
state therefore carried task: null with a task queued, and group-granted
modules would have dropped out the same way.

Declare it for the prefix, after _agentPrincipal() has bound a host and
after the 401 for an unbound one, so it stays a positive statement about
the entry point and a route that lost its 401 still would not get it.
Guard (k) in route-read-path-guards anchors the block and fails if the
declaration is removed or moved above the 401.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Capability `snapin` (module snapinclient) puts the host's snapin queue in
the desired state exactly as the server tasked it -- snapinTasks in
sequence order, from the resolver's host-first, then groups, deduplicated
list -- with each task's file, size, sha512, arguments, interpreter,
timeout, reboot or shutdown flag and the job's abort-on-fail. Two routes
serve it: GET /agent/v1/snapin/{id}/file streams the payload from the
storage node over the web tier's own FTP session and marks the task in
progress; POST /agent/v1/snapin/{id}/result closes it with the exit
code and output tail, cancels the rest of a job that aborts on failure,
ends the job after its last task, and audits agent.result on the host.
Both check the task belongs to the host's own job, the legacy Aisle 009
guard, with one message for "missing" and "not yours".

Agent\Snapins::stream() and close() ARE the legacy SnapinClient's
_downloadfile and _closeout bodies; the legacy methods keep their input
parsing and call the shared code, so both clients mark tasks and end
jobs identically and cannot drift.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Snapins reported success only when the payload exited 0, which mislabels
the codes installers actually return: 3010/1641 (installed, reboot to
finish) and 1618 (another install in progress, retry) both read as
failures and could trip abort-on-fail. This gives each snapin a
`code=class` table (sReturnCodes, empty = the Intune defaults
0/1707=success, 3010/1641=reboot, 1618=retry) and derives an outcome
from it on the server:

- retry puts the task back to queued so the next check-in runs it again
- reboot returns the outcome to the agent, whose coordinator handles it
- abort-on-fail only fires on failed, not on reboot/retry

snapinTasks gains stStatus (ran/hash_mismatch/timeout/cannot_run) next
to the raw exit code, so a payload that never ran is no longer recorded
as exit code 0, and stReturnDetails widens to TEXT so a 4 KB tail of
the payload's output fits. Schema 417.

UI: Return Codes textarea on snapin add/edit, Status column in the host
and group snapin history, report page labels outcomes from stStatus.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Generated from a live install after the 417 update: sReturnCodes,
stStatus and the TEXT stReturnDetails, plus the foreign-key backing
indexes the reconciler's constraint pass created on that install, which
the generator keeps by design.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Linux and macOS truncate an exit status to 8 bits, so 3010 and 1618
cannot be returned there; the help text now says to list the code the
program can actually return.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
… first

Design 0003 (fog-agent docs/design/0003-software.md). A software entry is
a package id plus a version policy (any, latest, pinned) and a state
(present, absent), assigned to hosts directly and granted to groups,
resolved per host in the snapin order (direct, then groups in group
order, deduplicated). The agent's `software` capability converges the
set and reports per entry; the server reads the exit code against the
entry's return-code table (snapin defaults plus Chocolatey's 350 as
reboot), refreshes one status row per host and entry, and answers the
outcome. Nothing here is a task; snapins are untouched.

Schema 418: software, softwareAssoc, groupSoftwareAssoc, softwareStatus,
module 13 `software`, FOG_SOFTWARE_DRIFT_INTERVAL (six hours), and the
stReturnDetails default 417 left off. Manifest entries added by hand in
the generator's shape until a migrated database can regenerate it.

UI: Software node (list, add, edit with General, Hosts and Status tabs),
Software tab on host and group edit with run order, Software Status tab
on the host, Software Report. Route /agent/v1/software/{id}/result.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
…TEXT defaults

418 seeded the software module at id 13, which step 223 had given to
powermanagement, so INSERT IGNORE dropped it and the capability was
never offered. 419 inserts by short name with whatever id is free, adds
FOG_CLIENT_SOFTWARE_ENABLED (and lists the module in
getGlobalModuleStatus), and re-applies the stReturnDetails and
sstDetails defaults for a server that ran 418 before they were added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Same entry the snapin result route has; without it the user-permission
layer answers 403 to every report the agent sends.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Two FOG Client settings, both empty by default, sent to the agent in
the software block as `bootstrap`: FOG_SOFTWARE_CHOCO_BOOTSTRAP_URL,
the install script the agent fetches and runs as SYSTEM on a host that
has software assigned and no Chocolatey, and FOG_SOFTWARE_CHOCO_NUPKG_URL,
the package it installs from for hosts with no route to the community
feed. Off by default because the fetched script runs as SYSTEM: an admin
opts in by naming it (fog-agent design 0003 section 8, agent c14b886).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
Capability `power` on module powermanagement (fog-agent design 0004). The
desired state carries the host's resolved shutdown and reboot schedules,
the way Client\PM hands them to the legacy client minus `wol`, which the
server keeps sending itself, plus the host's pending on-demand rows. The
agent acknowledges an on-demand action with a `power applied` result and
that report consumes the rows, where the legacy client consumed them on
read: a request the agent never received stays standing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
…Route::getIds

The software module (schema 419) is agent-only, but requestClientInfo()
mapped its short name to Items\Software through the default branch and
every legacy fog-client check-in fataled on the missing json().

_bootFileRow() called self::getIds(), which FOGPage does not have, so the
map stayed empty and every host and group page re-read and re-hashed every
boot file. Same fix as working-1.6 is shipping: Route::getIds('bootfile',
false), and the baseline count for that call shape goes to 2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01111oNkpZ7ZAXWZDVGmM4Yh
mastacontrola and others added 16 commits September 4, 2026 16:11
Every item report on /agent/v1/result carries the provider's output in
`details` -- a snapin's tail, a package manager's log -- and a printer's
failure message is the same thing: lpadmin's own words. PrinterSet was
reading `error`, a spelling nothing else on the route uses.

Caught wiring the agent half up to it: the first real end-to-end run
posted `details` and the message went nowhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
`Client\HostnameChanger::json()` puts ADUser and ADPass in the answer to
every check-in of every host with useAD set -- joined or not, forever. A
joined estate is one where every machine permanently holds a credential
that can create computer objects in the directory, for no reason: it is
already joined.

`FOG\Agent\DirectoryJoin` sends it only to a host the server believes is
unjoined, only while that is true, and not again for an hour after an
attempt. Null is returned -- no block at all -- when the host is not set
to use AD, names no domain, has never reported its membership (the server
does not know, and a credential is not something to send on a guess), is
already in the right domain, is joined to a different one (the agent
would refuse, so sending it exposes the account for nothing), or is
cooling.

The cooldown is not politeness. A join that fails on a bad password is a
failed authentication against a domain controller, and one per host per
poll is how a service account with a lockout policy gets locked out,
taking every other host's join with it.

The result rides the ITEM half of /agent/v1/result rather than the plain
one, because the join has its own vocabulary -- joined, already_joined,
failed, unsupported, refused -- and `failed` means two different things in
the two places. The item is the host's own membership row, addressed by
host id, and a host reporting on somebody else's gets a 404.

hostDirectory gains hdJoinAt and hdJoinError so an outcome has somewhere
to live; FOG has never recorded a join result at all. Both surface in the
Directory Membership report's new Join column, and a settled status
clears a stale error -- an admin chasing a message against a machine that
is now joined is worse off than one chasing none.

DirectoryPlacement::decodeStored() is made public rather than copying the
three-shape base64 dance a third time. The legacy client's copy keeps its
non-strict-base64 bug: changing what the legacy client is sent is a
separate and riskier change.

Proved on the live install by prove_directory_join.php -- 20 checks
through State::desired() and State::result(), including that turning the
hostnamechanger module off stops the credential entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
A magic packet is a link-layer broadcast, so FOG can only send one from a
machine it owns. `FOGBase::wakeUp()` already fans out to every enabled,
online storage node, which covers every link FOG has a machine on -- and
in a routed estate a subnet routinely has FOG hosts on it and no FOG
server or node at all. The documented answer, a directed broadcast, has
been off by default on enterprise routers since the smurf attack, and
asking a security team to re-enable it is asking them to undo a decision
that was right.

The sender that was always there is a machine already ON that link,
already awake, already authenticated. `FOG\Agent\WakeRelay` finds it and
asks it, ADDITIONAL to the node fan-out rather than instead of it.

Two controls, because a magic packet is unauthenticated by construction:
the server picks both ends -- a target is a row in `hosts` and its MACs
are that host's own rows, so there is no path from an arbitrary MAC to
the wire -- and the agent is never told WHERE to send. The block carries
host ids and MACs and no destination at all; the agent broadcasts on its
own interfaces. An agent that accepted a destination would be a UDP
reflector for whoever could feed it one.

Finding the neighbor needed a fact FOG has never held: a host's
interfaces. hostIP is one address with no prefix and no interface behind
it, so "which machines share a link with host 41" has not been a question
this server could answer. `hostNetwork` records what the machine reports
about its own links and stores the network address alongside the prefix,
which makes that an index lookup rather than a scan. The server
recomputes both from the address and prefix and discards what the agent
sent -- a host that could claim a network it is not on could join any
link's relay group it liked.

A candidate sender is on the same network AND prefix, has the interface
up and running, has a broadcast address at all, is not wireless (an
access point will not bridge a broadcast to a station that is asleep and
so not associated), has checked in recently enough to be awake, and is
not the target. Three are asked, because one datagram costs nothing and
the alternative is a wake that does nothing because the single sender
went to sleep. Requests expire, so a wake is never a standing
instruction.

The result is the first time FOG can say anything about whether a wake
happened -- the existing path is fire and forget, and a machine that
stayed asleep is indistinguishable from a packet that never left the
building. The pending row is also the authorization: this is the only
item report whose id is another host's, and a result with no pending row
naming this sender and that target is a 404.

Pending MACs are excluded, the way `Group::wakeOnLAN()` already does and
`Host::wakeOnLAN()` does not. That is a deliberate behavior difference
confined to the new path; narrowing the old one is a separate change.

Off by default (FOG_AGENT_WAKE_RELAY_ENABLED): this asks one customer
machine to put traffic on the network for another.

Proved on the live install by prove_wake_relay.php -- 25 checks through
the real entry points -- and end to end by
prove_wake_relay_on_the_wire.php, which caught a real gap: four magic
packets went out and the row recorded packets=0, because the agent had no
field to put the count in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
hosts.hostSbState is written by iPXE on every PXE boot, which is the right
place for it -- iPXE runs whenever a machine netboots, where FOS runs only
when someone schedules a task. But a machine that boots its own disk never
netboots, so its value is frozen at whatever it said the last time anybody
imaged it, and the staleness runs in the dangerous direction: `disabled` is
the value that makes a host look like a valid enrollment target, and it is
exactly what a machine leaves behind on the last netboot before it starts
enforcing. Measured on host 105 telliottwin11, which the ledger called
`disabled` while Confirm-SecureBootUEFI answered True.

A second reporter for the same column, not a second vocabulary. The agent
sends the same three raw values iPXE sends -- platform, the SecureBoot byte,
the SetupMode byte -- and SecureBootFacts maps them with
SecureBootState::fromBootRequest(), the very call the boot path uses. Letting
the agent send a computed name would put the six-way mapping in two codebases
in two languages, which is the drift the vocabulary was copied verbatim from
FOS's sbState() to avoid.

A registry entry and a block in the poll, never a route of its own (the route
rule). facts() dispatches FACT_REPORTS generically and answers want_secureboot
on its own, and hfsKind is varchar(16), so nothing else had to change.

Still advisory (ADR 0029). An agent report arrives over an enrolled mTLS
channel, so the server knows whose certificate asserted it -- that is
attribution, not trust. A compromised OS can lie about its own firmware.

Two things the tests caught before this shipped:

- HostManager::update() takes item PROPERTY names. perform_update() looks
  each key up in Host::$databaseFields, so 'hostSbState' resolved to nothing
  and built an UPDATE with an empty column. It wants 'sbstate'.
- The first version of the test read the SQL for the string "hostSbState" and
  passed against exactly that bug, because the bind placeholder is named
  :update_<field> from the property name. It now reads the column, and
  "writes nothing" means no UPDATE at all rather than no RECOGNIZED state --
  the earlier form let an UNKNOWN write through, which is the one write the
  guard exists to prevent.

Five mutants killed: the column name, the UNKNOWN guard, collapsing an absent
key to '', ignoring setup_mode, and a client-supplied timestamp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
The host form showed "Last Client Check-In" -- hostLastCheckin, written by
the legacy FOG Client. fog-agent writes a different column, hostAgentCheckin,
on every poll in Route::agentPoll(), and nothing rendered it anywhere. So a
host running only the agent showed "Never", or a real date from months ago,
for a machine that had polled a minute earlier. Two clients, two columns, one
of them displayed.

The agent replaces the legacy client, so the form now shows the agent's clock
and the legacy field is gone from it. hostLastCheckin itself is untouched:
the legacy client still writes it and the host list still carries a column
for it, which is what a site running both during a migration needs.

agentCheckin joins host's serverOwnedFields. It belongs there for the reason
lastcheckin does -- a caller writing it asserts an event that did not happen
-- and for one more that lastcheckin does not have: WakeRelay picks which
hosts are fresh enough to relay a wake by this column, so a host able to
write its own heartbeat could nominate itself as a relay for a subnet it is
not on. The existing test checked a subset of the list by hand, so the new
entry was not covered until it got its own assertion; removing it from the
list now turns that assertion red.

Two test gates this tripped, both fixed rather than worked around:

- psr4-layout: SecureBootFacts had no home in bin/psr4-scan.php's TABLE.
  Added with the reason it is not called SecureBootState -- that name is the
  Boot class holding the six state names, and this only reports into that
  vocabulary.
- utc-storage-boundary: it counts dateOrNever() calls that name their table
  and column, and its "hinted" pattern accepted only a lowercase property
  name. 'agentCheckin' is camelCase in Host::$databaseFields, so a correctly
  hinted call was reported as unhinted. Widened to [A-Za-z]; dropping the
  hint from the new call still turns it red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
Found by reading the first real report on the lab server, not by a test:

  agent reported Secure Boot Secure Boot ON (was Secure Boot OFF)

SecureBootState::label() already spells the words where they belong, so the
sprintf prefix duplicated them. Dropping it also reads correctly for the
labels that do not carry the words -- "agent reported UEFI, state unreadable".

The 13 checks in this file all passed straight through that, because none of
them looked at the audit line -- the thing an admin actually reads. Four
checks now do: that a line is recorded, that it does not repeat itself, that
it names both ends of a transition, and that a FIRST report does not append
"(was Never reported)" to every newly enrolled host. Reintroducing either
defect turns them red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
The card carries what you want at a glance -- name, MAC, image, last deploy,
group -- and said nothing about whether the machine is still talking to FOG.
That answer was two tabs away even after the General tab started showing it.

One note, "Last Check-In", from whichever client last spoke, labeled so the
card is not ambiguous about which one: "2026-09-05 06:29:14 (agent)".

The agent wins when both columns are set, and deliberately not by comparing
dates: a host that has enrolled an agent is a host whose legacy check-in has
stopped moving, so the agent is the live signal even on the day it is
installed. Falling back to hostLastCheckin keeps the card useful for hosts
that have not migrated, which during a migration is most of them. A host
neither client has ever reached reads "Never" with no source named, because
naming one there would assert a client that was never installed.

Two literal dateOrNever() calls rather than one with a computed column name.
The column decides whether a date can predate the UTC boundary, and a ternary
hides that from both a reader and tests/utc-storage-boundary.test.php --
which caught it.

Verified against the three real cases on the lab server: host 105 and 239
(agent set) resolve to the agent's stamp, host 48 (neither) to Never.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
Eleven agent.* audit types are written today -- enroll, token, result,
inventory, software, printers, directory, directory.move, secureboot,
usersession, wake -- every one tagged subjectType `host` with the host's id.
Nothing could read them by host. AuditManagement lists the whole install in
one flat grid whose getList() takes no arguments, so "what has this machine's
agent been doing" meant scrolling past every other machine.

Two surfaces, one query behind both:

- A per-host tab on the host page, beside Task/Snapin History where the other
  per-host ledgers already live.
- A page under Logging listing one row per host -- hostname, event count,
  last activity, last event -- expanding to that host's rows on demand.

SUMMARY FIRST rather than a flat grid with group headers. DataTables'
rowGroup groups only within the current PAGE, and the audit grid is
serverSide, so one hostname would head a dozen separate pages. registerTable()
also auto-pages any table using rowGroup -- Scroller cannot reconcile injected
header rows -- which fog.audit.list.js already records as why the audit grid
does not group. And an agent writes a row per changed fact per host, so a flat
list is the one thing that grows without bound while a per-host summary is
bounded by the fleet. The grouping happens in SQL, which is also the only way
it is exact across the whole table rather than per page.

The summary joins auditLog back to itself on MAX(alID) to name the last event
type. GROUP_CONCAT(alType ORDER BY alID DESC) with SUBSTRING_INDEX would build
a string of every row's type per host to read the first one, and truncates
silently at group_concat_max_len. The join to `hosts` is LEFT: rows outlive
the host, and on the lab install most of them already have -- those render as
"(deleted host N)" rather than an empty cell that reads as a rendering fault.

Its own permission node, not an alias onto `audit`. ADR 0021 made audit.view
narrow because the audit log discloses attempted usernames and refusals; agent
rows are what a machine reported about itself. Aliasing would force anyone who
may see what an agent did to also see every failed sign-in in the install.
view only -- auditlog has no create, update or delete route (ADR 0021
Decision 8).

Both surfaces filter through one TYPE_PREFIX constant rather than listing the
type names, so a new fact kind stays what the route rule says it is: a
registry entry and a block in the poll, not a third place to remember.

Verified against the live lab: the summary returns 21 hosts, and the scoped
endpoint returns 79 rows for host 105 and 134 for host 239 -- the same counts
the aggregate computed by a different query. 98 audit rows exist for host 105,
so the type filter correctly excludes the 19 that are not the agent's. An
absent, zero, negative or injection-shaped id returns nothing rather than
falling through to every host.

Seven mutants killed: the scope guard, the shared constant, a hardcoded type,
the node alias, the menu group, rowGroup, and a dropped column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
Two changes to the same strip at the top of the host and group edit pages.

REMOVED: the host card's "Primary Group". It meant
minId($this->obj->get('groups')) -- the lowest-id group the host happened
to be in. There is no primary group. A group GRANTS, and what a host ends
up with is resolved from every group it belongs to at task time, ordered
by groupOrder (ADR 0038, FOG\Assign\Resolver). So the label named a rank
that does not exist and the value picked one membership arbitrarily.

Nothing replaces it. Listing every group instead was considered and
dropped: a host in eight groups blows the card out, which makes the card
worse rather than better, and the Group Associations tab already shows
them properly.

ADDED: Deploy and Capture on the host card, Deploy and Multi-Cast on the
group card. One click, from whichever tab is open, so an admin already
looking at a host does not have to go back to the grid and find the row
again to do the obvious thing to it.

This is not a new pattern. The host LIST has carried the same three
buttons since _quickTaskItems(), for the same reason: these are the task
types that need no options, which is the whole reason they can be one
click. The pairing is the one that method already documents -- Deploy and
Capture are what a single host wants, Deploy and Multi-Cast what a set of
them wants. On a group it is also what the server will accept:
GroupManagement::deployPost() throws "Groups cannot create capture tasks"
outright, so a Capture button there could only produce a toast saying no.

Every button confirms first, and this is the one place it deliberately
differs from the list. There you tick a row to get the buttons; here you
arrive on the page just by clicking a host name, so one stray click would
deploy over a running machine -- or, from a group, over all of them. The
text is built server side because it is translated, and it names the
target: the host, or the group AND its member count, which is the fact
that decides whether you meant to press it and the one thing the button
itself cannot show.

Mechanically:

- FOGPage::$noteActions, a pre-rendered string, echoed by
  renderInfoCard() in a right-aligned column. ms-auto, not a float: the
  row is display:flex and a float would do nothing there.
- It rides the existing EDIT_INFO_DATA hook alongside notes/noteSources,
  so a plugin adding a button does it the same way it already adds a
  line. A second event would mean two registrations for one card.
- FOGPageRender::renderQuickTaskActions() builds them, gated on
  {node}.task -- the action ?node=X&sub=deploy resolves to through
  Authorization::_subToAction(), so the gate here and the gate the POST
  hits are the same string by construction.
- The script posts straight to ?node={node}&sub=deploy with
  scheduleType=instant, skipping the options form. Nothing on it these
  types need, and everything its POST is checked for -- pending host,
  assigned and enabled image, protected image on a capture, one image
  across a multicast -- is checked in deployPost(), not in the form.
  scheduleType is the one field that must be sent: validateScheduleType()
  throws on an absent value rather than defaulting.
- Suppressed where the server would refuse anyway: a pending host, an
  empty group.

Neutral outline buttons rather than a type color. Nothing here is the
card's commit action -- the General tab's Update is -- and these are
shortcuts in a header strip, not a decision cluster in a form footer. The
weight a red button would carry is carried by the confirmation instead.

tests/info-card-quick-tasks.test.php pins the four things that fail
silently: the permission gate per node (host.task must not unlock the
group card), the confirmation being present AND naming the target, the
values being read through get() rather than as properties, and the pair
each page asks for. Each was verified by reintroducing the defect and
watching it go red.

FOG_BCACHE_VER 360 -> 361 for the changed script.

Co-Authored-By: Claude <noreply@anthropic.com>
Reported against host 105: the summary says 79 events, expanding shows 10,
and there is no pager and no scroll to reach the other 69.

Self-inflicted. The child table was written as a bare .DataTable(), which
opts out of every convention registerTable() applies -- including its `dom`,
which is where the pager lives, and its Scroller setup. It went through the
helper's defaults for nothing and got DataTables' instead.

Building it with registerTable() fixes the report and answers the wider
question at the same time: the child now carries the SAME infinite scroll as
every other grid in FOG. Collapsed grouping and infinite scroll were never
actually in tension -- the tension is between rowGroup and Scroller, and
grouping in SQL means there is no rowGroup to have it with. Nothing about
registerTable() needed changing to get both.

Reproduced and verified in a standalone harness driving the real
fog.common.js and DataTables against a stubbed endpoint, because the UI
itself is behind an identity provider I cannot sign in to. Before: 10 rows
loaded of 79, Scroller off, no scroll body. After: 79 of 79, Scroller on,
a 560px viewport over 1742px of content, scrolled to the bottom, last row
"event #79".

That harness also surfaced an uncaught TypeError from fog.common.js itself.
registerTable() sizes a Scroller table on a setTimeout(0) after init, and a
grid built into a node still being attached -- a DataTables child row is
exactly that -- reaches fogSizeScroller() before its wrapper is in the
document. container() is null there, `outer` falls back to it, and
getBoundingClientRect() threw out of the timeout. Now guarded, for the same
reason and in the same way the function already guards init(): there is
nothing to measure against yet, and whatever attaches the node re-runs the
sizing pass once it has. The error is gone from the harness console and the
sizing is unchanged.

Three mutants killed: the bare .DataTable(), and the null-container guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
btn-outline-secondary keeps #6c757d as the TEXT color. Against the dark
card (#212529) that is 3.29:1 -- under the 4.5:1 AA floor for body-sized
text. Filled puts white on #6c757d instead and holds 4.69:1 in both
themes.

Measured in a browser against the shipped adminlte4.min.css and
fog-default-ui.min.css, rendering the real renderInfoCard() output, not
assumed from the Bootstrap defaults.

It is also what the host list's own quick buttons already are, since
DataTables draws its button bar filled -- so the two places that offer
the same one-click tasking now look the same.

The test pins the class rather than the ratio: a browser is what measures
contrast and the suite is PHP, but the class is the part a future edit
would actually change.

Co-Authored-By: Claude <noreply@anthropic.com>
mastacontrola and others added 13 commits September 5, 2026 08:25
Both checks were red on #1707 before this branch existed, and #1712
inherited them. Neither is a code defect -- both are a baseline that the
change which moved it did not update.

phpstan pass 1: AgentActivityManagement's constructor takes $name and
ignores it, overwriting with _('Agent Activity'). That is the shape EVERY
page in src/Pages has -- TaskManagement, HostManagement, GroupManagement,
ReportManagement, all of them -- and every one of the others is already in
phpstan-baseline.neon under constructor.unusedParameter. The new page just
had no entry. Added one, in path order beside its neighbours. Changing the
constructor instead would make this page the only one in the directory
with a different signature, to satisfy a rule the other 27 are exempt from.

phpstan pass 2: 26 entries for the agent test files. Worth saying that CI
never reported these -- the job runs both passes as separate steps without
continue-on-error, so pass 1 exiting 1 meant pass 2 never ran. Fixing pass
1 is what makes them visible, and they would have turned the check red
again on the next push looking like a new regression.

They are the usual test-file shapes: PHPStan narrows a literal, so an
in_array() that pins a constant against its own list, or a ?? guarding an
offset it can prove exists, reads as "always true". phpstan-tests.neon's
own header says this pass "adapts to the tests as they are written, it does
not ask them to change", so they are baselined, not rewritten.

Generated with --generate-baseline into a temp file and MERGED, not
applied: a straight regeneration emits only the 26 unignored errors and
would have dropped the 196 entries already there. Paths rewritten
repo-relative -- the generator writes absolute ones, which would match
nothing on a CI checkout. Verified as a set: 196 in, 222 out, 0 lost, 26
added, no deletions in the diff.

upgrade rehearsal: 3 constraints landed that the fixture did not know
about -- fk_agentEnrollment_aeHostID, fk_agentWake_awSenderID and
fk_agentWake_awTargetID, all agent tables referencing hosts. So declared
96 -> 99 and present 94 -> 97, while MISSING stays at the same 2 named
constraints and the integrity block is untouched. Nothing regressed; the
count moved because the schema grew.

The test's own docblock asks for exactly this: "a change that legitimately
moves the numbers -- adding a constraint -- updates it in the SAME commit
and the diff is visible in review."

Reproduced locally first against a throwaway mariadb:11.8 container rather
than the lab server, got CI's diff byte for byte, confirmed the three new
constraints in information_schema, then updated the fixture and re-ran on
a second clean database to check it passes from scratch rather than only
on the one it was generated from.

Co-Authored-By: Claude <noreply@anthropic.com>
Info card: one-click Deploy/Capture, and no more "Primary Group"
The one-click Deploy/Capture/Multi-Cast buttons in the info card asked for
confirmation through window.confirm(). It works, and it looks like nothing
else in FOG: the browser dialog cannot be styled, ignores the dark theme,
and prefixes the page URL, so the one place the app asks before wiping a
machine is the one place that reads as though the site got something wrong.

renderQuickTaskActions() now emits a modal beside the buttons, the same
shape assocDelModal() uses -- which is what every other "are you sure" in
this app already looks like. One modal per card, not one per button: the
script fills its body from the clicked button's data-confirm, so two
buttons cannot drift into two wordings of the same question. The text is
still built server side and still translated.

A .modal is position:fixed and display:none until shown, so it contributes
nothing to the flex row it is emitted into.

In fog.common.js the click handler now records which button opened the
modal and the request moves to fire(), called from the modal's Create.
`pending` is cleared before the request, so a second click during the hide
animation has nothing left to commit, and the per-button in-flight lock is
unchanged. Both handlers share the click.fogQuickTask namespace so the
existing .off() still clears the pair on AJAX nav. The body is set with
.text(), never .html() -- data-confirm carries an admin-supplied host or
group name.

Measured in both themes with the real stylesheets: the buttons stay at
4.69:1, the modal's Cancel at 5.92:1 light / 11.85:1 dark, and its Create
(the modal-warning fill) at 5.14:1. All pass WCAG AA.

FOG_BCACHE_VER 361 -> 362, since fog.common.js changed.

tests/info-card-quick-tasks.test.php grows five checks covering the modal:
that it is emitted, that it is the only one, that the footer is a dismiss
plus a commit, that the body is a filled-in placeholder rather than static
text, and that no window.confirm() is left in the handler. Each was proven
by reintroducing the defect and watching it go red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWJMQYE2br8E7Ehr55SJp2
Confirm a quick task in a modal, not window.confirm()
The expand on this page never worked. Clicking a host's chevron rendered
DataTables Responsive's hidden-column list; the nested table it was meant
to open was never constructed. Measured on a live install at 1920px with
zero columns hidden, so it was not a narrow-viewport artifact.

A DataTables row has ONE child slot. registerTable() turns Responsive on
for every grid and Responsive claims that slot, so row.child() handed it a
table it then overwrote. Nothing threw -- which is why 7b207c2 could fix
"the expanded host was stuck at ten rows" by repairing a pager inside a
table that did not exist.

So there is no child row now. The page is one grid, grouped by host with
rowGroup: the header carries the host, its event count and the expand
control, and expanding adds that host's events to the same grid as ordinary
rows. No table nested in a table, no second scrollbar, no second pager.

Three things had to be true for that to hold, and each was found by
measuring rather than by reasoning:

- A group whose rows are all filtered out renders NO header, so collapsing
  by filtering would make every collapsed host vanish. Each host's newest
  event is therefore seeded into the table and never filtered. It anchors
  the header and doubles as the thing worth seeing when all is collapsed:
  what each agent last did.
- rowGroup starts a new group every time its dataSrc changes down the
  ORDERED rows. Ordering by time alone let one host's older events fall
  past the next host's newest and drew its header twice, so a hidden column
  sorts every row of a host on that host's last-activity time plus its id
  -- groups stay whole and stay ordered by recency rather than by name.
- listem()'s recordsTotal is every row in auditLog, not the host's. The cap
  notice now reads recordsFiltered; against recordsTotal it told a
  134-event host it was truncated at 500.

The flat event set stays unbounded (FOG_AUDIT_RETENTION_DAYS defaults to 0,
keep forever), which is why the seed is a summary query and each expansion
is capped, and why rowGroup over a serverSide grid was never an option.

Separately, and not specific to this page: a grid that says `select: false`
no longer gets Select All and Deselect All, and a page that says it is not
selectable no longer gets "Delete selected". That was decided by a
hardcoded list of node names, which this page was never added to -- so it
shipped a red Delete selected over a table with no delete route anywhere in
FOG (ADR 0021 Decision 8). FOGPage::$selectable replaces the list, and
registerTable() drops the two buttons, so both halves are stated where they
are enforced. The 33 tables already passing `select: false` stop showing
two enabled buttons that did nothing.

tests/agent-activity-grouping.test.php covers all of it, and each of its
eight gates was proven by reintroducing the defect and watching it go red.
It strips comment lines before scanning, because the first run failed on
row.child() and on the old node list where both appear only inside the
comments saying never to go back to them.

tests/agent-activity-page.test.php had pinned the arrangement this replaces
-- "the grid does not use rowGroup" and the child table going through
registerTable. Those three checks now assert the opposite, and one of them
that no child table comes back by any route.

FOG_BCACHE_VER 362 -> 363.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWJMQYE2br8E7Ehr55SJp2
Agent Activity: group by host in the grid, and expand in it
Three faults, one root. Paging counts ROWS; this page's unit is HOSTS.

Expanding one host with 83 events at 25 rows a page filled pages one to
three with that host and pushed every other host onto page four. rowGroup
redraws a group's header on every page the group spans, so the same host
then appeared four times, each apparently expanded -- which is what it
looked like to the person reading it, and it is not a rowGroup bug. It is
what paging by row does when the thing grouped is larger than a page.

No page length fixes that, because the number of rows an expansion adds is
a property of the host and not of the setting. So paging is off. Collapsed,
the grid is one row per host; expanded, it gets longer and you scroll. The
seed is still bounded by MAX_HOSTS and each expansion by ROWS_PER_HOST, so
this is not "no limit". Scroller is not the alternative -- registerTable()
excludes any rowGroup table from it.

With paging gone the "entries per page" control has nothing to put in
itself, and rendered as an empty box beside its own label. Reported as
unreadable in both themes, which it was: there was nothing in it to read.
lengthChange: false removes the control rather than styling an empty one.

And the toolbar's Refresh is dt.clear().draw() + ajax.reload() -- it throws
the rows away and re-fetches the seed. The per-host maps survived that, so
a host still marked `loaded` was never re-fetched, its rows were gone, and
clicking its header did nothing at all. It read as the expander breaking
permanently after one press of Refresh. They now reset on xhr.dt, which
fires when the new seed lands.

Verified on the live install: 21 group headers collapsed and 21 expanded
with no duplicates, no host displaced, the length control and pager both
absent, and expand working again after a Refresh.

Three more gates in tests/agent-activity-grouping.test.php, each proven by
reintroducing the defect and watching it go red. FOG_BCACHE_VER 363 -> 364.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWJMQYE2br8E7Ehr55SJp2
Agent Activity: stop paging a grid whose unit is hosts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant