feat(ui): preview bounded text outputs

This commit is contained in:
rookiestar28
2026-07-11 00:24:40 +08:00
parent 02b0c4d2d6
commit fd82dd7fef
15 changed files with 1346 additions and 6 deletions
+2 -1
View File
@@ -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) |
+2 -2
View File
@@ -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.
+4 -1
View File
@@ -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
+1
View File
@@ -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:<folder_name>` 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.
+1
View File
@@ -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.
</details>
+1 -1
View File
@@ -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
+84
View File
@@ -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
@@ -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 = '<script>window.__openclawTextPwned = true</script>\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: '<script>SECRET_REJECTED_BODY</script>',
});
});
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');
});
});
+6 -1
View File
@@ -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"
}
}
}
+149
View File
@@ -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()
+107
View File
@@ -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;
+294
View File
@@ -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);
}
}
+88
View File
@@ -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";
@@ -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);
+329
View File
@@ -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 = '<script>window.pwned=true</script> [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("<script>");
});
it("counts the display cap in Unicode code points without splitting characters", async () => {
const emojiText = "😀".repeat(TEXT_OUTPUT_MAX_CHARS + 1);
const result = await loadBoundedTextOutput(
"/api/view?filename=result.txt&type=output",
{ fetchFn: vi.fn(async () => makeResponse([emojiText])) }
);
expect(result.status).toBe("truncated");
expect(Array.from(result.content)).toHaveLength(TEXT_OUTPUT_MAX_CHARS);
expect(result.content.endsWith("😀")).toBe(true);
});
it.each([
"application/json; charset=utf-8",
"application/xml",
"application/yaml",
"application/x-yaml",
"text/csv",
"text/xml",
])("accepts an explicit textual MIME: %s", async (contentType) => {
const result = await loadBoundedTextOutput(
"/api/view?filename=result.txt&type=output",
{ fetchFn: vi.fn(async () => makeResponse(["safe"], { contentType })) }
);
expect(result.status).toBe("success");
});
it("rejects a non-UTF-8 charset declaration before reading the body", async () => {
const response = makeResponse(["SECRET_WRONG_CHARSET"], {
contentType: "text/plain; charset=iso-8859-1",
});
const result = await loadBoundedTextOutput(
"/api/view?filename=result.txt&type=output",
{ fetchFn: vi.fn(async () => response) }
);
expect(result.reason).toBe("mime_rejected");
expect(response.__reader.read).not.toHaveBeenCalled();
});
it.each([
"application/octet-stream",
"text/html",
"image/svg+xml",
"application/xhtml+xml",
"multipart/form-data",
"",
])("rejects ambiguous or active MIME without reading the body: %s", async (contentType) => {
const response = makeResponse(["SECRET_BODY"], { contentType });
const result = await loadBoundedTextOutput(
"/api/view?filename=result.txt&type=output",
{ fetchFn: vi.fn(async () => response) }
);
expect(result).toEqual({
status: "unavailable",
content: "",
truncated: false,
reason: "mime_rejected",
});
expect(response.__reader.read).not.toHaveBeenCalled();
expect(JSON.stringify(result)).not.toContain("SECRET_BODY");
});
it("rejects declared and streamed oversize responses", async () => {
const declared = makeResponse(["ignored"], {
contentLength: TEXT_OUTPUT_MAX_BYTES + 1,
});
const declaredResult = await loadBoundedTextOutput(
"/api/view?filename=result.txt&type=output",
{ fetchFn: vi.fn(async () => declared) }
);
expect(declaredResult.reason).toBe("oversized");
expect(declared.__reader.read).not.toHaveBeenCalled();
const streamed = makeResponse([
new Uint8Array(TEXT_OUTPUT_MAX_BYTES),
new Uint8Array([65]),
]);
const streamedResult = await loadBoundedTextOutput(
"/api/view?filename=result.txt&type=output",
{ fetchFn: vi.fn(async () => streamed) }
);
expect(streamedResult.reason).toBe("oversized");
expect(streamed.__reader.cancel).toHaveBeenCalled();
});
it("rejects an oversized single chunk before copying or iterating it", async () => {
const iterator = vi.fn(function* iterator() {
throw new Error("oversized chunk must not be copied");
});
const hugeChunk = {
byteLength: TEXT_OUTPUT_MAX_BYTES + 1,
BYTES_PER_ELEMENT: 1,
[Symbol.toStringTag]: "Uint8Array",
[Symbol.iterator]: iterator,
};
const response = makeResponse([]);
response.body = {
getReader: () => ({
read: vi.fn()
.mockResolvedValueOnce({ done: false, value: hugeChunk })
.mockResolvedValueOnce({ done: true, value: undefined }),
cancel: vi.fn(async () => undefined),
}),
};
const result = await loadBoundedTextOutput(
"/api/view?filename=result.txt&type=output",
{ fetchFn: vi.fn(async () => response) }
);
expect(result.reason).toBe("oversized");
expect(iterator).not.toHaveBeenCalled();
});
it("rejects invalid UTF-8 without permissive replacement decoding", async () => {
const result = await loadBoundedTextOutput(
"/api/view?filename=result.txt&type=output",
{
fetchFn: vi.fn(async () => makeResponse([
new Uint8Array([0xc3, 0x28]),
])),
}
);
expect(result.reason).toBe("invalid_utf8");
expect(result.content).toBe("");
});
it("degrades to link-only when safe streaming is unavailable", async () => {
const response = makeResponse(["SECRET_BODY"], { withStream: false });
const result = await loadBoundedTextOutput(
"/api/view?filename=result.txt&type=output",
{ fetchFn: vi.fn(async () => response) }
);
expect(result).toEqual({
status: "link_only",
content: "",
truncated: false,
reason: "stream_unavailable",
});
expect(response.text).not.toHaveBeenCalled();
expect(response.arrayBuffer).not.toHaveBeenCalled();
});
it.each([
"https://evil.example/view?filename=result.txt&type=output",
"//evil.example/view?filename=result.txt&type=output",
"/api/assets/result.txt",
"/api/view/extra?filename=result.txt&type=output",
"/api/view?url=https%3A%2F%2Fevil.example%2Fresult.txt",
"/api/view?filename=result.txt&type=output&unknown=1",
"/api/view?filename=..%2Fsecret.txt&type=output",
"/api/view?filename=result.txt&type=unknown",
"/api/view?filename=image.png&type=output",
`/api/view?filename=${"x".repeat(1021)}.txt&type=output`,
`/api/view?filename=result.txt&type=output&subfolder=${encodeURIComponent("../private")}`,
])("rejects arbitrary or non-view URLs before fetch: %s", async (url) => {
const fetchFn = vi.fn();
const result = await loadBoundedTextOutput(url, { fetchFn });
expect(result.reason).toBe("invalid_url");
expect(fetchFn).not.toHaveBeenCalled();
});
it("rejects redirected and non-success responses without reading bodies", async () => {
const redirected = makeResponse(["SECRET_REDIRECT"], { redirected: true });
const redirectResult = await loadBoundedTextOutput(
"/api/view?filename=result.txt&type=output",
{ fetchFn: vi.fn(async () => redirected) }
);
expect(redirectResult.reason).toBe("redirected");
expect(redirected.__reader.read).not.toHaveBeenCalled();
const failed = makeResponse(["SECRET_HTTP_BODY"], { ok: false, status: 500 });
const failedResult = await loadBoundedTextOutput(
"/api/view?filename=result.txt&type=output",
{ fetchFn: vi.fn(async () => failed) }
);
expect(failedResult.reason).toBe("http_error");
expect(failed.__reader.read).not.toHaveBeenCalled();
});
it("times out after the fixed budget and does not echo network errors", async () => {
vi.useFakeTimers();
const fetchFn = vi.fn((url, init) => new Promise((resolve, reject) => {
init.signal.addEventListener("abort", () => {
reject(new DOMException("SECRET_NETWORK_DETAIL", "AbortError"));
}, { once: true });
}));
const resultPromise = loadBoundedTextOutput(
"/api/view?filename=result.txt&type=output",
{ fetchFn }
);
await vi.advanceTimersByTimeAsync(TEXT_OUTPUT_TIMEOUT_MS);
const result = await resultPromise;
expect(result.reason).toBe("timeout");
expect(JSON.stringify(result)).not.toContain("SECRET_NETWORK_DETAIL");
});
it("honors caller cancellation without leaking error details", async () => {
const external = new AbortController();
const fetchFn = vi.fn((url, init) => new Promise((resolve, reject) => {
init.signal.addEventListener("abort", () => {
reject(new DOMException("SECRET_CANCEL_DETAIL", "AbortError"));
}, { once: true });
}));
const resultPromise = loadBoundedTextOutput(
"/api/view?filename=result.txt&type=output",
{ fetchFn, signal: external.signal }
);
external.abort();
const result = await resultPromise;
expect(result.reason).toBe("cancelled");
expect(JSON.stringify(result)).not.toContain("SECRET_CANCEL_DETAIL");
});
});