Last week I wrote about Glassys, the self-hosted chat face I built for the coding agent that's already installed on a machine. It worked. I used it from my phone, the installer was one command, and the repo had screenshots.

Then I did the thing I should have done before publishing: I read the whole of it back as if it were someone else's instance, on someone else's host, reachable from outside their flat. That is a different piece of software, and on 2 October it took me eight passes to make it behave like one.

A failing adapter should not take the gateway with it

The worst thing I found wasn't a security hole. It was a process exit.

One adapter built a promise chain ending in .finally() and never handled the rejection of the derived promise. On Node 22 an unhandled rejection ends the process, so one bad reply from one vendor's SDK killed the gateway, and with it every thread, for everyone using that instance. Nearby: after you cancelled a run, the loop started a fresh next() on every tick and spun the event loop for twenty seconds. And a one-shot schedule that failed to queue fired again immediately, forever, which is the sort of loop that only turns up in a log nobody is reading.

I gave the child processes the same treatment — spawn errors handled, waiters settled on exit, a SIGKILL after a grace period, a cap on frame size — and, as a backstop, the gateway now logs unhandled rejections and shuts down cleanly on an uncaught exception, so the service manager restarts it instead of leaving a dead socket behind.

The races that ate your message

The second pass is the one I'd least have liked a stranger to hit. A message could be written to a thread while the identity underneath it changed, and then never run: it sat there in the transcript looking sent. Cancelling while a session was still starting left the start running and could open a second agent. Shutdown still accepted new messages on the way out.

I fixed those by owning the boundaries rather than by adding checks. Session startup is single-flight, so an abandoned start can't race the next one. Transcript appends take the thread lock, so a line can't land in a thread that was just archived. A cancel aborts the start it's racing with instead of hoping to arrive second, and drops the events of a run that comes back late. And a failed run now reports what actually failed instead of "Run failed", a sentence that has never helped anybody.

Login, logout, and the things you only need when it isn't just you

Login had no brakes at all. Attempts from one address now run one at a time, get counted before the password hash runs rather than after, and back off to thirty seconds; past four waiting, they get a 429. No hard lockout, and that's deliberate: behind a reverse proxy everyone shares one address, so a lockout is mostly a way to lock out the owner. Very long passwords never reach the hash at all.

Logging out now means something. The session token carries an id, logging out revokes that session and closes its sockets, and changing the password closes every socket signed before the change. The browser stopped keeping the token in sessionStorage — it's an HttpOnly cookie now, and the cookie is only marked Secure when the request genuinely arrived over TLS, because plenty of people will run this over plain http on their own LAN and I'd rather that work than pretend it doesn't happen.

The short, boring list

The rest of the security work reads like a checklist, which is what it was. An endpoint that took a URL and fetched it only accepts public https hosts now, instead of being pointable at the host's own network. Data files are created 0600 inside a 0700 directory. The HTML stopped allowing inline scripts, so the theme bootstrap moved into its own file. Slow or flooding websockets get cut. Probe errors stopped leaking what they knew. The installer refuses to run as root.

Then three passes with nothing to do with safety and everything to do with whether the thing is nice to use. Behaviour: Escape on the thread sheet goes back one step instead of leaving the app, the socket admits it's reconnecting the moment it drops instead of looking fine, and a host view drops replies from a load you already navigated away from. Speed: a streaming block reparses at most every 80 ms rather than on every chunk, tool output keeps a 64 KiB tail instead of all of it, settings and the host views load on demand, and the build ships precompressed assets. Keyboard and screen readers: focus moves into a sheet and stays there, Escape closes the sheet before it can cancel your run — which was a genuinely nasty interaction — and focus goes back to whatever opened it.

The last one is the smallest and I like it best: a published instance now asks search engines to stay away, in the page, in robots.txt and in a header. If you put a chat interface to your own server on a domain, you don't want it indexed, and "nobody will find it" isn't a plan.

What I'd do differently

None of this needed a rewrite. Every one of them was a default I'd inherited or picked while the only user was me, sitting next to the machine. So the lesson isn't "harden your software", which nobody has ever acted on. It's narrower: the moment I published an installer, I changed who the software was for, and the assumptions I'd been building on stopped being mine to make. I shipped the installer first and asked that question second.

It's all in the repository if you run it. If you installed it before 2 October, this is a good week to pull.