Skip to content

Keep the private-hosts switch out of production - #198

Merged
davidmckayv merged 2 commits into
CopilotKit:mainfrom
NathanTarbert:fix/issue-185-private-hosts-gate
Aug 23, 2026
Merged

Keep the private-hosts switch out of production#198
davidmckayv merged 2 commits into
CopilotKit:mainfrom
NathanTarbert:fix/issue-185-private-hosts-gate

Conversation

@NathanTarbert

@NathanTarbert NathanTarbert commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

What this changes

AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS is now refused in production, the way the placeholder encryption key already is, and it no longer ships live in .env.example.

Fixes #185. Thanks @beardthelion — the matrix is what made this quick to act on, and framing it as "forgetting is not the path that gets you there, copying the example is" is the whole argument. You offered to send a PR for whichever shape we picked; I took option 1 and the reason is below, along with something the matrix did not reach.

The gate

config.ts read the switch as a plain equality with no environment gate, and .env.example:162 shipped it on, so a deployment inherited it by copying the file.

It lifts more than browsing. app.ts:646 hands the same value to createAgentRoutes, and computer/target.ts:257 is an early return { allowed: true } rather than a relaxation of one rule, so with it on a signed-in person can register a Bot at a link-local address.

privateHostsAllowed() now mirrors the KEY_ENCRYPTION_KEY placeholder block at config.ts:228-241: throw under NODE_ENV=production, warn everywhere else and still allow it. The local workflow is unchanged — the switch still works on a laptop, it just says so on the way past.

The example line is commented out rather than deleted, so it stays discoverable with an explanation of what it lifts.

The image would have refused to start, and the reason it was set was not true

Dockerfile:179 set ENV AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true and :203 sets ENV NODE_ENV=production. Nothing unsets either and there is no compose override, so this change alone would have stopped the all-in-one image booting.

The comment above it explained the switch as the server reaching its own browser:

The browser is on loopback inside this container and reachable from nowhere else, which is why the private-host allowance is on: the server is browsing to its own sibling process, not the internet.

That path is AGENT_COMPUTER_URLgateway.ts:248checkComputerAddress, and that function never consults this switch. awk '/export function checkComputerAddress/,/^}/' server/src/computer/target.ts | grep -c allowPrivateHosts returns 0; it decides on the protocol list and the metadata floor alone. So the switch bought nothing for that address, and what it did buy was letting a Bot browse whatever the container's network reaches — on a default bridge, the host's LAN.

The line is removed and the comment now says what the switch actually governs. That makes the image match the example: neither ships with it on.

What the image loses, and this is the part worth a decision. The switch was doing something there, just not what the comment claimed: with it on, a Bot in the all-in-one could browse a private address, and a coworker could be registered at one — http://10.0.0.20:8000/ag-ui, say. Both stop. Because the image bakes NODE_ENV=production at Dockerfile:203, there is no way to opt back in from inside it, so anyone who needs either wants the compose setup or their own image.

That is in the Upgrading entry now rather than left to be discovered. If you would rather the image kept an escape hatch, that is a design call and I would rather raise it than assume: the options are leaving the image as it was and accepting that it ships with the floor down, or giving the refusal a deliberate override that is harder to arrive at by copying a file. I have not built either.

What has not moved

The cloud metadata floor is unchanged and still refuses 169.254.169.254, metadata.google.internal and [::ffff:169.254.169.254] with the switch on — computer-target.test.ts:33-79 already loops those over [false, true] and is untouched. The flag-absent default is still false, and now has an explicit production test guarding it.

Worth saying plainly: with the metadata floor holding, this is defence in depth rather than a live breach. The default was already right. What was wrong is that the opt-in was ungated and the two files most likely to be copied both arrived with it on.

Reference tables

README.md:205, docs/configuration.md:181 and docs/architecture.md:185 each describe the switch and none mentioned the refusal. That last one sits three lines under the KEY_ENCRYPTION_KEY bullet which already says "refused with NODE_ENV=production", so it was the most visible place the parity was missing. All three now say it.

Where it runs

  • New state that outlives a request? None. One configuration read, decided at boot.
  • What happens on the second replica? Every replica reads the same environment and reaches the same verdict. A deployment that has the switch set fails to start everywhere rather than on one machine, which is the direction that gets noticed.
  • Anything serialised? N/A, no writes.
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? No.

Boundary and audit

  • The floor itself is untouched: no change to isTargetAllowed, checkAgentEndpoint, or target.ts's early return. This only decides whether a deployment is allowed to ask for the floor to be lifted.
  • No new refusal path at request time. The refusal is at boot, and it is loud by construction.

Changelog

An Upgrading entry under Unreleased, in the voice of the two refuse-to-start entries v0.0.3 already shipped. A deployment that copied the example and runs NODE_ENV=production will not start until the line is removed, and the entry says exactly that and how to fix it.

Proof

Eleven tests added, all pure, none needing a database, each written first and confirmed failing for the right reason:

  • production plus the switch refuses to start, and the message names the variable and says to remove it
  • production with it absent loads, allowPrivateHosts: false
  • NODE_ENV=development and NODE_ENV unset both warn and still allow it (2)
  • false, 1, yes and "" all load in production — only the exact opt-in is refused (4)
  • NODE_ENV="production " with a trailing space still refuses — optional() trims the switch, so comparing NODE_ENV raw would let whitespace out of an env file walk past the gate while the line beside it still counted as set
  • the shipped example does not turn it on, by parsing .env.example for an uncommented assignment

Mutations, each applied alone and reverted:

Mutation Result
production throw removed 2 fail
console.warn removed 2 fail
throw under every NODE_ENV, not only production 2 fail
any non-empty value treated as the opt-in 3 fail
the trailing return true replaced with false 2 fail
an early return false at the top of the helper 4 fail — it kills the throw as well as the warn
NODE_ENV compared raw instead of through optional() 1 fail (the whitespace case)
live line restored in .env.example 1 fail

Measured by stashing in the same worktree:

baseline 6826e11 with the change
bun run test 1114 pass, 10 skip, 126 fail, 1 error, 1250 / 115 files 1125 pass, 10 skip, 126 fail, 1 error, 1261 / 115 files

Exactly the eleven new tests; the 126 Postgres failures and the pre-existing @langchain/core error are the baseline set unchanged. bunx biome lint . 0 warnings, format:check, typecheck and build clean.

One thing I could not run here: the image. There is no Docker on this machine, so the Dockerfile change is verified by reading — that checkComputerAddress never consults the switch, and that nothing else in the image or docker-compose.yml sets it. Since #117 the image job only builds on main or on a PR carrying full-ci, so this wants the label before it merges rather than merging unbuilt.

Noticed while writing the tests

baseEnvironment in server/tests/config.test.ts carries the placeholder KEY_ENCRYPTION_KEY, so any NODE_ENV=production test built on it throws on the key before reaching its own subject. I added a productionEnvironment fixture with a real key rather than working around it. Nothing existing broke: no test or fixture sets this variable, and the only other test touching NODE_ENV=production calls singleUserEnabled directly rather than loadConfig.

Possibly its own issue

The switch only reaches anything through config.computer?.allowPrivateHosts, so a deployment that sets it with no computer configured boots without hitting this refusal. Harmless today, since the value reaches nothing, and it fails closed the moment a computer is added. Left alone rather than widened into this change; happy to file it if you would rather it were closed.

AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS was read as a plain equality with no
environment gate, and .env.example shipped it live, so a deployment inherited it
by copying the file. That is the ordinary path rather than an unusual one.

It lifts more than browsing: app.ts hands the same value to createAgentRoutes,
and target.ts:257 is an early return { allowed: true } rather than a relaxation
of one rule, so with it on a signed-in person can register a Bot at a link-local
address.

Now mirrors the KEY_ENCRYPTION_KEY placeholder at config.ts:228: throws under
NODE_ENV=production naming the variable and what to do, warns everywhere else
and still allows it, so the local workflow is unchanged. The example line is
commented out, since copying the file is how a deployment ends up with it.

Dockerfile set the switch and NODE_ENV=production, so the all-in-one image would
have refused to start. The comment justified it as the server reaching its own
browser, but that path is checkComputerAddress, which never consults this switch
and decides on the protocol and the metadata floor alone. The switch only
governed where a Bot may browse, which on a default bridge network is the host's
LAN. Removed, and the comment now says what it actually controls.

The cloud metadata floor is unchanged and still refuses 169.254.169.254 and its
spellings with the switch on. The flag-absent default is still false, now with a
production test guarding it.
@NathanTarbert NathanTarbert changed the title Refuse the private-hosts switch in production, and stop shipping it on Keep the private-hosts switch out of production Aug 23, 2026
davidmckayv
davidmckayv previously approved these changes Aug 23, 2026

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified both directions on a real boot.

Refuses in production, and names the variable:

NODE_ENV=production AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true
-> AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true is for local development only: it lets a Bot
   reach this deployment's own network. Remove it from this deployment's environment.

With the flag off and everything else identical, it starts. In development it warns instead, which I watched the running server print on the stacked branch.

Why this is the highest-value of the three security fixes: it is on the documented happy path. Quick start says copy .env.example, and that file shipped the flag on; Deploy it runs the image, which bakes NODE_ENV=production. So a by-the-book deployment ran with the private-address floor down. In a template that is not one deployment, it is every clone that followed our own instructions.

I checked the claim in the Dockerfile comment too, since removing an ENV on a stated justification is where this could have gone wrong: checkComputerAddress takes no options and never consulted the flag, so the comment was wrong and removing it cannot break the server-to-computer path.

The cost you flagged is real and I would still merge. In the all-in-one image you can no longer register a BYO agent at a LAN address, with no opt-back-in. Bring-your-own-agent is a headline capability, so that deserves a follow-up — an explicit allowlist of hosts would serve the legitimate case without reopening the floor. But shipping "copy this file and deploy" with the guard down is worse than shipping this and adding the escape hatch deliberately.

Checks: stacked with ten other candidates on current main. Typecheck, lint, format clean; 1371 tests pass, 0 fail.

…t#185)

Review of CopilotKit#198 turned up three things.

NODE_ENV was compared raw while AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS goes through
optional(), which trims. A trailing space out of an env file walked past the
refusal while the switch beside it still counted as set. Now read through the
same helper, with a test.

The Upgrading entry only covered the deployment that fails to start. The
one-container image set the switch and NODE_ENV=production, so it really did run
with private-host browsing on: a Bot could browse a private address there and a
coworker could be registered at one, and both stop. The image bakes
NODE_ENV=production, so there is no override from inside it. Named in the entry
rather than left to be found.

README, docs/configuration.md and docs/architecture.md each describe the switch
and none mentioned the refusal. The architecture bullet sits three lines under
the KEY_ENCRYPTION_KEY one that already says it, which is the most visible place
the parity was missing.
@davidmckayv
davidmckayv merged commit bbdb7f2 into CopilotKit:main Aug 23, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The private-hosts opt-in is accepted in production, and the shipped example turns it on

2 participants