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
5 changes: 3 additions & 2 deletions doc/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down Expand Up @@ -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.
182 changes: 182 additions & 0 deletions orchestrator/src/boxenv.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<CredentialId, string>> = {},
over: Record<string, string> = {},
methods: Partial<Record<CredentialId, CredentialMethod>> = {},
): 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<Record<CredentialId, string>> = {},
over: Record<string, string> = {},
methods: Partial<Record<CredentialId, CredentialMethod>> = {},
): Promise<Record<string, string>> {
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);
});
86 changes: 86 additions & 0 deletions orchestrator/src/boxenv.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const env: Record<string, string> = {};
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 !== ''));
}
16 changes: 9 additions & 7 deletions orchestrator/src/boxes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
}),
};
}

Expand Down
1 change: 1 addition & 0 deletions orchestrator/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
4 changes: 2 additions & 2 deletions orchestrator/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading