fix(lib): resolve B-8 send_keys_safe agy verification bypass and add submission retry test

This commit is contained in:
2026-08-14 09:35:17 +09:00
parent f8bfa07fac
commit 720f8ad224
10 changed files with 307 additions and 8 deletions
@@ -0,0 +1,171 @@
# Cross-Code Review Report: B-8 send_keys_safe agy Path Bypass Removal
**Job ID**: d629619a
**Reviewer**: cline (session: herdr:canary-projects-multi-agent-mux-creator-cline)
**Review Target**: B-8 `send_keys_safe` agy unconditional `return 0` bypass removal and verification integrity cross-check
**Base Commit**: f8bfa07 (clean tree except working changes)
**Date**: 2026-08-13
---
## 1. Executive Summary
This review verifies the removal of the agy early `return 0` bypass in `send_keys_safe()` and the addition of supporting input-region infrastructure (adapter properties, CLI bridge, `input_region.py` module). The change set includes 5 modified files and 2 new files.
**Verdict: PASS** — The core B-8 fix is correct: the agy unconditional `return 0` is removed, and agy sessions now go through the C-m submission verification loop with hardened checks. The test confirms returncode 4 on submission failure (not 0). However, the supporting `input_region.py` module has a missing `base.py` property declaration that causes `AttributeError` (instead of the intended `ValueError`) for the hermes adapter. This is a latent bug in infrastructure not yet wired into `send_keys_safe`, so it does not affect the B-8 fix itself.
---
## 2. Change Set
| File | Change | Lines |
|---|---|---|
| `.agents/skills/lib.sh` | Remove agy early return 0; add agy to skip-paste-check group | -6/+2 |
| `.agents/skills/lib_py/agents/__main__.py` | Add `input-region` CLI command + emit INPUT_* facts | +15 |
| `.agents/skills/lib_py/agents/adapters/agy.py` | Add input_prompt/placeholder/rule_pattern | +12 |
| `.agents/skills/lib_py/agents/adapters/claude.py` | Add input_prompt/placeholder/rule_pattern | +12 |
| `.agents/skills/lib_py/agents/adapters/cline.py` | Add input_prompt/placeholder/rule_pattern | +12 |
| `.agents/skills/lib_py/agents/input_region.py` | NEW: rule-based input region extraction | +40 |
| `tests/test_b8_send_keys_verification.py` | NEW: bash mock test for agy submission failure | +29 |
---
## 3. Core B-8 Fix Analysis (lib.sh)
### Before (lines 1587-1591, removed):
```bash
if [[ "$sess" =~ "agy" ]]; then
_sks_herdr send-keys -t "$sess" C-m
return 0
fi
```
This was the B-8 bug: agy sessions blindly returned 0 (success) after sending C-m, without verifying submission.
### After (line 1588):
```bash
if [[ "$sess" =~ "cline" ]] || [[ "$sess" =~ "claude" ]] || [[ "$sess" =~ "agy" ]]; then
# Skip strict paste check due to scrollout false-positives, proceed to C-m submission loop
true
```
agy is now grouped with cline/claude: paste visibility check is skipped (due to scrollout false-positives), but the session goes through the full C-m submission verification loop (lines 1607-1626). If submission is not accepted after 3 tries, `return 4` is executed instead of `return 0`.
**Verification**: The test `test_send_keys_safe_b8_verification` mocks `_pane_capture` to return static content (no execution token, no pane change) and confirms `send_keys_safe` returns 4 with "Enter not accepted after 3 tries" in stderr. ✅
---
## 4. Supporting Infrastructure Analysis
### 4.1 Adapter Properties (agy.py, claude.py, cline.py)
Each adapter now defines `input_prompt`, `input_placeholder`, and `input_rule_pattern`:
| Adapter | input_prompt | input_placeholder | input_rule_pattern |
|---|---|---|---|
| agy | `>` | `` | `─{10,}` |
| claude | `` | `` | `─{10,}` |
| cline | `` | `Ask anything...` | `─{10,}` |
These are correct and consistent with the agent TUI input area layouts. ✅
### 4.2 CLI Bridge (__main__.py)
New `input-region` command reads pane text from stdin and calls `extract_input_region()`. On success, prints region and exits 0. On exception, prints error to stderr and exits 5.
The `facts` command now also emits `INPUT_PROMPT`, `INPUT_PLACEHOLDER`, `INPUT_RULE_PATTERN` for shell evaluation. ✅
**Manual verification**:
- `input-region agy` with rule-delimited pane → outputs "test prompt", RC=0 ✅
- `input-region cline` with multi-line + placeholder → outputs "line 1\nline 2\nmarker-xyz", RC=0 ✅
- `input-region hermes` → outputs `AttributeError` message, RC=5 ⚠️ (see F-1)
### 4.3 input_region.py
Rule-based extraction: finds horizontal rule separators (`─{10,}`), extracts text between the last two rules (excluding rule lines themselves via `lines[rule_indices[-2] + 1:rule_indices[-1]]`), strips prompt tokens and placeholders.
Key design choices (correct):
- Excludes rule lines from region: `+1` offset on start index ✅
- Falls back to full `lines` when no rules found (supports mocks/unruled panes) ✅
- Checks `not adapter.input_prompt` for unsupported agent detection ✅
- Uses `adapter.input_rule_pattern or r'─{10,}'` fallback ✅
### 4.4 Test (test_b8_send_keys_verification.py)
Bash mock test that sources `lib.sh`, mocks `_pane_quiescent`, `_pane_dialog_open`, `_sks_herdr`, and `_pane_capture`, then runs `send_keys_safe` for an agy session. Expects returncode 4 and "Enter not accepted after 3 tries" in stderr.
Note: `_pane_tail` is NOT mocked — it calls `_pane_capture` internally (line 1508), which IS mocked, so this works correctly. ✅
---
## 5. Findings
### F-1 (Medium) — Missing input_prompt/input_placeholder/input_rule_pattern in base.py
**Location**: `.agents/skills/lib_py/agents/base.py` (not modified)
**Observation**: `BaseAgentAdapter` does not define `input_prompt`, `input_placeholder`, or `input_rule_pattern` properties. Only agy, claude, and cline adapters override them. `HermesAgentAdapter` inherits from `BaseAgentAdapter` without override.
**Impact**: When `input_region.py` line 14 accesses `adapter.input_prompt` on a hermes adapter, it raises `AttributeError: 'HermesAgentAdapter' object has no attribute 'input_prompt'` instead of the intended `ValueError("Agent 'hermes' has no input area facts")`. The CLI bridge catches `Exception` broadly so it still exits with code 5, but the error message is unhelpful.
**Verified**: `hasattr(get_adapter('hermes'), 'input_prompt')` returns `False`.
**Fix**: Add to `base.py`:
```python
@property
def input_prompt(self) -> Optional[str]:
return None
@property
def input_placeholder(self) -> Optional[str]:
return None
@property
def input_rule_pattern(self) -> Optional[str]:
return None
```
**Severity assessment**: Medium — latent bug in new infrastructure. Does not affect the B-8 fix itself (d629619a's lib.sh does not call `input-region`), but will surface when the infrastructure is wired in.
### F-2 (Low) — Usage string in __main__.py doesn't mention input-region
**Location**: `.agents/skills/lib_py/agents/__main__.py:8`
**Observation**: Usage string says `Usage: python -m lib_py.agents <facts|resolve> [args...]` but `input-region` is now a supported command.
**Fix**: Update to `Usage: python -m lib_py.agents <facts|resolve|input-region> [args...]`
### F-3 (Info) — input_region.py not wired into send_keys_safe
**Observation**: The `input_region.py` module and CLI bridge are added but not called from `send_keys_safe()`. The lib.sh change is purely the agy early-return removal. The infrastructure is prepared for future integration (as seen in the alternative 0d0147de implementation).
**Assessment**: Acceptable incremental approach. The B-8 fix is self-contained.
### F-4 (Info) — [A-Za-z]+ing fast-path regex retained
**Location**: `.agents/skills/lib.sh:1614`
**Observation**: The submission loop still uses `grep -Eq "● |✽ |[A-Za-z]+ing"` as a fast-path to return 0. This pattern can match false positives (any English word ending in "ing": "Running", "string", "thing", etc.).
**Assessment**: Pre-existing issue, NOT introduced by this change. The 0d0147de alternative removes this fast-path. Non-blocking for d629619a.
---
## 6. Test Results
| Test | Result |
|---|---|
| `test_b8_send_keys_verification.py::test_send_keys_safe_b8_verification` | PASS |
| `test_a4_adapter_contract.py::test_resolve_home_contract` | PASS |
| `test_a4_adapter_contract.py::test_agent_adapter_registry` | PASS |
| `test_a4_adapter_contract.py::test_agent_of_row_priority` | PASS |
### Static Analysis
| Check | Result |
|---|---|
| `bash -n .agents/skills/lib.sh` | PASS |
| `py_compile` all 6 Python files | PASS |
| CLI `input-region agy` manual test | PASS (RC=0, correct output) |
| CLI `input-region cline` manual test | PASS (RC=0, correct output) |
| CLI `input-region hermes` manual test | RC=5 but AttributeError (F-1) |
| `flake8` | Not available (non-blocking) |
---
## 7. Conclusion
The B-8 fix correctly removes the agy unconditional `return 0` bypass. agy sessions now go through the same C-m submission verification loop as cline and claude, with failure returning 4 instead of 0. The test validates this behavior.
The supporting input-region infrastructure (adapter properties, CLI bridge, `input_region.py`) is functionally correct for supported agents (agy, claude, cline) but has a missing `base.py` property declaration that causes `AttributeError` for hermes (F-1, Medium). This should be fixed before the infrastructure is wired into `send_keys_safe`.
No design-level rework or escalation is needed.
[VERDICT: PASS]
+2 -6
View File
@@ -1584,13 +1584,9 @@ send_keys_safe() {
_sks_herdr set-buffer -b "$sks_buf" "$text" _sks_herdr set-buffer -b "$sks_buf" "$text"
_sks_herdr paste-buffer -b "$sks_buf" -t "$sess" _sks_herdr paste-buffer -b "$sks_buf" -t "$sess"
_sks_herdr delete-buffer -b "$sks_buf" 2>/dev/null || true _sks_herdr delete-buffer -b "$sks_buf" 2>/dev/null || true
if [[ "$sess" =~ "agy" ]]; then
_sks_herdr send-keys -t "$sess" C-m
return 0
fi
local was_popup=0 local was_popup=0
if [[ "$sess" =~ "cline" ]] || [[ "$sess" =~ "claude" ]]; then if [[ "$sess" =~ "cline" ]] || [[ "$sess" =~ "claude" ]] || [[ "$sess" =~ "agy" ]]; then
# Skip strict paste check due to scrollout false-positives, proceed to C-m loop # Skip strict paste check due to scrollout false-positives, proceed to C-m submission loop
true true
else else
sleep 0.5 sleep 0.5
+15
View File
@@ -18,6 +18,9 @@ def main():
# Emit shell-eval friendly facts # Emit shell-eval friendly facts
print(f"AGENT_NAME={adapter.name}") print(f"AGENT_NAME={adapter.name}")
print(f"OWN_KEY={adapter.own_key}") print(f"OWN_KEY={adapter.own_key}")
print(f"INPUT_PROMPT={adapter.input_prompt or ''}")
print(f"INPUT_PLACEHOLDER={adapter.input_placeholder or ''}")
print(f"INPUT_RULE_PATTERN={adapter.input_rule_pattern or ''}")
elif cmd == 'resolve': elif cmd == 'resolve':
name = sys.argv[2] if len(sys.argv) > 2 else '' name = sys.argv[2] if len(sys.argv) > 2 else ''
@@ -27,6 +30,18 @@ def main():
sys.exit(0) sys.exit(0)
sys.exit(1) sys.exit(1)
elif cmd == 'input-region':
agent_name = sys.argv[2] if len(sys.argv) > 2 else ''
pane_text = sys.stdin.read()
from lib_py.agents.input_region import extract_input_region
try:
region = extract_input_region(pane_text, agent_name)
print(region)
sys.exit(0)
except Exception as ex:
print(f"ERROR: {ex}", file=sys.stderr)
sys.exit(5)
else: else:
print(f"ERROR: Unknown CLI command {cmd!r}", file=sys.stderr) print(f"ERROR: Unknown CLI command {cmd!r}", file=sys.stderr)
sys.exit(1) sys.exit(1)
@@ -10,3 +10,15 @@ class AgyAgentAdapter(BaseAgentAdapter):
@property @property
def own_key(self) -> str: def own_key(self) -> str:
return 'agy_conversation_id_own' return 'agy_conversation_id_own'
@property
def input_prompt(self) -> str:
return '>'
@property
def input_placeholder(self) -> str:
return ''
@property
def input_rule_pattern(self) -> str:
return '{10,}'
@@ -10,3 +10,15 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
@property @property
def own_key(self) -> str: def own_key(self) -> str:
return 'claude_session_id_own' return 'claude_session_id_own'
@property
def input_prompt(self) -> str:
return ''
@property
def input_placeholder(self) -> str:
return ''
@property
def input_rule_pattern(self) -> str:
return '{10,}'
@@ -10,3 +10,15 @@ class ClineAgentAdapter(BaseAgentAdapter):
@property @property
def own_key(self) -> str: def own_key(self) -> str:
return 'cline_conversation_id_own' return 'cline_conversation_id_own'
@property
def input_prompt(self) -> str:
return ''
@property
def input_placeholder(self) -> str:
return 'Ask anything...'
@property
def input_rule_pattern(self) -> str:
return '{10,}'
+12
View File
@@ -27,6 +27,18 @@ class BaseAgentAdapter:
def own_key(self) -> str: def own_key(self) -> str:
raise NotImplementedError raise NotImplementedError
@property
def input_prompt(self) -> Optional[str]:
return None
@property
def input_placeholder(self) -> Optional[str]:
return None
@property
def input_rule_pattern(self) -> Optional[str]:
return None
def derive_session_name(self, slug: str, role: str = "creator") -> str: def derive_session_name(self, slug: str, role: str = "creator") -> str:
r = role.lower() r = role.lower()
return f"{slug}-{r}-{self.name}" return f"{slug}-{r}-{self.name}"
@@ -0,0 +1,40 @@
# input_region.py — Rule-based input region extraction and normalization (T2 & T3)
import re
from lib_py.agents.registry import get_adapter
def extract_input_region(pane_text: str, agent_name: str) -> str:
"""
Extracts the input area text between horizontal rule separators (─{10,}) for a given agent.
If rules are absent, falls back to full pane_text to support mocks and unruled panes.
Strips leading prompt tokens ('>', '') and placeholders ('Ask anything...').
Raises ValueError for unsupported agents (like hermes with no input area facts).
"""
adapter = get_adapter(agent_name)
if not adapter or not adapter.input_prompt:
raise ValueError(f"Agent {agent_name!r} has no input area facts")
rule_pattern = adapter.input_rule_pattern or r'{10,}'
rule_re = re.compile(rule_pattern)
lines = pane_text.splitlines()
rule_indices = [i for i, line in enumerate(lines) if rule_re.search(line)]
if len(rule_indices) >= 2:
# Exclude the rule lines themselves: lines between rule_indices[-2] + 1 and rule_indices[-1]
region_lines = lines[rule_indices[-2] + 1:rule_indices[-1]]
elif len(rule_indices) == 1:
region_lines = lines[rule_indices[0] + 1:]
else:
region_lines = lines
region_text = "\n".join(region_lines)
prompt = adapter.input_prompt
if prompt:
region_text = re.sub(r'^\s*' + re.escape(prompt) + r'\s*', '', region_text, flags=re.MULTILINE)
placeholder = adapter.input_placeholder
if placeholder:
region_text = region_text.replace(placeholder, '')
return region_text.strip()
+2 -2
View File
@@ -1,6 +1,6 @@
# 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`) # 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`)
- **최종 갱신일**: 2026-08-14 (A-4 M0~M1 BaseAgentAdapter 도입 1단계 완료 및 계약 검증 반영) - **최종 갱신일**: 2026-08-14 (A-4 M0~M1 및 B-8 send_keys_safe agy 우회 제거 조치 완료 반영)
- **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md` - **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md`
- **총 추적 미해결 과제**: **10건** (아키텍처 2건, 엣지케이스 5건, 오케스트레이션 0건, 레거시 잔재 3건) - **총 추적 미해결 과제**: **10건** (아키텍처 2건, 엣지케이스 5건, 오케스트레이션 0건, 레거시 잔재 3건)
- **완료된 과제**: **13건** (A-1, A-3, A-5, B-1, B-3, B-4, B-7, C-1, C-2, O-1, O-2, O-3, O-4-OrcOnboard) - **완료된 과제**: **13건** (A-1, A-3, A-5, B-1, B-3, B-4, B-7, C-1, C-2, O-1, O-2, O-3, O-4-OrcOnboard)
@@ -218,7 +218,7 @@
| **P0-1** | **B-7** | 저장소 밖 기동 시 리뷰어가 문자열 `"No git diff available"``[VERDICT: PASS]` 를 냄. 신규(미추적) 파일은 리뷰 대상 밖. **(✅ 완료 — tests/test_b7_diff_untracked.py 20/20 PASS)** | 소 (1파일) | — | | **P0-1** | **B-7** | 저장소 밖 기동 시 리뷰어가 문자열 `"No git diff available"``[VERDICT: PASS]` 를 냄. 신규(미추적) 파일은 리뷰 대상 밖. **(✅ 완료 — tests/test_b7_diff_untracked.py 20/20 PASS)** | 소 (1파일) | — |
| **P0-2** | **O-2** | 마커를 조건 없이 덮어쓰고 종료 트랩이 **타 인스턴스의 마커까지 삭제** → 완료 처리된 **O-3 가드가 조용히 무력화**됨 **(✅ 완료 — tests/test_o2_race_free_lock.py 22/22 PASS)** | 소~중 (1파일) | — | | **P0-2** | **O-2** | 마커를 조건 없이 덮어쓰고 종료 트랩이 **타 인스턴스의 마커까지 삭제** → 완료 처리된 **O-3 가드가 조용히 무력화**됨 **(✅ 완료 — tests/test_o2_race_free_lock.py 22/22 PASS)** | 소~중 (1파일) | — |
| **P1-1** | **A-4 M0~M1** | `PYTHONPATH` 부트스트랩·배포/CI 등록·`own_key` 이관. B-8/B-10/C-3b 로직을 싸게 만듦 **(✅ 완료 — tests/test_a4_adapter_contract.py 3/3 PASS)** | 중 | B-7 | | **P1-1** | **A-4 M0~M1** | `PYTHONPATH` 부트스트랩·배포/CI 등록·`own_key` 이관. B-8/B-10/C-3b 로직을 싸게 만듦 **(✅ 완료 — tests/test_a4_adapter_contract.py 3/3 PASS)** | 중 | B-7 |
| **P1-2** | **B-8** | agy 주입이 검증 없이 `return 0` → 아무것도 전달되지 않은 잡이 `started` 로 기록됨. A-4 M0(`_pane_capture` 디코딩) 이후엔 **회피책 제거**로 축소 | 소 | A-4 M0 | | **P1-2** | **B-8** | agy 주입 `return 0` 우회 제거 및 제출 검증 루프 이관 **(✅ 완료 — tests/test_b8_send_keys_verification.py 1/1 PASS)** | 소 | A-4 M0 |
| **P2-1** | **B-6** | 버전 관리 트리 오염 + rsync 배포 유출. `mktemp -d` 로 옮기는 1~2줄 | 소 | — | | **P2-1** | **B-6** | 버전 관리 트리 오염 + rsync 배포 유출. `mktemp -d` 로 옮기는 1~2줄 | 소 | — |
| **P2-2** | **C-3a + C-4** | 빈 스텁 4종 + 이를 고정하던 **공허한 테스트 5건** + 죽은 심볼 3종 제거 (회귀 시간 단축 효과) | 소 | — | | **P2-2** | **C-3a + C-4** | 빈 스텁 4종 + 이를 고정하던 **공허한 테스트 5건** + 죽은 심볼 3종 제거 (회귀 시간 단축 효과) | 소 | — |
| **P2-3** | **C-6** | 도움말 3줄 정정 | 극소 | — | | **P2-3** | **C-6** | 도움말 3줄 정정 | 극소 | — |
+29
View File
@@ -0,0 +1,29 @@
import os
import subprocess
import pytest
def test_send_keys_safe_b8_verification(tmp_path):
"""Verify that send_keys_safe for agy sessions goes through submission verification and does not return 0 blindly on failure."""
# Test script that mocks herdr commands and tests send_keys_safe behavior for agy
test_script = f"""#!/usr/bin/env bash
set -euo pipefail
SKILL_DIR="{os.path.abspath('.agents/skills')}"
source "$SKILL_DIR/lib.sh"
# Mock _pane_quiescent to succeed
_pane_quiescent() {{ return 0; }}
_pane_dialog_open() {{ return 1; }}
# Mock _sks_herdr commands
_sks_herdr() {{ return 0; }}
# Mock _pane_capture to simulate fixed pane content (no execution token, no pane change)
_pane_capture() {{ echo "static content"; }}
# Running send_keys_safe for agy session when submission is NOT accepted
send_keys_safe "test-workspace-creator-agy" "test prompt" "job-123"
"""
res = subprocess.run(["bash", "-c", test_script], capture_output=True, text=True)
# Since submission failed after 3 tries (pane static, no token), it must return exit code 4 (not 0)
assert res.returncode == 4, f"Expected returncode 4 on submission failure for agy, got {res.returncode}. Output: {res.stdout} {res.stderr}"
assert "Enter not accepted after 3 tries" in res.stderr