fog-agent: the server side — enrollment, the mTLS channel, nine capabilities, and five legacy modules removed - #1707
Merged
Merged
Conversation
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
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
marked this pull request as ready for review
September 5, 2026 21:01
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/enrollis the only unauthenticated agent route.FOG\Agent\Enrollmentmatches 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 agentissues 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\Principalre-verifies the presented certificate in PHP against the agent CA bundle and binds it to a host by SPKI fingerprint, andRoutegates every/agent/v1/*path except enroll on that principal.POST /agent/v1/enrollPOST /agent/v1/pollPOST /agent/v1/renewPOST /agent/v1/resultGET /agent/v1/payload/{capability}/{id}GET /agent/enrollments,POST /agent/enrollment/{id}/{action}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, soServiceModule::send()answered#!umto any legacy client that asked, and noservice/endpoint served them. What survived was stored opinion — amodulesrow and a per-host answer for every host.hostScreenSettingsgreenFogclientUpdatesdirCleaneruserCleanupAuto 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_SCHEMA433. Historical steps are not edited —schema.phpis a replay log, and step 326 set that precedent. Every dropped table is declared in theretiredblock ofschema-expected.phpwith the reason.Verified
bin/upgrade-rehearsal.phpreplays to the baseline with 0 differences.fog-agent renewover the agent's own mTLS session issued a new serial and a new validity window for the same public key, the chain verified,hosts.hostAgentNotAftermatched, and anagent.enrollaudit 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.pending / rebind; the last use rebound the original key; the spent token (uses 0) then went pending rather than approving.working-1.6merged in and re-verified, so this is tested as it will land.Known gaps
tests/certificate-table.test.phpfails 2 of 33 assertions. This is inherited — it fails identically onworking-1.6at48dc1c5cb, and is not caused by this branch.🤖 Generated with Claude Code
https://claude.ai/code/session_01Ft1sYpi27EW7g798fkkWR7