From c4a8a6337d58fbd730b793d92a010ec2bc59dc28 Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 9 Nov 2026 19:05:47 +0100 Subject: [PATCH 1/2] fix(core): add process-bound registration for sandboxed sessions --- .../trajectory.json | 52 ++++++ CHANGELOG.md | 1 + crates/relay-node/README.md | 16 ++ crates/relay-node/src/cli.rs | 59 ++++++- crates/relay-node/src/cli/relay_commands.rs | 34 +++- .../relay-node/src/relay/codex/stop_hook.rs | 26 ++- .../src/relay/codex/stop_hook/rules.rs | 5 +- .../src/relay/outbound/hooks/codex_stop.rs | 21 ++- .../outbound/hooks/codex_stop/affinity.rs | 6 +- crates/relay-node/src/relay/session_cli.rs | 152 +++++++++++++++++- crates/relay-node/tests/relay_outbound.rs | 78 +++++++++ 11 files changed, 433 insertions(+), 17 deletions(-) create mode 100644 .agentworkforce/trajectories/completed/traj_process_bound_register/trajectory.json diff --git a/.agentworkforce/trajectories/completed/traj_process_bound_register/trajectory.json b/.agentworkforce/trajectories/completed/traj_process_bound_register/trajectory.json new file mode 100644 index 0000000000..d299944176 --- /dev/null +++ b/.agentworkforce/trajectories/completed/traj_process_bound_register/trajectory.json @@ -0,0 +1,52 @@ +{ + "id": "traj_process_bound_register", + "version": 1, + "task": { + "title": "Add typed process-bound registration for sandboxed native sessions" + }, + "status": "completed", + "startedAt": "2026-11-09T17:50:34.125302Z", + "agents": [ + { + "name": "codex", + "role": "delegate", + "joinedAt": "2026-11-09T17:50:34.125302Z" + } + ], + "chapters": [ + { + "id": "chap_typed_registration", + "title": "Typed local registration", + "agentName": "codex", + "startedAt": "2026-11-09T17:50:34.125302Z", + "events": [ + { + "ts": 1794246634125, + "type": "decision", + "content": "Add a fixed POST /register session command and narrow Codex prefix rule; leave generic socket-request unallowed.", + "raw": { + "question": "How should a sandboxed native chat register safely?", + "chosen": "Typed register with name, description and existing MCP availability enum only.", + "alternatives": [ + "Allow raw socket-request", + "Accept a supplied native session identifier" + ], + "reasoning": "The server already binds registration to kernel peer PID/UID, discovers the native chat, and enforces self-registration and ownership policy. Arbitrary method/path or identity arguments would bypass the intended command boundary." + }, + "significance": "high" + }, + { + "ts": 1794247533984, + "type": "reflection", + "content": "Typed registration, JSON/argument scope, actual Codex policy engine, startup guidance, server self-registration and kernel-peer socket tests passed. Tests use isolated temporary state and fake services; no live chat was registered.", + "raw": { + "confidence": 0.93, + "tooling": "Trail executable was unavailable; recorded this trajectory directly using the repository format." + }, + "significance": "high" + } + ] + } + ], + "completedAt": "2026-11-09T18:05:33.961442Z" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9218d8c7c5..11db5a2170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `agent-relay-node register --socket ` lets a native session register itself from Codex's sandbox using a narrow command rule, preserving the core's process identity, ownership and self-registration checks. - New package `@agent-relay/core-bridge`: the Node client for the local Agent Relay core's bridge (connect, typed errors, events, and typed `spawn.*` helpers), extracted from the CLI. Replies over 16 MiB now close the connection and requests over 1 MiB are refused before they are sent. - `@agent-relay/core-bridge` reads the `released` flag of every spawn-control reply strictly: when an older core omits it or sends `null` or a non-boolean value, the reply carries `released: false`, and `isReleaseConfirmed(invocation)` is true only for a literal `true`, so a `completed` release is not by itself a confirmed one. diff --git a/crates/relay-node/README.md b/crates/relay-node/README.md index 25b6a9235b..bf29ce1801 100644 --- a/crates/relay-node/README.md +++ b/crates/relay-node/README.md @@ -56,6 +56,22 @@ contract), `call`, the harness hooks and `connect`. The account methods answer `method_not_found`, and the core uses the workspace the `agent-relay` CLI selects (`src/relay/workspace/cli.rs`), with no sign-in needed. +## Register the calling session + +After the person authorizes joining, a native chat can use the exact probe and +session socket provided by its core: + +```sh +agent-relay-node register --socket /path/to/relay.sock --name review-bot --description 'Reviews releases' +``` + +The core identifies the native chat from the connecting process, not a supplied +session ID. Its self-registration setting and ownership checks still apply. +`--mcp agent-relay-sessions` or `--mcp none` optionally declares whether that +chat has the hosted session tools. Registration takes no raw JSON or stdin. +Codex's generated allow rule permits this typed command only on the core's own +socket; generic `socket-request` remains outside those rules. + ## Claude mobile continuation On macOS and Linux, Claude Code terminal sessions can receive the verified owner's diff --git a/crates/relay-node/src/cli.rs b/crates/relay-node/src/cli.rs index adbdc3603a..db8f0aa3ce 100644 --- a/crates/relay-node/src/cli.rs +++ b/crates/relay-node/src/cli.rs @@ -138,6 +138,7 @@ mod sandbox_rule_reach_tests { ), "agents" => (&[("GET", "/agents")], &[]), "whoami" => (&[("GET", "/whoami")], &[]), + "register" => (&[("POST", "/register")], &[]), "read" => (&[("GET", "/messages")], &["ref"]), "fetch-attachment" => (&[("POST", "/attachments/fetch")], &["file_id"]), "connect-send" => (&[("POST", "/connect/send")], &["to"]), @@ -190,6 +191,63 @@ mod sandbox_rule_reach_tests { "swarm", ]; + #[test] + fn typed_registration_allows_no_identity_or_raw_request_overrides() { + let mut command = Cli::command(); + let prefix = ["probe", "register", "--socket", "/s/relay.sock"]; + let args: Vec = prefix + .into_iter() + .chain(["--name", "review-bot", "--description", "Reviews releases"]) + .map(str::to_string) + .collect(); + assert_eq!( + reach(&mut command, &args), + Some(("/s/relay.sock".into(), "POST", "/register".into())) + ); + for mcp in ["none", "agent-relay-sessions"] { + let args: Vec = prefix + .into_iter() + .chain(["--mcp", mcp]) + .map(str::to_string) + .collect(); + assert_eq!( + reach(&mut command, &args), + Some(("/s/relay.sock".into(), "POST", "/register".into())) + ); + } + for flag in [ + "--target", + "--pid", + "--uid", + "--session-id", + "--native-thread-id", + "--method", + "--path", + "--body", + "--socket", + "--mcp", // an unknown declaration is refused + ] { + let args: Vec = prefix + .into_iter() + .chain([flag, "other"]) + .map(str::to_string) + .collect(); + assert!(reach(&mut command, &args).is_none(), "{flag}"); + } + let rules = SandboxRules { + probe: "probe".into(), + socket: "/s/relay.sock".into(), + }; + assert!(rules + .patterns() + .iter() + .any(|p| p == &prefix.map(str::to_string))); + assert!(!rules + .patterns() + .iter() + .any(|p| p.iter().any(|word| word == "socket-request"))); + } + /// What `argv` would send if it ran: `None` when the probe refuses it /// before opening the socket, else the socket, method and path. fn reach( @@ -301,7 +359,6 @@ mod sandbox_rule_reach_tests { ("DELETE", "/webhooks"), ("POST", "/claude/attestation"), ("POST", "/copilot/mcp"), - ("POST", "/register"), ("DELETE", "/register"), ] { assert!( diff --git a/crates/relay-node/src/cli/relay_commands.rs b/crates/relay-node/src/cli/relay_commands.rs index 3540851d79..e0151e127f 100644 --- a/crates/relay-node/src/cli/relay_commands.rs +++ b/crates/relay-node/src/cli/relay_commands.rs @@ -137,6 +137,23 @@ pub enum RelayCommands { #[arg(long)] socket: PathBuf, }, + /// Put only the calling native session on the relay (`POST /register`). + /// The core identifies it from this process; its self-registration + /// setting and ownership checks still apply. + Register { + /// The session socket, as the hint names it. + #[arg(long)] + socket: PathBuf, + /// Optional relay name; otherwise use the session's suggested name. + #[arg(long)] + name: Option, + /// Optional description (at most 4096 bytes). + #[arg(long)] + description: Option, + /// Hosted MCP availability, without changing the session identity. + #[arg(long, value_parser = ["agent-relay-sessions", "none"])] + mcp: Option, + }, /// Fetch an attachment the calling session was sent but did not get in /// time (`POST /attachments/fetch`). FetchAttachment { @@ -270,7 +287,7 @@ pub enum RelayCommands { /// The self-limiting session command `command` is, with the socket it /// names and its `--body`: what `run` sends for `send`, `agents`, -/// `whoami`, `read --socket`, `fetch-attachment`, `connect-send` and +/// `whoami`, `register`, `read --socket`, `fetch-attachment`, `connect-send` and /// `subscribe-channel`. `None` for every /// other command. pub fn session_command( @@ -304,6 +321,20 @@ pub fn session_command( } RelayCommands::Agents { socket } => (socket.as_path(), SessionCommand::Agents, None), RelayCommands::Whoami { socket } => (socket.as_path(), SessionCommand::Whoami, None), + RelayCommands::Register { + socket, + name, + description, + mcp, + } => ( + socket.as_path(), + SessionCommand::Register { + name: name.clone(), + description: description.clone(), + mcp: mcp.clone(), + }, + None, + ), RelayCommands::SubscribeChannel { socket, channel, @@ -354,6 +385,7 @@ pub fn run(command: RelayCommands) -> Result<()> { RelayCommands::Send { .. } | RelayCommands::Agents { .. } | RelayCommands::Whoami { .. } + | RelayCommands::Register { .. } | RelayCommands::SubscribeChannel { .. } | RelayCommands::FetchAttachment { .. } | RelayCommands::ConnectSend { .. } => Err(user_error( diff --git a/crates/relay-node/src/relay/codex/stop_hook.rs b/crates/relay-node/src/relay/codex/stop_hook.rs index 7a1995ca23..b196b2533f 100644 --- a/crates/relay-node/src/relay/codex/stop_hook.rs +++ b/crates/relay-node/src/relay/codex/stop_hook.rs @@ -1238,6 +1238,28 @@ mod tests { ), (on(&["agents", "--socket", "SOCK"]), true), (on(&["whoami", "--socket", "SOCK"]), true), + ( + on(&[ + "register", + "--socket", + "SOCK", + "--name", + "review-bot", + "--mcp", + "none", + ]), + true, + ), + ( + on(&[ + "register", + "--socket", + "/tmp/other.sock", + "--name", + "review-bot", + ]), + false, + ), ( on(&["subscribe-channel", "--socket", "SOCK", "swarm", "--remove"]), true, @@ -1298,8 +1320,8 @@ mod tests { // The same check with Codex's own policy engine, when Codex is // installed (`codex execpolicy check`, verified with Codex 0.163): the - // rules allow the self-limiting commands and nothing that reaches a - // setting or registration route. + // rules allow the self-limiting commands, including typed registration, + // while generic requests and commands on another socket stay unallowed. #[test] fn codex_itself_allows_only_the_self_limiting_commands() { let Ok(codex) = which_codex() else { diff --git a/crates/relay-node/src/relay/codex/stop_hook/rules.rs b/crates/relay-node/src/relay/codex/stop_hook/rules.rs index 217fc857b7..89e88c1850 100644 --- a/crates/relay-node/src/relay/codex/stop_hook/rules.rs +++ b/crates/relay-node/src/relay/codex/stop_hook/rules.rs @@ -26,12 +26,12 @@ pub(super) const RULES_NAMES: usize = 9; /// A prefix rule constrains only a command's leading words: whatever follows /// them is allowed too. So the rules name only the probe's self-limiting /// session commands (`session_cli::ALLOWED_SUBCOMMANDS`: `send`, -/// `agents`, `whoami`, `read`, `fetch-attachment`, `connect-send` and +/// `agents`, `whoami`, `register`, `read`, `fetch-attachment`, `connect-send` and /// `subscribe-channel`), each only with /// `--socket` naming this core's own session socket. Each of those fixes its /// own method and route and checks every other argument, so nothing after /// the prefix reaches another route; `socket-request`, which takes any -/// method and path (`PATCH /settings`, `/register`), has no rule and still +/// method and path (`PATCH /settings`), has no rule and still /// asks. Nothing else is widened: no network, no other program, no other /// socket. /// @@ -79,6 +79,7 @@ impl SandboxRules { "send" => "Sends a message as this session through Agent Relay", "agents" => "Lists the agents this session can message", "whoami" => "Shows this session's own name on Agent Relay", + "register" => "Registers only this native session through Agent Relay's self-registration checks", "read" => "Reads a message Agent Relay relayed to this session", "fetch-attachment" => "Fetches an attachment sent to this session", "subscribe-channel" => "Joins or leaves one channel as this session", diff --git a/crates/relay-node/src/relay/outbound/hooks/codex_stop.rs b/crates/relay-node/src/relay/outbound/hooks/codex_stop.rs index bf38160085..491d95c018 100644 --- a/crates/relay-node/src/relay/outbound/hooks/codex_stop.rs +++ b/crates/relay-node/src/relay/outbound/hooks/codex_stop.rs @@ -32,11 +32,14 @@ const JOIN_POLL: Duration = Duration::from_millis(250); mod affinity; fn registration_context(socket: &std::path::Path) -> Option { - let command = super::super::probe_socket_command_with_body( + let command = super::super::hints::probe_session_command( socket, - "POST", - "/register", - r#"{"name":"NAME","mcp":"agent-relay-sessions"}"#, + &crate::relay::session_cli::SessionCommand::Register { + name: Some("NAME".into()), + description: None, + mcp: Some(crate::relay::hosted_mcp::SERVER_NAME.into()), + }, + None, )?; let whoami = super::super::hints::probe_session_command( socket, @@ -46,7 +49,7 @@ fn registration_context(socket: &std::path::Path) -> Option { Some(json!({"hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": format!( - "This Codex chat is not registered with Agent Relay Desktop. Hosted Relay tools cannot join or send as this chat until its desktop identity is registered. If the person asks to join or register, use their chosen name; if they have not authorized joining, ask first and do not register automatically. First check {whoami}; if already registered, use that identity. Otherwise replace NAME in {command} with the approved name and run it from this chat's own terminal. The absolute helper path works without a CLI on PATH. This registers only this chat and includes its conversation in the workspace's uploads; it does not change other sessions' sharing. After successful registration, retry hosted join_relay without name or description: the name comes from the verified desktop identity. Normal command approval still applies. Never run this from a separate terminal or substitute another chat ID." + "This Codex chat is not registered with Agent Relay Desktop. Hosted Relay tools cannot join or send as this chat until its desktop identity is registered. If the person asks to join or register, use their chosen name; if they have not authorized joining, ask first and do not register automatically. First check {whoami}; if already registered, use that identity. Otherwise replace NAME in {command} with the approved name and run it from this chat's own terminal. The absolute helper path works without a CLI on PATH. This registers only this chat and includes its conversation in the workspace's uploads; it does not change other sessions' sharing. After successful registration, retry hosted join_relay without name or description: the name comes from the verified desktop identity. If hosted linking is unavailable or refused, keep this locally registered identity and use the local session commands; never join under a new hosted name or use another identity as a fallback. Normal command approval still applies. Never run this from a separate terminal or substitute another chat ID." ) }})) } @@ -360,11 +363,15 @@ mod tests { let context = answer["hookSpecificOutput"]["additionalContext"] .as_str() .unwrap(); - assert!(context.contains("socket-request")); - assert!(context.contains("--path /register")); + assert!(!context.contains("socket-request")); + assert!(context.contains(" register --socket ")); + assert!(context.contains("--name NAME --mcp agent-relay-sessions")); assert!(context.contains("NAME")); assert!(context.contains("ask first")); assert!(context.contains("retry hosted join_relay without name")); + assert!(context + .contains("keep this locally registered identity and use the local session commands")); + assert!(context.contains("never join under a new hosted name")); assert!(outbound .core .store diff --git a/crates/relay-node/src/relay/outbound/hooks/codex_stop/affinity.rs b/crates/relay-node/src/relay/outbound/hooks/codex_stop/affinity.rs index 7410fc2c38..78446f77ad 100644 --- a/crates/relay-node/src/relay/outbound/hooks/codex_stop/affinity.rs +++ b/crates/relay-node/src/relay/outbound/hooks/codex_stop/affinity.rs @@ -292,7 +292,11 @@ async fn consuming_hook_thread_affinity_preserves_registration_guidance() { .codex_stop_hook(pid, outbound.uid, &fields(thread, "PostToolUse")) .await .unwrap(); - assert!(answer.to_string().contains("/register")); + let context = answer["hookSpecificOutput"]["additionalContext"] + .as_str() + .unwrap(); + assert!(context.contains(" register --socket ")); + assert!(!context.contains("socket-request")); assert!(!inbox.needs_registration_guidance(thread)); assert_eq!( outbound diff --git a/crates/relay-node/src/relay/session_cli.rs b/crates/relay-node/src/relay/session_cli.rs index 30b40d0a90..6d63cda789 100644 --- a/crates/relay-node/src/relay/session_cli.rs +++ b/crates/relay-node/src/relay/session_cli.rs @@ -1,5 +1,5 @@ //! The probe's self-limiting session-socket commands: `send`, -//! `agents`, `whoami`, `read`, `fetch-attachment`, +//! `agents`, `whoami`, `register`, `read`, `fetch-attachment`, //! `connect-send` and `subscribe-channel`. //! //! Codex's execpolicy rules match a command by its leading words only, so @@ -44,6 +44,12 @@ pub enum SessionCommand { Agents, /// `whoami`: `GET /whoami`. Whoami, + /// `register`: `POST /register`, identifying only the calling process. + Register { + name: Option, + description: Option, + mcp: Option, + }, /// `read `: `GET /messages?ref=`. Read { message_ref: String }, /// `fetch-attachment `: `POST /attachments/fetch`. @@ -73,10 +79,11 @@ impl SessionRequest { /// The subcommands Codex's sandbox rules allow (`SandboxRules`), each only /// on this core's own session socket. -pub const ALLOWED_SUBCOMMANDS: [&str; 7] = [ +pub const ALLOWED_SUBCOMMANDS: [&str; 8] = [ "send", "agents", "whoami", + "register", "read", "fetch-attachment", "connect-send", @@ -127,6 +134,7 @@ impl SessionCommand { SessionCommand::Send { .. } => "send", SessionCommand::Agents => "agents", SessionCommand::Whoami => "whoami", + SessionCommand::Register { .. } => "register", SessionCommand::Read { .. } => "read", SessionCommand::FetchAttachment { .. } => "fetch-attachment", SessionCommand::ConnectSend { .. } => "connect-send", @@ -160,6 +168,13 @@ impl SessionCommand { method: "GET", path: "/whoami".into(), }), + SessionCommand::Register { .. } => { + self.typed_body()?; + Ok(SessionRequest { + method: "POST", + path: "/register".into(), + }) + } SessionCommand::Read { message_ref } => { if message_ref.len() != 12 || !message_ref.bytes().all(|b| b.is_ascii_hexdigit()) { return Err(refuse( @@ -205,6 +220,44 @@ impl SessionCommand { } } + /// A body constructed solely from this command's typed fields. No + /// caller-supplied JSON, session id, target or process id is accepted: + /// the server identifies the session from the kernel peer PID and UID. + pub fn typed_body(&self) -> Result>> { + let SessionCommand::Register { + name, + description, + mcp, + } = self + else { + return Ok(None); + }; + let mut fields = serde_json::Map::new(); + if let Some(name) = name { + if crate::relay::agents::normalize_name(name).is_none() { + return Err(refuse( + "Use 2–48 lowercase letters, digits and single dashes for --name.", + )); + } + fields.insert("name".into(), name.clone().into()); + } + if let Some(description) = description { + if description.len() > 4096 || description.contains('\0') { + return Err(refuse( + "--description takes at most 4096 bytes, without NUL characters.", + )); + } + fields.insert("description".into(), description.clone().into()); + } + if let Some(mcp) = mcp { + if !crate::relay::hosted_mcp::valid_declaration(mcp) { + return Err(refuse("--mcp takes agent-relay-sessions or none.")); + } + fields.insert("mcp".into(), mcp.clone().into()); + } + Ok(Some(serde_json::to_vec(&fields)?)) + } + /// The arguments after `--socket `, as this command is typed. fn arguments(&self) -> Vec<&str> { match self { @@ -227,6 +280,23 @@ impl SessionCommand { parts } SessionCommand::Agents | SessionCommand::Whoami => Vec::new(), + SessionCommand::Register { + name, + description, + mcp, + } => { + let mut parts = Vec::new(); + if let Some(name) = name { + parts.extend(["--name", name.as_str()]); + } + if let Some(description) = description { + parts.extend(["--description", description.as_str()]); + } + if let Some(mcp) = mcp { + parts.extend(["--mcp", mcp.as_str()]); + } + parts + } SessionCommand::Read { message_ref } => vec![message_ref.as_str()], SessionCommand::FetchAttachment { file_id } => vec![file_id.as_str()], SessionCommand::ConnectSend { to } => match to { @@ -340,7 +410,7 @@ pub fn run(socket: &Path, command: &SessionCommand, body: Option<&str>) -> Resul std::io::stdin().read_to_end(&mut body)?; body } - None => Vec::new(), + None => command.typed_body()?.unwrap_or_default(), }; let timeout = match command { SessionCommand::FetchAttachment { .. } => { @@ -363,6 +433,73 @@ mod tests { } } + #[test] + fn registration_builds_only_typed_json_and_escapes_description() { + let description = "Review \"release\"\nwith --path /settings and {\"target\":\"other\"}"; + let command = SessionCommand::Register { + name: Some("Review-Bot".into()), + description: Some(description.into()), + mcp: Some("none".into()), + }; + assert_eq!( + command.request().unwrap(), + SessionRequest { + method: "POST", + path: "/register".into() + } + ); + assert!( + !command.sends_body(), + "registration never reads arbitrary JSON from stdin" + ); + let body: serde_json::Value = + serde_json::from_slice(&command.typed_body().unwrap().unwrap()).unwrap(); + assert_eq!( + body, + serde_json::json!({"name":"Review-Bot", "description":description, "mcp":"none"}) + ); + let empty = SessionCommand::Register { + name: None, + description: None, + mcp: None, + }; + assert_eq!(empty.typed_body().unwrap().unwrap(), b"{}"); + assert!(run( + Path::new("/does-not-exist"), + &command, + Some("{\"target\":\"other\"}") + ) + .unwrap_err() + .to_string() + .contains("no --body")); + } + + #[test] + fn registration_refuses_invalid_fields_before_socket_access() { + for name in ["", "a", "bad name", "a--b", "../register", "x&target=other"] { + let command = SessionCommand::Register { + name: Some(name.into()), + description: None, + mcp: None, + }; + assert!(command.request().is_err(), "{name:?}"); + } + for description in ["x".repeat(4097), "a\0b".into()] { + let command = SessionCommand::Register { + name: None, + description: Some(description), + mcp: None, + }; + assert!(command.request().is_err()); + } + let command = SessionCommand::Register { + name: None, + description: None, + mcp: Some("other-server".into()), + }; + assert!(command.request().is_err()); + } + #[test] fn each_command_makes_only_its_own_route() { let cases = [ @@ -407,6 +544,15 @@ mod tests { ), (SessionCommand::Agents, "GET", "/agents"), (SessionCommand::Whoami, "GET", "/whoami"), + ( + SessionCommand::Register { + name: None, + description: None, + mcp: None, + }, + "POST", + "/register", + ), ( SessionCommand::Read { message_ref: "0123456789ab".into(), diff --git a/crates/relay-node/tests/relay_outbound.rs b/crates/relay-node/tests/relay_outbound.rs index 72ae36b29a..5f28da897c 100644 --- a/crates/relay-node/tests/relay_outbound.rs +++ b/crates/relay-node/tests/relay_outbound.rs @@ -3075,6 +3075,84 @@ async fn whoami_names_the_caller_registered_or_not() { h.stop().await; } +#[tokio::test] +async fn typed_registration_keeps_peer_identity_and_self_registration_guards() { + use agent_relay_node::relay::session_cli::SessionCommand; + let h = Harness::start().await; + let command = SessionCommand::Register { + name: Some("review-bot".into()), + description: Some("Reviews releases".into()), + mcp: Some("none".into()), + }; + let request = command.request().unwrap(); + let body: Value = serde_json::from_slice(&command.typed_body().unwrap().unwrap()).unwrap(); + assert_eq!( + code( + h.call(OUTSIDE, request.method, &request.path, body.clone()) + .await + ), + (403, "not_a_relay_session".into()) + ); + assert_eq!( + code( + h.call_as( + IN_CODEX, + h.me + 1, + request.method, + &request.path, + body.clone() + ) + .await + ), + (403, "not_this_user".into()) + ); + *h.sessions.2.lock().unwrap() = Ok(vec![Target::new(SessionKind::Codex, CODEX)]); + assert_eq!( + code( + h.call(IN_CODEX, request.method, &request.path, body.clone()) + .await + ), + (403, "not_a_relay_session".into()) + ); + *h.sessions.2.lock().unwrap() = Ok(Vec::new()); + h.core + .set_settings(&serde_json::from_value(json!({"allow_self_registration": false})).unwrap()) + .unwrap(); + assert_eq!( + code( + h.call(IN_CODEX, request.method, &request.path, body.clone()) + .await + ), + (403, "not_allowed".into()) + ); + assert!(h.fake.calls("POST", "/v1/agents").is_empty()); + h.core + .set_settings(&serde_json::from_value(json!({"allow_self_registration": true})).unwrap()) + .unwrap(); + let registered = h + .call(IN_CODEX, request.method, &request.path, body.clone()) + .await + .unwrap(); + assert_eq!(registered["session_id"], CODEX); + assert_eq!(registered["name"], "review-bot"); + let repeated = h + .call(IN_CODEX, request.method, &request.path, body) + .await + .unwrap(); + assert_eq!(repeated["already_registered"], true); + assert_eq!(h.fake.calls("POST", "/v1/agents").len(), 1); + for key in ["target", "pid", "uid", "native_thread_id", "session_id"] { + assert_eq!( + code( + h.call(IN_CLAUDE, "POST", "/register", json!({key: "other"})) + .await + ), + (400, "invalid_request".into()) + ); + } + h.stop().await; +} + #[tokio::test] async fn a_session_registers_itself_once() { let h = Harness::start().await; From e211d4b176197418ce77be2f200ad06e91335b4f Mon Sep 17 00:00:00 2001 From: Miya Date: Mon, 9 Nov 2026 19:23:44 +0100 Subject: [PATCH 2/2] test(core): keep Claude permission count independent of Codex --- .../relay/claude/session_hook/permissions.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/relay-node/src/relay/claude/session_hook/permissions.rs b/crates/relay-node/src/relay/claude/session_hook/permissions.rs index 33b263550e..ef558d76d4 100644 --- a/crates/relay-node/src/relay/claude/session_hook/permissions.rs +++ b/crates/relay-node/src/relay/claude/session_hook/permissions.rs @@ -22,12 +22,12 @@ //! That is: read a message handed to this session, reply (a DM, a channel //! thread or a Relay Connect room), fetch an attachment it was sent, and //! list who it can message and who it is, and join or leave a channel (the -//! SessionStart preamble and a channel post's stop line). These are the same self-limiting commands -//! Codex's sandbox rules allow (`session_cli`): each fixes its own method +//! SessionStart preamble and a channel post's stop line). These are self-limiting +//! session commands (`session_cli`): each fixes its own method //! and route and checks every other argument, so nothing after the `*` -//! reaches another route. Every other session route (`/register`, -//! `PATCH /settings`, `/integrations/subscribe`, …) is reached only through -//! `socket-request`, which no rule allows, so it still asks. +//! reaches another route. Claude's rules do not allow `register` (which has a +//! separate Codex sandbox rule), `settings`, or generic `socket-request`; +//! registration and other settings changes still ask here. //! //! Claude Code matches the text before a rule's `*` as written, after it //! splits compound commands and strips a few wrappers (verified against @@ -333,6 +333,7 @@ mod tests { format!("{probe} agents {on}"), format!("{probe} whoami {on}"), format!("{probe} read {on} 0123456789ab"), + format!("{probe} subscribe-channel {on} swarm"), ] { assert!(allowed(&command), "{command}"); } @@ -357,6 +358,7 @@ mod tests { ); } for command in [ + format!("{probe} register {on} --name review-bot"), format!("{probe} settings {on} auto_join_general=true"), format!("{probe} subscribe {on} acme/api#1"), format!("{probe} agents {on} --body x"), @@ -382,10 +384,9 @@ mod tests { probe: PathBuf::from("/Apps/Bob's Relay/probe"), socket: PathBuf::from("/s/relay.sock"), }; - assert_eq!( - rules.rules().len(), - crate::relay::session_cli::ALLOWED_SUBCOMMANDS.len() - ); + // Claude's seven permissions intentionally exclude Codex-only + // registration. Do not couple this check to Codex's rule count. + assert_eq!(rules.rules().len(), 7); } #[test]