feat(web): build the container with voice and recall, and serve Ollama beside it - #108
Merged
Conversation
…a beside it The compose stack already ran the server and the UI. It did not run a model, and the image was built with the default features, so the microphone and the conversation search were compiled out and their endpoints answered 501. That looks like a broken image rather than a deliberate build. zorp-web/Dockerfile now builds with --features voice,recall and ships python3, its venv and pip for the Qwen3-ASR runtime. The runtime is not baked in: several gigabytes of weights and torch install themselves into a named volume the first time someone clicks the microphone. The image also runs as uid 1000 and creates the volume mount points before switching user, because zorp-voice refuses to set itself up as root on purpose and a named volume on a path absent from the image arrives owned by root. Four workspace members were missing from the COPY list, so cargo could not parse the workspace and the image had stopped building. compose.yml gains an ollama sidecar. It is there rather than on the host because zorp-recall requires its embedding endpoint to be loopback and enforces that four ways, and a normal compose network is not loopback. server joins the sidecar's network namespace, which gives both processes the same 127.0.0.1 without weakening a single guard. Port 7777 is published on ollama because a container sharing another's namespace cannot declare its own ports. ZORP_EMBED_URL is deliberately not overridable. Verified from the host, not with docker exec: - docker compose config validates, one stack, no shadowing - all three containers start, ui on 8080, ollama publishing 7777 - GET / is 200 at 29430 bytes, /dist/main.js 200 at 139899, /styles.css 200 at 62016 - GET /api/capabilities with no token is 401, with the token 200 and reports voice available with setup available - GET /api/recall/search answers with the embedder's own 404 for the unpulled model, which is the route being live rather than 501 - docker compose exec server id is uid=1000(zorp) - compose refuses to start with ZORP_WEB_TOKEN unset Claude-Session: https://claude.ai/code/session_01KGPVQ8wUG7h36zashWYCp4
… the API The stack had never worked from a browser. The UI image shipped nginx with no configuration at all, and index.html resolves the API base to the empty string when the page is served over http, meaning same origin. So every API request landed on the static file server and 404'd, and the page reported "no zorp server here" with a suggestion to go install zorp. Neither earlier check caught it because both curled port 7777 directly rather than loading the page. Curling the API is not a test of the UI. web/nginx.conf.template serves the built files and proxies /api/ through to the server, which lives in the sidecar's network namespace and so is reached by the namespace owner's name. Buffering is off on that path and the read timeout is an hour, because /api/sessions/:id/events is server sent events and a turn can think for a long time before it says anything. The proxy attaches the token itself. ZORP_API_TOKEN had only ever been a commented-out line in index.html and nothing injected it, so even with a working proxy the page would have collected 401s. Doing it in nginx keeps the token out of the browser entirely. Because the proxy authorizes on the browser's behalf, both published ports are now bound to the host's loopback. Whatever can reach port 8080 is already talking to the agent, and the agent runs commands on this machine. Verified from the host with the page loaded, not by curling 7777: - GET / through the UI is 200 - GET /api/capabilities on the UI's own origin, with no token sent by the client, returns real JSON reporting voice available - POST /api/sessions on that origin returns a session id - GET /api/voice/status answers - docker compose ps shows 127.0.0.1:8080->80 and 127.0.0.1:7777->7777 Claude-Session: https://claude.ai/code/session_01KGPVQ8wUG7h36zashWYCp4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The compose stack already ran the server and the UI. It did not run a model,
the image was built with the default features so the microphone and the
conversation search were compiled out and answered 501, and the UI container
shipped nginx with no configuration, so the page could never reach the API at
all. This makes the stack work.
It extends what is already here. An earlier attempt added a second
docker-compose.ymlbeside the existingcompose.yml, which compose loads inpreference, so none of that work ever ran unless you passed
-f, which nodocumentation told you to do. That branch is superseded and deleted.
The image
zorp-web/Dockerfilebuilds with--features voice,recalland shipspython3with its venv and pip. The Qwen3-ASR runtime is deliberately notbaked in: several gigabytes of weights and torch install themselves into a
named volume the first time somebody clicks the microphone, which keeps the
image small and the rebuild cheap.
The container runs as uid 1000. That is not just good practice here.
zorp-voice's setup callsrefuse_root_for(geteuid().is_root()), so underroot the microphone would not work at all and nothing would say why. The image
also creates
/home/zorp/stateand/home/zorp/voiceand chowns them beforeswitching user, because a named volume mounted onto a path absent from the
image arrives owned by root and the server then cannot write its databases.
Four workspace members,
zorp-search,zorp-skill,zorp-recallandzorp-voice, were missing from the COPY list. Cargo cannot parse a workspacewith absent members, so this file had stopped building before this change.
The page could not reach the API
The UI image was nginx with no config.
index.htmlresolves the API base tothe empty string when the page is served over http, meaning same origin, so
every API request landed on the static file server and 404'd. The page
reported "no zorp server here" and suggested going away and installing zorp.
The earlier verification did not catch this, and neither did mine at first,
because both curled port 7777 directly rather than loading the page. Curling
the API is not a test of the UI. This is the same shape of mistake as
verifying with
docker execfrom inside the container.web/nginx.conf.templateserves the built files and proxies/api/to theserver. Buffering is off on that path and the read timeout is an hour, because
/api/sessions/:id/eventsis server sent events and a turn can think for along time before it says anything.
The proxy attaches the token itself.
ZORP_API_TOKENhad only ever been acommented-out line in
index.htmland nothing injected it, so even with aworking proxy the page would have collected 401s. Doing it in nginx keeps the
token out of the browser entirely.
The sidecar
compose.ymlgains anollamaservice serving both the chat model and theembeddings the conversation search needs. It is in the stack rather than on
the host because
zorp-recallrequires its embedding endpoint to be loopbackand enforces that four ways: the written form is checked, then the resolution,
the HTTP client gets a resolver that answers for one host and port and does no
lookup, redirects are off and proxy-from-env is off. A normal compose network
is not loopback, so recall would refuse a separate service reached by name.
serverjoins the sidecar's network namespace withnetwork_mode: "service:ollama". Both processes then see the same127.0.0.1and not one guard has to be weakened.
ZORP_EMBED_URLis fixed rather thanoverridable for the same reason. Port 7777 is published on
ollamabecause acontainer sharing another's network namespace cannot declare its own ports, so
the namespace owner has to, and that is also the name nginx proxies to.
The bind, the token, and the published ports
The server binds
0.0.0.0inside its own network namespace, which is whatmakes the published port reachable at all. Because a reachable
zorp-webisagent-driven shell access to whatever the process can see,
zorp-web/src/main.rsrequires--tokenon any non-loopback bind and refusesto start without one. Compose uses a required variable so the stack refuses to
come up rather than coming up unprotected.
Because the proxy authorizes on the browser's behalf, both published ports are
bound to the host's loopback:
127.0.0.1:8080and127.0.0.1:7777. Whatevercan reach port 8080 is already talking to the agent. Anyone wanting the UI
reachable from another machine should put their own authenticating proxy in
front rather than widening these bindings.
None of this is the loopback rule that recall and voice enforce, and that rule
is untouched. Those still talk to
127.0.0.1inside the shared namespace, andnothing here loosens
LoopbackUrl,LoopbackResolver,redirects(0),try_proxy_from_env(false)or the canary tests.Verification
Run from the host with the page actually loaded, not with
docker execandnot by curling 7777 directly.
docker compose configvalidates. One stack, no shadowing warning.docker compose psshows127.0.0.1:8080->80on
uiand127.0.0.1:7777->7777onollama, withserverholding noports of its own as designed.
turn runs against a model through it.
GET /through the UI is 200.GET /api/capabilitieson the UI's own origin, with the client sending notoken, returns real JSON reporting voice available with setup available.
POST /api/sessionson that origin returns a session id.GET /api/voice/statusanswers.GET /api/capabilitiesstraight at 7777 with no token is 401, with thetoken 200.
GET /api/recall/searchanswers with the embedder's own 404 for theunpulled model, which is the route being live rather than 501. Pulling the
model is a documented step.
docker compose exec server idisuid=1000(zorp) gid=1000(zorp).ZORP_WEB_TOKENunset, compose refuses:required variable ZORP_WEB_TOKEN is missing a value.No Rust or TypeScript source changed. The root
Dockerfile, which builds thereleased agent image, is untouched.
https://claude.ai/code/session_01KGPVQ8wUG7h36zashWYCp4