Mapped drives stopped reporting because the user-context path failed silently in three separate places, and none of them left a trace to read back. - Probe: launch via -EncodedCommand instead of staging a temp .ps1 and running powershell.exe -File. A missing or quarantined script file fails BEFORE any statement runs, so the script's own try/catch never sees it and the only symptom is exit -196608. - Spool: pin to the ShopFloor profile. Resolving it through WTSQueryUserToken is privileged, endpoint protection blocks it, and on failure the tray (writer) and service (reader) silently disagree about the path. - Spool: consume after a successful parse, so a stopped tray cannot keep re-reporting stale mapped drives as current. Unparseable files are left in place for the next cycle. - Logging: user-session components cannot write to Program Files or to the SYSTEM-owned ProgramData log directory, and the file sink degrades to a silent no-op when it cannot create its directory. Tray and probe now log to C:\Users\ShopFloor\ShopDB\Logs, installs transcript to ProgramData, and the sink records a SinkProblem instead of swallowing UnauthorizedAccessException. - Installer: provision the operator spool/log directories with ShopFloor:Modify before the tray first runs, and stop the transcript via trap so a throw cannot orphan exactly the failed install worth reading. - Upload cap 64 -> 128 MB: the self-contained tray publishes at ~69 MB and was rejected on SIZE, but the size check returns before the PE is parsed, so the operator only ever saw a version-detection failure. AGENT-CONTRACT.md gains a normative section defining the three security contexts, their log destinations, the fixed spool path and consume-on-read. Agent 1.8.0 -> 1.8.1, tray 1.1.0 -> 1.1.1 so the deployed build is identifiable from a log line.
91 lines
3.6 KiB
Python
91 lines
3.6 KiB
Python
"""Agent binary upload version gating: PE-fallback and precedence.
|
|
|
|
The upload route no longer rejects the version at raw input. A typed value is
|
|
only a hint; the dotted-numeric FILE version in the binary's PE resource is the
|
|
authority. The route falls back to it when the typed value is missing or not
|
|
dotted-numeric, and only refuses when neither yields a usable version.
|
|
|
|
Building a real PE with a RT_VERSION resource inline is impractical (see the
|
|
agentstore unit tests), so these API tests monkeypatch read_pe_file_version to
|
|
stand in for the parser. PE-magic bytes keep looks_like_pe happy so the request
|
|
reaches the version gate.
|
|
"""
|
|
|
|
import io
|
|
|
|
from plugins.endpoints.api import agentstore
|
|
from plugins.endpoints.api import agentupload
|
|
from shopdb.config import Config
|
|
|
|
UPLOAD_URL = '/api/endpoints/siteagent/binaries/{}'
|
|
|
|
_PE_BYTES = b'MZ' + b'\x00' * 128
|
|
|
|
|
|
def _upload(client, headers, monkeypatch, detected, typed=None):
|
|
monkeypatch.setattr(agentstore, 'read_pe_file_version',
|
|
lambda data: detected)
|
|
form = {'file': (io.BytesIO(_PE_BYTES), 'shopdbagent.exe')}
|
|
if typed is not None:
|
|
form['version'] = typed
|
|
return client.post(
|
|
UPLOAD_URL.format('agent'),
|
|
data=form,
|
|
content_type='multipart/form-data',
|
|
headers=headers)
|
|
|
|
|
|
def test_upload_falls_back_to_pe_version_when_typed_is_missing(
|
|
client, db, app, auth_headers, monkeypatch):
|
|
response = _upload(client, auth_headers, monkeypatch, '1.5.0.0')
|
|
assert response.status_code == 200, response.get_json()
|
|
payload = response.get_json()['data']
|
|
assert payload['current'] == '1.5.0.0'
|
|
assert any(v['version'] == '1.5.0.0' for v in payload['versions'])
|
|
|
|
|
|
def test_upload_falls_back_when_typed_is_not_dotted_numeric(
|
|
client, db, app, auth_headers, monkeypatch):
|
|
response = _upload(client, auth_headers, monkeypatch, '1.5.0.0',
|
|
typed='1.5.0+1a1bf0640f956634dada1b34840')
|
|
assert response.status_code == 200, response.get_json()
|
|
assert response.get_json()['data']['current'] == '1.5.0.0'
|
|
|
|
|
|
def test_upload_prefers_valid_typed_version_over_pe(
|
|
client, db, app, auth_headers, monkeypatch):
|
|
response = _upload(client, auth_headers, monkeypatch, '9.9.9.9',
|
|
typed='1.6.3')
|
|
assert response.status_code == 200, response.get_json()
|
|
assert response.get_json()['data']['current'] == '1.6.3'
|
|
|
|
|
|
def test_upload_rejects_when_neither_typed_nor_pe_is_usable(
|
|
client, db, app, auth_headers, monkeypatch):
|
|
response = _upload(client, auth_headers, monkeypatch, None)
|
|
assert response.status_code == 400
|
|
assert 'dotted-numeric' in response.get_json()['data']['error']['message']
|
|
|
|
|
|
def test_upload_cap_admits_a_self_contained_single_file_publish():
|
|
"""The tray publishes ~69 MB; the cap must clear a real binary.
|
|
|
|
This is the bug that presented as "upload does not detect version": the
|
|
size check returns before the PE is parsed, so an oversized file looks
|
|
like a parser failure to the operator. Pinned at 100 MB rather than the
|
|
observed 69 MB so runtime growth does not silently re-break it.
|
|
"""
|
|
assert agentupload._MAX_UPLOAD_BYTES >= 100 * 1024 * 1024
|
|
|
|
|
|
def test_request_cap_stays_above_the_agent_upload_cap():
|
|
"""MAX_CONTENT_LENGTH bounds the whole multipart envelope, not the file.
|
|
|
|
Setting the two equal rejects a file sitting exactly at the agent cap,
|
|
because the boundaries and form fields push the request over. They must
|
|
move together, with real headroom between them.
|
|
"""
|
|
envelope = Config.MAX_CONTENT_LENGTH
|
|
assert envelope > agentupload._MAX_UPLOAD_BYTES
|
|
assert envelope - agentupload._MAX_UPLOAD_BYTES >= 1024 * 1024
|