mirror of
https://github.com/openai/codex.git
synced 2026-04-24 06:35:50 +00:00
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:
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name">
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user