Skip to content

Commit 02978fb

Browse files
committed
fix: harden input validation, race-safe inbox reads, and error wrapping
1 parent 8fafd5a commit 02978fb

9 files changed

Lines changed: 363 additions & 16 deletions

File tree

src/claude_teams/messaging.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -62,22 +62,32 @@ def read_inbox(
6262
if not path.exists():
6363
return []
6464

65-
raw_list = json.loads(path.read_text())
66-
all_msgs = [InboxMessage.model_validate(entry) for entry in raw_list]
67-
68-
if unread_only:
69-
result = [m for m in all_msgs if not m.read]
65+
if mark_as_read:
66+
lock_path = path.parent / ".lock"
67+
with file_lock(lock_path):
68+
raw_list = json.loads(path.read_text())
69+
all_msgs = [InboxMessage.model_validate(entry) for entry in raw_list]
70+
71+
if unread_only:
72+
result = [m for m in all_msgs if not m.read]
73+
else:
74+
result = list(all_msgs)
75+
76+
if result:
77+
for m in all_msgs:
78+
if m in result:
79+
m.read = True
80+
serialized = [m.model_dump(by_alias=True, exclude_none=True) for m in all_msgs]
81+
path.write_text(json.dumps(serialized))
82+
83+
return result
7084
else:
71-
result = list(all_msgs)
72-
73-
if mark_as_read and result:
74-
for m in all_msgs:
75-
if m in result:
76-
m.read = True
77-
serialized = [m.model_dump(by_alias=True, exclude_none=True) for m in all_msgs]
78-
path.write_text(json.dumps(serialized))
85+
raw_list = json.loads(path.read_text())
86+
all_msgs = [InboxMessage.model_validate(entry) for entry in raw_list]
7987

80-
return result
88+
if unread_only:
89+
return [m for m in all_msgs if not m.read]
90+
return list(all_msgs)
8191

8292

8393
def append_message(

src/claude_teams/server.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ def team_create(
6161
def team_delete(team_name: str, ctx: Context) -> dict:
6262
"""Delete a team and all its data. Fails if any teammates are still active.
6363
Removes both team config and task directories."""
64-
result = teams.delete_team(team_name)
64+
try:
65+
result = teams.delete_team(team_name)
66+
except (RuntimeError, FileNotFoundError) as e:
67+
raise ToolError(str(e))
6568
_get_lifespan(ctx)["active_team"] = None
6669
return result.model_dump()
6770

@@ -213,6 +216,12 @@ def send_message(
213216
).model_dump(exclude_none=True)
214217

215218
elif type == "plan_approval_response":
219+
if not recipient:
220+
raise ToolError("Plan approval recipient must not be empty")
221+
config = teams.read_config(team_name)
222+
member_names = {m.name for m in config.members}
223+
if recipient not in member_names:
224+
raise ToolError(f"Recipient {recipient!r} is not a member of team {team_name!r}")
216225
if approve:
217226
messaging.send_plain_message(
218227
team_name, sender, recipient,
@@ -354,12 +363,15 @@ async def poll_inbox(
354363
"""Poll an agent's inbox for new unread messages, waiting up to timeout_ms.
355364
Returns unread messages and marks them as read. Convenience tool for MCP
356365
clients that cannot watch the filesystem."""
366+
msgs = messaging.read_inbox(team_name, agent_name, unread_only=True, mark_as_read=True)
367+
if msgs:
368+
return [m.model_dump(by_alias=True, exclude_none=True) for m in msgs]
357369
deadline = time.time() + timeout_ms / 1000.0
358370
while time.time() < deadline:
371+
await asyncio.sleep(0.5)
359372
msgs = messaging.read_inbox(team_name, agent_name, unread_only=True, mark_as_read=True)
360373
if msgs:
361374
return [m.model_dump(by_alias=True, exclude_none=True) for m in msgs]
362-
await asyncio.sleep(0.5)
363375
return []
364376

365377

src/claude_teams/spawner.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from claude_teams import messaging, teams
1010
from claude_teams.models import COLOR_PALETTE, InboxMessage, TeammateMember
11+
from claude_teams.teams import _VALID_NAME_RE
1112

1213

1314
def discover_claude_binary() -> str:
@@ -62,6 +63,13 @@ def spawn_teammate(
6263
plan_mode_required: bool = False,
6364
base_dir: Path | None = None,
6465
) -> TeammateMember:
66+
if not _VALID_NAME_RE.match(name):
67+
raise ValueError(f"Invalid agent name: {name!r}. Use only letters, numbers, hyphens, underscores.")
68+
if len(name) > 64:
69+
raise ValueError(f"Agent name too long ({len(name)} chars, max 64)")
70+
if name == "team-lead":
71+
raise ValueError("Agent name 'team-lead' is reserved")
72+
6573
color = assign_color(team_name, base_dir)
6674
now_ms = int(time.time() * 1000)
6775

src/claude_teams/teams.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ def create_team(
4545
) -> TeamCreateResult:
4646
if not _VALID_NAME_RE.match(name):
4747
raise ValueError(f"Invalid team name: {name!r}. Use only letters, numbers, hyphens, underscores.")
48+
if len(name) > 64:
49+
raise ValueError(f"Team name too long ({len(name)} chars, max 64): {name[:20]!r}...")
4850

4951
teams_dir = _teams_dir(base_dir)
5052
tasks_dir = _tasks_dir(base_dir)
@@ -134,6 +136,9 @@ def delete_team(name: str, base_dir: Path | None = None) -> TeamDeleteResult:
134136

135137
def add_member(name: str, member: TeammateMember, base_dir: Path | None = None) -> None:
136138
config = read_config(name, base_dir=base_dir)
139+
existing_names = {m.name for m in config.members}
140+
if member.name in existing_names:
141+
raise ValueError(f"Member {member.name!r} already exists in team {name!r}")
137142
config.members.append(member)
138143
write_config(name, config, base_dir=base_dir)
139144

stress_test_lifecycle.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""Stress test: Team Lifecycle Edge Cases.
2+
3+
Calls the same functions the MCP server tools delegate to,
4+
exercising identical validation and I/O code paths.
5+
"""
6+
import json
7+
import tempfile
8+
import traceback
9+
from pathlib import Path
10+
11+
from claude_teams import teams
12+
13+
SESSION_ID = "stress-test-session-001"
14+
15+
# Use an isolated temp directory so we don't touch ~/.claude
16+
tmp_base = Path(tempfile.mkdtemp(prefix="stress_test_"))
17+
(tmp_base / "teams").mkdir()
18+
(tmp_base / "tasks").mkdir()
19+
20+
results = []
21+
22+
def run_test(num, name, fn, expected):
23+
actual = ""
24+
passed = False
25+
try:
26+
ret = fn()
27+
actual = repr(ret) if not isinstance(ret, str) else ret
28+
# If we got here without exception, result is "success"
29+
if "success" in expected.lower() or "created" in expected.lower() or "returns" in expected.lower() or "valid" in expected.lower():
30+
passed = True
31+
else:
32+
passed = False
33+
except Exception as e:
34+
actual = f"{type(e).__name__}: {e}"
35+
if "error" in expected.lower() or "fail" in expected.lower() or "reject" in expected.lower():
36+
passed = True
37+
else:
38+
passed = False
39+
results.append((num, name, expected, actual, "PASS" if passed else "FAIL"))
40+
print(f" {'PASS' if passed else 'FAIL'} | Test {num}: {name}")
41+
print(f" Expected: {expected}")
42+
print(f" Actual: {actual}")
43+
print()
44+
45+
46+
# ── Test 1: Create team with empty name ──
47+
run_test(1, "Create team with empty name",
48+
lambda: teams.create_team(name="", session_id=SESSION_ID, base_dir=tmp_base),
49+
"Error/reject: invalid team name")
50+
51+
# ── Test 2: Create team with special characters ──
52+
run_test(2, "Create team with special characters",
53+
lambda: teams.create_team(name="test!@#$%^&*()", session_id=SESSION_ID, base_dir=tmp_base),
54+
"Error/reject: invalid team name")
55+
56+
# ── Test 3: Create team with spaces ──
57+
run_test(3, "Create team with spaces",
58+
lambda: teams.create_team(name="my team name", session_id=SESSION_ID, base_dir=tmp_base),
59+
"Error/reject: invalid team name")
60+
61+
# ── Test 4: Create team with very long name (500 chars) ──
62+
run_test(4, "Create team with very long name (500 chars)",
63+
lambda: teams.create_team(name="a" * 500, session_id=SESSION_ID, base_dir=tmp_base),
64+
"Success or error depending on filesystem limits")
65+
66+
# ── Test 5: Create valid team ──
67+
run_test(5, "Create valid team stress-test-lifecycle-1",
68+
lambda: teams.create_team(name="stress-test-lifecycle-1", session_id=SESSION_ID, base_dir=tmp_base),
69+
"Success: team created")
70+
71+
# ── Test 6: Create duplicate team ──
72+
run_test(6, "Create duplicate team stress-test-lifecycle-1",
73+
lambda: teams.create_team(name="stress-test-lifecycle-1", session_id=SESSION_ID, base_dir=tmp_base),
74+
"Success or silent overwrite (no uniqueness check in create_team)")
75+
76+
# ── Test 7: Read config of non-existent team ──
77+
run_test(7, "Read config of non-existent team",
78+
lambda: teams.read_config(name="nonexistent-team-xyz", base_dir=tmp_base),
79+
"Error/fail: team not found or FileNotFoundError")
80+
81+
# ── Test 8: Read config of valid team ──
82+
run_test(8, "Read config of valid team stress-test-lifecycle-1",
83+
lambda: teams.read_config(name="stress-test-lifecycle-1", base_dir=tmp_base),
84+
"Success: returns TeamConfig")
85+
86+
# ── Test 9: Delete non-existent team ──
87+
run_test(9, "Delete non-existent team",
88+
lambda: teams.delete_team(name="nonexistent-team-xyz", base_dir=tmp_base),
89+
"Error/fail: team not found")
90+
91+
# ── Test 10: Delete valid team ──
92+
run_test(10, "Delete valid team stress-test-lifecycle-1",
93+
lambda: teams.delete_team(name="stress-test-lifecycle-1", base_dir=tmp_base),
94+
"Success: team deleted")
95+
96+
# ── Test 11: Double delete ──
97+
run_test(11, "Double delete stress-test-lifecycle-1",
98+
lambda: teams.delete_team(name="stress-test-lifecycle-1", base_dir=tmp_base),
99+
"Error/fail: team already deleted")
100+
101+
# ── Test 12: Create team with unicode ──
102+
run_test(12, "Create team with unicode emoji",
103+
lambda: teams.create_team(name="test-unicode-\U0001f680", session_id=SESSION_ID, base_dir=tmp_base),
104+
"Error/reject: invalid team name (regex rejects unicode)")
105+
106+
# ── Test 13: Create team with dots ──
107+
run_test(13, "Create team with dots",
108+
lambda: teams.create_team(name="test.dotted.name", session_id=SESSION_ID, base_dir=tmp_base),
109+
"Error/reject: dots not in allowed charset")
110+
111+
# ── Test 14: Create team with leading hyphen ──
112+
run_test(14, "Create team with leading hyphen",
113+
lambda: teams.create_team(name="-leading-hyphen", session_id=SESSION_ID, base_dir=tmp_base),
114+
"Success: hyphens are allowed by regex ^[A-Za-z0-9_-]+$")
115+
116+
# ── Test 15: Create team with only numbers ──
117+
run_test(15, "Create team with only numbers",
118+
lambda: teams.create_team(name="12345", session_id=SESSION_ID, base_dir=tmp_base),
119+
"Success: digits are allowed by regex")
120+
121+
122+
# ── Cleanup ──
123+
print("=" * 60)
124+
print("CLEANUP: removing teams created during tests")
125+
cleanup_teams = []
126+
# Test 4 may have created a long-name team
127+
long_name = "a" * 500
128+
for tname in [long_name, "-leading-hyphen", "12345"]:
129+
try:
130+
teams.delete_team(name=tname, base_dir=tmp_base)
131+
cleanup_teams.append(tname[:40])
132+
except Exception:
133+
pass
134+
if cleanup_teams:
135+
print(f" Deleted: {cleanup_teams}")
136+
else:
137+
print(" Nothing to clean up.")
138+
139+
import shutil
140+
shutil.rmtree(tmp_base, ignore_errors=True)
141+
print(f" Removed temp dir: {tmp_base}")
142+
143+
# ── Summary Table ──
144+
print()
145+
print("=" * 120)
146+
print(f"| {'#':>2} | {'Test':<50} | {'Expected':<55} | {'Pass/Fail':<9} |")
147+
print(f"|{'-'*4}|{'-'*52}|{'-'*57}|{'-'*11}|")
148+
for num, tname, expected, actual, verdict in results:
149+
print(f"| {num:>2} | {tname:<50} | {expected:<55} | {verdict:<9} |")
150+
print("=" * 120)
151+
152+
total = len(results)
153+
passed = sum(1 for r in results if r[4] == "PASS")
154+
print(f"\nTotal: {total} Passed: {passed} Failed: {total - passed}")
155+
156+
# ── Detailed Actual Results ──
157+
print()
158+
print("DETAILED ACTUAL RESULTS:")
159+
print("-" * 120)
160+
for num, tname, expected, actual, verdict in results:
161+
# Truncate long actual results for readability
162+
actual_display = actual if len(actual) < 200 else actual[:200] + "..."
163+
print(f" Test {num:>2} [{verdict}]: {actual_display}")

tests/test_messaging.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import re
5+
import threading
56
from pathlib import Path
67

78
import pytest
@@ -163,6 +164,36 @@ def test_send_shutdown_request_with_reason(tmp_claude_dir):
163164
assert parsed["reason"] == "Done"
164165

165166

167+
def test_should_not_lose_message_appended_during_mark_as_read(tmp_claude_dir):
168+
import fcntl
169+
170+
msg_a = InboxMessage(from_="lead", text="A", timestamp=now_iso(), read=False, summary="a")
171+
append_message("test-team", "race", msg_a, base_dir=tmp_claude_dir)
172+
173+
path = inbox_path("test-team", "race", base_dir=tmp_claude_dir)
174+
lock_path = path.parent / ".lock"
175+
lock_path.touch(exist_ok=True)
176+
177+
completed = threading.Event()
178+
179+
def do_read():
180+
read_inbox("test-team", "race", mark_as_read=True, base_dir=tmp_claude_dir)
181+
completed.set()
182+
183+
with open(lock_path) as f:
184+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
185+
reader = threading.Thread(target=do_read)
186+
reader.start()
187+
completed_without_lock = completed.wait(timeout=1.0)
188+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
189+
190+
reader.join(timeout=5)
191+
192+
assert not completed_without_lock, (
193+
"read_inbox(mark_as_read=True) completed without acquiring the inbox lock"
194+
)
195+
196+
166197
def test_now_iso_format():
167198
ts = now_iso()
168199
assert re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$", ts)

0 commit comments

Comments
 (0)