Route Genie app-server traffic through Agent proxy

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Iliyan Malchev
2026-03-19 12:31:46 -07:00
parent f212204fa4
commit 89c42926fc
9 changed files with 413 additions and 99 deletions

View File

@@ -13,9 +13,13 @@ import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
interface CodexHttpRequestForwarder {
fun sendHttpRequest(method: String, path: String, body: String?): CodexAgentBridge.HttpResponse
}
class AgentBridgeClient(
private val context: Context,
) : Closeable {
) : Closeable, CodexHttpRequestForwarder {
companion object {
private const val AGENT_PACKAGE = "com.openai.codexd"
private const val AGENT_BRIDGE_SERVICE = "com.openai.codexd.CodexAgentBridgeService"
@@ -69,12 +73,16 @@ class AgentBridgeClient(
)
}
fun sendHttpRequest(method: String, path: String, body: String?): CodexAgentBridge.HttpResponse {
override fun sendHttpRequest(
method: String,
path: String,
body: String?,
): CodexAgentBridge.HttpResponse {
val service = requireService()
val response = service.sendHttpRequest(BridgeHttpRequest(method, path, body))
return CodexAgentBridge.HttpResponse(
statusCode = response.statusCode,
body = response.body,
body = response.body ?: "",
)
}

View File

@@ -21,12 +21,12 @@ class CodexAppServerHost(
private val request: GenieRequest,
private val callback: GenieService.Callback,
private val control: GenieSessionControl,
private val bridgeClient: AgentBridgeClient,
private val runtimeStatus: CodexAgentBridge.RuntimeStatus,
private val targetAppContext: TargetAppContext?,
) : Closeable {
companion object {
private const val TAG = "CodexAppServerHost"
private const val AGENT_SOCKET_PATH = "@com.openai.codexd.codexd"
private const val REQUEST_TIMEOUT_MS = 30_000L
private const val POLL_TIMEOUT_MS = 250L
}
@@ -43,6 +43,7 @@ class CodexAppServerHost(
private var stderrThread: Thread? = null
private var finalAgentMessage: String? = null
private var resultPublished = false
private var localProxy: GenieLocalCodexProxy? = null
fun run() {
startProcess()
@@ -56,6 +57,7 @@ class CodexAppServerHost(
override fun close() {
stdoutThread?.interrupt()
stderrThread?.interrupt()
localProxy?.close()
synchronized(writerLock) {
runCatching { writer.close() }
}
@@ -67,9 +69,19 @@ class CodexAppServerHost(
private fun startProcess() {
val codexHome = File(context.filesDir, "codex-home").apply { mkdirs() }
localProxy = GenieLocalCodexProxy(
sessionId = request.sessionId,
requestForwarder = bridgeClient,
).also(GenieLocalCodexProxy::start)
val proxyBaseUrl = localProxy?.baseUrl
?: throw IOException("local Genie proxy did not start")
val processBuilder = ProcessBuilder(
listOf(
CodexBinaryLocator.resolve(context).absolutePath,
"-c",
"enable_request_compression=false",
"-c",
"openai_base_url=\"$proxyBaseUrl\"",
"app-server",
"--listen",
"stdio://",
@@ -77,8 +89,7 @@ class CodexAppServerHost(
)
val env = processBuilder.environment()
env["CODEX_HOME"] = codexHome.absolutePath
env["CODEX_OPENAI_UNIX_SOCKET"] = AGENT_SOCKET_PATH
env["OPENAI_BASE_URL"] = "http://localhost/v1"
env["CODEX_USE_AGENT_AUTH_PROXY"] = "1"
env["RUST_LOG"] = "info"
process = processBuilder.start()
control.process = process

View File

@@ -49,7 +49,7 @@ class CodexGenieService : GenieService() {
)
callback.publishTrace(
sessionId,
"Genie is headless. It hosts codex app-server locally, routes model traffic through the Agent-owned codexd socket, and exposes Android tooling as dynamic tools.",
"Genie is headless. It hosts codex app-server locally, routes model traffic through the Agent Binder bridge, and exposes Android tooling as dynamic tools.",
)
val targetAppContext = runCatching { TargetAppInspector.inspect(this, request.targetPackage) }
@@ -92,6 +92,7 @@ class CodexGenieService : GenieService() {
request = request,
callback = callback,
control = control,
bridgeClient = bridgeClient,
runtimeStatus = runtimeStatus,
targetAppContext = targetAppContext.getOrNull(),
).use { host ->

View File

@@ -0,0 +1,235 @@
package com.openai.codex.genie
import android.util.Log
import java.io.ByteArrayOutputStream
import java.io.Closeable
import java.io.EOFException
import java.io.IOException
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.nio.charset.StandardCharsets
import java.util.Collections
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
class GenieLocalCodexProxy(
private val sessionId: String,
private val requestForwarder: CodexHttpRequestForwarder,
) : Closeable {
companion object {
private const val TAG = "GenieLocalProxy"
}
private val pathSecret = UUID.randomUUID().toString().replace("-", "")
private val serverSocket = ServerSocket(0, 50, InetAddress.getLoopbackAddress())
private val closed = AtomicBoolean(false)
private val clientSockets = Collections.synchronizedSet(mutableSetOf<Socket>())
private val acceptThread = Thread(::acceptLoop, "GenieLocalProxy-$sessionId")
val baseUrl: String = "http://127.0.0.1:${serverSocket.localPort}/$pathSecret/v1"
fun start() {
acceptThread.start()
logInfo("Listening on $baseUrl for $sessionId")
}
override fun close() {
if (!closed.compareAndSet(false, true)) {
return
}
runCatching { serverSocket.close() }
synchronized(clientSockets) {
clientSockets.forEach { socket -> runCatching { socket.close() } }
clientSockets.clear()
}
acceptThread.interrupt()
}
private fun acceptLoop() {
while (!closed.get()) {
val socket = try {
serverSocket.accept()
} catch (err: IOException) {
if (!closed.get()) {
logWarn("Failed to accept local proxy connection for $sessionId", err)
}
return
}
clientSockets += socket
Thread(
{ handleClient(socket) },
"GenieLocalProxyClient-$sessionId",
).start()
}
}
private fun handleClient(socket: Socket) {
socket.use { client ->
try {
val request = readRequest(client)
logInfo("Forwarding ${request.method} ${request.forwardPath} for $sessionId")
val response = requestForwarder.sendHttpRequest(
request.method,
request.forwardPath,
request.body,
)
writeResponse(
socket = client,
statusCode = response.statusCode,
body = response.body,
path = request.forwardPath,
)
} catch (err: Exception) {
if (!closed.get()) {
logWarn("Local proxy request failed for $sessionId", err)
runCatching {
writeResponse(
socket = client,
statusCode = 502,
body = err.message ?: err::class.java.simpleName,
path = "/error",
)
}
}
} finally {
clientSockets -= client
}
}
}
private fun readRequest(socket: Socket): ParsedRequest {
val input = socket.getInputStream()
val headerBuffer = ByteArrayOutputStream()
var matched = 0
while (matched < 4) {
val next = input.read()
if (next == -1) {
throw EOFException("unexpected EOF while reading local proxy request headers")
}
headerBuffer.write(next)
matched = when {
matched == 0 && next == '\r'.code -> 1
matched == 1 && next == '\n'.code -> 2
matched == 2 && next == '\r'.code -> 3
matched == 3 && next == '\n'.code -> 4
next == '\r'.code -> 1
else -> 0
}
}
val headerBytes = headerBuffer.toByteArray()
val headerText = headerBytes
.copyOfRange(0, headerBytes.size - 4)
.toString(StandardCharsets.US_ASCII)
val lines = headerText.split("\r\n")
val requestLine = lines.firstOrNull()
?: throw IOException("local proxy request line missing")
val requestParts = requestLine.split(" ", limit = 3)
if (requestParts.size < 2) {
throw IOException("invalid local proxy request line: $requestLine")
}
val headers = mutableMapOf<String, String>()
lines.drop(1).forEach { line ->
val separatorIndex = line.indexOf(':')
if (separatorIndex <= 0) {
return@forEach
}
val name = line.substring(0, separatorIndex).trim().lowercase()
val value = line.substring(separatorIndex + 1).trim()
headers[name] = value
}
if (headers["transfer-encoding"]?.contains("chunked", ignoreCase = true) == true) {
throw IOException("chunked local proxy requests are unsupported")
}
val contentLength = headers["content-length"]?.toIntOrNull() ?: 0
val bodyBytes = ByteArray(contentLength)
var offset = 0
while (offset < bodyBytes.size) {
val read = input.read(bodyBytes, offset, bodyBytes.size - offset)
if (read == -1) {
throw EOFException("unexpected EOF while reading local proxy request body")
}
offset += read
}
val rawPath = requestParts[1]
val forwardPath = normalizeForwardPath(rawPath)
return ParsedRequest(
method = requestParts[0],
forwardPath = forwardPath,
body = if (bodyBytes.isEmpty()) null else bodyBytes.toString(StandardCharsets.UTF_8),
)
}
private fun normalizeForwardPath(rawPath: String): String {
val expectedPrefix = "/$pathSecret"
if (!rawPath.startsWith(expectedPrefix)) {
throw IOException("unexpected local proxy path: $rawPath")
}
val strippedPath = rawPath.removePrefix(expectedPrefix)
return if (strippedPath.isBlank()) "/" else strippedPath
}
private fun writeResponse(
socket: Socket,
statusCode: Int,
body: String,
path: String,
) {
val bodyBytes = body.toByteArray(StandardCharsets.UTF_8)
val contentType = when {
path.startsWith("/v1/responses") -> "text/event-stream; charset=utf-8"
body.trimStart().startsWith("{") || body.trimStart().startsWith("[") -> {
"application/json; charset=utf-8"
}
else -> "text/plain; charset=utf-8"
}
val responseHeaders = buildString {
append("HTTP/1.1 $statusCode ${reasonPhrase(statusCode)}\r\n")
append("Content-Type: $contentType\r\n")
append("Content-Length: ${bodyBytes.size}\r\n")
append("Connection: close\r\n")
append("\r\n")
}
val output = socket.getOutputStream()
output.write(responseHeaders.toByteArray(StandardCharsets.US_ASCII))
output.write(bodyBytes)
output.flush()
}
private fun reasonPhrase(statusCode: Int): String {
return when (statusCode) {
200 -> "OK"
400 -> "Bad Request"
401 -> "Unauthorized"
403 -> "Forbidden"
404 -> "Not Found"
500 -> "Internal Server Error"
502 -> "Bad Gateway"
503 -> "Service Unavailable"
else -> "Response"
}
}
private fun logInfo(message: String) {
runCatching { Log.i(TAG, message) }
}
private fun logWarn(
message: String,
err: Throwable,
) {
runCatching { Log.w(TAG, message, err) }
}
private data class ParsedRequest(
val method: String,
val forwardPath: String,
val body: String?,
)
}

View File

@@ -1,52 +0,0 @@
package com.openai.codex.genie
import java.io.IOException
import org.json.JSONObject
sealed interface GenieModelTurn {
data class Question(val text: String) : GenieModelTurn
data class Result(val text: String) : GenieModelTurn
data class ToolCall(
val name: String,
val arguments: JSONObject,
) : GenieModelTurn
}
object GenieModelTurnParser {
fun parse(message: String): GenieModelTurn {
val trimmed = message.trim()
stripTurnPrefix(trimmed, "TOOL:")?.let(::parseToolCall)?.let { return it }
stripTurnPrefix(trimmed, "QUESTION:")?.let { return GenieModelTurn.Question(it) }
stripTurnPrefix(trimmed, "RESULT:")?.let { return GenieModelTurn.Result(it) }
return if (trimmed.endsWith("?")) {
GenieModelTurn.Question(trimmed)
} else {
GenieModelTurn.Result(trimmed)
}
}
private fun parseToolCall(payload: String): GenieModelTurn.ToolCall {
val call = try {
JSONObject(payload)
} catch (err: Exception) {
throw IOException("Invalid TOOL payload: ${err.message}", err)
}
val name = call.optString("name").trim()
if (name.isEmpty()) {
throw IOException("TOOL payload missing name")
}
return GenieModelTurn.ToolCall(
name = name,
arguments = call.optJSONObject("arguments") ?: JSONObject(),
)
}
private fun stripTurnPrefix(message: String, prefix: String): String? {
if (!message.startsWith(prefix, ignoreCase = true)) {
return null
}
return message.substring(prefix.length).trim().ifEmpty { "continue" }
}
}

View File

@@ -0,0 +1,63 @@
package com.openai.codex.genie
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.Socket
import java.net.URI
import java.nio.charset.StandardCharsets
import java.util.concurrent.atomic.AtomicReference
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class GenieLocalCodexProxyTest {
@Test
fun forwardsLoopbackHttpRequestsToAgentBridge() {
val forwardedRequest = AtomicReference<Triple<String, String, String?>>()
val proxy = GenieLocalCodexProxy(
sessionId = "session-1",
requestForwarder = object : CodexHttpRequestForwarder {
override fun sendHttpRequest(
method: String,
path: String,
body: String?,
): CodexAgentBridge.HttpResponse {
forwardedRequest.set(Triple(method, path, body))
return CodexAgentBridge.HttpResponse(
statusCode = 200,
body = """{"ok":true}""",
)
}
},
)
proxy.use { localProxy ->
localProxy.start()
val uri = URI(localProxy.baseUrl)
Socket(uri.host, uri.port).use { socket ->
val body = """{"model":"gpt-5.3-codex"}"""
val requestPath = "${uri.rawPath}/responses"
val writer = OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)
writer.write("POST $requestPath HTTP/1.1\r\n")
writer.write("Host: ${uri.host}\r\n")
writer.write("Content-Type: application/json\r\n")
writer.write("Content-Length: ${body.toByteArray(StandardCharsets.UTF_8).size}\r\n")
writer.write("\r\n")
writer.write(body)
writer.flush()
socket.shutdownOutput()
val responseText = BufferedReader(
InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8),
).readText()
assertTrue(responseText.startsWith("HTTP/1.1 200 OK"))
assertTrue(responseText.contains("""{"ok":true}"""))
}
}
assertEquals(
Triple("POST", "/v1/responses", """{"model":"gpt-5.3-codex"}"""),
forwardedRequest.get(),
)
}
}

View File

@@ -1,33 +0,0 @@
package com.openai.codex.genie
import org.junit.Assert.assertEquals
import org.junit.Test
class GenieModelTurnParserTest {
@Test
fun parseToolCallTurn() {
val turn = GenieModelTurnParser.parse(
"""TOOL: {"name":"android.intent.launch","arguments":{"packageName":"com.android.deskclock"}}""",
)
val toolCall = turn as GenieModelTurn.ToolCall
assertEquals("android.intent.launch", toolCall.name)
assertEquals("com.android.deskclock", toolCall.arguments.getString("packageName"))
}
@Test
fun parseQuestionTurn() {
assertEquals(
GenieModelTurn.Question("Should I continue?"),
GenieModelTurnParser.parse("QUESTION: Should I continue?"),
)
}
@Test
fun parseResultTurn() {
assertEquals(
GenieModelTurn.Result("Launched the target app."),
GenieModelTurnParser.parse("RESULT: Launched the target app."),
)
}
}

View File

@@ -1,6 +1,9 @@
use std::ffi::OsString;
use std::path::Path;
use std::path::PathBuf;
pub const CODEX_OPENAI_UNIX_SOCKET_ENV_VAR: &str = "CODEX_OPENAI_UNIX_SOCKET";
pub const CODEX_USE_AGENT_AUTH_PROXY_ENV_VAR: &str = "CODEX_USE_AGENT_AUTH_PROXY";
pub fn openai_unix_socket_path() -> Option<PathBuf> {
std::env::var(CODEX_OPENAI_UNIX_SOCKET_ENV_VAR)
@@ -8,4 +11,73 @@ pub fn openai_unix_socket_path() -> Option<PathBuf> {
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.or_else(default_android_socket_path)
}
pub fn should_route_via_codexd() -> bool {
(openai_unix_socket_path().is_some()
|| should_use_agent_auth_proxy_env(std::env::var_os(CODEX_USE_AGENT_AUTH_PROXY_ENV_VAR)))
&& !is_codexd_process()
}
fn is_codexd_process() -> bool {
std::env::args_os()
.next()
.and_then(|arg0| {
Path::new(&arg0)
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
})
.is_some_and(|arg0| arg0.contains("codexd"))
}
#[cfg(target_os = "android")]
fn default_android_socket_path() -> Option<PathBuf> {
const CANDIDATES: [&str; 2] = [
"/data/data/com.openai.codexd/files/codexd.sock",
"/data/user/0/com.openai.codexd/files/codexd.sock",
];
CANDIDATES
.iter()
.map(PathBuf::from)
.find(|path| path.exists())
}
#[cfg(not(target_os = "android"))]
fn default_android_socket_path() -> Option<PathBuf> {
None
}
fn should_use_agent_auth_proxy_env(value: Option<OsString>) -> bool {
value
.and_then(|raw| raw.into_string().ok())
.map(|raw| raw.trim().to_ascii_lowercase())
.is_some_and(|value| value == "1" || value == "true" || value == "yes")
}
#[cfg(test)]
mod tests {
use super::should_use_agent_auth_proxy_env;
use std::ffi::OsString;
#[test]
fn agent_auth_proxy_env_accepts_truthy_values() {
assert!(should_use_agent_auth_proxy_env(Some(OsString::from("1"))));
assert!(should_use_agent_auth_proxy_env(Some(OsString::from(
"true"
))));
assert!(should_use_agent_auth_proxy_env(Some(OsString::from("YES"))));
}
#[test]
fn agent_auth_proxy_env_rejects_missing_or_falsey_values() {
assert!(!should_use_agent_auth_proxy_env(None));
assert!(!should_use_agent_auth_proxy_env(Some(OsString::from(""))));
assert!(!should_use_agent_auth_proxy_env(Some(OsString::from("0"))));
assert!(!should_use_agent_auth_proxy_env(Some(OsString::from(
"false"
))));
}
}

View File

@@ -29,9 +29,10 @@ The current repo now contains these implementation slices:
- The Genie runtime inspects the paired target package from inside the
target-app sandbox and feeds package metadata plus launcher intent details
into the delegated Codex prompt.
- The hosted `codex app-server` process routes model traffic through the
Agent-owned `codexd` abstract Unix socket, keeping network/auth Agent-owned
even while the Genie runs inside the target-app sandbox.
- The hosted `codex app-server` process now talks to a **Genie-local loopback
HTTP proxy** inside the Genie app. That proxy forwards HTTP traffic to the
Agent over Binder/AIDL, keeping network/auth Agent-owned without assuming the
Genie child process can reach the Agent's abstract socket directly.
- The Genie runtime exposes reusable Android capabilities to Codex as
**dynamic tools**, not via a custom `TOOL:` text protocol.
- Non-bridge Genie questions surface through AgentSDK question flow by mapping
@@ -39,7 +40,9 @@ The current repo now contains these implementation slices:
- The Agent also attempts to answer Genie questions through the embedded
`codexd` runtime before falling back to notification/UI escalation.
- Runtime testing on the emulator shows that the exported Agent Binder service
is reachable from Genie execution for the current bootstrap calls.
is reachable from Genie execution for the current bootstrap calls, while
direct cross-app access to the Agent-owned abstract socket is not a valid
assumption.
The Rust `codexd` service/client split remains in place and is still the
existing network/auth bridge while this refactor proceeds.
@@ -70,7 +73,9 @@ existing network/auth bridge while this refactor proceeds.
- Internal Agent<->Genie coordination now splits into:
- Binder/AIDL for fixed-form control/data RPC
- AgentSDK session events for free-form product dialogue
- hosted `codex app-server` inside Genie for the actual Codex execution loop
- hosted `codex app-server` inside Genie for the actual Codex execution loop
- Genie-local transport termination between the hosted `codex` child process
and the Binder control plane
## Runtime Model
@@ -104,6 +109,7 @@ existing network/auth bridge while this refactor proceeds.
- Android dynamic tool execution
- Agent escalation via `request_user_input`
- runtime bootstrap from the Agent-owned Binder bridge
- local proxying of hosted `codex` HTTP traffic onto Binder
## First Milestone Scope
@@ -123,8 +129,8 @@ existing network/auth bridge while this refactor proceeds.
- Agent-owned `/internal/runtime/status` metadata for Genie bootstrap
- Target-app package metadata and launcher-intent inspection from the Genie
sandbox, with that context included in the delegated Codex prompt
- Hosted `codex app-server` inside Genie, with model traffic routed through the
Agent-owned `codexd` abstract socket
- Hosted `codex app-server` inside Genie, with model traffic routed through a
Genie-local proxy backed by the Agent Binder bridge
- Android dynamic tools registered on the Genie Codex thread with:
- `android.package.inspect`
- `android.intent.launch`
@@ -169,6 +175,9 @@ existing network/auth bridge while this refactor proceeds.
- `android/genie/src/main/java/com/openai/codex/genie/CodexAppServerHost.kt`
- stdio JSON-RPC host for `codex app-server`, dynamic tools, and
`request_user_input` bridging
- `android/genie/src/main/java/com/openai/codex/genie/GenieLocalCodexProxy.kt`
- Genie-local loopback HTTP proxy that forwards hosted `codex` HTTP traffic to
the Agent Binder bridge
- `android/app/src/main/java/com/openai/codexd/CodexAgentBridgeService.kt`
- exported Binder/AIDL bridge for Genie control-plane calls
- `android/genie/src/main/java/com/openai/codex/genie/AgentBridgeClient.kt`