Initial project snapshot
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
# RDP Worker
|
||||
|
||||
Active C++ RDP Adapter worker for the Remote Access Platform.
|
||||
|
||||
Current test Docker deployment:
|
||||
|
||||
```text
|
||||
rap-rdp-worker:stage5-2-download
|
||||
```
|
||||
|
||||
This image is deployed on the canonical test Docker host
|
||||
`docker-test` / `192.168.200.61` for Stage 5.2 proof. The accepted
|
||||
ordered-region RDP path remains protected; the current focus is finishing
|
||||
server-to-client file download desktop UI proof, not new platform expansion.
|
||||
|
||||
## Scope
|
||||
|
||||
- standalone C++ worker service
|
||||
- Redis-backed worker registration, heartbeat, assignment consumption, lease renewal, and worker event publishing
|
||||
- FreeRDP-based server-side RDP connection runtime
|
||||
- persistent remote session process that stays connected when the client detaches
|
||||
- reattach and takeover-aware assignment updates without recreating the server-side RDP session
|
||||
- terminate and failure reporting back into the backend control plane
|
||||
|
||||
## Reproducible build environments
|
||||
|
||||
### Devcontainer
|
||||
|
||||
The repository now includes a worker-focused devcontainer in [`.devcontainer/devcontainer.json`](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\.devcontainer\devcontainer.json) and [`.devcontainer/Dockerfile`](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\.devcontainer\Dockerfile).
|
||||
|
||||
Environment assumptions:
|
||||
|
||||
- Debian `12.10-slim`
|
||||
- CMake from Debian 12 packages
|
||||
- Ninja from Debian 12 packages
|
||||
- GCC/G++ from `build-essential`
|
||||
- FreeRDP headers/libs from `freerdp2-dev`
|
||||
- WinPR headers/libs from `libwinpr2-dev`
|
||||
- no extra Redis client library is required because the worker uses its own socket-based RESP client
|
||||
|
||||
The devcontainer runs:
|
||||
|
||||
```sh
|
||||
cmake --preset dev --directory workers/rdp-worker
|
||||
```
|
||||
|
||||
after creation, so configure success is part of the expected environment contract.
|
||||
|
||||
### Docker image build
|
||||
|
||||
The worker Docker build is defined in [Dockerfile](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\workers\rdp-worker\Dockerfile).
|
||||
|
||||
Environment assumptions:
|
||||
|
||||
- Debian `12.10-slim`
|
||||
- `build-essential`
|
||||
- `cmake`
|
||||
- `ninja-build`
|
||||
- `pkg-config`
|
||||
- `freerdp2-dev`
|
||||
- `libwinpr2-dev`
|
||||
|
||||
The image uses [CMakePresets.json](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\workers\rdp-worker\CMakePresets.json) and installs the final binary to the deterministic path:
|
||||
|
||||
```text
|
||||
/usr/local/bin/rdp-worker
|
||||
```
|
||||
|
||||
## Exact build commands
|
||||
|
||||
### Local native build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```powershell
|
||||
cmake --preset dev --directory workers/rdp-worker
|
||||
cmake --build --preset dev --directory workers/rdp-worker
|
||||
```
|
||||
|
||||
Expected binary path:
|
||||
|
||||
```text
|
||||
workers/rdp-worker/build/rdp-worker
|
||||
```
|
||||
|
||||
### Docker build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```powershell
|
||||
docker build --tag rap-rdp-worker:dev --file workers/rdp-worker/Dockerfile workers/rdp-worker
|
||||
docker run --rm --entrypoint /bin/sh rap-rdp-worker:dev -lc "test -x /usr/local/bin/rdp-worker"
|
||||
```
|
||||
|
||||
Expected result:
|
||||
|
||||
- `docker build` completes successfully
|
||||
- the second command exits with code `0`
|
||||
- the runtime binary path inside the image is `/usr/local/bin/rdp-worker`
|
||||
|
||||
## Runtime configuration
|
||||
|
||||
Environment variables:
|
||||
|
||||
- `RDP_WORKER_ID`
|
||||
- `RDP_WORKER_REDIS_HOST`
|
||||
- `RDP_WORKER_REDIS_PORT`
|
||||
- `RDP_WORKER_REDIS_PASSWORD`
|
||||
- `RDP_WORKER_REDIS_DB`
|
||||
- `RDP_WORKER_HEARTBEAT_INTERVAL_SECONDS`
|
||||
- `RDP_WORKER_LEASE_RENEW_INTERVAL_SECONDS`
|
||||
- `RDP_WORKER_ASSIGNMENT_POLL_INTERVAL_SECONDS`
|
||||
- `RDP_WORKER_CAPABILITIES`
|
||||
- `RDP_WORKER_INSECURE_SKIP_VERIFY`
|
||||
- `RDP_WORKER_DATA_PLANE_ENABLED`
|
||||
- `RDP_WORKER_DATA_PLANE_LISTEN_HOST`
|
||||
- `RDP_WORKER_DATA_PLANE_LISTEN_PORT`
|
||||
- `RDP_WORKER_DATA_PLANE_PUBLIC_KEY_FILE`
|
||||
- `RDP_WORKER_DATA_PLANE_PUBLIC_KEY_PEM`
|
||||
- `RDP_WORKER_DATA_PLANE_TLS_CERT_FILE`
|
||||
- `RDP_WORKER_DATA_PLANE_TLS_KEY_FILE`
|
||||
- `RDP_WORKER_RDPGFX_ENABLED`
|
||||
|
||||
## Data Plane v1 Direct WSS Boundary
|
||||
|
||||
Stage DP-1C adds an optional direct worker WSS endpoint at
|
||||
`/rap/v1/data-plane`. It is disabled by default and does not change Windows
|
||||
client behavior by itself; the existing backend gateway remains the active
|
||||
fallback path unless the backend advertises a data-capable direct candidate.
|
||||
|
||||
When enabled, the endpoint:
|
||||
|
||||
- requires TLS certificate and private key files
|
||||
- validates the backend-issued RS256 `data_plane_token` with a public key
|
||||
- verifies token scope for session, attachment, user, organization, worker,
|
||||
resource, allowed channels, expiry, audience, and `jti`
|
||||
- binds only to an already-running `SessionRuntime`
|
||||
- rejects token `jti` replay with a bounded in-memory TTL cache
|
||||
- rejects missing runtime, wrong worker, expired token, invalid signature, and
|
||||
malformed token cases without creating a new RDP session
|
||||
- accepts the existing JSON realtime envelopes for `input`, `control`,
|
||||
`clipboard`, and `file_upload`
|
||||
- emits the existing JSON session state, render frame, clipboard, and file
|
||||
upload progress events
|
||||
- emits `session.frame` as DP-2 binary WebSocket frames when the client
|
||||
requests `render_transport=binary_v1`; JSON/base64 render remains available
|
||||
for backend gateway fallback/debug compatibility
|
||||
- drains direct input before Redis fallback input, keeps the direct inbound
|
||||
queue bounded, and coalesces stale mouse moves
|
||||
- keeps render latest-frame-only and droppable on the direct WSS writer
|
||||
- tags direct inbound envelopes with token-bound session/attachment/user/org
|
||||
claims so old attachments after takeover cannot keep controlling the runtime
|
||||
|
||||
Required environment:
|
||||
|
||||
```text
|
||||
RDP_WORKER_DATA_PLANE_ENABLED=true
|
||||
RDP_WORKER_DATA_PLANE_LISTEN_HOST=0.0.0.0
|
||||
RDP_WORKER_DATA_PLANE_LISTEN_PORT=8443
|
||||
RDP_WORKER_DATA_PLANE_PUBLIC_KEY_FILE=/path/to/data-plane-public.pem
|
||||
RDP_WORKER_DATA_PLANE_TLS_CERT_FILE=/path/to/cert.pem
|
||||
RDP_WORKER_DATA_PLANE_TLS_KEY_FILE=/path/to/key.pem
|
||||
```
|
||||
|
||||
Production direct worker WSS trust is coordinated by the backend candidate
|
||||
metadata described in `docs/architecture/DIRECT_WORKER_TLS_PKI.md`.
|
||||
|
||||
- Smoke/dev can use self-signed certificates only when the backend advertises
|
||||
the candidate as `smoke_only=true` and the Windows client has the explicit
|
||||
smoke override enabled.
|
||||
- Production workers must use certificates valid for their advertised direct
|
||||
WSS hostname/IP subject alternative names.
|
||||
- Production direct candidates should be advertised by the backend only with
|
||||
`DATA_PLANE_DIRECT_WORKER_TLS_TRUST_MODE=public_ca` or `platform_ca`.
|
||||
|
||||
The image also installs a token validation smoke helper:
|
||||
|
||||
```sh
|
||||
rdp-worker-dataplane-token-probe --token <jwt> --public-key-file <public.pem> --worker-id <worker_id>
|
||||
rdp-worker-dataplane-bind-probe --scenario valid
|
||||
rdp-worker-dataplane-bind-probe --scenario wrong-attachment
|
||||
rdp-worker-dataplane-bind-probe --scenario channels-too-broad
|
||||
```
|
||||
|
||||
Run the endpoint validation smoke from the repository root after building
|
||||
`rap-rdp-worker:dp1c-hardened` on the test Docker host:
|
||||
|
||||
```powershell
|
||||
pwsh -ExecutionPolicy Bypass -File scripts/smoke/data-plane-v1c-smoke.ps1
|
||||
```
|
||||
|
||||
Stage DP-2.1 removes the internal base64 encode/decode hop from the direct
|
||||
render path. FreeRDP frame capture now carries raw BGRA bytes through the worker
|
||||
runtime to the direct binary render sink, and direct WSS sends those bytes as
|
||||
`RAP2` binary WebSocket frames. Compatibility JSON/base64 `session_frame` events
|
||||
are still generated for the backend-gateway fallback boundary only. Backend
|
||||
gateway fallback remains supported and must stay enabled.
|
||||
|
||||
Stage DP-3A adds direct binary color mode selection. `full_color` sends the raw
|
||||
BGRA frame unchanged. `grayscale` converts the direct binary frame to grayscale
|
||||
inside the worker direct render sink, preserves BGRA32 output, and does not
|
||||
modify the backend-gateway JSON/base64 fallback path. Direct frame diagnostics
|
||||
include `requested_color_mode`, `applied_color_mode`,
|
||||
`grayscale_conversion_applied`, `raw_frame_bytes_before`,
|
||||
`raw_frame_bytes_after`, `binary_direct_bytes`, and `conversion_time_ms`.
|
||||
|
||||
## RDPGFX Gated Smoke Mode
|
||||
|
||||
`RDP_WORKER_RDPGFX_ENABLED=true` enables an experimental RDPGFX advertisement
|
||||
path for target compatibility testing only.
|
||||
|
||||
Default behavior:
|
||||
|
||||
- RDPGFX is disabled.
|
||||
- Classic GDI region-first rendering remains the safe production/dev path.
|
||||
- Direct binary WSS and backend gateway fallback continue to work without this
|
||||
flag.
|
||||
|
||||
When the flag is enabled, the worker logs:
|
||||
|
||||
- `rdp.gfx config requested=true`
|
||||
- `rdp.gfx channel_subscription ...`
|
||||
- `rdp.gfx channel_connected ...` if the target opens the RDPGFX channel
|
||||
- `rdp.gfx pipeline_init_success` or `rdp.gfx pipeline_init_failed`
|
||||
- `rdp.gfx surface_event ...` when FreeRDP surface callbacks are available
|
||||
- RDPGFX counters in `rdp.perf callback_summary`
|
||||
|
||||
The current live smoke target resets the connection when RDPGFX is advertised,
|
||||
so this flag must not be enabled for normal operation. See
|
||||
`artifacts/rdp-perf4-report.md`.
|
||||
|
||||
## Cursor Adapter Boundary
|
||||
|
||||
RDP-A4 adds a dedicated cursor boundary inside the C++ RDP Adapter.
|
||||
|
||||
The worker installs FreeRDP pointer callbacks and normalizes them into
|
||||
`session_cursor_updated` events. Direct worker WSS exposes those events as
|
||||
`cursor.update` envelopes. Cursor is scheduled as latest-only/droppable and does
|
||||
not wait behind binary display frames. Backend gateway fallback remains
|
||||
compatible with the same worker event payload.
|
||||
|
||||
The current implementation publishes cursor metadata:
|
||||
|
||||
- update kind
|
||||
- sequence
|
||||
- desktop width/height
|
||||
- x/y position
|
||||
- visibility
|
||||
- shape changed flag
|
||||
- cache index
|
||||
- hotspot
|
||||
- cursor width/height
|
||||
- XOR bpp
|
||||
- mask byte size
|
||||
- system cursor type
|
||||
|
||||
Smoke-proven image:
|
||||
|
||||
```text
|
||||
rap-rdp-worker:rdp-a4-cursor-adapter
|
||||
```
|
||||
|
||||
Probe:
|
||||
|
||||
```powershell
|
||||
docker -H ssh://docker-test run --rm rap-rdp-worker:rdp-a4-cursor-adapter /usr/local/bin/rdp-worker-cursor-adapter-probe
|
||||
```
|
||||
|
||||
Expected result:
|
||||
|
||||
```text
|
||||
cursor_adapter_probe ok
|
||||
```
|
||||
|
||||
See `artifacts/rdp-a4-cursor-adapter-report.md`.
|
||||
|
||||
## GDI Repaint Cadence Hardening
|
||||
|
||||
RDP-Perf-5A keeps the default classic GDI region-first path and improves its
|
||||
runtime cadence without changing backend contracts or session lifecycle.
|
||||
|
||||
The worker now:
|
||||
|
||||
- drains already-signaled FreeRDP event handles in a bounded loop
|
||||
- rate-limits no-change detector logs
|
||||
- renews worker leases outside the hot render/input loop
|
||||
- publishes region and interactive render frames at a 33 ms interval
|
||||
- keeps full-frame fallback at a 100 ms interval
|
||||
- keeps direct worker WSS binary render and backend gateway JSON/base64 fallback
|
||||
compatible
|
||||
|
||||
Smoke-proven image:
|
||||
|
||||
```text
|
||||
rap-rdp-worker:rdp-perf5a-repaint-cadence
|
||||
```
|
||||
|
||||
Report:
|
||||
|
||||
```text
|
||||
artifacts/rdp-perf5a-report.md
|
||||
```
|
||||
|
||||
Manual UX validation after subsequent adapter work showed that keyboard, mouse,
|
||||
idle repaint, and general interaction are usable enough for the current MVP
|
||||
baseline, but small redraw artifacts remain and are the next hardening target.
|
||||
|
||||
## Direct Attach Baseline And Region Repair
|
||||
|
||||
After P1 manual visual smoke, the current accepted baseline image is:
|
||||
|
||||
```text
|
||||
rap-rdp-worker:rdp-p1-region-order2
|
||||
```
|
||||
|
||||
This baseline keeps the classic GDI region-first path and adds two important
|
||||
visual correctness safeguards:
|
||||
|
||||
- direct attach baseline full-frame capture so a newly attached client does not
|
||||
start from a black or incomplete framebuffer
|
||||
- throttled full-frame repair after detected region loss so dropped dirty
|
||||
regions do not leave persistent holes
|
||||
|
||||
The direct worker WSS writer treats attach-baseline frames as non-droppable
|
||||
reliable events. Normal display updates remain region-first and droppable where
|
||||
safe. Full frames are reserved for baseline/attach/recovery/fallback repair, not
|
||||
normal rendering.
|
||||
|
||||
Probes verified during the P1 baseline freeze:
|
||||
|
||||
```powershell
|
||||
docker -H ssh://docker-test run --rm rap-rdp-worker:rdp-p1-region-order2 rdp-worker-graphics-adapter-probe
|
||||
docker -H ssh://docker-test run --rm rap-rdp-worker:rdp-p1-region-order2 rdp-worker-cursor-adapter-probe
|
||||
docker -H ssh://docker-test run --rm rap-rdp-worker:rdp-p1-region-order2 rdp-worker-service-adapter-protocol-probe
|
||||
docker -H ssh://docker-test run --rm rap-rdp-worker:rdp-p1-region-order2 rdp-worker-dataplane-bind-probe --scenario valid
|
||||
```
|
||||
|
||||
Expected results:
|
||||
|
||||
- `graphics_adapter_probe ok`
|
||||
- `cursor_adapter_probe ok`
|
||||
- service adapter channel list printed without error
|
||||
- `PASS scenario=valid`
|
||||
|
||||
Known remaining visual limitation:
|
||||
|
||||
- drag/release repaint is usable but not polished; drag behaves like an older
|
||||
RDP client on a weak link by moving a frame rather than continuously
|
||||
repainting the full window.
|
||||
|
||||
## Certificate Verification Policy
|
||||
|
||||
- `strict` is the default per-resource mode and keeps normal FreeRDP certificate validation enabled
|
||||
- `ignore` is a saved per-resource mode coming from backend session assignment and enables `FreeRDP_IgnoreCertificate` only for that connection
|
||||
- `RDP_WORKER_INSECURE_SKIP_VERIFY=true` remains available only as a smoke/debug fallback when assignment data does not include an explicit mode; it is not the production configuration path
|
||||
|
||||
## Resource metadata contract
|
||||
|
||||
The worker currently expects target connection data inside `resources.metadata`:
|
||||
|
||||
```json
|
||||
{
|
||||
"rdp_host": "10.0.0.10",
|
||||
"rdp_port": 3389,
|
||||
"username": "Administrator",
|
||||
"password": "secret",
|
||||
"domain": ""
|
||||
}
|
||||
```
|
||||
|
||||
`address` is used as a fallback host when `rdp_host` is absent.
|
||||
|
||||
## Current limitations
|
||||
|
||||
- rendering is implemented as a classic GDI region-first BGRA path with direct
|
||||
binary WSS support and baseline/region-loss repair; encoded graphics, codecs,
|
||||
tiles, and RDPGFX production mode are not accepted yet
|
||||
- clipboard is text-only; image/HTML/RTF/binary clipboard formats are
|
||||
intentionally blocked
|
||||
- file upload is implemented through controlled worker storage and restricted
|
||||
drive visibility; server-to-client download is build-proven through the
|
||||
restricted `RAP_Transfers\ToClient` drop zone, with live runtime acceptance
|
||||
still pending
|
||||
- audio, printer, webcam, multi-monitor, and advanced graphics pipeline
|
||||
optimizations are intentionally not implemented yet
|
||||
- worker-to-backend communication is currently Redis-only; no dedicated worker API exists yet
|
||||
- connection secrets are still sourced from resource metadata for this minimal end-to-end proof and are not yet integrated with encrypted secret storage
|
||||
- FreeRDP session loop remains focused on lifecycle correctness and the
|
||||
region-first adapter path; remaining redraw artifacts must be fixed before
|
||||
starting another graphics feature
|
||||
- the Docker/native build can now be reproduced in the defined environment, but a full real-target smoke run still depends on a reachable RDP host and a machine where Docker can run
|
||||
- `RDP_WORKER_INSECURE_SKIP_VERIFY=true` is only a smoke/debug fallback and should not be used instead of the per-resource certificate verification policy
|
||||
|
||||
## Verification levels
|
||||
|
||||
- local native: expected on machines that match the documented Debian/devcontainer package set
|
||||
- container-proven: the Dockerfile is the canonical reproducible worker build environment and a successful `docker build` proves configure + compile + install
|
||||
- CI-defined: [`.github/workflows/build.yml`](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\.github\workflows\build.yml) is prepared to build backend and worker image and verify the installed worker binary path
|
||||
- smoke-path: see [scripts/smoke/README.md](/\\?\UNC\192.168.220.200\mst\codex\rdp-proxy\scripts\smoke\README.md) for what is and is not proven yet
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If `pkg-config` cannot find `freerdp2` or `winpr2`, verify that the development packages are installed and that `pkg-config --list-all` includes both modules.
|
||||
- If `cmake --preset dev --directory workers/rdp-worker` fails, confirm that the environment matches the Debian 12 package set from the Dockerfile or devcontainer.
|
||||
- If `docker build` succeeds but runtime startup fails, verify Redis connectivity and worker environment variables.
|
||||
- If `freerdp_connect` fails, confirm that the seeded metadata contains `rdp_host`, `rdp_port`, `username`, `password`, and optional `domain`.
|
||||
- If the worker registers but never receives assignments, inspect Redis keys `worker:registration:<worker_id>` and `worker:control:<worker_id>`.
|
||||
|
||||
## Clipboard Text Boundary
|
||||
|
||||
The worker receives `clipboard` control envelopes from the backend session
|
||||
gateway only after backend policy checks have passed. It enforces
|
||||
`assignment.policy.clipboard_mode` again before touching the FreeRDP runtime
|
||||
boundary, so UI or gateway mistakes do not become the only clipboard guard.
|
||||
|
||||
Supported policy values are `disabled`, `client_to_server`,
|
||||
`server_to_client`, and `bidirectional`. The default is `disabled`.
|
||||
Current implementation is text-only. It deliberately rejects images, HTML,
|
||||
RTF, binary payloads, and file-like clipboard data by never accepting those
|
||||
formats in the worker envelope contract.
|
||||
|
||||
Stage 4.1 integrates the FreeRDP `cliprdr` virtual channel for text clipboard.
|
||||
The worker advertises `CF_UNICODETEXT`, requests server `CF_UNICODETEXT` data,
|
||||
and converts UTF-16LE clipboard payloads to UTF-8 before publishing normalized
|
||||
`session_clipboard_text` events to the backend.
|
||||
|
||||
Current limitation: only text is supported. Images, HTML, RTF, file lists,
|
||||
binary formats, and file-like clipboard payloads remain intentionally blocked.
|
||||
|
||||
## File Upload Boundary
|
||||
|
||||
Stage 5.1.1 supports client-to-server upload into a restricted per-session
|
||||
directory that is exposed to the remote Windows session through FreeRDP drive
|
||||
redirection. It does not expose a remote Windows filesystem browser, arbitrary
|
||||
paths, shared worker folders, or server-to-client download.
|
||||
|
||||
The worker receives `file_upload` control envelopes only after backend gateway
|
||||
policy checks pass, then enforces `assignment.policy.file_transfer_mode` again
|
||||
before writing any bytes. Supported policy values are `disabled`,
|
||||
`client_to_server`, `server_to_client`, and `bidirectional`; only
|
||||
`client_to_server` and `bidirectional` allow upload in this stage. Default is
|
||||
`disabled`.
|
||||
|
||||
Uploaded files are finalized under:
|
||||
|
||||
```text
|
||||
${RDP_WORKER_TRANSFER_ROOT:-/tmp/rap-rdp-worker-transfers}/<session_id>/visible/<sanitized_file_name>
|
||||
```
|
||||
|
||||
Only this `visible` directory is mapped to the remote RDP session as the
|
||||
restricted drive `RAP_Transfers`. The worker passes only that path to FreeRDP's
|
||||
RDPDR drive-redirection channel; it never maps `${RDP_WORKER_TRANSFER_ROOT}`,
|
||||
the session parent directory, or arbitrary client-provided paths.
|
||||
|
||||
Safety constraints in the worker:
|
||||
|
||||
- maximum file size is 25 MiB
|
||||
- maximum chunk size is 256 KiB
|
||||
- file names must be simple names only, with no path separators, drive
|
||||
prefixes, `..`, or empty names
|
||||
- existing target files are never overwritten silently
|
||||
- chunks must arrive in order with the expected offset
|
||||
- final FNV-1a content hash must match when the client supplies one
|
||||
- files are stored only; the worker never executes uploaded content
|
||||
- upload temp files are kept inside the same restricted `visible` directory and
|
||||
renamed into place only after validation completes
|
||||
- the `visible` directory is cleaned up on detach, and the full per-session
|
||||
transfer directory is removed on termination or session failure
|
||||
|
||||
Stage 5.2 adds a server-to-client download path using only the
|
||||
restricted `ToClient` directory inside the existing `RAP_Transfers` drive. A
|
||||
remote user copies a regular file into `RAP_Transfers\ToClient`; the worker
|
||||
detects stable files in that directory only, publishes `file_download.available`,
|
||||
and streams chunks after client `file_download.start` / `file_download.ack`.
|
||||
|
||||
The worker still does not expose a general remote filesystem browser, arbitrary
|
||||
paths, shared worker folders, SMB/WebDAV, or a Windows agent. Core download
|
||||
transport is runtime-proven for direct worker WSS and backend gateway fallback;
|
||||
lifecycle blocking is runtime-proven for detach, old-controller takeover, and
|
||||
worker failure. Full Stage 5.2 acceptance still needs manual desktop UI proof.
|
||||
|
||||
Runtime proof report:
|
||||
|
||||
- `artifacts/stage5-2-file-download-runtime-report.md`
|
||||
Reference in New Issue
Block a user