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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `agent-relay-node` can deliver messages to registered VS Code Copilot chats on macOS and resume the same chat while it waits.
- Copilot messages with uncertain delivery stay pending confirmation and are never blindly submitted again.

### Changed

- Codex SessionStart hooks skip the Relay preamble and early auto-activation for multi-chat app-server/IDE sessions and CLI roots holding live subagent locks.

### Fixed

- `relay init`'s Codex hooks keep a subagent from receiving its parent chat's pending messages or registration guidance.
Expand Down
14 changes: 14 additions & 0 deletions crates/relay-node/docs/relay-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,20 @@ the bridge socket is for UIs, the relay-send socket for sessions
session's identity, and a lock file's recorded pid counts only while that
pid still holds the lock. A process inside no supported session is
`not_a_relay_session`; another user's is `not_this_user`.
- **Codex hooks.** PostToolUse and Stop bind a nonempty hook `turn_id` to
the requested thread's active turn in its locally resolved, kernel-held
rollout, alongside the trusted writer and captured file-birth checks.
A missing turn ID, unavailable birth-time evidence, or an unresolved rollout
leaves offers and registration guidance pending; the hook client returns
`{}`. Rollout lookup uses the core's configured `CODEX_HOME` (default
`~/.codex`) and `CODEX_SQLITE_HOME`, so a different home set only on a hook
process is not discovered by this route. SessionStart has no turn ID and
runs only when the trusted writer holds that thread alone. Multi-chat
app-server/IDE sessions and CLI roots with live subagents skip the SessionStart
preamble and early auto-activation; those actions are not replayed later.
A later verified PostToolUse can still supply registration guidance.
These are local context-binding checks, not authentication against a
malicious process running as the same user.
- **Routes.** `POST /send` and `GET /agents` need the caller's registration;
`GET /whoami`, `POST`/`DELETE /register` and the setup routes do not.
Every route except `/whoami` counts against the session's 30 requests a
Expand Down
4 changes: 2 additions & 2 deletions crates/relay-node/src/relay/local/directory_trait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,8 @@ impl SessionDirectory for LocalSessions {
self.config.uid,
thread,
&path,
|| {
codex::rollout::RolloutSummary::read(&path)
|verified_path| {
codex::rollout::RolloutSummary::read(verified_path)
.is_some_and(|summary| summary.active_turn.as_deref() == Some(turn))
},
)
Expand Down
4 changes: 2 additions & 2 deletions crates/relay-node/src/relay/process/codex_hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ pub(crate) fn with_codex_hook_rollout(
uid: u32,
thread: &str,
rollout: &Path,
read: impl FnOnce() -> bool,
read: impl FnOnce(&Path) -> bool,
) -> bool {
let Some(chain) = writer_chain(table, pid, uid, thread) else {
return false;
Expand All @@ -148,7 +148,7 @@ pub(crate) fn with_codex_hook_rollout(
return false;
};
holds_rollout(table, writer.pid, &path, birth)
&& read()
&& read(&path)
&& file_birth_ns(&path) == Some(birth)
&& holds_rollout(table, writer.pid, &path, birth)
&& has_codex_writer_for_caller(table, pid, uid, thread)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Expand Down
196 changes: 154 additions & 42 deletions crates/relay-node/src/relay/process/codex_hook/scaling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};

struct CountingFiles {
files: Vec<OpenedFile>,
after: Option<Vec<OpenedFile>>,
scans: AtomicUsize,
}

Expand All @@ -28,8 +29,12 @@ impl ProcessTable for CountingFiles {
self.files.iter().map(|file| file.path.clone()).collect()
}
fn opened_files(&self, _: i32) -> Vec<OpenedFile> {
self.scans.fetch_add(1, Ordering::Relaxed);
self.files.clone()
let scan = self.scans.fetch_add(1, Ordering::Relaxed);
if scan == 0 {
self.files.clone()
} else {
self.after.as_ref().unwrap_or(&self.files).clone()
}
}
fn cwd(&self, _: i32) -> Option<PathBuf> {
None
Expand All @@ -39,50 +44,59 @@ impl ProcessTable for CountingFiles {
}
}

const PARENT: &str = "550e8400-e29b-41d4-a716-000000000000";
const CHILD: &str = "550e8400-e29b-41d4-a716-000000000001";

/// Build real rollout headers with captured file births for the snapshot tests.
fn fixture(count: usize) -> anyhow::Result<(tempfile::TempDir, CountingFiles)> {
let temp = tempfile::tempdir()?;

let mut files = Vec::new();
for index in 0..count {
let thread = format!("550e8400-e29b-41d4-a716-{index:012}");
let path = temp.path().join(format!("rollout-fixture-{thread}.jsonl"));
let payload = if index == 1 {
serde_json::json!({"id":thread,"thread_source":"subagent",
"parent_thread_id":PARENT,
"source":{"subagent":{"thread_spawn":{"parent_thread_id":PARENT}}}})
} else {
serde_json::json!({"id":thread})
};
std::fs::write(
&path,
format!(
"{}\n",
serde_json::json!({
"type":"session_meta","payload":payload
})
),
)?;
files.push(OpenedFile {
birth_ns: file_birth_ns(&path),
path,
});
files.push(OpenedFile {
path: temp
.path()
.join("thread-writer-locks")
.join(format!("{thread}.lock")),
birth_ns: None,
});
}
let table = CountingFiles {
files,
after: None,
scans: AtomicUsize::new(0),
};
Ok((temp, table))
}

#[test]
fn hook_rollout_file_scan_count_is_bounded() -> anyhow::Result<()> {
let mut counts = Vec::new();
for count in [2, 12] {
let temp = tempfile::tempdir()?;
let parent = "550e8400-e29b-41d4-a716-000000000000";
let child = "550e8400-e29b-41d4-a716-000000000001";
let mut files = Vec::new();
for index in 0..count {
let thread = format!("550e8400-e29b-41d4-a716-{index:012}");
let path = temp.path().join(format!("rollout-fixture-{thread}.jsonl"));
let payload = if index == 1 {
serde_json::json!({"id":thread,"thread_source":"subagent",
"parent_thread_id":parent,
"source":{"subagent":{"thread_spawn":{"parent_thread_id":parent}}}})
} else {
serde_json::json!({"id":thread})
};
std::fs::write(
&path,
format!(
"{}\n",
serde_json::json!({
"type":"session_meta","payload":payload
})
),
)?;
files.push(OpenedFile {
birth_ns: file_birth_ns(&path),
path,
});
files.push(OpenedFile {
path: temp
.path()
.join("thread-writer-locks")
.join(format!("{thread}.lock")),
birth_ns: None,
});
}
let table = CountingFiles {
files,
scans: AtomicUsize::new(0),
};
assert!(held_descendant_of(&table, 10, child, parent));
let (_temp, table) = fixture(count)?;
assert!(held_descendant_of(&table, 10, CHILD, PARENT));
counts.push(table.scans.load(Ordering::Relaxed));
}
assert_eq!(
Expand All @@ -92,3 +106,101 @@ fn hook_rollout_file_scan_count_is_bounded() -> anyhow::Result<()> {
);
Ok(())
}

#[test]
fn hook_rollout_second_snapshot_changes_deny() -> anyhow::Result<()> {
for change in ["parent_lock", "rollout", "birth"] {
let (_temp, mut table) = fixture(2)?;
let mut after = table.files.clone();
match change {
"parent_lock" => {
after.retain(|file| codex_thread_of_lock(&file.path).as_deref() != Some(PARENT))
}
"rollout" => {
after.retain(|file| rollout_thread_of_path(&file.path).as_deref() != Some(CHILD))
}
_ => {
for file in &mut after {
if rollout_thread_of_path(&file.path).as_deref() == Some(PARENT) {
file.birth_ns = file.birth_ns.map(|birth| birth + 1);
}
}
}
}
table.after = Some(after);
assert!(!held_descendant_of(&table, 10, CHILD, PARENT), "{change}");
assert_eq!(table.scans.load(Ordering::Relaxed), 2, "{change}");
}
Ok(())
}

#[cfg(unix)]
#[test]
fn hook_rollout_reader_uses_verified_canonical_path() -> anyhow::Result<()> {
use crate::relay::codex::rollout::RolloutSummary;
let temp = tempfile::tempdir()?;
let rollout = temp.path().join(format!("rollout-fixture-{PARENT}.jsonl"));
let other = temp.path().join("other.jsonl");
let alias = temp.path().join("alias.jsonl");
for (path, turn) in [(&rollout, "verified-turn"), (&other, "other-turn")] {
std::fs::write(
path,
format!(
"{}\n",
serde_json::json!({
"type":"event_msg","payload":{"type":"task_started","turn_id":turn}
})
),
)?;
}
std::os::unix::fs::symlink(&rollout, &alias)?;
let canonical = rollout.canonicalize()?;
let table = FakeProcesses::default();
let executable = temp.path().join("codex");
table.trust(&executable);
table.insert(
10,
FakeProcess {
parent: 1,
uid: 501,
start: 10,
executable: Some(executable),
arguments: vec!["codex".into(), "app-server".into()],
open_files: vec![
rollout,
temp.path()
.join("thread-writer-locks")
.join(format!("{PARENT}.lock")),
],
..FakeProcess::default()
},
);
table.insert(
20,
FakeProcess {
parent: 10,
uid: 501,
start: 20,
..FakeProcess::default()
},
);
assert!(with_codex_hook_rollout(
&table,
20,
501,
PARENT,
&alias,
|verified_path| {
assert_eq!(verified_path, canonical);
std::fs::remove_file(&alias).unwrap();
std::os::unix::fs::symlink(&other, &alias).unwrap();
assert_eq!(
RolloutSummary::read(&alias).unwrap().active_turn.as_deref(),
Some("other-turn")
);
RolloutSummary::read(verified_path)
.is_some_and(|summary| summary.active_turn.as_deref() == Some("verified-turn"))
}
));
Ok(())
}
15 changes: 15 additions & 0 deletions tests/relayflows/cases/codex-hook-canonical-read/case.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"version": 1,
"id": "codex-hook-canonical-read",
"kind": "bugfix",
"title": "Read the verified canonical Codex rollout after an alias changes",
"runner": {
"command": ["node", "tests/relayflows/cases/codex-hook-canonical-read/run.mjs"]
},
"requirements": [],
"timeoutSeconds": 900,
"expected": {
"base": { "outcome": "bug", "signature": "rollout_reader_follows_retargeted_alias" },
"head": { "outcome": "fixed", "signature": "rollout_reader_uses_verified_canonical_path" }
}
}
Loading
Loading