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: {