mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Add explicit local board connections with fixed runtime access (#53400)
## Why Local message boards were scoped to an agent tree. Independently created Codex runtimes need a way to share a board while receiving different access permissions. ## What changed - Add `LocalBoard` and `LocalBoardConnection` so hosts can supply read-only or read-write access through `thread_extension_init`, including for isolated, ephemeral runtimes. - Allow read-only connections to read and manage their own subscriptions; reject posting, replying, channel creation, and changes to another agent's subscriptions. Expose effective permissions in channel discovery. - Use unique runtime paths and require matching board IDs and canonical database paths when resolving agents or delivering notifications. - Document explicit extension installation and connection setup for new runtimes, cold resumes, and forks. ## Testing Add a scenario and context snapshot covering an independent writer and isolated Guardian sharing a board through a symlink alias, read-only permission denials, subscription and explicit notifications, and rejection of notifications to an unconnected runtime before a post is written. GitOrigin-RevId: f92410c3a5c6a46920c3e0e71849c85dc83d9c31
This commit is contained in:
@@ -16,6 +16,7 @@ use codex_agent_message_board_client::RemoteAgentMessageBoard;
|
||||
use codex_agent_message_board_extension::AgentMessageBoard;
|
||||
use codex_agent_message_board_extension::InMemoryMessageBoards;
|
||||
use codex_agent_message_board_extension::LocalAgentMessageBoard;
|
||||
use codex_agent_message_board_extension::LocalBoardConnection;
|
||||
use codex_agent_message_board_extension::MessageBoardHost;
|
||||
use codex_agent_message_board_extension::NotificationDelivery;
|
||||
use codex_agent_message_board_extension::PostPreview;
|
||||
@@ -58,28 +59,31 @@ pub fn install_agent_message_board(
|
||||
|config: &Config| config.multi_agent_v2.tool_namespace.clone(),
|
||||
move |input: &ThreadStartInput<'_, Config>, tree, caller| {
|
||||
let config = input.config;
|
||||
let connection = input.thread_store.get::<LocalBoardConnection>();
|
||||
let in_memory = config.multi_agent_v2.message_board_in_memory;
|
||||
// MAv2 supplies tree paths; ephemeral runtimes must not open local SQLite.
|
||||
if !config.features.enabled(Feature::AgentMessageBoard)
|
||||
|| !config.features.enabled(Feature::MultiAgentV2)
|
||||
|| (config.ephemeral
|
||||
&& !in_memory
|
||||
&& config.multi_agent_v2.message_board_remote.is_none())
|
||||
// Automatic boards need MAv2 and persistent storage. An explicit host
|
||||
// connection also works for isolated, ephemeral runtimes.
|
||||
if connection.is_none()
|
||||
&& (!config.features.enabled(Feature::AgentMessageBoard)
|
||||
|| !config.features.enabled(Feature::MultiAgentV2)
|
||||
|| (config.ephemeral
|
||||
&& !in_memory
|
||||
&& config.multi_agent_v2.message_board_remote.is_none()))
|
||||
{
|
||||
return Box::pin(async { Ok(None) });
|
||||
}
|
||||
let sqlite = config.sqlite_config().clone();
|
||||
let remote = config.multi_agent_v2.message_board_remote.clone();
|
||||
let http_factory = config.http_client_factory();
|
||||
let in_memory_boards = Arc::clone(&in_memory_boards);
|
||||
let host = Arc::new(LocalBoardHost {
|
||||
manager: manager.clone(),
|
||||
tree,
|
||||
caller,
|
||||
expected_turn_id: None,
|
||||
connection: connection.clone(),
|
||||
});
|
||||
Box::pin(async move {
|
||||
let board: Arc<dyn AgentMessageBoard> = if let Some(remote) = remote {
|
||||
let board: Arc<dyn AgentMessageBoard> = if let Some(connection) = connection {
|
||||
Arc::new(connection.open(host).await?)
|
||||
} else if let Some(remote) = config.multi_agent_v2.message_board_remote.clone() {
|
||||
let token = match remote.bearer_token_env_var {
|
||||
Some(name) => std::env::var(name).map_err(|_| CodexErr::InvalidRequest(
|
||||
"message-board credential environment variable is missing or invalid".into(),
|
||||
@@ -90,7 +94,8 @@ pub fn install_agent_message_board(
|
||||
};
|
||||
let token = AccessToken::new(token)
|
||||
.map_err(|err| CodexErr::InvalidRequest(err.to_string()))?;
|
||||
let http = http_factory
|
||||
let http = config
|
||||
.http_client_factory()
|
||||
.build_client(&remote.url, ClientRouteClass::Api)
|
||||
.map_err(|err| CodexErr::Io(std::io::Error::other(err)))?;
|
||||
input.thread_store.insert(host.as_ref().clone());
|
||||
@@ -104,7 +109,9 @@ pub fn install_agent_message_board(
|
||||
} else if in_memory {
|
||||
Arc::new(in_memory_boards.open(tree, host).await)
|
||||
} else {
|
||||
Arc::new(LocalAgentMessageBoard::open(&sqlite, tree, host).await?)
|
||||
Arc::new(
|
||||
LocalAgentMessageBoard::open(config.sqlite_config(), tree, host).await?,
|
||||
)
|
||||
};
|
||||
Ok(Some(board))
|
||||
})
|
||||
@@ -118,6 +125,7 @@ struct LocalBoardHost {
|
||||
tree: SessionId,
|
||||
caller: ThreadId,
|
||||
expected_turn_id: Option<String>,
|
||||
connection: Option<Arc<LocalBoardConnection>>,
|
||||
}
|
||||
|
||||
impl LocalBoardHost {
|
||||
@@ -126,8 +134,16 @@ impl LocalBoardHost {
|
||||
.manager
|
||||
.upgrade()
|
||||
.ok_or_else(|| CodexErr::ThreadNotFound(caller))?;
|
||||
if let Some(connection) = &self.connection {
|
||||
return manager.get_thread_for_board(caller, connection).await;
|
||||
}
|
||||
let actor = manager.get_thread(caller).await?;
|
||||
if actor.session.session_id() != self.tree {
|
||||
if actor.session.session_id() != self.tree
|
||||
|| actor
|
||||
.thread_extension_data()
|
||||
.get::<LocalBoardConnection>()
|
||||
.is_some()
|
||||
{
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"agent belongs to another message board".into(),
|
||||
));
|
||||
@@ -137,6 +153,9 @@ impl LocalBoardHost {
|
||||
|
||||
async fn registered_path(&self, actor: &CodexThread) -> Result<AgentPath> {
|
||||
let caller = actor.session.thread_id;
|
||||
if let Some(connection) = &self.connection {
|
||||
return connection.caller_path(caller);
|
||||
}
|
||||
let path = actor
|
||||
.session_source
|
||||
.get_agent_path()
|
||||
@@ -172,6 +191,19 @@ impl MessageBoardHost for LocalBoardHost {
|
||||
|
||||
fn resolve_agent(&self, path: AgentPath) -> BoxFuture<'_, Result<ThreadId>> {
|
||||
Box::pin(async move {
|
||||
if self.connection.is_some() {
|
||||
let caller = path
|
||||
.as_str()
|
||||
.strip_prefix("/root/")
|
||||
.and_then(|id| ThreadId::from_string(id).ok())
|
||||
.ok_or_else(|| {
|
||||
CodexErr::InvalidRequest("invalid connected board agent path".into())
|
||||
})?;
|
||||
return self
|
||||
.actor(caller)
|
||||
.await
|
||||
.map(|actor| actor.session.thread_id);
|
||||
}
|
||||
let actor = self.actor(self.caller).await?;
|
||||
actor
|
||||
.session
|
||||
@@ -207,21 +239,13 @@ impl MessageBoardHost for LocalBoardHost {
|
||||
post: PostPreview,
|
||||
) -> BoxFuture<'_, Result<NotificationDelivery>> {
|
||||
Box::pin(async move {
|
||||
let Some(manager) = self.manager.upgrade() else {
|
||||
return Ok(NotificationDelivery::SkippedInactive);
|
||||
};
|
||||
let recipient = match manager.get_thread(recipient_id).await {
|
||||
let recipient = match self.actor(recipient_id).await {
|
||||
Ok(thread) => thread,
|
||||
Err(error) if matches!(error.details(), CodexErrorDetails::ThreadNotFound(_)) => {
|
||||
return Ok(NotificationDelivery::SkippedInactive);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if recipient.session.session_id() != self.tree {
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"notification recipient belongs to another board".into(),
|
||||
));
|
||||
}
|
||||
let recipient_path = self.registered_path(&recipient).await?;
|
||||
let notice = AgentMessageBoardNotification(post);
|
||||
let communication = InterAgentCommunication::new(
|
||||
|
||||
@@ -953,6 +953,27 @@ impl ThreadManager {
|
||||
self.state.get_thread(thread_id).await
|
||||
}
|
||||
|
||||
/// Board hosts may reach internal runtimes only through a matching explicit connection.
|
||||
pub(crate) async fn get_thread_for_board(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
connection: &codex_agent_message_board_extension::LocalBoardConnection,
|
||||
) -> CodexResult<Arc<CodexThread>> {
|
||||
self.state
|
||||
.threads
|
||||
.read()
|
||||
.await
|
||||
.get(&thread_id)
|
||||
.filter(|thread| {
|
||||
thread
|
||||
.thread_extension_data()
|
||||
.get::<codex_agent_message_board_extension::LocalBoardConnection>()
|
||||
.is_some_and(|other| connection.same_board(&other))
|
||||
})
|
||||
.cloned()
|
||||
.ok_or(CodexErr::ThreadNotFound(thread_id))
|
||||
}
|
||||
|
||||
/// Updates metadata for loaded and cold threads through one entrypoint.
|
||||
///
|
||||
/// Loaded threads route through `CodexThread`/`LiveThread`, so metadata changes stay ordered
|
||||
|
||||
Reference in New Issue
Block a user