diff --git a/.gitignore b/.gitignore index 0e38d571..c06cbcd5 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,7 @@ native/macos/MCPProxy/.build/ # demo pipeline: playwright node_modules symlink (recreated at capture time) scripts/demo/node_modules + +# Transient work artifacts (brainstorm logs, editor backups) +*.bak +**/execution_log.md diff --git a/scanner-qa-report.html b/scanner-qa-report.html deleted file mode 100644 index 1c267daf..00000000 --- a/scanner-qa-report.html +++ /dev/null @@ -1,417 +0,0 @@ - - -
- - -Comprehensive audit of scanning feature across all server types | 2026-04-06 | Branch: feat/039-security-scanner-plugins
- -| Level | Count | Percentage |
|---|---|---|
| Critical | 14 | 0.7% |
| High | 631 | 33.2% |
| Medium | 1,163 | 61.2% |
| Low | 91 | 4.8% |
- Threat classification: 247 dangerous, 473 warnings, 1,179 informational -
-| Phase | Method | Scope |
|---|---|---|
| API Testing | curl + jq | 72 API tests across all scan endpoints |
| Frontend Code Review | Static analysis | ServerDetail.vue (2,000+ lines), Security.vue, api.ts |
| Backend Code Review | Static analysis | service.go, engine.go, source_resolver.go, registry_bundled.go, security_scanner.go |
| Visual UI Testing | Chrome screenshots | Global Security page, server detail security tabs |
| Scanner Quality | False positive analysis | All findings from cisco-mcp-scanner, trivy, semgrep |
| Type | Servers | Source Method | Scanners Run |
|---|---|---|---|
| HTTP (remote) | context7, hugginface, kaggle, supabase | url | 1-6 |
| Streamable-HTTP (remote) | kubic, synapbus | url | 1-3 |
| Stdio (local) | demo-filesystem | working_dir | 6 |
| Stdio (Docker) | perplexity, screenshot-website-fast | docker_extract | 3-6 |
| Stdio (quarantined) | malicious-demo | uvx_cache | 6 (1 failed) |
| Stdio (disconnected) | everything-server | npx_cache | 6 |
CVE-2025-66414 (DNS rebinding), CVE-2026-0621 (ReDoS) in @modelcontextprotocol/sdk
-Tool poisoning detector could not run - server failed to connect for tool export
-Audio processing tools incorrectly flagged as system manipulation
-Note: supabase and kubic had 2 failed scanners each (cisco, trivy) due to tools.json not exported at time of scan
-| # | Severity | Category | Description | Status |
|---|---|---|---|---|
| 1 | High | API | Concurrent scan returns 500 instead of 409 Conflict | Fixed |
| 2 | High | Backend | Duplicate findings when merging Pass 1 + Pass 2 reports (same CVE appears twice) | Fixed |
| 3 | High | Backend | Security overview threat levels all zero (dangerous/warnings/info_level not aggregated) | Fixed |
| 4 | High | Backend | malicious-demo tools.json not exported - cisco scanner fails, server shows "clean" | Open |
| 5 | High | Backend | CancelScan doesn't cancel running Docker containers (uses context.Background()) | Open |
| 6 | High | Backend | Race condition between Pass 1 completion and Pass 2 start | Open |
| 7 | High | Backend | Report directory (scanner-reports/) never cleaned up | Open |
| 8 | High | Backend | No scanner-source matching: all scanners run on all source types | Open |
| 9 | Medium | API | handleStartScan silently ignores JSON decode errors | Open |
| 10 | Medium | Backend | Pass 1 cleanup removes temp dir before Pass 2 can use it | Open |
| 11 | Medium | Backend | Race condition reading/writing job.Status without lock | Open |
| 12 | Medium | API | POST scan for nonexistent server returns 500 instead of 404 | Open |
| 13 | Medium | Backend | tools_exported inconsistently null for some servers | Open |
| 14 | Medium | Backend | Inconsistent scanner count: some servers get 6 scanners, others only 1-3 | Open |
| 15 | Medium | Backend | Docker cache mount at /root/.cache may conflict with scanner-specific paths | Open |
| 16 | Medium | Backend | extractTopLevelDir includes /usr, /var for Docker - too broad for supply chain audit | Open |
| 17 | Medium | Backend | cancel-all wipes scan job data for servers with active scans | Open |
| 18 | Medium | Backend | Scan report has duplicate scanner entries for multi-scanned servers | Open |
| 19 | Low | Backend | ValidateManifest requires Command non-empty, but 3 bundled scanners have nil Command | Open |
| 20 | Low | Backend | parseResults silently treats unparseable scanner output as 'clean' | Open |
| 21 | Low | Backend | File-to-findings path matching uses flawed normalization | Open |
| 22 | Low | Backend | GetScanSummary doesn't check for active Pass 2 scans | Open |
| 23 | Low | Backend | Cisco scanner hardcodes --tools /scan/source/tools.json path | Open |
| 24 | Low | Backend | Docker-extracted scans report total_files=0 despite scanning extracted files | Open |
| 25 | Low | Backend | Argument-based source resolution matches non-flag args as file paths incorrectly | Open |
| 26 | Low | Backend | Job ID collision risk with time.Now().UnixNano() generation | Open |
| 27 | Low | Backend | handleGetScanFiles retrieves report independently of job (potential mismatch) | Open |
| # | Severity | Category | Description | Status |
|---|---|---|---|---|
| 28 | High | UI | No Cancel button during active scan (API exists but UI doesn't expose it) | Fixed |
| 29 | Medium | UI | Scanned Files section visible for tool_definitions_only source method | Fixed |
| 30 | Medium | UI | No retry button after scan failure | Fixed |
| 31 | High | UI | Race condition: polling completion fires before scanReport loads | Open |
| 32 | High | UI | "Already in progress" error extracts job ID with fragile regex | Open |
| 33 | Medium | UI | No debounce on Scan Now button (rapid clicks can cause issues) | Open |
| 34 | Medium | UI | Polling continues silently on network errors with no max retry | Open |
| 35 | Medium | UI | Scan error alert has no dismiss action | Open |
| 36 | Medium | UI | Approve/Reject only shown with findings (can't approve clean servers) | Open |
| 37 | Medium | UI | Active scan state lost on page navigation and return | Open |
| 38 | Low | UI | Inconsistent risk score color thresholds between pages | Open |
| 39 | Low | UI | Failed scanners counted as "completed" in progress bar | Open |
| 40 | Low | UI | Scanner Execution Logs depend on scanStatus populated at wrong time | Open |
| 41 | Low | UI | No explanation of Risk Score metric anywhere | Open |
| 42 | Low | UI | No "last scanned" timestamp shown prominently | Open |
File: internal/httpapi/security_scanner.go
When a scan is already running for a server and another scan is triggered, the API now returns HTTP 409 Conflict instead of 500 Internal Server Error.
-Validated: POST /scan returns 409 with "scan already in progress" message
-File: internal/security/scanner/service.go
When merging Pass 1 (security scan) and Pass 2 (supply chain audit) reports, duplicate findings (same scanner + rule + title) are now removed. Pass 1 findings take priority.
-Example: Perplexity had 4 findings (2 duplicated). Now correctly shows 2.
-Validated: perplexity report shows 2 findings (was 4)
-File: internal/security/scanner/service.go
The global security overview now correctly counts findings by threat level (dangerous, warnings, info_level). Previously these were all zero because ClassifyFinding() wasn't called during overview aggregation.
-Validated: Overview shows dangerous=247, warnings=473, info_level=1179 (was all 0)
-File: frontend/src/views/ServerDetail.vue
Added a "Cancel" button that appears during active scans. Calls the existing cancelScan API endpoint, stops polling, and resets scan state.
-Validated: Cancel button renders, calls API correctly
-File: frontend/src/views/ServerDetail.vue
The Scanned Files collapsible section is now hidden for HTTP servers and tool_definitions_only source methods (no filesystem to show files for).
-Validated: Section hidden for url, url_full, and tool_definitions_only
-File: frontend/src/views/ServerDetail.vue
Added a "Retry" button to the scan error alert, allowing users to easily re-trigger a scan after failure without refreshing the page.
-Validated: Retry button clears error and re-triggers scan
-Scanner: cisco-mcp-scanner | Findings: 2
-What was flagged:
-Analysis: This is standard MCP tool description pattern. Context7 instructs the LLM to call resolve-library-id before query-docs. The phrase "You MUST call" triggers the prompt injection detector, but this is normal tool orchestration guidance, not malicious prompt injection.
-Verdict: FALSE POSITIVE — Cisco scanner is too aggressive with imperative language in tool descriptions.
-Scanner: cisco-mcp-scanner | Findings: 16 (2 dangerous + 14 warning)
-What was flagged: All audio tools (text_to_speech, speech_to_text, text_to_sound_effects, isolate_audio, speech_to_speech, etc.) flagged as "SYSTEM MANIPULATION"
-Analysis: ElevenLabs is a legitimate audio processing API. Its tools interact with audio data, not system resources. The scanner's description of system manipulation ("unsolicited modification or deletion of files, registries") does not match what these tools do.
-Verdict: FALSE POSITIVE — Cisco scanner misclassifies media processing as system manipulation.
-| Server | Findings | Assessment |
|---|---|---|
| demo-filesystem | 7 findings (Stripe key, GitHub PAT, private keys) | TRUE POSITIVE - real secrets in filesystem |
| perplexity | 2 CVEs (DNS rebinding, ReDoS in MCP SDK) | TRUE POSITIVE - real vulnerabilities in dependencies |
The quarantined malicious-demo server can't have its tool definitions exported because it fails to connect (MCP initialize timeout). The cisco-mcp-scanner, which is the primary tool poisoning detector, requires /scan/source/tools.json which can't be created without a connection.
Impact: Quarantined servers that are truly malicious can't be scanned for tool poisoning โ the exact scenario this feature is designed for.
-Suggested fix: Cache tool definitions when they are first discovered (before quarantine), so scanning can use cached definitions even when the server refuses to connect.
-Some servers get 6 scanners, others only 1-3. The scanner selection logic doesn't match scanner capabilities to source types. For example, hugginface (HTTP, 8 tools) only ran semgrep, while context7 (HTTP, 2 tools) ran all 6.
-Impact: Inconsistent security coverage across servers.
-Suggested fix: Implement scanner-to-source capability matching based on scanner input requirements.
-The Cisco MCP Scanner produces a high false positive rate for standard MCP tool descriptions. Imperative language ("You MUST call", "always use") and media processing tools are incorrectly flagged.
-Impact: Risk score of 60-100 for legitimate servers, eroding user trust.
-Suggested fix: Implement scanner result post-processing to filter known false positive patterns, or adjust cisco scanner configuration thresholds.
-
- Generated 2026-04-06 | MCPProxy v0.23.1 | Branch: feat/039-security-scanner-plugins
-
QA Coverage: 72 API tests, 15 UI bugs, 20 backend bugs, 8 design issues, 10 UX improvements
-
| Phase | -Surface | -Status | -Tests executed | -Notes | -
|---|---|---|---|---|
| Phase 1 | -CLI (doctor) |
- PASS | -6 | -All 29 codes registered; doctor fix dry-run + execute exercised; classifier maps stdio-spawn-enoent. |
-
| Phase 2 | -Web UI (ErrorPanel) | -PASS | -1 integration flow | -ErrorPanel renders full payload; Preview (dry-run) button fires fix endpoint โ 200. | -
| Phase 3 | -macOS Tray | -SKIPPED | -0 | -Production tray actively running; stopping mid-session was judged too risky. Visual confirm recommended post-merge. | -
- Ran the freshly-built dev mcpproxy on an isolated port (127.0.0.1:18080) and data dir
- (/tmp/mcpproxy-test-spec044) with a purpose-built test config containing a broken-stdio
- server whose command points at /nonexistent/binary. The production daemon on :8080 was
- never touched; its config was backed up (byte-identical after test) and the dev binary was run from the worktree.
-
doctor list-codes โ all 29 diagnostic codes registeredlist-codes enumerates them with docs + fix rows.
- Actual:jq length == 29. All 5 MCPX_STDIO codes present. Each entry surfaces severity, docs link, and fix rows (command / button / link).
- $ ./mcpproxy doctor list-codes -29 diagnostic codes registered: - - MCPX_CONFIG_DEPRECATED_FIELD warn The configuration uses a deprecated field that will be removed in a future release. - docs: docs/errors/MCPX_CONFIG_DEPRECATED_FIELD.md - fix (button): Preview migration (dry-run) key=config_migrate_deprecated [destructive -> dry-run default] - fix (link): Migration notes docs/errors/MCPX_CONFIG_DEPRECATED_FIELD.md - - MCPX_CONFIG_MISSING_SECRET error The configuration references a secret that is not defined. - docs: docs/errors/MCPX_CONFIG_MISSING_SECRET.md - fix (command): List secrets mcpproxy secret list - fix (link): Secret references docs/errors/MCPX_CONFIG_MISSING_SECRET.md - ... (27 more) ... - -$ ./mcpproxy doctor list-codes -o json | jq 'length' -29 - -$ ./mcpproxy doctor list-codes -o json | jq '[.[] | select(.code | startswith("MCPX_STDIO_"))] | map(.code)' -[ - "MCPX_STDIO_EXIT_NONZERO", - "MCPX_STDIO_HANDSHAKE_INVALID", - "MCPX_STDIO_HANDSHAKE_TIMEOUT", - "MCPX_STDIO_SPAWN_EACCES", - "MCPX_STDIO_SPAWN_ENOENT" -]-
doctor --server broken-stdio โ per-server health checkbroken-stdio, maps to MCPX_STDIO_SPAWN_ENOENT.
- Actual:With socket enabled, produces ⚠ Found 1 issue that need attention and ❌ Upstream Server Connection Errors for broken-stdio. Classifier correctly picks up the zsh:1: no such file or directory stderr pattern.
- enable_socket: false failed with "doctor requires running daemon. Start with: mcpproxy serve"
- โ misleading because the daemon was running and reachable over HTTP. Re-running with socket enabled
- resolved the issue. Worth a CLI UX follow-up.
- $ ./mcpproxy doctor --server broken-stdio -โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ -๐ MCPProxy Health Check -โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ -Version: v0.24.9 (latest) - -⚠ Found 1 issue that need attention - -❌ Upstream Server Connection Errors - Server: broken-stdio - -โ ๏ธ Deprecated Configuration - โข features - features is deprecated and has no effect - Suggestion: Remove from config (all feature flags are unused) -โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ-
$ ./mcpproxy doctor --server broken-stdio # enable_socket: false -Error: doctor requires running daemon. Start with: mcpproxy serve -# ...usage text elided... -Error: doctor requires running daemon. Start with: mcpproxy serve-
doctor fix MCPX_STDIO_SPAWN_ENOENT --server broken-stdio โ dry-run defaultstdio_show_last_logs
- success, Mode: dry_run, preview text returned.
- $ ./mcpproxy doctor fix MCPX_STDIO_SPAWN_ENOENT --server broken-stdio -โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ -๐ Doctor Fix: MCPX_STDIO_SPAWN_ENOENT -โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ -Server: broken-stdio -Fix step: Show last server log lines -Fixer key: stdio_show_last_logs -Destructive: no -Mode: dry_run - -Outcome: ✅ success - -Preview: - Server 'broken-stdio' log tail unavailable in this build โ enable server-side - log access to view the last 50 lines here. -โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ-
doctor fix ... --execute โ rate-limited safety guardPOST /api/v1/diagnostics/fix with {"mode":"execute",...} returned 200 {"mode":"execute","outcome":"success"}.
- $ ./mcpproxy doctor fix MCPX_STDIO_SPAWN_ENOENT --server broken-stdio --execute -Error: fix invocation failed: API returned status 429: { - "success":false, - "error":"Too many fix attempts; try again shortly", - "request_id":"eeb5791b-06ab-4055-aa2f-1acabf83bf42" -}-
/diagnostics, /servers, /servers/{name}/diagnosticsGET /api/v1/servers/broken-stdio/diagnostics returns full Diagnostic payload: error_code, user_message, fix_steps[] (command / button / link variants), docs_url, severity, detected_at, health.level=unhealthy, health.action=restart.GET /api/v1/servers returns each server with top-level error_code ("MCPX_STDIO_SPAWN_ENOENT" on the broken one, null on healthy).GET /api/v1/diagnostics aggregates to total_issues: 1, upstream_errors[0].server_name == "broken-stdio"."mode": "dry_run" | "execute" (see Findings #3), not a boolean.Despite the spawn being wrapped via /bin/zsh -l -c, the classifier parses the zsh stderr line
- zsh:1: no such file or directory: /nonexistent/binary and maps it to MCPX_STDIO_SPAWN_ENOENT
- (not a generic timeout or handshake error). This is the core value-add of the spec.
- Opened http://127.0.0.1:18080/ui/servers/broken-stdio?apikey=*** in claude-in-chrome. The Vue
- ErrorPanel component (4d92c82) rendered the full diagnostic payload with all expected
- elements. Clicking the Preview (dry-run) button fires the fix endpoint successfully.
-
/ui/servers/broken-stdioerrorMCPX_STDIO_SPAWN_ENOENTzsh:1: no such file or directory: /nonexistent/binarywhich npx && which uvx && which python3 + Copy buttondocs/errors/MCPX_STDIO_SPAWN_ENOENT.mdConnecting (yellow)POST /api/v1/diagnostics/fixPOST http://127.0.0.1:18080/api/v1/diagnostics/fix with {"mode":"dry_run","code":"MCPX_STDIO_SPAWN_ENOENT","server":"broken-stdio","fixer_key":"stdio_show_last_logs"}
- Response:200 OK + JSON body containing preview string
- UX observation:No toast or inline render of data.preview after success. Re-verified via direct curl that the payload does contain the preview text.
- ss_103222r6g and
- ss_0723w5ok4). Those IDs are ephemeral and were not written to disk โ no
- /tmp/spec044-verify-webui-*.png files exist. The textual test plan above documents every element that
- was visually confirmed.
- /ui/servers/broken-stdioMCPProxy tray app and mcpproxy
- core were actively running on port 8080 at the time of this verification. Killing and restarting
- them to swap in the dev tray binary (6aa8305) would have risked disrupting an active session for no
- critical upside โ Phases 1 + 2 already demonstrate end-to-end that the classifier, REST API, CLI formatter, fix
- endpoint, and Vue ErrorPanel all work correctly for MCPX_STDIO_SPAWN_ENOENT.
- total_issues > 0/ui/servers/<name> in the default browser
- Relevant code lives in the commit 6aa8305 โ feat(spec-044): macOS tray badge + Fix issues menu group.
- To verify post-merge, run:
-
$ # after-hours, when it's safe to cycle production -$ cd ~/repos/mcpproxy-go -$ make build -$ pkill -x MCPProxy -$ open /tmp/MCPProxy.app- - -
doctor CLI has no HTTP fallback when socket is disabled
- medium
- With "enable_socket": false in config, mcpproxy doctor fails with
- "doctor requires running daemon. Start with: mcpproxy serve" โ a misleading message, because the daemon
- is already running and reachable over HTTP at the configured listen address. Env vars
- MCPPROXY_LISTEN/MCPPROXY_API_KEY have no effect on the doctor subcommand either.
-
Reproduction: start mcpproxy serve with enable_socket: false, then run
- mcpproxy doctor.
Suggested fix: either (a) add HTTP fallback using listen + API key, or (b)
- surface a clearer error: "socket is disabled in config โ re-enable it, or use
- curl http://127.0.0.1:PORT/api/v1/diagnostics".
- Clicking the Preview (dry-run) button in ErrorPanel.vue fires the fix endpoint and receives
- a 200 response whose JSON body contains a helpful preview string (e.g.,
- "Server 'broken-stdio' log tail unavailable in this build..."), but the Vue component does not render
- it anywhere. Users have no visual feedback that the action succeeded.
-
Location: web/frontend/src/components/ErrorPanel.vue (or equivalent โ commit
- 4d92c82).
Suggested fix: surface response.data.preview via a DaisyUI toast, or render it
- inline below the button while preview is populated.
"mode" string, not a boolean "dry_run"
- low
- The POST /api/v1/diagnostics/fix endpoint expects {"mode": "dry_run" | "execute", ...},
- not a boolean flag like {"dry_run": true}. This matches the implementation at
- internal/httpapi/diagnostics_fix.go:27, but worth calling out explicitly in the OpenAPI description
- and in any published examples to prevent API-consumer confusion.
-
Suggested fix: add an inline note in oas/swagger.yaml and the spec's
- contracts/ examples explicitly showing the string-enum field.
list-codes -o json | jq length/nonexistent/binary correctly classifies to MCPX_STDIO_SPAWN_ENOENT even when wrapped through /bin/zsh -l -ccode, severity, user_message, cause, fix_steps[], docs_url, detected_atserver.health block populated with level: unhealthy, action: restart โ matches Spec 044's action-suggestion designerror_code field at top leveldoctor fix rate-limiter returns structured 429 with a request_id for correlation| Go toolchain | go1.25.1 darwin/arm64 |
| Binary version | v0.24.9 (reported by the daemon banner; mcpproxy version subcommand is absent from this build โ not yet ported to Cobra) |
| Binary size | 40,874,114 bytes (/Users/user/repos/mcpproxy-go-diagnostics-taxonomy/mcpproxy, 2026-04-24 18:45) |
| Worktree | /Users/user/repos/mcpproxy-go-diagnostics-taxonomy |
| Branch | feat/diagnostics-taxonomy @ 911704cc539e8c4965e7c1786cbcf3b0b70e0ae6 |
| Commits-under-test | 9 (see header) |
| Test data dir | /tmp/mcpproxy-test-spec044 (removed during cleanup) |
| Test listen | 127.0.0.1:18080 |
| Production untouched | :8080, config byte-identical to backup (diff -q) |
| Duration | ~45 minutes (setup + 6 CLI tests + 2 Web UI checks + cleanup) |
# 1. back up production config, but don't touch it -$ cp ~/.mcpproxy/mcp_config.json /tmp/mcp_config.json.backup-$(date +%s) - -# 2. build dev binary on the feature branch -$ cd ~/repos/mcpproxy-go-diagnostics-taxonomy -$ make build - -# 3. isolated data dir + config -$ mkdir -p /tmp/mcpproxy-test-spec044 -$ cat > /tmp/mcpproxy-test-spec044/config.json <<'JSON' -{ - "listen": "127.0.0.1:18080", - "api_key": "***", - "enable_socket": true, - "enable_web_ui": true, - "mcpServers": [ - {"name":"broken-stdio", "command":"/nonexistent/binary", - "protocol":"stdio", "enabled":true}, - {"name":"healthy-control", "command":"echo", - "protocol":"stdio", "enabled":false} - ] -} -JSON - -# 4. launch in tmux on the isolated port + data dir -$ tmux new-session -d -s spec044 \ - "./mcpproxy serve -c /tmp/mcpproxy-test-spec044/config.json \ - -d /tmp/mcpproxy-test-spec044 --log-level=debug" - -# 5. exercise CLI + REST + Web UI -$ ./mcpproxy doctor list-codes -o json | jq length -$ ./mcpproxy doctor --server broken-stdio -$ ./mcpproxy doctor fix MCPX_STDIO_SPAWN_ENOENT --server broken-stdio -$ curl -s -H "X-API-Key: ***" \ - http://127.0.0.1:18080/api/v1/servers/broken-stdio/diagnostics | jq . - -# 6. cleanup -$ tmux kill-session -t spec044 -$ rm -rf /tmp/mcpproxy-test-spec044 -$ diff -q ~/.mcpproxy/mcp_config.json /tmp/mcp_config.json.backup-*-
/Users/user/repos/mcpproxy-go/tmp-agent-report-spec044-verify.md (structured verification report, 9,038 bytes)/tmp/spec044-verify-cli.log (CLI command outputs, 10,091 bytes)/tmp/mcpproxy-test-spec044/server.log (server log โ not found, test data dir was cleaned up)/tmp/spec044-verify-webui-*.png (Web UI screenshots โ not found, captured via claude-in-chrome but not persisted to disk)/tmp/spec044-verify-tray-*.png (tray screenshots โ not found, Phase 3 skipped)