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 = 0

View on GitHub (pinned to d026163f58)

Solutions

  1. Fetch the run detail via GET /agent-runs/{run_id} and use asset names exactly as recorded.
  2. Verify the file exists under <runs_directory>/<run_id>/assets/ on disk.
  3. Send only the bare filename (no path separators) so basename() does not mangle it.
  4. 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

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.