From 93716bd4c91f80b1c3d9c19646d24d53f74b37d3 Mon Sep 17 00:00:00 2001 From: maxlamagna Date: Tue, 11 Aug 2026 21:43:07 +0100 Subject: [PATCH] feat(wrapper): opt-in repo-qualified tmux sessions and per-agent env overrides Shared installs collide. Each project's server issues its own slot counter from 1, so the first wrapper for an agent in any project asks for the same `agentchattr-` tmux session, and wrapper_unix kills a pre-existing session of that name before creating its own. Two onboarded projects therefore evict each other. Two opt-in mechanisms, both no-ops when unset: - `AGENTCHATTR_AGENT_` overlays one agent's config, so a per-project launcher need not edit the shared config.toml. Whitelisted to `cwd` and `mcp_settings_path` so a committed project env file cannot redirect security-sensitive keys such as `command` or `mcp_inject`. - `AGENTCHATTR_REPO_SLUG` qualifies the tmux session name. Both are extracted into named helpers so they can be tested directly. The config overlay keeps its original in-place update semantics, so this stays a testability refactor rather than a second behavioural change. 17 behavioural tests; six critical guard mutations, 6/6 killed. README documents both variables alongside the existing AGENTCHATTR_* list. Refs #67 --- README.md | 13 +++ tests/test_wrapper_repo_sessions.py | 129 ++++++++++++++++++++++++++++ wrapper.py | 42 ++++++++- 3 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 tests/test_wrapper_repo_sessions.py diff --git a/README.md b/README.md index 3848361b..5c658d3f 100644 --- a/README.md +++ b/README.md @@ -475,6 +475,19 @@ python wrapper.py claude \ Relative paths resolve against the shell's current directory (not agentchattr's install location), so `./.agentchattr` ends up inside your project folder. +**Running the same agent in two projects at once.** Each project's server numbers its own slots from 1, so the first `codex` wrapper in *any* project asks tmux for `agentchattr-codex` — and starting the second one kills the first. Two more opt-in env vars keep them apart: + +- `AGENTCHATTR_REPO_SLUG` — when set, names the tmux session `agentchattr--` instead of `agentchattr-`, so each project's wrappers are distinct. +- `AGENTCHATTR_AGENT_` — overrides one field of `[agents.]` for this invocation only, so a per-project launcher does not have to edit the shared `config.toml`. Only `AGENTCHATTR_AGENT_CWD` and `AGENTCHATTR_AGENT_MCP_SETTINGS_PATH` are accepted; security-sensitive fields such as `command` and `mcp_inject` are deliberately not overridable from the environment. + +```bash +AGENTCHATTR_REPO_SLUG=project-a \ +AGENTCHATTR_AGENT_CWD=/repos/project-a \ +python wrapper.py codex --data-dir ./project-a/.agentchattr --port 8310 +``` + +Both are unset by default and change nothing when absent. + Server and wrappers share the same `AGENTCHATTR_*` env vars and the same flag names, so a launcher/profile can run multiple isolated instances by passing matching values to each process. If no flags or env vars are set, `config.toml` is used exactly as before — zero change for existing setups. ### API agents (local models) diff --git a/tests/test_wrapper_repo_sessions.py b/tests/test_wrapper_repo_sessions.py new file mode 100644 index 00000000..c6a64315 --- /dev/null +++ b/tests/test_wrapper_repo_sessions.py @@ -0,0 +1,129 @@ +"""Concurrent per-project wrapper sessions for shared installs (issue #67). + +Two opt-in mechanisms, both no-ops when their env vars are unset: + +* `AGENTCHATTR_AGENT_` overlays one agent's config so a per-project + launcher does not have to edit the shared config.toml. Whitelisted, because + a committed project env file must not be able to redirect `command` or + `mcp_inject`. +* `AGENTCHATTR_REPO_SLUG` qualifies the tmux session name so two repos' + wrappers stop evicting each other. + +Both helpers take their input explicitly rather than reading os.environ, so +these tests need no environment mutation and cannot leak into other suites. +""" + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from wrapper import ( # noqa: E402 + _apply_agent_env_overrides, + _build_tmux_session_name, +) + + +class AgentEnvOverrideTests(unittest.TestCase): + def test_cwd_is_overridden(self): + cfg = _apply_agent_env_overrides( + {"cwd": "."}, {"AGENTCHATTR_AGENT_CWD": "/repos/project-a"}) + self.assertEqual(cfg["cwd"], "/repos/project-a") + + def test_mcp_settings_path_is_overridden(self): + cfg = _apply_agent_env_overrides( + {"mcp_settings_path": "~/.config/base.json"}, + {"AGENTCHATTR_AGENT_MCP_SETTINGS_PATH": "/repos/a/mcp.json"}) + self.assertEqual(cfg["mcp_settings_path"], "/repos/a/mcp.json") + + def test_override_applies_to_absent_key(self): + cfg = _apply_agent_env_overrides( + {}, {"AGENTCHATTR_AGENT_CWD": "/repos/project-a"}) + self.assertEqual(cfg["cwd"], "/repos/project-a") + + def test_key_suffix_is_matched_case_insensitively(self): + """The env var is upper-case by convention; the config key is not.""" + cfg = _apply_agent_env_overrides( + {"cwd": "."}, {"agentchattr_agent_cwd": "/lower"}) + self.assertEqual(cfg["cwd"], ".", "prefix match must stay case-sensitive") + cfg = _apply_agent_env_overrides( + {"cwd": "."}, {"AGENTCHATTR_AGENT_CWD": "/upper"}) + self.assertEqual(cfg["cwd"], "/upper") + + def test_empty_value_does_not_override(self): + cfg = _apply_agent_env_overrides( + {"cwd": "/keep"}, {"AGENTCHATTR_AGENT_CWD": ""}) + self.assertEqual(cfg["cwd"], "/keep") + + def test_command_cannot_be_overridden(self): + """The whole point of the whitelist: no arbitrary command injection.""" + cfg = _apply_agent_env_overrides( + {"command": "claude"}, {"AGENTCHATTR_AGENT_COMMAND": "/bin/evil"}) + self.assertEqual(cfg["command"], "claude") + + def test_mcp_inject_cannot_be_overridden(self): + cfg = _apply_agent_env_overrides( + {"mcp_inject": "settings"}, + {"AGENTCHATTR_AGENT_MCP_INJECT": "proxy_file"}) + self.assertEqual(cfg["mcp_inject"], "settings") + + def test_unknown_key_is_ignored(self): + cfg = _apply_agent_env_overrides( + {}, {"AGENTCHATTR_AGENT_LABEL": "spoofed"}) + self.assertNotIn("label", cfg) + + def test_unrelated_env_vars_are_ignored(self): + cfg = _apply_agent_env_overrides( + {"cwd": "/keep"}, + {"AGENTCHATTR_PORT": "8310", "HOME": "/root", "PATH": "/bin"}) + self.assertEqual(cfg, {"cwd": "/keep"}) + + def test_empty_environment_leaves_config_untouched(self): + cfg = _apply_agent_env_overrides({"cwd": ".", "command": "codex"}, {}) + self.assertEqual(cfg, {"cwd": ".", "command": "codex"}) + + def test_updates_the_config_in_place(self): + """Callers rely on the loaded config seeing the override.""" + original = {"cwd": "."} + returned = _apply_agent_env_overrides( + original, {"AGENTCHATTR_AGENT_CWD": "/repos/project-a"}) + self.assertIs(returned, original) + self.assertEqual(original["cwd"], "/repos/project-a") + + +class TmuxSessionNameTests(unittest.TestCase): + def test_slug_qualifies_the_session_name(self): + self.assertEqual( + _build_tmux_session_name("codex", "project-a"), + "agentchattr-project-a-codex") + + def test_empty_slug_preserves_the_original_name(self): + self.assertEqual( + _build_tmux_session_name("codex", ""), "agentchattr-codex") + + def test_whitespace_only_slug_preserves_the_original_name(self): + self.assertEqual( + _build_tmux_session_name("codex", " "), "agentchattr-codex") + + def test_none_slug_preserves_the_original_name(self): + """os.environ.get(..., "") cannot return None, but callers may.""" + self.assertEqual( + _build_tmux_session_name("codex", None), "agentchattr-codex") + + def test_surrounding_whitespace_is_stripped(self): + self.assertEqual( + _build_tmux_session_name("codex", " project-a "), + "agentchattr-project-a-codex") + + def test_multi_instance_names_are_kept_distinct(self): + """Slot suffixes and slugs compose: repo A's codex-2 is not repo B's.""" + self.assertNotEqual( + _build_tmux_session_name("codex-2", "project-a"), + _build_tmux_session_name("codex-2", "project-b")) + + +if __name__ == "__main__": + unittest.main() diff --git a/wrapper.py b/wrapper.py index c7fde420..7a678647 100644 --- a/wrapper.py +++ b/wrapper.py @@ -175,6 +175,42 @@ def _resolve_mcp_inject(agent: str, agent_cfg: dict) -> dict: return {} +_AGENT_OVERRIDE_PREFIX = "AGENTCHATTR_AGENT_" +_AGENT_OVERRIDE_ALLOWED = {"cwd", "mcp_settings_path"} + + +def _apply_agent_env_overrides(agent_cfg: dict, environ) -> dict: + """Overlay AGENTCHATTR_AGENT_ env vars onto one agent's config. + + Lets a per-project launcher scope a wrapper to one repo without editing the + shared config.toml. Restricted to a whitelist so a committed project env + file cannot redirect security-sensitive keys such as `command` or + `mcp_inject`; empty values are ignored. Updates and returns `agent_cfg`. + """ + for env_key, env_val in environ.items(): + if not env_key.startswith(_AGENT_OVERRIDE_PREFIX) or not env_val: + continue + cfg_key = env_key[len(_AGENT_OVERRIDE_PREFIX):].lower() + if cfg_key in _AGENT_OVERRIDE_ALLOWED: + agent_cfg[cfg_key] = env_val + return agent_cfg + + +def _build_tmux_session_name(assigned_name: str, repo_slug: str) -> str: + """tmux session name, repo-qualified when a repo slug is supplied. + + Each repo's server issues its own slot counter starting at 1, so the first + wrapper for an agent in ANY repo asks for `agentchattr-`. Since + wrapper_unix kills a pre-existing session of that name before creating its + own, two repos evict each other. An opt-in slug keeps them distinct; unset, + empty or whitespace-only leaves the name exactly as it was. + """ + slug = (repo_slug or "").strip() + if slug: + return f"agentchattr-{slug}-{assigned_name}" + return f"agentchattr-{assigned_name}" + + def _get_server_url(mcp_cfg: dict, transport: str) -> str: """Build the MCP server URL for the given transport.""" if transport == "sse": @@ -586,7 +622,8 @@ def main(): args, extra = parser.parse_known_args() agent = args.agent - agent_cfg = config.get("agents", {}).get(agent, {}) + agent_cfg = _apply_agent_env_overrides( + config.get("agents", {}).get(agent, {}), os.environ) cwd = agent_cfg.get("cwd", ".") command = agent_cfg.get("command", agent) data_dir = ROOT / config.get("server", {}).get("data_dir", "./data") @@ -868,7 +905,8 @@ def _activity_monitor(): else: from wrapper_unix import get_activity_checker, run_agent - unix_session_name = f"agentchattr-{assigned_name}" + unix_session_name = _build_tmux_session_name( + assigned_name, os.environ.get("AGENTCHATTR_REPO_SLUG", "")) _set_activity_checker(get_activity_checker(unix_session_name, trigger_flag=_trigger_flag)) run_kwargs = dict(