feat(privacy-host): add signed native calendar contacts and reminders host
Add the owner-only AF_UNIX Reyna CLI privacy host, strict signed-app installation, and typed native routing for Calendar, Contacts, and Reminders.\n\nAdd bounded system-status paths and config-only direct local-service wrappers. Preserve MacMiniMCP pending explicit cutover approval.\n\nApple Notes is intentionally deferred: no native Notes operations, Apple Events declaration, or Automation helper are included; legacy Notes handling remains untouched.
This commit is contained in:
@@ -0,0 +1,710 @@
|
||||
"""Deterministic Apple-signed Reyna CLI.app bundle builder via Xcode.
|
||||
|
||||
Bundle layout:
|
||||
<repo>/native/ReynaCLIHost/dist/Reyna CLI.app/
|
||||
Contents/
|
||||
Info.plist (bound, deterministic, CFBundleIdentifier=com.reyna.cli.privacy-host)
|
||||
MacOS/
|
||||
ReynaCLIHost (executable, 0755, copied atomically)
|
||||
|
||||
Security:
|
||||
- Xcode owns signing; no manual `codesign --sign` invocation.
|
||||
- Real build command is xcodebuild with Automatic Signing (CODE_SIGN_STYLE=Automatic in project).
|
||||
- For CI/test unsigned verification, caller may disable signing via CODE_SIGNING_ALLOWED=NO.
|
||||
- Validation remains fail-closed: non-adhoc signature, team present, bound Info plist, fixed bundle identifier.
|
||||
- All subprocess invocations use arg arrays, never shell.
|
||||
- No secret material logged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import plistlib
|
||||
import stat
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
BUNDLE_IDENTIFIER = "com.reyna.cli.privacy-host"
|
||||
APP_BUNDLE_NAME = "Reyna CLI.app"
|
||||
APP_EXECUTABLE_NAME = "ReynaCLIHost"
|
||||
BUNDLE_VERSION = "1"
|
||||
BUNDLE_SHORT_VERSION = "1.0.0"
|
||||
|
||||
XCODE_PROJECT_REL = Path("native") / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj"
|
||||
XCODE_TARGET_NAME = "Reyna CLI" # deprecated alias; use SCHEME for valid -derivedDataPath builds
|
||||
XCODE_SCHEME_NAME = "Reyna CLI"
|
||||
XCODE_CONFIGURATION = "Release"
|
||||
XCODE_INFO_PLIST_REL = Path("native") / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist"
|
||||
DERIVED_DATA_REL = Path("native") / "ReynaCLIHost" / "build" / "DerivedData"
|
||||
|
||||
SIGNING_IDENTITY_ENV_VAR = "REYNA_CLI_SIGNING_IDENTITY" # deprecated, kept for compat; no longer required
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _package_dir(repo_root: Optional[Path] = None) -> Path:
|
||||
r = repo_root or _repo_root()
|
||||
return r / "native" / "ReynaCLIHost"
|
||||
|
||||
|
||||
def _xcodeproj_path(repo_root: Optional[Path] = None) -> Path:
|
||||
r = repo_root or _repo_root()
|
||||
return r / XCODE_PROJECT_REL
|
||||
|
||||
|
||||
def _info_plist_source_path(repo_root: Optional[Path] = None) -> Path:
|
||||
r = repo_root or _repo_root()
|
||||
return r / XCODE_INFO_PLIST_REL
|
||||
|
||||
|
||||
def _derived_data_path(repo_root: Optional[Path] = None, override: Optional[Path] = None) -> Path:
|
||||
if override is not None:
|
||||
return Path(override)
|
||||
r = repo_root or _repo_root()
|
||||
return r / DERIVED_DATA_REL
|
||||
|
||||
|
||||
def _built_product_app_path(derived_data_path: Path) -> Path:
|
||||
return derived_data_path / "Build" / "Products" / XCODE_CONFIGURATION / APP_BUNDLE_NAME
|
||||
|
||||
|
||||
def app_bundle_dir(repo_root: Optional[Path] = None) -> Path:
|
||||
r = repo_root or _repo_root()
|
||||
return r / "native" / "ReynaCLIHost" / "dist"
|
||||
|
||||
|
||||
def app_bundle_path(repo_root: Optional[Path] = None) -> Path:
|
||||
return app_bundle_dir(repo_root) / APP_BUNDLE_NAME
|
||||
|
||||
|
||||
def app_bundle_info_plist_path(repo_root: Optional[Path] = None) -> Path:
|
||||
return app_bundle_path(repo_root) / "Contents" / "Info.plist"
|
||||
|
||||
|
||||
def app_bundle_executable_path(repo_root: Optional[Path] = None) -> Path:
|
||||
return app_bundle_path(repo_root) / "Contents" / "MacOS" / APP_EXECUTABLE_NAME
|
||||
|
||||
|
||||
def build_app_bundle_info_plist_dict() -> Dict[str, Any]:
|
||||
"""Deterministic Info.plist dict, sorted keys for reproducibility."""
|
||||
return {
|
||||
"CFBundleDevelopmentRegion": "en",
|
||||
"CFBundleDisplayName": "Reyna CLI",
|
||||
"CFBundleExecutable": APP_EXECUTABLE_NAME,
|
||||
"CFBundleIdentifier": BUNDLE_IDENTIFIER,
|
||||
"CFBundleInfoDictionaryVersion": "6.0",
|
||||
"CFBundleName": "Reyna CLI",
|
||||
"CFBundlePackageType": "APPL",
|
||||
"CFBundleShortVersionString": BUNDLE_SHORT_VERSION,
|
||||
"CFBundleVersion": BUNDLE_VERSION,
|
||||
"LSMinimumSystemVersion": "13.0",
|
||||
"NSCalendarsFullAccessUsageDescription": "Reyna CLI needs calendar access to list and manage your events locally.",
|
||||
"NSContactsUsageDescription": "Reyna CLI needs contacts access to search and manage your contacts locally.",
|
||||
"NSRemindersFullAccessUsageDescription": "Reyna CLI needs reminders access to list and manage your reminders locally.",
|
||||
"LSUIElement": True,
|
||||
}
|
||||
|
||||
|
||||
def _default_runner(args: List[str], cwd: Optional[Path] = None, **kwargs: Any):
|
||||
return subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_dir_mode(path: Path, mode: int) -> None:
|
||||
try:
|
||||
st_l = path.lstat()
|
||||
if stat.S_ISLNK(st_l.st_mode):
|
||||
raise ValueError(f"refusing symlink directory: {path}")
|
||||
if not stat.S_ISDIR(st_l.st_mode):
|
||||
raise ValueError(f"path exists and is not a directory: {path}")
|
||||
os.chmod(path, mode)
|
||||
st = path.stat()
|
||||
if stat.S_IMODE(st.st_mode) != mode:
|
||||
raise PermissionError(f"directory mode {oct(stat.S_IMODE(st.st_mode))} != {oct(mode)}: {path}")
|
||||
return
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
path.mkdir(parents=True, exist_ok=True, mode=mode)
|
||||
os.chmod(path, mode)
|
||||
st_l = path.lstat()
|
||||
if stat.S_ISLNK(st_l.st_mode):
|
||||
raise ValueError(f"refusing symlink dir after creation: {path}")
|
||||
if not stat.S_ISDIR(st_l.st_mode):
|
||||
raise ValueError(f"path not dir after creation: {path}")
|
||||
st = path.stat()
|
||||
if stat.S_IMODE(st.st_mode) != mode:
|
||||
os.chmod(path, mode)
|
||||
st = path.stat()
|
||||
if stat.S_IMODE(st.st_mode) != mode:
|
||||
raise PermissionError(f"directory mode {oct(stat.S_IMODE(st.st_mode))} != {oct(mode)} after chmod: {path}")
|
||||
|
||||
|
||||
def _copy_app_bundle_atomic(src: Path, dst: Path) -> None:
|
||||
"""Atomically copy .app bundle from src to dst."""
|
||||
if not src.exists():
|
||||
raise FileNotFoundError(f"source bundle not found: {src}")
|
||||
try:
|
||||
s_l = src.lstat()
|
||||
if stat.S_ISLNK(s_l.st_mode):
|
||||
raise ValueError(f"refusing symlink source bundle: {src}")
|
||||
if not stat.S_ISDIR(s_l.st_mode):
|
||||
raise ValueError(f"source bundle not a directory: {src}")
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
|
||||
parent = dst.parent
|
||||
_ensure_dir_mode(parent, 0o700)
|
||||
|
||||
tmp_name = f".{dst.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}"
|
||||
tmp_path = parent / tmp_name
|
||||
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
if tmp_path.is_dir():
|
||||
shutil.rmtree(str(tmp_path))
|
||||
else:
|
||||
tmp_path.unlink()
|
||||
|
||||
shutil.copytree(str(src), str(tmp_path), symlinks=False)
|
||||
|
||||
tst = tmp_path.lstat()
|
||||
if stat.S_ISLNK(tst.st_mode):
|
||||
raise ValueError(f"temp dst is symlink: {tmp_path}")
|
||||
if not stat.S_ISDIR(tst.st_mode):
|
||||
raise ValueError(f"temp dst not dir: {tmp_path}")
|
||||
|
||||
if dst.exists():
|
||||
dl = dst.lstat()
|
||||
if stat.S_ISLNK(dl.st_mode):
|
||||
raise ValueError(f"refusing to replace symlinked dst: {dst}")
|
||||
if dst.is_dir():
|
||||
shutil.rmtree(str(dst))
|
||||
else:
|
||||
dst.unlink()
|
||||
|
||||
os.replace(str(tmp_path), str(dst))
|
||||
|
||||
final_st = dst.lstat()
|
||||
if stat.S_ISLNK(final_st.st_mode):
|
||||
raise ValueError(f"final dst is symlink: {dst}")
|
||||
finally:
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
if tmp_path.is_dir():
|
||||
shutil.rmtree(str(tmp_path))
|
||||
else:
|
||||
tmp_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def build_xcodebuild_command(
|
||||
repo_root: Optional[Path] = None,
|
||||
derived_data_path: Optional[Path] = None,
|
||||
disable_code_signing: bool = False,
|
||||
) -> List[str]:
|
||||
"""Return safe arg-array xcodebuild command.
|
||||
|
||||
This is the canonical build command; Xcode owns signing (Automatic).
|
||||
Uses -scheme (shared, committed) with -derivedDataPath which requires scheme.
|
||||
When disable_code_signing is True, adds CODE_SIGNING_ALLOWED=NO for unsigned verification.
|
||||
"""
|
||||
r = repo_root or _repo_root()
|
||||
proj = _xcodeproj_path(r)
|
||||
dd = _derived_data_path(r, override=derived_data_path)
|
||||
|
||||
cmd: List[str] = [
|
||||
"xcodebuild",
|
||||
"-project",
|
||||
str(proj),
|
||||
"-scheme",
|
||||
XCODE_SCHEME_NAME,
|
||||
"-configuration",
|
||||
XCODE_CONFIGURATION,
|
||||
"-derivedDataPath",
|
||||
str(dd),
|
||||
"build",
|
||||
]
|
||||
if disable_code_signing:
|
||||
cmd.append("CODE_SIGNING_ALLOWED=NO")
|
||||
return cmd
|
||||
|
||||
|
||||
# Backward compat: old RELEASE_BUILD_ARGS now points to xcodebuild with default derived path
|
||||
# (callers should use build_xcodebuild_command for testability).
|
||||
def _default_xcodebuild_args_for_compat() -> List[str]:
|
||||
return build_xcodebuild_command()
|
||||
|
||||
|
||||
RELEASE_BUILD_ARGS: List[str] = _default_xcodebuild_args_for_compat()
|
||||
|
||||
|
||||
def _resolve_signing_identity(explicit: Optional[str]) -> Optional[str]:
|
||||
"""Deprecated: signing identity no longer required for Automatic Signing.
|
||||
Kept for backwards compatibility; returns identity if provided, else env var if set.
|
||||
"""
|
||||
if explicit is not None:
|
||||
s = str(explicit).strip()
|
||||
if s:
|
||||
return s
|
||||
return None
|
||||
env_val = os.environ.get(SIGNING_IDENTITY_ENV_VAR)
|
||||
if env_val is None:
|
||||
return None
|
||||
s = str(env_val).strip()
|
||||
if not s:
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
def _defense_check_source_app_path(p: Path) -> Optional[str]:
|
||||
"""Defend source .app path: absolute, .app suffix, dir, not symlink, no traversal tricks."""
|
||||
try:
|
||||
s = str(p)
|
||||
except Exception:
|
||||
return "invalid app bundle path"
|
||||
# Reject empty
|
||||
if not s:
|
||||
return "empty app bundle path"
|
||||
# Must be absolute
|
||||
if not p.is_absolute():
|
||||
return f"app bundle path must be absolute: {p}"
|
||||
# Must end with .app
|
||||
if not s.endswith(".app"):
|
||||
return f"app bundle path must end with .app suffix: {p}"
|
||||
# Reject if contains .. components to avoid traversal sneaks (even though absolute)
|
||||
# Use Path parts check: if \"..\" in parts
|
||||
if ".." in Path(s).parts:
|
||||
return f"app bundle path must not contain '..': {p}"
|
||||
try:
|
||||
st = p.lstat()
|
||||
except FileNotFoundError:
|
||||
return f"source bundle not found: {p}"
|
||||
except Exception as exc:
|
||||
return f"source bundle lstat failed: {exc}"
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
return f"refusing symlink source bundle: {p}"
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
return f"source bundle is not a directory: {p}"
|
||||
return None
|
||||
|
||||
|
||||
def _validate_bundle_at_paths(
|
||||
bundle_path: Path,
|
||||
exe_path: Path,
|
||||
plist_path: Path,
|
||||
runner: Optional[Callable[..., Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Core validation logic parameterized by explicit paths. No secret leakage."""
|
||||
run_fn = runner or _default_runner
|
||||
result: Dict[str, Any] = {
|
||||
"ok": False,
|
||||
"app_bundle_path": str(bundle_path),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"bundle_identifier_expected": BUNDLE_IDENTIFIER,
|
||||
"executable_path": str(exe_path),
|
||||
"info_plist_path": str(plist_path),
|
||||
"bundle_exists": False,
|
||||
"executable_exists": False,
|
||||
"info_plist_exists": False,
|
||||
"bundle_identifier_matches": False,
|
||||
"signature_verified": False,
|
||||
"is_ad_hoc": None,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
try:
|
||||
result["bundle_exists"] = bundle_path.exists()
|
||||
result["executable_exists"] = exe_path.exists()
|
||||
result["info_plist_exists"] = plist_path.exists()
|
||||
except Exception as exc:
|
||||
result["errors"].append(f"existence check failed: {exc}")
|
||||
|
||||
if not result["bundle_exists"]:
|
||||
result["errors"].append(f"bundle missing at {bundle_path}")
|
||||
return result
|
||||
if not result["executable_exists"]:
|
||||
result["errors"].append(f"executable missing at {exe_path}")
|
||||
return result
|
||||
if not result["info_plist_exists"]:
|
||||
result["errors"].append(f"Info.plist missing at {plist_path}")
|
||||
return result
|
||||
|
||||
try:
|
||||
with open(plist_path, "rb") as f:
|
||||
d = plistlib.load(f)
|
||||
bid = d.get("CFBundleIdentifier")
|
||||
result["bundle_identifier_found"] = bid
|
||||
if bid == BUNDLE_IDENTIFIER:
|
||||
result["bundle_identifier_matches"] = True
|
||||
else:
|
||||
result["errors"].append(f"bundle identifier mismatch: expected {BUNDLE_IDENTIFIER} got {bid}")
|
||||
return result
|
||||
|
||||
bexe = d.get("CFBundleExecutable")
|
||||
if bexe != APP_EXECUTABLE_NAME:
|
||||
result["errors"].append(f"CFBundleExecutable mismatch: expected {APP_EXECUTABLE_NAME} got {bexe}")
|
||||
return result
|
||||
|
||||
if "NSCalendarsFullAccessUsageDescription" not in d:
|
||||
result["errors"].append("missing NSCalendarsFullAccessUsageDescription")
|
||||
return result
|
||||
|
||||
if "NSContactsUsageDescription" not in d:
|
||||
result["errors"].append("missing NSContactsUsageDescription")
|
||||
return result
|
||||
|
||||
if "NSRemindersFullAccessUsageDescription" not in d:
|
||||
result["errors"].append("missing NSRemindersFullAccessUsageDescription")
|
||||
return result
|
||||
|
||||
# Notes (AppleEvents) deliberately deferred — must NOT be required
|
||||
# Forbid AppleEvents / Notes usage description
|
||||
if "NSAppleEventsUsageDescription" in d:
|
||||
result["errors"].append("forbidden usage description present: NSAppleEventsUsageDescription (Notes deferred)")
|
||||
return result
|
||||
|
||||
forbidden_keys = [
|
||||
"NSRemindersUsageDescription",
|
||||
]
|
||||
for fk in forbidden_keys:
|
||||
if fk in d:
|
||||
result["errors"].append(f"forbidden usage description present: {fk}")
|
||||
return result
|
||||
|
||||
allowed_usage_keys = {
|
||||
"NSCalendarsFullAccessUsageDescription",
|
||||
"NSCalendarsWriteOnlyAccessUsageDescription",
|
||||
"NSCalendarsUsageDescription",
|
||||
"NSContactsUsageDescription",
|
||||
"NSRemindersFullAccessUsageDescription",
|
||||
}
|
||||
for k in d.keys():
|
||||
if k.startswith("NS") and "UsageDescription" in k:
|
||||
if k not in allowed_usage_keys:
|
||||
result["errors"].append(f"unexpected usage description key: {k}")
|
||||
return result
|
||||
except Exception as exc:
|
||||
result["errors"].append(f"Info.plist read/validation failed: {exc}")
|
||||
return result
|
||||
|
||||
try:
|
||||
proc = run_fn(["codesign", "--verify", "--deep", "--strict", str(bundle_path)])
|
||||
rc = getattr(proc, "returncode", -1)
|
||||
if rc == 0:
|
||||
result["signature_verified"] = True
|
||||
else:
|
||||
result["signature_verified"] = False
|
||||
# Do not leak raw codesign identity output; truncate generic message
|
||||
result["errors"].append(f"codesign verify failed rc={rc}")
|
||||
return result
|
||||
except Exception as exc:
|
||||
result["errors"].append(f"codesign verify exception: {exc}")
|
||||
return result
|
||||
|
||||
try:
|
||||
proc2 = run_fn(["codesign", "-dv", str(bundle_path)])
|
||||
stderr = (getattr(proc2, "stderr", "") or "") + (getattr(proc2, "stdout", "") or "")
|
||||
lower = stderr.lower()
|
||||
is_ad_hoc = False
|
||||
if "signature=adhoc" in lower:
|
||||
is_ad_hoc = True
|
||||
if "teamidentifier=not set" in lower:
|
||||
is_ad_hoc = True
|
||||
result["is_ad_hoc"] = is_ad_hoc
|
||||
if is_ad_hoc:
|
||||
result["errors"].append("bundle is ad-hoc signed (TeamIdentifier not set) - stable TCC identity required")
|
||||
result["signature_verified"] = False
|
||||
return result
|
||||
except Exception as exc:
|
||||
result["errors"].append(f"ad-hoc check failed: {exc}")
|
||||
result["signature_verified"] = False
|
||||
return result
|
||||
|
||||
result["ok"] = True
|
||||
return result
|
||||
|
||||
|
||||
def validate_app_bundle(
|
||||
repo_root: Optional[Path] = None,
|
||||
runner: Optional[Callable[..., Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Validate bundle layout and signature without logging secrets (dist location)."""
|
||||
r = repo_root or _repo_root()
|
||||
bundle = app_bundle_path(r)
|
||||
exe = app_bundle_executable_path(r)
|
||||
plist_p = app_bundle_info_plist_path(r)
|
||||
return _validate_bundle_at_paths(bundle, exe, plist_p, runner=runner)
|
||||
|
||||
|
||||
def validate_app_bundle_at_path(
|
||||
bundle_path: Path,
|
||||
runner: Optional[Callable[..., Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Validate an arbitrary source .app bundle path (absolute, defended) using full signed validation."""
|
||||
bp = Path(bundle_path)
|
||||
# Basic defense (no copy, just validation): must be absolute .app dir, not symlink
|
||||
err = _defense_check_source_app_path(bp)
|
||||
if err:
|
||||
return {
|
||||
"ok": False,
|
||||
"app_bundle_path": str(bp),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"bundle_identifier_expected": BUNDLE_IDENTIFIER,
|
||||
"bundle_exists": False,
|
||||
"signature_verified": False,
|
||||
"is_ad_hoc": None,
|
||||
"errors": [err],
|
||||
}
|
||||
exe = bp / "Contents" / "MacOS" / APP_EXECUTABLE_NAME
|
||||
plist_p = bp / "Contents" / "Info.plist"
|
||||
return _validate_bundle_at_paths(bp, exe, plist_p, runner=runner)
|
||||
|
||||
|
||||
def install_prebuilt_app_bundle(
|
||||
source_bundle_path: Path,
|
||||
repo_root: Optional[Path] = None,
|
||||
runner: Optional[Callable[..., Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Secure prebuilt install: validate source, atomic copy to dist, validate copy. No xcodebuild."""
|
||||
run_fn = runner or _default_runner
|
||||
r = repo_root or _repo_root()
|
||||
src = Path(source_bundle_path)
|
||||
|
||||
def_err = _defense_check_source_app_path(src)
|
||||
if def_err:
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "install_prebuilt_app_bundle",
|
||||
"error": def_err,
|
||||
"source_bundle_path": str(src),
|
||||
"app_bundle_path": str(app_bundle_path(r)),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
}
|
||||
|
||||
# Validate source before any copy
|
||||
src_validation = validate_app_bundle_at_path(src, runner=run_fn)
|
||||
if not src_validation.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "install_prebuilt_app_bundle",
|
||||
"error": f"source bundle validation failed: {'; '.join(src_validation.get('errors', []))}",
|
||||
"validation": src_validation,
|
||||
"source_bundle_path": str(src),
|
||||
"app_bundle_path": str(app_bundle_path(r)),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
}
|
||||
|
||||
dist_bundle = app_bundle_path(r)
|
||||
try:
|
||||
dist_dir = app_bundle_dir(r)
|
||||
_ensure_dir_mode(dist_dir, 0o700)
|
||||
_copy_app_bundle_atomic(src, dist_bundle)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "install_prebuilt_app_bundle",
|
||||
"error": f"bundle copy failed: {exc}",
|
||||
"source_bundle_path": str(src),
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
}
|
||||
|
||||
# Validate copy
|
||||
dst_validation = validate_app_bundle(repo_root=r, runner=run_fn)
|
||||
if not dst_validation.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "install_prebuilt_app_bundle",
|
||||
"error": f"copied bundle validation failed: {'; '.join(dst_validation.get('errors', []))}",
|
||||
"validation": dst_validation,
|
||||
"source_validation": src_validation,
|
||||
"source_bundle_path": str(src),
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "install_prebuilt_app_bundle",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"executable_path": str(app_bundle_executable_path(r)),
|
||||
"info_plist_path": str(app_bundle_info_plist_path(r)),
|
||||
"signature_verified": True,
|
||||
"validation": {k: v for k, v in dst_validation.items() if k != "errors" or v},
|
||||
"source_validation": {k: v for k, v in src_validation.items() if k != "errors" or v},
|
||||
"source_bundle_path": str(src),
|
||||
}
|
||||
|
||||
|
||||
def build_app_bundle(
|
||||
signing_identity: Optional[str] = None,
|
||||
repo_root: Optional[Path] = None,
|
||||
runner: Optional[Callable[..., Any]] = None,
|
||||
disable_code_signing: bool = False,
|
||||
derived_data_path_override: Optional[Path] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build deterministic Reyna CLI.app bundle via xcodebuild.
|
||||
|
||||
Steps:
|
||||
1. Compute xcodeproject path and controlled DerivedData path.
|
||||
2. Run xcodebuild -project <project> -scheme 'Reyna CLI' -configuration Release -derivedDataPath <controlled> build
|
||||
(with CODE_SIGNING_ALLOWED=NO when disable_code_signing=True for unsigned verification).
|
||||
Xcode owns signing via Automatic Signing; no manual `codesign --sign`.
|
||||
3. Locate product app in DerivedData/Build/Products/Release/Reyna CLI.app
|
||||
4. Safely copy it to native/ReynaCLIHost/dist/Reyna CLI.app
|
||||
5. Validate final bundle (identifier, not ad-hoc when signed).
|
||||
|
||||
signing_identity arg is deprecated and ignored for Automatic Signing; kept for compat.
|
||||
"""
|
||||
run_fn = runner or _default_runner
|
||||
r = repo_root or _repo_root()
|
||||
proj_path = _xcodeproj_path(r)
|
||||
derived_path = _derived_data_path(r, override=derived_data_path_override)
|
||||
built_product = _built_product_app_path(derived_path)
|
||||
dist_bundle = app_bundle_path(r)
|
||||
exe_dst = app_bundle_executable_path(r)
|
||||
plist_dst = app_bundle_info_plist_path(r)
|
||||
|
||||
if not proj_path.exists():
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "build_app_bundle",
|
||||
"error": f"xcodeproj not found at {proj_path}",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
}
|
||||
|
||||
info_src = _info_plist_source_path(r)
|
||||
if not info_src.exists():
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "build_app_bundle",
|
||||
"error": f"Info.plist source not found at {info_src}",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
}
|
||||
|
||||
build_cmd = build_xcodebuild_command(
|
||||
repo_root=r,
|
||||
derived_data_path=derived_path,
|
||||
disable_code_signing=disable_code_signing,
|
||||
)
|
||||
|
||||
try:
|
||||
proc = run_fn(build_cmd)
|
||||
rc = getattr(proc, "returncode", 0)
|
||||
out = getattr(proc, "stdout", "") or ""
|
||||
err = getattr(proc, "stderr", "") or ""
|
||||
if rc != 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "build_app_bundle",
|
||||
"error": f"xcodebuild failed rc={rc}",
|
||||
"stdout": out[-2000:],
|
||||
"stderr": err[-2000:],
|
||||
"build_command": build_cmd,
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"derived_data_path": str(derived_path),
|
||||
"xcodeproj_path": str(proj_path),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "build_app_bundle",
|
||||
"error": f"xcodebuild exception: {exc}",
|
||||
"build_command": build_cmd,
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"derived_data_path": str(derived_path),
|
||||
"xcodeproj_path": str(proj_path),
|
||||
}
|
||||
|
||||
if not built_product.exists():
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "build_app_bundle",
|
||||
"error": f"built product not found after build at {built_product}",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"build_command": build_cmd,
|
||||
"derived_data_path": str(derived_path),
|
||||
"built_product_path": str(built_product),
|
||||
}
|
||||
|
||||
try:
|
||||
dist_dir = app_bundle_dir(r)
|
||||
_ensure_dir_mode(dist_dir, 0o700)
|
||||
_copy_app_bundle_atomic(built_product, dist_bundle)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "build_app_bundle",
|
||||
"error": f"bundle copy failed: {exc}",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"build_command": build_cmd,
|
||||
"derived_data_path": str(derived_path),
|
||||
"built_product_path": str(built_product),
|
||||
}
|
||||
|
||||
# Final validation (optional but informative; unsigned builds will fail validation)
|
||||
validation = validate_app_bundle(repo_root=r, runner=run_fn)
|
||||
|
||||
# If signing was disabled, we don't require validation ok, but report state
|
||||
if disable_code_signing:
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "build_app_bundle",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"executable_path": str(exe_dst),
|
||||
"info_plist_path": str(plist_dst),
|
||||
"signature_verified": validation.get("signature_verified", False),
|
||||
"validation": validation,
|
||||
"build_command": build_cmd,
|
||||
"derived_data_path": str(derived_path),
|
||||
"built_product_path": str(built_product),
|
||||
"unsigned_build": True,
|
||||
}
|
||||
|
||||
if not validation.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"action": "build_app_bundle",
|
||||
"error": f"bundle validation failed: {'; '.join(validation.get('errors', []))}",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"validation": validation,
|
||||
"build_command": build_cmd,
|
||||
"derived_data_path": str(derived_path),
|
||||
"built_product_path": str(built_product),
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "build_app_bundle",
|
||||
"app_bundle_path": str(dist_bundle),
|
||||
"bundle_identifier": BUNDLE_IDENTIFIER,
|
||||
"executable_path": str(exe_dst),
|
||||
"info_plist_path": str(plist_dst),
|
||||
"signature_verified": True,
|
||||
"validation": {k: v for k, v in validation.items() if k != "errors" or v},
|
||||
"build_command": build_cmd,
|
||||
"derived_data_path": str(derived_path),
|
||||
"built_product_path": str(built_product),
|
||||
}
|
||||
+425
-49
@@ -19,6 +19,8 @@ from reyna_cli.email_local import ThunderbirdEmailClient, email_tool_specs
|
||||
from reyna_cli.immich import ImmichClient
|
||||
from reyna_cli.mcp import MCPClient
|
||||
from reyna_cli.mongo_direct import MongoDirectClient
|
||||
from reyna_cli.privacy_client import default_socket_path as privacy_default_socket_path
|
||||
from reyna_cli.privacy_host import native_calendar_list, privacy_host_status_payload
|
||||
from reyna_cli.remarkable import LISTENER_LABEL, listen_forever, listener_service_action, listener_service_status, sync_once
|
||||
from reyna_cli.tts import TTSError, synthesize_wav
|
||||
from reyna_cli.utils import infer_capabilities, resolve_tool_name
|
||||
@@ -39,13 +41,13 @@ mongo_app = typer.Typer(help="MongoDB direct driver commands.")
|
||||
zoom_app = typer.Typer(help="Zoom direct REST API commands.")
|
||||
email_app = typer.Typer(help="Read-only local Thunderbird email commands.")
|
||||
deco_app = typer.Typer(help="TP-Link Deco direct router commands.")
|
||||
macmini_app = typer.Typer(help="Mac mini MCP tools (Calendar, Contacts, Notes, Reminders, Deco).")
|
||||
macmini_app = typer.Typer(help="Mac mini MCP tools (Calendar, Contacts, Reminders, Deco).")
|
||||
remarkable_app = typer.Typer(help="Paper Pro discovery, local cache, and macOS listener service.")
|
||||
macmini_calendar_app = typer.Typer(help="Mac mini Calendar tools.")
|
||||
macmini_contacts_app = typer.Typer(help="Mac mini Contacts tools.")
|
||||
macmini_notes_app = typer.Typer(help="Mac mini Notes tools.")
|
||||
macmini_reminders_app = typer.Typer(help="Mac mini Reminders tools.")
|
||||
macmini_deco_app = typer.Typer(help="TP-Link Deco direct router commands (backward-compatible alias).")
|
||||
privacy_host_app = typer.Typer(help="Native privacy host commands.")
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -67,11 +69,11 @@ app.add_typer(email_app, name="email")
|
||||
app.add_typer(deco_app, name="deco")
|
||||
macmini_app.add_typer(macmini_calendar_app, name="calendar")
|
||||
macmini_app.add_typer(macmini_contacts_app, name="contacts")
|
||||
macmini_app.add_typer(macmini_notes_app, name="notes")
|
||||
macmini_app.add_typer(macmini_reminders_app, name="reminders")
|
||||
macmini_app.add_typer(macmini_deco_app, name="deco")
|
||||
app.add_typer(macmini_app, name="macmini")
|
||||
app.add_typer(remarkable_app, name="remarkable")
|
||||
app.add_typer(privacy_host_app, name="privacy-host")
|
||||
|
||||
|
||||
def scrub_sensitive(value: Any) -> Any:
|
||||
@@ -1100,40 +1102,260 @@ def macmini_ping(json_output: bool = typer.Option(False, "--json")):
|
||||
@macmini_calendar_app.command("calendars")
|
||||
def macmini_calendar_calendars(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
emit(call_macmini_tool("calendar_list_calendars", {}), json_output)
|
||||
from reyna_cli.privacy_host import native_calendar_list
|
||||
|
||||
emit(native_calendar_list(), json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@privacy_host_app.command("status")
|
||||
def privacy_host_status(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
from reyna_cli.privacy_host import privacy_host_status_payload
|
||||
|
||||
emit(privacy_host_status_payload(), json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@privacy_host_app.command("calendar-authorize")
|
||||
def privacy_host_calendar_authorize(json_output: bool = typer.Option(False, "--json", help="Output JSON. This command will trigger the macOS Calendar permission prompt if not yet decided, making ReynaCLIHost appear in System Settings > Privacy & Security > Calendars.")):
|
||||
"""Request Calendar full-access permission via the native privacy host.
|
||||
|
||||
This is the ONLY command that triggers the macOS Calendar permission dialog.
|
||||
It calls the native host operation `calendar.request_full_access` directly,
|
||||
with no generic call and no MCP fallback. On first run, macOS shows a prompt
|
||||
to authorize ReynaCLIHost for Calendars. Approve it to make the host appear
|
||||
in System Settings > Privacy & Security > Calendars.
|
||||
"""
|
||||
try:
|
||||
from reyna_cli.privacy_host import native_calendar_request_full_access
|
||||
|
||||
emit(native_calendar_request_full_access(), json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@privacy_host_app.command("contacts-authorize")
|
||||
def privacy_host_contacts_authorize(json_output: bool = typer.Option(False, "--json", help="Output JSON. This command will trigger the macOS Contacts permission prompt if not yet decided, making ReynaCLIHost appear in System Settings > Privacy & Security > Contacts.")):
|
||||
"""Request Contacts permission via the native privacy host.
|
||||
|
||||
This is the ONLY command that triggers the macOS Contacts permission dialog.
|
||||
It calls the native host operation `contacts.request_access` directly,
|
||||
with no generic call and no MCP fallback. On first run, macOS shows a prompt
|
||||
to authorize ReynaCLIHost for Contacts. Approve it to make the host appear
|
||||
in System Settings > Privacy & Security > Contacts.
|
||||
"""
|
||||
try:
|
||||
from reyna_cli.privacy_host import native_contacts_request_access
|
||||
|
||||
emit(native_contacts_request_access(), json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@privacy_host_app.command("reminders-authorize")
|
||||
def privacy_host_reminders_authorize(json_output: bool = typer.Option(False, "--json", help="Output JSON. This command will trigger the macOS Reminders permission prompt if not yet decided, making ReynaCLIHost appear in System Settings > Privacy & Security > Reminders.")):
|
||||
"""Request Reminders full-access permission via the native privacy host.
|
||||
|
||||
This is the ONLY command that triggers the macOS Reminders permission dialog.
|
||||
It calls the native host operation `reminders.request_full_access` directly,
|
||||
with no generic call and no MCP fallback. On first run, macOS shows a prompt
|
||||
to authorize ReynaCLIHost for Reminders. Approve it to make the host appear
|
||||
in System Settings > Privacy & Security > Reminders.
|
||||
"""
|
||||
try:
|
||||
from reyna_cli.privacy_host import native_reminders_request_full_access
|
||||
|
||||
emit(native_reminders_request_full_access(), json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@privacy_host_app.command("install")
|
||||
def privacy_host_install(
|
||||
json_output: bool = typer.Option(False, "--json"),
|
||||
app_bundle: Optional[Path] = typer.Option(
|
||||
None,
|
||||
"--app-bundle",
|
||||
help="Absolute path to a prebuilt signed Reyna CLI.app bundle (GUI-built). Validated before copy; no xcodebuild when set.",
|
||||
),
|
||||
):
|
||||
try:
|
||||
from reyna_cli.privacy_host import install_privacy_host_service
|
||||
from pathlib import Path as _P
|
||||
|
||||
# Explicit prebuilt option — defend early at CLI layer too
|
||||
prebuilt_path: Optional[_P] = None
|
||||
if app_bundle is not None:
|
||||
p = _P(app_bundle)
|
||||
# Must be absolute and end with .app per secure bridge contract
|
||||
if not p.is_absolute():
|
||||
fail(f"app bundle path must be absolute: {p}", json_output)
|
||||
if not str(p).endswith(".app"):
|
||||
fail(f"app bundle path must end with .app suffix: {p}", json_output)
|
||||
if ".." in p.parts:
|
||||
fail(f"app bundle path must not contain '..': {p}", json_output)
|
||||
try:
|
||||
st = p.lstat()
|
||||
import stat as _st_mod
|
||||
|
||||
if _st_mod.S_ISLNK(st.st_mode):
|
||||
fail(f"refusing symlink source bundle: {p}", json_output)
|
||||
if not _st_mod.S_ISDIR(st.st_mode):
|
||||
fail(f"source bundle is not a directory: {p}", json_output)
|
||||
except FileNotFoundError:
|
||||
fail(f"source bundle not found: {p}", json_output)
|
||||
except typer.Exit:
|
||||
raise
|
||||
except Exception as exc:
|
||||
fail(f"source bundle check failed: {exc}", json_output)
|
||||
prebuilt_path = p
|
||||
|
||||
result = install_privacy_host_service(prebuilt_app_bundle_path=prebuilt_path)
|
||||
emit(result, json_output)
|
||||
if not result.get("ok"):
|
||||
raise typer.Exit(1)
|
||||
except typer.Exit:
|
||||
raise
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@privacy_host_app.command("start")
|
||||
def privacy_host_start(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
from reyna_cli.privacy_host import start_privacy_host_service
|
||||
|
||||
result = start_privacy_host_service()
|
||||
emit(result, json_output)
|
||||
if not result.get("ok"):
|
||||
raise typer.Exit(1)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@privacy_host_app.command("stop")
|
||||
def privacy_host_stop(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
from reyna_cli.privacy_host import stop_privacy_host_service
|
||||
|
||||
result = stop_privacy_host_service()
|
||||
emit(result, json_output)
|
||||
if not result.get("ok"):
|
||||
raise typer.Exit(1)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@privacy_host_app.command("uninstall")
|
||||
def privacy_host_uninstall(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
from reyna_cli.privacy_host import uninstall_privacy_host_service
|
||||
|
||||
result = uninstall_privacy_host_service()
|
||||
emit(result, json_output)
|
||||
if not result.get("ok"):
|
||||
raise typer.Exit(1)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@macmini_calendar_app.command("events")
|
||||
def macmini_calendar_events(start: str, end: str, calendar: Optional[str] = None, calendar_index: Optional[int] = typer.Option(None, "--calendar-index"), limit: int = 50, json_output: bool = typer.Option(False, "--json")):
|
||||
args: Dict[str, Any] = {"start": start, "end": end, "limit": limit}
|
||||
if calendar is not None:
|
||||
args["calendar"] = calendar
|
||||
if calendar_index is not None:
|
||||
args["calendarIndex"] = calendar_index
|
||||
try:
|
||||
emit(call_macmini_tool("calendar_list_events", args), json_output)
|
||||
from reyna_cli.privacy_host import native_calendar_events_list, native_calendar_list
|
||||
|
||||
calendar_id: Optional[str] = None
|
||||
calendar_title = calendar
|
||||
|
||||
if calendar_index is not None:
|
||||
# Legacy compatibility: resolve index via native calendar list exactly once, map to stable ID
|
||||
list_payload = native_calendar_list()
|
||||
result_obj = list_payload.get("result") or {}
|
||||
# result_obj may be dict with calendars key, or direct list (legacy test)
|
||||
if isinstance(result_obj, dict):
|
||||
if "calendars" in result_obj:
|
||||
calendars = result_obj["calendars"]
|
||||
else:
|
||||
# fallback: result itself might be the list payload object containing calendars inside?
|
||||
# handle protocol_version wrapper: result_obj is ResultPayload dict
|
||||
calendars = result_obj.get("calendars") or []
|
||||
elif isinstance(result_obj, list):
|
||||
calendars = result_obj
|
||||
else:
|
||||
calendars = []
|
||||
|
||||
# calendars is list of dicts with id/title etc, already sorted by source/title/id deterministically by host
|
||||
if not isinstance(calendars, list):
|
||||
fail(f"Invalid calendar list response for index mapping", json_output)
|
||||
|
||||
if calendar_index < 0 or calendar_index >= len(calendars):
|
||||
fail(f"Calendar index {calendar_index} out of range (0..{len(calendars)-1})", json_output)
|
||||
|
||||
cal_item = calendars[calendar_index]
|
||||
if not isinstance(cal_item, dict) or "id" not in cal_item:
|
||||
fail(f"Calendar at index {calendar_index} missing stable id", json_output)
|
||||
|
||||
calendar_id = cal_item["id"]
|
||||
# If both calendar title and index were provided, ID wins per spec, but we preserve title for logging? ID wins.
|
||||
# Clear title to avoid ambiguous double filter (ID wins server-side anyway)
|
||||
# Keep calendar_title only if user didn't provide index? Actually spec says stable ID wins, so if index supplied we use ID only.
|
||||
calendar_title = None
|
||||
|
||||
emit(native_calendar_events_list(start=start, end=end, calendar_id=calendar_id, calendar=calendar_title, limit=limit), json_output)
|
||||
except Exception as exc:
|
||||
# If already failed via fail(), typer.Exit already raised; don't double wrap
|
||||
if isinstance(exc, typer.Exit):
|
||||
raise
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@macmini_calendar_app.command("create")
|
||||
def macmini_calendar_create(title: str, start: str, end: str, all_day: bool = typer.Option(False, "--all-day"), notes: Optional[str] = None, location: Optional[str] = None, calendar: Optional[str] = None, calendar_index: Optional[int] = typer.Option(None, "--calendar-index"), json_output: bool = typer.Option(False, "--json")):
|
||||
args: Dict[str, Any] = {"title": title, "start": start, "end": end, "allDay": all_day}
|
||||
for key, value in {"notes": notes, "location": location, "calendar": calendar, "calendarIndex": calendar_index}.items():
|
||||
if value is not None:
|
||||
args[key] = value
|
||||
try:
|
||||
emit(call_macmini_tool("calendar_create_event", args), json_output)
|
||||
from reyna_cli.privacy_host import native_calendar_event_create, native_calendar_list
|
||||
|
||||
calendar_id: Optional[str] = None
|
||||
calendar_title = calendar
|
||||
|
||||
if calendar_index is not None:
|
||||
list_payload = native_calendar_list()
|
||||
result_obj = list_payload.get("result") or {}
|
||||
if isinstance(result_obj, dict):
|
||||
calendars = result_obj.get("calendars") or []
|
||||
elif isinstance(result_obj, list):
|
||||
calendars = result_obj
|
||||
else:
|
||||
calendars = []
|
||||
|
||||
if not isinstance(calendars, list):
|
||||
fail(f"Invalid calendar list response for index mapping", json_output)
|
||||
|
||||
if calendar_index < 0 or calendar_index >= len(calendars):
|
||||
fail(f"Calendar index {calendar_index} out of range (0..{len(calendars)-1})", json_output)
|
||||
|
||||
cal_item = calendars[calendar_index]
|
||||
if not isinstance(cal_item, dict) or "id" not in cal_item:
|
||||
fail(f"Calendar at index {calendar_index} missing stable id", json_output)
|
||||
|
||||
calendar_id = cal_item["id"]
|
||||
calendar_title = None
|
||||
|
||||
emit(native_calendar_event_create(title=title, start=start, end=end, all_day=all_day, notes=notes, location=location, calendar_id=calendar_id, calendar=calendar_title), json_output)
|
||||
except Exception as exc:
|
||||
if isinstance(exc, typer.Exit):
|
||||
raise
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@macmini_contacts_app.command("search")
|
||||
def macmini_contacts_search(query: Optional[str] = None, limit: int = 20, json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
emit(call_macmini_tool("contacts_search", {"query": query, "limit": limit}), json_output)
|
||||
from reyna_cli.privacy_host import native_contacts_search
|
||||
|
||||
emit(native_contacts_search(query=query, limit=limit), json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
@@ -1141,51 +1363,168 @@ def macmini_contacts_search(query: Optional[str] = None, limit: int = 20, json_o
|
||||
@macmini_contacts_app.command("read")
|
||||
def macmini_contacts_read(contact_id: str, json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
emit(call_macmini_tool("contacts_read", {"id": contact_id}), json_output)
|
||||
from reyna_cli.privacy_host import native_contacts_read
|
||||
|
||||
emit(native_contacts_read(contact_id=contact_id), json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@macmini_contacts_app.command("create")
|
||||
def macmini_contacts_create(first_name: Optional[str] = typer.Option(None, "--first-name"), last_name: Optional[str] = typer.Option(None, "--last-name"), organization: Optional[str] = None, job_title: Optional[str] = typer.Option(None, "--job-title"), note: Optional[str] = None, email: Optional[str] = None, phone: Optional[str] = None, json_output: bool = typer.Option(False, "--json")):
|
||||
args: Dict[str, Any] = {}
|
||||
for key, value in {"firstName": first_name, "lastName": last_name, "organization": organization, "jobTitle": job_title, "note": note}.items():
|
||||
if value is not None:
|
||||
args[key] = value
|
||||
if email:
|
||||
args["email"] = {"label": "work", "value": email}
|
||||
if phone:
|
||||
args["phone"] = {"label": "mobile", "value": phone}
|
||||
try:
|
||||
emit(call_macmini_tool("contacts_create", args), json_output)
|
||||
from reyna_cli.privacy_host import native_contacts_create
|
||||
|
||||
emit(
|
||||
native_contacts_create(
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
organization=organization,
|
||||
job_title=job_title,
|
||||
note=note,
|
||||
email=email,
|
||||
phone=phone,
|
||||
),
|
||||
json_output,
|
||||
)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@macmini_notes_app.command("list")
|
||||
def macmini_notes_list(query: Optional[str] = None, folder: Optional[str] = None, include_preview: bool = typer.Option(False, "--include-preview"), limit: int = 20, json_output: bool = typer.Option(False, "--json")):
|
||||
args = {"query": query, "folder": folder, "includePreview": include_preview, "limit": limit}
|
||||
# ─── System info direct via native host (A) ──────────────────────────────────
|
||||
|
||||
|
||||
@macmini_app.command("system-info")
|
||||
def macmini_system_info(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
emit(call_macmini_tool("notes_list", args), json_output)
|
||||
from reyna_cli.privacy_host import native_system_get_info
|
||||
|
||||
emit(native_system_get_info(), json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@macmini_notes_app.command("read")
|
||||
def macmini_notes_read(note_id: str, json_output: bool = typer.Option(False, "--json")):
|
||||
@macmini_app.command("speech-api-status")
|
||||
def macmini_speech_api_status(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
emit(call_macmini_tool("notes_read", {"id": note_id}), json_output)
|
||||
from reyna_cli.privacy_host import native_system_speech_api_status
|
||||
|
||||
emit(native_system_speech_api_status(), json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@macmini_notes_app.command("create")
|
||||
def macmini_notes_create(title: str, body: str = "", folder: Optional[str] = None, json_output: bool = typer.Option(False, "--json")):
|
||||
args: Dict[str, Any] = {"title": title, "body": body}
|
||||
if folder is not None:
|
||||
args["folder"] = folder
|
||||
# ─── Local services direct wrappers (B) ──────────────────────────────────────
|
||||
|
||||
local_services_app = typer.Typer(help="Local TTS/STT/Voice services direct (Kokoro, Voicebox, Apple LLM, Speech, Image) — no MCP.")
|
||||
speech_direct_app = typer.Typer(help="macOS say + SpeechTranscriber direct.")
|
||||
kokoro_app = typer.Typer(help="Kokoro ksay TTS daemon direct.")
|
||||
voicebox_direct_app = typer.Typer(help="Voicebox Qwen3-TTS direct.")
|
||||
apple_llm_app = typer.Typer(help="Apple ANE 3B LLM direct.")
|
||||
image_direct_app = typer.Typer(help="Image generation config (Codex/Gemini) direct — config only.")
|
||||
system_direct_app = typer.Typer(help="Local system info direct (offline safe).")
|
||||
|
||||
local_services_app.add_typer(speech_direct_app, name="speech")
|
||||
local_services_app.add_typer(kokoro_app, name="kokoro")
|
||||
local_services_app.add_typer(voicebox_direct_app, name="voicebox")
|
||||
local_services_app.add_typer(apple_llm_app, name="apple-llm")
|
||||
local_services_app.add_typer(image_direct_app, name="image")
|
||||
local_services_app.add_typer(system_direct_app, name="system")
|
||||
app.add_typer(local_services_app, name="local-services")
|
||||
|
||||
|
||||
@speech_direct_app.command("config")
|
||||
def speech_direct_config(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
emit(call_macmini_tool("notes_create", args), json_output)
|
||||
from reyna_cli.local_services_direct import SpeechDirectClient
|
||||
|
||||
emit({"ok": True, "source": "direct", "result": SpeechDirectClient().config_status()}, json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@speech_direct_app.command("voices")
|
||||
def speech_direct_voices(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
from reyna_cli.local_services_direct import SpeechDirectClient
|
||||
|
||||
emit(SpeechDirectClient().list_voices(), json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@kokoro_app.command("config")
|
||||
def kokoro_direct_config(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
from reyna_cli.local_services_direct import KokoroDirectClient
|
||||
|
||||
emit({"ok": True, "source": "direct", "result": KokoroDirectClient().config_status()}, json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@voicebox_direct_app.command("config")
|
||||
def voicebox_direct_config(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
from reyna_cli.local_services_direct import VoiceboxDirectClient
|
||||
|
||||
emit({"ok": True, "source": "direct", "result": VoiceboxDirectClient().config_status()}, json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@apple_llm_app.command("config")
|
||||
def apple_llm_direct_config(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
from reyna_cli.local_services_direct import AppleLLMDirectClient
|
||||
|
||||
emit({"ok": True, "source": "direct", "result": AppleLLMDirectClient().config_status()}, json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@apple_llm_app.command("check")
|
||||
def apple_llm_direct_check(json_output: bool = typer.Option(False, "--json")):
|
||||
# Prefer native privacy host probe (A), but also allow offline config
|
||||
try:
|
||||
from reyna_cli.privacy_host import native_apple_llm_check
|
||||
|
||||
emit(native_apple_llm_check(), json_output)
|
||||
except Exception:
|
||||
try:
|
||||
from reyna_cli.local_services_direct import AppleLLMDirectClient
|
||||
|
||||
emit({"ok": True, "source": "direct_offline", "result": AppleLLMDirectClient().config_status()}, json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@image_direct_app.command("config")
|
||||
def image_direct_config(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
from reyna_cli.local_services_direct import ImageDirectClient
|
||||
|
||||
emit({"ok": True, "source": "direct", "result": ImageDirectClient().config_status()}, json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@system_direct_app.command("config")
|
||||
def system_direct_config(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
from reyna_cli.local_services_direct import SystemDirectClient
|
||||
|
||||
emit({"ok": True, "source": "direct", "result": SystemDirectClient().config_status()}, json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@system_direct_app.command("info")
|
||||
def system_direct_info(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
from reyna_cli.local_services_direct import SystemDirectClient
|
||||
|
||||
emit({"ok": True, "source": "direct_offline", "result": SystemDirectClient().get_info_offline()}, json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
@@ -1193,28 +1532,65 @@ def macmini_notes_create(title: str, body: str = "", folder: Optional[str] = Non
|
||||
@macmini_reminders_app.command("lists")
|
||||
def macmini_reminders_lists(json_output: bool = typer.Option(False, "--json")):
|
||||
try:
|
||||
emit(call_macmini_tool("reminders_list_lists", {}), json_output)
|
||||
from reyna_cli.privacy_host import native_reminders_lists
|
||||
|
||||
emit(native_reminders_lists(), json_output)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@macmini_reminders_app.command("list")
|
||||
def macmini_reminders_list(list_name: Optional[str] = typer.Option(None, "--list"), completed: Optional[bool] = typer.Option(False, "--completed/--incomplete"), limit: int = 25, json_output: bool = typer.Option(False, "--json")):
|
||||
args = {"list": list_name, "completed": completed, "limit": limit}
|
||||
def macmini_reminders_list(
|
||||
list_name: Optional[str] = typer.Option(None, "--list"),
|
||||
list_id: Optional[str] = typer.Option(None, "--list-id"),
|
||||
completed: Optional[bool] = typer.Option(None, "--completed/--incomplete"),
|
||||
limit: int = 25,
|
||||
json_output: bool = typer.Option(False, "--json"),
|
||||
):
|
||||
try:
|
||||
emit(call_macmini_tool("reminders_list", args), json_output)
|
||||
from reyna_cli.privacy_host import native_reminders_list
|
||||
|
||||
# Determine if completed filter was explicitly set
|
||||
# typer with Optional[bool] + None default => None when not passed, bool when passed
|
||||
emit(
|
||||
native_reminders_list(
|
||||
list_id=list_id,
|
||||
list_name=list_name,
|
||||
completed=completed,
|
||||
limit=limit,
|
||||
),
|
||||
json_output,
|
||||
)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@macmini_reminders_app.command("create")
|
||||
def macmini_reminders_create(title: str, list_name: Optional[str] = typer.Option(None, "--list"), notes: Optional[str] = None, due: Optional[str] = None, json_output: bool = typer.Option(False, "--json")):
|
||||
args: Dict[str, Any] = {"title": title}
|
||||
for key, value in {"list": list_name, "notes": notes, "due": due}.items():
|
||||
if value is not None:
|
||||
args[key] = value
|
||||
def macmini_reminders_create(
|
||||
title: str,
|
||||
list_name: Optional[str] = typer.Option(None, "--list"),
|
||||
list_id: Optional[str] = typer.Option(None, "--list-id"),
|
||||
notes: Optional[str] = None,
|
||||
due: Optional[str] = None,
|
||||
priority: Optional[int] = typer.Option(None, "--priority", min=0, max=9),
|
||||
json_output: bool = typer.Option(False, "--json"),
|
||||
):
|
||||
if list_id is None and list_name is None:
|
||||
fail("Reminders list must be specified by --list or --list-id", json_output)
|
||||
try:
|
||||
emit(call_macmini_tool("reminders_create", args), json_output)
|
||||
from reyna_cli.privacy_host import native_reminders_create
|
||||
|
||||
emit(
|
||||
native_reminders_create(
|
||||
title=title,
|
||||
list_id=list_id,
|
||||
list_name=list_name,
|
||||
notes=notes,
|
||||
due=due,
|
||||
priority=priority,
|
||||
),
|
||||
json_output,
|
||||
)
|
||||
except Exception as exc:
|
||||
fail(str(exc), json_output)
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Direct typed clients for local TTS/ASR services — no MCP, offline-safe.
|
||||
|
||||
Covers:
|
||||
- macOS say (list_voices / synthesize)
|
||||
- SpeechTranscriber (locales / file transcribe config)
|
||||
- Kokoro ksay HTTP daemon (http://127.0.0.1:7332)
|
||||
- Voicebox Qwen3-TTS (http://127.0.0.1:17493)
|
||||
- Apple LLM ANE 3B (config + health probe)
|
||||
- Codex image / Gemini image config status (offline)
|
||||
All config_status() methods are offline-safe, env-driven, no live network/audio, never expose secrets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
from reyna_cli.env import load_hermes_env
|
||||
|
||||
# ─── macOS say ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _run_say_list() -> List[Dict[str, str]]:
|
||||
say = shutil.which("say")
|
||||
if not say:
|
||||
return []
|
||||
try:
|
||||
result = subprocess.run([say, "-v", "?"], capture_output=True, text=True, timeout=10, check=False)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
out: List[Dict[str, str]] = []
|
||||
for line in result.stdout.splitlines():
|
||||
line=line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
continue
|
||||
name = parts[0]
|
||||
# second token often locale like en_US
|
||||
locale = parts[1] if len(parts)>1 else ""
|
||||
desc = " ".join(parts[2:]).lstrip("# ").strip()
|
||||
out.append({"name": name, "locale": locale, "description": desc})
|
||||
return out
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
class SpeechDirectClient:
|
||||
"""Direct say + SpeechTranscriber config — offline safe."""
|
||||
|
||||
def config_status(self) -> Dict[str, Any]:
|
||||
load_hermes_env()
|
||||
say_path = shutil.which("say")
|
||||
afconvert = shutil.which("afconvert")
|
||||
return {
|
||||
"say_available": bool(say_path),
|
||||
"say_path": say_path or "(not found)",
|
||||
"afconvert_available": bool(afconvert),
|
||||
"afconvert_path": afconvert or "(not found)",
|
||||
"speech_framework_expected": "/System/Library/Frameworks/Speech.framework",
|
||||
"macOS_version": self._macos_version(),
|
||||
"source": "direct",
|
||||
}
|
||||
|
||||
def _macos_version(self) -> str:
|
||||
try:
|
||||
r = subprocess.run(["/usr/bin/sw_vers", "-productVersion"], capture_output=True, text=True, timeout=3, check=False)
|
||||
return r.stdout.strip() or "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
def list_voices(self) -> Dict[str, Any]:
|
||||
voices = _run_say_list()
|
||||
return {"ok": True, "count": len(voices), "voices": voices, "source": "direct"}
|
||||
|
||||
def synthesize_args(self, text: str, voice: Optional[str]=None, rate: Optional[int]=None) -> Dict[str, Any]:
|
||||
# Validate offline, no audio generation
|
||||
if not text or not text.strip():
|
||||
raise ValueError("text required")
|
||||
clean = text[:5000]
|
||||
v = (voice or "").strip()[:100] or None
|
||||
r = None
|
||||
if rate is not None:
|
||||
ri = int(rate)
|
||||
if ri < 80 or ri > 500:
|
||||
raise ValueError("rate must be 80..500")
|
||||
r = ri
|
||||
return {"text": clean, "voice": v or "default", "rate": r, "source": "direct", "offline_validation": True}
|
||||
|
||||
# ─── Kokoro ksay ────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KokoroConfig:
|
||||
url: str
|
||||
voice: str
|
||||
lang_code: str
|
||||
|
||||
class KokoroDirectClient:
|
||||
DEFAULT_URL = "http://127.0.0.1:7332"
|
||||
DEFAULT_VOICE = "af_heart"
|
||||
DEFAULT_LANG = "a"
|
||||
|
||||
def __init__(self, url: Optional[str]=None, voice: Optional[str]=None, lang_code: Optional[str]=None):
|
||||
load_hermes_env()
|
||||
self.url = (url or os.environ.get("KSAY_URL") or self.DEFAULT_URL).rstrip("/")
|
||||
self.voice = (voice or os.environ.get("KSAY_VOICE") or self.DEFAULT_VOICE).strip()[:100]
|
||||
self.lang_code = (lang_code or os.environ.get("KSAY_LANG_CODE") or self.DEFAULT_LANG).strip()[:8]
|
||||
|
||||
def config_status(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"url": self.url,
|
||||
"voice": self.voice,
|
||||
"lang_code": self.lang_code,
|
||||
"configured": True,
|
||||
"password_configured": False,
|
||||
"source": "direct",
|
||||
"note": "Uses warm ksay daemon at KSAY_URL; config_status does NOT contact daemon.",
|
||||
}
|
||||
|
||||
def validate_synthesize(self, text: str, voice: Optional[str]=None, speed: Optional[float]=None, lang_code: Optional[str]=None) -> Dict[str, Any]:
|
||||
if not text or not text.strip():
|
||||
raise ValueError("text required")
|
||||
clean = text[:8000]
|
||||
v = (voice or self.voice).strip()[:100]
|
||||
lc = (lang_code or self.lang_code).strip()[:8]
|
||||
spd = 1.0
|
||||
if speed is not None:
|
||||
spd = float(speed)
|
||||
if spd < 0.5 or spd > 2.0:
|
||||
raise ValueError("speed must be 0.5..2.0")
|
||||
return {"text": clean, "voice": v, "speed": spd, "langCode": lc, "url": self.url, "offline_validation": True}
|
||||
|
||||
# ─── Voicebox ───────────────────────────────────────────────────────────────
|
||||
|
||||
class VoiceboxDirectClient:
|
||||
DEFAULT_URL = "http://127.0.0.1:17493"
|
||||
KNOWN_PROFILES = {
|
||||
"Aiden": "ff624ec6-5485-4173-a4f0-2ec2196efd39",
|
||||
"Adolfo": "0e042c6b-ae52-4f28-835b-528381ed60b4",
|
||||
"Nicole": "52330098-6fc3-4e9c-a30c-11164869636e",
|
||||
"Jessica": "579c7444-3905-4aab-8067-eb10a0b3e76f",
|
||||
}
|
||||
|
||||
def __init__(self, url: Optional[str]=None):
|
||||
load_hermes_env()
|
||||
self.url = (url or os.environ.get("VOICEBOX_URL") or self.DEFAULT_URL).rstrip("/")
|
||||
|
||||
def config_status(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"url": self.url,
|
||||
"known_profiles": self.KNOWN_PROFILES,
|
||||
"default_boy_voice": "Aiden",
|
||||
"default_girl_voice": "Jessica",
|
||||
"source": "direct",
|
||||
"note": "config_status does NOT contact Voicebox daemon.",
|
||||
}
|
||||
|
||||
def validate_generate(self, text: str, profile: Optional[str]=None) -> Dict[str, Any]:
|
||||
if not text or not text.strip():
|
||||
raise ValueError("text required")
|
||||
clean = text[:1000]
|
||||
prof = (profile or "Aiden").strip()[:200]
|
||||
pid = self.KNOWN_PROFILES.get(prof, prof)
|
||||
return {"text": clean, "profile": prof, "profile_id": pid, "url": self.url, "offline_validation": True}
|
||||
|
||||
# ─── Apple LLM ANE 3B ───────────────────────────────────────────────────────
|
||||
|
||||
class AppleLLMDirectClient:
|
||||
def config_status(self) -> Dict[str, Any]:
|
||||
load_hermes_env()
|
||||
swift = shutil.which("swift")
|
||||
swiftc = shutil.which("swiftc")
|
||||
return {
|
||||
"swift_available": bool(swift),
|
||||
"swift_path": swift or "(not found)",
|
||||
"swiftc_available": bool(swiftc),
|
||||
"swiftc_path": swiftc or "(not found)",
|
||||
"framework": "FoundationModels SystemLanguageModel ANE 3B",
|
||||
"expected_session_idle_timeout_sec": 120,
|
||||
"source": "direct",
|
||||
}
|
||||
|
||||
def validate_polish(self, text: str, mode: str="line") -> Dict[str, Any]:
|
||||
if not text:
|
||||
raise ValueError("text required")
|
||||
m = mode if mode in ("line","paragraph","quick_reply","chat","check") else "line"
|
||||
return {"text": text[:5000], "mode": m, "offline_validation": True}
|
||||
|
||||
# ─── Image gen (out of scope but config only) ───────────────────────────────
|
||||
|
||||
class ImageDirectClient:
|
||||
def config_status(self) -> Dict[str, Any]:
|
||||
load_hermes_env()
|
||||
codex = shutil.which("codex") or os.environ.get("CODEX_CLI_PATH") or "codex"
|
||||
gemini_key_configured = bool(os.environ.get("GEMINI_API_KEY"))
|
||||
return {
|
||||
"codex_cli_path": codex,
|
||||
"codex_output_dir": os.environ.get("CODEX_IMAGE_OUTPUT_DIR") or "~/Projects/MacMiniMCP/generated-images",
|
||||
"gemini_api_key_configured": gemini_key_configured,
|
||||
"gemini_model_default": "gemini-3.1-flash-image",
|
||||
"gemini_output_dir": os.environ.get("GEMINI_IMAGE_OUTPUT_DIR") or "~/Projects/MacMiniMCP/generated-images",
|
||||
"gemini_chrome_profile": os.environ.get("GEMINI_CHROME_PROFILE_NAME") or "ReynaFamilyBot",
|
||||
"source": "direct",
|
||||
"note": "config_status only, no image generation, no key exposure",
|
||||
}
|
||||
|
||||
# ─── System info (offline safe, no TCC) ───────────────────────────────────
|
||||
|
||||
class SystemDirectClient:
|
||||
def config_status(self) -> Dict[str, Any]:
|
||||
load_hermes_env()
|
||||
return {
|
||||
"tools": ["sw_vers", "uname", "sysctl hw.model", "sysctl machdep.cpu.brand_string"],
|
||||
"source": "direct",
|
||||
"requires_tcc": False,
|
||||
}
|
||||
|
||||
def get_info_offline(self) -> Dict[str, Any]:
|
||||
# Offline deterministic stub via python platform
|
||||
import platform
|
||||
ver = platform.mac_ver()[0] or platform.platform()
|
||||
return {
|
||||
"macos_version": ver,
|
||||
"platform": platform.platform(),
|
||||
"machine": platform.machine(),
|
||||
"is_macos_26_plus": False, # conservative offline
|
||||
"source": "direct_offline",
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Synchronous Unix-domain-socket JSON-lines privacy RPC client.
|
||||
|
||||
Protocol:
|
||||
- Client connects to Unix socket, sends exactly one JSON line: {"id": "<unique>", "operation": "...", "arguments": {...}}\n
|
||||
- Server replies with one JSON line containing same id and ok bool.
|
||||
- Validates id match; raises PrivacyClientError otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
MAX_REQUEST_BYTES = 64 * 1024 # 64 KiB
|
||||
|
||||
DEFAULT_TIMEOUT = 5.0
|
||||
|
||||
|
||||
class PrivacyClientError(RuntimeError):
|
||||
"""Raised for missing socket, timeout, malformed JSON, mismatched id, or ok==false."""
|
||||
|
||||
|
||||
def default_socket_path() -> Path:
|
||||
return Path.home() / "Library/Application Support/reyna-cli/privacy/reyna-cli.sock"
|
||||
|
||||
|
||||
class PrivacyClient:
|
||||
def __init__(
|
||||
self,
|
||||
socket_path: Optional[Union[str, Path]] = None,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
):
|
||||
if socket_path is None:
|
||||
socket_path = default_socket_path()
|
||||
self.socket_path = Path(socket_path)
|
||||
self.timeout = float(timeout)
|
||||
|
||||
def call(self, operation: str, arguments: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
if arguments is None:
|
||||
arguments = {}
|
||||
|
||||
# unique nonempty id
|
||||
req_id = uuid.uuid4().hex
|
||||
|
||||
payload = {
|
||||
"id": req_id,
|
||||
"operation": operation,
|
||||
"arguments": arguments,
|
||||
}
|
||||
|
||||
# Serialize + enforce size BEFORE connecting
|
||||
try:
|
||||
line = json.dumps(payload, separators=(",", ":")) + "\n"
|
||||
except Exception as e:
|
||||
raise PrivacyClientError(f"failed to serialize request: {e}") from e
|
||||
|
||||
encoded = line.encode("utf-8")
|
||||
if len(encoded) > MAX_REQUEST_BYTES:
|
||||
raise PrivacyClientError(
|
||||
f"request payload too large: {len(encoded)} bytes exceeds {MAX_REQUEST_BYTES} bytes (64 KiB) limit"
|
||||
)
|
||||
|
||||
# Connect and do RPC
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(self.timeout)
|
||||
try:
|
||||
try:
|
||||
sock.connect(str(self.socket_path))
|
||||
except FileNotFoundError as e:
|
||||
raise PrivacyClientError(f"privacy socket not found at {self.socket_path}: {e}") from e
|
||||
except ConnectionRefusedError as e:
|
||||
raise PrivacyClientError(f"privacy socket connection refused at {self.socket_path}: {e}") from e
|
||||
except OSError as e:
|
||||
# Covers missing socket, no such file, etc.
|
||||
# Distinguish missing vs other
|
||||
if "No such file" in str(e) or e.errno in (2,): # ENOENT
|
||||
raise PrivacyClientError(f"privacy socket not found at {self.socket_path}: {e}") from e
|
||||
raise PrivacyClientError(f"failed to connect to privacy socket {self.socket_path}: {e}") from e
|
||||
|
||||
# Send exactly one line
|
||||
try:
|
||||
sock.sendall(encoded)
|
||||
except socket.timeout as e:
|
||||
raise PrivacyClientError(f"privacy RPC send timed out after {self.timeout}s") from e
|
||||
except OSError as e:
|
||||
raise PrivacyClientError(f"privacy RPC send failed: {e}") from e
|
||||
|
||||
# Read one line - buffered
|
||||
# We must read until newline, but guard against huge response? For minimal impl, read up to reasonable limit
|
||||
# but spec doesn't require limit on response. We'll read chunks until newline.
|
||||
buf = bytearray()
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
chunk = sock.recv(8192)
|
||||
except socket.timeout as e:
|
||||
raise PrivacyClientError(
|
||||
f"privacy RPC response timed out after {self.timeout}s"
|
||||
) from e
|
||||
if not chunk:
|
||||
# EOF before newline - malformed
|
||||
if not buf:
|
||||
raise PrivacyClientError("privacy RPC: connection closed without response")
|
||||
break
|
||||
buf.extend(chunk)
|
||||
if b"\n" in buf:
|
||||
break
|
||||
# safety: if response grows too huge without newline, treat as malformed
|
||||
if len(buf) > 1024 * 1024 * 2: # 2 MiB soft cap for response line
|
||||
raise PrivacyClientError("privacy RPC response too large without newline - malformed JSON")
|
||||
except PrivacyClientError:
|
||||
raise
|
||||
except OSError as e:
|
||||
raise PrivacyClientError(f"privacy RPC receive failed: {e}") from e
|
||||
|
||||
# Extract first line
|
||||
if b"\n" in buf:
|
||||
first_line_bytes = bytes(buf.split(b"\n", 1)[0])
|
||||
else:
|
||||
first_line_bytes = bytes(buf)
|
||||
|
||||
if not first_line_bytes.strip():
|
||||
raise PrivacyClientError("privacy RPC received empty response")
|
||||
|
||||
try:
|
||||
resp = json.loads(first_line_bytes.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
raise PrivacyClientError(f"privacy RPC malformed JSON response: {e}") from e
|
||||
|
||||
if not isinstance(resp, dict):
|
||||
raise PrivacyClientError("privacy RPC malformed JSON: response is not an object")
|
||||
|
||||
resp_id = resp.get("id")
|
||||
if not isinstance(resp_id, str) or not resp_id:
|
||||
# still consider mismatched if id missing/invalid vs expected?
|
||||
# spec says validate response id, raise mismatched id. We'll include id mismatch wording.
|
||||
if resp_id != req_id:
|
||||
raise PrivacyClientError(
|
||||
f"privacy RPC mismatched id: expected {req_id!r} got {resp_id!r}"
|
||||
)
|
||||
|
||||
if resp_id != req_id:
|
||||
raise PrivacyClientError(
|
||||
f"privacy RPC mismatched id: expected {req_id!r} got {resp_id!r}"
|
||||
)
|
||||
|
||||
# ok false handling
|
||||
if resp.get("ok") is False:
|
||||
# provide error details if present
|
||||
err_detail = resp.get("error") or resp.get("message") or resp
|
||||
raise PrivacyClientError(f"privacy RPC returned ok=false: {err_detail}")
|
||||
|
||||
return resp
|
||||
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Reyna CLI privacy-host contract — minimal typed allowlist + redaction.
|
||||
|
||||
Foundation slice only: no CLI mutation, no agents/services, no macOS permission calls.
|
||||
- ALLOWED_OPERATIONS typed registry includes service.health and calendar.list
|
||||
- command_to_operation maps macmini CLI tool names (calendar_list_calendars -> calendar.list)
|
||||
- scrub_privacy_result recursively redacts exactly token/password/secret/api_key/authorization (case-insensitive)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Final, Sequence
|
||||
|
||||
REDACTED: Final[str] = "[REDACTED]"
|
||||
|
||||
# Exact keys to redact (lowercased for case-insensitive match).
|
||||
_SENSITIVE_KEYS: Final[frozenset[str]] = frozenset(
|
||||
{"token", "password", "secret", "api_key", "authorization"}
|
||||
)
|
||||
|
||||
# Typed allowlist registry — per plan Task 1 Step 4 + remaining-coverage migration.
|
||||
# Includes at minimum service.health and calendar.list; rest per inventory.
|
||||
ALLOWED_OPERATIONS: Final[Dict[str, Dict[str, str]]] = {
|
||||
"service.health": {"description": "privacy host health and identity"},
|
||||
"calendar.list": {"description": "list calendars metadata"},
|
||||
"calendar.request_full_access": {"description": "request calendar full-access permission to make host appear in System Settings Privacy"},
|
||||
"calendar.events.list": {"description": "list events in bounded range"},
|
||||
"calendar.event.create": {"description": "create calendar event"},
|
||||
"contacts.search": {"description": "search contacts"},
|
||||
"contacts.read": {"description": "read contact details"},
|
||||
"contacts.create": {"description": "create contact"},
|
||||
"contacts.request_access": {"description": "request contacts permission to make host appear in System Settings Privacy"},
|
||||
"reminders.request_full_access": {"description": "request reminders full-access permission to make host appear in System Settings Privacy"},
|
||||
"reminders.lists": {"description": "list reminder lists"},
|
||||
"reminders.list": {"description": "list reminders in a list"},
|
||||
"reminders.create": {"description": "create reminder"},
|
||||
"speech.transcribe_file": {"description": "transcribe audio file via Apple Speech"},
|
||||
"speech.locales": {"description": "list speech locales"},
|
||||
"speech.synthesize": {"description": "synthesize speech"},
|
||||
"system.get_info": {"description": "get system info (sw_vers, hw model, macOS version)"},
|
||||
"system.speech_api_status": {"description": "check SpeechAnalyzer availability + macOS version"},
|
||||
"speech.live_status": {"description": "show live transcription sessions (local service, proxied via native host if needed)"},
|
||||
"apple_llm.check": {"description": "check if Apple on-device 3B LLM is available (health probe)"},
|
||||
}
|
||||
|
||||
# Mapping from existing macmini tool names / CLI command shims to typed operations.
|
||||
# Required: calendar_list_calendars -> calendar.list
|
||||
_COMMAND_TO_OPERATION: Final[Dict[str, str]] = {
|
||||
"calendar_list_calendars": "calendar.list",
|
||||
"calendar_list_events": "calendar.events.list",
|
||||
"calendar_create_event": "calendar.event.create",
|
||||
"contacts_search": "contacts.search",
|
||||
"contacts_read": "contacts.read",
|
||||
"contacts_create": "contacts.create",
|
||||
"reminders_list_lists": "reminders.lists",
|
||||
"reminders_list": "reminders.list",
|
||||
"reminders_create": "reminders.create",
|
||||
"speech_transcribe_file": "speech.transcribe_file",
|
||||
"speech_list_locales": "speech.locales",
|
||||
"speech_synthesize": "speech.synthesize",
|
||||
"service_health": "service.health",
|
||||
"system_get_info": "system.get_info",
|
||||
"system_speech_api_status": "system.speech_api_status",
|
||||
"speech_live_status": "speech.live_status",
|
||||
"apple_llm_check": "apple_llm.check",
|
||||
}
|
||||
|
||||
|
||||
def command_to_operation(command: str) -> str:
|
||||
"""Map a macmini tool/command name to a typed privacy operation.
|
||||
|
||||
Raises KeyError if unknown — keeps contract strict.
|
||||
"""
|
||||
return _COMMAND_TO_OPERATION[command]
|
||||
|
||||
|
||||
def _is_sensitive_key(key: str) -> bool:
|
||||
return key.lower() in _SENSITIVE_KEYS
|
||||
|
||||
|
||||
def scrub_privacy_result(value: Any) -> Any:
|
||||
"""Recursively redact values under exactly sensitive keys (case-insensitive).
|
||||
|
||||
- Dict: if key (case-insensitive) equals token/password/secret/api_key/authorization,
|
||||
replace value with REDACTED; otherwise recurse.
|
||||
- List/tuple: recurse elementwise, preserving list type for list and converting tuple->list.
|
||||
- Other scalars: returned as-is.
|
||||
- Original inputs are not mutated.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
out: Dict[Any, Any] = {}
|
||||
for k, v in value.items():
|
||||
if isinstance(k, str) and _is_sensitive_key(k):
|
||||
out[k] = REDACTED
|
||||
else:
|
||||
out[k] = scrub_privacy_result(v)
|
||||
return out
|
||||
if isinstance(value, list):
|
||||
return [scrub_privacy_result(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [scrub_privacy_result(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
__all__: Sequence[str] = [
|
||||
"ALLOWED_OPERATIONS",
|
||||
"command_to_operation",
|
||||
"scrub_privacy_result",
|
||||
"REDACTED",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user