diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index bdf409f..dc47e36 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -28,14 +28,19 @@ AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions. _sanitize_herdr_agent_name() { local n="$1" - if [ "${#n}" -le 32 ]; then - printf '%s\n' "$n" + if [ -z "$n" ]; then + printf 'agent\n' return 0 fi - local p="${n:0:16}" - local s="${n: -15}" - local res="${p}-${s}" - printf '%s\n' "${res:0:32}" + local s + s=$(echo "$n" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g') + if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi + if [ "${#s}" -gt 32 ]; then + local p="${s:0:16}" + local suf="${s: -15}" + s="${p}-${suf}" + fi + printf '%s\n' "${s:0:32}" } # Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells @@ -202,17 +207,20 @@ _real_herdr() { _sanitize_herdr_agent_name() { local n="$1" - if [ "${#n}" -le 32 ]; then - printf '%s\n' "$n" + if [ -z "$n" ]; then + printf 'agent\n' return 0 fi - local p="${n:0:16}" - local s="${n: -15}" - local res="${p}-${s}" - printf '%s\n' "${res:0:32}" + local s + s=$(echo "$n" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]/-/g') + if [[ ! "$s" =~ ^[a-z] ]]; then s="x-$s"; fi + if [ "${#s}" -gt 32 ]; then + local p="${s:0:16}" + local suf="${s: -15}" + s="${p}-${suf}" + fi + printf '%s\n' "${s:0:32}" } - -wrapper_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) cmd="${1:-}" if [ -z "$cmd" ]; then echo "herdr shim: no command specified" >&2 @@ -434,7 +442,7 @@ except Exception: fi if [ "$split_dir" = "right" ] || [ "$split_dir" = "down" ]; then - split_json=$(_real_herdr pane split --pane "$sample_pane" --direction "$split_dir" --cwd "${ws:-.}" --no-focus 2>/dev/null || echo "") + split_json=$(_real_herdr pane split --pane "$sample_pane" --direction "$split_dir" --cwd "${ws:-.}" $env_flags --no-focus 2>/dev/null || echo "") target_pane=$(echo "$split_json" | python3 -c " import sys, json try: @@ -448,7 +456,7 @@ except Exception: # W2b: Overflow threshold reached — force create fresh workspace existing_ws="" else - split_json=$(_real_herdr pane split --pane "$sample_pane" --direction right --cwd "${ws:-.}" --no-focus 2>/dev/null || echo "") + split_json=$(_real_herdr pane split --pane "$sample_pane" --direction right --cwd "${ws:-.}" $env_flags --no-focus 2>/dev/null || echo "") target_pane=$(echo "$split_json" | python3 -c " import sys, json try: @@ -462,7 +470,7 @@ except Exception: fi if [ -z "$existing_ws" ] || [ -z "$target_pane" ]; then - ws_json=$(_real_herdr workspace create --cwd "${ws:-.}" --no-focus 2>/dev/null || echo "") + ws_json=$(_real_herdr workspace create --cwd "${ws:-.}" $env_flags --no-focus 2>/dev/null || echo "") target_pane=$(echo "$ws_json" | python3 -c " import sys, json try: @@ -475,24 +483,25 @@ except Exception: " 2>/dev/null || echo "") fi + if [ -z "$target_pane" ]; then + echo "Error: target_pane allocation failed for $name" >&2 + exit 1 + fi + if [ -z "$kind" ]; then + echo "Error: unable to determine agent kind for session $name" >&2 + exit 1 + fi + # W5/W6: Backoff retries (0.5 -> 1 -> 2), abort immediately on usage or unknown flag errors res="" success=0 backoffs=(0.5 1 2) agent_name=$(_sanitize_herdr_agent_name "$name") for i in $(seq 0 2); do - if [ -n "$target_pane" ]; then - if [ -n "$final_cmd" ]; then - res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"${kind:-claude}\" --pane \"$target_pane\" $env_flags -- $final_cmd" 2>&1 || true) - else - res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"${kind:-claude}\" --pane \"$target_pane\" $env_flags" 2>&1 || true) - fi + if [ -n "$final_cmd" ]; then + res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\" -- $final_cmd" 2>&1 || true) else - if [ -n "$final_cmd" ]; then - res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"${kind:-claude}\" $env_flags -- $final_cmd" 2>&1 || true) - else - res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"${kind:-claude}\" $env_flags" 2>&1 || true) - fi + res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\"" 2>&1 || true) fi if echo "$res" | grep -q "agent_started"; then success=1 diff --git a/.agents/skills/lib_py/agents/sanitize.py b/.agents/skills/lib_py/agents/sanitize.py new file mode 100644 index 0000000..871ad82 --- /dev/null +++ b/.agents/skills/lib_py/agents/sanitize.py @@ -0,0 +1,30 @@ +# sanitize.py — Unified agent name sanitization contract for herdr 0.8.0 (E3 & E4) + +import re + +def sanitize_herdr_agent_name(name: str) -> str: + """ + Sanitizes an agent name according to herdr 0.8.0 rules: + 1. Convert to lowercase. + 2. Replace invalid characters ([^a-z0-9_-]) with '-'. + 3. Ensure starts with a lowercase letter [a-z], prefixing with 'x-' if needed. + 4. Truncate to maximum 32 characters (16 + '-' + 15). + """ + if not name: + return "agent" + + # 1. Lowercase + s = str(name).lower() + + # 2. Replace illegal characters with '-' + s = re.sub(r'[^a-z0-9_-]', '-', s) + + # 3. Ensure starts with a letter [a-z] + if not s or not s[0].isalpha(): + s = "x-" + s + + # 4. Truncate to 32 chars if needed + if len(s) > 32: + s = s[:16] + '-' + s[-15:] + + return s diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index 9f50048..71bf748 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -458,10 +458,7 @@ def _pin_and_verify_resume(s, agent, cwd, uuid, degraded=False): actions.append(f"updated {id_name} id: {uuid}") -def _sanitize(n): - if len(n) <= 32: - return n - return f"{n[:16]}-{n[-15:]}"[:32] +from lib_py.agents.sanitize import sanitize_herdr_agent_name as _sanitize yaml_sessions = d.get('herdr_sessions', []) yaml_session_names = {s['name'] for s in yaml_sessions if s.get('name')} diff --git a/.agents/skills/multi-agent-mux-status/scripts/status.sh b/.agents/skills/multi-agent-mux-status/scripts/status.sh index 9026fe8..11185f8 100755 --- a/.agents/skills/multi-agent-mux-status/scripts/status.sh +++ b/.agents/skills/multi-agent-mux-status/scripts/status.sh @@ -122,21 +122,10 @@ def get_job_status(s): sessions_detail = [] -def _sanitize(n): - if len(n) <= 32: - return n - return f"{n[:16]}-{n[-15:]}"[:32] +from lib_py.agents.sanitize import sanitize_herdr_agent_name as _sanitize def is_alive(name, server): - if f"{name}|{server}" in alive or f"{_sanitize(name)}|{server}" in alive: - return True - prefix = name[:14] - suffix = name[-12:] - for item in alive: - a_name, a_srv = item.split("|", 1) if "|" in item else (item, "default") - if a_srv == server and a_name.startswith(prefix) and a_name.endswith(suffix): - return True - return False + return f"{name}|{server}" in alive or f"{_sanitize(name)}|{server}" in alive for s in d.get('herdr_sessions', []): name = s.get('name', '?') @@ -235,10 +224,7 @@ def get_job_status(s): return (jid, 'unknown') -def _sanitize(n): - if len(n) <= 32: - return n - return f"{n[:16]}-{n[-15:]}"[:32] +from lib_py.agents.sanitize import sanitize_herdr_agent_name as _sanitize sessions = d.get('herdr_sessions', []) print(f"agent-sessions status — {drift['timestamp']} (herdr_confirmed={drift['herdr_confirmed']})") @@ -248,15 +234,7 @@ print("-" * 136) if not sessions: print("(no sessions registered)") def is_alive(name, server): - if f"{name}|{server}" in alive or f"{_sanitize(name)}|{server}" in alive: - return True - prefix = name[:14] - suffix = name[-12:] - for item in alive: - a_name, a_srv = item.split("|", 1) if "|" in item else (item, "default") - if a_srv == server and a_name.startswith(prefix) and a_name.endswith(suffix): - return True - return False + return f"{name}|{server}" in alive or f"{_sanitize(name)}|{server}" in alive for s in sessions: name = s.get('name', '?') diff --git a/tests/conftest.py b/tests/conftest.py index 752b06f..d358633 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -193,6 +193,12 @@ if cmd1 == "workspace": elif args[i] == "--cwd": cwd = args[i+1] i += 2 + elif args[i] == "--env": + env_val = args[i+1] + if "=" in env_val: + k, v = env_val.split("=", 1) + os.environ[k] = v + i += 2 else: i += 1 workspaces = state.get("workspaces", []) @@ -250,7 +256,17 @@ elif cmd1 == "pane": print(json.dumps({"result": {"panes": panes_list}})) sys.exit(0) elif cmd2 == "split": - # W11: pane split handler + # W11: pane split handler with --env support + i = 2 + while i < len(args): + if args[i] == "--env": + env_val = args[i+1] + if "=" in env_val: + k, v = env_val.split("=", 1) + os.environ[k] = v + i += 2 + else: + i += 1 print(json.dumps({"result": {"type": "pane_split", "pane": {"pane_id": "w1:p_split"}}})) sys.exit(0) elif cmd2 == "layout": @@ -351,6 +367,7 @@ elif cmd1 == "agent": ws = "" cwd = "" pane = "" + agent_type = "" # Find where -- is try: double_dash_idx = args.index("--") @@ -360,8 +377,8 @@ elif cmd1 == "agent": agent_cmd = [] opts = args[3:] - # Whitelist herdr allowed flags (0.7.4 + 0.8.0) - whitelist = {"--cwd", "--workspace", "--tab", "--split", "--env", "--focus", "--no-focus", "--kind", "--pane", "--timeout"} + # Whitelist herdr 0.8.0 allowed flags for agent start + whitelist = {"--kind", "--pane", "--timeout"} i = 0 unknown_flags = [] while i < len(opts): @@ -370,13 +387,7 @@ elif cmd1 == "agent": unknown_flags.append(opt) i += 1 continue - if opt == "--workspace": - ws = opts[i+1] - i += 2 - elif opt == "--cwd": - cwd = opts[i+1] - i += 2 - elif opt == "--kind": + if opt == "--kind": agent_type = opts[i+1] i += 2 elif opt == "--pane": @@ -384,42 +395,22 @@ elif cmd1 == "agent": i += 2 elif opt == "--timeout": i += 2 - elif opt == "--env": - env_val = opts[i+1] - if "=" in env_val: - k, v = env_val.split("=", 1) - os.environ[k] = v - i += 2 - elif opt in ("--split", "--tab"): - i += 2 else: i += 1 if unknown_flags: - print(f"usage: herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- (unknown flag: {unknown_flags[0]})") - sys.exit(0) - - # Determine the agent type (claude, agy, hermes, cline) - agent_type = "claude" - if agent_cmd: - if "claude" in agent_cmd[0]: - agent_type = "claude" - elif "agy" in agent_cmd[0]: - agent_type = "agy" - elif "hermes" in agent_cmd[0]: - agent_type = "hermes" - elif "cline" in agent_cmd[0]: - agent_type = "cline" - else: - # guess from name - if "claude" in name: - agent_type = "claude" - elif "agy" in name: - agent_type = "agy" - elif "hermes" in name: - agent_type = "hermes" - elif "cline" in name: - agent_type = "cline" + sys.stderr.write("error: unexpected argument '" + str(unknown_flags[0]) + "' found\\\\n\\\\nUsage: herdr agent start --kind --pane [OPTIONS] [-- [AGENT_ARG]...]\\\\n") + sys.exit(1) + + if not agent_type or not pane: + sys.stderr.write("error: the following required arguments were not provided:\\\\n --kind \\\\n --pane \\\\n") + sys.exit(1) + + # Name validation: ^[a-z][a-z0-9_-]{0,31}$ + import re + if not re.match(r'^[a-z][a-z0-9_-]{0,31}$', name): + print(f"error: invalid value '{name}' for '': agent name must start with a lowercase letter and contain only lowercase letters, digits, '-' or '_' (1-32 characters)", file=sys.stderr) + sys.exit(1) # TUI Welcome Tokens definition to prevent TUI readiness check timeout buffer_content = { diff --git a/tests/test_b4_session_created.py b/tests/test_b4_session_created.py index 32462b6..b502d33 100644 --- a/tests/test_b4_session_created.py +++ b/tests/test_b4_session_created.py @@ -12,7 +12,7 @@ import pytest def _create_mock_agent_session(mam_sandbox): """Helper to register an active agent session in mock herdr.""" subprocess.run(["herdr", "agent", "start", "test-creator-claude", - "--workspace", str(mam_sandbox), "--cwd", str(mam_sandbox), + "--kind", "claude", "--pane", "w1:p1", "--", "claude"], capture_output=True, text=True, cwd=str(mam_sandbox)) diff --git a/tests/test_herdr_shim_contract.py b/tests/test_herdr_shim_contract.py index 5112511..a151a7a 100644 --- a/tests/test_herdr_shim_contract.py +++ b/tests/test_herdr_shim_contract.py @@ -22,11 +22,11 @@ def test_h1_to_h8_shim_contract(mam_sandbox, mock_herdr, mock_agents): tmp_path = mam_sandbox lib_path = tmp_path / ".agents" / "skills" / "lib.sh" - # Test H-1 & H-4: Allowed flags and absolute path preservation + # Test H-1 & H-4: Allowed flags and 0.8.0 contract cmd_str = f""" source {lib_path} _init_herdr_isolation - herdr new-session -d -s "test-h1-sess" -c "{tmp_path}" "/usr/bin/python3 -c 'print(1)'" + herdr new-session -d -s "test-h1-creator-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions" """ res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True, cwd=str(tmp_path)) assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}" @@ -39,7 +39,8 @@ def test_h1_to_h8_shim_contract(mam_sandbox, mock_herdr, mock_agents): agent_start_calls = [c for c in calls if len(c) > 1 and c[0] == "agent" and c[1] == "start"] assert len(agent_start_calls) > 0 - whitelist = {"--cwd", "--workspace", "--tab", "--split", "--env", "--focus", "--no-focus", "--kind", "--pane", "--timeout"} + whitelist = {"--kind", "--pane", "--timeout"} + forbidden = {"--cwd", "--workspace", "--tab", "--split", "--env", "--focus", "--no-focus"} for call in agent_start_calls: try: dd_idx = call.index("--") @@ -49,14 +50,14 @@ def test_h1_to_h8_shim_contract(mam_sandbox, mock_herdr, mock_agents): opts = call[3:] argv = [] - # H-1: Check no un-whitelisted flags like --kind or --pane + # H-1: Check only 0.8.0 allowed flags are present and forbidden flags are absent for i in range(len(opts)): if opts[i].startswith("--"): - assert opts[i] in whitelist, f"Forbidden flag in agent start: {opts[i]}" + assert opts[i] in whitelist, f"Unexpected flag in agent start: {opts[i]}" + assert opts[i] not in forbidden, f"Forbidden 0.7.4 flag in agent start: {opts[i]}" - # H-4: Check executable absolute path preserved - if argv: - assert argv[0] == "/usr/bin/python3", f"Executable path mutated: {argv[0]}" + assert "--kind" in opts, "Missing required --kind in agent start" + assert "--pane" in opts, "Missing required --pane in agent start" def test_h9_mock_response_contract_schema(mock_herdr): """H-9: Verify mock_herdr responses match tests/fixtures/herdr_contract.json schema.""" @@ -98,7 +99,7 @@ def test_h11_to_h13_layout_policy(mam_sandbox, mock_herdr, mock_agents): _init_herdr_isolation export MAM_MIN_PANE_COLS=60 export MAM_MIN_PANE_ROWS=20 - herdr new-session -d -s "test-h11-sess" -c "{tmp_path}" "python3 -c 'print(1)'" + herdr new-session -d -s "test-h11-creator-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions" """ res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True, cwd=str(tmp_path)) assert res.returncode == 0, f"Stderr: {res.stderr}" @@ -111,7 +112,7 @@ def test_h14_mock_concurrency_lock_invariant(mock_herdr): import subprocess procs = [] for i in range(10): - p = subprocess.Popen(["python3", "{mock_state.parent / 'bin' / 'herdr'}", "agent", "start", f"agent_{{i}}", "--cwd", "/tmp", "--", "claude"]) + p = subprocess.Popen(["python3", "{mock_state.parent / 'bin' / 'herdr'}", "agent", "start", f"agent_{{i}}", "--kind", "claude", "--pane", f"w1:p{{i}}"]) procs.append(p) for p in procs: p.wait()