Commit Graph

12 Commits

Author SHA1 Message Date
Lucio Lelii 1eedcb8964 Reclaim previews nobody is using any more
Nothing gave an instance back. A flow that ended, or an agent that simply stopped
calling, left its service running and its copy on disk until somebody noticed -
which is how dev-server-instances became the one volume that only ever grows.

Two clocks, because they answer different questions. Idle asks whether anybody
is still looking: a preview request or an MCP call resets it, and running out
stops the process while keeping the copy, so an evaluator who stepped away for a
meeting pays a restart and not a reinstall. The absolute one asks whether this
has been here long enough to be forgotten, and discards everything - the only
thing in the whole stack that hands disk back on its own.

Asking after an instance counts as using it, not only preview traffic. The
browser reaches a running service straight on its port, which the worker never
sees, so an agent mid-loop would otherwise look idle to the clock watching it.

What the sweep reclaimed is logged. A preview that vanishes without a word is
indistinguishable from one that broke, and the person it vanished under has no
way to tell the difference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 17:12:05 +02:00
Lucio Lelii 2e34c209e6 Let a person open the preview an execution is running
Until now a running instance was reachable only from inside the Docker network:
the browser-mcp could see it, a human could not. That was the hole under the
whole human-evaluation node - it asks somebody to judge software, with no
software to judge.

The route rides the gateway that already exists, on 443 with the certificate
already issued, rather than publishing a port per execution. Twenty open ports
on an institutional VM is the kind of thing a firewall refuses; one path does
not. The cost is that the previewed application lives under a prefix, which is
acceptable here because the agent writes it under our own instructions.

Authorisation is the part that needed care. A browser attaches no bearer token,
and the execution key is no secret - rootExecutionId is on screen in the editor
and in every event the flow logs - so each preview carries a token of its own,
issued when the service starts, swapped on first visit for an HttpOnly cookie
scoped to that one preview's path, and gone from the address bar afterwards so
it cannot travel onward in Referer. A restart invalidates the previous link,
because a port handed back to the allocator could be serving someone else.

One defect found by walking the whole path through the real gateway rather than
testing the proxy alone: the redirect pointed at the path the proxy receives,
which the gateway has already stripped, so the browser was sent out of the mount
into the gateway's own 404. The cookie had assumed the prefix and the redirect
had not. The mount is now one parameter feeding both, with a test on the
Location header that the first round had no reason to have.

Verified end to end against the stack: link opened, cookie set, navigation
inside the application arriving at the path it expects, and 403 for anyone
holding the key but not the link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 17:00:22 +02:00
Lucio Lelii b06c9df73c Stop shipping Firefox and WebKit this server never launches
Microsoft's own Playwright image carries all three engines at 3.5 GB; this
server calls chromium.launch and nothing else, ever. Node's own slim base plus
Chromium installed on its own comes to 1.84 GB - most of that is the OS
libraries --with-deps pulls in for a sandboxed Chromium to run at all, not the
browser binary itself.

One mistake made and caught while building this: installing under
HOME=/tmp/browser-home, which is gone the instant the container starts, because
/tmp is mounted as tmpfs. Moved to PLAYWRIGHT_BROWSERS_PATH=/ms-playwright,
outside it - which is also where Microsoft's own image keeps browsers, and for
the same reason.

Verified under the production security constraints, not assumed to still hold
on a different base: seccomp profile, cap_drop ALL plus cap_add SYS_CHROOT,
read-only root, tmpfs /tmp, chromiumSandbox: true. Chromium launched sandboxed
and navigated a real page under all of them together.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 15:36:08 +02:00
Lucio Lelii 44ca1c5435 Bound the one log squid's Docker log cap does not reach
squid writes access.log and cache.log to files, not to stdout - the previous
commit already explains why - which puts them entirely outside the json-file
driver's max-size the rest of the stack just got. Left alone they grow forever
on the one service in this file without read_only: true.

Bounded the same way /tmp is bounded elsewhere: a size-capped tmpfs, lost on
restart rather than kept and unbounded. Needs uid/gid=13 because squid drops to
its own "proxy" user before opening these files, and a tmpfs mount point
defaults to root-owned like any other - which failed loudly the first time,
with squid's own answer to an unopenable log being fatal rather than a quieter
fallback. Compose's long tmpfs syntax has no field for that, so this uses the
raw mount-options string the top-level tmpfs: list already uses elsewhere in
this file.

Verified under compose itself, not a bare docker run: no errors at start, and
the log files it creates are owned by proxy:proxy as expected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 11:55:50 +02:00
Lucio Lelii 15a8fd3d0d Cap every container's logs, or they grow forever
Docker's json-file driver never rotates on its own, and every service here is
meant to run for weeks. One x-logging anchor, reused across all six services,
so the limit lives in one place instead of six copies that could drift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 11:51:34 +02:00
Lucio Lelii da7200a394 Improve MCP guidance and tool availability 2026-09-23 13:15:39 +02:00
Lucio Lelii 1ca30a0ccf Add a dependency-free service for the first run
npm ci wants a lockfile, so an agent starting from an empty directory cannot use
the example configuration until it has written a whole npm project. This one
runs a server.js on node's own http module with no install step at all, which
makes the shortest loop that exercises all three servers: write a file, start it,
open it in the browser.

It reads the port from PORT as well as from the argument. An agent writing a
server from scratch reaches for process.env.PORT far more readily than for argv,
and a preview listening on the wrong port fails its health check with nothing in
the log to explain why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:56:14 +02:00
Lucio Lelii 7f1d06ba7e Make the egress proxy start, and prove what it lets through
Two mistakes in the configuration, both of which stopped squid dead. It refuses
an allowlist that names a host and something above it - registry.npmjs.org beside
.npmjs.org - and the continuation lines that followed were then read as
directives of their own. The domains are now one directive per line, each with a
leading dot so it covers the host and everything under it.

Pointing the logs at /dev/stdout so they would reach `docker logs` was the second
one, and it is not fixable here: squid drops to the proxy user, the container's
stdout is a root-owned pipe, and squid calls being unable to open its log fatal.
They stay at squid's own paths, and the config says where to read them.

Checked against a running proxy rather than by reading: npm, PyPI, pythonhosted
and both Maven hosts tunnel through, example.com is refused, a plain HTTP request
to an allowed host is refused as well, and the access log names the refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:44:25 +02:00
Lucio Lelii a5769114eb Say what is wrong when the user id is not a number
A compose .env file is read literally, so MCP_UID=$(id -u) written there arrives
as those eight characters. What came back was "groupadd: invalid group ID", from
a line four steps into a build - true, but it names neither the variable nor the
file that holds it. The build now refuses with both, and says that the file wants
the output of the command rather than the command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:26:36 +02:00
Lucio Lelii d19fa7ba4e Let the worker write to its own volumes
A named volume whose mount point is absent from the image is created owned by
root, so the worker - which drops root - could not have written a single copy
into /instances. The failure would have surfaced at the first start_service, as
a refused execution far from the line that caused it. Creating the two
directories in the image and giving them to the runtime user is what makes the
volume inherit that ownership.

The user id follows as a build argument, because one number has to satisfy three
things at once: who the process is, who owns the volumes, and who owns the
workspace directory on the host. Left hardcoded, changing MCP_UID to match a
deployment's own user would have brought the same fault back. The guard around
useradd is for the ordinary case of pointing it at an id the base image already
uses - 1000 is the node user - which is otherwise a build failure.

Verified by building both ways and writing into a fresh volume as 10001 and as
1000, and by checking the image carries node 24, Python 3.11 and OpenJDK 25.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:04:36 +02:00
Lucio Lelii b76642a09b Let the browser reach the ports an execution is actually given
The allowlist still named the shared service's fixed 5173, from before the dev
server handed a port per execution. Every preview would have started on a port
the browser was not allowed to open, and the failure would have read as a broken
application rather than as a refused origin - the kind of mismatch that is found
by exhausting the other explanations first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:59:05 +02:00
Lucio Lelii 0e6b400441 Bring the three MCP servers together as one deployable stack
They were three folders on one laptop: a coding agent with a git history of its
own, and two servers - a development server and a browser - with none at all.
What makes them a stack is what sits between them, and that lived nowhere: the
compose file, the gateway, the egress proxy and the networks that keep the worker
off the internet. So the whole thing is one repository, and the coding agent's
separate history is folded into it rather than kept alongside.

What is deliberately absent: .env, node_modules, and the live service
definitions. The .example files next to them say what belongs there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:45:55 +02:00