Skip to content

fog-agent: the server side — enrollment, the mTLS channel, nine capabilities, and five legacy modules removed - #1707

Merged
fog-workflows[bot] merged 127 commits into
working-1.6from
feat/agent-enroll
Sep 5, 2026
Merged

fog-agent: the server side — enrollment, the mTLS channel, nine capabilities, and five legacy modules removed#1707
fog-workflows[bot] merged 127 commits into
working-1.6from
feat/agent-enroll

Conversation

@mastacontrola

@mastacontrola mastacontrola commented Sep 3, 2026

Copy link
Copy Markdown
Member

Server side of fog-agent, the replacement for the legacy .NET FOG client. This branch has collected every fogproject-side commit for the agent work; it is now ready to land on working-1.6.

Nothing here changes what the legacy client does. The agent reaches the server on its own /agent/v1/* routes behind mutual TLS, and the legacy endpoints are untouched except where a module was removed outright (below).

Enrollment and the authenticated channel

POST /agent/v1/enroll is the only unauthenticated agent route. FOG\Agent\Enrollment matches SMBIOS identity and CSR key against existing hosts, pends anything unknown or rebinding for an admin, and auto-approves against a minted token or an active deploy task. fog-sign-node-cert agent issues a clientAuth-only leaf under a dedicated agent intermediate CA; the CN carries the host id, never a name.

Everything after that is mutual TLS. FOG\Agent\Principal re-verifies the presented certificate in PHP against the agent CA bundle and binds it to a host by SPKI fingerprint, and Route gates every /agent/v1/* path except enroll on that principal.

Route Purpose
POST /agent/v1/enroll enrol, unauthenticated
POST /agent/v1/poll desired state; records agent version and check-in
POST /agent/v1/renew an enrolled agent renews its own certificate over its own session
POST /agent/v1/result one item per snapin task or software entry
GET /agent/v1/payload/{capability}/{id} fetch a payload
GET /agent/enrollments, POST /agent/enrollment/{id}/{action} admin approval

The installer publishes the CA bundle and configures nginx and Apache for optional client verification. The Apache path is untested — the lab is nginx.

Capabilities

The poll answer carries the desired state for the capabilities a host's modules allow, and the agent reports what it supports. Nine are implemented on both sides: hostname, taskreboot, power, software, printers, directory, wake, snapin, autologout. Reporting surfaces — inventory, user tracking, secure boot facts, netboot arming — ride the same channel.

Each capability is gated on the existing per-host and per-group module switches, so an admin's current choices carry over untouched.

Modules removed

Five legacy client modules are gone, with their tables. Each was already inert: getGlobalModuleStatus() had stopped listing them, so ServiceModule::send() answered #!um to any legacy client that asked, and no service/ endpoint served them. What survived was stored opinion — a modules row and a per-host answer for every host.

Module Table dropped Replaced by
Display Manager hostScreenSettings nothing; the agent does not set resolutions
Green FOG greenFog Power Management
Client Updater clientUpdates the MSI at a stable URL plus a snapin filtered on the reported agent version
Directory Cleaner dirCleaner snapins, which return an exit code and output
User Cleanup userCleanup snapins

Auto Log Out is the one module the rebuild reimplements rather than drops, and it gains FOG_CLIENT_AUTOLOGOFF_WARN — seconds of warning before the log out, 0 for none. The warning is a message box in the user's own session, because the agent is a service and a service has had no visible desktop since Vista.

Data loss is limited and documented in the release notes: the per-host width, height and refresh from Display Manager, and any names an operator added to User Cleanup beyond FOG's own install seed. The other three tables are empty on any server that ran the modules, because 1.6 had already removed every page that wrote them.

Schema

Steps 416–433, FOG_SCHEMA 433. Historical steps are not edited — schema.php is a replay log, and step 326 set that precedent. Every dropped table is declared in the retired block of schema-expected.php with the reason.

Verified

  • CI: all ten jobs green, including the schema replay on MariaDB 10.5, MariaDB 11.8 and MySQL 8.0, and the upgrade rehearsal.
  • Suite 336 passed. bin/upgrade-rehearsal.php replays to the baseline with 0 differences.
  • Both PHPStan passes clean.
  • Lab: enrol → pending → approve → issue → poll with the certificate; the nine capabilities each exercised on a host; schema 433 applied to a live database and read back.
  • Lab, certificate renewal: fog-agent renew over the agent's own mTLS session issued a new serial and a new validity window for the same public key, the chain verified, hosts.hostAgentNotAfter matched, and an agent.enroll audit record was written. A CSR for a different key over the same client certificate was refused — 400 the request is not for the key this certificate proved.
  • Lab, token minting: a 2-use token auto-approved a fresh key immediately and decremented 2 → 1; a wrong token consumed nothing and went pending / rebind; the last use rebound the original key; the spent token (uses 0) then went pending rather than approving.
  • working-1.6 merged in and re-verified, so this is tested as it will land.

Known gaps

  • The Apache client-verification config is untested.
  • tests/certificate-table.test.php fails 2 of 33 assertions. This is inherited — it fails identically on working-1.6 at 48dc1c5cb, and is not caused by this branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7

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
fog-workflows Bot and others added 24 commits September 5, 2026 13:55
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
Two changes to the same surface, so they land together: they touch the
same module list, the same service-configuration tabs and the same schema
file, and splitting them would mean a commit that half-edits all three.

DISPLAY MANAGER IS REMOVED, AND ITS TABLE WITH IT.

The module reset a client's screen resolution to a fixed size at logoff
and at startup. That was reasonable for a lab in 2010 and it is the wrong
layer now: Windows has honored each monitor's own preferred mode by itself
for a decade, a fixed resolution pushed over the top of it is wrong on
every machine whose panel is not the size the setting names, and a laptop
that docks changes its answer twice a day. Nothing in the rebuilt agent
implements it and nothing will.

This is the greenfog removal (step 375) repeated with one difference:
Display Manager owns a table, and the table goes too. Step 431 deletes the
per-host module answers, the `modules` row and the four
FOG_CLIENT_DISPLAYMANAGER_* settings in that order, then drops
`hostScreenSettings` through Schema::dropTable() -- which is also what lets
tests/schema-retired-tables.test.php see the drop and account for the
table's absence from the manifest.

THE PER-HOST WIDTH, HEIGHT AND REFRESH ARE GONE AND NOT RECOVERABLE. That
was considered and chosen. Every consumer is removed in this commit -- the
client endpoint, the host card, the mass-edit field, Host::getDispVals()
and setDisp(), Group::setDisp(), Setting::setDisplay() -- so keeping the
table would mean keeping HostScreenSetting, its manager, its
`hostscreensetting` REST route, its Authorization mapping and its foreign
key alive to serve data nothing writes and nothing honors. Step 375 named
that failure: a setting that lies is worse than no setting, and an API
route reporting a resolution the fleet does not apply is a setting that
lies.

MassEdit::resolveComposite() goes with it, not as tidying. It existed for
exactly one field -- a resolution is three numbers written as one row --
and removing that field orphans it, along with the whole `composite`
concept in the host mass edit. The array-value guard in columnUpdates()
stays: it protects against a plugin naming `field` on a key whose posted
value is an array, which is still reachable.

AUTO LOG OUT IS KEPT, AND IS NOW AN AGENT CAPABILITY.

It is the one legacy module the rebuild reimplements rather than drops: a
machine left logged in at a desk holds a profile and, on a shared lab
machine, somebody else's turn, and nothing else in the stack answers that.
State::CAPABILITIES gains `autologout`, gated on the existing module
exactly as every other capability is, so an admin's per-host and per-group
choices carry over untouched. Host::getAlo() is unchanged and the block is
withheld below five minutes, so a policy under the floor CLEARS what the
agent stored rather than sitting there as a number nobody acts on.

FOG_CLIENT_AUTOLOGOFF_WARN is new (step 432, default 60): how long the
user is told first. It is edited on the existing Auto Log Out tab next to
the timeout it modifies, and 0 means log the user out with no warning.

FOG_CLIENT_AUTOLOGOFF_BGIMAGE is removed in the same step. It named the
background of the .NET client's countdown window, and there is no
countdown window: the agent is a service in session 0, which has had no
visible desktop since Vista, so it warns through WTSSendMessage. Nothing
has read the setting since the legacy client stopped shipping and no page
ever rendered it -- the FOG_PLUGINSYS_DIR and greenfog defect exactly.
Design 0014 in fog-agent has the rest.

THREE THINGS FOUND WHILE DOING IT.

FOG_SCHEMA was 430 and is now 432. tests/schema-gate.test.php caught this
and it was not cosmetic: the coarse gate is `mySchema < FOG_SCHEMA`, so
both new steps would have applied to NOBODY on any existing install, with
no error and no log line. Only a fresh install, which runs from 0, would
ever have seen them.

packages/web/vendor/composer/autoload_classmap.php was stale on this
branch -- it listed none of the FOG\Agent\* classes this branch added, nor
FOG\Base\SmbiosIdentity or FOG\Assign\Resolver. dump-autoload had to run
anyway to drop the deleted classes; the additions are that staleness
corrected, not churn.

phpstan-tests-baseline.neon needed three patterns updated, by hand rather
than regenerated. A baselined message spells out the whole inferred array
shape, so a ninth key in State::CAPABILITIES un-matches every entry naming
it. Regenerating the file moved forty-odd unrelated entries; three lines
are edited instead.

VERIFIED: sh tests/run-all.sh, both phpstan passes, php -l on every file.
certificate-table.test.php fails identically on working-1.6 at 48dc1c5
and is inherited, not from this.

NOT VERIFIED HERE: tests/schema-executes.test.php and
bin/upgrade-rehearsal.php both need a database user that may CREATE
DATABASE, which a FOG service account deliberately is not. Steps 431 and
432 have not been executed against a server by this commit; CI's schema
matrix is what does that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
…ed at is gone

Missed in the Display Manager removal. Dropping hostScreenSettings drops
its hssHostID -> hosts.hostID constraint, so the rehearsal's decade
profile now declares 98 applicable constraints and finds 96, not 99 and
97. MISSING stays 2: both are the seed-induced refusals the block below
already documents, and neither is this table.

The rehearsal itself replayed steps 431 and 432 against MariaDB 11.8
without complaint -- "every seeded row landed (no REFUSED)" passed and
only the count line differed. That is the database execution the parent
commit recorded as NOT VERIFIED HERE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
…st after

Found while proving the new autologout desired-state block on the lab. I
set host 239's auto logout to 10, read the block, set it to 3, and read
again -- and got 10 both times. Then I set it back to 0 and getAlo() still
said 10, while the database said 0.

self::$_hostalo was declared `= []` but assigned a scalar, and the guard
was `!empty()`. So the first host whose getAlo() runs in a request wins,
and every host read after it in that same request gets that host's number,
silently and with no error. The `!empty()` guard has a second face: a host
whose auto logout is legitimately 0 never caches at all, so it re-queries
on every read -- which is why single-host requests looked fine and hid
this.

It is now keyed by host id, guarded with array_key_exists so a real 0
caches, and setAlo() drops this host's entry so a save and a read in the
same request agree.

This matters more than it did yesterday. Until now the only readers were
one host per request -- the legacy client endpoint and the host edit page
-- so the bug was reachable but rarely reached. State::desired() reads
getAlo() for the agent, and anything that walks a list of hosts (a group
operation, the host mass edit, a REST list with the field serialized) hits
it directly: the whole page would report one host's auto logout time.

$_hostscreen had the same shape and went with Display Manager in the
parent commit. $_hostalo was the last one; there are no other
`!empty(self::$_...)` cache guards left in src/Items.

VERIFIED: same round trip re-run against the deployed fix now reports 10,
then withheld below the floor, then 0. sh tests/run-all.sh 335 passed, 1
failed (certificate-table, inherited). Both phpstan passes clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
… page

Found deploying: https://<server>/fog/management/index.php?node=service
returns 500 with "Call to undefined method
ServiceConfigurationPage::serviceSoftware()". Not from this branch's Auto
Log Out work -- php-fpm logged the same fatal at 08:25 this morning, hours
before any of today's deploys, and the failing name has nothing to do with
Display Manager.

The cause is the dispatcher in edit(): it walks the `modules` table,
builds `service` . ucfirst($shortName) and calls it with no check. Schema
seeds a `software` module row for the inventory work; its tab has not been
written yet. So one row in a database table fatals the page that
configures every other module -- Auto Log Out, Snapins, Printer Manager
and the rest all became unreachable because of a module unrelated to any
of them.

It now renders "This module has no settings to configure." instead of
fataling. That is honest about the state -- the module exists, it has no
settings surface -- and it is not a stub anyone has to remove: the moment
serviceSoftware() is written, method_exists() finds it and the real tab
renders with no change here.

Deliberately NOT added to the $notWhere exclusion list next to
clientupdater, dircleanup and usercleanup. Those three are excluded
because they are settled -- they will never have a tab. `software` is
mid-build, and putting it there would hide the module from an admin and
have to be undone by whoever finishes it.

editPost() needed nothing: its dispatch is an explicit switch with no
default, so an unhandled tab is already a no-op rather than a fatal.

VERIFIED: the page renders after the fix (it 500'd before). php -l,
phpstan clean, sh tests/run-all.sh 335 passed 1 failed (certificate-table,
inherited).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
Finishing my own gap, not somebody else's. Schema 419 seeded the
`software` module row and Agent\SoftwareSet reads three global settings
from it, but ServiceConfigurationPage never got a serviceSoftware() -- so
the page that configures every module 500'd on a call to a method that was
never written, and the only way to change a software setting was the raw
FOG Configuration page.

The tab carries the three settings SoftwareSet actually sends:

  Re-check Interval (FOG_SOFTWARE_DRIFT_INTERVAL). Seconds between a
  host's re-checks of a set that has not changed. 0 is meaningful and is
  labeled as such: the agent treats DriftInterval <= 0 as "only check when
  the assigned set changes" (cmd/fog-agent/main.go). The POST clamps
  negatives to 0 rather than refusing them, so the stored value and the
  agent's reading can never disagree.

  Chocolatey Install Script (FOG_SOFTWARE_CHOCO_BOOTSTRAP_URL) and
  Chocolatey Package Source (FOG_SOFTWARE_CHOCO_NUPKG_URL). Empty
  bootstrap means never install Chocolatey, which is what SoftwareSet
  already documents; the second is for a mirrored or air-gapped install.
  Both are trimmed on save because SoftwareSet trims them on send, so a
  pasted trailing space cannot change the value out from under the admin.

FOG_SOFTWARE_DRIFT_INTERVAL is registered numeric in _settingsMeta() as
well -- that map is the shared source of truth for validating and
rendering the same setting on the FOG Configuration page, and it was
missing.

TWO THINGS FOUND WHILE DOING IT.

The Update button on a new tab does nothing until fog.service.list.js
names it. That file holds an explicit button/form registry and there is no
fallback, so the first version of this tab rendered perfectly and silently
discarded every save -- I only caught it by reading the row back out of
the database instead of trusting the success toast.

Printer Manager and Power Management both passed 'pm' as their id prefix.
Every tab renders into one document, so both emitted id="ispmEnabled", and
a <label for> binds to the first match -- clicking Power Management's
"Module Enabled" label toggled Printer Manager's checkbox. Power
Management is 'pwm' now; nothing outside this file referenced either id.
The page now has zero duplicate ids, checked in the rendered DOM.

VERIFIED on the deployed lab: the tab renders all three fields prefilled
from the database, saving 7200 and a nupkg URL through the form put both
in globalSettings, and the lab values are restored. php -l, both phpstan
passes clean, sh tests/run-all.sh 335 passed 1 failed (certificate-table,
inherited).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
The Software tab shipped rendering perfectly, reporting success, and
throwing every save away, because fog.service.list.js holds an explicit
button/form registry with no fallback and nothing had added `software` to
it. Nothing at runtime notices: no log line, no error, and the toast says
it worked. I only caught it by reading the row back out of the database.

Global Module Settings is built from four places that have to agree --
service<Name>(), service<Name>Post(), editPost()'s switch case, and that JS
registry -- and this holds all four to each other. The tab list is read off
the class rather than hard-coded, so a tab added later is held to the
contract without touching this file.

It also checks that the id prefix each tab hands _renderModuleTab() is
unique, which is the second defect the Software work turned up: every tab
renders into one document, Printer Manager and Power Management both
passed 'pm', so both emitted id="ispmEnabled" and Power Management's
"Module Enabled" label toggled Printer Manager's checkbox.

MADE TO FAIL, all three, before being trusted:

  drop `software` from the JS registry
    -> "fog.service.list.js posts #software-update, so the button is not
        dead"
  put power management back on 'pm'
    -> "the id prefix 'pm' (powermanagement) is not already used by
        printermanager"
  drop `case 'service-software'` from editPost()
    -> "editPost() routes service-software to its Post method"

Each names the actual problem rather than reporting a missing string, and
the tree was restored from a copy after each.

38 checks, 336 passed in the suite (certificate-table still fails and is
still inherited).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
Directory Cleaner, User Cleanup, Client Updater and Green FOG. None is
reimplemented in the rebuilt agent and none will be, so this is step 375
and step 431 a third time: stop storing a fleet's worth of opinion about
modules that cannot run.

THEY WERE ALREADY INERT, WHICH IS THE POINT.

getGlobalModuleStatus() has not listed any of them for some time. So
ServiceModule::send() answered `#!um` to a legacy client that asked for
one, no packages/web/service/ endpoint serves them, and their
FOG_CLIENT_*_ENABLED settings rendered as checkboxes on the FOG
Configuration page that nothing read. What survived was rows: a `modules`
row apiece and 86 per-host answers each on the lab -- 258 stored decisions
about three modules that had no code behind them.

Every list in the UI that mentioned them was filtering them back OUT of a
set they were no longer in. $notWhere in HostManagement, GroupManagement
and ServiceConfigurationPage, $igMods in FOGPage, the `dircleaner` ->
`dircleanup` aliases in ServiceModule and FOGClient: all dead, all gone,
and the array_diff wrappers around them collapse. $remArr in ServiceModule
was the exception and the reason the schema step comes first -- it filtered
these three out of $hostModules, which reads the `modules` TABLE, so it
was live until step 433 deleted the rows.

WHY NOT REIMPLEMENT THEM.

Green FOG is Power Management (design 0004), built and proven. Client
Updater served .NET binaries so the old service could replace itself; the
rebuild updates through the MSI at a stable URL plus a snapin filtered on
the agentVersion each host already reports, which is a decision already
taken against a self-replacing updater -- so it is a closed question, not a
gap. Directory Cleaner deleted the contents of paths a row named and User
Cleanup deleted the profiles a row named; both are what a snapin does
today, and a snapin returns an exit code and its output where these
returned nothing an admin could read. Building either as a capability
means designing a fleet-wide deletion primitive and proving its blast
radius, which is a design, not a port.

NOTHING RECOVERABLE IS LOST, AND I CHECKED RATHER THAN ASSUMED.

dirCleaner, clientUpdates and greenFog are empty on the lab and on any
server that ran them, because 1.6 had already removed every page that
wrote them. userCleanup holds six rows and they are the seed schema.php:572
inserts on every install -- admin, guest, administrator, HelpAssistant,
ASPNET, SUPPORT_ -- not operator data. An install that added its own names
loses those names; that is the same trade step 431 took and the release
notes say so.

GREEN FOG'S TABLE WAS STILL THERE. Step 375 removed its module, its
settings and its per-host rows in 1.6 and left `greenFog` standing, still
declared in schema-expected.php and still carrying an enforced foreign key
to hosts. Step 433 finishes it, and the FK map, the rehearsal seed and the
two ADR counts come down with it: 123 of 138 declared is now 122 of 137.

FOG_CLIENT_AUTOUPDATE is deliberately KEPT. It looks like Client Updater's
and is not -- service/getversion.php reads it on every legacy client
version check, and that endpoint still serves.

VERIFIED: sh tests/run-all.sh 336 passed, 1 failed (certificate-table,
inherited -- it fails identically on working-1.6 at 48dc1c5). Both
phpstan passes clean. php -l on every touched file. A residue grep across
src/, service/, management/js/ and text.php returns nothing.

NOT VERIFIED HERE: step 433 has not run against a database. The live
migration needs an approval I do not have, and the local FOG account
cannot CREATE DATABASE, so CI's schema matrix and upgrade rehearsal are
what execute it -- as they did for 431 and 432, where the rehearsal caught
a foreign-key count I had missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
Dropping the greenFog table drops its gfHostID -> hosts constraint, so the
decade profile now declares 97 applicable constraints and finds 95, not 98
and 96. MISSING stays 2 and both are the seed-induced refusals the block
below documents.

Second time I have missed this file for the same class of change -- step
431 dropped hostScreenSettings and moved 99/97 to 98/96, and CI caught
that one too. tests/foreign-key-map.test.php checks the two counts in ADR
0031 and foreign-keys.md automatically and told me about both; this
fixture is a golden file that only bin/upgrade-rehearsal.php can produce,
and that script needs a database user allowed to CREATE DATABASE. The FOG
service account deliberately is not one, and root on this box has no
socket auth, so the rehearsal cannot run here at all -- which is why CI is
the first thing to see it, both times.

Step 433 itself replayed clean on MariaDB 10.5, MariaDB 11.8 and MySQL
8.0; only the count line differed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7
@mastacontrola mastacontrola changed the title fog-agent: enrollment and authenticated channel (server side) fog-agent: the server side — enrollment, the mTLS channel, nine capabilities, and five legacy modules removed Sep 5, 2026
@mastacontrola
mastacontrola marked this pull request as ready for review September 5, 2026 21:01
@fog-workflows
fog-workflows Bot added this pull request to the merge queue Sep 5, 2026
Merged via the queue into working-1.6 with commit e82419c Sep 5, 2026
11 checks passed
@fog-workflows
fog-workflows Bot deleted the feat/agent-enroll branch September 5, 2026 21:05
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