Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `agent-relay-node register --socket <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.

Expand Down
16 changes: 16 additions & 0 deletions crates/relay-node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 58 additions & 1 deletion crates/relay-node/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down Expand Up @@ -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<String> = 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<String> = 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<String> = 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(
Expand Down Expand Up @@ -301,7 +359,6 @@ mod sandbox_rule_reach_tests {
("DELETE", "/webhooks"),
("POST", "/claude/attestation"),
("POST", "/copilot/mcp"),
("POST", "/register"),
("DELETE", "/register"),
] {
assert!(
Expand Down
34 changes: 33 additions & 1 deletion crates/relay-node/src/cli/relay_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Optional description (at most 4096 bytes).
#[arg(long)]
description: Option<String>,
/// Hosted MCP availability, without changing the session identity.
#[arg(long, value_parser = ["agent-relay-sessions", "none"])]
mcp: Option<String>,
},
/// Fetch an attachment the calling session was sent but did not get in
/// time (`POST /attachments/fetch`).
FetchAttachment {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
19 changes: 10 additions & 9 deletions crates/relay-node/src/relay/claude/session_hook/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}");
}
Expand All @@ -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"),
Expand All @@ -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]
Expand Down
26 changes: 24 additions & 2 deletions crates/relay-node/src/relay/codex/stop_hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions crates/relay-node/src/relay/codex/stop_hook/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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",
Expand Down
21 changes: 14 additions & 7 deletions crates/relay-node/src/relay/outbound/hooks/codex_stop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,14 @@ const JOIN_POLL: Duration = Duration::from_millis(250);
mod affinity;

fn registration_context(socket: &std::path::Path) -> Option<Value> {
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,
Expand All @@ -46,7 +49,7 @@ fn registration_context(socket: &std::path::Path) -> Option<Value> {
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."
)
}}))
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading