mirror of
https://github.com/openai/codex.git
synced 2026-04-29 17:06:51 +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,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 {
|
||||
|
||||
Reference in New Issue
Block a user