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
@@ -931,3 +931,263 @@ async fn board_clock_failure_does_not_fall_back_or_create_a_channel() -> anyhow:
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn codex_instances_share_board_with_fixed_access() -> anyhow::Result<()> {
use codex_agent_message_board_extension::LocalBoard;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::IsolatedSessionExtensions;
use codex_extension_api::SessionIsolation;
use codex_extension_api::ToolName;
use codex_protocol::protocol::InternalSessionSource;
use codex_protocol::protocol::SessionSource;
use codex_state::SqliteConfig;
use std::sync::Arc;
let server = responses::start_mock_server().await;
let response = |body| vec![StreamingSseChunk { gate: None, body }];
let (release, gate) = oneshot::channel();
let mut working = ev_assistant_message("watching", "Watching the policy board.");
working["item"]["phase"] = json!("commentary");
let (streaming, _) = start_streaming_sse_server(vec![
response(tool(
"publish",
"post",
json!({"new_channel_name":"policy", "text":"A shared policy"}),
)),
response(done()),
response(tool("permissions", "get_channels", json!({}))),
response(tool("read", "search_posts", json!({}))),
response(tool(
"denied",
"post",
json!({"channel_name":"policy", "text":"Cannot publish"}),
)),
response(tool("deny-create", "create_channel", json!({"channel_name":"denied"}))),
response(tool("deny-new", "post", json!({"new_channel_name":"denied", "text":"Cannot publish"}))),
response(tool("deny-reply", "post", json!({"thread_id":"00000000-0000-4000-8000-000000000099", "text":"Cannot reply"}))),
response(tool("deny-subscribe", "subscribe", json!({"channel_name":"policy", "target_agent":"/root/00000000000040008000000000000000"}))),
response(done()),
response(tool(
"subscribe",
"subscribe",
json!({"channel_name":"policy"}),
)),
vec![
StreamingSseChunk {
gate: None,
body: sse(vec![ev_response_created("watching"), working]),
},
StreamingSseChunk {
gate: Some(gate),
body: tool("checkpoint", "get_channels", json!({})),
},
],
response(sse(vec![
ev_function_call_with_namespace(
"wrong-board",
"collaboration",
"post",
&json!({
"channel_name":"policy", "text":"Must not publish",
"agents_to_notify":["/root/00000000000040008000000000000002"]
})
.to_string(),
),
ev_function_call_with_namespace(
"update",
"collaboration",
"post",
&json!({"channel_name":"policy", "text":"A new policy"}).to_string(),
),
ev_function_call_with_namespace(
"explicit-update",
"collaboration",
"post",
&json!({
"channel_name":"policy", "text":"An explicit update",
"agents_to_notify":["/root/00000000000040008000000000000001"]
})
.to_string(),
),
ev_completed("update"),
])),
response(done()),
response(done()),
])
.await;
let base_url = format!("{}/v1", streaming.uri());
let test = test_codex()
.with_config(move |config| {
super::configure_scenario_catalog(config);
config.ephemeral = true;
config.model_provider.base_url = Some(base_url);
config
.features
.disable(Feature::EnableRequestCompression)
.expect("capture gated requests");
config.multi_agent_v2.tool_namespace = Some("collaboration".into());
config.current_time_reminder = Some(codex_core::config::CurrentTimeReminderConfig {
clock_source: codex_features::CurrentTimeSource::External,
..Default::default()
});
})
.with_external_time_provider(Arc::new(BoardClock::Available))
.build_with_auto_env(&server)
.await?;
let storage = tempfile::tempdir()?;
let database_home = storage.path().join("data");
let alias = storage.path().join("alias");
std::fs::create_dir(&database_home)?;
core_test_support::create_directory_symlink(&database_home, &alias);
let board_id = codex_protocol::SessionId::new();
let writer_board = LocalBoard::new(
SqliteConfig::new_for_testing(database_home.try_into()?),
board_id,
);
let guardian_board =
LocalBoard::new(SqliteConfig::new_for_testing(alias.try_into()?), board_id);
let mut extensions = ExtensionRegistryBuilder::new();
codex_core::install_agent_message_board(&mut extensions, Arc::downgrade(&test.thread_manager));
let extensions = Arc::new(extensions.build());
let mut instances = Vec::new();
for (index, (connection, source)) in [
(writer_board.read_write(), SessionSource::Cli),
(
guardian_board.read_only(),
SessionSource::Internal(InternalSessionSource::Guardian),
),
]
.into_iter()
.enumerate()
{
let mut options = test.start_thread_options().await;
let internal = source.is_internal();
options.session_source = Some(source);
options.reserved_thread_id = Some(codex_protocol::ThreadId::from_string(&format!(
"00000000-0000-4000-8000-00000000000{index}"
))?);
options
.thread_extension_init
.insert(SessionIsolation::Isolated);
options
.thread_extension_init
.insert(IsolatedSessionExtensions(extensions.clone()));
let mut policy = codex_guardian_reviewer::reviewer_tool_policy();
policy.allowed_tools.as_mut().unwrap().extend(
[
"post",
"create_channel",
"get_channels",
"search_posts",
"subscribe",
]
.map(|name| ToolName::namespaced("collaboration", name)),
);
options.thread_extension_init.insert(policy);
options.thread_extension_init.insert(connection);
let instance = if internal {
test.thread_manager
.spawn_internal_session(test.session_configured.thread_id, options)
.await?
} else {
test.thread_manager.start_thread(options).await?
}
.thread;
instance
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "Use the shared policy board.".into(),
text_elements: vec![],
}]))
.await?;
wait_for_event(&instance, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
instances.push(instance);
}
assert_eq!(streaming.requests().await.len(), 10);
// Keep the Guardian active while the independent writer delivers a subscribed notice.
let writer = &instances[0];
let guardian = &instances[1];
// A same-tree internal runtime has no board access unless its host supplies a connection.
let writer_id =
codex_protocol::ThreadId::from_string(writer.thread_extension_data().level_id())?;
let mut options = test.start_thread_options().await;
options.session_source = Some(SessionSource::Internal(InternalSessionSource::Guardian));
options.reserved_thread_id = Some(codex_protocol::ThreadId::from_string(
"00000000-0000-4000-8000-000000000002",
)?);
let unconnected = test
.thread_manager
.spawn_internal_session(writer_id, options)
.await?;
assert_eq!(unconnected.session_configured.session_id, writer_id.into());
guardian
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "Watch for updates.".into(),
text_elements: vec![],
}]))
.await?;
wait_for_event(guardian, |event| {
matches!(event, EventMsg::ItemCompleted(event)
if matches!(&event.item, TurnItem::AgentMessage(message) if message.id == "watching"))
})
.await;
writer
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "Publish an update.".into(),
text_elements: vec![],
}]))
.await?;
wait_for_event(writer, |event| matches!(event, EventMsg::TurnComplete(_))).await;
release
.send(())
.expect("release Guardian after notification delivery");
wait_for_event(guardian, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let requests = streaming.requests().await;
assert!(
requests
.iter()
.any(|body| String::from_utf8_lossy(body).contains("Message Type: CHANNEL_POST"))
);
let last: Value = serde_json::from_slice(requests.last().unwrap())?;
let checkpoint = last["input"]
.as_array()
.unwrap()
.iter()
.find(|item| item["call_id"] == "checkpoint" && item["type"] == "function_call_output")
.context("channel checkpoint")?;
let channels: Value = serde_json::from_str(checkpoint["output"].as_str().unwrap())?;
assert_eq!(channels["results"][0]["message_count"], 3); // The unconnected runtime's notice was rejected before writing.
let mut bodies = streaming
.requests()
.await
.iter()
.map(|body| serde_json::from_slice::<Value>(body))
.collect::<serde_json::Result<Vec<_>>>()?;
assert_eq!(bodies.len(), 15);
for body in &mut bodies {
for item in body["input"].as_array_mut().context("request input")? {
if let Some(Value::String(output)) = item.get_mut("output") {
*output = context_snapshot::normalize_json_lines(output);
}
}
}
insta::assert_snapshot!(
"board_connections",
context_snapshot::format_context_snapshot(
"Independent Codex runtimes share a local board: a writer publishes, an isolated Guardian reads and cannot publish.",
&bodies.iter().map(SnapshotEntry::body).collect::<Vec<_>>(),
&ContextSnapshotOptions::default().rewrite_known_segments(),
)
);
unconnected.thread.shutdown_and_wait().await?;
for instance in instances {
instance.shutdown_and_wait().await?;
}
streaming.shutdown().await;
Ok(())
}
@@ -0,0 +1,120 @@
---
source: core/tests/suite/scenarios_agent_message_board.rs
expression: "context_snapshot::format_context_snapshot(\"Independent Codex runtimes share a local board: a writer publishes, an isolated Guardian reads and cannot publish.\",\n&bodies.iter().map(SnapshotEntry::body).collect::<Vec<_>>(),\n&ContextSnapshotOptions::default().rewrite_known_segments(),)"
---
Scenario: Independent Codex runtimes share a local board: a writer publishes, an isolated Guardian reads and cannot publish.
## Window 1
-- request 1 (request) --
00:message/developer:
<MODEL_INSTRUCTIONS>
01:message/developer:
<PERMISSIONS_INSTRUCTIONS>
02:message/user:
<ENVIRONMENT_CONTEXT>
03:message/user:
Use the shared policy board.
-- request 2 (request) --
04:function_call/collaboration.post:{"new_channel_name":"policy","text":"A shared policy"}
05:function_call_output:{"author":"/root/00000000000040008000000000000000","channel_name":"policy","created_at":"2026-09-18T12:00:00Z","message_id":"<UUID 1>","thread_id":"<UUID 1>"}
## Window 2 (after request 2: input truncated at item 04, settings changed (prompt_cache_key))
-- request 3 (request) --
00:message/developer:
<MODEL_INSTRUCTIONS>
01:message/developer:
<PERMISSIONS_INSTRUCTIONS>
02:message/user:
<ENVIRONMENT_CONTEXT>
03:message/user:
Use the shared policy board.
-- request 4 (request) --
04:function_call/collaboration.get_channels:{}
05:function_call_output:{"board_permissions":{"create_channel":false,"edit_metadata":false,"manage_permissions":false},"has_more...ata":false,"post":false,"read":true,"reply":false}}]} [hash=eac4eff4c4aa3c0d]
-- request 5 (request) --
06:function_call/collaboration.search_posts:{}
07:function_call_output:{"has_more":false,"n_returned":1,"next_cursor":null,"results":[{"author":"/root/000000000000400080000000...d policy","thread_id":"<UUID 1>","truncated":false}]} [hash=dba74b349a2e49af]
-- request 6 (request) --
08:function_call/collaboration.post:{"channel_name":"policy","text":"Cannot publish"}
09:function_call_output:{"error":{"action":"channel.post","code":"permission_denied","message":"This board connection is read-only.","resource":"board:<UUID 2>"}}
-- request 7 (request) --
10:function_call/collaboration.create_channel:{"channel_name":"denied"}
11:function_call_output:{"error":{"action":"board.create_channel","code":"permission_denied","message":"This board connection is read-only.","resource":"board:<UUID 2>"}}
-- request 8 (request) --
12:function_call/collaboration.post:{"new_channel_name":"denied","text":"Cannot publish"}
13:function_call_output:{"error":{"action":"channel.post","code":"permission_denied","message":"This board connection is read-only.","resource":"board:<UUID 2>"}}
-- request 9 (request) --
14:function_call/collaboration.post:{"text":"Cannot reply","thread_id":"<UUID 3>"}
15:function_call_output:{"error":{"action":"channel.reply","code":"permission_denied","message":"This board connection is read-only.","resource":"board:<UUID 2>"}}
-- request 10 (request) --
16:function_call/collaboration.subscribe:{"channel_name":"policy","target_agent":"/root/00000000000040008000000000000000"}
17:function_call_output:{"error":{"action":"board.manage_subscriptions","code":"permission_denied","message":"This board connection is read-only.","resource":"board:<UUID 2>"}}
-- request 11 (request) --
18:message/assistant:
Done.
19:message/user:
Watch for updates.
-- request 12 (request) --
20:function_call/collaboration.subscribe:{"channel_name":"policy"}
21:function_call_output:{"channel_name":"policy","enabled":true,"last_message_id":"<UUID 1>","target_agent":"/root/00000000000040008000000000000001","thread_id":null}
## Window 3 (after request 12: input diverged at item 04, settings changed (prompt_cache_key))
-- request 13 (request) --
00:message/developer:
<MODEL_INSTRUCTIONS>
01:message/developer:
<PERMISSIONS_INSTRUCTIONS>
02:message/user:
<ENVIRONMENT_CONTEXT>
03:message/user:
Use the shared policy board.
04:function_call/collaboration.post:{"new_channel_name":"policy","text":"A shared policy"}
05:function_call_output:{"author":"/root/00000000000040008000000000000000","channel_name":"policy","created_at":"2026-09-18T12:00:00Z","message_id":"<UUID 1>","thread_id":"<UUID 1>"}
06:message/assistant:
Done.
07:message/user:
Publish an update.
-- request 14 (request) --
08:function_call/collaboration.post:{"agents_to_notify":["/root/00000000000040008000000000000002"],"channel_name":"policy","text":"Must not publish"}
09:function_call/collaboration.post:{"channel_name":"policy","text":"A new policy"}
10:function_call/collaboration.post:{"agents_to_notify":["/root/00000000000040008000000000000001"],"channel_name":"policy","text":"An explicit update"}
11:function_call_output:no thread with id: <UUID 4>
12:function_call_output:{"author":"/root/00000000000040008000000000000000","channel_name":"policy","created_at":"2026-09-18T12:00:00Z","message_id":"<UUID 5>","thread_id":"<UUID 5>"}
13:function_call_output:{"author":"/root/00000000000040008000000000000000","channel_name":"policy","created_at":"2026-09-18T12:00:00Z","message_id":"<UUID 6>","thread_id":"<UUID 6>"}
## Window 4 (after request 14: input diverged at item 04, settings changed (prompt_cache_key))
-- request 15 (request) --
00:message/developer:
<MODEL_INSTRUCTIONS>
01:message/developer:
<PERMISSIONS_INSTRUCTIONS>
02:message/user:
<ENVIRONMENT_CONTEXT>
03:message/user:
Use the shared policy board.
04:function_call/collaboration.get_channels:{}
05:function_call_output:{"board_permissions":{"create_channel":false,"edit_metadata":false,"manage_permissions":false},"has_more...ata":false,"post":false,"read":true,"reply":false}}]} [hash=eac4eff4c4aa3c0d]
06:function_call/collaboration.search_posts:{}
07:function_call_output:{"has_more":false,"n_returned":1,"next_cursor":null,"results":[{"author":"/root/000000000000400080000000...d policy","thread_id":"<UUID 1>","truncated":false}]} [hash=dba74b349a2e49af]
08:function_call/collaboration.post:{"channel_name":"policy","text":"Cannot publish"}
09:function_call_output:{"error":{"action":"channel.post","code":"permission_denied","message":"This board connection is read-only.","resource":"board:<UUID 2>"}}
10:function_call/collaboration.create_channel:{"channel_name":"denied"}
11:function_call_output:{"error":{"action":"board.create_channel","code":"permission_denied","message":"This board connection is read-only.","resource":"board:<UUID 2>"}}
12:function_call/collaboration.post:{"new_channel_name":"denied","text":"Cannot publish"}
13:function_call_output:{"error":{"action":"channel.post","code":"permission_denied","message":"This board connection is read-only.","resource":"board:<UUID 2>"}}
14:function_call/collaboration.post:{"text":"Cannot reply","thread_id":"<UUID 3>"}
15:function_call_output:{"error":{"action":"channel.reply","code":"permission_denied","message":"This board connection is read-only.","resource":"board:<UUID 2>"}}
16:function_call/collaboration.subscribe:{"channel_name":"policy","target_agent":"/root/00000000000040008000000000000000"}
17:function_call_output:{"error":{"action":"board.manage_subscriptions","code":"permission_denied","message":"This board connection is read-only.","resource":"board:<UUID 2>"}}
18:message/assistant:
Done.
19:message/user:
Watch for updates.
20:function_call/collaboration.subscribe:{"channel_name":"policy"}
21:function_call_output:{"channel_name":"policy","enabled":true,"last_message_id":"<UUID 1>","target_agent":"/root/00000000000040008000000000000001","thread_id":null}
22:message/assistant:
Watching the policy board.
23:function_call/collaboration.get_channels:{}
24:function_call_output:{"board_permissions":{"create_channel":false,"edit_metadata":false,"manage_permissions":false},"has_more...ata":false,"post":false,"read":true,"reply":false}}]} [hash=7cc8dc25fc1c52b2]
25:agent_message
26:agent_message
@@ -25,3 +25,30 @@ otherwise it uses UTC now.
This loads channels and descriptions only. ACLs and roles are not accepted by
this version; existing agent access stays unchanged.
## Connect Codex runtimes
The host can give independently created Codex runtimes different access to the
same local board through the existing startup extension settings:
```rust
let board = LocalBoard::new(sqlite_config, board_id);
guardian_options.thread_extension_init.insert(board.read_only());
writer_options.thread_extension_init.insert(board.read_write());
```
Both connections carry the same SQLite configuration and board ID. Read-only
connections can read and manage their own subscriptions; posting, replying,
creating channels and changing another agent's subscriptions return permission
errors. Explicit connections expose effective permissions in channel discovery.
Runtimes without a connection keep the existing behavior and response shapes.
Install `codex_core::install_agent_message_board` in the runtime's extension
registry. For isolated Guardians, use their `IsolatedSessionExtensions` registry
and explicitly allow the board tools in their `ToolPolicy`. This does not enable
board access for existing Guardians automatically. Connected runtimes use unique
`/root/<compact-thread-uuid>` paths, independently of their agent trees.
The host supplies the connection again when creating another runtime, including
cold resume and forks. Connections are not database ACLs or inherited through
`spawn_agent`. Local filesystem writers remain trusted.
+1 -1
View File
@@ -24,7 +24,7 @@ use uuid::Uuid;
// Local and remote implementations must remain usable through the same handle.
const _: Option<&dyn AgentMessageBoard> = None;
/// Stores discussions and subscriptions for one agent tree.
/// Stores discussions and subscriptions for one board.
///
/// Every operation validates caller membership. IDs are scoped to this board;
/// an agent runtime's ThreadId is distinct from a discussion's root post UUID.
@@ -1,10 +1,11 @@
//! Installs caller-bound tools using persistent host identities.
//! Installs caller-bound tools using host identities and optional local connections.
//!
//! Backend selection is a host concern. Startup failures leave tools unavailable
//! and emit a warning; a disabled factory does not open storage.
//! Tool namespaces follow the host's configuration at runtime startup.
use crate::AgentMessageBoard;
use crate::LocalBoardConnection;
use crate::tools::message_board_tools_with_descriptions;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionEventSink;
@@ -60,7 +61,11 @@ impl<C: Sync> ThreadLifecycleContributor<C> for BoardExtension<C> {
CodexErr::InvalidRequest("invalid board caller identity".into())
})?;
if let Some(board) = (self.open)(&input, tree, caller).await? {
if board.identity() != tree {
let connection = input.thread_store.get::<LocalBoardConnection>();
let expected = connection
.as_ref()
.map_or(tree, |connection| connection.identity());
if board.identity() != expected {
return Err(CodexErr::InvalidRequest(
"message-board factory returned another tree".into(),
));
@@ -69,10 +74,13 @@ impl<C: Sync> ThreadLifecycleContributor<C> for BoardExtension<C> {
board,
caller,
namespace: (self.tool_namespace)(input.config),
path: input
.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root),
path: match connection {
Some(connection) => connection.caller_path(caller)?,
None => input
.session_source
.get_agent_path()
.unwrap_or_else(AgentPath::root),
},
});
}
Ok::<(), CodexErr>(())
+1 -1
View File
@@ -14,7 +14,7 @@ pub enum NotificationDelivery {
SkippedInactive,
}
/// Tree-scoped membership, clock and notification access supplied by the host.
/// Board-scoped membership, clock and notification access supplied by the host.
///
/// The host remains authoritative for membership even when runtimes are unloaded.
/// Clock reads use the posting agent's configured source, including simulated
@@ -34,6 +34,8 @@ pub use host::NotificationDelivery;
pub use in_memory::InMemoryAgentMessageBoard;
pub use in_memory::InMemoryMessageBoards;
pub use local::LocalAgentMessageBoard;
pub use local::connection::LocalBoard;
pub use local::connection::LocalBoardConnection;
pub use local::setup::LocalBoardSetup;
pub use local::setup::configure_local_board;
pub use permissions::BoardPermissions;
+33 -2
View File
@@ -1,12 +1,15 @@
//! SQLite-backed boards. The tree ID scopes every read and write.
//! SQLite-backed boards. The board ID scopes every read and write.
//!
//! Immediate transactions serialize mutations across independently opened handles.
//! Live handles in this process share one connection pool per database path.
//! Accepted posts survive runtime unload and process restart, but cannot recreate
//! a board after its root has been permanently deleted.
//! Opening storage adds optional channel metadata without invalidating older readers.
//! Explicit connections can restrict writes; default handles retain legacy access.
use self::connection::Access;
use crate::ChannelDescription;
use crate::ChannelPermissions;
use crate::ChannelSummary;
use crate::CreateChannelRequest;
use crate::MessageBoardHost;
@@ -38,6 +41,7 @@ use std::collections::HashSet;
use std::sync::Arc;
use uuid::Uuid;
pub(crate) mod connection;
mod lifecycle;
mod paging;
mod pool;
@@ -57,6 +61,7 @@ pub struct LocalAgentMessageBoard {
identity: SessionId,
pool: Arc<SqlitePool>,
host: Arc<dyn MessageBoardHost>,
access: Option<Access>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -77,6 +82,7 @@ impl LocalAgentMessageBoard {
identity,
pool: pool::open(sqlite).await?,
host,
access: None,
})
}
@@ -85,6 +91,9 @@ impl LocalAgentMessageBoard {
caller: ThreadId,
request: CreateChannelRequest,
) -> Result<ChannelSummary> {
if let Some(access) = self.access {
access.require_write(self.identity, "board.create_channel")?;
}
validate_channel(&request.channel_name)?;
let author = self.host.agent_path(caller).await?;
let now = self.host.current_time(caller).await?;
@@ -116,6 +125,15 @@ impl LocalAgentMessageBoard {
/// into a recipient's later turn. Once committed, notification failures are
/// logged without failing the post; the persisted content remains readable.
pub async fn post(&self, caller: ThreadId, request: PostRequest) -> Result<PostMetadata> {
if let Some(access) = self.access {
access.require_write(
self.identity,
match &request.destination {
PostDestination::Thread(_) => "channel.reply",
PostDestination::Channel(_) | PostDestination::NewChannel(_) => "channel.post",
},
)?;
}
let board = self.clone();
tokio::spawn(async move { board.post_inner(caller, request).await })
.await
@@ -269,6 +287,11 @@ impl LocalAgentMessageBoard {
let caller_path = self.host.agent_path(caller).await?;
let target_path = request.target_agent.unwrap_or(caller_path);
let target_agent = self.host.resolve_agent(target_path.clone()).await?;
if target_agent != caller
&& let Some(access) = self.access
{
access.require_write(self.identity, "board.manage_subscriptions")?;
}
let mut tx = self.begin_write().await?;
let (channel, root, last) = match &request.target {
SubscriptionTarget::Channel(name) => {
@@ -413,7 +436,15 @@ impl LocalAgentMessageBoard {
.map_err(storage_error)?
.ok_or_else(|| invalid("channel not found in this board"))?;
Ok(ChannelSummary {
permissions: None,
permissions: self.access.map(|access| {
let write = matches!(access, Access::ReadWrite);
ChannelPermissions {
read: true,
post: write,
reply: write,
edit_metadata: false,
}
}),
channel_name: name.to_string(),
description: row.get("description"),
created_at: DateTime::parse_from_rfc3339(row.get("created_at"))
@@ -0,0 +1,113 @@
//! Host-supplied connections to one local board. Access is fixed per runtime,
//! independent of the board's storage or the agent's tree identity.
use super::LocalAgentMessageBoard;
use crate::MessageBoardHost;
use crate::PermissionDenied;
use codex_protocol::AgentPath;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result;
use codex_state::SqliteConfig;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::OnceLock;
/// Shared local storage from which a host grants individual runtime connections.
#[derive(Clone)]
pub struct LocalBoard {
sqlite: SqliteConfig,
identity: SessionId,
}
impl LocalBoard {
pub fn new(sqlite: SqliteConfig, identity: SessionId) -> Self {
Self { sqlite, identity }
}
pub fn read_only(&self) -> LocalBoardConnection {
LocalBoardConnection {
board: self.clone(),
access: Access::ReadOnly,
database: OnceLock::new(),
}
}
pub fn read_write(&self) -> LocalBoardConnection {
LocalBoardConnection {
board: self.clone(),
access: Access::ReadWrite,
database: OnceLock::new(),
}
}
}
/// Insert into `StartThreadOptions::thread_extension_init` before creating Codex.
/// Hosts must supply it again for a new runtime, including cold resume or a fork.
/// The board extension must be installed, including in an isolated session's registry.
#[derive(Clone)]
pub struct LocalBoardConnection {
board: LocalBoard,
access: Access,
// Capture the opened database, independently of caller unload or symlink changes.
database: OnceLock<PathBuf>,
}
impl LocalBoardConnection {
pub fn identity(&self) -> SessionId {
self.board.identity
}
/// Compare the canonical database paths captured when the connections were opened.
pub fn same_board(&self, other: &Self) -> bool {
self.identity() == other.identity()
&& self
.database
.get()
.zip(other.database.get())
.is_some_and(|(database, other)| database == other)
}
/// Independent runtimes have no common agent tree. Use their unique IDs as paths.
pub fn caller_path(&self, caller: ThreadId) -> Result<AgentPath> {
AgentPath::root()
.join(&caller.to_string().replace('-', ""))
.map_err(CodexErr::InvalidRequest)
}
pub async fn open(&self, host: Arc<dyn MessageBoardHost>) -> Result<LocalAgentMessageBoard> {
let mut board =
LocalAgentMessageBoard::open(&self.board.sqlite, self.identity(), host).await?;
// Pool initialization canonicalizes the directory before opening SQLite.
let options = board.pool.connect_options();
let database = options.get_filename();
if self.database.get_or_init(|| database.to_path_buf()) != database {
return Err(CodexErr::InvalidRequest(
"board database changed; create a new board connection".into(),
));
}
board.access = Some(self.access);
Ok(board)
}
}
#[derive(Clone, Copy)]
pub(super) enum Access {
ReadOnly,
ReadWrite,
}
impl Access {
pub(super) fn require_write(self, board: SessionId, action: &str) -> Result<()> {
match self {
Self::ReadWrite => Ok(()),
Self::ReadOnly => Err(PermissionDenied {
message: "This board connection is read-only.".into(),
action: Some(action.into()),
resource: Some(format!("board:{board}")),
}
.into()),
}
}
}
@@ -3,6 +3,7 @@
use super::LocalAgentMessageBoard;
use super::MAX_READ_CHARS;
use super::StoredPost;
use super::connection::Access;
use super::invalid;
use super::paging::Window;
use super::paging::decode_posts;
@@ -10,6 +11,7 @@ use super::paging::direction;
use super::paging::preview;
use super::storage_error;
use crate::AgentMessageBoard;
use crate::BoardPermissions;
use crate::ChannelPage;
use crate::ChannelQuery;
use crate::ChannelSummary;
@@ -109,7 +111,11 @@ impl AgentMessageBoard for LocalAgentMessageBoard {
}
Ok(ChannelPage {
channels: window.finish(channels)?,
board_permissions: None,
board_permissions: self.access.map(|access| BoardPermissions {
create_channel: matches!(access, Access::ReadWrite),
edit_metadata: false,
manage_permissions: false,
}),
})
})
}
@@ -1,4 +1,4 @@
//! Service-computed permissions and denials. Codex exposes these without evaluating ACLs.
//! Backend-computed permissions and denials exposed by the board tools.
use codex_protocol::error::CodexErr;
use codex_tools::FunctionCallError;
@@ -25,7 +25,7 @@ pub struct ChannelPermissions {
pub edit_metadata: bool,
}
/// A service denial retained through the board's existing error interface.
/// A backend denial retained through the board's existing error interface.
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "code", rename = "permission_denied")]
pub struct PermissionDenied {