Implement framework-mediated Agent bridge for Genie

Add an internal AgentSDK question/answer bridge so Genie can reach Agent-owned codexd state from the paired app sandbox, keep the Android daemon on abstract unix sockets, and document the runtime constraint this proves on the emulator.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Iliyan Malchev
2026-03-19 00:46:08 -07:00
parent 6450c8ccbc
commit 3d4274962c
12 changed files with 570 additions and 25 deletions

View File

@@ -1,5 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

View File

@@ -1,19 +1,102 @@
package com.openai.codexd
import android.app.agent.AgentManager
import android.app.agent.AgentService
import android.app.agent.AgentSessionEvent
import android.app.agent.AgentSessionInfo
import android.util.Log
import org.json.JSONObject
import java.util.concurrent.ConcurrentHashMap
class CodexAgentService : AgentService() {
companion object {
private const val TAG = "CodexAgentService"
private const val BRIDGE_REQUEST_PREFIX = "__codex_bridge__ "
private const val BRIDGE_RESPONSE_PREFIX = "__codex_bridge_result__ "
private const val METHOD_GET_AUTH_STATUS = "get_auth_status"
}
private val handledBridgeRequests = ConcurrentHashMap.newKeySet<String>()
private val agentManager by lazy { getSystemService(AgentManager::class.java) }
override fun onSessionChanged(session: AgentSessionInfo) {
Log.i(TAG, "onSessionChanged $session")
handleInternalBridgeQuestion(session.sessionId)
}
override fun onSessionRemoved(sessionId: String) {
Log.i(TAG, "onSessionRemoved sessionId=$sessionId")
}
private fun handleInternalBridgeQuestion(sessionId: String) {
val manager = agentManager ?: return
val events = manager.getSessionEvents(sessionId)
val question = events.lastOrNull { event ->
event.type == AgentSessionEvent.TYPE_QUESTION && event.message != null
}?.message ?: return
if (!question.startsWith(BRIDGE_REQUEST_PREFIX)) {
return
}
val requestJson = runCatching {
JSONObject(question.removePrefix(BRIDGE_REQUEST_PREFIX))
}.getOrElse { err ->
Log.w(TAG, "Ignoring malformed bridge question for $sessionId", err)
return
}
val requestId = requestJson.optString("requestId")
val method = requestJson.optString("method")
if (requestId.isBlank() || method.isBlank()) {
return
}
val requestKey = "$sessionId:$requestId"
if (hasAnswerForRequest(events, requestId) || !handledBridgeRequests.add(requestKey)) {
return
}
val response = when (method) {
METHOD_GET_AUTH_STATUS -> runCatching { CodexdLocalClient.waitForAuthStatus(this) }
.fold(
onSuccess = { status ->
JSONObject()
.put("requestId", requestId)
.put("ok", true)
.put("authenticated", status.authenticated)
.put("accountEmail", status.accountEmail)
.put("clientCount", status.clientCount)
},
onFailure = { err ->
JSONObject()
.put("requestId", requestId)
.put("ok", false)
.put("error", err.message ?: err::class.java.simpleName)
},
)
else -> JSONObject()
.put("requestId", requestId)
.put("ok", false)
.put("error", "Unknown bridge method: $method")
}
runCatching {
manager.answerQuestion(sessionId, "$BRIDGE_RESPONSE_PREFIX$response")
}.onFailure { err ->
handledBridgeRequests.remove(requestKey)
Log.w(TAG, "Failed to answer bridge question for $sessionId", err)
}
}
private fun hasAnswerForRequest(events: List<AgentSessionEvent>, requestId: String): Boolean {
return events.any { event ->
if (event.type != AgentSessionEvent.TYPE_ANSWER || event.message == null) {
return@any false
}
val message = event.message
if (!message.startsWith(BRIDGE_RESPONSE_PREFIX)) {
return@any false
}
runCatching {
JSONObject(message.removePrefix(BRIDGE_RESPONSE_PREFIX)).optString("requestId")
}.getOrNull() == requestId
}
}
}

View File

@@ -0,0 +1,23 @@
package com.openai.codexd
import android.net.LocalSocketAddress
object CodexSocketConfig {
const val DEFAULT_SOCKET_PATH = "@com.openai.codexd.codexd"
fun toLocalSocketAddress(socketPath: String): LocalSocketAddress {
val trimmed = socketPath.trim()
return when {
trimmed.startsWith("@") -> {
LocalSocketAddress(trimmed.removePrefix("@"), LocalSocketAddress.Namespace.ABSTRACT)
}
trimmed.startsWith("abstract:") -> {
LocalSocketAddress(
trimmed.removePrefix("abstract:"),
LocalSocketAddress.Namespace.ABSTRACT,
)
}
else -> LocalSocketAddress(trimmed, LocalSocketAddress.Namespace.FILESYSTEM)
}
}
}

View File

@@ -0,0 +1,205 @@
package com.openai.codexd
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.util.Log
import java.io.File
import java.io.InterruptedIOException
import java.io.IOException
class CodexdForegroundService : Service() {
companion object {
const val ACTION_START = "com.openai.codexd.action.START"
const val ACTION_STOP = "com.openai.codexd.action.STOP"
const val EXTRA_SOCKET_PATH = "com.openai.codexd.extra.SOCKET_PATH"
const val EXTRA_CODEX_HOME = "com.openai.codexd.extra.CODEX_HOME"
const val EXTRA_UPSTREAM_BASE_URL = "com.openai.codexd.extra.UPSTREAM_BASE_URL"
const val EXTRA_RUST_LOG = "com.openai.codexd.extra.RUST_LOG"
private const val CHANNEL_ID = "codexd_service"
private const val NOTIFICATION_ID = 1
private const val TAG = "CodexdService"
}
private val processLock = Any()
private var codexdProcess: Process? = null
private var logThread: Thread? = null
private var exitThread: Thread? = null
private var statusThread: Thread? = null
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> startCodexd(intent)
ACTION_STOP -> stopSelf()
}
return START_STICKY
}
override fun onDestroy() {
synchronized(processLock) {
codexdProcess?.destroy()
codexdProcess = null
}
statusThread?.interrupt()
stopForeground(STOP_FOREGROUND_REMOVE)
super.onDestroy()
}
private fun startCodexd(intent: Intent) {
synchronized(processLock) {
if (codexdProcess != null) {
return
}
createNotificationChannel()
startForeground(NOTIFICATION_ID, buildNotification("Starting codexd"))
val socketPath = intent.getStringExtra(EXTRA_SOCKET_PATH) ?: defaultSocketPath()
val codexHome = intent.getStringExtra(EXTRA_CODEX_HOME) ?: defaultCodexHome()
File(codexHome).mkdirs()
val codexdBinary = resolveCodexdBinary()
val args = mutableListOf(
codexdBinary.absolutePath,
"--socket-path",
socketPath,
"--codex-home",
codexHome,
)
val upstream = intent.getStringExtra(EXTRA_UPSTREAM_BASE_URL)
if (!upstream.isNullOrBlank()) {
args.add("--upstream-base-url")
args.add(upstream)
}
val builder = ProcessBuilder(args)
builder.redirectErrorStream(true)
val env = builder.environment()
env["RUST_LOG"] = intent.getStringExtra(EXTRA_RUST_LOG) ?: "info"
codexdProcess = builder.start()
startLogThread(codexdProcess!!)
startExitWatcher(codexdProcess!!)
startStatusWatcher(socketPath)
updateNotification("codexd running")
}
}
private fun startLogThread(process: Process) {
logThread = Thread {
try {
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line -> Log.i(TAG, line) }
}
} catch (_: InterruptedIOException) {
// Expected when the process exits and closes its stdout pipe.
} catch (err: IOException) {
if (process.isAlive) {
Log.w(TAG, "codexd log stream failed", err)
}
}
}.also { it.start() }
}
private fun startExitWatcher(process: Process) {
exitThread = Thread {
val exitCode = process.waitFor()
Log.i(TAG, "codexd exited with code ${exitCode}")
stopSelf()
}.also { it.start() }
}
private fun startStatusWatcher(socketPath: String) {
statusThread?.interrupt()
statusThread = Thread {
var lastAuthenticated: Boolean? = null
var lastEmail: String? = null
var lastClientCount: Int? = null
while (!Thread.currentThread().isInterrupted) {
val status = CodexdLocalClient.fetchAuthStatus(socketPath)
if (status != null) {
val message = if (status.authenticated) {
val emailSuffix = status.accountEmail?.let { " (${it})" } ?: ""
"codexd signed in${emailSuffix}"
} else {
"codexd needs sign-in"
}
val messageWithClients = "${message} (clients: ${status.clientCount})"
if (lastAuthenticated != status.authenticated
|| lastEmail != status.accountEmail
|| lastClientCount != status.clientCount
) {
updateNotification(messageWithClients)
lastAuthenticated = status.authenticated
lastEmail = status.accountEmail
lastClientCount = status.clientCount
}
}
try {
Thread.sleep(3000)
} catch (_: InterruptedException) {
return@Thread
}
}
}.also { it.start() }
}
private fun buildNotification(status: String): Notification {
val launchIntent = Intent(this, MainActivity::class.java)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val pendingIntent = PendingIntent.getActivity(this, 0, launchIntent, flags)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_codex)
.setContentTitle("codexd")
.setContentText(status)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
private fun updateNotification(status: String) {
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
manager.notify(NOTIFICATION_ID, buildNotification(status))
}
private fun createNotificationChannel() {
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) != null) {
return
}
val channel = NotificationChannel(
CHANNEL_ID,
"codexd service",
NotificationManager.IMPORTANCE_LOW,
)
manager.createNotificationChannel(channel)
}
private fun resolveCodexdBinary(): File {
val nativeDir = applicationInfo.nativeLibraryDir
val outputFile = File(nativeDir, "libcodexd.so")
if (!outputFile.exists()) {
throw IOException("codexd binary missing at ${outputFile.absolutePath}")
}
return outputFile
}
private fun defaultSocketPath(): String {
return CodexSocketConfig.DEFAULT_SOCKET_PATH
}
private fun defaultCodexHome(): String {
return File(filesDir, "codex-home").absolutePath
}
}

View File

@@ -0,0 +1,84 @@
package com.openai.codexd
import android.content.Context
import android.net.LocalSocket
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.io.BufferedInputStream
import java.nio.charset.StandardCharsets
object CodexdLocalClient {
data class AuthStatus(
val authenticated: Boolean,
val accountEmail: String?,
val clientCount: Int,
)
fun waitForAuthStatus(context: Context): AuthStatus {
context.startForegroundService(
android.content.Intent(context, CodexdForegroundService::class.java).apply {
action = CodexdForegroundService.ACTION_START
putExtra(CodexdForegroundService.EXTRA_SOCKET_PATH, CodexSocketConfig.DEFAULT_SOCKET_PATH)
putExtra(CodexdForegroundService.EXTRA_CODEX_HOME, File(context.filesDir, "codex-home").absolutePath)
},
)
repeat(30) {
fetchAuthStatus(CodexSocketConfig.DEFAULT_SOCKET_PATH)?.let { return it }
Thread.sleep(100)
}
throw IOException("codexd unavailable")
}
fun fetchAuthStatus(socketPath: String): AuthStatus? {
return try {
val socket = LocalSocket()
val address = CodexSocketConfig.toLocalSocketAddress(socketPath)
socket.connect(address)
val request = buildString {
append("GET /internal/auth/status HTTP/1.1\r\n")
append("Host: localhost\r\n")
append("Connection: close\r\n")
append("\r\n")
}
val output = socket.outputStream
output.write(request.toByteArray(StandardCharsets.UTF_8))
output.flush()
val responseBytes = BufferedInputStream(socket.inputStream).use { it.readBytes() }
socket.close()
val responseText = responseBytes.toString(StandardCharsets.UTF_8)
val splitIndex = responseText.indexOf("\r\n\r\n")
if (splitIndex == -1) {
return null
}
val statusLine = responseText.substring(0, splitIndex)
.lineSequence()
.firstOrNull()
.orEmpty()
val statusCode = statusLine.split(" ").getOrNull(1)?.toIntOrNull() ?: return null
if (statusCode != 200) {
return null
}
val body = responseText.substring(splitIndex + 4)
val json = JSONObject(body)
val accountEmail =
if (json.isNull("accountEmail")) null else json.optString("accountEmail")
val clientCount = if (json.has("clientCount")) {
json.optInt("clientCount", 0)
} else {
json.optInt("client_count", 0)
}
AuthStatus(
authenticated = json.optBoolean("authenticated", false),
accountEmail = accountEmail,
clientCount = clientCount,
)
} catch (_: Exception) {
null
}
}
}

View File

@@ -7,7 +7,6 @@ import android.app.agent.AgentSessionInfo
import android.content.Intent
import android.content.pm.PackageManager
import android.net.LocalSocket
import android.net.LocalSocketAddress
import android.os.Binder
import android.os.Build
import android.os.Bundle
@@ -179,6 +178,7 @@ class MainActivity : Activity() {
showToast("Enter a prompt")
return
}
ensureCodexdRunningForAgent()
thread {
val result = runCatching {
agentSessionController.startDirectSession(
@@ -279,7 +279,10 @@ class MainActivity : Activity() {
}
fun toggleCodexd(@Suppress("UNUSED_PARAMETER") view: View) {
val intent = Intent(this, CodexdForegroundService::class.java)
val intent = Intent(this, CodexdForegroundService::class.java).apply {
putExtra(CodexdForegroundService.EXTRA_SOCKET_PATH, defaultSocketPath())
putExtra(CodexdForegroundService.EXTRA_CODEX_HOME, defaultCodexHome())
}
if (isServiceRunning) {
intent.action = CodexdForegroundService.ACTION_STOP
startService(intent)
@@ -306,6 +309,8 @@ class MainActivity : Activity() {
private fun startDeviceAuth() {
val intent = Intent(this, CodexdForegroundService::class.java).apply {
action = CodexdForegroundService.ACTION_START
putExtra(CodexdForegroundService.EXTRA_SOCKET_PATH, defaultSocketPath())
putExtra(CodexdForegroundService.EXTRA_CODEX_HOME, defaultCodexHome())
}
startForegroundService(intent)
isServiceRunning = true
@@ -649,6 +654,16 @@ class MainActivity : Activity() {
}
}
private fun ensureCodexdRunningForAgent() {
val intent = Intent(this, CodexdForegroundService::class.java).apply {
action = CodexdForegroundService.ACTION_START
putExtra(CodexdForegroundService.EXTRA_SOCKET_PATH, defaultSocketPath())
putExtra(CodexdForegroundService.EXTRA_CODEX_HOME, defaultCodexHome())
}
startForegroundService(intent)
isServiceRunning = true
}
private data class AuthStatus(
val authenticated: Boolean,
val accountEmail: String?,
@@ -791,7 +806,7 @@ class MainActivity : Activity() {
body: String?,
): HttpResponse {
val socket = LocalSocket()
val address = LocalSocketAddress(socketPath, LocalSocketAddress.Namespace.FILESYSTEM)
val address = CodexSocketConfig.toLocalSocketAddress(socketPath)
socket.connect(address)
val payload = body ?: ""
@@ -827,7 +842,7 @@ class MainActivity : Activity() {
}
private fun defaultSocketPath(): String {
return File(filesDir, "codexd.sock").absolutePath
return CodexSocketConfig.DEFAULT_SOCKET_PATH
}
private fun defaultCodexHome(): String {

View File

@@ -1,5 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="false"
android:label="@string/app_name">

View File

@@ -0,0 +1,45 @@
package com.openai.codex.genie
import org.json.JSONObject
import java.io.IOException
object CodexAgentBridge {
private const val BRIDGE_REQUEST_PREFIX = "__codex_bridge__ "
private const val BRIDGE_RESPONSE_PREFIX = "__codex_bridge_result__ "
private const val METHOD_GET_AUTH_STATUS = "get_auth_status"
fun buildAuthStatusRequest(requestId: String): String {
val payload = JSONObject()
.put("requestId", requestId)
.put("method", METHOD_GET_AUTH_STATUS)
return "$BRIDGE_REQUEST_PREFIX$payload"
}
fun isBridgeResponse(message: String): Boolean {
return message.startsWith(BRIDGE_RESPONSE_PREFIX)
}
data class AuthStatus(
val authenticated: Boolean,
val accountEmail: String?,
val clientCount: Int,
)
fun parseAuthStatusResponse(response: String, requestId: String): AuthStatus {
if (!response.startsWith(BRIDGE_RESPONSE_PREFIX)) {
throw IOException("Unexpected bridge response format")
}
val data = JSONObject(response.removePrefix(BRIDGE_RESPONSE_PREFIX))
if (data.optString("requestId") != requestId) {
throw IOException("Mismatched bridge response id")
}
if (!data.optBoolean("ok", false)) {
throw IOException(data.optString("error", "Agent bridge request failed"))
}
return AuthStatus(
authenticated = data.optBoolean("authenticated", false),
accountEmail = if (data.isNull("accountEmail")) null else data.optString("accountEmail"),
clientCount = data.optInt("clientCount", 0),
)
}
}

View File

@@ -4,7 +4,11 @@ import android.app.agent.AgentSessionInfo
import android.app.agent.GenieRequest
import android.app.agent.GenieService
import android.util.Log
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.UUID
class CodexGenieService : GenieService() {
companion object {
@@ -30,8 +34,12 @@ class CodexGenieService : GenieService() {
}
override fun onUserResponse(sessionId: String, response: String) {
sessionControls[sessionId]?.answer = response
sessionControls[sessionId]?.answerLatch = false
val control = sessionControls[sessionId] ?: return
if (CodexAgentBridge.isBridgeResponse(response)) {
control.bridgeResponses.offer(response)
} else {
control.userResponses.offer(response)
}
Log.i(TAG, "Received user response for $sessionId")
}
@@ -45,8 +53,22 @@ class CodexGenieService : GenieService() {
)
callback.publishTrace(
sessionId,
"Agent-mediated Codex runtime transport is the next integration step; this service currently validates framework lifecycle and question flow.",
"Genie is headless and uses the Agent-owned bridge for auth/network reachability checks.",
)
val bridgeStatus = runCatching { requestAgentAuthStatus(sessionId, callback, control) }
bridgeStatus.onSuccess { status ->
val accountSuffix = status.accountEmail?.let { " (${it})" } ?: ""
callback.publishTrace(
sessionId,
"Reached Agent bridge through framework orchestration; authenticated=${status.authenticated}${accountSuffix}, clients=${status.clientCount}.",
)
}
bridgeStatus.onFailure { err ->
callback.publishTrace(
sessionId,
"Agent bridge probe failed: ${err.message}",
)
}
if (request.isDetachedModeAllowed) {
callback.requestLaunchDetachedTargetHidden(sessionId)
@@ -59,18 +81,14 @@ class CodexGenieService : GenieService() {
)
callback.updateState(sessionId, AgentSessionInfo.STATE_WAITING_FOR_USER)
while (control.answerLatch && !control.cancelled) {
Thread.sleep(100)
}
if (control.cancelled) {
callback.publishError(sessionId, "Cancelled")
callback.updateState(sessionId, AgentSessionInfo.STATE_CANCELLED)
return
}
val answer = waitForUserResponse(control)
callback.updateState(sessionId, AgentSessionInfo.STATE_RUNNING)
val answer = control.answer ?: ""
callback.publishTrace(sessionId, "Received user response: $answer")
callback.publishResult(
sessionId,
@@ -89,9 +107,47 @@ class CodexGenieService : GenieService() {
}
}
private fun requestAgentAuthStatus(
sessionId: String,
callback: Callback,
control: SessionControl,
): CodexAgentBridge.AuthStatus {
val requestId = UUID.randomUUID().toString()
callback.publishQuestion(sessionId, CodexAgentBridge.buildAuthStatusRequest(requestId))
callback.updateState(sessionId, AgentSessionInfo.STATE_WAITING_FOR_USER)
val response = waitForBridgeResponse(control, requestId)
callback.updateState(sessionId, AgentSessionInfo.STATE_RUNNING)
return CodexAgentBridge.parseAuthStatusResponse(response, requestId)
}
private fun waitForBridgeResponse(control: SessionControl, requestId: String): String {
val deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (!control.cancelled) {
val remainingNanos = deadlineNanos - System.nanoTime()
if (remainingNanos <= 0) {
throw IOException("Timed out waiting for Agent bridge response")
}
val response = control.bridgeResponses.poll(remainingNanos, TimeUnit.NANOSECONDS)
if (response != null) {
return response
}
}
throw IOException("Cancelled while waiting for Agent bridge response $requestId")
}
private fun waitForUserResponse(control: SessionControl): String {
while (!control.cancelled) {
val response = control.userResponses.poll(100, TimeUnit.MILLISECONDS)
if (response != null) {
return response
}
}
throw IOException("Cancelled while waiting for user response")
}
private class SessionControl {
@Volatile var answerLatch = true
@Volatile var cancelled = false
@Volatile var answer: String? = null
val bridgeResponses = LinkedBlockingQueue<String>()
val userResponses = LinkedBlockingQueue<String>()
}
}

View File

@@ -761,10 +761,9 @@ fn bind_listener(target: &UnixSocketBindTarget) -> Result<UnixListener> {
#[cfg(any(target_os = "android", target_os = "linux"))]
fn bind_abstract_listener(name: &str) -> Result<UnixListener> {
use std::os::unix::net::SocketAddr;
use std::os::unix::net::UnixListener as StdUnixListener;
let address = SocketAddr::from_abstract_name(name.as_bytes())
let address = abstract_socket_addr(name.as_bytes())
.with_context(|| format!("failed to create abstract socket address @{name}"))?;
let listener = StdUnixListener::bind_addr(&address)
.with_context(|| format!("failed to bind abstract socket @{name}"))?;
@@ -775,6 +774,18 @@ fn bind_abstract_listener(name: &str) -> Result<UnixListener> {
.with_context(|| format!("failed to adopt abstract socket @{name} into tokio"))
}
#[cfg(any(target_os = "android", target_os = "linux"))]
fn abstract_socket_addr(name: &[u8]) -> std::io::Result<std::os::unix::net::SocketAddr> {
use std::os::unix::net::SocketAddr;
#[cfg(target_os = "android")]
use std::os::android::net::SocketAddrExt;
#[cfg(target_os = "linux")]
use std::os::linux::net::SocketAddrExt;
SocketAddr::from_abstract_name(name)
}
#[cfg(not(any(target_os = "android", target_os = "linux")))]
fn bind_abstract_listener(name: &str) -> Result<UnixListener> {
anyhow::bail!("abstract unix sockets are unsupported on this platform: @{name}")

View File

@@ -242,13 +242,11 @@ async fn connect_unix_stream(
async fn connect_abstract_unix_stream(
name: String,
) -> Result<tokio::net::UnixStream, TransportError> {
use std::os::unix::net::SocketAddr;
use std::os::unix::net::UnixStream as StdUnixStream;
use tokio::net::UnixStream;
tokio::task::spawn_blocking(move || {
let address = SocketAddr::from_abstract_name(name.as_bytes())
.map_err(|err| TransportError::Network(err.to_string()))?;
let address = abstract_socket_addr(name.as_bytes())?;
let stream = StdUnixStream::connect_addr(&address)
.map_err(|err| TransportError::Network(err.to_string()))?;
stream
@@ -260,6 +258,18 @@ async fn connect_abstract_unix_stream(
.map_err(|err| TransportError::Network(err.to_string()))?
}
#[cfg(all(unix, any(target_os = "android", target_os = "linux")))]
fn abstract_socket_addr(name: &[u8]) -> Result<std::os::unix::net::SocketAddr, TransportError> {
use std::os::unix::net::SocketAddr;
#[cfg(target_os = "android")]
use std::os::android::net::SocketAddrExt;
#[cfg(target_os = "linux")]
use std::os::linux::net::SocketAddrExt;
SocketAddr::from_abstract_name(name).map_err(|err| TransportError::Network(err.to_string()))
}
#[cfg(all(unix, not(any(target_os = "android", target_os = "linux"))))]
async fn connect_abstract_unix_stream(
_name: String,

View File

@@ -19,6 +19,11 @@ The current repo now contains the first implementation slice:
- attach detached targets
- The Genie app currently validates framework lifecycle, detached-target
requests, question flow, and result publication with a placeholder executor.
- The first Agent<->Genie bridge now uses **framework question/answer events**
for internal machine-to-machine RPC. This is intentional: runtime testing on
the emulator showed that a Genie execution runs inside the paired target
app's sandbox/UID, so ordinary cross-app Android service/provider IPC to the
Agent app is not a reliable transport.
The Rust `codexd` service/client split remains in place and is still the
existing network/auth bridge while this refactor proceeds.
@@ -39,6 +44,9 @@ existing network/auth bridge while this refactor proceeds.
- orchestration of parent + child sessions
- The first milestone keeps the current local CLI/socket bridge internally so
the Rust runtime can migrate incrementally.
- Internal Agent<->Genie coordination must use a transport that survives the
target-app sandbox boundary. The current working bootstrap path is
AgentSDK-mediated internal question/answer exchange.
## Runtime Model
@@ -69,6 +77,7 @@ existing network/auth bridge while this refactor proceeds.
- question/answer flow
- detached-target requests
- result publication
- Agent-mediated internal bridge requests over framework session events
## First Milestone Scope
@@ -81,6 +90,8 @@ existing network/auth bridge while this refactor proceeds.
- Direct session launcher in the Agent UI
- Framework session inspection UI in the Agent app
- Question answering and detached-target attach controls
- Framework-mediated internal bridge request handling in `CodexAgentService`
- Framework-mediated internal bridge request issuance in `CodexGenieService`
- Abstract-unix-socket support in the legacy Rust bridge via `@name` or
`abstract:name`, so the compatibility transport can move off app-private
filesystem sockets when Agent<->Genie traffic is introduced
@@ -89,8 +100,8 @@ existing network/auth bridge while this refactor proceeds.
- Replacing the placeholder Genie executor with a real Codex runtime
- Moving network/auth mediation from `codexd` into the Agent runtime
- Defining the long-term Agent<->Genie transport beyond the current compatibility
bridge
- Replacing the temporary internal question/answer bridge with a transport that
supports richer request/response and eventually streaming semantics
- Wiring Android-native target-driving tools into the Genie runtime
- Making the Agent the default product surface instead of the legacy service app
@@ -108,6 +119,10 @@ existing network/auth bridge while this refactor proceeds.
- Agent session UI plus existing `codexd` bridge controls
- `android/genie/src/main/java/com/openai/codex/genie/CodexGenieService.kt`
- placeholder Genie executor
- `android/genie/src/main/java/com/openai/codex/genie/CodexAgentBridge.kt`
- internal framework bridge protocol helpers
- `android/app/src/main/java/com/openai/codexd/CodexdLocalClient.kt`
- Agent-local client for the embedded `codexd` bridge
## Build
@@ -130,8 +145,8 @@ The Agent app still depends on `just android-service-build` for the packaged
## Next Implementation Steps
1. Move the placeholder Genie session executor to a real Codex runtime role.
2. Define the Agent-mediated local transport that Genie uses for model/backend
access.
2. Generalize the current framework question/answer bridge into a transport the
Genie runtime can use for more than auth/status probes.
3. Split the legacy `codexd` concerns out of the Agent UI once the Agent owns
auth and transport directly.
4. Add Android-native tool surfaces to Genie for target inspection and control.