mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
feat(ui): preview bounded text outputs
This commit is contained in:
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user