diff --git a/docker/plugin-setup/codex-auth-relay.js b/docker/plugin-setup/codex-auth-relay.js new file mode 100644 index 000000000..155abf90d --- /dev/null +++ b/docker/plugin-setup/codex-auth-relay.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +/** + * Codex OAuth login relay for the hardened harness. + * + * `codex login` binds its callback server to 127.0.0.1:1455 inside the + * container. A Docker port publish forwards to the container interface, not + * loopback, so the host browser's redirect to http://localhost:1455 is + * refused. This bridges the published interface to loopback: + * + * host 127.0.0.1:1455 -> container 0.0.0.0:1456 (this relay) -> 127.0.0.1:1455 + * + * Run inside the networked session before `codex login`: + * node /ecc/docker/plugin-setup/codex-auth-relay.js & + * + * Env: ECC_RELAY_LISTEN (default 1456), ECC_RELAY_TARGET (default 1455). + */ + +'use strict'; + +const net = require('net'); + +function portFromEnv(name, fallback) { + const value = process.env[name] === undefined ? fallback : process.env[name]; + const port = Number(value); + if (!/^\d+$/.test(String(value)) || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`${name} must be an integer port between 1 and 65535`); + } + return port; +} + +const listenPort = portFromEnv('ECC_RELAY_LISTEN', 1456); +const targetPort = portFromEnv('ECC_RELAY_TARGET', 1455); + +net.createServer(client => { + const upstream = net.connect(targetPort, '127.0.0.1'); + client.pipe(upstream).pipe(client); + const drop = () => { + client.destroy(); + upstream.destroy(); + }; + client.on('error', drop); + upstream.on('error', drop); + client.on('close', drop); + upstream.on('close', drop); + client.setTimeout(30000, drop); + upstream.setTimeout(30000, drop); +}).listen(listenPort, '0.0.0.0', () => { + console.log(`codex-auth-relay: 0.0.0.0:${listenPort} -> 127.0.0.1:${targetPort}`); +}); diff --git a/docker/plugin-setup/compose.yaml b/docker/plugin-setup/compose.yaml index ef19064e5..15678eaa6 100644 --- a/docker/plugin-setup/compose.yaml +++ b/docker/plugin-setup/compose.yaml @@ -76,6 +76,9 @@ services: profiles: - networked network_mode: default + # Codex OAuth callback bridge: host browser -> codex-auth-relay.js -> loopback. + ports: + - "127.0.0.1:1455:1456" image: ecc-plugin-setup:debian real-cli-ubuntu: diff --git a/skills/docker-patterns/SKILL.md b/skills/docker-patterns/SKILL.md index e60c1d20f..f5dbb4683 100644 --- a/skills/docker-patterns/SKILL.md +++ b/skills/docker-patterns/SKILL.md @@ -416,6 +416,35 @@ explicit Compose `--env NAME` flag, understand that the value is inspectable and can be exfiltrated for the container lifetime, and remove the exact named container immediately after. +### Authenticate Codex Inside the Networked Session + +`codex login` binds its OAuth callback server to `127.0.0.1:1455` inside the +container. A Docker port publish forwards to the container interface, not +loopback, so the host browser's redirect to `http://localhost:1455` is refused +without a bridge. The `real-cli-networked` service publishes host +`127.0.0.1:1455` to container port `1456`; bridge that port to loopback before +logging in: + +```bash +npm install --global @openai/codex # the harness image does not ship codex +node /ecc/docker/plugin-setup/codex-auth-relay.js & +relay_pid=$! +codex login +kill "$relay_pid" +``` + +Complete the sign-in in the host browser. Each attempt mints a fresh +`state`/`code`, so always follow the newest printed link. The token stays in +the disposable container's `~/.codex/auth.json`; removing the named container +deletes that credential copy. Stop the relay after the login attempt. Only one container can hold host port 1455 at a time +because the OAuth redirect targets a fixed port. + +The host publish is restricted to loopback. Inside the container, the relay +listens on all interfaces so Docker can forward to it; other containers on the +same bridge network can reach it while it runs. Use a dedicated network without +untrusted peers for this login, and remove the exact named container after use. +Idle callback connections close after 30 seconds. + Run the same focused suite natively on the host: ```bash diff --git a/tests/docker/codex-auth-relay.test.js b/tests/docker/codex-auth-relay.test.js new file mode 100644 index 000000000..9c8aa40ea --- /dev/null +++ b/tests/docker/codex-auth-relay.test.js @@ -0,0 +1,62 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const net = require('node:net'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const test = require('node:test'); + +const script = path.resolve(__dirname, '../../docker/plugin-setup/codex-auth-relay.js'); + +async function availablePort() { + const server = net.createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const port = server.address().port; + await new Promise(resolve => server.close(resolve)); + return port; +} + +test('actual relay forwards callback bytes and closes its upstream when the client leaves', { timeout: 10000 }, async t => { + const upstreamSockets = new Set(); + const upstream = net.createServer(socket => { + upstreamSockets.add(socket); + socket.on('close', () => upstreamSockets.delete(socket)); + socket.on('data', data => socket.write(data)); + }); + upstream.listen(0, '127.0.0.1'); + await once(upstream, 'listening'); + const relayPort = await availablePort(); + const child = spawn(process.execPath, [script], { env: { ...process.env, + ECC_RELAY_LISTEN: String(relayPort), ECC_RELAY_TARGET: String(upstream.address().port) } }); + t.after(async () => { + child.kill(); + for (const socket of upstreamSockets) socket.destroy(); + await new Promise(resolve => upstream.close(resolve)); + }); + const startup = await Promise.race([once(child.stdout, 'data'), once(child, 'exit')]); + assert.ok(Buffer.isBuffer(startup[0]), 'Relay must start before exiting'); + const client = net.connect(relayPort, '127.0.0.1'); + t.after(() => client.destroy()); + await once(client, 'connect'); + client.write('GET /auth/callback?state=fixture&code=fixture HTTP/1.1\r\n\r\n'); + const [bytes] = await once(client, 'data'); + assert.equal(bytes.toString(), 'GET /auth/callback?state=fixture&code=fixture HTTP/1.1\r\n\r\n'); + const socket = [...upstreamSockets][0]; + const closed = once(socket, 'close'); + client.destroy(); + await closed; + assert.equal(child.exitCode, null); +}); + +for (const value of ['0', '-1', '65536', '1.5', 'not-a-port']) { + test(`rejects invalid relay port ${value} before listening`, { timeout: 5000 }, async () => { + const child = spawn(process.execPath, [script], { env: { ...process.env, ECC_RELAY_LISTEN: value } }); + let stderr = ''; + child.stderr.on('data', chunk => { stderr += chunk; }); + const [code] = await once(child, 'exit'); + assert.equal(code, 1); + assert.match(stderr, /ECC_RELAY_LISTEN must be an integer port/); + }); +}