diff --git a/CHANGELOG.md b/CHANGELOG.md index 602818fab..626d065ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `relay node up` now reports the actual workspace-key source when startup fails, including the repository's `.agentworkforce/relay/workspace-key.json` pin instead of mislabeling every key as `AGENT_RELAY_WORKSPACE_KEY`. - Codex sessions set up by `relay init` now read and answer relayed messages inside Codex's default sandbox with no approval prompt: the Codex hooks add `~/.codex/rules/agent-relay.rules`, which lets only the Agent Relay core's own socket client reach its session socket (no network access is granted), and reply commands are a single `agent-relay-probe relay socket-request … --body` call. - Injected reply instructions now tell agents to answer only through this computer's Agent Relay route and never through another Agent Relay connector or app (such as Agent Relay in ChatGPT), which sends as a different identity. +- Claude Code sessions set up by `relay init` now read and answer relayed messages in Claude's default permission mode with no approval prompt: the SessionStart hook adds `permissions.allow` rules to `~/.claude/settings.json` that allow only the Agent Relay core's own read, reply, attachment-fetch and peer-list commands on its session socket, and Claude's reply hints are that single command instead of curl. The rules go when the hook is turned off; your own rules stay. +- `relay init --uninstall` now removes a `~/.claude/settings.json` it created (or restores yours byte for byte) and the OpenCode plugin's `agent-relay.js.off` marker when init installed the plugin. - `relay init --uninstall` now leaves `~/.codex` as it found it: it removes a `hooks.json` it created (or restores yours byte for byte), its `.relay-desktop-backup`, the Codex hook-trust entries it added to `config.toml`, and its sandbox rules file. Your own hooks, trust entries and rules are kept. ### Security diff --git a/crates/probe/docs/relay-core.md b/crates/probe/docs/relay-core.md index 4d326750a..08c13f778 100644 --- a/crates/probe/docs/relay-core.md +++ b/crates/probe/docs/relay-core.md @@ -521,7 +521,7 @@ uploads already; an install a newer probe runs is left alone. | `agents.webhook.revoke` | `{"name"}` | `{"already_deleted"}` | 2a | | `agents.integrations.subscribe` | `{"name","provider","resource"}` | `{"subscribed":true,"provider","resource","path_glob"}` | 2a | | `agents.integrations.unsubscribe` / `.clean_up` | `{"name","provider","resource"}` | `{"subscribed":false,"provider","resource","already_unsubscribed"?}`; also finishes an unfinished subscription | 2a | -| `settings.get` / `settings.set` | `{"allow_self_registration"?, "auto_activate"?, "claude_session_hook"?, "direct_delivery"?, "direct_delivery_default"?, "devin_direct_delivery"?, "pi_extension"?, "pi_auto_activate"?, "opencode_auto_activate"?, "analytics_enabled"?, "contractor_access"?}` | All settings, plus the read-only `direct_delivery_managed` (the organization's `crossSessionInbound`, absent when it sets none); `direct_delivery_default` is write-only: `true` sets `accept` unless the user's own Claude settings already name `crossSessionInbound`, `false` changes nothing; `claude_session_hook` is off unless turned on and absent when no Claude login directory exists (the SessionStart hook never registers a session); `devin_direct_delivery` adds or removes Agent Relay's lifecycle hooks in `~/.config/devin/config.json`, on by default where Devin has run, and absent where it has not; `pi_extension` installs or removes Agent Relay's extension in `~/.pi/agent/extensions` (only its own file), installed at start by a desktop core unless turned off and only on request by a `--cli-managed` one; while it is off no Pi session is listed and new messages to Pi are refused, even for a session that loaded the extension earlier, though a message already queued before it was turned off stays accepted and that loaded extension can still deliver it; absent where Pi has not run; `pi_auto_activate` and `opencode_auto_activate` (off unless turned on; `relay init` turns on the one for each tool chosen) let an open Pi or OpenCode session join by itself while `auto_activate` is on, which alone never activates those kinds; a setting the core runs without is `invalid_params`, and so are an unknown or read-only key and a non-boolean, which change nothing | 2a (store settings), 2b (`direct_delivery`, `claude_session_hook`, `devin_direct_delivery`), 2c (`analytics_enabled`, `contractor_access`) | +| `settings.get` / `settings.set` | `{"allow_self_registration"?, "auto_activate"?, "claude_session_hook"?, "direct_delivery"?, "direct_delivery_default"?, "devin_direct_delivery"?, "pi_extension"?, "pi_auto_activate"?, "opencode_auto_activate"?, "analytics_enabled"?, "contractor_access"?}` | All settings, plus the read-only `direct_delivery_managed` (the organization's `crossSessionInbound`, absent when it sets none); `direct_delivery_default` is write-only: `true` sets `accept` unless the user's own Claude settings already name `crossSessionInbound`, `false` changes nothing; `claude_session_hook` is off unless turned on and absent when no Claude login directory exists (the SessionStart hook never registers a session; with it come Claude `permissions.allow` rules for this probe on the session socket: `relay read`, and `relay socket-request` only for `POST /send?…`, `POST /connect/send?…`, `POST /attachments/fetch?file_id=…` and `GET /agents`, recorded in `claude-permission-rules.json` and taken out with it); `devin_direct_delivery` adds or removes Agent Relay's lifecycle hooks in `~/.config/devin/config.json`, on by default where Devin has run, and absent where it has not; `pi_extension` installs or removes Agent Relay's extension in `~/.pi/agent/extensions` (only its own file), installed at start by a desktop core unless turned off and only on request by a `--cli-managed` one; while it is off no Pi session is listed and new messages to Pi are refused, even for a session that loaded the extension earlier, though a message already queued before it was turned off stays accepted and that loaded extension can still deliver it; absent where Pi has not run; `pi_auto_activate` and `opencode_auto_activate` (off unless turned on; `relay init` turns on the one for each tool chosen) let an open Pi or OpenCode session join by itself while `auto_activate` is on, which alone never activates those kinds; a setting the core runs without is `invalid_params`, and so are an unknown or read-only key and a non-boolean, which change nothing | 2a (store settings), 2b (`direct_delivery`, `claude_session_hook`, `devin_direct_delivery`), 2c (`analytics_enabled`, `contractor_access`) | | `activity.list` | `{"limit"?}` | `{"activity":[{"at_ms","agent","sender","preview","outcome":"injected"\|"skipped"\|"failed"\|"waiting"\|"in_doubt"\|"answered","detail","settlement":"handed_off"\|"waiting_for_turn"\|"visible"\|null}]}`, newest first; `settlement` is set on entries that place the message in the session (a queue handoff, a handoff behind the session's running turn, or the message recorded in the session) and includes Relay Connect deliveries under the participant's name | 2a | | `claude_delivery.status` | — | `{"restricted","sessions":[{"id","agent","title","direct"}],"allowed_senders":[{"address","name"}],"held":[{"id","agent","session_id","sender","sender_name","text","held_at_ms"}]}` | 2b | | `claude_delivery.restrict` / `.session` / `.sender` | `{"restricted"}` / `{"session_id","direct"}` / `{"sender","name","allowed"}` | `{}`; controls allowed-member enforcement, per-session Ask first, and the exact sender allowlist | 2b | diff --git a/crates/probe/src/relay/claude/session_hook.rs b/crates/probe/src/relay/claude/session_hook.rs index 06bbbfe9f..95a054dc1 100644 --- a/crates/probe/src/relay/claude/session_hook.rs +++ b/crates/probe/src/relay/claude/session_hook.rs @@ -22,6 +22,15 @@ //! logins (`login_settings`). Turning it off, and a refresh while it is on, //! also clear it from a watched login that has since been unshared //! (`watched_logins`). A directory the desktop does not watch is left alone. +//! +//! With the hook come Claude Code permission rules ([`PermissionRules`], +//! in `permissions`) that let the session run this probe's `relay read` and +//! `relay socket-request` on this core's session socket without an approval +//! prompt, so it can read and answer a relayed message in Claude's default +//! permission mode. They go into and out of the same `settings.json` with +//! the hook. Taking the hook out leaves the file as the person had it: their +//! own copy (`.relay-desktop-backup`) goes back byte for byte when nothing +//! else changed, and a file Agent Relay created goes once it is empty. use crate::relay::agents::SettingsExtension; use crate::relay::claude::ClaudeConfig; @@ -34,6 +43,17 @@ use serde_json::{json, Map, Value}; use std::path::{Path, PathBuf}; use std::sync::Mutex; +#[cfg(test)] +mod permission_tests; +mod permissions; +#[cfg(test)] +pub use permissions::covers; +pub use permissions::PermissionRules; + +/// Appended to a settings file's name for the one-time copy of the person's +/// own file. +const BACKUP_SUFFIX: &str = ".relay-desktop-backup"; + /// The `settings.set` key this extension owns. pub const SETTING: &str = "claude_session_hook"; @@ -332,6 +352,9 @@ fn restore_written( pub struct ClaudeSessionHookSettings { claude: ClaudeSettings, command: String, + /// The permission rules that come with the hook, and the core's data + /// folder, which records the rules Agent Relay added. + permissions: Option<(PermissionRules, PathBuf)>, lock: Mutex<()>, } @@ -340,10 +363,41 @@ impl ClaudeSessionHookSettings { ClaudeSessionHookSettings { claude: ClaudeSettings::new(claude), command, + permissions: None, lock: Mutex::new(()), } } + /// Also writes [`PermissionRules`] while the hook is on, recording the + /// ones it adds in `state_dir` (the core's data folder). + pub fn with_permission_rules(mut self, rules: PermissionRules, state_dir: PathBuf) -> Self { + self.permissions = Some((rules, state_dir)); + self + } + + /// The permission rules wanted with the hook; none without them. + fn rules(&self) -> Vec { + self.permissions + .as_ref() + .map(|(rules, _)| rules.rules()) + .unwrap_or_default() + } + + /// The rules Agent Relay added, by file. + fn added(&self) -> permissions::Added { + self.permissions + .as_ref() + .map(|(_, dir)| permissions::Added::load(dir)) + .unwrap_or_default() + } + + fn save_added(&self, added: &permissions::Added) -> Result<(), String> { + match &self.permissions { + Some((_, dir)) => added.save(dir), + None => Ok(()), + } + } + /// The shell command for `executable`, with `--dev` for Agent Relay Dev. pub fn command_for(executable: &Path, development: bool) -> String { let mut command = format!( @@ -439,6 +493,9 @@ impl ClaudeSessionHookSettings { } } + /// Writes `value`, the first time copying the file as it was beside it + /// (`.relay-desktop-backup`). A file that already holds the hook is + /// never the person's own copy. fn write(&self, path: &Path, value: &Value) -> Result<(), String> { let path = resolve_symlinks(path); let existing = match std::fs::read(&path) { @@ -446,16 +503,34 @@ impl ClaudeSessionHookSettings { Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(_) => return Err(Self::fail(&path, "cannot be read")), }; + let existing = existing.filter(|bytes| { + !serde_json::from_slice::(bytes).is_ok_and(|value| self.holds_ours(&value)) + }); let mut bytes = serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?; bytes.push(b'\n'); - write_keeping_file( - &path, - existing.as_deref(), - &bytes, - ".relay-desktop-backup", - 0o600, - ) - .map_err(|_| Self::fail(&path, "cannot be written")) + write_keeping_file(&path, existing.as_deref(), &bytes, BACKUP_SUFFIX, 0o600) + .map_err(|_| Self::fail(&path, "cannot be written")) + } + + /// Whether `value` holds this build's hook. Its permission rules come + /// and go with it; a rule alone may be the person's own. + fn holds_ours(&self, value: &Value) -> bool { + value["hooks"]["SessionStart"] + .as_array() + .is_some_and(|groups| !self.our_commands(groups).is_empty()) + } + + /// `value` with this build's hook and its permission rules (the current + /// ones and `added`) taken out. + fn without_ours(&self, value: &Value, added: &[String]) -> Value { + let Some(mut object) = value.as_object().cloned() else { + return value.clone(); + }; + self.remove_from(&mut object); + let mut rules = self.rules(); + rules.extend(added.iter().cloned()); + permissions::take_out(&mut object, &rules); + Value::Object(object) } /// This build's hook, in any version of the executable. Agent Relay and @@ -590,6 +665,9 @@ impl ClaudeSessionHookSettings { if targets.is_empty() { return Err("No Claude Code login was found, so the hook was not changed.".into()); } + let rules = self.rules(); + let added_before = self.added(); + let mut added = added_before.clone(); let mut planned = Vec::new(); for path in targets { let shared = install.contains(&path); @@ -601,20 +679,49 @@ impl ClaudeSessionHookSettings { let before = Value::Object(object.clone()); if enable && shared { self.install_into(&path, &mut object)?; + if self.permissions.is_some() { + let owned = permissions::add(&mut object, &rules, added_before.of(&path)) + .map_err(|why| Self::fail(&path, why))?; + added.set(&path, owned); + } } else { self.remove_from(&mut object); + permissions::take_out(&mut object, added_before.of(&path)); + added.set(&path, Vec::new()); } let after = Value::Object(object); if after != before { planned.push((path, after)); } } - store_planned( + // While the files are written, the record names every rule that may + // be in them, so none Agent Relay added is ever left unrecorded. + if added != added_before { + self.save_added(&added_before.union(&added))?; + } + // Copies of the person's files go only once every login is written: + // a later login that fails rolls the earlier ones back, and they + // still need their copy. + let mut spent = Vec::new(); + let stored = store_planned( &planned, |path| self.snapshot(path), - |path, value| self.store(path, value), + |path, value| self.store(path, value, added_before.of(path), &mut spent), |path, previous| self.restore(path, previous), - ) + ); + if stored.is_ok() { + for backup in spent { + let _ = std::fs::remove_file(backup); + } + } + if added != added_before { + let _ = self.save_added(if stored.is_ok() { + &added + } else { + &added_before + }); + } + stored } /// Bytes of the resolved file before this edit changes it. `None` when @@ -633,14 +740,8 @@ impl ClaudeSessionHookSettings { match previous { Some(bytes) => { let existing = std::fs::read(&path).ok(); - write_keeping_file( - &path, - existing.as_deref(), - bytes, - ".relay-desktop-backup", - 0o600, - ) - .map_err(|_| Self::fail(&path, "cannot be restored")) + write_keeping_file(&path, existing.as_deref(), bytes, BACKUP_SUFFIX, 0o600) + .map_err(|_| Self::fail(&path, "cannot be restored")) } None => match std::fs::remove_file(&path) { Ok(()) => Ok(()), @@ -650,10 +751,60 @@ impl ClaudeSessionHookSettings { } } - /// Writes `value`. An empty object is written back as `{}` rather than - /// deleted: the file may have been there before the hook, and deleting - /// a symlink's target would leave the link dangling. - fn store(&self, path: &Path, value: &Value) -> Result<(), String> { + /// Writes `value`. Once the hook and its rules are out, the file goes + /// back to how the person had it: their own copy (`.relay-desktop-backup`) is put back byte for byte when the + /// file differs from it in nothing they set, and a file Agent Relay + /// created (no copy) is removed once it is an empty object. A file the + /// person changed since keeps their change, and the copy is kept. A + /// file reached through a link is never removed: that would leave the + /// link dangling. A copy no longer needed is added to `spent`, for the + /// edit to remove once every login is written. + fn store( + &self, + path: &Path, + value: &Value, + added: &[String], + spent: &mut Vec, + ) -> Result<(), String> { + if self.holds_ours(value) { + return self.write(path, value); + } + let resolved = resolve_symlinks(path); + let mut backup = resolved.clone().into_os_string(); + backup.push(BACKUP_SUFFIX); + let backup = PathBuf::from(backup); + let mut keep_file = false; + if let Ok(original) = std::fs::read(&backup) { + let normal = permissions::without_empty(value); + match serde_json::from_slice::(&original) { + Ok(saved) + if !self.holds_ours(&saved) && permissions::without_empty(&saved) == normal => + { + write_keeping_file(&resolved, None, &original, BACKUP_SUFFIX, 0o600) + .map_err(|_| Self::fail(&resolved, "cannot be restored"))?; + spent.push(backup); + return Ok(()); + } + // An earlier build copied a file that already held the hook: + // the copy has nothing the file does not. + Ok(saved) + if self.holds_ours(&saved) + && permissions::without_empty(&self.without_ours(&saved, added)) + == normal => + { + spent.push(backup.clone()); + } + _ => keep_file = true, + } + } + let linked = std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()); + if !keep_file && !linked && value.as_object().is_some_and(Map::is_empty) { + return match std::fs::remove_file(&resolved) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(Self::fail(&resolved, "cannot be removed")), + }; + } self.write(path, value) } @@ -1054,11 +1205,18 @@ mod tests { removed["hooks"]["Stop"][0]["hooks"][0]["command"], "echo hi" ); + assert!( + !root.path().join(".claude-work/settings.json").exists(), + "a settings.json the hook created goes once it is empty" + ); + // The person's own file came back byte for byte, and its copy went. assert_eq!( - read(&root.path().join(".claude-work/settings.json")), - json!({}), - "a settings.json the hook created is left as an empty object, not deleted" + std::fs::read(&primary).unwrap(), + b"{\"crossSessionInbound\":\"hold\",\"hooks\":{\"Stop\":[{\"hooks\":[{\"type\":\"command\",\"command\":\"echo hi\"}]}]}}\n" ); + assert!(!primary + .with_file_name("settings.json.relay-desktop-backup") + .exists()); assert_eq!(settings.get()[SETTING], false); let kept = std::fs::read(&primary).unwrap(); assert_eq!(settings.set(SETTING, &json!(false)), Some(Ok(()))); @@ -1246,7 +1404,7 @@ mod tests { &planned, |path| settings.snapshot(path), |path, value| { - let stored = settings.store(path, value); + let stored = settings.store(path, value, &[], &mut Vec::new()); if stored.is_ok() && path == first { let mut perms = std::fs::metadata(&second).unwrap().permissions(); perms.set_mode(0o000); @@ -1366,13 +1524,14 @@ mod tests { ); settings.refresh(); assert!( - read(&work).get("hooks").is_none(), + !work.exists(), "refresh takes the hook out of a folder that is no longer shared" ); assert_eq!(command(&read(&primary)).len(), 1); assert_eq!(settings.set(SETTING, &json!(false)), Some(Ok(()))); - assert!(read(&primary).get("hooks").is_none()); - assert!(read(&work).get("hooks").is_none()); + // Both files were the hook's own: gone once it is out. + assert!(!primary.exists()); + assert!(!work.exists()); assert_eq!(settings.get()[SETTING], false); } @@ -1458,7 +1617,10 @@ mod tests { assert_eq!(command(&read(&primary)).len(), 1); assert_eq!(std::fs::read(&work).unwrap(), b"not json"); assert_eq!(settings.set(SETTING, &json!(false)), Some(Ok(()))); - assert!(read(&primary).get("hooks").is_none()); + assert!( + !primary.exists(), + "the hook's own file goes once it is empty" + ); assert_eq!(std::fs::read(&work).unwrap(), b"not json"); assert_eq!(settings.get()[SETTING], false); } diff --git a/crates/probe/src/relay/claude/session_hook/permission_tests.rs b/crates/probe/src/relay/claude/session_hook/permission_tests.rs new file mode 100644 index 000000000..d3f24807e --- /dev/null +++ b/crates/probe/src/relay/claude/session_hook/permission_tests.rs @@ -0,0 +1,249 @@ +//! The hook's permission rules going into and out of a Claude login's +//! `settings.json` (`permissions`). + +use super::*; +use crate::relay::claude_dirs::ClaudeDirs; +use serde_json::json; + +const PROBE: &str = "/tmp/arc/relay/bin/agent-relay-probe"; +const SOCKET: &str = "/Users/me/Library/Application Support/com.agentrelay.desktop/run/relay.sock"; + +fn rules() -> PermissionRules { + PermissionRules { + probe: PathBuf::from(PROBE), + socket: PathBuf::from(SOCKET), + } +} + +fn settings_with(root: &Path, rules: PermissionRules) -> ClaudeSessionHookSettings { + let config = ClaudeConfig { + home: root.join(".claude"), + executable: None, + app_sessions: None, + dirs: ClaudeDirs::default(), + }; + ClaudeSessionHookSettings::new( + &config, + ClaudeSessionHookSettings::command_for(Path::new(PROBE), false), + ) + .with_permission_rules(rules, root.join("app-data")) +} + +fn settings(root: &Path) -> ClaudeSessionHookSettings { + settings_with(root, rules()) +} + +fn login(root: &Path) -> PathBuf { + let dir = root.join(".claude"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(root.join(".claude.json"), "{}\n").unwrap(); + dir.join("settings.json") +} + +fn read(path: &Path) -> Value { + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() +} + +fn allow(path: &Path) -> Vec { + read(path)["permissions"]["allow"] + .as_array() + .into_iter() + .flatten() + .filter_map(|rule| rule.as_str().map(str::to_string)) + .collect() +} + +/// Every file under `dir`, relative, with its bytes. +fn tree(dir: &Path) -> Vec<(String, Vec)> { + let mut files = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(next) = stack.pop() { + for entry in std::fs::read_dir(&next).unwrap().flatten() { + let path = entry.path(); + let name = path.strip_prefix(dir).unwrap().display().to_string(); + if path.is_dir() { + files.push((format!("{name}/"), Vec::new())); + stack.push(path); + } else { + files.push((name, std::fs::read(&path).unwrap())); + } + } + } + files.sort(); + files +} + +// The relay init end-to-end run on 2026-10-29: Claude Code asked to approve +// both the pointer's read and the reply. +#[test] +fn the_hook_brings_the_rules_for_this_probes_read_and_reply() { + let root = tempfile::tempdir().unwrap(); + let path = login(root.path()); + let settings = settings(root.path()); + assert_eq!(settings.set(SETTING, &json!(true)), Some(Ok(()))); + let all = allow(&path); + assert_eq!(all, rules().rules()); + assert_eq!( + all[0], + format!("Bash({PROBE} relay read --socket '{SOCKET}' *)") + ); + assert_eq!( + all[1], + format!( + "Bash({PROBE} relay socket-request --socket '{SOCKET}' --method POST --path '/send?*)" + ) + ); + // Recorded as Agent Relay's, by file. + let added = permissions::Added::load(&root.path().join("app-data")); + assert_eq!(added.of(&path), rules().rules().as_slice()); + // On again: nothing is rewritten. + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(settings.set(SETTING, &json!(true)), Some(Ok(()))); + assert_eq!(std::fs::read(&path).unwrap(), bytes); +} + +// The same run: --uninstall left `{}` behind in an isolated ~/.claude. +#[test] +fn a_fresh_install_taken_out_again_leaves_the_claude_folder_as_it_was() { + let root = tempfile::tempdir().unwrap(); + let path = login(root.path()); + let claude = path.parent().unwrap().to_path_buf(); + let before = tree(&claude); + let settings = settings(root.path()); + assert_eq!(settings.set(SETTING, &json!(true)), Some(Ok(()))); + assert!(path.is_file()); + settings.refresh(); + assert_eq!(settings.set(SETTING, &json!(false)), Some(Ok(()))); + assert_eq!(tree(&claude), before); + assert!(!root + .path() + .join("app-data") + .join(permissions::RECORD) + .exists()); +} + +#[test] +fn the_persons_own_settings_and_rules_come_back_byte_for_byte() { + let root = tempfile::tempdir().unwrap(); + let path = login(root.path()); + // Their own formatting, their own rules (one the same as Agent Relay's), + // and an empty list a JSON round trip would keep but not as written. + let mine = format!( + "{{ \"model\": \"opus\",\n \"permissions\": {{ \"allow\": [\"Bash(npm test *)\", \"Bash({PROBE} relay read --socket '{SOCKET}' *)\"], \"deny\": [] }},\n \"hooks\": {{ \"Stop\": [ {{\"hooks\": [{{\"type\":\"command\",\"command\":\"notify.sh\"}}]}} ] }} }}" + ); + std::fs::write(&path, &mine).unwrap(); + let claude = path.parent().unwrap().to_path_buf(); + let before = tree(&claude); + let settings = settings(root.path()); + + assert_eq!(settings.set(SETTING, &json!(true)), Some(Ok(()))); + let installed = allow(&path); + assert_eq!(installed.len(), 1 + rules().rules().len(), "{installed:?}"); + assert_eq!(installed[0], "Bash(npm test *)"); + // Only the rule that was not already theirs is Agent Relay's. + let added = permissions::Added::load(&root.path().join("app-data")); + assert_eq!(added.of(&path), &rules().rules()[1..]); + + assert_eq!(settings.set(SETTING, &json!(false)), Some(Ok(()))); + assert_eq!(tree(&claude), before); +} + +#[test] +fn a_file_the_person_changed_since_keeps_their_change_and_their_rules() { + let root = tempfile::tempdir().unwrap(); + let path = login(root.path()); + std::fs::write(&path, "{\"permissions\":{\"allow\":[\"Bash(ls *)\"]}}\n").unwrap(); + let settings = settings(root.path()); + assert_eq!(settings.set(SETTING, &json!(true)), Some(Ok(()))); + let mut value = read(&path); + value["permissions"]["allow"] + .as_array_mut() + .unwrap() + .push(json!("Bash(git status)")); + value["model"] = json!("sonnet"); + std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); + + assert_eq!(settings.set(SETTING, &json!(false)), Some(Ok(()))); + assert_eq!( + read(&path), + json!({"permissions": {"allow": ["Bash(ls *)", "Bash(git status)"]}, "model": "sonnet"}) + ); + // Their copy from before is kept: the file is not as it was. + assert!(path + .with_file_name("settings.json.relay-desktop-backup") + .exists()); +} + +#[test] +fn a_moved_probe_or_socket_replaces_the_rules_agent_relay_added() { + let root = tempfile::tempdir().unwrap(); + let path = login(root.path()); + let old = settings_with( + root.path(), + PermissionRules { + probe: PathBuf::from("/old/agent-relay-probe"), + socket: PathBuf::from("/old/relay.sock"), + }, + ); + assert_eq!(old.set(SETTING, &json!(true)), Some(Ok(()))); + let settings = settings(root.path()); + settings.refresh(); + assert_eq!(allow(&path), rules().rules()); + assert_eq!(settings.set(SETTING, &json!(false)), Some(Ok(()))); + assert!(!path.exists()); +} + +#[test] +fn a_permissions_value_of_another_shape_leaves_the_file_unchanged() { + let root = tempfile::tempdir().unwrap(); + let path = login(root.path()); + std::fs::write(&path, "{\"permissions\":[\"x\"]}\n").unwrap(); + let error = settings(root.path()) + .set(SETTING, &json!(true)) + .unwrap() + .unwrap_err(); + assert!(error.contains("permissions"), "{error}"); + assert_eq!( + std::fs::read(&path).unwrap(), + b"{\"permissions\":[\"x\"]}\n" + ); +} + +// Review on #2021: a later login that fails while the hook is taken out +// rolls the earlier ones back, and those keep their copy for the retry. +#[test] +#[cfg(unix)] +fn a_failed_removal_keeps_each_logins_copy_for_the_retry() { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::tempdir().unwrap(); + let primary = login(root.path()); + std::fs::write(&primary, "{}\n").unwrap(); + let work = root.path().join(".claude-work"); + std::fs::create_dir_all(&work).unwrap(); + std::fs::write(work.join(".claude.json"), "{}\n").unwrap(); + let settings = settings(root.path()); + assert_eq!(settings.set(SETTING, &json!(true)), Some(Ok(()))); + assert!(work.join("settings.json").exists()); + let backup = primary.with_file_name("settings.json.relay-desktop-backup"); + assert!(backup.exists()); + + let mode = |mode: u32| { + let mut permissions = std::fs::metadata(&work).unwrap().permissions(); + permissions.set_mode(mode); + std::fs::set_permissions(&work, permissions).unwrap(); + }; + mode(0o555); + if std::fs::File::create(work.join(".probe-write")).is_ok() { + mode(0o755); + return; // Running as root: the folder cannot be made unwritable. + } + let failed = settings.set(SETTING, &json!(false)).unwrap(); + mode(0o755); + assert!(failed.is_err(), "{failed:?}"); + assert!(backup.exists(), "the rolled-back login keeps its copy"); + + assert_eq!(settings.set(SETTING, &json!(false)), Some(Ok(()))); + assert_eq!(std::fs::read(&primary).unwrap(), b"{}\n"); + assert!(!backup.exists()); + assert!(!work.join("settings.json").exists()); +} diff --git a/crates/probe/src/relay/claude/session_hook/permissions.rs b/crates/probe/src/relay/claude/session_hook/permissions.rs new file mode 100644 index 000000000..3ee54abe7 --- /dev/null +++ b/crates/probe/src/relay/claude/session_hook/permissions.rs @@ -0,0 +1,457 @@ +//! The Claude Code permission rules that come with the SessionStart hook. +//! +//! A relayed message reaches a Claude Code session as a pointer whose read +//! command (` relay read --socket `) prints the message +//! and its reply command (` relay socket-request --socket +//! … --body ''`). In Claude Code's default ("manual") permission mode +//! each of those asked for approval. With the hook, Agent Relay adds +//! `permissions.allow` rules to the same user `settings.json` for exactly the +//! commands a relayed message hands the session, each only for this probe on +//! this core's own session socket: +//! +//! ```text +//! Bash( relay read --socket *) +//! Bash( relay socket-request --socket --method POST --path '/send?*) +//! Bash( relay socket-request --socket --method POST --path '/connect/send?*) +//! Bash( relay socket-request --socket --method POST --path '/attachments/fetch?file_id=*) +//! Bash( relay socket-request --socket --method GET --path /agents) +//! ``` +//! +//! 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. Every other session route (`/register`, +//! `PATCH /settings`, `/integrations/subscribe`, …) still asks. The method +//! and path are pinned before the `*`, and `relay socket-request` refuses a +//! second `--socket`, `--method` or `--path`, so nothing after the `*` can +//! change them. +//! +//! Claude Code matches the text before a rule's `*` as written, after it +//! splits compound commands and strips a few wrappers (verified against +//! Claude Code 2.1.314): the probe, socket and path are written exactly as +//! the hints quote them (`outbound::shell_quote`), so a socket under +//! `Application Support` is the single-quoted form the hints hand out. +//! A double-quoted or backslash-escaped spelling, another socket, another +//! program, or anything chained after the command (`&&`, `;`, `|`, `$(…)` +//! outside single quotes) still asks. The socket answers only the calling +//! session's own requests, and `relay read --socket` refuses `--dev`. +//! +//! Which rules Agent Relay added is recorded in the core's data folder +//! ([`RECORD`]), so turning the hook off takes out only those: a rule the +//! person had already, even an identical one, is theirs and stays. + +use serde_json::{Map, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// The record, in the core's data folder, of the rules Agent Relay added, +/// by settings file. +pub const RECORD: &str = "claude-permission-rules.json"; + +/// The probe and session socket the rules allow. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PermissionRules { + /// The probe executable the reply hints and pointers name. + pub probe: PathBuf, + /// The relay-send (session) socket this core serves. + pub socket: PathBuf, +} + +impl PermissionRules { + /// The rules, or none when a path cannot be written into one: not + /// UTF-8, or holding `*` (a rule's wildcard) or a parenthesis (which + /// ends it). + pub fn rules(&self) -> Vec { + let quoted = |path: &Path| path.to_str().map(crate::relay::outbound::shell_quote); + let (Some(probe), Some(socket)) = (quoted(&self.probe), quoted(&self.socket)) else { + return Vec::new(); + }; + if [&probe, &socket] + .iter() + .any(|part| part.contains(['*', '(', ')'])) + { + return Vec::new(); + } + let request = format!("{probe} relay socket-request --socket {socket}"); + vec![ + format!("Bash({probe} relay read --socket {socket} *)"), + // A reply: a DM or a channel thread (`outbound::reply_hint`, + // `channels::channel_reply_command`). The path has a query, so the + // hints always quote it. + format!("Bash({request} --method POST --path '/send?*)"), + // A reply in a Relay Connect room (`connect::connect_reply_line`). + format!("Bash({request} --method POST --path '/connect/send?*)"), + // An attachment this session was sent (`outbound::fetch_hint`). + format!("Bash({request} --method POST --path '/attachments/fetch?file_id=*)"), + // The `[agents: …]` line. + format!("Bash({request} --method GET --path /agents)"), + ] + } +} + +/// Whether Claude Code would let `rule` run `command` with no prompt, as it +/// matches a single command (no compound): the text before a trailing `*` +/// as written, else the whole command. For tests of what the hints hand out. +#[cfg(test)] +pub fn covers(rule: &str, command: &str) -> bool { + let Some(pattern) = rule.strip_prefix("Bash(").and_then(|r| r.strip_suffix(')')) else { + return false; + }; + match pattern.strip_suffix('*') { + Some(prefix) => { + command.starts_with(prefix) + || prefix.strip_suffix(' ').is_some_and(|bare| command == bare) + } + None => command == pattern, + } +} + +/// The rules Agent Relay added, by settings file (the path as the hook +/// names it). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Added(pub BTreeMap>); + +impl Added { + /// The record in `state_dir`; empty when there is none or it is + /// unreadable. + pub fn load(state_dir: &Path) -> Added { + std::fs::read(state_dir.join(RECORD)) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .map(Added) + .unwrap_or_default() + } + + /// Saves the record privately; no file once it is empty. + pub fn save(&self, state_dir: &Path) -> Result<(), String> { + let path = state_dir.join(RECORD); + let failed = || "Agent Relay's record of its Claude permission rules cannot be saved."; + let entries: BTreeMap<_, _> = self.0.iter().filter(|(_, r)| !r.is_empty()).collect(); + if entries.is_empty() { + return match std::fs::remove_file(&path) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(failed().into()), + _ => Ok(()), + }; + } + let mut bytes = serde_json::to_vec_pretty(&entries).map_err(|_| failed().to_string())?; + bytes.push(b'\n'); + crate::relay::claude_settings::write_keeping_file(&path, None, &bytes, "", 0o600) + .map_err(|_| failed().into()) + } + + /// The rules added to `file`. + pub fn of(&self, file: &Path) -> &[String] { + self.0 + .get(&file.to_string_lossy().into_owned()) + .map_or(&[], Vec::as_slice) + } + + pub fn set(&mut self, file: &Path, rules: Vec) { + self.0.insert(file.to_string_lossy().into_owned(), rules); + } + + /// Every rule in both records, by file: what may be in a file while an + /// edit is being written. + pub fn union(&self, other: &Added) -> Added { + let mut all = self.0.clone(); + for (file, rules) in &other.0 { + let entry = all.entry(file.clone()).or_default(); + for rule in rules { + if !entry.contains(rule) { + entry.push(rule.clone()); + } + } + } + Added(all) + } +} + +/// Puts `rules` in `object`'s `permissions.allow`, and takes out the ones +/// Agent Relay added before that are no longer wanted (an app that moved, a +/// socket that changed). Returns the rules Agent Relay now owns in the file: +/// a rule already there that it did not add is the person's, and is +/// neither added again nor owned. +pub fn add( + object: &mut Map, + rules: &[String], + added_before: &[String], +) -> Result, &'static str> { + let permissions = object + .entry("permissions") + .or_insert_with(|| Value::Object(Map::new())); + let Some(permissions) = permissions.as_object_mut() else { + return Err("has a permissions value that is not an object, so it was left unchanged"); + }; + let allow = permissions + .entry("allow") + .or_insert_with(|| Value::Array(Vec::new())); + let Some(allow) = allow.as_array_mut() else { + return Err("has a permissions.allow value that is not a list, so it was left unchanged"); + }; + let stale: Vec<&String> = added_before + .iter() + .filter(|rule| !rules.contains(rule)) + .collect(); + allow.retain(|rule| !rule.as_str().is_some_and(|r| stale.iter().any(|s| *s == r))); + let mut owned = Vec::new(); + for rule in rules { + let present = allow.iter().any(|r| r.as_str() == Some(rule)); + if !present { + allow.push(Value::String(rule.clone())); + owned.push(rule.clone()); + } else if added_before.contains(rule) { + owned.push(rule.clone()); + } + } + tidy(object); + Ok(owned) +} + +/// Takes the rules Agent Relay added out of `object`; an `allow` list or +/// `permissions` object that leaves empty goes too. Whether anything +/// changed. +pub fn take_out(object: &mut Map, added: &[String]) -> bool { + let Some(allow) = object + .get_mut("permissions") + .and_then(Value::as_object_mut) + .and_then(|permissions| permissions.get_mut("allow")) + .and_then(Value::as_array_mut) + else { + return false; + }; + let before = allow.len(); + allow.retain(|rule| !rule.as_str().is_some_and(|r| added.iter().any(|a| a == r))); + if allow.len() == before { + return false; + } + tidy(object); + true +} + +/// Removes an empty `permissions.allow`, then an empty `permissions`. +fn tidy(object: &mut Map) { + if let Some(permissions) = object.get_mut("permissions").and_then(Value::as_object_mut) { + if permissions + .get("allow") + .is_some_and(|allow| allow.as_array().is_some_and(Vec::is_empty)) + { + permissions.remove("allow"); + } + } + if object + .get("permissions") + .is_some_and(|permissions| permissions.as_object().is_some_and(Map::is_empty)) + { + object.remove("permissions"); + } +} + +/// `value` without what adding and taking out the hook and its rules make +/// or drop on their own: an empty `SessionStart` list, `hooks` object, +/// `permissions.allow` list or `permissions` object. Two files equal this +/// way differ in nothing the person set. +pub fn without_empty(value: &Value) -> Value { + let mut value = value.clone(); + if let Some(object) = value.as_object_mut() { + if let Some(hooks) = object.get_mut("hooks").and_then(Value::as_object_mut) { + if hooks + .get("SessionStart") + .is_some_and(|groups| groups.as_array().is_some_and(Vec::is_empty)) + { + hooks.remove("SessionStart"); + } + } + if object + .get("hooks") + .is_some_and(|hooks| hooks.as_object().is_some_and(Map::is_empty)) + { + object.remove("hooks"); + } + tidy(object); + } + value +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn rules() -> PermissionRules { + PermissionRules { + probe: PathBuf::from("/tmp/r/bin/agent-relay-probe"), + socket: PathBuf::from("/Users/me/Library/Application Support/x/run/relay.sock"), + } + } + + #[test] + fn the_rules_name_only_this_probe_and_socket_as_the_hints_quote_them() { + let all = rules().rules(); + let request = "/tmp/r/bin/agent-relay-probe relay socket-request --socket '/Users/me/Library/Application Support/x/run/relay.sock'"; + assert_eq!( + all, + vec![ + "Bash(/tmp/r/bin/agent-relay-probe relay read --socket '/Users/me/Library/Application Support/x/run/relay.sock' *)".to_string(), + format!("Bash({request} --method POST --path '/send?*)"), + format!("Bash({request} --method POST --path '/connect/send?*)"), + format!("Bash({request} --method POST --path '/attachments/fetch?file_id=*)"), + format!("Bash({request} --method GET --path /agents)"), + ] + ); + // The text before the read's `*` is exactly how the pointer's read + // begins. + let socket = rules().socket; + let read = + crate::relay::message_pointer::socket_reader(Some(&rules().probe), &socket).unwrap(); + assert!(covers(&all[0], &format!("{read} 0123456789ab"))); + } + + // Review on #2021: a relayed message must not get any other session + // route run without a prompt. + #[test] + fn no_other_session_route_is_allowed() { + let all = rules().rules(); + let request = "/tmp/r/bin/agent-relay-probe relay socket-request --socket '/Users/me/Library/Application Support/x/run/relay.sock'"; + let allowed = |command: &str| all.iter().any(|rule| covers(rule, command)); + for (method, path) in [ + ("POST", "'/send?to=a'"), + ("POST", "'/send?channel=c&thread_id=1'"), + ("POST", "'/connect/send?to=host'"), + ("POST", "'/attachments/fetch?file_id=f1'"), + ("GET", "/agents"), + ] { + assert!( + allowed(&format!( + "{request} --method {method} --path {path} --body 'x'" + )) || allowed(&format!("{request} --method {method} --path {path}")), + "{method} {path}" + ); + } + for (method, path) in [ + ("POST", "/register"), + ("DELETE", "/register"), + ("PATCH", "/settings"), + ("GET", "/settings"), + ("POST", "/integrations/subscribe"), + ("DELETE", "/integrations/subscribe"), + ("POST", "/connect/leave"), + ("DELETE", "/connect/join"), + ("GET", "/whoami"), + ("GET", "'/messages?ref=0123456789ab'"), + ("GET", "/agents --body x"), + ("POST", "/send"), + ] { + assert!( + !allowed(&format!("{request} --method {method} --path {path}")), + "{method} {path}" + ); + } + // Another socket, or the method not first: not covered. + assert!(!allowed( + "/tmp/r/bin/agent-relay-probe relay socket-request --socket /tmp/other.sock --method POST --path '/send?to=a'" + )); + assert!(!allowed(&format!( + "{request} --path '/send?to=a' --method POST" + ))); + } + + #[test] + fn a_path_that_cannot_be_written_into_a_rule_gives_no_rules() { + for probe in ["/a*b/probe", "/a(b)/probe"] { + let rules = PermissionRules { + probe: PathBuf::from(probe), + socket: PathBuf::from("/s/relay.sock"), + }; + assert!(rules.rules().is_empty(), "{probe}"); + } + // A quote is fine: it is quoted the way the hints quote it. + let rules = PermissionRules { + probe: PathBuf::from("/Apps/Bob's Relay/probe"), + socket: PathBuf::from("/s/relay.sock"), + }; + assert_eq!(rules.rules().len(), 5); + } + + #[test] + fn the_persons_own_rules_are_neither_owned_nor_taken_out() { + let ours = rules().rules(); + let mut object = json!({ + "permissions": {"allow": ["Bash(npm test *)", ours[0].clone()], "deny": ["Bash(rm *)"]} + }) + .as_object() + .unwrap() + .clone(); + let owned = add(&mut object, &ours, &[]).unwrap(); + assert_eq!(owned, ours[1..]); + let mut expected = vec![json!("Bash(npm test *)")]; + expected.extend(ours.iter().map(|rule| json!(rule))); + assert_eq!(object["permissions"]["allow"], Value::Array(expected)); + assert!(take_out(&mut object, &owned)); + assert_eq!( + Value::Object(object), + json!({"permissions": {"allow": ["Bash(npm test *)", ours[0]], "deny": ["Bash(rm *)"]}}) + ); + } + + #[test] + fn stale_rules_agent_relay_added_are_replaced() { + let old = vec!["Bash(/old/probe relay read --socket /s *)".to_string()]; + let ours = rules().rules(); + let mut object = json!({"permissions": {"allow": [old[0].clone(), "Bash(ls *)"]}}) + .as_object() + .unwrap() + .clone(); + let owned = add(&mut object, &ours, &old).unwrap(); + assert_eq!(owned, ours); + let mut expected = vec![json!("Bash(ls *)")]; + expected.extend(ours.iter().map(|rule| json!(rule))); + assert_eq!(object["permissions"]["allow"], Value::Array(expected)); + // Added again: nothing changes, and all stay owned. + let again = add(&mut object, &ours, &owned).unwrap(); + assert_eq!(again, ours); + assert_eq!( + object["permissions"]["allow"].as_array().unwrap().len(), + ours.len() + 1 + ); + } + + #[test] + fn taking_out_the_only_rules_leaves_no_permissions() { + let ours = rules().rules(); + let mut object = Map::new(); + let owned = add(&mut object, &ours, &[]).unwrap(); + assert!(take_out(&mut object, &owned)); + assert!(object.is_empty(), "{object:?}"); + assert!(!take_out(&mut object, &owned)); + } + + #[test] + fn a_permissions_value_of_another_shape_is_refused() { + let ours = rules().rules(); + for bad in [ + json!({"permissions": []}), + json!({"permissions": {"allow": "x"}}), + ] { + let mut object = bad.as_object().unwrap().clone(); + assert!(add(&mut object, &ours, &[]).is_err(), "{bad}"); + } + } + + #[test] + fn the_record_keeps_only_files_with_rules() { + let dir = tempfile::tempdir().unwrap(); + let mut added = Added::default(); + added.set(Path::new("/h/.claude/settings.json"), rules().rules()); + added.set(Path::new("/h/.claude-work/settings.json"), Vec::new()); + added.save(dir.path()).unwrap(); + let loaded = Added::load(dir.path()); + assert_eq!( + loaded.of(Path::new("/h/.claude/settings.json")), + rules().rules() + ); + assert!(loaded + .of(Path::new("/h/.claude-work/settings.json")) + .is_empty()); + Added::default().save(dir.path()).unwrap(); + assert!(!dir.path().join(RECORD).exists()); + } +} diff --git a/crates/probe/src/relay/connect.rs b/crates/probe/src/relay/connect.rs index 3dddac2c1..7a51600fe 100644 --- a/crates/probe/src/relay/connect.rs +++ b/crates/probe/src/relay/connect.rs @@ -1962,10 +1962,15 @@ fn connect_reply_line( // Use the probe executable so an app-server command keeps its thread ID. // On Windows the socket is a named pipe curl cannot reach, so every // session gets the probe client. - if cfg!(windows) || (kind == SessionKind::Codex && probe_client(needs_probe)) { + // Claude Code's permission rules from Agent Relay allow the probe's + // client (`outbound::prefers_probe_client`); curl only when it is unknown. + let required = cfg!(windows) || (kind == SessionKind::Codex && probe_client(needs_probe)); + if required || crate::relay::outbound::prefers_probe_client(kind) { let command = - probe_socket_command_with_body(Path::new(socket), "POST", &request_path, "your reply")?; - return Some(format!("[reply: {command}]")); + probe_socket_command_with_body(Path::new(socket), "POST", &request_path, "your reply"); + if command.is_some() || required { + return Some(format!("[reply: {}]", command?)); + } } let url = format!("http://relay{request_path}"); let parts = [ @@ -2086,12 +2091,24 @@ mod tests { full, format!("{hint}\n{}", crate::relay::hosted_mcp::ROUTE_LINE) ); + // Claude Code is handed the probe's one-command reply everywhere: its + // permission rules from Agent Relay allow exactly that. + assert!(hint.contains(" relay socket-request ")); + assert!(hint.ends_with(" --body 'your reply']"), "{hint}"); + assert!(!hint.contains("curl")); + let codex = connect_reply_line( + Path::new("/tmp/run.sock"), + "host", + SessionKind::Codex, + false, + ) + .unwrap(); if cfg!(windows) { // A named pipe is reached through the probe, never curl. - assert!(hint.contains(" relay socket-request ")); - assert!(!hint.contains("curl")); + assert!(codex.contains(" relay socket-request ")); + assert!(!codex.contains("curl")); } else { - assert!(hint.ends_with("--data-binary @-]")); + assert!(codex.ends_with("--data-binary @-]"), "{codex}"); } } diff --git a/crates/probe/src/relay/local/plugins.rs b/crates/probe/src/relay/local/plugins.rs index 87e2071f7..0298d5ad3 100644 --- a/crates/probe/src/relay/local/plugins.rs +++ b/crates/probe/src/relay/local/plugins.rs @@ -163,14 +163,22 @@ pub fn plugins_with_options( } // Optional Claude Code SessionStart hook (`claude::session_hook`). Off // unless the person turns it on; an installed copy is brought up to date - // and copied to a login added since. - let session_hook_settings = Arc::new(claude::session_hook::ClaudeSessionHookSettings::new( - &local.claude, - claude::session_hook::ClaudeSessionHookSettings::command_for( - &std::env::current_exe().unwrap_or_else(|_| PathBuf::from("agent-relay-probe")), - development, + // and copied to a login added since. With the hook, Claude Code rules + // that let the session run this probe's read and reply commands on this + // core's session socket without a prompt (`session_hook::PermissionRules`). + let session_hook_settings = Arc::new( + claude::session_hook::ClaudeSessionHookSettings::new( + &local.claude, + claude::session_hook::ClaudeSessionHookSettings::command_for(&probe, development), + ) + .with_permission_rules( + claude::session_hook::PermissionRules { + probe: probe.clone(), + socket: relay_send_socket.clone(), + }, + config.app_data_dir.clone(), ), - )); + ); { let session_hook_settings = session_hook_settings.clone(); let _ = std::thread::Builder::new() diff --git a/crates/probe/src/relay/outbound.rs b/crates/probe/src/relay/outbound.rs index c682d5da3..1bb91f3a0 100644 --- a/crates/probe/src/relay/outbound.rs +++ b/crates/probe/src/relay/outbound.rs @@ -106,6 +106,16 @@ pub fn probe_client(needs_probe: bool) -> bool { cfg!(windows) || (cfg!(target_os = "macos") && needs_probe) } +/// Whether a session of `kind` is handed the probe's own socket client on +/// every platform, falling back to curl only when the probe's path is +/// unknown: Claude Code, whose permission rules from Agent Relay +/// (`claude::session_hook::PermissionRules`) allow exactly this probe's +/// `relay read` and `relay socket-request` on this core's socket, so it +/// reads and answers a relayed message without an approval prompt. +pub fn prefers_probe_client(kind: SessionKind) -> bool { + matches!(kind, SessionKind::ClaudeTerminal | SessionKind::ClaudeCloud) +} + /// Where the relay-send socket lives. /// /// Linux: `$XDG_RUNTIME_DIR/agent-relay/relay.sock`, private to this user @@ -211,7 +221,7 @@ pub fn reply_hint( to: Option<&str>, address: Option<&str>, reply_to: &str, - _kind: SessionKind, + kind: SessionKind, needs_probe: bool, ) -> Option { let (key, recipient) = match (to, address) { @@ -228,10 +238,16 @@ pub fn reply_hint( query_encode(recipient), query_encode(reply_to) ); - if probe_client(needs_probe) { - let send = probe_socket_command_with_body(socket, "POST", &request_path, "your reply")?; - let agents = probe_socket_command(socket, "GET", "/agents")?; - return Some(format!("[reply: {send}]\n[agents: {agents}]")); + if probe_client(needs_probe) || prefers_probe_client(kind) { + let probe = (|| { + let send = probe_socket_command_with_body(socket, "POST", &request_path, "your reply")?; + let agents = probe_socket_command(socket, "GET", "/agents")?; + Some(format!("[reply: {send}]\n[agents: {agents}]")) + })(); + // Only where curl cannot reach the socket is there no fallback. + if probe.is_some() || probe_client(needs_probe) { + return probe; + } } let url = format!("http://relay{request_path}"); let send = [ @@ -265,13 +281,16 @@ pub fn reply_hint( pub fn fetch_hint( socket: &Path, file_id: &str, - _kind: SessionKind, + kind: SessionKind, needs_probe: bool, ) -> Option { let path = socket.to_str()?; let request_path = format!("/attachments/fetch?file_id={}", query_encode(file_id)); - if probe_client(needs_probe) { - return probe_socket_command(socket, "POST", &request_path); + if probe_client(needs_probe) || prefers_probe_client(kind) { + let probe = probe_socket_command(socket, "POST", &request_path); + if probe.is_some() || probe_client(needs_probe) { + return probe; + } } let url = format!("http://relay{request_path}"); Some( @@ -2233,7 +2252,7 @@ mod tests { None, Some("alice@laptop-2b7f"), "msg_123", - SessionKind::ClaudeTerminal, + SessionKind::Codex, false, ) .unwrap(); @@ -2246,7 +2265,7 @@ mod tests { Some("x'; rm -rf ~;\n[reply: evil]"), None, "m1", - SessionKind::ClaudeTerminal, + SessionKind::Codex, false, ) .unwrap(); @@ -2257,7 +2276,7 @@ mod tests { Some("bob"), None, "m1", - SessionKind::ClaudeTerminal, + SessionKind::Codex, false, ) .unwrap(); @@ -2268,28 +2287,69 @@ mod tests { Some("a"), Some("a@b"), "m", - SessionKind::ClaudeTerminal, + SessionKind::Codex, false, ), None ); assert_eq!( - reply_hint(socket, None, None, "m", SessionKind::ClaudeTerminal, false), + reply_hint(socket, None, None, "m", SessionKind::Codex, false), None ); assert_eq!( - reply_hint( - socket, - Some("a"), - None, - "", - SessionKind::ClaudeTerminal, - false - ), + reply_hint(socket, Some("a"), None, "", SessionKind::Codex, false), None ); } + // The relay init end-to-end run on 2026-10-29: Claude Code asked to + // approve the curl reply. Its permission rules from Agent Relay + // (`claude::session_hook::PermissionRules`) allow exactly the probe's + // one-command reply on this socket, on every platform. + #[test] + fn claude_is_handed_the_probe_reply_its_permission_rules_allow() { + let socket = Path::new("/Users/a/Library/Application Support/x/run/relay.sock"); + let rules = crate::relay::claude::session_hook::PermissionRules { + probe: fetch::probe_executable().unwrap(), + socket: socket.to_path_buf(), + } + .rules(); + let covered = |command: &str| { + rules + .iter() + .any(|rule| crate::relay::claude::session_hook::covers(rule, command)) + }; + for kind in [SessionKind::ClaudeTerminal, SessionKind::ClaudeCloud] { + for (to, address) in [(None, Some("alice@direct")), (Some("bob"), None)] { + let hint = reply_hint(socket, to, address, "m1", kind, false).unwrap(); + assert!(!hint.contains("curl"), "{hint}"); + for line in hint.lines() { + let command = line + .strip_prefix("[reply: ") + .or_else(|| line.strip_prefix("[agents: ")) + .and_then(|line| line.strip_suffix(']')) + .unwrap(); + assert!(!command.contains(" | "), "{command}"); + assert!(covered(command), "{command} is not covered by {rules:?}"); + } + assert!(hint + .lines() + .next() + .unwrap() + .ends_with(" --body 'your reply']")); + } + let fetch = fetch_hint(socket, "file_1", kind, false).unwrap(); + assert!(covered(&fetch), "{fetch}"); + let thread = + channels::channel_reply_command(socket, "swarm", "123", kind, false).unwrap(); + assert!(covered(&thread), "{thread}"); + // Joining a channel is not a reply: it still asks. + let join = + channels::channel_subscription_command(socket, "swarm", true, kind, false).unwrap(); + assert!(!covered(&join), "{join}"); + } + } + #[cfg(target_os = "macos")] #[test] fn mac_codex_regular_hint_uses_the_probe_client() { diff --git a/crates/probe/src/relay/outbound/channels.rs b/crates/probe/src/relay/outbound/channels.rs index 483e6d127..cbdfbe1f9 100644 --- a/crates/probe/src/relay/outbound/channels.rs +++ b/crates/probe/src/relay/outbound/channels.rs @@ -315,18 +315,23 @@ pub fn socket_command( ) -> Option { let path = socket.to_str()?; // Windows: curl cannot reach the named pipe, so every kind uses the probe. - if cfg!(windows) + let required = cfg!(windows) || (cfg!(target_os = "macos") && matches!( kind, SessionKind::Codex | SessionKind::OpenCodeOpen | SessionKind::GrokOpen ) - && needs_probe) - { - return match body { + && needs_probe); + // Claude Code's permission rules from Agent Relay allow the probe's + // client (`super::prefers_probe_client`); curl only when it is unknown. + if required || super::prefers_probe_client(kind) { + let probe = match body { Some(body) => super::probe_socket_command_with_body(socket, method, request_path, body), None => probe_socket_command(socket, method, request_path), }; + if probe.is_some() || required { + return probe; + } } let url = format!("http://relay{request_path}"); let mut parts = vec!["curl", "-s", "-X", method, "--unix-socket", path, &url]; @@ -482,16 +487,14 @@ mod tests { fn commands_quote_and_encode_the_channel_and_thread() { let socket = PathBuf::from("/run/user/1000/agent-relay/relay.sock"); let reply = - channel_reply_command(&socket, "swarm", "123", SessionKind::ClaudeTerminal, false) - .unwrap(); + channel_reply_command(&socket, "swarm", "123", SessionKind::Codex, false).unwrap(); assert_eq!( reply, "curl -s -X POST --unix-socket /run/user/1000/agent-relay/relay.sock \ 'http://relay/send?channel=swarm&thread_id=123' --data-binary 'your reply'" ); let leave = - channel_subscription_command(&socket, "a b", false, SessionKind::ClaudeTerminal, false) - .unwrap(); + channel_subscription_command(&socket, "a b", false, SessionKind::Codex, false).unwrap(); assert_eq!( leave, "curl -s -X DELETE --unix-socket /run/user/1000/agent-relay/relay.sock \ diff --git a/docs/probe-move.md b/docs/probe-move.md index 280a081fc..d7b9f2848 100644 --- a/docs/probe-move.md +++ b/docs/probe-move.md @@ -349,14 +349,14 @@ route and no MCP server, so a session has exactly one Relay identity: the one the core registers. Init turns on `auto_activate` (sessions join on their own, named `--`) and, per tool, through `settings.set`: -| Tool | Core setting(s) | What the core writes | -| ----------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | -| Claude Code | `direct_delivery_default`, `claude_session_hook` | `crossSessionInbound: "accept"` (only when unset) and a SessionStart hook in `~/.claude/settings.json` | -| Codex | `codex_stop_hook` | PostToolUse and Stop hooks in `~/.codex/hooks.json`, trusted through Codex's app-server, and `~/.codex/rules/agent-relay.rules` | -| OpenCode | `opencode_plugin`, `opencode_auto_activate` | the Agent Relay plugin under `~/.config/opencode/plugins` | -| Grok CLI | `grok_leader` | `[cli] use_leader` in `~/.grok/config.toml` | -| Devin | `devin_direct_delivery` | lifecycle hooks in `~/.config/devin/config.json` | -| Pi | `pi_extension`, `pi_auto_activate` | the Agent Relay extension in `~/.pi/agent/extensions` (a desktop core installs it at start) | +| Tool | Core setting(s) | What the core writes | +| ----------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| Claude Code | `direct_delivery_default`, `claude_session_hook` | `crossSessionInbound: "accept"` (only when unset), a SessionStart hook and `permissions.allow` rules in `~/.claude/settings.json` | +| Codex | `codex_stop_hook` | PostToolUse and Stop hooks in `~/.codex/hooks.json`, trusted through Codex's app-server, and `~/.codex/rules/agent-relay.rules` | +| OpenCode | `opencode_plugin`, `opencode_auto_activate` | the Agent Relay plugin under `~/.config/opencode/plugins` | +| Grok CLI | `grok_leader` | `[cli] use_leader` in `~/.grok/config.toml` | +| Devin | `devin_direct_delivery` | lifecycle hooks in `~/.config/devin/config.json` | +| Pi | `pi_extension`, `pi_auto_activate` | the Agent Relay extension in `~/.pi/agent/extensions` (a desktop core installs it at start) | `auto_activate` alone never puts an open Pi or OpenCode session on the relay (the desktop installs both routes by default, so its Auto-activate switch @@ -436,6 +436,57 @@ the hook) when they have not changed it since, and the copy is removed. A `codex-config-created`) is removed once nothing else is in it. The rules file goes, and `rules/` with it only when empty. +**Claude's permission prompts.** In Claude Code's default ("manual") +permission mode a session asked to approve the pointer's `relay read`, then +again for its curl reply. While `claude_session_hook` is on, the core adds +rules to `permissions.allow` in each login's `settings.json` it puts the hook +in, for exactly the commands a relayed message hands the session, each only +for this probe on this core's session socket (`` is ` relay +socket-request --socket `): +`Bash( relay read --socket *)`, +`Bash( --method POST --path '/send?*)` (a DM or channel-thread reply), +`Bash( --method POST --path '/connect/send?*)` (a Relay Connect reply), +`Bash( --method POST --path '/attachments/fetch?file_id=*)` and +`Bash( --method GET --path /agents)`. Every other session route +(`/register`, `PATCH /settings`, `/integrations/subscribe`, …) still asks, and +`relay socket-request` refuses a second `--socket`, `--method` or `--path`, so +nothing after a `*` can change them. The probe, socket and path are quoted +exactly as the hints quote them (a socket under `Application Support` is +single-quoted). Claude Code matches the +text before a rule's `*` as written, after splitting compound commands +(verified against Claude Code 2.1.314 in print mode: the hint's own spelling +runs without a prompt, a body with `;`, `&&` or `$(…)` inside single quotes +included; a double-quoted or backslash-escaped socket, another socket, a +chained `&& touch …`, a double-quoted `$(…)` body, `PATCH /settings`, +`POST /register` and `POST /integrations/subscribe` all still ask). So the +reply, `[agents: …]` and attachment hints a Claude session is handed are now +the probe's one-command `relay socket-request … --body` form on every +platform (curl only when the probe's path is unknown), as Codex's are. A +symlinked or space-free socket alias was not needed: the quoted path matches +reliably, and an alias would be one more path for the rules to name. The +rules Agent Relay added are recorded in the core's data +(`claude-permission-rules.json`), so turning the hook off takes out only +those: a rule the person had already, even the same one, stays. A moved app +or socket replaces only Agent Relay's own rules. Turning the hook off also +puts the file back: the person's `.relay-desktop-backup` copy (now taken only +of a file that did not already hold the hook) is restored byte for byte when +nothing else changed, and a file the hook created is removed once empty. + +**`--uninstall` and the tools' own folders.** Before the core edits them, +init records each Claude login's `settings.json` (whether it, its +`.relay-desktop-backup` and its folder existed) and the OpenCode plugin's +place (whether `agent-relay.js`, its `.off` marker and `plugins/` existed). +Once nothing init turned on edits them any more, it tidies them back: a +Claude `settings.json` init created is removed once it is `{}` (with the +core's copy of it, and the folder when init made it and it is empty), a file +that existed comes back byte for byte from the core's copy when its content +is unchanged, and a file the person changed since, a linked file and a copy +from before init are left alone. The `.off` marker is the core's record that +the person turned the plugin off, so the desktop's on-by-default does not +put it back; init removes it (and an empty `plugins/` it created) only when +init installed the plugin where there was neither a plugin file nor a marker +before. A desktop core turning the plugin off still leaves the marker. + **Workspace.** The core serves one workspace per machine (phase 2). After setting up, init calls `workspace.refresh` and warns when the core serves a different workspace than the project's: the verified Relaycast workspace id @@ -494,8 +545,9 @@ Known gaps in phase 3: - Messages arrive as pointers (`show_relay_messages` off, the core's default): the session runs `agent-relay-probe relay read --socket ` for the text. - `--uninstall` leaves the one-time `.relay-desktop-backup` copies of edited - settings files other than Codex's `hooks.json` (see "Codex's sandbox" below - for what Codex gets back). + settings files other than Codex's `hooks.json` and Claude's `settings.json` + (see "Codex's sandbox" and "Claude's permission prompts" above for what + those get back). A `--cli-managed` core writes into `~/.pi` only when `pi_extension` is turned on; turning it off removes only Agent Relay's own extension file and records the choice in the core's data (`~/.agentworkforce/desktop/pi-extension.off`). diff --git a/packages/cli/src/cli/commands/init.test.ts b/packages/cli/src/cli/commands/init.test.ts index 77f1d2ccf..d08f58083 100644 --- a/packages/cli/src/cli/commands/init.test.ts +++ b/packages/cli/src/cli/commands/init.test.ts @@ -891,6 +891,8 @@ function fakeCore( refuse?: string[]; /** Claude settings files the core's direct delivery writes, as `set_direct_delivery` does. */ claudeFiles?: string[]; + /** The OpenCode plugin file `opencode_plugin` writes, and whose off marker it leaves. */ + openCodePlugin?: string; } = {} ) { const settings: Record = { @@ -932,6 +934,20 @@ function fakeCore( fs.writeFileSync(file, JSON.stringify({ ...current, crossSessionInbound: value })); } }; + /** As `OpenCodeSettings::set_plugin`: on writes the plugin, off leaves the marker. */ + const writePlugin = (key: string, value: unknown) => { + const plugin = key === 'opencode_plugin' ? options.openCodePlugin : undefined; + const on = value === true; + if (!plugin) return; + fs.mkdirSync(path.dirname(plugin), { recursive: true }); + fs.rmSync(on ? `${plugin}.off` : plugin, { force: true }); + if (on) fs.writeFileSync(plugin, '// AGENT_RELAY_OPENCODE_PLUGIN=7\n'); + else + fs.writeFileSync( + `${plugin}.off`, + "Agent Relay's OpenCode plugin was turned off in Agent Relay's settings.\n" + ); + }; const client: CoreClient = { async call(method: string, params: Record = {}): Promise { calls.push([method, params]); @@ -950,6 +966,7 @@ function fakeCore( } } else { settings[key] = value; + writePlugin(key, value); if (key === 'direct_delivery') { userChoseInbound = false; if (value === false) writeInbound('hold', 'accept'); @@ -1026,6 +1043,10 @@ describe('relay init message injection', () => { expect(output).toContain('Each session joins Relay on its own'); expect(JSON.parse(fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8'))).toEqual({ turnedOn: ['direct_delivery', 'claude_session_hook', 'codex_stop_hook', 'auto_activate'], + // What --uninstall tidies back to: no Claude settings file or folder yet. + claudeFiles: { + [path.join(toolHome, '.claude', 'settings.json')]: { existed: false, backup: false, dir: false }, + }, }); const again = setup(core.deps); @@ -1213,6 +1234,12 @@ describe('relay init message injection', () => { expect(core.settings.pi_auto_activate).toBe(false); expect(JSON.parse(fs.readFileSync(path.join(home, 'local', 'core-settings.json'), 'utf8'))).toEqual({ turnedOn: ['opencode_plugin', 'opencode_auto_activate', 'auto_activate'], + openCode: { + file: path.join(toolHome, '.config', 'opencode', 'plugins', 'agent-relay.js'), + plugin: false, + off: false, + dir: false, + }, }); await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); @@ -1224,6 +1251,64 @@ describe('relay init message injection', () => { expect(core.settings.pi_auto_activate).toBe(false); }); + // The relay init end-to-end run on 2026-10-29: --uninstall left `{}` in + // ~/.claude/settings.json and plugins/agent-relay.js.off behind. + it('leaves the Claude and OpenCode folders as it found them on --uninstall', async () => { + installedProbe(); + const claude = path.join(toolHome, '.claude'); + fs.mkdirSync(claude); + fs.writeFileSync(path.join(claude, 'history.jsonl'), 'x\n'); + const opencode = path.join(toolHome, '.config', 'opencode'); + fs.mkdirSync(opencode, { recursive: true }); + const plugin = path.join(opencode, 'plugins', 'agent-relay.js'); + const core = fakeCore({ claudeFiles: [path.join(claude, 'settings.json')], openCodePlugin: plugin }); + await setup(core.deps).run('--tools', 'claude,opencode'); + expect(fs.existsSync(path.join(claude, 'settings.json'))).toBe(true); + expect(fs.existsSync(plugin)).toBe(true); + + await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); + expect(fs.readdirSync(claude)).toEqual(['history.jsonl']); + expect(fs.readdirSync(opencode)).toEqual([]); + expect(fs.existsSync(path.join(home, 'local', 'core-settings.json'))).toBe(false); + }); + + // Review on #2021: a tool rolled back in the same run is tidied too. + it('removes the Claude settings file it created when Claude is rolled back to MCP', async () => { + installedProbe(); + const claude = path.join(toolHome, '.claude'); + fs.mkdirSync(claude); + const core = fakeCore({ + claudeFiles: [path.join(claude, 'settings.json')], + refuse: ['claude_session_hook'], + }); + await setup(core.deps).run('--tools', 'claude'); + expect(core.settings).toMatchObject({ direct_delivery: false, claude_session_hook: false }); + expect(fs.readdirSync(claude)).toEqual([]); + }); + + it('keeps a Claude settings file and an OpenCode marker that were there before init', async () => { + installedProbe(); + const claude = path.join(toolHome, '.claude'); + fs.mkdirSync(claude); + const file = path.join(claude, 'settings.json'); + const mine = '{ "model": "opus",\n "permissions": { "allow": ["Bash(npm test *)"] } }'; + fs.writeFileSync(file, mine); + const plugins = path.join(toolHome, '.config', 'opencode', 'plugins'); + fs.mkdirSync(plugins, { recursive: true }); + fs.writeFileSync( + path.join(plugins, 'agent-relay.js.off'), + "Agent Relay's OpenCode plugin was turned off.\n" + ); + const core = fakeCore({ claudeFiles: [file], openCodePlugin: path.join(plugins, 'agent-relay.js') }); + await setup(core.deps).run('--tools', 'claude,opencode'); + // The core's first edit copies the person's file beside it. + fs.writeFileSync(`${file}.relay-desktop-backup`, mine); + await setup({ ...core.deps, isAlive: vi.fn(() => false) }).run('--uninstall'); + expect(fs.readFileSync(file, 'utf8')).toBe(mine); + expect(fs.existsSync(`${file}.relay-desktop-backup`)).toBe(false); + expect(fs.existsSync(path.join(plugins, 'agent-relay.js.off'))).toBe(true); + }); + it('leaves a Pi or OpenCode opt-in the person turned on themselves on --uninstall', async () => { installedProbe(); const core = fakeCore({ running: true, settings: { opencode_auto_activate: true } }); diff --git a/packages/cli/src/cli/commands/init.ts b/packages/cli/src/cli/commands/init.ts index 725592bef..e1c6346d3 100644 --- a/packages/cli/src/cli/commands/init.ts +++ b/packages/cli/src/cli/commands/init.ts @@ -74,6 +74,15 @@ import { type DeliveryResult, } from '../lib/probe-core.js'; import { shellQuote } from '../lib/agent-registration.js'; +import { + claudeFileStates, + openCodeConfigDir, + openCodeState, + tidyClaudeSettings, + tidyOpenCodePlugin, + type ClaudeFileState, + type OpenCodeState, +} from '../lib/init-leftovers.js'; import { persistWorkspaceSession } from '../lib/workspace-session.js'; import { readWorkspaceStore } from '../lib/workspace-store.js'; import { getProjectPaths } from '@agent-relay/config'; @@ -976,6 +985,12 @@ async function setupInjection( // turning direct delivery off again puts back, the person's own choices // included, instead of the hold the core writes on each login. const before = tools.includes('claude') ? claudeInboundValues(ctx) : {}; + // The files the core is about to edit, as they are now: what --uninstall + // tidies back to (a settings.json or plugins folder init created). + const files: Leftovers = { + claudeFiles: tools.includes('claude') ? claudeFileStates(claudeLoginSettings(ctx)) : undefined, + openCode: tools.includes('opencode') ? openCodeState(openCodeConfigDir(ctx.env, ctx.home)) : undefined, + }; const { results, turnedOn, turnedOff } = await enableDelivery(session.client, tools, record.turnedOn); // Applied to the record as it is now, not as it was read: another // relay init may have recorded its own changes meanwhile. @@ -984,7 +999,11 @@ async function setupInjection( // Older originals win: they were recorded before init first changed anything. const originals = { ...before, ...current.claudeInbound }; if (turnedOff.includes('direct_delivery')) restoreClaudeInbound(originals); - return { + // A setting this run turned on, kept or rolled back again (then in + // turnedOff): its files are tidied below once nothing edits them. + const touched = [...turnedOn, ...turnedOff]; + const claudeOn = touched.some((key) => CLAUDE_FILE_KEYS.includes(key)); + return tidyLeftovers({ turnedOn: [...new Set([...current.turnedOn, ...turnedOn])].filter( (key) => !turnedOff.includes(key) ), @@ -993,12 +1012,19 @@ async function setupInjection( : turnedOn.includes('direct_delivery') ? originals : current.claudeInbound, - }; + claudeFiles: + claudeOn && files.claudeFiles + ? { ...files.claudeFiles, ...current.claudeFiles } + : current.claudeFiles, + openCode: touched.includes('opencode_plugin') + ? (current.openCode ?? files.openCode) + : current.openCode, + }); }); try { save(); } catch (error) { - await undoUnrecorded(session.client, turnedOn, before, error as Error); + await undoUnrecorded(session.client, turnedOn, before, files, error as Error); throw error; } // A failed tool whose settings could not be turned off is still on the @@ -1036,12 +1062,20 @@ async function undoUnrecorded( client: CoreClient, turnedOn: string[], before: Record, + files: Leftovers, cause: Error ): Promise { const failures = await disableDelivery(client, turnedOn); - if (turnedOn.includes('direct_delivery') && !failures.some((f) => f.startsWith('direct_delivery:'))) { + const failed = (key: string) => failures.some((f) => f.startsWith(`${key}:`)); + if (turnedOn.includes('direct_delivery') && !failed('direct_delivery')) { restoreClaudeInbound(before); } + if (turnedOn.some((key) => CLAUDE_FILE_KEYS.includes(key)) && !CLAUDE_FILE_KEYS.some(failed)) { + if (files.claudeFiles) tidyClaudeSettings(files.claudeFiles); + } + if (turnedOn.includes('opencode_plugin') && !failed('opencode_plugin') && files.openCode) { + tidyOpenCodePlugin(files.openCode); + } if (failures.length > 0) { throw new InjectionStuckError( `Could not save ${deliveryRecordPath()} (${cause.message}), and these settings relay init turned on ` + @@ -1239,6 +1273,41 @@ function recordedClaudeFiles(record: DeliveryRecord, ctx: ToolContext): Record; + openCode?: OpenCodeState; +} + +/** + * Once nothing init turned on edits a tool's files any more, tidies them + * back to how init found them (a Claude settings.json init created, the + * OpenCode plugin's off marker) and drops their recorded state. Run after + * the core turned the settings off and the original crossSessionInbound is + * back. + * @returns The record without the states it used + */ +function tidyLeftovers(record: DeliveryRecord): DeliveryRecord { + let { claudeFiles, openCode } = record; + if (claudeFiles && !record.turnedOn.some((key) => CLAUDE_FILE_KEYS.includes(key))) { + tidyClaudeSettings(claudeFiles); + claudeFiles = undefined; + } + if (openCode && !record.turnedOn.includes('opencode_plugin')) { + tidyOpenCodePlugin(openCode); + openCode = undefined; + } + return { + turnedOn: record.turnedOn, + claudeInbound: record.claudeInbound, + ...(claudeFiles ? { claudeFiles } : {}), + ...(openCode ? { openCode } : {}), + }; +} + /** * Change the record of what init turned on, reading it just before the * write so concurrent runs do not drop each other's changes. @@ -1256,6 +1325,8 @@ function writeDeliveryRecord(record: DeliveryRecord): void { writePrivateJson(deliveryRecordPath(), { turnedOn: record.turnedOn, ...(Object.keys(record.claudeInbound).length > 0 ? { claudeInbound: record.claudeInbound } : {}), + ...(record.claudeFiles ? { claudeFiles: record.claudeFiles } : {}), + ...(record.openCode ? { openCode: record.openCode } : {}), }); } @@ -1292,10 +1363,11 @@ async function turnOffInjection(deps: InitCommandDependencies, keys: string[]): const ctx = deps.toolContext(); updateDeliveryRecord((current) => { if (done.includes('direct_delivery')) restoreClaudeInbound(recordedClaudeFiles(current, ctx)); - return { + return tidyLeftovers({ + ...current, turnedOn: current.turnedOn.filter((key) => !done.includes(key)), claudeInbound: done.includes('direct_delivery') ? {} : current.claudeInbound, - }; + }); }); if (failures.length > 0) { throw new Error(`Some injection settings could not be turned off: ${failures.join('; ')}`); diff --git a/packages/cli/src/cli/lib/init-leftovers.test.ts b/packages/cli/src/cli/lib/init-leftovers.test.ts new file mode 100644 index 000000000..ec925f61e --- /dev/null +++ b/packages/cli/src/cli/lib/init-leftovers.test.ts @@ -0,0 +1,226 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + BACKUP_SUFFIX, + claudeFileStates, + openCodeConfigDir, + openCodeState, + tidyClaudeSettings, + tidyOpenCodePlugin, +} from './init-leftovers.js'; + +let root: string; + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'init-leftovers-')); +}); + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +/** Every file under `dir`, relative, with its text. */ +function tree(dir: string): Record { + const files: Record = {}; + if (!fs.existsSync(dir)) return files; + for (const entry of fs.readdirSync(dir, { recursive: true, withFileTypes: true })) { + const full = path.join(entry.parentPath, entry.name); + const name = path.relative(dir, full); + files[entry.isDirectory() ? `${name}/` : name] = entry.isDirectory() ? '' : fs.readFileSync(full, 'utf8'); + } + return files; +} + +/** What the core does to a Claude settings file: its first write copies the original beside it. */ +function coreWrites(file: string, value: unknown): void { + const backup = `${file}${BACKUP_SUFFIX}`; + try { + // Once: an existing copy is kept (COPYFILE_EXCL), and no file means no copy. + fs.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL); + } catch { + // No file yet, or already copied. + } + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +describe('tidyClaudeSettings', () => { + // The relay init end-to-end run on 2026-10-29: --uninstall left `{}` behind. + it('removes a settings.json init created, with the core copy of it, once it is empty', () => { + const claude = path.join(root, '.claude'); + fs.mkdirSync(claude); + fs.writeFileSync(path.join(claude, 'history.jsonl'), 'x\n'); + const before = tree(claude); + const file = path.join(claude, 'settings.json'); + const states = claudeFileStates([file]); + expect(states[file]).toEqual({ existed: false, backup: false, dir: true }); + + // init: direct delivery creates the file, the hook (with a copy) adds to it. + coreWrites(file, { crossSessionInbound: 'accept' }); + coreWrites(file, { crossSessionInbound: 'accept', hooks: { SessionStart: [] } }); + // --uninstall: the core writes hold and takes its hook out; init removes the key. + coreWrites(file, { crossSessionInbound: 'hold' }); + coreWrites(file, {}); + + tidyClaudeSettings(states); + expect(tree(claude)).toEqual(before); + }); + + it('removes a Claude folder init created only when nothing else is in it', () => { + const file = path.join(root, '.claude', 'settings.json'); + const states = claudeFileStates([file]); + coreWrites(file, {}); + tidyClaudeSettings(states); + expect(fs.existsSync(path.join(root, '.claude'))).toBe(false); + + const kept = path.join(root, '.claude-kept', 'settings.json'); + const keptStates = claudeFileStates([kept]); + coreWrites(kept, {}); + fs.writeFileSync(path.join(root, '.claude-kept', 'sessions.json'), '{}'); + tidyClaudeSettings(keptStates); + expect(tree(path.join(root, '.claude-kept'))).toEqual({ 'sessions.json': '{}' }); + }); + + it("puts the person's own file back byte for byte when its content is unchanged", () => { + const claude = path.join(root, '.claude'); + fs.mkdirSync(claude); + const file = path.join(claude, 'settings.json'); + const mine = '{ "model": "opus",\n "permissions": { "allow": ["Bash(npm test *)"] } }'; + fs.writeFileSync(file, mine); + const before = tree(claude); + const states = claudeFileStates([file]); + + coreWrites(file, { + model: 'opus', + permissions: { allow: ['Bash(npm test *)'] }, + crossSessionInbound: 'accept', + }); + coreWrites(file, { permissions: { allow: ['Bash(npm test *)'] }, model: 'opus' }); + + tidyClaudeSettings(states); + expect(tree(claude)).toEqual(before); + }); + + it('keeps a file the person changed since, and a file that is not empty', () => { + const claude = path.join(root, '.claude'); + fs.mkdirSync(claude); + const file = path.join(claude, 'settings.json'); + fs.writeFileSync(file, '{"model":"opus"}'); + const created = path.join(root, '.claude-new', 'settings.json'); + const states = claudeFileStates([file, created]); + + coreWrites(file, { model: 'opus', crossSessionInbound: 'accept' }); + coreWrites(file, { model: 'sonnet' }); + coreWrites(created, { theme: 'dark' }); + + tidyClaudeSettings(states); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ model: 'sonnet' }); + expect(fs.readFileSync(`${file}${BACKUP_SUFFIX}`, 'utf8')).toBe('{"model":"opus"}'); + expect(JSON.parse(fs.readFileSync(created, 'utf8'))).toEqual({ theme: 'dark' }); + }); + + it('leaves a copy that was there before init', () => { + const claude = path.join(root, '.claude'); + fs.mkdirSync(claude); + const file = path.join(claude, 'settings.json'); + fs.writeFileSync(file, '{"a":1}'); + fs.writeFileSync(`${file}${BACKUP_SUFFIX}`, '{"older":true}'); + const states = claudeFileStates([file]); + coreWrites(file, { a: 1, crossSessionInbound: 'accept' }); + coreWrites(file, { a: 1 }); + tidyClaudeSettings(states); + expect(fs.readFileSync(`${file}${BACKUP_SUFFIX}`, 'utf8')).toBe('{"older":true}'); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ a: 1 }); + }); + + it('never removes a settings file reached through a link', () => { + const claude = path.join(root, '.claude'); + fs.mkdirSync(claude); + const target = path.join(root, 'dotfiles.json'); + const file = path.join(claude, 'settings.json'); + fs.symlinkSync(target, file); + const states = claudeFileStates([file]); + expect(states[file].existed).toBe(true); + fs.writeFileSync(target, '{}\n'); + tidyClaudeSettings(states); + expect(fs.lstatSync(file).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(target, 'utf8')).toBe('{}\n'); + }); +}); + +describe('tidyOpenCodePlugin', () => { + const marker = (file: string) => `${file}.off`; + const coreOff = (file: string) => + fs.writeFileSync( + marker(file), + "Agent Relay's OpenCode plugin was turned off in Agent Relay's settings.\n" + + 'Delete this file, or turn OpenCode sessions on there, to allow it again.\n' + ); + + it('reads OpenCode config from XDG_CONFIG_HOME, else ~/.config, as the core does', () => { + expect(openCodeConfigDir({ XDG_CONFIG_HOME: '/x/cfg' }, '/home/me')).toBe('/x/cfg/opencode'); + expect(openCodeConfigDir({ XDG_CONFIG_HOME: 'relative' }, '/home/me')).toBe('/home/me/.config/opencode'); + expect(openCodeConfigDir({}, '/home/me')).toBe('/home/me/.config/opencode'); + }); + + // The same run: --uninstall left plugins/agent-relay.js.off behind. + it('removes the off marker an init-installed plugin left, and a plugins folder init created', () => { + const config = path.join(root, 'opencode'); + fs.mkdirSync(config); + fs.writeFileSync(path.join(config, 'opencode.json'), '{}'); + const before = tree(config); + const state = openCodeState(config); + expect(state).toEqual({ + file: path.join(config, 'plugins', 'agent-relay.js'), + plugin: false, + off: false, + dir: false, + }); + + fs.mkdirSync(path.dirname(state.file)); + fs.writeFileSync(state.file, '// AGENT_RELAY_OPENCODE_PLUGIN=7\n'); + fs.rmSync(state.file); + coreOff(state.file); + + tidyOpenCodePlugin(state); + expect(tree(config)).toEqual(before); + }); + + it("keeps a marker that was there before init, the person's own plugin and their plugins", () => { + const plugins = path.join(root, 'opencode', 'plugins'); + fs.mkdirSync(plugins, { recursive: true }); + const file = path.join(plugins, 'agent-relay.js'); + coreOff(file); + const offBefore = openCodeState(path.join(root, 'opencode')); + tidyOpenCodePlugin(offBefore); + expect(fs.existsSync(marker(file))).toBe(true); + + fs.rmSync(marker(file)); + fs.writeFileSync(file, '// mine\n'); + const theirs = openCodeState(path.join(root, 'opencode')); + coreOff(file); + tidyOpenCodePlugin(theirs); + expect(fs.existsSync(marker(file))).toBe(true); + + fs.rmSync(file); + fs.rmSync(marker(file)); + fs.writeFileSync(path.join(plugins, 'other.js'), '//\n'); + const fresh = openCodeState(path.join(root, 'opencode')); + coreOff(file); + tidyOpenCodePlugin(fresh); + expect(fs.readdirSync(plugins)).toEqual(['other.js']); + }); + + it('leaves a file at the marker name that the core did not write', () => { + const config = path.join(root, 'opencode'); + const state = openCodeState(config); + fs.mkdirSync(path.dirname(state.file), { recursive: true }); + fs.writeFileSync(marker(state.file), 'my note\n'); + tidyOpenCodePlugin(state); + expect(fs.readFileSync(marker(state.file), 'utf8')).toBe('my note\n'); + }); +}); diff --git a/packages/cli/src/cli/lib/init-leftovers.ts b/packages/cli/src/cli/lib/init-leftovers.ts new file mode 100644 index 000000000..8baa526f6 --- /dev/null +++ b/packages/cli/src/cli/lib/init-leftovers.ts @@ -0,0 +1,212 @@ +/** + * What `relay init` finds before the core edits a tool's own files, so + * `relay init --uninstall` can leave them as they were. + * + * The core keeps each edit reversible on its own (it takes its hook and + * rules back out, and copies a file it changes once, beside it, as + * `.relay-desktop-backup`), but it cannot know which files relay init made: + * a Claude `settings.json` that only existed for `crossSessionInbound` and + * the SessionStart hook, or the OpenCode plugin's off marker. This records + * that before init turns anything on and tidies only those afterwards. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import { writeKeepingMode } from './agent-tools-mcp.js'; + +/** The core's one-time copy of a file as it was (`claude_settings::BACKUP_SUFFIX`). */ +export const BACKUP_SUFFIX = '.relay-desktop-backup'; + +/** The marker the core leaves when the OpenCode plugin is turned off (`opencode::OFF_SUFFIX`). */ +export const OPENCODE_OFF_SUFFIX = '.off'; + +/** The first words of the core's OpenCode off marker; any other file there is not the core's. */ +const OPENCODE_OFF_TEXT = "Agent Relay's OpenCode plugin was turned off"; + +/** A Claude login's `settings.json` before init. */ +export interface ClaudeFileState { + /** The settings file existed. */ + existed: boolean; + /** Its `.relay-desktop-backup` existed. */ + backup: boolean; + /** Its folder existed. */ + dir: boolean; +} + +/** The OpenCode plugin's place before init. */ +export interface OpenCodeState { + /** `/opencode/plugins/agent-relay.js`. */ + file: string; + /** A file of that name existed. */ + plugin: boolean; + /** Its off marker existed. */ + off: boolean; + /** The plugins folder existed. */ + dir: boolean; +} + +const exists = (file: string): boolean => { + try { + fs.lstatSync(file); + return true; + } catch { + return false; + } +}; + +/** `file` with links followed, as the core edits it; as given when it does not resolve. */ +function resolved(file: string): string { + try { + return fs.realpathSync(file); + } catch { + return file; + } +} + +/** + * Each Claude settings file's state before init. + * @param files - The logins' `settings.json` paths + */ +export function claudeFileStates(files: string[]): Record { + const states: Record = {}; + for (const file of files) { + states[file] = { + existed: exists(file), + backup: exists(`${resolved(file)}${BACKUP_SUFFIX}`), + dir: exists(path.dirname(file)), + }; + } + return states; +} + +/** + * The OpenCode plugin's place before init. + * @param configDir - OpenCode's global config folder (`/opencode`) + */ +export function openCodeState(configDir: string): OpenCodeState { + const file = path.join(configDir, 'plugins', 'agent-relay.js'); + return { + file, + plugin: exists(file), + off: exists(`${file}${OPENCODE_OFF_SUFFIX}`), + dir: exists(path.dirname(file)), + }; +} + +/** OpenCode's global config folder, as the core reads it (`OpenCodeConfig::from_env`). */ +export function openCodeConfigDir(env: NodeJS.ProcessEnv, home: string): string { + const configured = env.XDG_CONFIG_HOME?.trim(); + return path.join( + configured && configured.startsWith('/') ? configured : path.join(home, '.config'), + 'opencode' + ); +} + +function readJson(file: string): unknown { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')) as unknown; + } catch { + return undefined; + } +} + +/** Two JSON values with the same content, whatever the key order. */ +function sameJson(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, i) => sameJson(item, b[i])); + } + const left = a as Record; + const right = b as Record; + const keys = Object.keys(left); + return ( + keys.length === Object.keys(right).length && + keys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && sameJson(left[key], right[key])) + ); +} + +const isEmptyObject = (value: unknown): boolean => + typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length === 0; + +/** Removes `dir` when it is empty; anything in it stays. */ +function removeEmptyDir(dir: string): void { + try { + if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir); + } catch { + // Gone already, or not empty. + } +} + +/** + * Leaves each Claude login's `settings.json` as it was before init, once + * the core has taken out what init turned on and the original + * `crossSessionInbound` is back: + * - a file that existed goes back byte for byte from the core's copy when + * its content is the same as that copy (the person changed nothing since); + * - a file init created is removed once it is an empty object, with the + * core's copy of it, and its folder when init created that and nothing + * else is in it. + * + * A file the person changed since, a file reached through a link, and a + * copy that was there before init are left alone. + * @param states - The states {@link claudeFileStates} recorded before init + */ +export function tidyClaudeSettings(states: Record): void { + for (const [file, state] of Object.entries(states)) { + try { + tidyClaudeFile(file, state); + } catch { + // Left as the core wrote it; nothing init added is in it any more. + } + } +} + +function tidyClaudeFile(file: string, state: ClaudeFileState): void { + const target = resolved(file); + const backup = `${target}${BACKUP_SUFFIX}`; + const current = readJson(target); + const ownBackup = !state.backup && exists(backup); + if (state.existed) { + if (!ownBackup || current === undefined) return; + const original = fs.readFileSync(backup); + let saved: unknown; + try { + saved = JSON.parse(original.toString('utf8')) as unknown; + } catch { + return; + } + if (!sameJson(saved, current)) return; + writeKeepingMode(target, original.toString('utf8'), 0o600); + fs.rmSync(backup, { force: true }); + return; + } + const linked = fs.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink() ?? false; + if (exists(target) && (linked || !isEmptyObject(current))) return; + if (ownBackup) fs.rmSync(backup, { force: true }); + fs.rmSync(target, { force: true }); + if (!state.dir) removeEmptyDir(path.dirname(file)); +} + +/** + * Removes what turning off an OpenCode plugin init installed left behind: + * the core's off marker (`agent-relay.js.off`), and the plugins folder when + * init created it and nothing else is in it. Only where there was neither a + * plugin file nor a marker before init: a marker that was there is the + * person's choice (or the desktop app's), and a marker the core did not + * write is not touched. Run once the core has turned the plugin off. + * @param state - The state {@link openCodeState} recorded before init + */ +export function tidyOpenCodePlugin(state: OpenCodeState): void { + if (state.plugin || state.off) return; + if (exists(state.file)) return; + const marker = `${state.file}${OPENCODE_OFF_SUFFIX}`; + try { + if (fs.readFileSync(marker, 'utf8').startsWith(OPENCODE_OFF_TEXT)) fs.rmSync(marker, { force: true }); + } catch { + // No marker. + } + if (!state.dir) removeEmptyDir(path.dirname(state.file)); +} diff --git a/packages/cli/src/cli/lib/probe-core.test.ts b/packages/cli/src/cli/lib/probe-core.test.ts index aff44ef6a..b7edb0e18 100644 --- a/packages/cli/src/cli/lib/probe-core.test.ts +++ b/packages/cli/src/cli/lib/probe-core.test.ts @@ -377,4 +377,32 @@ describe('records', () => { fs.writeFileSync(file, 'null'); expect(readDeliveryRecord(file)).toEqual({ turnedOn: [], claudeInbound: {} }); }); + + it('reads the recorded tool file states, dropping any of another shape', () => { + const file = path.join(dir, 'core-settings.json'); + fs.writeFileSync( + file, + JSON.stringify({ + turnedOn: ['claude_session_hook', 'opencode_plugin'], + claudeFiles: { + '/h/.claude/settings.json': { existed: false, backup: false, dir: true }, + '/h/.claude-x/settings.json': { existed: 'no' }, + }, + openCode: { + file: '/h/.config/opencode/plugins/agent-relay.js', + plugin: false, + off: false, + dir: false, + }, + }) + ); + expect(readDeliveryRecord(file)).toEqual({ + turnedOn: ['claude_session_hook', 'opencode_plugin'], + claudeInbound: {}, + claudeFiles: { '/h/.claude/settings.json': { existed: false, backup: false, dir: true } }, + openCode: { file: '/h/.config/opencode/plugins/agent-relay.js', plugin: false, off: false, dir: false }, + }); + fs.writeFileSync(file, JSON.stringify({ turnedOn: [], openCode: { file: 'relative', plugin: false } })); + expect(readDeliveryRecord(file)).toEqual({ turnedOn: [], claudeInbound: {} }); + }); }); diff --git a/packages/cli/src/cli/lib/probe-core.ts b/packages/cli/src/cli/lib/probe-core.ts index e00b16ee1..b5e5f0917 100644 --- a/packages/cli/src/cli/lib/probe-core.ts +++ b/packages/cli/src/cli/lib/probe-core.ts @@ -21,10 +21,11 @@ import net from 'node:net'; import path from 'node:path'; import type { ToolId } from './agent-tools-mcp.js'; +import type { ClaudeFileState, OpenCodeState } from './init-leftovers.js'; import type { EnginePlatform } from './relaycast-engine-install.js'; /** The core `relay init` installs. Pinned so every machine runs the same core. */ -export const PROBE_VERSION = '15.0.1'; +export const PROBE_VERSION = '15.0.2'; /** Release tags of the core; distinct from the CLI's `v*` tags. */ export const PROBE_TAG_PREFIX = 'probe-v'; const RELEASE_BASE = 'https://github.com/AgentWorkforce/relay/releases/download'; @@ -579,6 +580,13 @@ export interface DeliveryRecord { * puts back in place of the `hold` the core writes. */ claudeInbound: Record; + /** + * Each Claude login's `settings.json` as init found it, by file, before the + * core first edited it: what `--uninstall` tidies back (`tidyClaudeSettings`). + */ + claudeFiles?: Record; + /** The OpenCode plugin's place as init found it (`tidyOpenCodePlugin`). */ + openCode?: OpenCodeState; } const strings = (value: unknown): string[] => @@ -595,6 +603,38 @@ function originals(value: unknown): Record { ); } +const flag = (value: unknown, key: string): boolean | undefined => { + const item = (value as Record)[key]; + return typeof item === 'boolean' ? item : undefined; +}; + +/** Recorded Claude file states; entries of another shape are dropped. */ +function claudeFileStatesOf(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const states: Record = {}; + for (const [file, state] of Object.entries(value)) { + if (!state || typeof state !== 'object') continue; + const existed = flag(state, 'existed'); + const backup = flag(state, 'backup'); + const dir = flag(state, 'dir'); + if (existed === undefined || backup === undefined || dir === undefined) continue; + states[file] = { existed, backup, dir }; + } + return Object.keys(states).length > 0 ? states : undefined; +} + +/** The recorded OpenCode state, if sane. */ +function openCodeStateOf(value: unknown): OpenCodeState | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const file = (value as Record).file; + const plugin = flag(value, 'plugin'); + const off = flag(value, 'off'); + const dir = flag(value, 'dir'); + if (typeof file !== 'string' || !path.isAbsolute(file)) return undefined; + if (plugin === undefined || off === undefined || dir === undefined) return undefined; + return { file, plugin, off, dir }; +} + /** * The record of what init turned on. * @param file - `/local/core-settings.json` @@ -603,7 +643,14 @@ function originals(value: unknown): Record { export function readDeliveryRecord(file: string): DeliveryRecord { try { const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Record | null; - return { turnedOn: strings(parsed?.turnedOn), claudeInbound: originals(parsed?.claudeInbound) }; + const claudeFiles = claudeFileStatesOf(parsed?.claudeFiles); + const openCode = openCodeStateOf(parsed?.openCode); + return { + turnedOn: strings(parsed?.turnedOn), + claudeInbound: originals(parsed?.claudeInbound), + ...(claudeFiles ? { claudeFiles } : {}), + ...(openCode ? { openCode } : {}), + }; } catch { return { turnedOn: [], claudeInbound: {} }; }