abi/screenshot-to-code · warning · HTTPException
Asset not found
Error message
Asset not found
What it means
Raised by GET /agent-runs/{run_id}/assets/{filename} (404) when the containment checks pass but no regular file exists at assets/{basename(filename)}. Usually it simply means the requested asset name is not one this run produced; it also fires for traversal-style inputs, because basename() reduces '../x' to 'x' which does not exist.
Source
Thrown at backend/routes/agent_runs.py:224
for candidate in ("final_selfcontained.html", "final.html"):
path = os.path.join(run_dir, candidate)
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
return HTMLResponse(content=f.read())
raise HTTPException(status_code=404, detail="Run has no captured output")
@router.get("/agent-runs/{run_id}/assets/{filename}")
async def get_agent_run_asset(run_id: str, filename: str) -> FileResponse:
_fetch_run(run_id)
assets_dir = os.path.join(get_agent_runs_directory(), run_id, "assets")
# basename() strips any traversal components before the containment check.
safe_name = os.path.basename(filename)
path = os.path.realpath(os.path.join(assets_dir, safe_name))
if not path.startswith(os.path.realpath(assets_dir) + os.sep):
raise HTTPException(status_code=400, detail="Invalid asset path")
if not os.path.isfile(path):
raise HTTPException(status_code=404, detail="Asset not found")
return FileResponse(path)
@router.post("/agent-runs/prune", response_model=PruneAgentRunsResponse)
async def prune_agent_runs(request: PruneAgentRunsRequest) -> PruneAgentRunsResponse:
if request.max_age_days < 1:
raise HTTPException(status_code=400, detail="max_age_days must be >= 1")
runs_directory = get_agent_runs_directory()
if not os.path.isdir(runs_directory):
return PruneAgentRunsResponse(deleted_count=0, freed_bytes=0)
cutoff_timestamp = (
datetime.now() - timedelta(days=request.max_age_days)
).timestamp()
deleted_count = 0
freed_bytes = 0View on GitHub (pinned to d026163f58)
Solutions
- Fetch the run detail via GET /agent-runs/{run_id} and use asset names exactly as recorded.
- Verify the file exists under <runs_directory>/<run_id>/assets/ on disk.
- Send only the bare filename (no path separators) so basename() does not mangle it.
- If assets were pruned, re-run generation to recreate them.
Example fix
# before GET /agent-runs/run_.../assets/images/logo.png # basename strips dir -> 404 # after GET /agent-runs/run_.../assets/logo.png # bare filename that exists in assets/
Defensive patterns
Strategy: validation
Validate before calling
detail = client.get(f"/agent-runs/{run_id}").json()
# use names exactly as recorded by the run (e.g. from events/assets metadata)
valid_names = get_recorded_asset_names(detail)
if filename not in valid_names:
raise LookupError(f"asset {filename!r} not part of run {run_id}") Type guard
def asset_name_is_plain(filename: str) -> bool:
return isinstance(filename, str) and filename == os.path.basename(filename) and bool(filename.strip()) Try / catch
resp = client.get(f"/agent-runs/{run_id}/assets/{filename}")
if resp.status_code == 404:
filename = pick_fresh_asset_name(run_id) # re-sync with run detail
resp = client.get(f"/agent-runs/{run_id}/assets/{filename}")
resp.raise_for_status() Prevention
- Derive asset URLs from current run metadata, not cached values.
- Send only the bare filename.
- Re-fetch run details after re-generation since filenames change.
When it happens
Trigger: Requesting a mistyped or wrong-run asset name; referencing an asset from a different run's manifest; traversal payloads (../..) after basename stripping; assets deleted by prune or manual cleanup.
Common situations: Frontend caches asset URLs after a re-run produced different filenames; copy-paste of asset names between runs; partial cleanup of a run directory.
Related errors
AI-assisted analysis of abi/screenshot-to-code@d026163f58 (2026-08-14).
Data as JSON: /api/errors/73699c711504f5d5.
Report an issue: GitHub.