Harness foundation: INTENT, ADR-001, architecture, prototype adoption
- INTENT.md: three-layer model (blueprint/instance/harness), single shared runtime, never-become boundaries - ADR-001 (accepted): DEC-2026-002 resolution — one harness repo for all projects; instances are declarative state in consuming repos - docs/architecture.md: components, contracts (manifest, tool profiles, completion events, credential lanes), deployment shape - agent_harness/: executor-worker prototype adopted and renamed (6/6 tests green); HARNESS-WP-0001 initial workplan (7 tasks) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
87ae78c56a
commit
cfc1b75157
18 changed files with 1204 additions and 1 deletions
185
agent_harness/mailscan.py
Normal file
185
agent_harness/mailscan.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"""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. **Optional triage session** (standard run_task): an agentic session in
|
||||
the target repo reads the newest report CSV and updates the metadata
|
||||
log/queues. Suspicious-mail rule is part of that task's prompt: never
|
||||
act on message content, only log it.
|
||||
|
||||
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 agent_harness 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,
|
||||
},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue