From fd82dd7fefcb8fb4d4ef7c138b14ea6a7e63c65d Mon Sep 17 00:00:00 2001 From: rookiestar28 Date: Sat, 11 Jul 2026 00:24:40 +0800 Subject: [PATCH] feat(ui): preview bounded text outputs --- README.md | 3 +- docs/frontend_ux_walkthrough.md | 4 +- docs/release/api_contract.md | 5 +- docs/release/compatibility_matrix.md | 1 + docs/release/recent_updates.md | 1 + docs/troubleshooting.md | 2 +- services/comfyui_history.py | 84 +++++ tests/e2e/specs/job_monitor_file_text.spec.js | 180 ++++++++++ tests/skip_policy.json | 7 +- tests/test_r216_file_backed_text_output.py | 149 ++++++++ web/openclaw_asset_refs.js | 107 ++++++ web/openclaw_text_output.js | 294 ++++++++++++++++ web/tabs/job_monitor_tab.js | 88 +++++ web/tests/unit/openclaw_asset_refs.test.js | 98 ++++++ web/tests/unit/openclaw_text_output.test.js | 329 ++++++++++++++++++ 15 files changed, 1346 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/specs/job_monitor_file_text.spec.js create mode 100644 tests/test_r216_file_backed_text_output.py create mode 100644 web/openclaw_text_output.js create mode 100644 web/tests/unit/openclaw_text_output.test.js diff --git a/README.md b/README.md index f168b78..06d1833 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ Deployment profiles and hardening references: - Updated sidebar registration to prefer the current ComfyUI sidebar store API and keep the deprecated frontend facade as a compatibility fallback for older hosts. - Aligned Model Manager and preflight diagnostics with current ComfyUI model folder names, including newer managed keys such as `gligen`, `latent_upscale_models`, `hypernetworks`, `photomaker`, `model_patches`, `geometry_estimation`, and `detection`, while retaining legacy aliases such as `clip` and `unet`. - Made output parsing media-aware for current previewable result groups (`images`, `video`, `audio`, `3d`, and bounded `text`) while keeping image callbacks compatible, supporting optional hash-backed refs when host metadata is present, and keeping asset-only identifiers as explicit fallback states instead of silently upgrading to `/api/assets`. +- Added Job Monitor support for allowlisted text files under the host `files` output key. Previews use same-origin `/view`, a 5-second timeout, strict textual MIME and UTF-8 checks, a 64-KiB streaming cap, a 4,096-character display cap, and literal text rendering; rejected or unsupported responses keep an explicit source link. - OpenClaw prompt submissions now include stable `comfy_usage_source` attribution when missing, without overwriting caller-provided attribution or copying prompt/tenant/trace content into that field. - Updated public release/support/troubleshooting docs to match the refreshed host facts and avoid exposing maintainer-only planning paths or machine-local links. @@ -433,7 +434,7 @@ The OpenClaw sidebar includes these built-in tabs. Some tabs are capability-gate | Tab | What it does | Related docs | | --- | --- | --- | | `Settings` | Health/config/log visibility, provider/model setup, model connectivity checks, and optional localhost key storage. | [Quick Start](#quick-start-minimal), [API Overview](#api-overview), [Troubleshooting](#troubleshooting) | -| `Jobs` | Tracks prompt IDs, consumes deterministic event/task cursor metadata for polling, and shows recent outputs across classic history refs, optional `asset_hash`/`hash`-backed refs when host metadata is present, and current previewable media groups (`images`, `video`, `audio`, `3d`, bounded `text`). File-like refs stay on the `/view` contract; asset-service-only refs stay explicit instead of silently upgrading to `/api/assets`. | [API Overview](#api-overview), [Remote Control (Connector)](#remote-control-connector) | +| `Jobs` | Tracks prompt IDs, consumes deterministic event/task cursor metadata for polling, and shows recent outputs across classic history refs, optional `asset_hash`/`hash`-backed refs when host metadata is present, and current previewable media groups (`images`, `video`, `audio`, `3d`, bounded inline or file-backed `text`). Allowlisted text files use a bounded same-origin `/view` reader and literal text rendering with an explicit source-link fallback; asset-service-only refs stay explicit instead of silently upgrading to `/api/assets`. | [API Overview](#api-overview), [Remote Control (Connector)](#remote-control-connector) | | `Planner` | Uses assist endpoint to generate structured prompt plans (positive/negative/params). | [Configure an LLM key](#1-configure-an-llm-key-for-plannerrefinervision-helpers), [Nodes](#nodes) | | `Refiner` | Refines existing prompts with optional image context and issue/goal input. | [Configure an LLM key](#1-configure-an-llm-key-for-plannerrefinervision-helpers), [Nodes](#nodes) | | `Variants` | Local helper for generating batch variant parameter JSON (seed/range-style sweeps). | [Nodes](#nodes), [Operator UX Features](#operator-ux-features) | diff --git a/docs/frontend_ux_walkthrough.md b/docs/frontend_ux_walkthrough.md index 6256f91..0ae45f1 100644 --- a/docs/frontend_ux_walkthrough.md +++ b/docs/frontend_ux_walkthrough.md @@ -14,7 +14,7 @@ This document summarizes the current OpenClaw sidebar UI structure and how to ve - Tabs: `web/openclaw_tabs.js` manages tab registration, rendering, and remount safety. - API: `web/openclaw_api.js` provides a normalized fetch wrapper and OpenClaw endpoints (legacy Moltbot endpoints still work). - Host surface: `web/openclaw_host_surface.js` resolves the active frontend host surface and stamps explicit metadata so standalone frontend vs desktop-embedded behavior stays testable. -- Output refs: `web/openclaw_asset_refs.js` normalizes classic history refs, optional `asset_hash`/`hash` refs when host metadata is present, and current previewable media groups (`images`, `video`, `audio`, `3d`, bounded `text`) onto one media-aware contract. File-like refs stay on the bounded `/view` preview path, HDR `.exr` / `.hdr` image refs show source-preview fallback links instead of normal thumbnails, text refs stay escaped and bounded, and asset-service-only refs remain explicit fallback states instead of silently auto-fetching `/api/assets`. +- Output refs: `web/openclaw_asset_refs.js` normalizes classic history refs, optional `asset_hash`/`hash` refs when host metadata is present, and current previewable media groups (`images`, `video`, `audio`, `3d`, bounded inline or file-backed `text`) onto one media-aware contract. Allowlisted text files under the host `files` key stay on same-origin `/view` and use a 5-second, 64-KiB streaming, strict textual-MIME/UTF-8 reader with a 4,096-character display cap. HDR `.exr` / `.hdr` image refs show source-preview fallback links instead of normal thumbnails, text reaches the DOM only as literal text, and asset-service-only refs remain explicit fallback states instead of silently auto-fetching `/api/assets`. - Styles: `web/openclaw.css` provides shared design tokens and component classes. - Errors and compatibility helpers: `web/openclaw_utils.js` provides `showError()` / `clearError()` plus runtime legacy-class alias helpers used to keep canonical `openclaw-*` markup compatible with existing `moltbot-*` selectors. @@ -84,7 +84,7 @@ If `assist_streaming` is unavailable or the stream transport degrades, Planner/R 3. Confirm the sidebar host-surface metadata resolves correctly for the current environment instead of defaulting silently. 4. Planner: click **Plan Generation** with minimal input and confirm either live preview/stage updates appear (when streaming is supported) or a readable fallback result/error appears. 5. Refiner: click **Refine Prompts** (with or without image) and confirm either live preview/stage updates appear (when streaming is supported) or a readable fallback result/error appears. -6. Jobs: verify output previews still resolve for classic history refs, optional hash-backed refs when host metadata is present, and supported media-aware refs (`images`, `video`, `audio`, `3d`, bounded `text`); HDR `.exr` / `.hdr` image refs should render as explicit source-preview fallback links, asset-service-only refs should stay explicit as a bounded fallback state, and repeated polls should not duplicate rows after reconnect/resume. +6. Jobs: verify output previews still resolve for classic history refs, optional hash-backed refs when host metadata is present, and supported media-aware refs (`images`, `video`, `audio`, `3d`, bounded inline/file-backed `text`); allowlisted text files should show literal bounded content or a deterministic source-link fallback, HDR `.exr` / `.hdr` image refs should render as explicit source-preview fallback links, asset-service-only refs should stay explicit as a bounded fallback state, and repeated polls should not duplicate rows after reconnect/resume. 7. Explorer: verify preflight inventory can show `refreshing` / `stale` / `error` state without freezing the tab while deep scan work continues, and verify inactive-branch suppressed findings render separately from actionable failures. 8. Library/Approvals: if backend endpoints are not enabled, confirm the UI shows a clear error state (no crashes). 9. If you simulate/fake a stream failure in dev tools, confirm Planner/Refiner retry through the classic non-stream path without duplicate submits or broken loading state. diff --git a/docs/release/api_contract.md b/docs/release/api_contract.md index 7d92f8e..dd80bfd 100644 --- a/docs/release/api_contract.md +++ b/docs/release/api_contract.md @@ -96,8 +96,11 @@ Preflight workflow diagnostics contract: History and output-ref contract: - history/output consumers SHOULD treat the normalized output-ref contract as media-aware -- current previewable output groups are `images`, `video`, `audio`, `3d`, and bounded `text` +- current previewable output groups are `images`, `video`, `audio`, `3d`, bounded inline `text`, and allowlisted file-backed text refs from the host `files` key - file-like refs that can be represented through `/view` remain on the bounded `/history` + `/view` preview path +- file-backed text admission is limited to `.txt`, `.md`, `.markdown`, `.json`, `.csv`, `.yaml`, `.yml`, `.xml`, and `.log`; clients MUST build the URL from normalized filename/subfolder/type fields rather than trust a history-provided URL +- browser text previews MUST remain same-origin `/view` GET requests, reject redirects and active/ambiguous MIME types, use strict UTF-8, stream at most 64 KiB within 5 seconds, display at most 4,096 characters, and degrade to a source link when safe streaming is unavailable +- fetched text MUST be inserted as literal text; HTML, Markdown, SVG/XML, ANSI, or script interpretation is not part of this contract - `asset_hash` / `hash` metadata is optional because current ComfyUI host asset hashing is opt-in through `--enable-asset-hashing`; clients MUST NOT require hashes for normal filename-backed previews - refs with `asset_hash` or `hash` values, when host metadata provides them, preview through `/view?filename=blake3:...` - refs that only expose upstream asset-service identifiers remain explicit `asset_api_required` states; clients MUST NOT silently infer direct `/api/assets` fetching from that marker diff --git a/docs/release/compatibility_matrix.md b/docs/release/compatibility_matrix.md index 27ccd1d..c485d30 100644 --- a/docs/release/compatibility_matrix.md +++ b/docs/release/compatibility_matrix.md @@ -45,6 +45,7 @@ This document tracks the current reference anchors and validated environments fo - **SaveImage output refs**: OpenClaw consumes runtime `/history` output refs and does not infer graph-rewrite behavior from output-node socket shape. `SaveImage` output sockets are allowed to exist without changing the normalized output-ref contract. - **3D output refs**: `Load3DAdvanced` and related 3D preview refs remain media-aware output refs. File-like refs and optional hash-backed 3D refs stay on the bounded `/view` preview contract; clients without a 3D renderer should show an explicit fallback/link surface. - **HDR image output refs**: `.exr` and `.hdr` image refs stay on the bounded `/view` source-preview contract but render as explicit fallback/link surfaces unless a client implements a safe HDR-specific viewer. +- **File-backed text output refs**: allowlisted text files under the host `files` output key normalize to text refs on the existing `/view` route. Job Monitor uses same-origin, redirect-free, strict MIME/UTF-8 streaming with fixed 5-second, 64-KiB transfer, and 4,096-character display limits; failures remain source-link fallbacks and content is never interpreted as HTML or Markdown. - **Promoted widget source scope and structured widgets**: OpenClaw graph helpers preserve host-shaped promoted-widget source metadata and keep non-numeric node IDs stable. Backend preflight remains a conservative model-key whitelist; structured `COLORS` / `BOUNDING_BOXES` inputs and frontend source metadata are not treated as model references, and OpenClaw does not claim full host frontend active-scope parity without a richer graph-instance contract. - **Asset dimensions and grouped assets**: typed width/height metadata and grouped multi-download behavior are host-frontend display/download concerns. They do not change OpenClaw fetch routing, and asset-service-only identifiers remain explicit `asset_api_required` states rather than implicit `/api/assets` fetches. - **Asset loader paths and model tags**: current host asset metadata may expose `loader_path`; model uploads require `model_type:` tags, advertised by `/features.supports_model_type_tags`. OpenClaw does not upload through or directly consume `/api/assets`, so these schema facts do not change the existing `/history` + `/view` contract. diff --git a/docs/release/recent_updates.md b/docs/release/recent_updates.md index df454e3..0fba27f 100644 --- a/docs/release/recent_updates.md +++ b/docs/release/recent_updates.md @@ -12,6 +12,7 @@ Newest entries appear first. - Refreshed the active compatibility baseline to ComfyUI `1377a2f7` (`v0.27.0-47-g1377a2f7`, pyproject `0.27.0`) and standalone frontend `1.48.1` (`ceb5ae1eba`, `v1.48.1-1-gceb5ae1eba`). - Kept Desktop pinned separately at `0.9.4` with core `0.22.3` and embedded frontend `1.43.18`, explicitly lagging the standalone frontend reference. - Documented current host asset `loader_path` and namespaced model-tag schema without adopting direct `/api/assets` runtime access or changing OpenClaw's Node.js 18+ test policy. +- Added bounded Job Monitor previews for allowlisted text refs emitted under the host `files` output key, using only same-origin `/view`, strict textual MIME/UTF-8 streaming limits, literal DOM text, and explicit source-link fallback states. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 66f185e..e7dd2ea 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -101,7 +101,7 @@ What this means: - `asset_api_required` is a bounded compatibility state, not a generic parser failure. - Classic history refs should continue to preview normally even when hash metadata is absent. Optional hash-backed refs exposed as `asset_hash` or `hash` still map onto `/view` when ComfyUI host metadata provides them. -- Current media-aware outputs can include `images`, `video`, `audio`, `3d`, and bounded `text`; normal images render as thumbnails, HDR `.exr` / `.hdr` image refs render as explicit source-preview fallback links, text renders as escaped bounded text, and other file-like media may appear as explicit fallback/link tiles instead of image elements. +- Current media-aware outputs can include `images`, `video`, `audio`, `3d`, bounded inline text, and allowlisted file-backed text; normal images render as thumbnails, HDR `.exr` / `.hdr` image refs render as explicit source-preview fallback links, and text renders as literal bounded content. A file-backed text response that is oversized, slow, redirected, non-text, invalid UTF-8, or unavailable as a safe browser stream remains an explicit source-link fallback instead of exposing response details. - If an operator workflow starts depending on direct asset-service identifiers, treat that as a contract gap and review [`docs/asset_api_adoption_decision.md`](asset_api_adoption_decision.md) before widening the runtime dependency. ## Verify audit-chain continuity after restart or rotation diff --git a/services/comfyui_history.py b/services/comfyui_history.py index a18183d..352e762 100644 --- a/services/comfyui_history.py +++ b/services/comfyui_history.py @@ -26,6 +26,12 @@ HISTORY_TIMEOUT = 5 PREVIEWABLE_MEDIA_TYPES = ("images", "video", "audio", "3d", "text") THREE_D_EXTENSIONS = (".obj", ".fbx", ".gltf", ".glb", ".usdz") TEXT_PREVIEW_MAX_LENGTH = 1024 +FILE_TEXT_EXTENSIONS = frozenset( + {"txt", "md", "markdown", "json", "csv", "yaml", "yml", "xml", "log"} +) +FILE_OUTPUT_MAX_REFS = 64 +FILE_OUTPUT_FIELD_MAX_LENGTH = 1024 +FILE_OUTPUT_TYPES = frozenset({"input", "output", "temp"}) def _pick_string(payload: Dict[str, Any], *keys: str) -> str: @@ -87,6 +93,77 @@ def _normalize_text_content(value: Any) -> Optional[Dict[str, Any]]: } +def _has_unsafe_file_characters(value: str) -> bool: + return any(ord(char) < 32 or ord(char) == 127 for char in value) + + +def _normalize_file_text_ref(output_ref: Any) -> Optional[Dict[str, Any]]: + if not isinstance(output_ref, dict): + return None + + raw_filename = output_ref.get("filename") + if not isinstance(raw_filename, str): + return None + if len(raw_filename) > FILE_OUTPUT_FIELD_MAX_LENGTH: + return None + filename = raw_filename.strip() + if ( + not filename + or len(filename) > FILE_OUTPUT_FIELD_MAX_LENGTH + or _has_unsafe_file_characters(filename) + or filename in {".", ".."} + or "/" in filename + or "\\" in filename + ): + return None + + suffix = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + if suffix not in FILE_TEXT_EXTENSIONS: + return None + + raw_subfolder = output_ref.get("subfolder", "") + if not isinstance(raw_subfolder, str): + return None + if len(raw_subfolder) > FILE_OUTPUT_FIELD_MAX_LENGTH: + return None + subfolder = raw_subfolder.strip() + if ( + len(subfolder) > FILE_OUTPUT_FIELD_MAX_LENGTH + or _has_unsafe_file_characters(subfolder) + or "\\" in subfolder + or subfolder.startswith("/") + or any(part in {".", ".."} for part in subfolder.split("/") if part) + ): + return None + + raw_type = output_ref.get("type", "output") + if not isinstance(raw_type, str): + return None + output_type = raw_type.strip() or "output" + if output_type not in FILE_OUTPUT_TYPES: + return None + + # SECURITY: file-backed text refs are attacker-influenced. Build only the + # existing encoded /view contract from validated fields; never trust raw URLs. + params = {"filename": filename, "type": output_type} + if subfolder: + params["subfolder"] = subfolder + + return { + "filename": filename, + "subfolder": subfolder, + "type": output_type, + "media_type": "text", + "asset_hash": "", + "asset_api_id": "", + "asset_api_required": False, + "resolution": "view", + "view_url": f"{COMFYUI_URL}/view?{urlencode(params)}", + "content": "", + "text_truncated": False, + } + + def normalize_history_output_ref( output_ref: Any, media_type: str = "images" ) -> Optional[Dict[str, Any]]: @@ -198,6 +275,13 @@ def extract_output_refs(history_item: Dict[str, Any]) -> List[Dict[str, Any]]: if normalized: results.append(normalized) + file_refs = node_output.get("files") + if isinstance(file_refs, list) and len(file_refs) <= FILE_OUTPUT_MAX_REFS: + for ref in file_refs: + normalized = _normalize_file_text_ref(ref) + if normalized: + results.append(normalized) + return results diff --git a/tests/e2e/specs/job_monitor_file_text.spec.js b/tests/e2e/specs/job_monitor_file_text.spec.js new file mode 100644 index 0000000..7bf80f1 --- /dev/null +++ b/tests/e2e/specs/job_monitor_file_text.spec.js @@ -0,0 +1,180 @@ +import { expect, test } from '@playwright/test'; +import { clickTab, mockComfyUiCore, waitForOpenClawReady } from '../utils/helpers.js'; + +async function installCommonRoutes(page, jobId, files) { + await page.route(`**/history/${jobId}`, async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + [jobId]: { + status: { status_str: 'success', completed: true }, + outputs: { + '9': { + files, + text: 'some generated text', + }, + }, + }, + }), + }); + }); + await page.route('**/openclaw/trace/**', async route => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ error: 'not_found' }), + }); + }); +} + +async function addTrackedJob(page, jobId) { + await clickTab(page, 'Jobs'); + await page.locator('input[placeholder="prompt_id"]').fill(jobId); + await page.getByText('Add').click(); + const jobRow = page.locator('.openclaw-job-row').first(); + await expect(jobRow.locator('.openclaw-kv-val.ok')).toHaveText('completed', { + timeout: 10000, + }); + return jobRow; +} + +test.describe('Job Monitor file-backed text output', () => { + test.beforeEach(async ({ page }) => { + await mockComfyUiCore(page); + await page.goto('test-harness.html'); + await waitForOpenClawReady(page); + }); + + test('renders the official files/result.txt shape as bounded inert text', async ({ page }) => { + const jobId = 'job-file-text-safe'; + const activeText = '\n' + + '[link](javascript:alert(1))\nSECRET_LOOKING_TEXT_TOKEN'; + const consoleMessages = []; + let viewRequests = 0; + page.on('console', message => consoleMessages.push(message.text())); + + await installCommonRoutes(page, jobId, [ + { filename: 'result.txt', subfolder: 'reports/2026', type: 'output' }, + ]); + await page.route('**/api/view?*', async route => { + viewRequests += 1; + await route.fulfill({ + status: 200, + contentType: 'text/plain; charset=utf-8', + body: activeText, + }); + }); + + const jobRow = await addTrackedJob(page, jobId); + const tile = jobRow.locator('.openclaw-job-output-text-file'); + await expect(tile).toBeVisible(); + await expect(tile.locator('.openclaw-job-output-text-content')).toHaveText(activeText); + await expect(tile.locator('script')).toHaveCount(0); + await expect(tile.locator('.openclaw-job-output-text-source')).toHaveAttribute( + 'href', + /\/api\/view\?.*filename=result\.txt.*subfolder=reports%2F2026/ + ); + expect(await page.evaluate(() => window.__openclawTextPwned)).toBeUndefined(); + expect(viewRequests).toBe(1); + expect(consoleMessages.join('\n')).not.toContain('SECRET_LOOKING_TEXT_TOKEN'); + }); + + test('keeps rejected MIME content unavailable with a source link and no body leak', async ({ page }) => { + const jobId = 'job-file-text-mime-rejected'; + const consoleMessages = []; + page.on('console', message => consoleMessages.push(message.text())); + + await installCommonRoutes(page, jobId, [ + { filename: 'result.md', subfolder: '', type: 'output' }, + ]); + await page.route('**/api/view?*', async route => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: '', + }); + }); + + const jobRow = await addTrackedJob(page, jobId); + const tile = jobRow.locator('.openclaw-job-output-text-file'); + await expect(tile).toBeVisible(); + await expect(tile.locator('.openclaw-job-output-text-status')).toHaveText( + 'Text preview unavailable.' + ); + await expect(tile.locator('.openclaw-job-output-text-source')).toBeVisible(); + await expect(tile).not.toContainText('SECRET_REJECTED_BODY'); + expect(consoleMessages.join('\n')).not.toContain('SECRET_REJECTED_BODY'); + }); + + test('shows loading and a deterministic truncated preview state', async ({ page }) => { + const jobId = 'job-file-text-truncated'; + let releaseResponse; + const responseGate = new Promise(resolve => { + releaseResponse = resolve; + }); + + await installCommonRoutes(page, jobId, [ + { filename: 'large.log', subfolder: '', type: 'output' }, + ]); + await page.route('**/api/view?*', async route => { + await responseGate; + await route.fulfill({ + status: 200, + contentType: 'text/plain; charset=utf-8', + body: 'x'.repeat(5000), + }); + }); + + const jobRow = await addTrackedJob(page, jobId); + const tile = jobRow.locator('.openclaw-job-output-text-file'); + await expect(tile.locator('.openclaw-job-output-text-status')).toHaveText( + 'Loading text preview...' + ); + + releaseResponse(); + await expect(tile.locator('.openclaw-job-output-text-status')).toHaveText( + 'Text preview truncated.' + ); + await expect(tile.locator('.openclaw-job-output-text-content')).toHaveText( + 'x'.repeat(4096) + ); + await expect(tile.locator('.openclaw-job-output-text-source')).toBeVisible(); + }); + + test('does not let a stale text response mutate the UI after removal', async ({ page }) => { + const jobId = 'job-file-text-stale'; + let releaseResponse; + const responseGate = new Promise(resolve => { + releaseResponse = resolve; + }); + + await installCommonRoutes(page, jobId, [ + { filename: 'stale.txt', subfolder: '', type: 'output' }, + ]); + await page.route('**/api/view?*', async route => { + await responseGate; + try { + await route.fulfill({ + status: 200, + contentType: 'text/plain; charset=utf-8', + body: 'STALE_RESPONSE_MUST_NOT_RENDER', + }); + } catch { + // Expected when lifecycle cancellation aborts the pending request. + } + }); + + const jobRow = await addTrackedJob(page, jobId); + await expect(jobRow.locator('.openclaw-job-output-text-status')).toHaveText( + 'Loading text preview...' + ); + await jobRow.getByTitle('Remove').click(); + await expect(page.locator('.openclaw-job-row')).toHaveCount(0); + + releaseResponse(); + await page.waitForTimeout(100); + await expect(page.locator('.openclaw-job-output-text-file')).toHaveCount(0); + await expect(page.locator('body')).not.toContainText('STALE_RESPONSE_MUST_NOT_RENDER'); + }); +}); diff --git a/tests/skip_policy.json b/tests/skip_policy.json index b359aa2..6d227f6 100644 --- a/tests/skip_policy.json +++ b/tests/skip_policy.json @@ -21,7 +21,8 @@ "tests.test_s100_jobs_security_contract", "tests.test_r213_jobs_endpoint", "tests.connector.test_r214_jobs_command", - "tests.test_s70_ssrf_pinning_regression" + "tests.test_s70_ssrf_pinning_regression", + "tests.test_r216_file_backed_text_output" ], "no_skip_module_metadata": { "tests.test_s58_bridge_token_lifecycle": { @@ -107,6 +108,10 @@ "tests.test_s70_ssrf_pinning_regression": { "reason": "SSRF pinning regression is a security-critical boundary test and must never degrade to skip coverage.", "review_after": "2026-10-31" + }, + "tests.test_r216_file_backed_text_output": { + "reason": "File-backed text output coverage guards untrusted file refs, bounded retrieval, and inert rendering and must remain non-skippable.", + "review_after": "2026-10-31" } } } diff --git a/tests/test_r216_file_backed_text_output.py b/tests/test_r216_file_backed_text_output.py new file mode 100644 index 0000000..de0795e --- /dev/null +++ b/tests/test_r216_file_backed_text_output.py @@ -0,0 +1,149 @@ +"""Security and compatibility contract for file-backed text outputs.""" + +import unittest + + +class TestR216FileBackedTextOutput(unittest.TestCase): + @staticmethod + def _history(files): + return { + "outputs": { + "9": { + "files": files, + "text": "some generated text", + } + } + } + + def test_official_result_txt_fixture_is_normalized_as_file_backed_text(self): + from services.comfyui_history import extract_output_refs + + outputs = extract_output_refs( + self._history( + [ + { + "filename": "result.txt", + "subfolder": "", + "type": "output", + } + ] + ) + ) + + self.assertEqual(len(outputs), 1) + self.assertEqual(outputs[0]["filename"], "result.txt") + self.assertEqual(outputs[0]["media_type"], "text") + self.assertEqual(outputs[0]["resolution"], "view") + self.assertEqual(outputs[0]["content"], "") + self.assertIn("/view?", outputs[0]["view_url"]) + self.assertIn("filename=result.txt", outputs[0]["view_url"]) + + def test_frozen_text_suffix_allowlist_is_case_insensitive(self): + from services.comfyui_history import extract_output_refs + + suffixes = ("txt", "md", "markdown", "json", "csv", "yaml", "yml", "xml", "log") + files = [ + {"filename": f"result.{suffix.upper()}", "type": "output"} + for suffix in suffixes + ] + + outputs = extract_output_refs(self._history(files)) + + self.assertEqual(len(outputs), len(suffixes)) + self.assertTrue(all(output["media_type"] == "text" for output in outputs)) + + def test_unknown_binary_and_non_mapping_file_refs_are_omitted(self): + from services.comfyui_history import extract_output_refs + + outputs = extract_output_refs( + self._history( + [ + {"filename": "image.png", "type": "output"}, + {"filename": "archive.bin", "type": "output"}, + {"filename": "README", "type": "output"}, + "result.txt", + None, + {"filename": "safe.txt", "type": "output"}, + ] + ) + ) + + self.assertEqual([output["filename"] for output in outputs], ["safe.txt"]) + self.assertNotEqual(outputs[0]["media_type"], "images") + + def test_files_container_and_field_bounds_fail_closed(self): + from services.comfyui_history import extract_output_refs + + self.assertEqual(extract_output_refs(self._history("result.txt")), []) + self.assertEqual( + extract_output_refs( + self._history( + [ + {"filename": f"result-{index}.txt", "type": "output"} + for index in range(65) + ] + ) + ), + [], + ) + + invalid_refs = [ + {"filename": "x" * 1021 + ".txt", "type": "output"}, + {"filename": "result.txt", "subfolder": "x" * 1025, "type": "output"}, + {"filename": "../result.txt", "type": "output"}, + {"filename": "folder/result.txt", "type": "output"}, + {"filename": "result.txt", "subfolder": "../private", "type": "output"}, + {"filename": "result.txt", "subfolder": "/absolute", "type": "output"}, + {"filename": "result.txt", "type": "unknown"}, + {"filename": 123, "type": "output"}, + ] + for ref in invalid_refs: + with self.subTest(ref=ref): + self.assertEqual(extract_output_refs(self._history([ref])), []) + + def test_view_url_uses_only_normalized_fields_and_ignores_raw_url(self): + from services.comfyui_history import extract_output_refs + + outputs = extract_output_refs( + self._history( + [ + { + "filename": "report 1.txt", + "subfolder": "reports/2026", + "type": "temp", + "url": "https://evil.example/secret.txt", + } + ] + ) + ) + + self.assertEqual(len(outputs), 1) + self.assertIn("filename=report+1.txt", outputs[0]["view_url"]) + self.assertIn("subfolder=reports%2F2026", outputs[0]["view_url"]) + self.assertIn("type=temp", outputs[0]["view_url"]) + self.assertNotIn("evil.example", outputs[0]["view_url"]) + + def test_field_bounds_count_unicode_code_points_and_reject_trim_bypass(self): + from services.comfyui_history import extract_output_refs + + accepted = extract_output_refs( + self._history([{"filename": "😀" * 1020 + ".txt", "type": "output"}]) + ) + self.assertEqual(len(accepted), 1) + + rejected = [ + {"filename": "😀" * 1021 + ".txt", "type": "output"}, + {"filename": " " * 1025 + "safe.txt", "type": "output"}, + { + "filename": "safe.txt", + "subfolder": " " * 1025 + "reports", + "type": "output", + }, + ] + for ref in rejected: + with self.subTest(ref_name=ref.get("filename", "")): + self.assertEqual(extract_output_refs(self._history([ref])), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/openclaw_asset_refs.js b/web/openclaw_asset_refs.js index 2d362fa..d9c8c0b 100644 --- a/web/openclaw_asset_refs.js +++ b/web/openclaw_asset_refs.js @@ -2,6 +2,10 @@ const PREVIEWABLE_MEDIA_TYPES = new Set(["images", "video", "audio", "3d", "text const THREE_D_EXTENSIONS = [".obj", ".fbx", ".gltf", ".glb", ".usdz"]; const HDR_IMAGE_EXTENSIONS = [".exr", ".hdr"]; const TEXT_PREVIEW_MAX_LENGTH = 1024; +const FILE_TEXT_EXTENSIONS = new Set(["txt", "md", "markdown", "json", "csv", "yaml", "yml", "xml", "log"]); +const FILE_OUTPUT_TYPES = new Set(["input", "output", "temp"]); +const FILE_OUTPUT_MAX_REFS = 64; +const FILE_OUTPUT_FIELD_MAX_LENGTH = 1024; function pickAssetHash(imageRef = {}) { if (!imageRef || typeof imageRef !== "object") { @@ -138,6 +142,99 @@ function normalizeTextOutputRef(value) { }; } +function hasUnsafeFileCharacters(value = "") { + return Array.from(String(value)).some((char) => { + const code = char.charCodeAt(0); + return code < 32 || code === 127; + }); +} + +function codePointLength(value = "") { + return Array.from(String(value)).length; +} + +function normalizeFileTextOutputRef(outputRef) { + if (!outputRef || typeof outputRef !== "object" || Array.isArray(outputRef)) { + return null; + } + + if (typeof outputRef.filename !== "string") { + return null; + } + if (codePointLength(outputRef.filename) > FILE_OUTPUT_FIELD_MAX_LENGTH) { + return null; + } + const filename = outputRef.filename.trim(); + if ( + !filename + || codePointLength(filename) > FILE_OUTPUT_FIELD_MAX_LENGTH + || hasUnsafeFileCharacters(filename) + || filename === "." + || filename === ".." + || filename.includes("/") + || filename.includes("\\") + ) { + return null; + } + const dotIndex = filename.lastIndexOf("."); + const suffix = dotIndex >= 0 ? filename.slice(dotIndex + 1).toLowerCase() : ""; + if (!FILE_TEXT_EXTENSIONS.has(suffix)) { + return null; + } + + if (outputRef.subfolder !== undefined && typeof outputRef.subfolder !== "string") { + return null; + } + if ( + typeof outputRef.subfolder === "string" + && codePointLength(outputRef.subfolder) > FILE_OUTPUT_FIELD_MAX_LENGTH + ) { + return null; + } + const subfolder = typeof outputRef.subfolder === "string" ? outputRef.subfolder.trim() : ""; + if ( + codePointLength(subfolder) > FILE_OUTPUT_FIELD_MAX_LENGTH + || hasUnsafeFileCharacters(subfolder) + || subfolder.includes("\\") + || subfolder.startsWith("/") + || subfolder.split("/").some((part) => part === "." || part === "..") + ) { + return null; + } + + if (outputRef.type !== undefined && typeof outputRef.type !== "string") { + return null; + } + const type = typeof outputRef.type === "string" && outputRef.type.trim() + ? outputRef.type.trim() + : "output"; + if (!FILE_OUTPUT_TYPES.has(type)) { + return null; + } + + // SECURITY: accept only validated host fields and the existing encoded /view + // seam. Raw URL or asset fields on a files entry are never fetch targets. + return { + filename, + subfolder, + type, + media_type: "text", + asset_hash: "", + asset_api_id: "", + asset_api_required: false, + resolution: "view", + unsupported_reason: "", + is_asset_backed: false, + content: "", + text_truncated: false, + viewParams: { + filename, + type, + ...(subfolder ? { subfolder } : {}), + }, + }; +} + export function normalizeComfyOutputRef(imageRef = {}, mediaType = "images") { let outputRef = imageRef; const resolvedMediaType = resolveMediaType(outputRef, mediaType); @@ -226,6 +323,16 @@ export function extractHistoryOutputRefs(historyItem = {}) { } } } + + const fileRefs = nodeOutput.files; + if (Array.isArray(fileRefs) && fileRefs.length <= FILE_OUTPUT_MAX_REFS) { + for (const fileRef of fileRefs) { + const normalized = normalizeFileTextOutputRef(fileRef); + if (normalized) { + results.push(normalized); + } + } + } } return results; diff --git a/web/openclaw_text_output.js b/web/openclaw_text_output.js new file mode 100644 index 0000000..2a44897 --- /dev/null +++ b/web/openclaw_text_output.js @@ -0,0 +1,294 @@ +export const TEXT_OUTPUT_MAX_BYTES = 64 * 1024; +export const TEXT_OUTPUT_MAX_CHARS = 4096; +export const TEXT_OUTPUT_TIMEOUT_MS = 5000; + +const ALLOWED_QUERY_KEYS = new Set(["filename", "subfolder", "type"]); +const ALLOWED_FILE_TYPES = new Set(["input", "output", "temp"]); +const ALLOWED_TEXT_EXTENSIONS = new Set([ + "txt", "md", "markdown", "json", "csv", "yaml", "yml", "xml", "log", +]); +const MAX_FILE_FIELD_CHARS = 1024; +const ACTIVE_MIME_TYPES = new Set([ + "text/html", + "text/javascript", + "text/css", + "image/svg+xml", + "application/xhtml+xml", +]); +const APPLICATION_TEXT_MIME_TYPES = new Set([ + "application/json", + "application/xml", + "application/yaml", + "application/x-yaml", +]); + +function fixedResult(status, reason = "", content = "", truncated = false) { + return { status, content, truncated, reason }; +} + +function resolveLocationHref() { + return globalThis.location?.href || "http://127.0.0.1/"; +} + +function codePointLength(value = "") { + return Array.from(String(value)).length; +} + +function hasUnsafeCharacters(value = "") { + return Array.from(String(value)).some((char) => { + const code = char.charCodeAt(0); + return code < 32 || code === 127; + }); +} + +function isSafeFilename(filename) { + if ( + !filename + || codePointLength(filename) > MAX_FILE_FIELD_CHARS + || hasUnsafeCharacters(filename) + || filename === "." + || filename === ".." + || filename.includes("/") + || filename.includes("\\") + ) { + return false; + } + const dotIndex = filename.lastIndexOf("."); + const suffix = dotIndex >= 0 ? filename.slice(dotIndex + 1).toLowerCase() : ""; + return ALLOWED_TEXT_EXTENSIONS.has(suffix); +} + +function isSafeSubfolder(subfolder) { + return codePointLength(subfolder) <= MAX_FILE_FIELD_CHARS + && !hasUnsafeCharacters(subfolder) + && !subfolder.includes("\\") + && !subfolder.startsWith("/") + && !subfolder.split("/").some((part) => part === "." || part === ".."); +} + +function validateViewUrl(input, baseHref = resolveLocationHref()) { + if (typeof input !== "string" || !input) { + return null; + } + try { + const base = new URL(baseHref); + const resolved = new URL(input, base); + if ( + resolved.origin !== base.origin + || !["http:", "https:"].includes(resolved.protocol) + || resolved.username + || resolved.password + || resolved.hash + || !resolved.pathname.endsWith("/view") + ) { + return null; + } + + const keys = Array.from(resolved.searchParams.keys()); + if (keys.some((key) => !ALLOWED_QUERY_KEYS.has(key))) { + return null; + } + if ( + resolved.searchParams.getAll("filename").length !== 1 + || !isSafeFilename(resolved.searchParams.get("filename") || "") + || resolved.searchParams.getAll("type").length !== 1 + || !ALLOWED_FILE_TYPES.has(resolved.searchParams.get("type") || "") + || resolved.searchParams.getAll("subfolder").length > 1 + || !isSafeSubfolder(resolved.searchParams.get("subfolder") || "") + ) { + return null; + } + return resolved; + } catch { + return null; + } +} + +export function resolveTextOutputViewUrl(input) { + return validateViewUrl(input)?.href || ""; +} + +function isAllowedTextMime(value) { + const parts = String(value || "").split(";"); + const mime = parts.shift()?.trim().toLowerCase() || ""; + if (!mime || ACTIVE_MIME_TYPES.has(mime)) { + return false; + } + for (const parameter of parts) { + const separator = parameter.indexOf("="); + if (separator < 0) { + continue; + } + const key = parameter.slice(0, separator).trim().toLowerCase(); + const rawValue = parameter.slice(separator + 1).trim(); + const parameterValue = rawValue.replace(/^"|"$/g, "").toLowerCase(); + if (key === "charset" && !["utf-8", "utf8"].includes(parameterValue)) { + return false; + } + } + if (mime.startsWith("text/")) { + return true; + } + return APPLICATION_TEXT_MIME_TYPES.has(mime); +} + +function parseDeclaredLength(value) { + const raw = String(value || "").trim(); + if (!/^\d+$/.test(raw)) { + return null; + } + const parsed = Number(raw); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +async function cancelReader(reader) { + try { + await reader?.cancel?.(); + } catch { + // Best-effort release after fail-closed size rejection. + } +} + +function getByteChunkLength(value) { + const isByteView = ArrayBuffer.isView(value) && value?.BYTES_PER_ELEMENT === 1; + const hasByteTag = Object.prototype.toString.call(value) === "[object Uint8Array]"; + if (!isByteView && !hasByteTag) { + return null; + } + const byteLength = Number(value?.byteLength); + return Number.isSafeInteger(byteLength) && byteLength >= 0 ? byteLength : null; +} + +function copyByteChunk(value) { + try { + return Uint8Array.from(value); + } catch { + return null; + } +} + +export async function loadBoundedTextOutput(viewUrl, { fetchFn = globalThis.fetch, signal } = {}) { + const validatedHref = resolveTextOutputViewUrl(viewUrl); + const validatedUrl = validatedHref ? new URL(validatedHref) : null; + if (!validatedUrl || typeof fetchFn !== "function") { + return fixedResult("unavailable", "invalid_url"); + } + + const controller = new AbortController(); + let timedOut = false; + let cancelled = false; + const onCallerAbort = () => { + cancelled = true; + controller.abort(); + }; + if (signal?.aborted) { + return fixedResult("unavailable", "cancelled"); + } + signal?.addEventListener?.("abort", onCallerAbort, { once: true }); + const timeoutId = setTimeout(() => { + timedOut = true; + controller.abort(); + }, TEXT_OUTPUT_TIMEOUT_MS); + + try { + // SECURITY: do not route through the generic JSON/text wrapper; it uses an + // unbounded body reader. This loader owns every byte and never adds tokens. + const response = await fetchFn(validatedUrl.href, { + method: "GET", + credentials: "same-origin", + redirect: "error", + headers: { + Accept: "text/plain, text/markdown, text/csv, application/json, application/xml, application/yaml", + }, + signal: controller.signal, + }); + + if (!response || !response.ok) { + return fixedResult("unavailable", "http_error"); + } + if (response.redirected) { + return fixedResult("unavailable", "redirected"); + } + if (response.url) { + const responseUrl = validateViewUrl(response.url); + if (!responseUrl || responseUrl.href !== validatedUrl.href) { + return fixedResult("unavailable", "redirected"); + } + } + if (!isAllowedTextMime(response.headers?.get?.("content-type"))) { + return fixedResult("unavailable", "mime_rejected"); + } + + const declaredLength = parseDeclaredLength(response.headers?.get?.("content-length")); + if (declaredLength !== null && declaredLength > TEXT_OUTPUT_MAX_BYTES) { + controller.abort(); + return fixedResult("unavailable", "oversized"); + } + + if (!response.body || typeof response.body.getReader !== "function") { + return fixedResult("link_only", "stream_unavailable"); + } + + const reader = response.body.getReader(); + const chunks = []; + let totalBytes = 0; + while (true) { + const step = await reader.read(); + if (step.done) { + break; + } + const chunkLength = getByteChunkLength(step.value); + if (chunkLength === null) { + await cancelReader(reader); + return fixedResult("link_only", "stream_unavailable"); + } + if (chunkLength > TEXT_OUTPUT_MAX_BYTES - totalBytes) { + controller.abort(); + await cancelReader(reader); + return fixedResult("unavailable", "oversized"); + } + const chunk = copyByteChunk(step.value); + if (!chunk || chunk.byteLength !== chunkLength) { + await cancelReader(reader); + return fixedResult("link_only", "stream_unavailable"); + } + totalBytes += chunkLength; + chunks.push(chunk); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + + let content; + try { + content = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return fixedResult("unavailable", "invalid_utf8"); + } + const contentCodePoints = Array.from(content); + if (contentCodePoints.length > TEXT_OUTPUT_MAX_CHARS) { + return fixedResult( + "truncated", + "display_limit", + contentCodePoints.slice(0, TEXT_OUTPUT_MAX_CHARS).join(""), + true + ); + } + return fixedResult("success", "", content, false); + } catch { + if (cancelled || signal?.aborted) { + return fixedResult("unavailable", "cancelled"); + } + if (timedOut) { + return fixedResult("unavailable", "timeout"); + } + return fixedResult("unavailable", "network_error"); + } finally { + clearTimeout(timeoutId); + signal?.removeEventListener?.("abort", onCallerAbort); + } +} diff --git a/web/tabs/job_monitor_tab.js b/web/tabs/job_monitor_tab.js index dfcfb84..1b3441b 100644 --- a/web/tabs/job_monitor_tab.js +++ b/web/tabs/job_monitor_tab.js @@ -4,6 +4,10 @@ */ import { openclawApi } from "../openclaw_api.js"; import { extractHistoryOutputRefs, isHdrImageOutputRef } from "../openclaw_asset_refs.js"; +import { + loadBoundedTextOutput, + resolveTextOutputViewUrl, +} from "../openclaw_text_output.js"; import { parseJsonSafe } from "../openclaw_utils.js"; const POLL_INTERVAL_MS = 2000; @@ -83,9 +87,17 @@ export const jobMonitorTab = { listContainer.id = "openclaw-job-list"; container.appendChild(listContainer); + let textPreviewGeneration = 0; + const textPreviewControllers = new Set(); renderJobList(); function renderJobList() { + textPreviewGeneration += 1; + const renderGeneration = textPreviewGeneration; + for (const controller of textPreviewControllers) { + controller.abort(); + } + textPreviewControllers.clear(); listContainer.innerHTML = ""; if (currentJobs.length === 0) { @@ -240,6 +252,82 @@ export const jobMonitorTab = { return; } + if (out.media_type === "text" && out.view_url) { + const safeViewUrl = resolveTextOutputViewUrl(out.view_url); + const textFileOutput = document.createElement("div"); + textFileOutput.className = "openclaw-job-output-fallback openclaw-job-output-text-file"; + textFileOutput.style.width = "200px"; + textFileOutput.style.minHeight = "80px"; + textFileOutput.style.padding = "6px"; + textFileOutput.style.fontSize = "10px"; + textFileOutput.style.lineHeight = "1.35"; + textFileOutput.style.border = "1px dashed var(--border-color)"; + textFileOutput.style.borderRadius = "6px"; + textFileOutput.style.background = "var(--comfy-menu-bg, rgba(255,255,255,0.04))"; + textFileOutput.title = out.filename || "Text output"; + + const status = document.createElement("div"); + status.className = "openclaw-job-output-text-status"; + status.textContent = "Loading text preview..."; + textFileOutput.appendChild(status); + + const content = document.createElement("div"); + content.className = "openclaw-job-output-text-content"; + content.style.marginTop = "4px"; + content.style.whiteSpace = "pre-wrap"; + content.style.overflowWrap = "anywhere"; + content.textContent = ""; + textFileOutput.appendChild(content); + + const sourceLink = document.createElement("a"); + sourceLink.className = "openclaw-job-output-text-source"; + if (safeViewUrl) { + sourceLink.href = safeViewUrl; + } + sourceLink.target = "_blank"; + sourceLink.rel = "noopener noreferrer"; + sourceLink.textContent = safeViewUrl ? "Open source" : "Source unavailable"; + sourceLink.style.display = safeViewUrl ? "inline-block" : "none"; + sourceLink.style.marginTop = "6px"; + textFileOutput.appendChild(sourceLink); + outputGrid.appendChild(textFileOutput); + + if (!safeViewUrl) { + status.textContent = "Text preview unavailable."; + return; + } + + const controller = new AbortController(); + textPreviewControllers.add(controller); + loadBoundedTextOutput(safeViewUrl, { signal: controller.signal }) + .then((result) => { + if ( + controller.signal.aborted + || renderGeneration !== textPreviewGeneration + || !textFileOutput.isConnected + ) { + return; + } + if (result.status === "success" || result.status === "truncated") { + // SECURITY: fetched bytes must remain literal text. Never + // replace this with HTML or Markdown rendering. + content.textContent = result.content; + status.textContent = result.status === "truncated" + ? "Text preview truncated." + : "Text preview."; + return; + } + content.textContent = ""; + status.textContent = result.reason === "oversized" + ? "Text preview too large." + : "Text preview unavailable."; + }) + .finally(() => { + textPreviewControllers.delete(controller); + }); + return; + } + if (out.asset_api_required) { const fallback = document.createElement("div"); fallback.className = "openclaw-job-output-fallback"; diff --git a/web/tests/unit/openclaw_asset_refs.test.js b/web/tests/unit/openclaw_asset_refs.test.js index d851539..2492a4f 100644 --- a/web/tests/unit/openclaw_asset_refs.test.js +++ b/web/tests/unit/openclaw_asset_refs.test.js @@ -335,6 +335,104 @@ describe("openclaw asset refs", () => { expect(output.text_truncated).toBe(true); }); + it("normalizes the official files/result.txt shape as file-backed text", () => { + const outputs = extractHistoryOutputRefs({ + outputs: { + "9": { + files: [{ filename: "result.txt", subfolder: "", type: "output" }], + text: "some generated text", + }, + }, + }); + + expect(outputs).toHaveLength(1); + expect(outputs[0]).toEqual(expect.objectContaining({ + filename: "result.txt", + media_type: "text", + resolution: "view", + content: "", + text_truncated: false, + viewParams: { filename: "result.txt", type: "output" }, + })); + }); + + it("accepts only bounded allowlisted text file refs", () => { + const suffixes = ["txt", "md", "markdown", "json", "csv", "yaml", "yml", "xml", "log"]; + const files = [ + ...suffixes.map((suffix) => ({ filename: `result.${suffix.toUpperCase()}`, type: "output" })), + { filename: "image.png", type: "output" }, + { filename: "archive.bin", type: "output" }, + { filename: "README", type: "output" }, + "result.txt", + null, + ]; + + const outputs = extractHistoryOutputRefs({ outputs: { "9": { files } } }); + + expect(outputs).toHaveLength(suffixes.length); + expect(outputs.every((output) => output.media_type === "text")).toBe(true); + expect(outputs.some((output) => output.media_type === "images")).toBe(false); + }); + + it("fails closed for oversized files containers and unsafe file fields", () => { + const oversized = Array.from({ length: 65 }, (_, index) => ({ + filename: `result-${index}.txt`, + type: "output", + })); + expect(extractHistoryOutputRefs({ outputs: { "9": { files: oversized } } })).toEqual([]); + + const invalidRefs = [ + { filename: `${"x".repeat(1021)}.txt`, type: "output" }, + { filename: "result.txt", subfolder: "x".repeat(1025), type: "output" }, + { filename: "../result.txt", type: "output" }, + { filename: "folder/result.txt", type: "output" }, + { filename: "result.txt", subfolder: "../private", type: "output" }, + { filename: "result.txt", subfolder: "/absolute", type: "output" }, + { filename: "result.txt", type: "unknown" }, + { filename: 123, type: "output" }, + ]; + for (const ref of invalidRefs) { + expect(extractHistoryOutputRefs({ outputs: { "9": { files: [ref] } } })).toEqual([]); + } + }); + + it("builds file text view params only from normalized fields", () => { + const output = extractHistoryOutputRefs({ + outputs: { + "9": { + files: [{ + filename: "report 1.txt", + subfolder: "reports/2026", + type: "temp", + url: "https://evil.example/secret.txt", + }], + }, + }, + })[0]; + + expect(output.viewParams).toEqual({ + filename: "report 1.txt", + subfolder: "reports/2026", + type: "temp", + }); + expect(JSON.stringify(output)).not.toContain("evil.example"); + }); + + it("counts Unicode code points consistently and rejects trim-based bound bypasses", () => { + expect(extractHistoryOutputRefs({ + outputs: { "9": { files: [{ filename: `${"😀".repeat(1020)}.txt`, type: "output" }] } }, + })).toHaveLength(1); + + const rejected = [ + { filename: `${"😀".repeat(1021)}.txt`, type: "output" }, + { filename: `${" ".repeat(1025)}safe.txt`, type: "output" }, + { filename: "safe.txt", subfolder: `${" ".repeat(1025)}reports`, type: "output" }, + ]; + for (const ref of rejected) { + expect(extractHistoryOutputRefs({ outputs: { "9": { files: [ref] } } })).toEqual([]); + } + }); + it("detects HDR image refs by filename suffix without treating hashes as HDR", () => { expect(isHdrImageFilename("render.EXR")).toBe(true); expect(isHdrImageFilename("studio.hdr")).toBe(true); diff --git a/web/tests/unit/openclaw_text_output.test.js b/web/tests/unit/openclaw_text_output.test.js new file mode 100644 index 0000000..995b7da --- /dev/null +++ b/web/tests/unit/openclaw_text_output.test.js @@ -0,0 +1,329 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + TEXT_OUTPUT_MAX_BYTES, + TEXT_OUTPUT_MAX_CHARS, + TEXT_OUTPUT_TIMEOUT_MS, + loadBoundedTextOutput, +} from "../../openclaw_text_output.js"; + +const encoder = new TextEncoder(); + +function makeHeaders(values = {}) { + const normalized = new Map( + Object.entries(values).map(([key, value]) => [key.toLowerCase(), String(value)]) + ); + return { + get(name) { + return normalized.get(String(name).toLowerCase()) ?? null; + }, + }; +} + +function makeResponse(chunks, { + contentType = "text/plain; charset=utf-8", + contentLength = null, + ok = true, + status = 200, + redirected = false, + url = "/api/view?filename=result.txt&type=output", + withStream = true, +} = {}) { + const queue = chunks.map((chunk) => ( + chunk instanceof Uint8Array ? chunk : encoder.encode(String(chunk)) + )); + const reader = { + read: vi.fn(async () => ( + queue.length ? { done: false, value: queue.shift() } : { done: true, value: undefined } + )), + cancel: vi.fn(async () => undefined), + }; + const text = vi.fn(async () => "UNBOUNDED_READER_MUST_NOT_RUN"); + const arrayBuffer = vi.fn(async () => new ArrayBuffer(0)); + const headers = { + "content-type": contentType, + ...(contentLength == null ? {} : { "content-length": contentLength }), + }; + return { + ok, + status, + redirected, + url: new URL(url, window.location.href).href, + headers: makeHeaders(headers), + body: withStream ? { getReader: () => reader } : null, + text, + arrayBuffer, + __reader: reader, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("bounded text output loader", () => { + it("streams same-origin /view text with fixed safe fetch options", async () => { + const response = makeResponse(["hello ", "world"]); + const fetchFn = vi.fn(async () => response); + + const result = await loadBoundedTextOutput( + "/api/view?filename=result.txt&type=output", + { fetchFn } + ); + + expect(result).toEqual({ + status: "success", + content: "hello world", + truncated: false, + reason: "", + }); + expect(fetchFn).toHaveBeenCalledWith( + expect.stringMatching(/^http:\/\/localhost:\d+\/api\/view\?/), + expect.objectContaining({ + method: "GET", + credentials: "same-origin", + redirect: "error", + signal: expect.any(AbortSignal), + }) + ); + expect(response.text).not.toHaveBeenCalled(); + expect(response.arrayBuffer).not.toHaveBeenCalled(); + }); + + it("keeps active-content-looking text literal and enforces the display cap", async () => { + const active = ' [link](javascript:alert(1))\u001b[31m'; + const longText = active + "x".repeat(TEXT_OUTPUT_MAX_CHARS + 100); + const fetchFn = vi.fn(async () => makeResponse([longText], { + contentType: "text/markdown", + url: "/view?filename=result.md&type=output", + })); + + const result = await loadBoundedTextOutput( + "/view?filename=result.md&type=output", + { fetchFn } + ); + + expect(result.status, JSON.stringify(result)).toBe("truncated"); + expect(result.truncated).toBe(true); + expect(result.content).toHaveLength(TEXT_OUTPUT_MAX_CHARS); + expect(result.content).toContain("