Compare commits

...
Author SHA1 Message Date
Hanzo AI ffea1234a8 upload: 100MB cap — 4k masters run 30-60MB and belong in the library 2026-07-17 12:21:51 -07:00
9993da806e chat: surface the server's error message — a 402 says where to add credits (#19)
Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 12:14:44 -07:00
b5bbbfd724 library: dotfiles are never assets (#18)
The indexer skips them; _safe_asset refuses them (one gate for file serving,
fix sources, and every other path lookup). AppleDouble forks carry image
extensions, serve resource-fork bytes, and rendered 0x0 across the library.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 12:14:02 -07:00
73abeb1109 chat: same-origin /v1/agent bridge — the cookie never leaves the host (#17)
The SPA called the gateway cross-origin; the session cookie is host-only, so
every signed-in user got 403 on their first message (401 once the cookie was
widened — the gateway takes bearers, not cookies). The bridge forwards the
caller's bearer server-side, the wallet pattern. VITE_AGENT_API still selects
direct mode.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 11:52:55 -07:00
Hanzo AI f75cb4b0cc studio-chat: keep renders reachable on mobile (library becomes a bottom strip)
On a phone (<=820px) the library rail was display:none, so a user could chat and
dispatch renders but never see them. Now the body stacks (chat fills, library
drops under it) and the rail shows as a compact scrollable bottom strip — but only
when there is something to show (jobs or assets set the `active` class), so the
empty chat state stays clean. Verified with Playwright at iPhone 14 (390x844):
empty = clean chat; with renders = 4-col strip under the composer, no overflow.
2026-07-17 09:38:42 -07:00
c2f16445f2 upload: reject dotfiles and sniff magic bytes (#16)
* upload: reject dotfiles and sniff magic bytes — the name lies, the bytes never do

AppleDouble forks (._foo.png) passed the extension check; 700+ poisoned a
library within an hour of the mirror going live. PNG/JPEG/WebP signatures
required; hidden names rejected.

* upload: size cap precedes the sniff; test payloads carry the full PNG signature

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 01:18:17 -07:00
Hanzo AI 2bf5421313 studio: serve the /v1/agent chat SPA (web/dist) at /studio
The studio-chat frontend (web/, Vite on @hanzo/ai talking to POST /v1/agent) was
repointed + committed but never served. Build it in a node stage of the Dockerfile
(-> /app/web/dist) and serve it at GET /studio, with GET /studio/assets/{name} for
the hashed bundle (immutable) and a traversal guard. Falls back to the legacy
studio_home.html when the build is absent so /studio is never a dark page. This is
the decided cutover: studio.hanzo.ai/studio becomes the /v1/agent chat app.
2026-07-17 01:13:51 -07:00
93688cac99 worklog: widen ?q= search to paths + refs, not just prompt (#15)
An ingested render row has an empty prompt, so filtering Queue & History by
filename found nothing. Widen the /v1/worklog ?q= filter to also match kind,
output_prefix, output (the landed path), and refs — so a render is findable by
its filename or source image, not only by a typed prompt. Test covers filename,
ref, and prompt matches.

Co-authored-by: Hanzo AI <ai@hanzo.ai>
2026-07-17 00:49:53 -07:00
7 changed files with 134 additions and 13 deletions
+12
View File
@@ -1,3 +1,12 @@
# ── studio-chat SPA (web/) ── the /v1/agent chat UI served at /studio. Built here
# so the image is self-contained; the small Vite bundle lands at /app/web/dist.
FROM node:20-slim AS web
WORKDIR /web
COPY web/package.json web/package-lock.json* ./
RUN npm ci 2>/dev/null || npm install
COPY web/ ./
RUN npm run build
FROM python:3.11-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -25,6 +34,9 @@ RUN chmod +x /tmp/branding/apply-branding.sh && /tmp/branding/apply-branding.sh
# Copy application code
COPY . .
# The studio-chat SPA built above → served at /studio by middleware/studio_home.py.
COPY --from=web /web/dist ./web/dist
# Vendor the Hanzo Studio custom-node packs (segmentation, controlnet, ipadapter,
# upscaling, video, utils) at their pinned commits and install their deps with the
# torch/numpy/transformers stack locked. See custom_nodes/hanzo-packs.txt.
+68 -6
View File
@@ -51,6 +51,11 @@ log = logging.getLogger("studio.home")
_HTML = Path(__file__).with_name("studio_home.html")
_ASSET_DIR = Path(__file__).parent
# The studio-chat SPA (web/, a Vite bundle on @hanzo/ai talking to /v1/agent) built
# into the image at /app/web/dist and served AT /studio. When the build is absent
# (dev checkout, an image that skipped the web stage) /studio falls back to the
# legacy studio_home.html so the route is never a dark page.
_WEB_DIST = Path(__file__).resolve().parents[1] / "web" / "dist"
_STATUSES = ("draft", "approved", "queued", "published", "deleted")
_PROV_CACHE: dict[str, tuple[float, dict]] = {} # abspath -> (mtime, provenance)
@@ -144,6 +149,10 @@ def _save_library(root: Path, lib: dict) -> None:
def _safe_asset(root: Path, rel: str) -> Path | None:
p = (root / rel).resolve()
if p.name.startswith("."):
# Dotfiles are never assets (AppleDouble forks carry image extensions
# but serve resource-fork bytes) — refuse them everywhere at once.
return None
return p if p.is_file() and str(p).startswith(str(root.resolve())) else None
@@ -679,7 +688,7 @@ async def _wallet(request: web.Request, org: str) -> dict:
return out
_MAX_UPLOAD = 30 * 1024 * 1024 # 30 MB — a sane ceiling for one render
_MAX_UPLOAD = 100 * 1024 * 1024 # 100 MB — 4k masters run 30-60MB; the cap must hold the largest real render
_UPLOAD_EXT = (".png", ".jpg", ".jpeg", ".webp")
_SUBPATH_RE = re.compile(r"[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*")
@@ -731,12 +740,29 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
async def studio_home(request: web.Request):
# no-store: the SPA HTML changes every release; without this, browsers
# (and any proxy) heuristically cache it and serve a STALE page after a
# deploy — which is why UI fixes (e.g. delete) appeared "not working" to
# a user still running yesterday's cached studio_home.html. The page is
# tiny; always serve fresh. Static assets keep their own long cache.
return web.Response(text=_HTML.read_text(), content_type="text/html",
# deploy — which is why UI fixes appeared "not working" to a user still
# running yesterday's cached page. The page is tiny; always serve fresh.
# Hashed assets keep their own immutable cache. Serve the studio-chat SPA
# when built into the image; fall back to the legacy home otherwise.
index = _WEB_DIST / "index.html"
html = index.read_text() if index.is_file() else _HTML.read_text()
return web.Response(text=html, content_type="text/html",
headers={"Cache-Control": "no-store, must-revalidate"})
@routes.get("/studio/assets/{name}")
async def studio_asset(request: web.Request):
# The studio-chat SPA's hashed bundle (the hash IS the version → immutable).
name = request.match_info["name"]
assets = (_WEB_DIST / "assets").resolve()
f = (assets / name).resolve()
if f.parent != assets or not f.is_file():
return web.Response(status=404)
ctype = ("text/javascript" if name.endswith(".js")
else "text/css" if name.endswith(".css")
else "application/octet-stream")
return web.Response(body=f.read_bytes(), content_type=ctype,
headers={"Cache-Control": "public, max-age=31536000, immutable"})
@routes.get("/studio/shell.js")
async def shell_js(request: web.Request):
return _asset("shell.js", "text/javascript")
@@ -867,6 +893,29 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
org = _org_of(request)
return web.json_response(await _wallet(request, org))
@routes.post("/v1/agent")
async def agent_bridge(request: web.Request):
"""Same-origin bridge to the cloud agent orchestrator. The session cookie
stays host-only (never widened to *.hanzo.ai); this forwards the caller's
bearer server-side — the wallet pattern. Direct-to-gateway remains
available to the SPA via VITE_AGENT_API."""
from middleware.gpu_dispatch import CLOUD, _bearer
tok = _bearer(request)
if not tok:
return web.json_response({"error": "auth required"}, status=401)
body = await request.read()
if len(body) > 1 << 20:
return web.json_response({"error": "body too large"}, status=413)
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=180)) as s:
async with s.post(f"{CLOUD}/v1/agent", data=body,
headers={"Authorization": tok,
"Content-Type": "application/json"}) as r:
return web.Response(status=r.status, body=await r.read(),
content_type="application/json")
except Exception:
return web.json_response({"error": "agent unreachable"}, status=502)
@routes.get("/v1/library")
async def get_library(request: web.Request):
org = _org_of(request)
@@ -955,10 +1004,19 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
name = os.path.basename(request.query.get("name", ""))
if not name or os.path.splitext(name)[1].lower() not in _UPLOAD_EXT:
return web.json_response({"error": "image name required (.png/.jpg/.jpeg/.webp)"}, status=400)
if name.startswith("."):
# `._foo.png` (AppleDouble fork) and other dotfiles pass the extension
# check but are not renders.
return web.json_response({"error": "hidden file"}, status=400)
if not data:
return web.json_response({"error": "empty body"}, status=400)
if len(data) > _MAX_UPLOAD:
return web.json_response({"error": "file too large"}, status=413)
if not (data[:8] == b"\x89PNG\r\n\x1a\n"
or data[:3] == b"\xff\xd8\xff"
or (data[:4] == b"RIFF" and data[8:12] == b"WEBP")):
# The name lies sometimes; the bytes never do.
return web.json_response({"error": "not an image"}, status=400)
out_root = Path(folder_paths.get_org_output_directory(org))
dest = (out_root / sub / name).resolve()
if not str(dest).startswith(str(out_root.resolve()) + os.sep):
@@ -1199,7 +1257,11 @@ def add_studio_home_routes(routes: web.RouteTableDef, server) -> None:
out.append(row)
q = (request.query.get("q") or "").lower().strip()
if q:
out = [r for r in out if q in (r.get("prompt", "") or "").lower() or q in (r.get("kind", "") or "")]
# search prompt + kind + the render's paths (output_prefix/output) + refs, so
# an ingested render with an empty prompt is still findable by its filename.
out = [r for r in out if q in " ".join(str(x) for x in
[r.get("prompt", ""), r.get("kind", ""), r.get("output_prefix", ""),
r.get("output", ""), *(r.get("refs") or [])]).lower()]
return web.json_response({"org": org, "items": out[:200]})
@routes.get("/v1/worklog/lineage")
+5
View File
@@ -93,6 +93,11 @@ def scan(root: str) -> list[str]:
found: list[str] = []
for base, _dirs, files in os.walk(root):
for f in files:
if f.startswith("."):
# Dotfiles are never assets: `._foo.png` AppleDouble forks ride
# along with mac transfers, carry an image extension, and are
# resource-fork bytes that render 0x0 in the library.
continue
ext = os.path.splitext(f)[1].lower()
full = os.path.join(base, f)
rel = os.path.relpath(full, root)
@@ -505,7 +505,7 @@ async def test_library_upload_roundtrip_dedup_and_tenant_isolation(tmp_path, mon
assert (await r2.json()) == {"ok": True, "existed": True, "path": "renders/r1.png"}
# tenant isolation: org B never sees org A's file; its own upload is a separate tree
assert (await client.get("/v1/library/file?path=renders/r1.png", headers={"X-Org": "globex"})).status == 404
rg = await client.post("/v1/library/upload?name=r1.png", data=b"\x89PNGglobex",
rg = await client.post("/v1/library/upload?name=r1.png", data=b"\x89PNG\r\n\x1a\nglobex",
headers={"X-Org": "globex", "Content-Type": "application/octet-stream"})
assert (await rg.json())["path"] == "renders/r1.png"
# org A's bytes are untouched by org B's same-named upload
@@ -523,7 +523,7 @@ async def test_library_upload_rejects_traversal_bad_name_and_oversize(tmp_path,
# non-image name -> 400
assert (await client.post("/v1/library/upload?name=notes.txt", data=b"hi", headers=hdr)).status == 400
# a traversal-y NAME is neutralized to its basename, lands safely under renders/
r = await client.post("/v1/library/upload?name=../../evil.png", data=b"\x89PNGevil", headers=hdr)
r = await client.post("/v1/library/upload?name=../../evil.png", data=b"\x89PNG\r\n\x1a\nevil", headers=hdr)
assert (await r.json())["path"] == "renders/evil.png"
assert (Path(sh.folder_paths.get_org_output_directory("acme")) / "renders" / "evil.png").is_file()
# size cap -> 413
@@ -566,3 +566,25 @@ async def test_library_upload_indexes_into_assets_and_worklog(tmp_path, monkeypa
wl3 = await (await client.get("/v1/worklog", headers={"X-Org": "acme"})).json()
assert sum(1 for x in wl3["items"] if x["output_prefix"] == "renders/shot.png") == 1
await client.close()
@pytest.mark.asyncio
async def test_worklog_search_matches_paths_and_refs_not_just_prompt(tmp_path, monkeypatch):
_orgaware(tmp_path, monkeypatch)
(Path(sh.folder_paths.get_org_output_directory("acme")) / "library.json").write_text('{"assets":[]}')
# an ingested render: empty prompt, findable only by its filename / ref
sh.worklog.record("acme", kind="render", prompt="", refs=["srcphoto.png"], uploads=[],
parents=[], output_prefix="renders/model_shot.png", pid="r1", status="done")
sh.worklog.record("acme", kind="fix", prompt="brighten the sky", refs=[], uploads=[],
parents=[], output_prefix="fixes/x", pid="r2")
client = await _client(_FakeServer())
async def ids(q):
r = await client.get("/v1/worklog?q=" + q, headers={"X-Org": "acme"})
return [it["id"] for it in (await r.json())["items"]]
assert await ids("model_shot") == ["r1"] # by output filename, empty prompt
assert await ids("srcphoto") == ["r1"] # by reference
assert await ids("brighten") == ["r2"] # prompt search still works
assert await ids("nomatch") == []
await client.close()
+1 -1
View File
@@ -92,7 +92,7 @@ export function App() {
</div>
</section>
<aside className="rail">
<aside className={`rail${assets.length || jobs.length ? ' active' : ''}`}>
{jobs.length > 0 && (
<div className="queue">
<div className="rail-h">Rendering ({jobs.length})</div>
+16 -2
View File
@@ -10,11 +10,25 @@
// VITE_STUDIO_API / VITE_AGENT_API override the bases for local dev.
const BASE = (import.meta.env.VITE_STUDIO_API ?? '').replace(/\/$/, '')
const AGENT_API = (import.meta.env.VITE_AGENT_API ?? 'https://api.hanzo.ai').replace(/\/$/, '')
// Default: same-origin through the studio's /v1/agent bridge — the host-only
// session cookie authorizes it and the middleware forwards the bearer, so no
// parent-domain cookie and no gateway CORS dependency. VITE_AGENT_API points
// straight at the gateway for direct mode.
const AGENT_API = (import.meta.env.VITE_AGENT_API ?? '').replace(/\/$/, '')
async function j<T>(url: string, init?: RequestInit): Promise<T> {
const r = await fetch(url, { credentials: 'include', ...init })
if (!r.ok) throw new Error(`${url} -> ${r.status}`)
if (!r.ok) {
// Surface the server's own message — a 402 says "add credits at
// pay.hanzo.ai", which beats a bare status code.
let msg = `${url} -> ${r.status}`
try {
const b = await r.json()
const m = b?.error?.message ?? b?.error ?? b?.message
if (typeof m === 'string' && m) msg = m
} catch { /* keep the status line */ }
throw new Error(msg)
}
return r.json() as Promise<T>
}
+8 -2
View File
@@ -42,7 +42,13 @@ body { background: var(--bg); color: var(--text); font: 15px/1.5 -apple-system,
.empty { color: var(--dim); font-size: 13px; }
@media (max-width: 820px) {
.body { grid-template-columns: 1fr; }
.rail { display: none; }
/* Chat fills; the library drops UNDER it as a compact strip so renders stay
reachable on a phone. Empty (no jobs, no assets) it hides entirely — the
`active` class is set only when there is something to show — so the empty
chat state stays clean. */
.body { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; }
.chat { border-right: 0; }
.rail { display: none; }
.rail.active { display: block; border-top: 1px solid var(--line); max-height: 42vh; overflow-y: auto; }
.rail.active .grid { grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); }
}