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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<slug>-<agent>` instead of `agentchattr-<agent>`, so each project's wrappers are distinct.
- `AGENTCHATTR_AGENT_<KEY>` — overrides one field of `[agents.<name>]` 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)
Expand Down
129 changes: 129 additions & 0 deletions tests/test_wrapper_repo_sessions.py
Original file line number Diff line number Diff line change
@@ -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_<KEY>` 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()
42 changes: 40 additions & 2 deletions wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_<KEY> 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-<agent>`. 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":
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand Down