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:
jif
2026-10-12 14:44:22 +00:00
committed by copyberry
parent cab328f275
commit 97239fd052
13 changed files with 647 additions and 35 deletions
+46 -22
View File
@@ -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(
+21
View File
@@ -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