Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,11 @@ AGENT_COMPUTER_URL=http://localhost:4100
# without this value and refuses every request that does not present it. Use a long random value;
# `scripts/start.sh` sets a development one for you.
COMPUTER_TOKEN=
# Local only. Lets a Bot browse this machine's own services; never set this in a deployment.
AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true
# Local only, and commented out because this file gets copied. Uncommenting it lets a Bot browse this
# machine's own services, which is what a laptop wants and what a deployment must never have: it
# lifts the private-address floor in browsing and in the endpoint a Bot may be registered against.
# The server refuses to start with it set under NODE_ENV=production.
# AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true
#
# What a Bot may do on its computer, as one JSON object. Absent uses the built-in default, which
# permits the acting tools and forbids nothing, and records every action either way.
Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,37 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### Upgrading

**A deployment that sets `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true` with `NODE_ENV=production` no
longer starts.** Remove the line and it starts again. Nothing else needs changing, and a deployment
that never set it is unaffected.

The switch lets a Bot reach addresses inside the deployment's own network — `10.0.0.5`,
`192.168.1.1`, `127.0.0.1:5432`, a link-local address — and it does that in two places, not one:
browsing, and the endpoint a Bot may be registered against. It exists for a laptop, where the
services a Bot is asked to look at are the ones running beside it.

The reason this is a refusal rather than a warning is how a deployment came to have it. `.env.example`
shipped the line on, and copying that file is the ordinary way an environment gets filled in, so the
path to a hosted deployment reaching its own network was not forgetting to set something, it was
inheriting something. It now ships commented out, which means a laptop that wants the old behaviour
uncomments it and everything else arrives without it. Under any other `NODE_ENV` the switch works
exactly as before, with a warning at boot saying it does not travel.

**The one-container image shipped with the switch on, and no longer does.** It set both
`AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true` and `NODE_ENV=production`, so the image really did run
with private-host browsing enabled. Two things that worked there stop: a Bot browsing a private
address such as an intranet page, and registering a coworker at a private endpoint like
`http://10.0.0.20:8000/ag-ui`. Because the image bakes in `NODE_ENV=production`, there is no
override — a deployment that needs either of those wants the compose setup or its own image rather
than the all-in-one. The image continues to start, and everything else in it is unchanged.

The cloud metadata addresses — `169.254.169.254`, `metadata.google.internal`, and the IPv6 and
NAT64 spellings of them — were refused whatever this switch said, before and after. That floor has
not moved. What changed is that it is no longer the only thing left standing in a production
deployment that copied the example.

## 0.0.4

### A click citing a ref this deployment cannot resolve is refused
Expand Down
11 changes: 8 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends sudo \
ENV WORKSPACE_DIR=/workspace
ENV PROFILES_DIR=/profiles

# 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.
# The browser is on loopback inside this container and reachable from nowhere else.
#
# No private-host allowance is set for it, and none is needed. Reaching the sibling process goes
# through `checkComputerAddress`, which decides on the protocol and the metadata floor and never
# consults `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS`. That switch governs where a *Bot* may browse and
# which endpoint one may be registered against, so setting it here bought nothing for this address
# and let a Bot reach whatever this container's network reaches, which on a default bridge is the
# host's LAN.
ENV AGENT_COMPUTER_URL=http://127.0.0.1:4100
ENV AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true

# NOTHING THAT MATTERS RUNS AS ROOT.
#
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ Settings worth knowing:
| `COMPUTER_SANDBOX` | Set to `on` for Chromium's own sandbox, where the host permits it. |
| `EMBEDDED_POSTGRES` | Set to `on` for a database inside the deployment container. |
| `AGENT_COMPUTER_POLICY` | JSON action policy. Malformed JSON stops server startup. |
| `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` | Lets a Bot reach this machine's own services. |
| `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` | Lets a Bot reach this machine's own services. Local only, and refused under `NODE_ENV=production`. |
| `TENANT_PACKAGE_DIR` | Directory containing tenant YAML. Defaults to `../examples/fintech`. |
| `DEPLOYMENT_ID` | Names this deployment when two share one Intelligence project. |

Expand Down Expand Up @@ -291,7 +291,7 @@ provider's discovery document listed in `TRUSTED_ORIGINS`, not only the issuer.

- `agent-computer` drives a browser holding real logins. `docker-compose.yml` binds it to loopback; leave it there.
- Store credentials through `/admin/credentials`, which encrypts them. Do not put credential values in tenant YAML or in committed files.
- `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` lets a Bot reach services on this machine. Unset it if you would rather it could not.
- `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` lets a Bot reach services on this machine. It ships commented out in `.env.example`, is for a laptop only, and a deployment running with `NODE_ENV=production` refuses to start while it is set.

## Development

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,5 +182,5 @@ Connector credentials are stored through the credential vault and referenced by
- `KEY_ENCRYPTION_KEY` must be a base64-encoded 32-byte value. The example key is refused with `NODE_ENV=production`.
- Credential plaintext is encrypted at rest, never returned by APIs, and redacted from audit events.
- Browser navigation allows `http` and `https`; cloud metadata addresses are refused under every configuration.
- `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true` is for local development only.
- `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true` is for local development only, and a deployment running with `NODE_ENV=production` refuses to start while it is set.
- Computer tokens and supervisor tokens must be long random values outside local development.
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ where `<provider>` is `google`, `microsoft` or `okta`.
| `COMPUTER_BROWSER_IDLE_MS` | How long an untouched browser is kept. 30 minutes by default; `0` keeps them resident. |
| `COMPUTER_SUPERVISOR_URL` | Supervisor URL for per-Bot computers. If absent, Bots share `AGENT_COMPUTER_URL`. |
| `SUPERVISOR_TOKEN` | Bearer token required by the supervisor. |
| `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` | Local-only private-host browsing when `true`. Cloud metadata addresses are still refused. |
| `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` | Local-only private-host browsing when `true`. A deployment running with `NODE_ENV=production` refuses to start while it is set. Cloud metadata addresses are refused either way. |
| `AGENT_COMPUTER_POLICY` | JSON action policy: `{"mode":"enforce","deny":[...],"allow":[...]}`. |
| `COMPUTER_RUNTIME` | Set to `runsc` to run supervised computers under gVisor. |
| `COMPUTER_SANDBOX` | Set to `on` to enable Chromium's own sandbox where the host permits user namespaces. Which way it went is printed at start-up. |
Expand Down
35 changes: 33 additions & 2 deletions server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,38 @@ function runtimeCapabilities(environment: Environment): RuntimeCapabilities {
};
}

/**
* Whether a Bot may reach addresses inside this deployment's own network.
*
* Off unless asked for, and the asking is only allowed on a laptop. The switch exists so that a
* local deployment can browse the services running beside it; what it turns off is not one rule but
* the whole private-address floor, in navigation and in the endpoint a Bot may be registered
* against, so with it on a signed-in person can point a Bot at a link-local address.
*
* Refused in production for the reason the example encryption key is: the way a deployment ends up
* with it is not forgetting to set something, it is copying `.env.example`, which shipped it on. The
* cloud metadata addresses are refused underneath this either way — see `computer/target.ts` — but
* that floor is the last one, not the only one worth keeping.
*/
function privateHostsAllowed(environment: Environment): boolean {
if (optional(environment, "AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS") !== "true") {
return false;
}

// Through `optional`, so the comparison trims. Read raw, `NODE_ENV="production "` out of an env
// file would slip past a gate that the switch beside it, which does trim, would still trip.
if (optional(environment, "NODE_ENV") === "production") {
throw new Error(
"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.",
);
}
console.warn(
"AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true lets a Bot reach this machine's own services. Fine locally, and for local development only. Remove it before deploying.",
);

return true;
}

function computerConfig(environment: Environment): ComputerConfig | undefined {
const supervisorAddress = optional(environment, "COMPUTER_SUPERVISOR_URL");
const sharedAddress = optional(environment, "AGENT_COMPUTER_URL");
Expand All @@ -494,8 +526,7 @@ function computerConfig(environment: Environment): ComputerConfig | undefined {
*/
const computerToken = optional(environment, "COMPUTER_TOKEN");

const allowPrivateHosts =
optional(environment, "AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS") === "true";
const allowPrivateHosts = privateHostsAllowed(environment);
const policy = actionPolicy(environment);

const supervisorUrl = url(environment, "COMPUTER_SUPERVISOR_URL");
Expand Down
137 changes: 136 additions & 1 deletion server/tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { describe, expect, spyOn, test } from "bun:test";
import { configuredAuthProviders, loadConfig } from "../src/config";

// Intelligence is part of the MINIMUM contract, so it belongs in the base environment every other
Expand Down Expand Up @@ -27,6 +28,19 @@ const baseEnvironment = {
* The provider tests need the opposite starting point, or "Microsoft is configured" cannot be told
* apart from "Microsoft and the Google that was already there".
*/
/**
* A deployment that is actually deployed.
*
* `baseEnvironment` carries the example encryption key, which is refused under
* `NODE_ENV=production` — so a production case built on it fails on the key before it reaches
* whatever it meant to test. A real key here keeps each production test about its own subject.
*/
const productionEnvironment = {
...baseEnvironment,
NODE_ENV: "production",
KEY_ENCRYPTION_KEY: "b3BlbmJvdC1wcm9kdWN0aW9uLXRlc3Qta2V5LTMyMzI=",
};

const {
GOOGLE_OAUTH_CLIENT_ID: _googleId,
GOOGLE_OAUTH_CLIENT_SECRET: _googleSecret,
Expand Down Expand Up @@ -418,6 +432,127 @@ describe("deployment configuration", () => {
expect(loadConfig(baseEnvironment).computer).toBeUndefined();
});

// `.env.example` used to ship AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true, and copying that file is the
// ordinary way a deployment gets its environment. So the way a hosted deployment ends up reaching
// its own network is not forgetting to set something, it is inheriting something. Refused in
// production for the same reason the example encryption key is: convenient locally, and an opening
// anywhere else.
test("refuses to start when a production deployment allows private hosts", () => {
expect(() =>
loadConfig({
...productionEnvironment,
AGENT_COMPUTER_URL: "http://localhost:4100",
AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS: "true",
}),
).toThrow("AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS");
});

// Both sides of the comparison come out of the same env file, and the switch is read through
// `optional`, which trims. Comparing NODE_ENV raw would mean a trailing space typed into that file
// slipped past the refusal while the switch beside it still counted as set.
test("refuses a production deployment whose NODE_ENV carries whitespace", () => {
expect(() =>
loadConfig({
...productionEnvironment,
NODE_ENV: "production ",
AGENT_COMPUTER_URL: "http://localhost:4100",
AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS: "true",
}),
).toThrow("AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS");
});

// The refusal has to name the way out, because the person reading it at boot is looking at a file
// they copied and does not necessarily know which line is the problem.
test("says to remove the line, and that it is local only", () => {
const attempt = () =>
loadConfig({
...productionEnvironment,
AGENT_COMPUTER_URL: "http://localhost:4100",
AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS: "true",
});

expect(attempt).toThrow("local development only");
expect(attempt).toThrow("Remove it");
});

// The half of the matrix that was always right and has to stay right: absent means off, including
// in the environment where the new refusal lives.
test("starts in production when nothing asked for private hosts", () => {
const config = loadConfig({
...productionEnvironment,
AGENT_COMPUTER_URL: "http://localhost:4100",
COMPUTER_TOKEN: "computer-token",
});

expect(config.computer?.allowPrivateHosts).toBe(false);
});

// The local workflow is the reason the flag exists, so outside production it still does exactly
// what it did. Warned about, because a laptop is where a deployment is configured and the warning
// is the only chance to say this line does not travel.
test.each(["development", undefined])(
"warns and still allows private hosts under NODE_ENV=%p",
(nodeEnv) => {
const consoleWarn = spyOn(console, "warn").mockImplementation(() => {});

try {
const config = loadConfig({
...baseEnvironment,
...(nodeEnv ? { NODE_ENV: nodeEnv } : {}),
AGENT_COMPUTER_URL: "http://localhost:4100",
AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS: "true",
});

expect(config.computer?.allowPrivateHosts).toBe(true);
// Searched rather than indexed: `baseEnvironment` carries the example encryption key, which
// warns on its own account first.
const warning = consoleWarn.mock.calls
.map(([first]) => String(first))
.find((line) => line.includes("AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS"));

expect(warning).toBeDefined();
expect(warning).toContain("local development only");
expect(warning).toContain("Remove it before deploying");
} finally {
consoleWarn.mockRestore();
}
},
);

// The refusal above only helps a deployment that reads it. The reason there was anything to refuse
// is that the file everybody copies arrived with the switch on, so the file is worth asserting
// about directly: a live line here is the regression, whatever the code does afterwards.
test("the shipped example does not turn private hosts on", () => {
const example = readFileSync(
new URL("../../.env.example", import.meta.url),
"utf8",
);

// Commented-out mentions are wanted — that is how the switch stays discoverable for a laptop.
const live = example
.split("\n")
.filter((line) =>
/^\s*AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS\s*=/.test(line),
);

expect(live).toEqual([]);
});

// Anything that is not the exact opt-in is not an opt-in, so it is not the thing being refused
// either. A deployment that wrote something else has private hosts off and starts.
test.each(["false", "1", "yes", ""])(
"starts in production on AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=%p",
(value) => {
const config = loadConfig({
...productionEnvironment,
AGENT_COMPUTER_URL: "http://localhost:4100",
AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS: value,
});

expect(config.computer?.allowPrivateHosts).toBe(false);
},
);

test.each([
["Docker", "COMPUTER_SUPERVISOR_URL"],
["shared", "AGENT_COMPUTER_URL"],
Expand Down