← Back to Blog

Day 167: Pinchy 0.9.1

0.9.1 is out. It is a bugfix release with no migration and no compose change, and it exists because of one thing I found while testing the release before it.

The knowledge base was off, on exactly the instances that took our advice

Pinchy has a domain lock. You tell it which hostname it is served on, it refuses requests that arrive under any other name, and it switches its session cookies to __Secure-. The hardening guide tells you to turn it on. It is the sort of control that only ever seems to cost you something the day it saves you.

The check that enforces it had a hand-maintained list of internal routes to wave through — the ones Pinchy’s own plugins use to call back into the app. The list had fallen behind the code by three routes:

The plugins run in the agent runtime container and reach Pinchy on a Docker-internal hostname. That hostname can never match your public domain — not by accident, by construction. So every one of those calls was answered 403 Forbidden: request host does not match the configured domain, and the answer the agent gave you was that it could not reach its documents.

What makes this the kind of bug I want to write about rather than quietly patch is that it was invisible from both ends. The plugin got a 403 and reported a failure with no cause attached. Pinchy refused the request before any handler ran, so nothing was written to the audit trail either. On our own production instance, three Telegram bots had been running for weeks against a message store with zero rows in it, and nothing anywhere said so.

And the failure mode selected for the careful. An instance left unhardened worked fine. An instance that followed the guide lost its knowledge base.

The fix is to stop keeping a list

A list of exempt routes is a promise that every future route will be added to it, made by whoever writes that route, months from now, while thinking about something else. The list broke exactly the way lists break.

The rule is now the prefix: everything under /api/internal/ is plugin traffic, authenticated by the gateway token, and exempt from the host check by definition rather than by enumeration. A test pins it, so a new internal route cannot go missing the same way. And a blocked /api/ request now writes an auth.host_blocked audit row, so the next time a security control refuses something it should not have, there is a record of it instead of a silence.

If you run with a domain lock: re-index your knowledge-base agents after upgrading. Indexing itself was never blocked — but if you tried once, got nothing, and concluded the feature did not work, this is why.

The token proved something, but not enough

The second fix in this release is one I would have shipped as its own release if the first had not been so urgent.

Plugins fetch third-party credentials from Pinchy through an internal endpoint, authenticated with the gateway token. That token is a single shared secret, inlined into every plugin’s config. It proves that something inside the agent runtime is asking. It proves nothing about whether that something has any claim to the connection it just named — so a plugin could name any connection id and receive decrypted credentials belonging to a different agent.

Callers now identify their agent, and the grant is checked before anything is decrypted. A refused request is audited with the agent, the connection, and whether the agent was unknown or simply not granted. If you run agents with different integration scopes, that log is worth a look after upgrading.

Also in

Chat survives a backgrounded tab. On a phone, as an installed PWA, or just left in a background tab, the connection could come back missing recent messages until you reloaded. The socket now pauses its reconnect loop while hidden, closes cleanly after a grace period, resumes the instant the tab is visible, and reconciles the thread over the connection it already has.

A failed migration says what failed. It used to report a guess at which file broke, derived from a count. It now names the target and reports the actual error.

Audit verification stopped stepping over rows. The periodic verify sweep could skip rows written while it was running. If you rely on those reports, run one full verification after upgrading — the earlier clean results covered slightly less than they claimed.

The security banner stops nagging local installs. Running on http://localhost produced a warning telling you to lock your domain, which is advice you cannot take when there is no domain, about a context the browser already treats as secure.

Day 167

The changed thing here is not the code, it is how the release was tested. Our checklist used to say the release candidate had been clicked through on staging. Clicking through confirms the app boots. It does not confirm the release works, and it would not have found this: both halves were fine on their own, and only their combination was broken. What found it was driving the actual candidate on an actual hardened instance, adversarially, trying to make it fail.

Four days between releases is not a rhythm I want. But the alternative was leaving 0.9.0’s main feature switched off for everyone who had done the security work we asked of them, and that is not a thing you sit on until the next minor.

← Day 166: Clicking Through Is Not Testing

Pinchy is open source and ready to deploy. Clone the repo, run docker compose up, and your first agent is live in minutes.