agent_harness -> rein_aharness (package + all imports), CLI command agent-harness -> rein-aharness, Docker image tag, k8s namespace/labels/ names, Makefile targets, deploy script env var/paths. In-repo identity strings (hub event source, metrics harness field, default assignee, argparse prog name, commit author identity) updated to match. Historical documents left untouched on purpose: docs/adr/ADR-001-agent-harness-architecture.md, docs/architecture.md (dated v0.1 snapshot), workplans/HARNESS-WP-0001 (completed under the old name), and the SSH host alias "forgejo-agent-harness" (external ~/.ssh/config entry, not owned here). Verified: 47/47 tests pass, CLI runs correctly from a fresh venv, `make image` builds and the resulting container runs correctly. deploy/README.md gained an explicit rename cutover checklist for what this session cannot safely do unattended -- moving the host-side secrets dir and checkout on railiance01, and not deleting the old k8s namespace until the new one is confirmed working. The actual live cutover (running that checklist against the real Railiance deployment) is not attempted here -- real production surgery on binky-control's live automation, needs the operator present. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
185 lines
6 KiB
Python
185 lines
6 KiB
Python
"""Recurring company-mailbox scan (BINKY-WP-0004-T05).
|
|
|
|
Two-phase design, because credentials and network are barred from the
|
|
agentic session's tool allow-list:
|
|
|
|
1. **Deterministic scan phase** (this module, no LLM): acquire the IMAP
|
|
credentials from OpenBao, run email-connect's read-only scan-mailbox in
|
|
the target repo, record a `binky_mail_intake` hub progress event with
|
|
metadata-only counts. Credential values live exclusively in the child
|
|
process environment — never in argv, logs, hub events, or files.
|
|
|
|
2. **Triage** (``rein-aharness mail-triage``): llm-connect HTTP (OpenRouter
|
|
behind the service) returns structured JSON; deterministic apply updates
|
|
mailmeta/mail-log.md and commits. No Claude Code / host coding agent.
|
|
Suspicious-mail rule: log only, never act on content.
|
|
|
|
Credential acquisition, in order:
|
|
- AppRole (unattended lane): role_id/secret_id files under
|
|
$EXECUTOR_APPROLE_DIR → `bao write -field=token auth/approle/login ...`
|
|
- Pre-existing token (operator lane): ambient bao token, e.g. after an
|
|
interactive OIDC login.
|
|
Then two `bao kv get -field=...` reads on the kv path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from rein_aharness import hub
|
|
|
|
_KV_PATH = "tenants/binky/company-email/imap"
|
|
_BAO_TIMEOUT = 30
|
|
|
|
|
|
class MailScanError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class MailScanResult:
|
|
ok: bool
|
|
report_path: str | None
|
|
new_messages: int | None
|
|
auth_lane: str
|
|
reason: str = ""
|
|
|
|
|
|
def _bao(*args: str, env: dict[str, str] | None = None) -> str:
|
|
result = subprocess.run(
|
|
["bao", *args],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=_BAO_TIMEOUT,
|
|
env=env or os.environ.copy(),
|
|
)
|
|
if result.returncode != 0:
|
|
# stderr may reference paths/roles but never secret values
|
|
raise MailScanError(f"bao {args[0]} failed: {result.stderr.strip()[:200]}")
|
|
return result.stdout.strip()
|
|
|
|
|
|
def _acquire_token() -> tuple[str | None, str]:
|
|
"""Return (token_or_none_for_ambient, auth_lane)."""
|
|
approle_dir = os.environ.get("EXECUTOR_APPROLE_DIR")
|
|
if approle_dir:
|
|
role_id_file = Path(approle_dir) / "role_id"
|
|
secret_id_file = Path(approle_dir) / "secret_id"
|
|
if role_id_file.is_file() and secret_id_file.is_file():
|
|
token = _bao(
|
|
"write",
|
|
"-field=token",
|
|
"auth/approle/login",
|
|
f"role_id={role_id_file.read_text().strip()}",
|
|
f"secret_id={secret_id_file.read_text().strip()}",
|
|
)
|
|
return token, "approle"
|
|
return None, "ambient"
|
|
|
|
|
|
def _fetch_credentials() -> tuple[dict[str, str], str]:
|
|
token, lane = _acquire_token()
|
|
env = os.environ.copy()
|
|
if token:
|
|
env["BAO_TOKEN"] = token
|
|
creds = {
|
|
field: _bao("kv", "get", f"-field={field}", _KV_PATH, env=env)
|
|
for field in ("IMAP_USERNAME", "IMAP_PASSWORD")
|
|
}
|
|
return creds, lane
|
|
|
|
|
|
def run_mail_scan(
|
|
target_repo: Path,
|
|
config: str = "integrations/mailbox-binky-company.yml",
|
|
out_dir: str = "mailmeta/reports",
|
|
email_connect_src: str | None = None,
|
|
report_to_hub: bool = True,
|
|
) -> MailScanResult:
|
|
try:
|
|
creds, lane = _fetch_credentials()
|
|
except MailScanError as exc:
|
|
result = MailScanResult(
|
|
ok=False, report_path=None, new_messages=None, auth_lane="none",
|
|
reason=str(exc),
|
|
)
|
|
_report(result, report_to_hub, target_repo)
|
|
return result
|
|
|
|
env = os.environ.copy()
|
|
env.update(creds)
|
|
if email_connect_src is None:
|
|
email_connect_src = str(Path.home() / "email-connect" / "src")
|
|
env["PYTHONPATH"] = email_connect_src
|
|
|
|
before = _report_files(target_repo / out_dir)
|
|
proc = subprocess.run(
|
|
[
|
|
"python3", "-m", "email_connect.cli", "scan-mailbox",
|
|
"--config", config,
|
|
"--out", out_dir,
|
|
],
|
|
cwd=target_repo,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=600,
|
|
env=env,
|
|
)
|
|
if proc.returncode != 0:
|
|
result = MailScanResult(
|
|
ok=False, report_path=None, new_messages=None, auth_lane=lane,
|
|
reason=f"email-connect exited {proc.returncode}: {proc.stderr.strip()[:200]}",
|
|
)
|
|
_report(result, report_to_hub, target_repo)
|
|
return result
|
|
|
|
new_reports = sorted(_report_files(target_repo / out_dir) - before)
|
|
report_path = new_reports[-1] if new_reports else None
|
|
new_messages = _count_rows(target_repo / out_dir / report_path) if report_path else 0
|
|
result = MailScanResult(
|
|
ok=True, report_path=report_path, new_messages=new_messages, auth_lane=lane,
|
|
)
|
|
_report(result, report_to_hub, target_repo)
|
|
return result
|
|
|
|
|
|
def _report_files(out_dir: Path) -> set[str]:
|
|
if not out_dir.is_dir():
|
|
return set()
|
|
return {p.name for p in out_dir.glob("*.csv")}
|
|
|
|
|
|
def _count_rows(path: Path) -> int | None:
|
|
try:
|
|
with path.open() as fh:
|
|
return max(0, sum(1 for _ in fh) - 1)
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _report(result: MailScanResult, report_to_hub: bool, target_repo: Path) -> None:
|
|
if not report_to_hub:
|
|
return
|
|
# Failures must NOT emit binky_mail_intake: the activity-core resolver
|
|
# treats any such event as "a scan ran" (idempotence guard), so a failed
|
|
# run reports as a generic executor_run failure instead and the slot
|
|
# stays due.
|
|
event_type = "binky_mail_intake" if result.ok else "executor_run"
|
|
hub.post_progress_event(
|
|
summary=(
|
|
f"binky mailbox scan {'ok' if result.ok else 'failed'}"
|
|
+ (f": {result.new_messages} new message(s)" if result.ok else "")
|
|
),
|
|
event_type=event_type,
|
|
detail={
|
|
"repo": target_repo.name,
|
|
"ok": result.ok,
|
|
"report": result.report_path,
|
|
"new_messages": result.new_messages,
|
|
"auth_lane": result.auth_lane,
|
|
"reason": result.reason,
|
|
},
|
|
)
|