286 lines
9.6 KiB
TypeScript
286 lines
9.6 KiB
TypeScript
/**
|
|
* CCR upstreamproxy — container-side wiring.
|
|
*
|
|
* When running inside a CCR session container with upstreamproxy configured,
|
|
* this module:
|
|
* 1. Reads the session token from /run/ccr/session_token
|
|
* 2. Sets prctl(PR_SET_DUMPABLE, 0) to block same-UID ptrace of the heap
|
|
* 3. Downloads the upstreamproxy CA cert and concatenates it with the
|
|
* system bundle so curl/gh/python trust the MITM proxy
|
|
* 4. Starts a local CONNECT→WebSocket relay (see relay.ts)
|
|
* 5. Unlinks the token file (token stays heap-only; file is gone before
|
|
* the agent loop can see it, but only after the relay is confirmed up
|
|
* so a supervisor restart can retry)
|
|
* 6. Exposes HTTPS_PROXY / SSL_CERT_FILE env vars for all agent subprocesses
|
|
*
|
|
* Every step fails open: any error logs a warning and disables the proxy.
|
|
* A broken proxy setup must never break an otherwise-working session.
|
|
*
|
|
* Design doc: api-go/ccr/docs/plans/CCR_AUTH_DESIGN.md § "Week-1 pilot scope".
|
|
*/
|
|
|
|
import { mkdir, readFile, unlink, writeFile } from 'fs/promises'
|
|
import { homedir } from 'os'
|
|
import { join } from 'path'
|
|
import { registerCleanup } from '../utils/cleanupRegistry.js'
|
|
import { logForDebugging } from '../utils/debug.js'
|
|
import { isEnvTruthy } from '../utils/envUtils.js'
|
|
import { isENOENT } from '../utils/errors.js'
|
|
import { startUpstreamProxyRelay } from './relay.js'
|
|
|
|
export const SESSION_TOKEN_PATH = '/run/ccr/session_token'
|
|
const SYSTEM_CA_BUNDLE = '/etc/ssl/certs/ca-certificates.crt'
|
|
|
|
// Hosts the proxy must NOT intercept. Covers loopback, RFC1918, the IMDS
|
|
// range, and the package registries + GitHub that CCR containers already
|
|
// reach directly. Mirrors airlock/scripts/sandbox-shell-ccr.sh.
|
|
const NO_PROXY_LIST = [
|
|
'localhost',
|
|
'127.0.0.1',
|
|
'::1',
|
|
'169.254.0.0/16',
|
|
'10.0.0.0/8',
|
|
'172.16.0.0/12',
|
|
'192.168.0.0/16',
|
|
// Anthropic API: no upstream route will ever match, and the MITM breaks
|
|
// non-Bun runtimes (Python httpx/certifi doesn't trust the forged CA).
|
|
// Three forms because NO_PROXY parsing differs across runtimes:
|
|
// *.anthropic.com — Bun, curl, Go (glob match)
|
|
// .anthropic.com — Python urllib/httpx (suffix match, strips leading dot)
|
|
// anthropic.com — apex domain fallback
|
|
'anthropic.com',
|
|
'.anthropic.com',
|
|
'*.anthropic.com',
|
|
'github.com',
|
|
'api.github.com',
|
|
'*.github.com',
|
|
'*.githubusercontent.com',
|
|
'registry.npmjs.org',
|
|
'pypi.org',
|
|
'files.pythonhosted.org',
|
|
'index.crates.io',
|
|
'proxy.golang.org',
|
|
].join(',')
|
|
|
|
type UpstreamProxyState = {
|
|
enabled: boolean
|
|
port?: number
|
|
caBundlePath?: string
|
|
}
|
|
|
|
let state: UpstreamProxyState = { enabled: false }
|
|
|
|
/**
|
|
* Initialize upstreamproxy. Called once from init.ts. Safe to call when the
|
|
* feature is off or the token file is absent — returns {enabled: false}.
|
|
*
|
|
* Overridable paths are for tests; production uses the defaults.
|
|
*/
|
|
export async function initUpstreamProxy(opts?: {
|
|
tokenPath?: string
|
|
systemCaPath?: string
|
|
caBundlePath?: string
|
|
ccrBaseUrl?: string
|
|
}): Promise<UpstreamProxyState> {
|
|
if (!isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) {
|
|
return state
|
|
}
|
|
// CCR evaluates ccr_upstream_proxy_enabled server-side (where GrowthBook is
|
|
// warm) and injects this env var via StartupContext.EnvironmentVariables.
|
|
// Every CCR session is a fresh container with no GB cache, so a client-side
|
|
// GB check here always returned the default (false).
|
|
if (!isEnvTruthy(process.env.CCR_UPSTREAM_PROXY_ENABLED)) {
|
|
return state
|
|
}
|
|
|
|
const sessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID
|
|
if (!sessionId) {
|
|
logForDebugging(
|
|
'[upstreamproxy] CLAUDE_CODE_REMOTE_SESSION_ID unset; proxy disabled',
|
|
{ level: 'warn' },
|
|
)
|
|
return state
|
|
}
|
|
|
|
const tokenPath = opts?.tokenPath ?? SESSION_TOKEN_PATH
|
|
const token = await readToken(tokenPath)
|
|
if (!token) {
|
|
logForDebugging('[upstreamproxy] no session token file; proxy disabled')
|
|
return state
|
|
}
|
|
|
|
setNonDumpable()
|
|
|
|
// CCR injects ANTHROPIC_BASE_URL via StartupContext (sessionExecutor.ts /
|
|
// sessionHandler.ts). getOauthConfig() is wrong here: it keys off
|
|
// USER_TYPE + USE_{LOCAL,STAGING}_OAUTH, none of which the container sets,
|
|
// so it always returned the prod URL and the CA fetch 404'd.
|
|
const baseUrl =
|
|
opts?.ccrBaseUrl ??
|
|
process.env.ANTHROPIC_BASE_URL ??
|
|
'https://api.anthropic.com'
|
|
const caBundlePath =
|
|
opts?.caBundlePath ?? join(homedir(), '.ccr', 'ca-bundle.crt')
|
|
|
|
const caOk = await downloadCaBundle(
|
|
baseUrl,
|
|
opts?.systemCaPath ?? SYSTEM_CA_BUNDLE,
|
|
caBundlePath,
|
|
)
|
|
if (!caOk) return state
|
|
|
|
try {
|
|
const wsUrl = baseUrl.replace(/^http/, 'ws') + '/v1/code/upstreamproxy/ws'
|
|
const relay = await startUpstreamProxyRelay({ wsUrl, sessionId, token })
|
|
registerCleanup(async () => relay.stop())
|
|
state = { enabled: true, port: relay.port, caBundlePath }
|
|
logForDebugging(`[upstreamproxy] enabled on 127.0.0.1:${relay.port}`)
|
|
// Only unlink after the listener is up: if CA download or listen()
|
|
// fails, a supervisor restart can retry with the token still on disk.
|
|
await unlink(tokenPath).catch(() => {
|
|
logForDebugging('[upstreamproxy] token file unlink failed', {
|
|
level: 'warn',
|
|
})
|
|
})
|
|
} catch (err) {
|
|
logForDebugging(
|
|
`[upstreamproxy] relay start failed: ${err instanceof Error ? err.message : String(err)}; proxy disabled`,
|
|
{ level: 'warn' },
|
|
)
|
|
}
|
|
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* Env vars to merge into every agent subprocess. Empty when the proxy is
|
|
* disabled. Called from subprocessEnv() so Bash/MCP/LSP/hooks all inherit
|
|
* the same recipe.
|
|
*/
|
|
export function getUpstreamProxyEnv(): Record<string, string> {
|
|
if (!state.enabled || !state.port || !state.caBundlePath) {
|
|
// Child CLI processes can't re-initialize the relay (token file was
|
|
// unlinked by the parent), but the parent's relay is still running and
|
|
// reachable at 127.0.0.1:<port>. If we inherited proxy vars from the
|
|
// parent (HTTPS_PROXY + SSL_CERT_FILE both set), pass them through so
|
|
// our subprocesses also route through the parent's relay.
|
|
if (process.env.HTTPS_PROXY && process.env.SSL_CERT_FILE) {
|
|
const inherited: Record<string, string> = {}
|
|
for (const key of [
|
|
'HTTPS_PROXY',
|
|
'https_proxy',
|
|
'NO_PROXY',
|
|
'no_proxy',
|
|
'SSL_CERT_FILE',
|
|
'NODE_EXTRA_CA_CERTS',
|
|
'REQUESTS_CA_BUNDLE',
|
|
'CURL_CA_BUNDLE',
|
|
]) {
|
|
if (process.env[key]) inherited[key] = process.env[key]
|
|
}
|
|
return inherited
|
|
}
|
|
return {}
|
|
}
|
|
const proxyUrl = `http://127.0.0.1:${state.port}`
|
|
// HTTPS only: the relay handles CONNECT and nothing else. Plain HTTP has
|
|
// no credentials to inject, so routing it through the relay would just
|
|
// break the request with a 405.
|
|
return {
|
|
HTTPS_PROXY: proxyUrl,
|
|
https_proxy: proxyUrl,
|
|
NO_PROXY: NO_PROXY_LIST,
|
|
no_proxy: NO_PROXY_LIST,
|
|
SSL_CERT_FILE: state.caBundlePath,
|
|
NODE_EXTRA_CA_CERTS: state.caBundlePath,
|
|
REQUESTS_CA_BUNDLE: state.caBundlePath,
|
|
CURL_CA_BUNDLE: state.caBundlePath,
|
|
}
|
|
}
|
|
|
|
/** Test-only: reset module state between test cases. */
|
|
export function resetUpstreamProxyForTests(): void {
|
|
state = { enabled: false }
|
|
}
|
|
|
|
async function readToken(path: string): Promise<string | null> {
|
|
try {
|
|
const raw = await readFile(path, 'utf8')
|
|
return raw.trim() || null
|
|
} catch (err) {
|
|
if (isENOENT(err)) return null
|
|
logForDebugging(
|
|
`[upstreamproxy] token read failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
{ level: 'warn' },
|
|
)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* prctl(PR_SET_DUMPABLE, 0) via libc FFI. Blocks same-UID ptrace of this
|
|
* process, so a prompt-injected `gdb -p $PPID` can't scrape the token from
|
|
* the heap. Linux-only; silently no-ops elsewhere.
|
|
*/
|
|
function setNonDumpable(): void {
|
|
if (process.platform !== 'linux' || typeof Bun === 'undefined') return
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const ffi = require('bun:ffi') as typeof import('bun:ffi')
|
|
const lib = ffi.dlopen('libc.so.6', {
|
|
prctl: {
|
|
args: ['int', 'u64', 'u64', 'u64', 'u64'],
|
|
returns: 'int',
|
|
},
|
|
} as const)
|
|
const PR_SET_DUMPABLE = 4
|
|
const rc = lib.symbols.prctl(PR_SET_DUMPABLE, 0n, 0n, 0n, 0n)
|
|
if (rc !== 0) {
|
|
logForDebugging(
|
|
'[upstreamproxy] prctl(PR_SET_DUMPABLE,0) returned nonzero',
|
|
{
|
|
level: 'warn',
|
|
},
|
|
)
|
|
}
|
|
} catch (err) {
|
|
logForDebugging(
|
|
`[upstreamproxy] prctl unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
|
{ level: 'warn' },
|
|
)
|
|
}
|
|
}
|
|
|
|
async function downloadCaBundle(
|
|
baseUrl: string,
|
|
systemCaPath: string,
|
|
outPath: string,
|
|
): Promise<boolean> {
|
|
try {
|
|
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
|
const resp = await fetch(`${baseUrl}/v1/code/upstreamproxy/ca-cert`, {
|
|
// Bun has no default fetch timeout — a hung endpoint would block CLI
|
|
// startup forever. 5s is generous for a small PEM.
|
|
signal: AbortSignal.timeout(5000),
|
|
})
|
|
if (!resp.ok) {
|
|
logForDebugging(
|
|
`[upstreamproxy] ca-cert fetch ${resp.status}; proxy disabled`,
|
|
{ level: 'warn' },
|
|
)
|
|
return false
|
|
}
|
|
const ccrCa = await resp.text()
|
|
const systemCa = await readFile(systemCaPath, 'utf8').catch(() => '')
|
|
await mkdir(join(outPath, '..'), { recursive: true })
|
|
await writeFile(outPath, systemCa + '\n' + ccrCa, 'utf8')
|
|
return true
|
|
} catch (err) {
|
|
logForDebugging(
|
|
`[upstreamproxy] ca-cert download failed: ${err instanceof Error ? err.message : String(err)}; proxy disabled`,
|
|
{ level: 'warn' },
|
|
)
|
|
return false
|
|
}
|
|
}
|