From 230cdc9c5000b859fc411d798abe04bf7751c965 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Sun, 11 Oct 2026 12:34:31 +0000 Subject: [PATCH] Build the box environment in the core, and reach the proxy control channel by URL The Docker code built the environment of a box: it put the proxy URL, the CA variables and TERM next to the placeholders that the core gave it, and it made the proxy URL from the alias and the port settings. On another runtime, this code had to be written again. The core now builds the full environment as a plain record in boxEnv, and the Docker code only converts it to its KEY=value list. The proxy URL comes from the runtime through proxyUrl, because only the runtime knows how a box reaches the proxy. The control channel of the proxy was reached at the container name EGRESS_PROXY_CONTAINER and the port EGRESS_CONTROL_PORT. On a runtime without container names, this address has no meaning. It is now the URL EGRESS_CONTROL_URL, with the same default address. Part of #109 --- doc/environment.md | 5 +- orchestrator/src/boxenv.test.ts | 182 ++++++++++++++++++++++++++++++++ orchestrator/src/boxenv.ts | 86 +++++++++++++++ orchestrator/src/boxes.ts | 16 +-- orchestrator/src/config.test.ts | 1 + orchestrator/src/config.ts | 4 +- orchestrator/src/docker.test.ts | 155 --------------------------- orchestrator/src/docker.ts | 96 +---------------- orchestrator/src/egress.test.ts | 12 +-- orchestrator/src/egress.ts | 2 +- 10 files changed, 292 insertions(+), 267 deletions(-) create mode 100644 orchestrator/src/boxenv.test.ts create mode 100644 orchestrator/src/boxenv.ts diff --git a/doc/environment.md b/doc/environment.md index fb4fa17..79b6682 100644 --- a/doc/environment.md +++ b/doc/environment.md @@ -49,7 +49,7 @@ These variables are read by the orchestrator container. | `EGRESS_PROXY_CONTAINER` | `boxes-egress-proxy` | Non-empty container name | Name of the egress proxy container. Change this only when the proxy container is renamed too. | | `EGRESS_PROXY_ALIAS` | `proxy` | Non-empty hostname | Name boxes use for the egress proxy on their private networks. | | `EGRESS_PROXY_PORT` | `3128` | Positive integer | Egress proxy port used by boxes. Changing it requires matching proxy container configuration. | -| `EGRESS_CONTROL_PORT` | `3129` | Positive integer | Egress proxy control port used by the orchestrator. Changing it requires matching proxy container configuration. | +| `EGRESS_CONTROL_URL` | `http://boxes-egress-proxy:3129` | `http:` URL | Egress proxy control channel used by the orchestrator. Changing it requires matching proxy container configuration. | | `EGRESS_ALLOWED_HOSTS` | empty | Comma- or whitespace-separated hostnames | Optional [egress allowlist](egress.md). Exact hostnames and one-label wildcards such as `*.example.com` are accepted. Leave empty to allow all public hosts. Private ranges remain blocked. A bare `*` is invalid. | | `GITLAB_HOST` | `gitlab.com` | Bare hostname with at least two labels | GitLab host for the GitLab credential. Set this for a self-managed public GitLab instance. | @@ -82,4 +82,5 @@ preserves the image entrypoint's expectations. The [egress proxy](egress.md) accepts `PORT`, `CONTROL_PORT`, and `CONTROL_BIND` directly. They default to `3128`, `3129`, and an automatically selected Compose-network address. The shipped Compose file does not pass these variables into the proxy. Use them only with a custom Compose configuration that changes the matching orchestrator settings: -`EGRESS_PROXY_PORT` for `PORT` and `EGRESS_CONTROL_PORT` for `CONTROL_PORT`. `CONTROL_BIND` must be an IP address. +`EGRESS_PROXY_PORT` for `PORT` and the port in `EGRESS_CONTROL_URL` for `CONTROL_PORT`. `CONTROL_BIND` must be an IP +address. diff --git a/orchestrator/src/boxenv.test.ts b/orchestrator/src/boxenv.test.ts new file mode 100644 index 0000000..e321950 --- /dev/null +++ b/orchestrator/src/boxenv.test.ts @@ -0,0 +1,182 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { boxEnv } from './boxenv.ts'; +import { loadConfig, type Config } from './config.ts'; +import { CredentialStore, type CredentialId, type CredentialMethod } from './credentials.ts'; +import { openDb, type Db } from './db.ts'; +import { EgressManager } from './egress.ts'; +import { proxyUrl } from './docker.ts'; +import { readSettings } from './settings.ts'; + +/** A real Claude token as the store holds it. */ +const CLAUDE_TOKEN = 'sk-ant-oat01-the-real-claude-token'; + +/** A real Anthropic API key as the store holds it. */ +const ANTHROPIC_KEY = 'sk-ant-api03-the-real-anthropic-key'; + +/** A real GitHub token as the store holds it. */ +const GH_TOKEN = 'ghp_therealgithubtoken'; + +/** A real OpenAI API key as the store holds it. */ +const OPENAI_KEY = 'sk-therealopenaiapikey'; + +/** A real GitLab token as the store holds it. */ +const GITLAB_TOKEN = 'glpat-therealgitlabtoken'; + +/** A real Dev Tunnels token as the store holds it. */ +const DEVTUNNELS_TOKEN = 'ghu_therealdevtunnelstoken'; + +let dirs: string[] = []; +let dbs: Db[] = []; + +afterEach(() => { + for (const d of dbs) d.close(); + dbs = []; + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + dirs = []; +}); + +/** Creates a temporary data directory that afterEach removes. */ +function dataDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'boxes-boxenv-')); + dirs.push(dir); + return dir; +} + +/** + * A deployment: a config, a credential store holding whatever was passed, and + * a prepared egress manager over both. + */ +async function deployment( + secrets: Partial> = {}, + over: Record = {}, + methods: Partial> = {}, +): Promise<{ cfg: Config; db: Db; egress: EgressManager }> { + const cfg = loadConfig({ DATA_DIR: dataDir(), ...over }); + const db = openDb(cfg.DATA_DIR); + dbs.push(db); + const credentials = new CredentialStore(db, () => {}, cfg.credentialSet); + for (const [id, secret] of Object.entries(secrets)) { + credentials.put(id as CredentialId, methods[id as CredentialId] ?? 'token', secret); + } + const egress = new EgressManager(cfg, credentials, db); + await egress.prepare(); + return { cfg, db, egress }; +} + +/** The env of one box, as a map, for a given deployment. */ +async function envFor( + secrets: Partial> = {}, + over: Record = {}, + methods: Partial> = {}, +): Promise> { + const { cfg, db, egress } = await deployment(secrets, over, methods); + return boxEnv({ + placeholderFor: (id) => egress.placeholderFor(id), + methodFor: (id) => egress.methodFor(id), + identity: readSettings(db), + gitlabHost: cfg.GITLAB_HOST, + proxyUrl: proxyUrl(cfg), + caCertificate: egress.caCertificate(), + }); +} + +describe('boxEnv', () => { + it('carries placeholders, and no real credential anywhere in it', async () => { + const env = await envFor({ claude: CLAUDE_TOKEN, github: GH_TOKEN }); + + expect(env['CLAUDE_CODE_OAUTH_TOKEN']).toMatch(/^sk-ant-oat01-/); + expect(env['GH_TOKEN']).toMatch(/^ghp_/); + + const everything = Object.entries(env) + .map(([k, v]) => `${k}=${v}`) + .join('\n'); + expect(everything).not.toContain(CLAUDE_TOKEN); + expect(everything).not.toContain(GH_TOKEN); + }, 30_000); + + it('carries a Claude API key as ANTHROPIC_API_KEY instead of the OAuth token', async () => { + // Claude Code sends the API key as x-api-key, and it wins over a token. + const env = await envFor({ claude: ANTHROPIC_KEY }, {}, { claude: 'api_key' }); + + expect(env['ANTHROPIC_API_KEY']).toMatch(/^sk-ant-/); + expect(env['ANTHROPIC_API_KEY']).not.toBe(ANTHROPIC_KEY); + expect(env['CLAUDE_CODE_OAUTH_TOKEN']).toBeUndefined(); + }, 30_000); + + it('carries no credential variable where nothing is configured', async () => { + const env = await envFor(); + + // A box holds a placeholder for a stored credential only. One entered + // later reaches the box at its next restart. + expect(env['CLAUDE_CODE_OAUTH_TOKEN']).toBeUndefined(); + expect(env['ANTHROPIC_API_KEY']).toBeUndefined(); + expect(env['GH_TOKEN']).toBeUndefined(); + expect(env['CODEX_API_KEY']).toBeUndefined(); + // What each harness needs beside its credential still comes from the registry. + expect(env['CLAUDE_CONFIG_DIR']).toBe('/home/agent/.claude'); + expect(env['CODEX_HOME']).toBe('/home/agent/.codex'); + }, 30_000); + + it('carries the GitLab host always, and the token once one is stored', async () => { + // glab reads the host, and the entrypoint points the credential helper at it. + const env = await envFor(); + expect(env['GITLAB_TOKEN']).toBeUndefined(); + expect(env['GITLAB_HOST']).toBe('gitlab.com'); + + const own = await envFor({ gitlab: GITLAB_TOKEN }, { GITLAB_HOST: 'gitlab.example.com' }); + expect(own['GITLAB_TOKEN']).toMatch(/^glpat-/); + expect(own['GITLAB_TOKEN']).not.toBe(GITLAB_TOKEN); + expect(own['GITLAB_HOST']).toBe('gitlab.example.com'); + }, 30_000); + + it('carries a Dev Tunnels placeholder for the share-app skill', async () => { + const env = await envFor({ devtunnels: DEVTUNNELS_TOKEN }); + expect(env['DEVTUNNELS_TOKEN']).toMatch(/^ghu_/); + expect(env['DEVTUNNELS_TOKEN']).not.toBe(DEVTUNNELS_TOKEN); + }, 30_000); + + it('carries what Codex needs to log itself in from the environment', async () => { + // The Codex app-server reads no key from its environment; `codex-acp` is + // what reads CODEX_API_KEY, and it only does so when DEFAULT_AUTH_REQUEST + // tells it to log in with the api-key method on the first `session/*` call. + // The value has to reach the box as the JSON the adapter parses. + const env = await envFor({ openai: OPENAI_KEY }); + + expect(env['CODEX_API_KEY']).toMatch(/^sk-/); + expect(env['CODEX_API_KEY']).not.toBe(OPENAI_KEY); + expect(env['DEFAULT_AUTH_REQUEST']).toBe('{"methodId":"api-key"}'); + expect(JSON.parse(env['DEFAULT_AUTH_REQUEST']!)).toEqual({ methodId: 'api-key' }); + // A fresh Codex thread starts where the registry says, and the browser + // method is hidden because nothing in a box can open one. + expect(env['INITIAL_AGENT_MODE']).toBe('agent-full-access'); + expect(env['NO_BROWSER']).toBe('1'); + }, 30_000); + + it('points every client at the CA, whether or not anything is intercepted', async () => { + const path = '/home/agent/.boxes/ca-bundle.crt'; + for (const env of [await envFor({ github: GH_TOKEN }), await envFor()]) { + expect(env['BOXES_PROXY_CA']).toContain('BEGIN CERTIFICATE'); + expect(env['NODE_EXTRA_CA_CERTS']).toBe(path); + expect(env['SSL_CERT_FILE']).toBe(path); + expect(env['GIT_SSL_CAINFO']).toBe(path); + expect(env['CURL_CA_BUNDLE']).toBe(path); + // Codex reads this one before SSL_CERT_FILE. + expect(env['CODEX_CA_CERTIFICATE']).toBe(path); + } + }, 30_000); + + it('still routes every client through the proxy', async () => { + const env = await envFor(); + expect(env['HTTPS_PROXY']).toBe('http://proxy:3128'); + expect(env['NO_PROXY']).toBe('localhost,127.0.0.1'); + }, 30_000); + + it('carries the git identity from the settings, which is not a credential', async () => { + const env = await envFor(); + expect(env['GIT_NAME']).toBe('boxes-bot'); + expect(env['GIT_EMAIL']).toBe('boxes-bot@users.noreply.github.com'); + }, 30_000); +}); diff --git a/orchestrator/src/boxenv.ts b/orchestrator/src/boxenv.ts new file mode 100644 index 0000000..630e5eb --- /dev/null +++ b/orchestrator/src/boxenv.ts @@ -0,0 +1,86 @@ +import type { CredentialId, CredentialMethod } from './credentials.ts'; +import { HOME_DIR } from './docker.ts'; +import { HARNESSES } from './harness.ts'; + +/** + * Where the entrypoint writes the system authorities together with the + * deployment CA, and where the CA env vars point. + */ +const CA_PATH = `${HOME_DIR}/.boxes/ca-bundle.crt`; + +/** What the environment of a box is built from. */ +export interface BoxEnvInputs { + /** The placeholder the egress proxy swaps for a stored credential. */ + placeholderFor: (credentialId: string) => string; + /** The method a stored credential is used with, or null when none is stored. */ + methodFor: (credentialId: CredentialId) => CredentialMethod | null; + /** The git identity from the settings. */ + identity: { gitName: string; gitEmail: string }; + /** The GitLab host the GitLab credential is for. */ + gitlabHost: string; + /** The URL of the egress proxy, as a box reaches it. */ + proxyUrl: string; + /** + * PEM of the deployment CA this box trusts. It is set even before any + * credential exists, as the box keeps it for its whole life. + */ + caCertificate: string; +} + +/** + * The full environment of a box, as a plain record. The runtime converts it + * to its own format. Variables with an empty value are left out. + * + * It carries the credential placeholders, never a real credential. The CA + * travels here as a PEM, so it needs no file on the host. + * + * Every harness adds its variables, even one no thread in the box uses. Each + * credential value is a placeholder that the egress proxy swaps for the real + * secret. A credential that is not stored has none, and its variable is + * dropped: the environment is fixed at container creation, so a credential + * entered later reaches the box at its next restart. + * + * GH_TOKEN is for git and gh. GITLAB_TOKEN and GITLAB_HOST are the same for + * git and glab. DEVTUNNELS_TOKEN is the GitHub token the Dev Tunnels API + * takes, for the share-app skill. + */ +export function boxEnv(inputs: BoxEnvInputs): Record { + const env: Record = {}; + for (const harness of Object.values(HARNESSES)) { + const id = harness.credentialId; + Object.assign(env, harness.env(inputs.placeholderFor(id), inputs.methodFor(id))); + } + env['GH_TOKEN'] = inputs.placeholderFor('github'); + env['GITLAB_TOKEN'] = inputs.placeholderFor('gitlab'); + env['GITLAB_HOST'] = inputs.gitlabHost; + env['DEVTUNNELS_TOKEN'] = inputs.placeholderFor('devtunnels'); + env['GIT_NAME'] = inputs.identity.gitName; + env['GIT_EMAIL'] = inputs.identity.gitEmail; + + env['TERM'] = 'dumb'; + // Every proxy-aware client honours these; anything else has no route + // out, which is the intended failure mode. + env['HTTP_PROXY'] = inputs.proxyUrl; + env['HTTPS_PROXY'] = inputs.proxyUrl; + env['http_proxy'] = inputs.proxyUrl; + env['https_proxy'] = inputs.proxyUrl; + env['NO_PROXY'] = 'localhost,127.0.0.1'; + env['no_proxy'] = 'localhost,127.0.0.1'; + + if (inputs.caCertificate !== '') { + // The entrypoint appends the PEM to the system authorities in CA_PATH; + // these are the variables that point node, gh, git, git-lfs, curl and + // Codex at it. A tool honouring none of them fails TLS against the + // intercepted hosts and nothing else. + env['BOXES_PROXY_CA'] = inputs.caCertificate; + env['NODE_EXTRA_CA_CERTS'] = CA_PATH; + env['SSL_CERT_FILE'] = CA_PATH; + env['GIT_SSL_CAINFO'] = CA_PATH; + env['CURL_CA_BUNDLE'] = CA_PATH; + // Codex reads this one first and falls back to SSL_CERT_FILE. It is + // unconfirmed whether the published Codex binary reads either. + env['CODEX_CA_CERTIFICATE'] = CA_PATH; + } + + return Object.fromEntries(Object.entries(env).filter(([, v]) => v !== '')); +} diff --git a/orchestrator/src/boxes.ts b/orchestrator/src/boxes.ts index 9c67eab..03d4cb6 100644 --- a/orchestrator/src/boxes.ts +++ b/orchestrator/src/boxes.ts @@ -12,6 +12,7 @@ import { } from '../../shared/types.ts'; import { AgentStore, ensureAgentsRoot, hostAgentConfigPath } from './agents.ts'; import type { Config } from './config.ts'; +import { boxEnv } from './boxenv.ts'; import type { EgressManager } from './egress.ts'; import { clearBoxTurns, @@ -634,13 +635,14 @@ export class BoxManager { nixSource: ws.hostNixPath(this.hostDataDir, row.id), agentConfigSource: hostAgentConfigPath(this.hostDataDir, row.id), homeSource: ws.hostHomePath(this.hostDataDir, row.id), - env: dk.credentialEnv( - (id) => this.egress.placeholderFor(id), - (id) => this.egress.methodFor(id), - { gitName: settings.gitName, gitEmail: settings.gitEmail }, - this.cfg.GITLAB_HOST, - ), - caCertificate: this.egress.caCertificate(), + env: boxEnv({ + placeholderFor: (id) => this.egress.placeholderFor(id), + methodFor: (id) => this.egress.methodFor(id), + identity: { gitName: settings.gitName, gitEmail: settings.gitEmail }, + gitlabHost: this.cfg.GITLAB_HOST, + proxyUrl: dk.proxyUrl(this.cfg), + caCertificate: this.egress.caCertificate(), + }), }; } diff --git a/orchestrator/src/config.test.ts b/orchestrator/src/config.test.ts index 1d64ea2..1c15169 100644 --- a/orchestrator/src/config.test.ts +++ b/orchestrator/src/config.test.ts @@ -32,6 +32,7 @@ test('an empty environment yields the documented defaults', () => { assert.equal(cfg.EGRESS_PROXY_CONTAINER, 'boxes-egress-proxy'); assert.equal(cfg.EGRESS_PROXY_ALIAS, 'proxy'); assert.equal(cfg.EGRESS_PROXY_PORT, 3128); + assert.equal(cfg.EGRESS_CONTROL_URL, 'http://boxes-egress-proxy:3129'); assert.equal(cfg.GITLAB_HOST, 'gitlab.com'); assert.equal(cfg.LOG_LEVEL, 'info'); }); diff --git a/orchestrator/src/config.ts b/orchestrator/src/config.ts index 4af8288..7e48a95 100644 --- a/orchestrator/src/config.ts +++ b/orchestrator/src/config.ts @@ -178,10 +178,10 @@ const schema = z.object({ /** Port boxes reach the egress proxy on. */ EGRESS_PROXY_PORT: z.coerce.number().int().positive().default(3128), /** - * Port of the proxy's control channel on the compose network. Only the + * URL of the proxy's control channel on the compose network. Only the * orchestrator connects to it. */ - EGRESS_CONTROL_PORT: z.coerce.number().int().positive().default(3129), + EGRESS_CONTROL_URL: z.url({ protocol: /^http$/ }).default('http://boxes-egress-proxy:3129'), /** * Hosts boxes may reach, comma or whitespace separated. Exact names and diff --git a/orchestrator/src/docker.test.ts b/orchestrator/src/docker.test.ts index 5c904b7..4fc891d 100644 --- a/orchestrator/src/docker.test.ts +++ b/orchestrator/src/docker.test.ts @@ -13,32 +13,10 @@ import { containerProcesses, createLoginContainer, createContainer, - credentialEnv, killInContainer, seedHomeFromImage, - boxEnv, setDockerForTests, - type CreateContainerSpec, } from './docker.ts'; -import { readSettings } from './settings.ts'; - -/** A real Claude token as the store holds it. */ -const CLAUDE_TOKEN = 'sk-ant-oat01-the-real-claude-token'; - -/** A real Anthropic API key as the store holds it. */ -const ANTHROPIC_KEY = 'sk-ant-api03-the-real-anthropic-key'; - -/** A real GitHub token as the store holds it. */ -const GH_TOKEN = 'ghp_therealgithubtoken'; - -/** A real OpenAI API key as the store holds it. */ -const OPENAI_KEY = 'sk-therealopenaiapikey'; - -/** A real GitLab token as the store holds it. */ -const GITLAB_TOKEN = 'glpat-therealgitlabtoken'; - -/** A real Dev Tunnels token as the store holds it. */ -const DEVTUNNELS_TOKEN = 'ghu_therealdevtunnelstoken'; let dirs: string[] = []; let dbs: Db[] = []; @@ -78,138 +56,6 @@ async function deployment( return { cfg, db, egress }; } -/** The env of one box, as a map, for a given deployment. */ -async function envFor( - secrets: Partial> = {}, - over: Record = {}, - methods: Partial> = {}, -): Promise> { - const { cfg, db, egress } = await deployment(secrets, over, methods); - const settings = readSettings(db); - const spec: CreateContainerSpec = { - boxId: 'abcd1234', - image: cfg.BOX_IMAGE, - networkName: 'bn-abcd1234', - subnet: '10.200.0.0/29', - workspaceSource: '/var/lib/docker/volumes/boxes-data/_data/workspaces/abcd1234', - agentConfigSource: '/var/lib/docker/volumes/boxes-data/_data/agents/abcd1234', - homeSource: '/var/lib/docker/volumes/boxes-data/_data/homes/abcd1234', - nixSource: '/var/lib/docker/volumes/boxes-data/_data/nix/abcd1234', - env: credentialEnv( - (id) => egress.placeholderFor(id), - (id) => egress.methodFor(id), - settings, - cfg.GITLAB_HOST, - ), - caCertificate: egress.caCertificate(), - }; - - return Object.fromEntries( - boxEnv(spec, cfg).map((entry) => { - const eq = entry.indexOf('='); - return [entry.slice(0, eq), entry.slice(eq + 1)]; - }), - ); -} - -describe('boxEnv', () => { - it('carries placeholders, and no real credential anywhere in it', async () => { - const env = await envFor({ claude: CLAUDE_TOKEN, github: GH_TOKEN }); - - expect(env['CLAUDE_CODE_OAUTH_TOKEN']).toMatch(/^sk-ant-oat01-/); - expect(env['GH_TOKEN']).toMatch(/^ghp_/); - - const everything = Object.entries(env) - .map(([k, v]) => `${k}=${v}`) - .join('\n'); - expect(everything).not.toContain(CLAUDE_TOKEN); - expect(everything).not.toContain(GH_TOKEN); - }, 30_000); - - it('carries a Claude API key as ANTHROPIC_API_KEY instead of the OAuth token', async () => { - // Claude Code sends the API key as x-api-key, and it wins over a token. - const env = await envFor({ claude: ANTHROPIC_KEY }, {}, { claude: 'api_key' }); - - expect(env['ANTHROPIC_API_KEY']).toMatch(/^sk-ant-/); - expect(env['ANTHROPIC_API_KEY']).not.toBe(ANTHROPIC_KEY); - expect(env['CLAUDE_CODE_OAUTH_TOKEN']).toBeUndefined(); - }, 30_000); - - it('carries no credential variable where nothing is configured', async () => { - const env = await envFor(); - - // A box holds a placeholder for a stored credential only. One entered - // later reaches the box at its next restart. - expect(env['CLAUDE_CODE_OAUTH_TOKEN']).toBeUndefined(); - expect(env['ANTHROPIC_API_KEY']).toBeUndefined(); - expect(env['GH_TOKEN']).toBeUndefined(); - expect(env['CODEX_API_KEY']).toBeUndefined(); - // What each harness needs beside its credential still comes from the registry. - expect(env['CLAUDE_CONFIG_DIR']).toBe('/home/agent/.claude'); - expect(env['CODEX_HOME']).toBe('/home/agent/.codex'); - }, 30_000); - - it('carries the GitLab host always, and the token once one is stored', async () => { - // glab reads the host, and the entrypoint points the credential helper at it. - const env = await envFor(); - expect(env['GITLAB_TOKEN']).toBeUndefined(); - expect(env['GITLAB_HOST']).toBe('gitlab.com'); - - const own = await envFor({ gitlab: GITLAB_TOKEN }, { GITLAB_HOST: 'gitlab.example.com' }); - expect(own['GITLAB_TOKEN']).toMatch(/^glpat-/); - expect(own['GITLAB_TOKEN']).not.toBe(GITLAB_TOKEN); - expect(own['GITLAB_HOST']).toBe('gitlab.example.com'); - }, 30_000); - - it('carries a Dev Tunnels placeholder for the share-app skill', async () => { - const env = await envFor({ devtunnels: DEVTUNNELS_TOKEN }); - expect(env['DEVTUNNELS_TOKEN']).toMatch(/^ghu_/); - expect(env['DEVTUNNELS_TOKEN']).not.toBe(DEVTUNNELS_TOKEN); - }, 30_000); - - it('carries what Codex needs to log itself in from the environment', async () => { - // The Codex app-server reads no key from its environment; `codex-acp` is - // what reads CODEX_API_KEY, and it only does so when DEFAULT_AUTH_REQUEST - // tells it to log in with the api-key method on the first `session/*` call. - // The value has to reach the box as the JSON the adapter parses. - const env = await envFor({ openai: OPENAI_KEY }); - - expect(env['CODEX_API_KEY']).toMatch(/^sk-/); - expect(env['CODEX_API_KEY']).not.toBe(OPENAI_KEY); - expect(env['DEFAULT_AUTH_REQUEST']).toBe('{"methodId":"api-key"}'); - expect(JSON.parse(env['DEFAULT_AUTH_REQUEST']!)).toEqual({ methodId: 'api-key' }); - // A fresh Codex thread starts where the registry says, and the browser - // method is hidden because nothing in a box can open one. - expect(env['INITIAL_AGENT_MODE']).toBe('agent-full-access'); - expect(env['NO_BROWSER']).toBe('1'); - }, 30_000); - - it('points every client at the CA, whether or not anything is intercepted', async () => { - const path = '/home/agent/.boxes/ca-bundle.crt'; - for (const env of [await envFor({ github: GH_TOKEN }), await envFor()]) { - expect(env['BOXES_PROXY_CA']).toContain('BEGIN CERTIFICATE'); - expect(env['NODE_EXTRA_CA_CERTS']).toBe(path); - expect(env['SSL_CERT_FILE']).toBe(path); - expect(env['GIT_SSL_CAINFO']).toBe(path); - expect(env['CURL_CA_BUNDLE']).toBe(path); - // Codex reads this one before SSL_CERT_FILE. - expect(env['CODEX_CA_CERTIFICATE']).toBe(path); - } - }, 30_000); - - it('still routes every client through the proxy', async () => { - const env = await envFor(); - expect(env['HTTPS_PROXY']).toBe('http://proxy:3128'); - expect(env['NO_PROXY']).toBe('localhost,127.0.0.1'); - }, 30_000); - - it('carries the git identity from the settings, which is not a credential', async () => { - const env = await envFor(); - expect(env['GIT_NAME']).toBe('boxes-bot'); - expect(env['GIT_EMAIL']).toBe('boxes-bot@users.noreply.github.com'); - }, 30_000); -}); - describe('the container template', () => { /** Captures what createContainer would ask the daemon for. */ async function capture(): Promise> { @@ -234,7 +80,6 @@ describe('the container template', () => { homeSource: '/var/lib/docker/volumes/boxes-data/_data/homes/abcd1234', nixSource: '/var/lib/docker/volumes/boxes-data/_data/nix/abcd1234', env: {}, - caCertificate: '', }, cfg, ); diff --git a/orchestrator/src/docker.ts b/orchestrator/src/docker.ts index 4d234b7..764c4b3 100644 --- a/orchestrator/src/docker.ts +++ b/orchestrator/src/docker.ts @@ -5,8 +5,6 @@ import { PassThrough, Readable } from 'node:stream'; import type { Duplex } from 'node:stream'; import type { DockerState, ImageInfo } from '../../shared/types.ts'; import type { Config } from './config.ts'; -import type { CredentialId, CredentialMethod } from './credentials.ts'; -import { HARNESSES } from './harness.ts'; import { log } from './log.ts'; import { boxOwner } from './workspaces.ts'; @@ -124,100 +122,16 @@ export interface CreateContainerSpec { homeSource: string; /** Host-side path of the box's Nix store directory, bound at NIX_DIR. */ nixSource: string; - /** - * The credential placeholders and the git identity, as built by - * credentialEnv(). - */ + /** The full environment of the box, as built by boxEnv(). */ env: Record; - /** - * PEM of the deployment CA this box trusts. It is set even before any - * credential exists, as the box keeps it for its whole life. - */ - caCertificate: string; -} - -/** - * The credential and identity part of a box's environment. - * - * Every harness adds its variables, even one no thread in the box uses. Each - * credential value is a placeholder that the egress proxy swaps for the real - * secret. A credential that is not stored has none, and its variable is - * dropped: the environment is fixed at container creation, so a credential - * entered later reaches the box at its next restart. - * - * GH_TOKEN is for git and gh. GITLAB_TOKEN and GITLAB_HOST are the same for - * git and glab. DEVTUNNELS_TOKEN is the GitHub token the Dev Tunnels API - * takes, for the share-app skill. - */ -export function credentialEnv( - placeholderFor: (credentialId: string) => string, - methodFor: (credentialId: CredentialId) => CredentialMethod | null, - identity: { gitName: string; gitEmail: string }, - gitlabHost: string, -): Record { - const env: Record = {}; - for (const harness of Object.values(HARNESSES)) { - const id = harness.credentialId; - Object.assign(env, harness.env(placeholderFor(id), methodFor(id))); - } - env['GH_TOKEN'] = placeholderFor('github'); - env['GITLAB_TOKEN'] = placeholderFor('gitlab'); - env['GITLAB_HOST'] = gitlabHost; - env['DEVTUNNELS_TOKEN'] = placeholderFor('devtunnels'); - env['GIT_NAME'] = identity.gitName; - env['GIT_EMAIL'] = identity.gitEmail; - return env; } /** The agent user's home inside a box container, where its own files and caches live. */ export const HOME_DIR = '/home/agent'; -/** - * Where the entrypoint writes the system authorities together with the - * deployment CA, and where the CA env vars point. - */ -const CA_PATH = `${HOME_DIR}/.boxes/ca-bundle.crt`; - -/** - * Environment of a box container, as Docker's `KEY=value` list. Variables - * with an empty value are left out. - * - * It carries the credential placeholders, never a real credential. The CA - * travels here as a PEM, so it needs no file on the host. - */ -export function boxEnv(spec: CreateContainerSpec, cfg: Config): string[] { - const proxyUrl = `http://${cfg.EGRESS_PROXY_ALIAS}:${cfg.EGRESS_PROXY_PORT}`; - const env: Record = { - ...spec.env, - TERM: 'dumb', - // Every proxy-aware client honours these; anything else has no route - // out, which is the intended failure mode. - HTTP_PROXY: proxyUrl, - HTTPS_PROXY: proxyUrl, - http_proxy: proxyUrl, - https_proxy: proxyUrl, - NO_PROXY: 'localhost,127.0.0.1', - no_proxy: 'localhost,127.0.0.1', - }; - - if (spec.caCertificate !== '') { - // The entrypoint appends the PEM to the system authorities in CA_PATH; - // these are the variables that point node, gh, git, git-lfs, curl and - // Codex at it. A tool honouring none of them fails TLS against the - // intercepted hosts and nothing else. - env['BOXES_PROXY_CA'] = spec.caCertificate; - env['NODE_EXTRA_CA_CERTS'] = CA_PATH; - env['SSL_CERT_FILE'] = CA_PATH; - env['GIT_SSL_CAINFO'] = CA_PATH; - env['CURL_CA_BUNDLE'] = CA_PATH; - // Codex reads this one first and falls back to SSL_CERT_FILE. It is - // unconfirmed whether the published Codex binary reads either. - env['CODEX_CA_CERTIFICATE'] = CA_PATH; - } - - return Object.entries(env) - .filter(([, v]) => v !== '') - .map(([k, v]) => `${k}=${v}`); +/** The URL of the egress proxy, as a box on its network reaches it. */ +export function proxyUrl(cfg: Config): string { + return `http://${cfg.EGRESS_PROXY_ALIAS}:${cfg.EGRESS_PROXY_PORT}`; } /** Converts a Docker-style memory limit such as 4g into bytes. */ @@ -520,7 +434,7 @@ export async function createContainer(spec: CreateContainerSpec, cfg: Config): P Image: spec.image, User: boxUser(), WorkingDir: WORKSPACE_DIR, - Env: boxEnv(spec, cfg), + Env: Object.entries(spec.env).map(([k, v]) => `${k}=${v}`), Labels: { [LABEL]: spec.boxId, // Keeps Watchtower away. A recreated container would leave a stale id diff --git a/orchestrator/src/egress.test.ts b/orchestrator/src/egress.test.ts index 9aee17c..3890f99 100644 --- a/orchestrator/src/egress.test.ts +++ b/orchestrator/src/egress.test.ts @@ -249,10 +249,7 @@ describe('the control channel, from the orchestrator side', () => { server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port)); }); - const cfg = configFrom({ - EGRESS_PROXY_CONTAINER: '127.0.0.1', - EGRESS_CONTROL_PORT: String(port), - }); + const cfg = configFrom({ EGRESS_CONTROL_URL: `http://127.0.0.1:${port}` }); const { store, db } = storeWith(cfg, { claude: CLAUDE_TOKEN, github: GH_TOKEN }); const material = await resolveEgressMaterial(db); return { cfg, material, store, seen }; @@ -367,10 +364,7 @@ describe('EgressManager', () => { server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port)); }); - const cfg = configFrom({ - EGRESS_PROXY_CONTAINER: '127.0.0.1', - EGRESS_CONTROL_PORT: String(port), - }); + const cfg = configFrom({ EGRESS_CONTROL_URL: `http://127.0.0.1:${port}` }); // The wiring the app builds: a write to the store pushes the policy. let manager: EgressManager | undefined; const { store, db } = storeWith(cfg, {}, () => void manager?.sync()); @@ -388,7 +382,7 @@ describe('EgressManager', () => { }, 30_000); it('records why a push failed, for the health probe', async () => { - const cfg = configFrom({ EGRESS_CONTROL_PORT: '1' }); + const cfg = configFrom({ EGRESS_CONTROL_URL: 'http://127.0.0.1:1' }); const { store, db } = storeWith(cfg, { claude: CLAUDE_TOKEN }); const manager = new EgressManager(cfg, store, db); await manager.prepare(); diff --git a/orchestrator/src/egress.ts b/orchestrator/src/egress.ts index 8f1941a..42ad03f 100644 --- a/orchestrator/src/egress.ts +++ b/orchestrator/src/egress.ts @@ -111,7 +111,7 @@ async function controlCall( path: string, body?: unknown, ): Promise { - const url = `http://${cfg.EGRESS_PROXY_CONTAINER}:${cfg.EGRESS_CONTROL_PORT}${path}`; + const url = new URL(path, cfg.EGRESS_CONTROL_URL); const res = await fetch(url, { method, headers: {